feat(cupang): 설정 화면 2열 개편 — itemcode 다중선택 등록 + 활성/삭제

- 수동 등록 카드 제거
- 왼쪽: itemcode_db 전체 목록(낱개+세트) 다중선택, 등록된 항목 진회색+흰글씨
  /cupang/api/products/all + /cupang/products/bulk(JSON 일괄 upsert)
- 오른쪽: 등록 제품명 목록 — 제품명/코드/상태 + 활성·비활성 토글 + 완전삭제
  /products/{id}/active (토글), /products/{id}/delete (hard delete)
- ItemcodeReader.list_all 추가

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-05-30 03:46:35 +09:00
parent 96e2c49506
commit 1d33761003
5 changed files with 229 additions and 94 deletions
+122 -84
View File
@@ -9,102 +9,140 @@
<a class="erp-btn erp-btn-outline" href="/cupang/">← 달력</a>
</div>
<!-- itemcode_db 검색 → 등록 -->
<div class="erp-card cpg-form-card">
<div class="cpg-card-head">
<h2>itemcode_db 에서 제품 검색·등록</h2>
<span class="erp-muted">
{% if search_enabled %}제품코드/제품명으로 검색 후 "등록".{% else %}
상품 검색 비활성: {{ search_reason }} — 아래에서 수동 등록하세요.{% endif %}
</span>
<div class="cpg-prod-layout">
<!-- ── 왼쪽: itemcode_db 상품 목록 (다중선택 → 등록) ── -->
<div class="erp-card cpg-form-card">
<div class="cpg-card-head">
<h2>itemcode_db 상품</h2>
<span class="erp-muted">
{% if search_enabled %}선택(다중) 후 등록. 이미 등록된 항목은 진한 회색.{% else %}
검색 비활성: {{ search_reason }}{% endif %}
</span>
</div>
{% if search_enabled %}
<div class="cpg-inline-form" style="margin-bottom:10px;">
<input class="erp-input" type="text" id="cpg-prod-q" placeholder="이름/코드 필터" style="min-width:200px" />
<button type="button" class="erp-btn erp-btn-primary" id="cpg-prod-register">선택 등록</button>
</div>
<div id="cpg-src-list" class="cpg-src-list"><p class="erp-muted">불러오는 중…</p></div>
{% else %}
<p class="erp-muted">ITEMCODE_DB_URL / ITEMCODE_SEARCH_SQL 설정 후 사용 가능합니다.</p>
{% endif %}
</div>
{% if search_enabled %}
<div class="cpg-inline-form">
<input class="erp-input" type="text" id="cpg-prod-q" placeholder="제품코드 또는 제품명" style="min-width:240px" />
<button type="button" class="erp-btn erp-btn-outline" id="cpg-prod-search">검색</button>
<!-- ── 오른쪽: 등록된 제품명 ── -->
<div class="erp-card cpg-form-card">
<div class="cpg-card-head"><h2>등록된 제품명 ({{ products|length }})</h2>
<span class="erp-muted">폼의 제품명 드롭다운에 노출</span></div>
<div class="erp-table-wrap">
<table class="erp-table">
<thead><tr><th>제품명</th><th>제품코드</th><th>상태</th><th>동작</th></tr></thead>
<tbody>
{% for p in products %}
<tr {% if not p.active %}style="opacity:.55"{% endif %}>
<td>{{ p.product_name }}</td>
<td>{{ p.product_code }}</td>
<td>{% if p.active %}<span class="erp-badge erp-badge-success">활성</span>{% else %}<span class="erp-badge erp-badge-neutral">비활성</span>{% endif %}</td>
<td>
<div class="cpg-row-actions">
{% if p.active %}
<form method="post" action="/cupang/products/{{ p.id }}/active" class="cpg-inline-form">
<input type="hidden" name="active" value="0" />
<button type="submit" class="erp-btn erp-btn-outline">비활성</button>
</form>
{% else %}
<form method="post" action="/cupang/products/{{ p.id }}/active" class="cpg-inline-form">
<input type="hidden" name="active" value="1" />
<button type="submit" class="erp-btn erp-btn-outline">활성</button>
</form>
{% endif %}
<form method="post" action="/cupang/products/{{ p.id }}/delete" class="cpg-inline-form"
onsubmit="return confirm('완전 삭제합니다. 계속할까요?');">
<button type="submit" class="erp-btn erp-btn-danger">삭제</button>
</form>
</div>
</td>
</tr>
{% endfor %}
{% if not products %}
<tr><td colspan="4" class="erp-muted">등록된 제품명이 없습니다. 왼쪽에서 선택해 등록하세요.</td></tr>
{% endif %}
</tbody>
</table>
</div>
</div>
<div id="cpg-prod-results" class="cpg-prod-results"></div>
{% endif %}
</div>
<!-- 수동 등록 -->
<div class="erp-card cpg-form-card">
<div class="cpg-card-head"><h2>제품명 수동 등록 / 수정</h2>
<span class="erp-muted">같은 제품코드는 덮어씁니다.</span></div>
<form method="post" action="/cupang/products" class="cpg-rule-grid">
<label class="erp-field"><span>제품코드 *</span>
<input class="erp-input" type="text" name="product_code" required placeholder="예: MS-1001" /></label>
<label class="erp-field"><span>제품명 *</span>
<input class="erp-input" type="text" name="product_name" required placeholder="예: 미라네 1호세트" /></label>
<label class="erp-field"><span>정렬</span>
<input class="erp-input" type="number" name="sort_order" value="0" /></label>
<div><button type="submit" class="erp-btn erp-btn-primary">등록</button></div>
</form>
</div>
<!-- 등록된 제품명 목록 -->
<div class="erp-card cpg-form-card">
<div class="cpg-card-head"><h2>등록된 제품명 ({{ products|length }})</h2>
<span class="erp-muted">폼의 제품명 드롭다운에 노출됩니다.</span></div>
<div class="erp-table-wrap">
<table class="erp-table">
<thead><tr><th>제품명</th><th>제품코드</th><th>정렬</th><th>상태</th><th></th></tr></thead>
<tbody>
{% for p in products %}
<tr {% if not p.active %}style="opacity:.55"{% endif %}>
<td>{{ p.product_name }}</td>
<td>{{ p.product_code }}</td>
<td>{{ p.sort_order }}</td>
<td>{% if p.active %}<span class="erp-badge erp-badge-success">활성</span>{% else %}<span class="erp-badge erp-badge-neutral">비활성</span>{% endif %}</td>
<td>
{% if p.active %}
<form method="post" action="/cupang/products/{{ p.id }}/delete" class="cpg-inline-form"
onsubmit="return confirm('비활성화합니다. 계속할까요?');">
<button type="submit" class="erp-btn erp-btn-danger">비활성화</button>
</form>
{% else %}—{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</section>
{% if search_enabled %}
<script type="application/json" id="cpg-registered">{{ registered_codes | tojson }}</script>
<script>
(function () {
var q = document.getElementById("cpg-prod-q");
var btn = document.getElementById("cpg-prod-search");
var box = document.getElementById("cpg-prod-results");
var listBox = document.getElementById("cpg-src-list");
var filter = document.getElementById("cpg-prod-q");
var regBtn = document.getElementById("cpg-prod-register");
var registered = {};
try { (JSON.parse(document.getElementById("cpg-registered").textContent || "[]")).forEach(function(c){ registered[c]=true; }); } catch(e){}
var all = [];
var selected = {};
function esc(s){ var d=document.createElement("div"); d.textContent=s||""; return d.innerHTML; }
function run() {
var term = (q.value || "").trim();
if (!term) { box.innerHTML = ""; return; }
box.innerHTML = '<p class="erp-muted">검색중…</p>';
fetch("/cupang/api/products/search?q=" + encodeURIComponent(term))
.then(function (r) { return r.json(); })
.then(function (data) {
var rows = (data && data.results) || [];
if (!rows.length) { box.innerHTML = '<p class="erp-muted">결과 없음</p>'; return; }
var html = '<table class="erp-table"><thead><tr><th>제품코드</th><th>제품명</th><th>구분</th><th></th></tr></thead><tbody>';
rows.forEach(function (it) {
html += '<tr><td>' + esc(it.code) + '</td><td>' + esc(it.name) + '</td><td>' + esc(it.type) + '</td>' +
'<td><form method="post" action="/cupang/products" class="cpg-inline-form">' +
'<input type="hidden" name="product_code" value="' + esc(it.code) + '">' +
'<input type="hidden" name="product_name" value="' + esc(it.name) + '">' +
'<button type="submit" class="erp-btn erp-btn-outline">등록</button></form></td></tr>';
});
html += "</tbody></table>";
box.innerHTML = html;
})
.catch(function () { box.innerHTML = '<p class="erp-muted">검색 실패</p>'; });
function render() {
var term = (filter.value || "").trim().toLowerCase();
var rows = all.filter(function (it) {
if (!term) return true;
return (it.code + " " + it.name).toLowerCase().indexOf(term) >= 0;
});
if (!rows.length) { listBox.innerHTML = '<p class="erp-muted">결과 없음</p>'; return; }
var html = "";
rows.forEach(function (it) {
var cls = "cpg-src-item";
if (registered[it.code]) cls += " cpg-registered";
if (selected[it.code]) cls += " is-selected";
html += '<div class="' + cls + '" data-code="' + esc(it.code) + '">' +
'<span class="cpg-src-name">' + esc(it.name) + '</span>' +
'<span class="cpg-src-code">' + esc(it.code) + (it.type ? ' · ' + esc(it.type) : '') + '</span>' +
'</div>';
});
listBox.innerHTML = html;
}
btn.addEventListener("click", run);
q.addEventListener("keydown", function (e) { if (e.key === "Enter") { e.preventDefault(); run(); } });
listBox.addEventListener("click", function (e) {
var item = e.target.closest(".cpg-src-item");
if (!item) return;
var code = item.getAttribute("data-code");
if (selected[code]) { delete selected[code]; item.classList.remove("is-selected"); }
else { selected[code] = true; item.classList.add("is-selected"); }
});
filter.addEventListener("input", render);
regBtn.addEventListener("click", function () {
var items = all.filter(function (it) { return selected[it.code]; })
.map(function (it) { return { code: it.code, name: it.name }; });
if (!items.length) { alert("등록할 상품을 선택하세요."); return; }
regBtn.disabled = true;
fetch("/cupang/products/bulk", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ items: items })
}).then(function (r) { return r.json(); })
.then(function () { location.reload(); })
.catch(function () { regBtn.disabled = false; alert("등록 실패"); });
});
fetch("/cupang/api/products/all")
.then(function (r) { return r.json(); })
.then(function (data) {
all = (data && data.results) || [];
if (!all.length) { listBox.innerHTML = '<p class="erp-muted">itemcode_db 결과 없음</p>'; return; }
render();
})
.catch(function () { listBox.innerHTML = '<p class="erp-muted">목록 로드 실패</p>'; });
})();
</script>
{% endif %}