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); })();