feat(cupang): 센터 중복 등록 알림 + 목록 지역별 그룹화

추가 시 같은 이름이 있으면 조용히 재활성되던 동작(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 <noreply@anthropic.com>
This commit is contained in:
2026-08-31 12:07:23 +09:00
parent 7b358b0189
commit 21ee27ac40
9 changed files with 167 additions and 43 deletions
+59 -2
View File
@@ -11,8 +11,10 @@ from __future__ import annotations
import calendar as _calendar import calendar as _calendar
import json import json
import re
from fractions import Fraction from fractions import Fraction
from typing import Any from typing import Any
from urllib.parse import quote
from app.timezone import today_kst 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) @router.get("/centers", response_class=HTMLResponse)
async def centers_page(request: Request) -> HTMLResponse: async def centers_page(request: Request) -> HTMLResponse:
from app.main import build_erp_nav, render_template # noqa: WPS433 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): if not isinstance(guard, tuple):
return guard return guard
store, user = 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( return render_template(
request, request,
"cupang/centers.html", "cupang/centers.html",
@@ -428,6 +461,9 @@ async def centers_page(request: Request) -> HTMLResponse:
"page_title": "쿠팡 밀크런 — 입고센터 관리", "page_title": "쿠팡 밀크런 — 입고센터 관리",
"page_subtitle": "센터명 등록 · 수정 · 활성/비활성 · 삭제.", "page_subtitle": "센터명 등록 · 수정 · 활성/비활성 · 삭제.",
"centers": centers, "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) store = _store(request)
if store is None: if store is None:
raise HTTPException(status_code=503, detail="cupang_db 미설정") 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: try:
store.create_center(name=name, sort_order=sort_order) store.create_center(name=name, sort_order=sort_order)
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=400, detail=str(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") @router.post("/centers/{center_id}/edit")
@@ -1,6 +1,6 @@
{% extends "erp_base.html" %} {% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260831b" />{% endblock %} {% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260831c" />{% endblock %}
{% block content %} {% block content %}
<section class="cpg"> <section class="cpg">
@@ -1,6 +1,6 @@
{% extends "erp_base.html" %} {% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260831b" />{% endblock %} {% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260831c" />{% endblock %}
{% block content %} {% block content %}
<section class="cpg"> <section class="cpg">
@@ -1,6 +1,6 @@
{% extends "erp_base.html" %} {% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260831b" />{% endblock %} {% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260831c" />{% endblock %}
{% block content %} {% block content %}
<section class="cpg"> <section class="cpg">
@@ -24,47 +24,78 @@
<div class="cpg-card-head cpg-center-head"> <div class="cpg-card-head cpg-center-head">
<h2>센터 목록 <span class="erp-muted" id="cpgCenterCount">({{ centers|length }})</span></h2> <h2>센터 목록 <span class="erp-muted" id="cpgCenterCount">({{ centers|length }})</span></h2>
<span class="erp-muted">쓰지 않는 센터는 비활성 또는 삭제. 이름 변경은 지원하지 않음 — 새로 등록.</span> <span class="erp-muted">지역별로 묶어 표시. 이름 변경은 지원하지 않음 — 삭제 후 새로 등록.</span>
</div> </div>
<!-- 목록: 여러 열로 한 화면에 --> <!-- 목록: 지역 그룹 카드, 위→아래로 읽고 열로 흐름 -->
<div class="cpg-center-grid" id="cpgCenterGrid"> <div class="cpg-center-cols" id="cpgCenterGrid">
{% for c in centers %} {% for g in center_groups %}
<div class="cpg-center-row {% if not c.active %}is-inactive{% endif %}" data-name="{{ c.name|lower }}"> <div class="cpg-center-group" data-group="{{ g.name|lower }}">
<span class="cpg-center-dot" title="{% if c.active %}활성{% else %}비활성{% endif %}"></span> <div class="cpg-center-gtitle">
<span class="cpg-center-name">{{ c.name }}</span> <span class="cpg-center-gname">{{ g.name }}</span>
<form method="post" action="/cupang/centers/{{ c.id }}/edit" class="cpg-inline-form"> <span class="cpg-center-gcount">{{ g.count }}</span>
<input type="hidden" name="active" value="{% if c.active %}0{% else %}1{% endif %}" /> </div>
<button type="submit" class="erp-btn erp-btn-outline cpg-btn-sm" {% for c in g["items"] %}
title="{% if c.active %}비활성으로 전환{% else %}활성으로 전환{% endif %}">{% if c.active %}비활성{% else %}활성{% endif %}</button> <div class="cpg-center-row {% if not c.active %}is-inactive{% endif %}" data-name="{{ c.name|lower }}">
</form> <span class="cpg-center-dot" title="{% if c.active %}활성{% else %}비활성{% endif %}"></span>
<form method="post" action="/cupang/centers/{{ c.id }}/delete" class="cpg-inline-form" <span class="cpg-center-name">{{ c.name }}</span>
onsubmit="return confirm('{{ c.name }} 센터를 삭제할까요?');"> <span class="cpg-center-acts">
<button type="submit" class="erp-btn erp-btn-danger cpg-btn-sm">삭제</button> <form method="post" action="/cupang/centers/{{ c.id }}/edit" class="cpg-inline-form">
</form> <input type="hidden" name="active" value="{% if c.active %}0{% else %}1{% endif %}" />
<button type="submit" class="cpg-icon-btn"
title="{% if c.active %}비활성으로 전환{% else %}활성으로 전환{% endif %}">{% if c.active %}비활성{% else %}활성{% endif %}</button>
</form>
<form method="post" action="/cupang/centers/{{ c.id }}/delete" class="cpg-inline-form"
onsubmit="return confirm('{{ c.name }} 센터를 삭제할까요?');">
<button type="submit" class="cpg-icon-btn is-danger" title="삭제">삭제</button>
</form>
</span>
</div>
{% endfor %}
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
<p class="cpg-center-empty erp-muted" id="cpgCenterEmpty" hidden>검색 결과 없음</p>
</div> </div>
</section> </section>
<script> <script>
(function () { (function () {
// 추가 결과 알림 (중복 등록 방지)
var flash = {{ flash|tojson }};
var fname = {{ flash_name|tojson }};
if (flash === 'dup') {
alert('"' + fname + '" 센터는 이미 등록되어 있습니다.');
} else if (flash === 'dup_inactive') {
alert('"' + fname + '" 센터는 이미 등록되어 있습니다. (현재 비활성 상태 — 목록에서 활성으로 전환하세요.)');
}
if (flash) {
history.replaceState(null, '', '/cupang/centers');
}
var box = document.getElementById('cpgCenterSearch'); var box = document.getElementById('cpgCenterSearch');
var grid = document.getElementById('cpgCenterGrid'); var grid = document.getElementById('cpgCenterGrid');
var count = document.getElementById('cpgCenterCount'); var count = document.getElementById('cpgCenterCount');
var empty = document.getElementById('cpgCenterEmpty');
if (!box || !grid) return; if (!box || !grid) return;
var rows = Array.prototype.slice.call(grid.querySelectorAll('.cpg-center-row')); var groups = Array.prototype.slice.call(grid.querySelectorAll('.cpg-center-group'));
box.addEventListener('input', function () { box.addEventListener('input', function () {
var q = box.value.trim().toLowerCase(); var q = box.value.trim().toLowerCase();
var n = 0; var n = 0;
rows.forEach(function (r) { groups.forEach(function (g) {
var hit = !q || (r.dataset.name || '').indexOf(q) !== -1; var shown = 0;
r.style.display = hit ? '' : 'none'; Array.prototype.forEach.call(g.querySelectorAll('.cpg-center-row'), function (r) {
if (hit) n += 1; var hit = !q || (r.dataset.name || '').indexOf(q) !== -1;
r.style.display = hit ? '' : 'none';
if (hit) shown += 1;
});
g.style.display = shown ? '' : 'none';
n += shown;
}); });
if (count) count.textContent = '(' + n + ')'; if (count) count.textContent = '(' + n + ')';
if (empty) empty.hidden = n !== 0;
}); });
})(); })();
</script> </script>
@@ -1,6 +1,6 @@
{% extends "erp_base.html" %} {% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260831b" />{% endblock %} {% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260831c" />{% endblock %}
{% block content %} {% block content %}
<section class="cpg"> <section class="cpg">
@@ -1,6 +1,6 @@
{% extends "erp_base.html" %} {% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260831b" />{% endblock %} {% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260831c" />{% endblock %}
{% block content %} {% block content %}
<section class="cpg"> <section class="cpg">
@@ -1,6 +1,6 @@
{% extends "erp_base.html" %} {% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260831b" />{% endblock %} {% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260831c" />{% endblock %}
{% block content %} {% block content %}
<section class="cpg"> <section class="cpg">
@@ -1,6 +1,6 @@
{% extends "erp_base.html" %} {% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260831b" />{% endblock %} {% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260831c" />{% endblock %}
{% block content %} {% block content %}
<section class="cpg"> <section class="cpg">
+49 -13
View File
@@ -169,7 +169,7 @@
.cpg-inline-form { display: inline-flex; gap: 6px; align-items: center; margin: 0; } .cpg-inline-form { display: inline-flex; gap: 6px; align-items: center; margin: 0; }
.cpg-row-actions { display: flex; gap: 6px; flex-wrap: wrap; } .cpg-row-actions { display: flex; gap: 6px; flex-wrap: wrap; }
/* ── 입고센터 관리 (상단: 추가·검색 / 아래: 목록 그리드) ── */ /* ── 입고센터 관리 (상단: 추가·검색 / 아래: 지역 그룹 목록) ── */
.cpg-center-card { padding: 16px; } .cpg-center-card { padding: 16px; }
.cpg-center-bar { .cpg-center-bar {
@@ -180,33 +180,69 @@
.cpg-center-addinput { flex: 1 1 auto; min-width: 0; } .cpg-center-addinput { flex: 1 1 auto; min-width: 0; }
.cpg-center-search { flex: 0 1 240px; 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; .cpg-center-cols {
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); columns: 4 220px;
gap: 6px; column-gap: 20px;
max-height: 70vh; overflow-y: auto; }
padding-right: 4px; .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 { .cpg-center-row {
display: flex; align-items: center; gap: 6px; display: flex; align-items: center; gap: 7px;
margin: 0; padding: 7px 10px; padding: 5px 4px;
border: 1px solid var(--color-subtle-ash); border-radius: 10px; border-bottom: 1px solid var(--color-subtle-ash);
min-width: 0; min-width: 0;
} }
.cpg-center-row:last-child { border-bottom: none; }
.cpg-center-dot { .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); 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-row.is-inactive .cpg-center-dot { background: var(--color-subtle-ash); }
.cpg-center-name { .cpg-center-name {
flex: 1 1 auto; min-width: 0; flex: 1 1 auto; min-width: 0;
font-size: 14px; font-weight: 500; font-size: 14px; font-weight: 500;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; 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; } .cpg-btn-sm { padding: 3px 8px; font-size: 12px; flex: 0 0 auto; }
/* ── 라인 테이블 ── */ /* ── 라인 테이블 ── */