feat(cupang): 달력 상세에서 출고 삭제 (건별 / 날짜 전체)

분배 확정으로 만든 출고를 지우려면 상세 페이지까지 들어가야 했다. 달력에서
날짜를 고른 뒤 오른쪽 카드에서 바로 지울 수 있게 한다. 센터가 여러 건일 때를
위해 목록 헤더에 "이 날짜 전체 삭제" 도 둔다. 둘 다 가운데 확인 팝업을 거친다.

삭제는 기존 정책대로 status='취소' soft delete 이고, 대신 달력·목록에서
취소 건을 걸러내 사용자에게는 삭제로 보이게 했다(기록은 DB 에 남는다).

- router: /{id}/delete 에 next 폼값(내부 경로만 허용) 추가, POST /cupang/day-delete 신설
- index.html: 카드 헤더에 삭제 버튼, 앵커 안에 버튼이 들어가지 않게 구조 정리
This commit is contained in:
2026-08-31 17:13:10 +09:00
parent 7a01c140b0
commit 1dc4cfc097
9 changed files with 149 additions and 12 deletions
+47 -2
View File
@@ -129,7 +129,11 @@ async def index(request: Request) -> HTMLResponse:
store, user = guard
year, month = _ym(request)
shipments = store.list_shipments(year=year, month=month)
# 취소된 묶음은 달력·목록 어디에도 보이지 않는다(soft delete = 삭제로 취급).
shipments = [
s for s in store.list_shipments(year=year, month=month)
if s.get("status") != "취소"
]
# 달력 칸에는 출고 건수와 센터 수만 보여준다.
counts: dict[str, dict[str, Any]] = {}
@@ -340,10 +344,19 @@ async def update(
return RedirectResponse(url=f"/cupang/{shipment_id}", status_code=303)
def _safe_next(raw: str, fallback: str) -> str:
"""열린 리다이렉트 방지 — /cupang/ 안쪽 경로만 허용."""
nxt = (raw or "").strip()
if nxt.startswith("/cupang/") and "//" not in nxt[1:]:
return nxt
return fallback
@router.post("/{shipment_id:int}/delete")
async def delete(
request: Request,
shipment_id: int,
next: str = Form(""),
user: dict[str, Any] = Depends(_require_user),
) -> RedirectResponse:
"""운영 안전: 기본은 status='취소' soft delete."""
@@ -354,7 +367,9 @@ async def delete(
store.soft_delete(shipment_id=shipment_id)
except KeyError:
raise HTTPException(status_code=404, detail="출고 묶음을 찾을 수 없습니다.")
return RedirectResponse(url=f"/cupang/{shipment_id}", status_code=303)
return RedirectResponse(
url=_safe_next(next, f"/cupang/{shipment_id}"), status_code=303
)
@router.post("/{shipment_id:int}/hard-delete")
@@ -374,6 +389,36 @@ async def hard_delete(
return RedirectResponse(url="/cupang/", status_code=303)
@router.post("/day-delete")
async def day_delete(
request: Request,
date: str = Form(...),
next: str = Form(""),
user: dict[str, Any] = Depends(_require_user),
) -> RedirectResponse:
"""선택한 출고일의 묶음을 한 번에 취소 처리한다(soft delete)."""
store = _store(request)
if store is None:
raise HTTPException(status_code=503, detail="cupang_db 미설정")
day = (date or "").strip()
try:
_date.fromisoformat(day)
except ValueError:
raise HTTPException(status_code=400, detail="날짜 형식이 올바르지 않습니다.")
for ship in store.list_shipments(date_from=day, date_to=day):
if ship.get("status") == "취소":
continue
try:
store.soft_delete(shipment_id=ship["id"])
except KeyError:
continue
return RedirectResponse(
url=_safe_next(next, f"/cupang/?date={day}"), status_code=303
)
# ════════════════════════════════════════════════════════════
# 입고센터 관리
# ════════════════════════════════════════════════════════════
@@ -1,6 +1,6 @@
{% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260831i" />{% endblock %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260831j" />{% endblock %}
{% block content %}
<section class="cpg">
@@ -1,6 +1,6 @@
{% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260831i" />{% endblock %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260831j" />{% endblock %}
{% block content %}
<section class="cpg">
@@ -1,6 +1,6 @@
{% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260831i" />{% endblock %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260831j" />{% endblock %}
{% block content %}
<section class="cpg">
@@ -1,6 +1,6 @@
{% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260831i" />{% endblock %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260831j" />{% endblock %}
{% block content %}
<section class="cpg">
@@ -1,6 +1,6 @@
{% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260831i" />{% endblock %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260831j" />{% endblock %}
{% block content %}
<section class="cpg">
+81 -4
View File
@@ -1,6 +1,6 @@
{% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260831i" />{% endblock %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260831j" />{% endblock %}
{% block content %}
<section class="cpg">
@@ -60,16 +60,22 @@
<div class="cpg-list-head">
<h2>{{ selected_date }} 출고</h2>
<span class="erp-muted">{{ sel_shipments|length }}건</span>
{% if sel_shipments %}
<button type="button" class="erp-btn erp-btn-danger cpg-btn-sm cpg-day-del"
data-day-del="{{ selected_date }}" data-count="{{ sel_shipments|length }}">이 날짜 전체 삭제</button>
{% endif %}
</div>
{% if sel_shipments %}
<ul class="cpg-list">
{% for s in sel_shipments %}
<li class="cpg-list-item">
<a href="/cupang/{{ s.id }}" class="cpg-ship-card">
<div class="cpg-ship-card">
<div class="cpg-ship-head">
<strong>{{ s.center_name_snapshot or '센터 미지정' }}</strong>
<a class="cpg-ship-name" href="/cupang/{{ s.id }}">{{ s.center_name_snapshot or '센터 미지정' }}</a>
<span class="erp-badge erp-badge-neutral cpg-mini">{{ s.ship_method }}</span>
<button type="button" class="cpg-icon-btn is-danger" title="이 출고 삭제"
data-del="{{ s.id }}" data-name="{{ s.center_name_snapshot }}">삭제</button>
</div>
<div class="cpg-ship-meta erp-muted">
출고 {{ s.ship_date }} · {{ s.total_boxes }}박스 · {{ s.total_qty }}개
@@ -83,7 +89,7 @@
</li>
{% endfor %}
</ul>
</a>
</div>
</li>
{% endfor %}
</ul>
@@ -94,7 +100,78 @@
</div>
</div>
<!-- 삭제 확인 팝업 -->
<div class="cpg-modal" id="cpg-del-dlg" hidden>
<div class="cpg-modal-back" data-del-close></div>
<div class="cpg-modal-box cpg-del-box" role="dialog" aria-modal="true" aria-labelledby="cpg-del-title">
<h3 id="cpg-del-title">출고 삭제</h3>
<p class="cpg-del-msg" id="cpg-del-msg"></p>
<div class="cpg-del-act">
<button type="button" class="erp-btn erp-btn-outline" data-del-close>취소</button>
<button type="button" class="erp-btn erp-btn-danger" id="cpg-del-ok">삭제</button>
</div>
</div>
</div>
<form method="post" id="cpg-del-form" hidden>
<input type="hidden" name="date" id="cpg-del-date" />
<input type="hidden" name="next" id="cpg-del-next" />
</form>
</section>
{% endblock %}
{% block scripts %}
<script>
(function () {
var dlg = document.getElementById("cpg-del-dlg");
if (!dlg) return;
var msgEl = document.getElementById("cpg-del-msg");
var okBtn = document.getElementById("cpg-del-ok");
var form = document.getElementById("cpg-del-form");
var dateEl = document.getElementById("cpg-del-date");
var nextEl = document.getElementById("cpg-del-next");
var pending = null; // {action, date}
function close() { dlg.hidden = true; pending = null; }
function open(message, action, day) {
msgEl.textContent = message;
pending = { action: action, date: day || "" };
dlg.hidden = false;
okBtn.focus();
}
Array.prototype.forEach.call(dlg.querySelectorAll("[data-del-close]"), function (el) {
el.addEventListener("click", close);
});
document.addEventListener("keydown", function (e) {
if (e.key === "Escape" && !dlg.hidden) close();
});
okBtn.addEventListener("click", function () {
if (!pending) return;
form.action = pending.action;
dateEl.value = pending.date;
nextEl.value = window.location.pathname + window.location.search;
form.submit();
});
document.addEventListener("click", function (e) {
var one = e.target.closest("[data-del]");
if (one) {
open('"' + (one.dataset.name || "이 센터") + '" 출고를 삭제할까요?',
"/cupang/" + one.dataset.del + "/delete", "");
return;
}
var day = e.target.closest("[data-day-del]");
if (day) {
open(day.dataset.dayDel + " 출고 " + day.dataset.count + "건을 모두 삭제할까요?",
"/cupang/day-delete", day.dataset.dayDel);
}
});
})();
</script>
{% endblock %}
@@ -1,6 +1,6 @@
{% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260831i" />{% endblock %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260831j" />{% endblock %}
{% block content %}
<section class="cpg">
+15
View File
@@ -836,6 +836,21 @@ body.cpg-dragging { cursor: grabbing; user-select: none; }
.cpg-ship-iqty { color: var(--color-midtone-gray); }
.cpg-ship-ibox { font-weight: 600; }
/* 달력 오른쪽 — 삭제 버튼 / 확인 팝업 */
.cpg-list-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.cpg-list-head > h2 { flex: 0 0 auto; }
.cpg-list-head > .erp-muted { flex: 1 1 auto; }
.cpg-day-del { flex: 0 0 auto; }
.cpg-ship-name {
flex: 1 1 auto; min-width: 0;
font-size: 14px; font-weight: 700; text-decoration: none; color: inherit;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.cpg-ship-name:hover { text-decoration: underline; }
.cpg-del-box { width: min(400px, calc(100vw - 32px)); }
.cpg-del-msg { margin: 0; font-size: 14px; line-height: 1.5; word-break: keep-all; }
.cpg-del-act { display: flex; gap: 6px; justify-content: flex-end; margin-top: 4px; }
/* ③ 헤더의 분배 확정 버튼 */
.cpg-dist-head-bar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.cpg-dist-head-bar > .erp-muted { flex: 1 1 auto; min-width: 0; }