diff --git a/app/modules/cafe24/templates/cafe24/products.html b/app/modules/cafe24/templates/cafe24/products.html index 161bf86..724c049 100644 --- a/app/modules/cafe24/templates/cafe24/products.html +++ b/app/modules/cafe24/templates/cafe24/products.html @@ -378,8 +378,9 @@ var reloadBtn = pane.querySelector("[data-reload]"); if (reloadBtn) { reloadBtn.addEventListener("click", function () { - if (!confirmLeave()) return; - select(reloadBtn.dataset.reload, false); + confirmLeave().then(function (ok) { + if (ok) select(reloadBtn.dataset.reload, false); + }); }); } @@ -456,9 +457,14 @@ // date/time 이 필수(required)라 브라우저가 빈 값이면 여기까지 오지 않는다. if (atHolder && dateEl && timeEl) atHolder.value = dateEl.value + "T" + timeEl.value; - if (!window.confirm(schedForm.dataset.confirm)) { e.preventDefault(); return; } - // 예약 등록으로 화면을 떠나므로 편집 중 경고를 끈다. - dirty = false; + // 확인창이 비동기라 일단 제출을 멈추고, 확인을 받으면 다시 보낸다. + // (required 검사는 이미 통과한 뒤라 form.submit() 으로 보내도 안전하다) + e.preventDefault(); + window.erpConfirm(schedForm.dataset.confirm).then(function (ok) { + if (!ok) return; + dirty = false; // 예약 등록으로 화면을 떠나므로 편집 중 경고를 끈다. + schedForm.submit(); + }); }); } @@ -502,10 +508,16 @@ function save() { var name = input.value.trim(); - if (!name) { window.alert("상품명을 입력하세요."); input.focus(); return; } + if (!name) { + window.erpAlert("상품명을 입력하세요.").then(function () { input.focus(); }); + return; + } if (name === text.textContent) { open(false); return; } - if (!window.confirm("상품명을 「" + name + "」(으)로 바꿉니다.\n카페24 쇼핑몰에 바로 반영됩니다. 계속할까요?")) return; + window.erpConfirm("상품명을 「" + name + "」(으)로 바꿉니다.\n카페24 쇼핑몰에 바로 반영됩니다. 계속할까요?") + .then(function (ok) { if (ok) send(name); }); + } + function send(name) { busy(true); fetch("/cafe24/products/" + no + "/name", { method: "POST", @@ -529,7 +541,7 @@ open(false); }) .catch(function (err) { - window.alert("상품명 변경에 실패했습니다: " + err.message); + window.erpAlert("상품명 변경에 실패했습니다: " + err.message); }) .then(function () { busy(false); }); } @@ -592,45 +604,54 @@ var want = btn.dataset.statusOn !== "1"; var msg = label.word + " 상태를 「" + (want ? label.on : label.off) + "」(으)로 바꿉니다.\n" + "카페24 쇼핑몰에 바로 반영됩니다. 계속할까요?"; - if (!window.confirm(msg)) return; - - busy(true); - fetch("/cafe24/products/" + no + "/status", { - method: "POST", - credentials: "same-origin", - cache: "no-store", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ field: field, value: want }) - }) - .then(function (res) { - return res.json().catch(function () { return {}; }).then(function (data) { - if (!res.ok) throw new Error(data.detail || "HTTP " + res.status); - return data; - }); - }) - .then(function (data) { - buttons.forEach(function (b) { paint(b, !!data[b.dataset.statusField]); }); - paintRow(data); - }) - .catch(function (err) { - window.alert("상태 변경에 실패했습니다: " + err.message); - }) - .then(function () { busy(false); }); + window.erpConfirm(msg).then(function (ok) { if (ok) send(field, want); }); }); }); + + function send(field, want) { + busy(true); + fetch("/cafe24/products/" + no + "/status", { + method: "POST", + credentials: "same-origin", + cache: "no-store", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ field: field, value: want }) + }) + .then(function (res) { + return res.json().catch(function () { return {}; }).then(function (data) { + if (!res.ok) throw new Error(data.detail || "HTTP " + res.status); + return data; + }); + }) + .then(function (data) { + buttons.forEach(function (b) { paint(b, !!data[b.dataset.statusField]); }); + paintRow(data); + }) + .catch(function (err) { + window.erpAlert("상태 변경에 실패했습니다: " + err.message); + }) + .then(function () { busy(false); }); + } })(); var form = pane.querySelector(".cf24-editor-form"); if (form) { form.addEventListener("submit", function (e) { - if (!window.confirm(form.dataset.confirm)) { e.preventDefault(); return; } - dirty = false; + e.preventDefault(); + window.erpConfirm(form.dataset.confirm).then(function (ok) { + if (!ok) return; + dirty = false; + form.submit(); + }); }); } }; + // 공용 확인창(erp-dialog.js)은 비동기다 — 기본 confirm() 과 달리 Promise 를 + // 돌려주므로 호출부는 모두 then() 안에서 이어간다. function confirmLeave() { - return !dirty || window.confirm("편집한 내용이 저장되지 않았습니다. 이동할까요?"); + if (!dirty) return Promise.resolve(true); + return window.erpConfirm("편집한 내용이 저장되지 않았습니다. 이동할까요?"); } // ── 목록 클릭 → 오른쪽만 교체 ── @@ -663,12 +684,14 @@ document.querySelectorAll("#cf24-list tbody tr.cf24-row").forEach(function (tr) { tr.addEventListener("click", function (e) { if (e.target.tagName === "A") e.preventDefault(); - if (!confirmLeave()) return; - document.querySelectorAll("#cf24-list tr.is-active").forEach(function (el) { - el.classList.remove("is-active"); + confirmLeave().then(function (ok) { + if (!ok) return; + document.querySelectorAll("#cf24-list tr.is-active").forEach(function (el) { + el.classList.remove("is-active"); + }); + tr.classList.add("is-active"); + select(tr.dataset.no, true); }); - tr.classList.add("is-active"); - select(tr.dataset.no, true); }); }); @@ -680,7 +703,11 @@ var filterForm = document.getElementById("cf24-filter-form"); filterForm.querySelectorAll('input[type="checkbox"]').forEach(function (cb) { cb.addEventListener("change", function () { - if (confirmLeave()) filterForm.submit(); + confirmLeave().then(function (ok) { + // 확인창이 비동기라 체크는 이미 바뀌어 있다 — 취소하면 되돌린다. + if (ok) filterForm.submit(); + else cb.checked = !cb.checked; + }); }); }); diff --git a/app/modules/cafe24/templates/cafe24/schedules.html b/app/modules/cafe24/templates/cafe24/schedules.html index 72fece2..42cc3d2 100644 --- a/app/modules/cafe24/templates/cafe24/schedules.html +++ b/app/modules/cafe24/templates/cafe24/schedules.html @@ -64,7 +64,7 @@ {% if r.editable %}
+ data-erp-confirm="예약 #{{ r.id }} 을 취소할까요?">
{% endif %} diff --git a/app/modules/cafe24/templates/cafe24/system.html b/app/modules/cafe24/templates/cafe24/system.html index 3d4e34a..8040478 100644 --- a/app/modules/cafe24/templates/cafe24/system.html +++ b/app/modules/cafe24/templates/cafe24/system.html @@ -64,7 +64,7 @@ {% if status.connected or status.needs_reauth %}
+ data-erp-confirm="저장된 카페24 토큰을 삭제합니다. 계속할까요? (변경 이력·예약 데이터는 지워지지 않습니다)">
{% endif %} diff --git a/app/static/erp-dialog.css b/app/static/erp-dialog.css new file mode 100644 index 0000000..5655a5b --- /dev/null +++ b/app/static/erp-dialog.css @@ -0,0 +1,98 @@ +/* ════════════════════════════════════════════════════ + ERP 공용 확인/경고 창 + 브라우저 기본 confirm()/alert() 은 제목에 도메인이 찍히고 위치·모양을 바꿀 수 + 없다. 화면 정중앙에 같은 모양으로 띄우기 위해 직접 그린다(erp-dialog.js). + ════════════════════════════════════════════════════ */ + +.erp-dialog-backdrop { + position: fixed; + inset: 0; + z-index: 9999; + display: flex; + align-items: center; /* 화면 정중앙 */ + justify-content: center; + padding: var(--sp-16, 16px); + background: rgba(10, 10, 10, 0.45); + font-family: var(--font-geist, system-ui, sans-serif); + animation: erp-dialog-fade 0.12s ease-out; +} + +.erp-dialog { + width: 100%; + max-width: 420px; + background: var(--color-canvas-white, #fff); + border-radius: var(--r-card, 14px); + box-shadow: 0 18px 48px rgba(0, 0, 0, 0.28); + overflow: hidden; + text-align: center; /* 제목·본문·버튼 모두 가운데 정렬 */ + animation: erp-dialog-pop 0.12s ease-out; +} + +.erp-dialog-title { + margin: 0; + padding: var(--sp-12, 12px) var(--sp-20, 20px); + border-bottom: 1px solid var(--color-subtle-ash, #e5e5e5); + background: var(--color-ghost-gray, #f2f2f2); + font-size: var(--text-body, 14px); + font-weight: 600; + letter-spacing: var(--tracking-heading, -0.45px); + color: var(--color-rich-black, #0a0a0a); +} + +/* 아이콘은 글 왼쪽에 고정하고, 글 묶음만 가운데 정렬한다. */ +.erp-dialog-body { + display: flex; + align-items: center; + justify-content: center; + gap: var(--sp-12, 12px); + padding: var(--sp-24, 24px) var(--sp-20, 20px); +} + +.erp-dialog-icon { + flex: 0 0 auto; + line-height: 0; + color: var(--color-callout-red, #c22b10); +} + +/* 확인(질문)용은 붉은 경고색 대신 차분한 검정으로 */ +.erp-dialog-ask .erp-dialog-icon { + color: var(--color-rich-black, #0a0a0a); +} + +.erp-dialog-message { + margin: 0; + font-size: var(--text-body, 14px); + line-height: var(--leading-body, 1.43); + color: var(--color-rich-black, #0a0a0a); + /* 메시지의 줄바꿈(\n)을 그대로 살린다 */ + white-space: pre-line; + word-break: break-word; +} + +.erp-dialog-actions { + display: flex; + justify-content: center; + gap: var(--sp-8, 8px); + padding: 0 var(--sp-20, 20px) var(--sp-20, 20px); +} + +.erp-dialog-actions .erp-btn { + min-width: 88px; + padding: 9px 18px; + font-size: var(--text-body, 14px); +} + +@keyframes erp-dialog-fade { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes erp-dialog-pop { + from { opacity: 0; transform: translateY(6px) scale(0.98); } + to { opacity: 1; transform: none; } +} + +@media (prefers-reduced-motion: reduce) { + .erp-dialog-backdrop, + .erp-dialog { animation: none; } +} diff --git a/app/static/erp-dialog.js b/app/static/erp-dialog.js new file mode 100644 index 0000000..9679a9a --- /dev/null +++ b/app/static/erp-dialog.js @@ -0,0 +1,156 @@ +/* ════════════════════════════════════════════════════ + ERP 공용 확인/경고 창 — window.confirm()/alert() 대체 + ──────────────────────────────────────────────────── + 브라우저 기본 창은 제목에 도메인("dbx.no1king.freeddns.org 내용:")이 찍히고 + 위치·정렬·아이콘을 바꿀 수 없다. 그래서 같은 역할의 창을 직접 그린다. + + 쓰는 법 (기본 창과 달리 **비동기**다 — Promise 를 돌려준다): + erpConfirm("지울까요?").then(function (ok) { if (ok) ... }); + erpAlert("실패했습니다.").then(...) + + HTML 만으로 쓰려면 form 에 data-erp-confirm 을 달면 된다. 제출을 가로채 + 확인을 받은 뒤 통과시킨다(가로챈 제출은 form.submit() 으로 다시 보내므로 + 이 스크립트의 submit 리스너를 다시 타지 않는다). +
+ ════════════════════════════════════════════════════ */ +(function () { + "use strict"; + + var TITLE = "No.1 King ERP 프로그램"; + + // 경고 삼각형. 외부 아이콘 라이브러리를 쓰지 않는다(자체 호스팅 원칙). + var ICON = + '"; + + function build(message, options) { + var backdrop = document.createElement("div"); + backdrop.className = "erp-dialog-backdrop"; + + var box = document.createElement("div"); + box.className = "erp-dialog" + (options.ask ? " erp-dialog-ask" : ""); + box.setAttribute("role", "alertdialog"); + box.setAttribute("aria-modal", "true"); + box.setAttribute("aria-label", TITLE); + + var title = document.createElement("h2"); + title.className = "erp-dialog-title"; + title.textContent = TITLE; + + var body = document.createElement("div"); + body.className = "erp-dialog-body"; + + var icon = document.createElement("span"); + icon.className = "erp-dialog-icon"; + icon.innerHTML = ICON; + + var text = document.createElement("p"); + text.className = "erp-dialog-message"; + // 메시지는 반드시 textContent 로 넣는다 — 서버/사용자 문자열이 들어와도 + // HTML 로 해석되지 않게. + text.textContent = String(message == null ? "" : message); + + var actions = document.createElement("div"); + actions.className = "erp-dialog-actions"; + + var cancel = null; + if (options.ask) { + cancel = document.createElement("button"); + cancel.type = "button"; + cancel.className = "erp-btn erp-btn-outline"; + cancel.textContent = options.cancelText || "취소"; + actions.appendChild(cancel); + } + + var ok = document.createElement("button"); + ok.type = "button"; + ok.className = "erp-btn erp-btn-primary"; + ok.textContent = options.okText || "확인"; + actions.appendChild(ok); + + body.appendChild(icon); + body.appendChild(text); + box.appendChild(title); + box.appendChild(body); + box.appendChild(actions); + backdrop.appendChild(box); + + return { backdrop: backdrop, box: box, ok: ok, cancel: cancel }; + } + + function open(message, options) { + return new Promise(function (resolve) { + var el = build(message, options); + var previous = document.activeElement; + var closed = false; + + function close(result) { + if (closed) return; + closed = true; + document.removeEventListener("keydown", onKey, true); + el.backdrop.remove(); + if (previous && typeof previous.focus === "function") previous.focus(); + resolve(result); + } + + function onKey(e) { + if (e.key === "Escape") { + e.preventDefault(); + close(false); // 확인창의 Esc = 취소, 알림창의 Esc = 닫기 + } else if (e.key === "Enter") { + e.preventDefault(); + close(true); + } else if (e.key === "Tab") { + // 포커스가 창 밖으로 나가지 않게 두 버튼 사이에서만 돈다. + var focusable = el.cancel ? [el.cancel, el.ok] : [el.ok]; + var index = focusable.indexOf(document.activeElement); + e.preventDefault(); + var next = e.shiftKey ? index - 1 : index + 1; + if (next < 0) next = focusable.length - 1; + if (next >= focusable.length) next = 0; + focusable[next].focus(); + } + } + + el.ok.addEventListener("click", function () { close(true); }); + if (el.cancel) el.cancel.addEventListener("click", function () { close(false); }); + // 바깥을 누르면 취소(= 안전한 쪽). 창 안쪽 클릭은 그대로 둔다. + el.backdrop.addEventListener("mousedown", function (e) { + if (e.target === el.backdrop) close(false); + }); + document.addEventListener("keydown", onKey, true); + + document.body.appendChild(el.backdrop); + el.ok.focus(); + }); + } + + window.erpConfirm = function (message, options) { + var opts = options || {}; + opts.ask = true; + return open(message, opts); + }; + + window.erpAlert = function (message, options) { + var opts = options || {}; + opts.ask = false; + return open(message, opts).then(function () { return undefined; }); + }; + + // data-erp-confirm 이 달린 폼은 제출 전에 확인을 받는다. + document.addEventListener("submit", function (e) { + var form = e.target; + if (!form || !form.dataset || !form.dataset.erpConfirm) return; + if (form.dataset.erpConfirmed === "1") return; // 확인을 마치고 다시 보낸 것 + e.preventDefault(); + window.erpConfirm(form.dataset.erpConfirm).then(function (ok) { + if (!ok) return; + form.dataset.erpConfirmed = "1"; + form.submit(); // submit 이벤트를 다시 타지 않는다 + }); + }); +})(); diff --git a/app/templates/erp_base.html b/app/templates/erp_base.html index f44b98c..45b0a06 100644 --- a/app/templates/erp_base.html +++ b/app/templates/erp_base.html @@ -7,6 +7,7 @@ + {% block head_extra %}{% endblock %} @@ -161,6 +162,9 @@ })(); + {# 공용 확인/경고 창. defer 를 쓰지 않는다 — 아래 {% block scripts %} 의 + 인라인 스크립트가 곧바로 erpConfirm/erpAlert 을 쓸 수 있어야 한다. #} + {% block scripts %}{% endblock %}