diff --git a/app/modules/cupang/router.py b/app/modules/cupang/router.py
index 58d52b1..c44a7dd 100644
--- a/app/modules/cupang/router.py
+++ b/app/modules/cupang/router.py
@@ -592,7 +592,8 @@ async def box_calc_page(request: Request) -> HTMLResponse:
"page_title": "쿠팡 밀크런 — 박스 계산",
"page_subtitle": "제품명과 수량을 넣으면 박스 수와 남은 낱개를 계산합니다.",
"box_rules": store.list_box_rules(),
- "centers": store.list_centers(),
+ # 센터 선택 드롭다운은 가나다순 (한글 음절은 코드포인트 순 = 가나다순)
+ "centers": sorted(store.list_centers(), key=lambda c: (c.get("name") or "")),
},
)
diff --git a/app/modules/cupang/templates/cupang/box_calc.html b/app/modules/cupang/templates/cupang/box_calc.html
index 7ff0223..fdd36bb 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 %}
@@ -143,6 +143,7 @@
var sumBoxes = document.getElementById("cpg-sum-boxes");
var sumMixNote = document.getElementById("cpg-sum-mix-note");
var distList = document.getElementById("cpg-dist-list");
+ var distCard = document.querySelector(".cpg-dist-card");
var distEmpty = document.getElementById("cpg-dist-empty");
var centerPick = document.getElementById("cpg-center-pick");
var centerAdd = document.getElementById("cpg-center-add");
@@ -426,7 +427,7 @@
distList.innerHTML = html;
}
- function render() { renderSummary(); renderDist(); }
+ function render() { renderSummary(); bindDragSources(); renderDist(); }
// ── 수량 대화상자 ─────────────────────────────────
function openDialog(key, centerId) {
@@ -476,46 +477,99 @@
});
// ── 드래그 앤 드롭 (+ 클릭 대체 경로) ─────────────
- sumBody.addEventListener("dragstart", function (e) {
- var card = e.target.closest("[data-key]");
- if (!card || card.getAttribute("draggable") !== "true") return;
- e.dataTransfer.setData("text/plain", card.getAttribute("data-key"));
- e.dataTransfer.effectAllowed = "copy";
- card.classList.add("is-dragging");
- });
- sumBody.addEventListener("dragend", function (e) {
- var card = e.target.closest("[data-key]");
- if (card) card.classList.remove("is-dragging");
- });
+ // 브라우저마다 다른 실패 지점을 모두 막는다:
+ // · 리스너를 카드에 직접 건다(위임 시 dragstart 가 안 잡히는 환경 대비)
+ // · dragenter/dragover 둘 다 preventDefault (드롭 허용 신호)
+ // · 드롭 대상은 좌표(elementFromPoint)로도 찾고, 못 찾으면 ③ 영역 전체를 대상으로 본다
+ // · dataTransfer 를 못 읽는 브라우저 대비로 dragKey 를 따로 보관
+ var dragKey = null;
+ var dragEndedAt = 0;
+
+ function bindDragSources() {
+ Array.prototype.forEach.call(sumBody.querySelectorAll('[data-key][draggable="true"]'), function (card) {
+ card.addEventListener("dragstart", function (e) {
+ dragKey = card.getAttribute("data-key");
+ try {
+ e.dataTransfer.setData("text/plain", dragKey);
+ e.dataTransfer.effectAllowed = "copy";
+ } catch (err) { /* 일부 브라우저는 setData 를 막는다 — dragKey 로 진행 */ }
+ card.classList.add("is-dragging");
+ if (distCard) distCard.classList.add("is-drop-ready");
+ });
+ card.addEventListener("dragend", function () {
+ card.classList.remove("is-dragging");
+ if (distCard) distCard.classList.remove("is-drop-ready");
+ dragEndedAt = Date.now();
+ dragKey = null;
+ clearOver();
+ });
+ });
+ }
+
+ // 드래그가 끝난 직후 브라우저가 click 을 한 번 더 쏘는 경우가 있어 무시한다.
sumBody.addEventListener("click", function (e) {
+ if (Date.now() - dragEndedAt < 250) return;
var card = e.target.closest("[data-key]");
if (!card) return;
openDialog(card.getAttribute("data-key"), openCenters[0]);
});
- if (distList) {
- // 드롭 대상은 센터 패널 전체 — 여백에 놓아도 인식된다.
- distList.addEventListener("dragover", function (e) {
- var zone = e.target.closest("[data-drop-for]");
- if (!zone) return;
- e.preventDefault();
- e.dataTransfer.dropEffect = "copy";
- zone.classList.add("is-over");
- });
- distList.addEventListener("dragleave", function (e) {
- var zone = e.target.closest("[data-drop-for]");
- if (zone && !zone.contains(e.relatedTarget)) zone.classList.remove("is-over");
- });
- distList.addEventListener("drop", function (e) {
- var zone = e.target.closest("[data-drop-for]");
- if (!zone) return;
- e.preventDefault();
- zone.classList.remove("is-over");
- var key = e.dataTransfer.getData("text/plain");
- if (!key) return;
- openDialog(key, zone.getAttribute("data-drop-for"));
+ function clearOver() {
+ if (!distList) return;
+ Array.prototype.forEach.call(distList.querySelectorAll(".is-over"), function (el) {
+ el.classList.remove("is-over");
});
+ }
+ function zoneFromEvent(e) {
+ var z = e.target && e.target.closest ? e.target.closest("[data-drop-for]") : null;
+ if (z) return z;
+ if (document.elementFromPoint && typeof e.clientX === "number") {
+ var el = document.elementFromPoint(e.clientX, e.clientY);
+ if (el && el.closest) {
+ z = el.closest("[data-drop-for]");
+ if (z) return z;
+ }
+ }
+ // ③ 영역 안이지만 패널 사이 여백에 놓은 경우 — 센터가 하나면 그 센터로.
+ if (openCenters.length === 1 && distList) {
+ return distList.querySelector("[data-drop-for]");
+ }
+ return null;
+ }
+
+ function keyFromEvent(e) {
+ var key = "";
+ try { key = e.dataTransfer.getData("text/plain") || ""; } catch (err) { key = ""; }
+ return key || dragKey || "";
+ }
+
+ if (distCard) {
+ // ③ 카드 전체에서 드롭을 허용한다(패널 여백·빈 공간 포함).
+ ["dragenter", "dragover"].forEach(function (type) {
+ distCard.addEventListener(type, function (e) {
+ if (!dragKey && !(e.dataTransfer && e.dataTransfer.types &&
+ Array.prototype.indexOf.call(e.dataTransfer.types, "text/plain") >= 0)) return;
+ e.preventDefault();
+ if (e.dataTransfer) e.dataTransfer.dropEffect = "copy";
+ var zone = zoneFromEvent(e);
+ if (zone) { clearOver(); zone.classList.add("is-over"); }
+ });
+ });
+ distCard.addEventListener("dragleave", function (e) {
+ if (!distCard.contains(e.relatedTarget)) clearOver();
+ });
+ distCard.addEventListener("drop", function (e) {
+ e.preventDefault();
+ clearOver();
+ var key = keyFromEvent(e);
+ if (!key) { msg.textContent = "드래그 정보를 읽지 못했습니다 — 카드를 클릭해 담아 주세요."; return; }
+ var zone = zoneFromEvent(e);
+ openDialog(key, zone ? zone.getAttribute("data-drop-for") : openCenters[0]);
+ });
+ }
+
+ if (distList) {
distList.addEventListener("input", function (e) {
if (!e.target.classList.contains("cpg-dist-qty")) return;
var li = e.target.closest(".cpg-dist-item");
@@ -529,6 +583,7 @@
entry.count = want;
li.querySelector(".cpg-dist-unit").textContent = "박스 · " + (want * (units[entry.key] || 0)) + "개";
renderSummary();
+ bindDragSources();
updateCenterTotals(cid);
});
diff --git a/app/modules/cupang/templates/cupang/box_rules.html b/app/modules/cupang/templates/cupang/box_rules.html
index 4d4d6e4..7d98293 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 01d84c4..7bae566 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 %}
diff --git a/app/modules/cupang/templates/cupang/detail.html b/app/modules/cupang/templates/cupang/detail.html
index 6e77cdb..492adbf 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 3bf51fa..24307c4 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 76e5f77..e3a4b66 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 38ce1e4..907b531 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 de30422..6914be0 100644
--- a/app/static/cupang.css
+++ b/app/static/cupang.css
@@ -675,3 +675,7 @@
.cpg-dlg-item { margin: 0; font-weight: 600; }
.cpg-dlg-actions { margin: 4px 0 0; flex-wrap: wrap; }
.cpg-modal-box .erp-input, .cpg-modal-box .erp-select { width: 100%; min-width: 0; }
+
+/* 드래그 중일 때 ③ 영역을 강조 — 어디에 놓아야 하는지 보이게 */
+.cpg-dist-card.is-drop-ready { outline: 2px dashed var(--color-rich-black); outline-offset: 2px; }
+.cpg-dist-card.is-drop-ready .cpg-dist-center { border-color: var(--color-midtone-gray); }
diff --git a/tests/js/box_calc_ui.test.js b/tests/js/box_calc_ui.test.js
index 84b9d4a..389bd90 100644
--- a/tests/js/box_calc_ui.test.js
+++ b/tests/js/box_calc_ui.test.js
@@ -134,6 +134,37 @@ function fire(el, type, init) { el.dispatchEvent(new w.Event(type, Object.assign
check("센터 빼기", $$(".cpg-dist-center").length === 0);
check("센터 빼면 전량 잔여 복귀", text(".cpg-sum-prod .cpg-sum-prod-nums").startsWith("2박스16개"), text(".cpg-sum-prod .cpg-sum-prod-nums"));
+ // ── 드롭 실패 경로 방어 확인 ──────────────────────
+ $("#cpg-center-pick").value = "2";
+ $("#cpg-center-add").click();
+ check("센터 다시 추가", $$(".cpg-dist-center").length === 1, String($$(".cpg-dist-center").length));
+
+ // (1) 패널이 아닌 여백(리스트 자체)에 놓아도, 센터가 하나면 그 센터로 담긴다
+ const card2 = $(".cpg-sum-prod[draggable='true']");
+ const dt3 = { data: {}, setData(t, v) { this.data[t] = v; }, getData(t) { return this.data[t] || ""; }, types: ["text/plain"] };
+ const ds3 = new w.Event("dragstart", { bubbles: true }); ds3.dataTransfer = dt3;
+ card2.dispatchEvent(ds3);
+ const den = new w.Event("dragenter", { bubbles: true, cancelable: true }); den.dataTransfer = dt3;
+ $(".cpg-dist-card").dispatchEvent(den);
+ check("dragenter 도 기본동작을 막는다", den.defaultPrevented);
+ const drop3 = new w.Event("drop", { bubbles: true, cancelable: true }); drop3.dataTransfer = dt3;
+ $("#cpg-dist-list").dispatchEvent(drop3);
+ check("여백에 놓아도 대화상자가 열린다", $("#cpg-dlg").hidden === false);
+ $("#cpg-dlg-ok").click();
+ check("여백 드롭도 센터에 담긴다", $$(".cpg-dist-item").length === 1, String($$(".cpg-dist-item").length));
+
+ // (2) setData 를 막는 브라우저: dataTransfer 가 비어도 dragKey 로 담긴다
+ $(".cpg-dist-del").click();
+ const card3 = $(".cpg-sum-prod[draggable='true']");
+ const dtBlocked = { setData() { throw new Error("blocked"); }, getData() { return ""; }, types: [] };
+ const ds4 = new w.Event("dragstart", { bubbles: true }); ds4.dataTransfer = dtBlocked;
+ card3.dispatchEvent(ds4);
+ const drop4 = new w.Event("drop", { bubbles: true, cancelable: true }); drop4.dataTransfer = dtBlocked;
+ $(".cpg-dist-center").dispatchEvent(drop4);
+ check("setData 가 막혀도 드롭이 동작한다", $("#cpg-dlg").hidden === false, text("#cpg-calc-msg"));
+ $("#cpg-dlg-ok").click();
+ check("setData 차단 환경에서도 담긴다", $$(".cpg-dist-item").length === 1);
+
console.log("\n" + (fails.length ? "FAILED: " + fails.join(" | ") : "ALL PASS"));
process.exit(fails.length ? 1 : 0);
})();