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
+3 -3
View File
@@ -237,11 +237,11 @@ class CupangDBStore:
).fetchone()
return self._product_serialize(row)
def deactivate_product(self, *, product_id: int) -> None:
def set_product_active(self, *, product_id: int, active: bool) -> None:
with self._pool.connection() as conn:
cur = conn.execute(
"UPDATE cupang_products SET active = FALSE WHERE id = %s",
(product_id,),
"UPDATE cupang_products SET active = %s WHERE id = %s",
(bool(active), product_id),
)
if cur.rowcount == 0:
raise KeyError(product_id)
+10 -2
View File
@@ -114,9 +114,17 @@ class ItemcodeReader:
비활성 상태이거나 조회 실패 시 빈 리스트(예외 비전파 — UI 는 수동 입력 폴백).
"""
q = (query or "").strip()
if not self.enabled or not self._pool or not self._sql or not q:
if not q:
return []
return self._run(f"%{q}%", limit)
def list_all(self, *, limit: int = 2000) -> list[dict[str, Any]]:
"""전체 상품 목록(낱개+세트). 설정 화면 왼쪽 리스트용."""
return self._run("%", limit)
def _run(self, like: str, limit: int) -> list[dict[str, Any]]:
if not self.enabled or not self._pool or not self._sql:
return []
like = f"%{q}%"
try:
with self._pool.connection() as conn:
rows = conn.execute(
+65 -5
View File
@@ -15,7 +15,7 @@ import json
from datetime import date, datetime
from typing import Any
from fastapi import APIRouter, Depends, Form, HTTPException, Request
from fastapi import APIRouter, Body, Depends, Form, HTTPException, Request
from fastapi.responses import (
HTMLResponse,
JSONResponse,
@@ -609,14 +609,54 @@ async def products_page(request: Request) -> HTMLResponse:
"is_admin": is_admin(user),
"nav_items": build_erp_nav(user, active="cupang"),
"page_title": "쿠팡 밀크런 — 설정 (제품명)",
"page_subtitle": "제품명 리스트 관리. itemcode_db 에서 검색해 등록하면 폼 드롭다운에 노출됩니다.",
"page_subtitle": "왼쪽 itemcode_db 목록에서 선택해 등록하면 폼 드롭다운에 노출됩니다.",
"products": store.list_products(include_inactive=True),
"registered_codes": [p["product_code"] for p in store.list_products(include_inactive=True)],
"search_enabled": bool(reader and reader.enabled),
"search_reason": (reader.reason if reader else ""),
},
)
@router.get("/api/products/all")
async def product_all(
request: Request, _: dict[str, Any] = Depends(_require_user)
) -> JSONResponse:
"""itemcode_db 전체 상품 목록(낱개+세트). 설정 화면 왼쪽 리스트 소스."""
reader = _itemcode(request)
return JSONResponse(
{
"enabled": bool(reader and reader.enabled),
"reason": (reader.reason if reader else "itemcode 리더 미초기화"),
"results": reader.list_all() if reader else [],
}
)
@router.post("/products/bulk")
async def product_bulk(
request: Request,
items: list[dict[str, Any]] = Body(..., embed=True),
user: dict[str, Any] = Depends(_require_user),
) -> JSONResponse:
"""선택한 상품들을 일괄 등록(upsert). body: {"items":[{"code","name"}, ...]}."""
store = _store(request)
if store is None:
raise HTTPException(status_code=503, detail="cupang_db 미설정")
added = 0
for it in items:
code = str(it.get("code") or "").strip()
name = str(it.get("name") or "").strip()
if not code or not name:
continue
try:
store.upsert_product(product_code=code, product_name=name)
added += 1
except ValueError:
continue
return JSONResponse({"ok": True, "added": added})
@router.post("/products")
async def product_upsert(
request: Request,
@@ -637,17 +677,37 @@ async def product_upsert(
return RedirectResponse(url="/cupang/products", status_code=303)
@router.post("/products/{product_id:int}/delete")
async def product_delete(
@router.post("/products/{product_id:int}/active")
async def product_set_active(
request: Request,
product_id: int,
active: str = Form(...),
user: dict[str, Any] = Depends(_require_user),
) -> RedirectResponse:
store = _store(request)
if store is None:
raise HTTPException(status_code=503, detail="cupang_db 미설정")
try:
store.deactivate_product(product_id=product_id)
store.set_product_active(
product_id=product_id, active=active in ("1", "true", "on", "True")
)
except KeyError:
raise HTTPException(status_code=404, detail="제품을 찾을 수 없습니다.")
return RedirectResponse(url="/cupang/products", status_code=303)
@router.post("/products/{product_id:int}/delete")
async def product_delete(
request: Request,
product_id: int,
user: dict[str, Any] = Depends(_require_user),
) -> RedirectResponse:
"""완전 삭제(hard delete)."""
store = _store(request)
if store is None:
raise HTTPException(status_code=503, detail="cupang_db 미설정")
try:
store.delete_product(product_id=product_id)
except KeyError:
raise HTTPException(status_code=404, detail="제품을 찾을 수 없습니다.")
return RedirectResponse(url="/cupang/products", status_code=303)
+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 %}
+29
View File
@@ -118,6 +118,35 @@
.cpg-detail-grid dt { font-size: 12px; color: var(--color-midtone-gray); }
.cpg-detail-grid dd { margin: 2px 0 0; font-size: 14px; }
/* ── 설정(제품명) 2열 레이아웃 ── */
.cpg-prod-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 16px; align-items: start;
}
@media (max-width: 980px) { .cpg-prod-layout { grid-template-columns: 1fr; } }
.cpg-src-list {
max-height: 520px; overflow-y: auto;
border: 1px solid var(--color-subtle-ash); border-radius: 10px;
}
.cpg-src-item {
display: flex; align-items: baseline; justify-content: space-between; gap: 10px;
padding: 8px 12px; cursor: pointer;
border-bottom: 1px solid var(--color-subtle-ash);
}
.cpg-src-item:last-child { border-bottom: 0; }
.cpg-src-item:hover { background: var(--color-ghost-gray); }
.cpg-src-name { font-size: 14px; font-weight: 500; }
.cpg-src-code { font-size: 12px; color: var(--color-midtone-gray); white-space: nowrap; }
/* 선택됨: 검정 테두리 강조 */
.cpg-src-item.is-selected { box-shadow: inset 0 0 0 2px var(--color-deep-black); }
/* 이미 등록됨: 진한 회색 배경 + 흰 글씨 */
.cpg-src-item.cpg-registered { background: #4b4b4b; }
.cpg-src-item.cpg-registered .cpg-src-name { color: #fff; }
.cpg-src-item.cpg-registered .cpg-src-code { color: #d4d4d4; }
.cpg-src-item.cpg-registered:hover { background: #3a3a3a; }
/* ── 상품 검색 드롭다운 ── */
.cpg-search-pop {
position: absolute; z-index: 50;