From 21ee27ac40a2921a9663d9e87d0a42d599919222 Mon Sep 17 00:00:00 2001 From: king Date: Mon, 31 Aug 2026 12:07:23 +0900 Subject: [PATCH] =?UTF-8?q?feat(cupang):=20=EC=84=BC=ED=84=B0=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20=EB=93=B1=EB=A1=9D=20=EC=95=8C=EB=A6=BC=20+=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=A7=80=EC=97=AD=EB=B3=84=20=EA=B7=B8?= =?UTF-8?q?=EB=A3=B9=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 추가 시 같은 이름이 있으면 조용히 재활성되던 동작(ON CONFLICT DO UPDATE) 때문에 사용자가 중복을 알 수 없었다. 등록 전에 먼저 조회해서 이미 있으면 만들지 않고 알림(활성/비활성 구분)을 띄운다. 목록은 29개가 가로 4열로 흩어져 훑기 어려웠다. 센터명 앞 지역 접두사로 묶고(인천14 → "인천") CSS 멀티컬럼으로 한 열을 위→아래로 읽게 바꿨다. 숫자는 자연 정렬(인천4 < 인천14). 행 액션은 hover/focus 시에만 노출해 이름이 먼저 눈에 들어오게 했다. - 라우터: _center_group / _center_sort_key / _group_centers 추가 - 검색은 그룹 단위로 접히고 결과 0건이면 안내 표시 Co-Authored-By: Claude Opus 5 --- app/modules/cupang/router.py | 61 ++++++++++++++- .../cupang/templates/cupang/box_calc.html | 2 +- .../cupang/templates/cupang/box_rules.html | 2 +- .../cupang/templates/cupang/centers.html | 75 +++++++++++++------ .../cupang/templates/cupang/detail.html | 2 +- app/modules/cupang/templates/cupang/form.html | 2 +- .../cupang/templates/cupang/index.html | 2 +- .../cupang/templates/cupang/products.html | 2 +- app/static/cupang.css | 62 +++++++++++---- 9 files changed, 167 insertions(+), 43 deletions(-) diff --git a/app/modules/cupang/router.py b/app/modules/cupang/router.py index 4d7f4d5..303904d 100644 --- a/app/modules/cupang/router.py +++ b/app/modules/cupang/router.py @@ -11,8 +11,10 @@ from __future__ import annotations import calendar as _calendar import json +import re from fractions import Fraction from typing import Any +from urllib.parse import quote from app.timezone import today_kst @@ -408,6 +410,34 @@ async def hard_delete( # ════════════════════════════════════════════════════════════ # 입고센터 관리 # ════════════════════════════════════════════════════════════ +_CENTER_PREFIX_RE = re.compile(r"^\D*") + + +def _center_group(name: str) -> str: + """센터명 앞부분(숫자 전까지)을 지역 그룹 키로 쓴다. 예) 인천14 → 인천.""" + name = (name or "").strip() + prefix = _CENTER_PREFIX_RE.match(name).group(0).strip() + return prefix or name or "기타" + + +def _center_sort_key(name: str) -> list[Any]: + """숫자를 숫자로 비교하는 자연 정렬. 예) 인천4 < 인천14.""" + parts = re.split(r"(\d+)", (name or "").strip()) + return [(1, int(p), "") if p.isdigit() else (0, 0, p.lower()) for p in parts] + + +def _group_centers(centers: list[dict[str, Any]]) -> list[dict[str, Any]]: + buckets: dict[str, list[dict[str, Any]]] = {} + for c in centers: + buckets.setdefault(_center_group(c["name"]), []).append(c) + groups = [] + for key, items in buckets.items(): + items.sort(key=lambda c: _center_sort_key(c["name"])) + groups.append({"name": key, "items": items, "count": len(items)}) + groups.sort(key=lambda g: _center_sort_key(g["name"])) + return groups + + @router.get("/centers", response_class=HTMLResponse) async def centers_page(request: Request) -> HTMLResponse: from app.main import build_erp_nav, render_template # noqa: WPS433 @@ -417,7 +447,10 @@ async def centers_page(request: Request) -> HTMLResponse: if not isinstance(guard, tuple): return guard store, user = guard - centers = sorted(store.list_centers(include_inactive=True), key=lambda c: c["name"]) + centers = sorted( + store.list_centers(include_inactive=True), + key=lambda c: _center_sort_key(c["name"]), + ) return render_template( request, "cupang/centers.html", @@ -428,6 +461,9 @@ async def centers_page(request: Request) -> HTMLResponse: "page_title": "쿠팡 밀크런 — 입고센터 관리", "page_subtitle": "센터명 등록 · 수정 · 활성/비활성 · 삭제.", "centers": centers, + "center_groups": _group_centers(centers), + "flash": request.query_params.get("msg", ""), + "flash_name": request.query_params.get("name", ""), }, ) @@ -442,11 +478,32 @@ async def center_create( store = _store(request) if store is None: raise HTTPException(status_code=503, detail="cupang_db 미설정") + name = (name or "").strip() + if not name: + return RedirectResponse(url="/cupang/centers", status_code=303) + + # 같은 이름이 이미 있으면 새로 만들지 않고 알림만 돌려준다. + existing = next( + ( + c + for c in store.list_centers(include_inactive=True) + if (c.get("name") or "").strip().lower() == name.lower() + ), + None, + ) + if existing is not None: + msg = "dup" if existing.get("active") else "dup_inactive" + return RedirectResponse( + url=f"/cupang/centers?msg={msg}&name={quote(name)}", status_code=303 + ) + try: store.create_center(name=name, sort_order=sort_order) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) - return RedirectResponse(url="/cupang/centers", status_code=303) + return RedirectResponse( + url=f"/cupang/centers?msg=added&name={quote(name)}", status_code=303 + ) @router.post("/centers/{center_id}/edit") diff --git a/app/modules/cupang/templates/cupang/box_calc.html b/app/modules/cupang/templates/cupang/box_calc.html index afe553c..e82bbd6 100644 --- a/app/modules/cupang/templates/cupang/box_calc.html +++ b/app/modules/cupang/templates/cupang/box_calc.html @@ -1,6 +1,6 @@ {% extends "erp_base.html" %} -{% block head_extra %}{% endblock %} +{% block head_extra %}{% endblock %} {% block content %}
diff --git a/app/modules/cupang/templates/cupang/box_rules.html b/app/modules/cupang/templates/cupang/box_rules.html index a22f73a..e1b6b7b 100644 --- a/app/modules/cupang/templates/cupang/box_rules.html +++ b/app/modules/cupang/templates/cupang/box_rules.html @@ -1,6 +1,6 @@ {% extends "erp_base.html" %} -{% block head_extra %}{% endblock %} +{% block head_extra %}{% endblock %} {% block content %}
diff --git a/app/modules/cupang/templates/cupang/centers.html b/app/modules/cupang/templates/cupang/centers.html index cc3c03c..3d668ba 100644 --- a/app/modules/cupang/templates/cupang/centers.html +++ b/app/modules/cupang/templates/cupang/centers.html @@ -1,6 +1,6 @@ {% extends "erp_base.html" %} -{% block head_extra %}{% endblock %} +{% block head_extra %}{% endblock %} {% block content %}
@@ -24,47 +24,78 @@

센터 목록 ({{ centers|length }})

- 쓰지 않는 센터는 비활성 또는 삭제. 이름 변경은 지원하지 않음 — 새로 등록. + 지역별로 묶어 표시. 이름 변경은 지원하지 않음 — 삭제 후 새로 등록.
- -
- {% for c in centers %} -
- - {{ c.name }} -
- - -
-
- -
+ +
+ {% for g in center_groups %} +
+
+ {{ g.name }} + {{ g.count }} +
+ {% for c in g["items"] %} +
+ + {{ c.name }} + +
+ + +
+
+ +
+
+
+ {% endfor %}
{% endfor %}
+ +
diff --git a/app/modules/cupang/templates/cupang/detail.html b/app/modules/cupang/templates/cupang/detail.html index 3a8b017..d2948ee 100644 --- a/app/modules/cupang/templates/cupang/detail.html +++ b/app/modules/cupang/templates/cupang/detail.html @@ -1,6 +1,6 @@ {% extends "erp_base.html" %} -{% block head_extra %}{% endblock %} +{% block head_extra %}{% endblock %} {% block content %}
diff --git a/app/modules/cupang/templates/cupang/form.html b/app/modules/cupang/templates/cupang/form.html index 2320854..5b92acf 100644 --- a/app/modules/cupang/templates/cupang/form.html +++ b/app/modules/cupang/templates/cupang/form.html @@ -1,6 +1,6 @@ {% extends "erp_base.html" %} -{% block head_extra %}{% endblock %} +{% block head_extra %}{% endblock %} {% block content %}
diff --git a/app/modules/cupang/templates/cupang/index.html b/app/modules/cupang/templates/cupang/index.html index 6676185..8ce1002 100644 --- a/app/modules/cupang/templates/cupang/index.html +++ b/app/modules/cupang/templates/cupang/index.html @@ -1,6 +1,6 @@ {% extends "erp_base.html" %} -{% block head_extra %}{% endblock %} +{% block head_extra %}{% endblock %} {% block content %}
diff --git a/app/modules/cupang/templates/cupang/products.html b/app/modules/cupang/templates/cupang/products.html index 419a5f9..b781cce 100644 --- a/app/modules/cupang/templates/cupang/products.html +++ b/app/modules/cupang/templates/cupang/products.html @@ -1,6 +1,6 @@ {% extends "erp_base.html" %} -{% block head_extra %}{% endblock %} +{% block head_extra %}{% endblock %} {% block content %}
diff --git a/app/static/cupang.css b/app/static/cupang.css index e181716..834a280 100644 --- a/app/static/cupang.css +++ b/app/static/cupang.css @@ -169,7 +169,7 @@ .cpg-inline-form { display: inline-flex; gap: 6px; align-items: center; margin: 0; } .cpg-row-actions { display: flex; gap: 6px; flex-wrap: wrap; } -/* ── 입고센터 관리 (상단: 추가·검색 / 아래: 목록 그리드) ── */ +/* ── 입고센터 관리 (상단: 추가·검색 / 아래: 지역 그룹 목록) ── */ .cpg-center-card { padding: 16px; } .cpg-center-bar { @@ -180,33 +180,69 @@ .cpg-center-addinput { flex: 1 1 auto; min-width: 0; } .cpg-center-search { flex: 0 1 240px; min-width: 0; } -.cpg-center-head { margin: 12px 0 8px; } +.cpg-center-head { margin: 12px 0 10px; } -.cpg-center-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); - gap: 6px; - max-height: 70vh; overflow-y: auto; - padding-right: 4px; +/* 멀티컬럼: 한 열을 위→아래로 읽고, 다 차면 다음 열로 */ +.cpg-center-cols { + columns: 4 220px; + column-gap: 20px; +} +.cpg-center-group { + break-inside: avoid; page-break-inside: avoid; + margin: 0 0 16px; +} +.cpg-center-gtitle { + display: flex; align-items: center; gap: 6px; + padding: 0 4px 4px; + border-bottom: 2px solid var(--color-rich-black); + margin-bottom: 4px; +} +.cpg-center-gname { font-size: 13px; font-weight: 700; letter-spacing: .02em; } +.cpg-center-gcount { + font-size: 11px; font-weight: 600; line-height: 1; + padding: 2px 6px; border-radius: 999px; + background: var(--color-subtle-ash); color: var(--color-midtone-gray); } .cpg-center-row { - display: flex; align-items: center; gap: 6px; - margin: 0; padding: 7px 10px; - border: 1px solid var(--color-subtle-ash); border-radius: 10px; + display: flex; align-items: center; gap: 7px; + padding: 5px 4px; + border-bottom: 1px solid var(--color-subtle-ash); min-width: 0; } +.cpg-center-row:last-child { border-bottom: none; } .cpg-center-dot { - flex: 0 0 auto; width: 8px; height: 8px; border-radius: 50%; + flex: 0 0 auto; width: 7px; height: 7px; border-radius: 50%; background: var(--color-success-green, #10c22b); } -.cpg-center-row.is-inactive { opacity: .55; } +.cpg-center-row.is-inactive { opacity: .5; } .cpg-center-row.is-inactive .cpg-center-dot { background: var(--color-subtle-ash); } .cpg-center-name { flex: 1 1 auto; min-width: 0; font-size: 14px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +/* 행 액션: 평소엔 흐리게, hover/focus 시 또렷하게 */ +.cpg-center-acts { + display: inline-flex; gap: 2px; flex: 0 0 auto; + opacity: 0; transition: opacity .12s ease; +} +.cpg-center-row:hover .cpg-center-acts, +.cpg-center-row:focus-within .cpg-center-acts { opacity: 1; } +@media (hover: none) { + .cpg-center-acts { opacity: .7; } +} +.cpg-icon-btn { + border: 0; background: none; cursor: pointer; + font-size: 11px; line-height: 1; padding: 3px 5px; border-radius: 6px; + color: var(--color-midtone-gray); +} +.cpg-icon-btn:hover { background: var(--color-subtle-ash); color: var(--color-rich-black); } +.cpg-icon-btn.is-danger:hover { background: var(--color-ghost-gray); color: var(--color-callout-red); } + +.cpg-center-empty { margin: 8px 0 0; } + .cpg-btn-sm { padding: 3px 8px; font-size: 12px; flex: 0 0 auto; } /* ── 라인 테이블 ── */