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:
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user