Files
dbx-main/app/modules/cupang/templates/cupang/box_calc.html
T
king 1cf6ba385d feat(cupang): 달력을 2개월(현재+다음 달) 뷰로 개편
- index 라우터가 두 달치 출고를 합쳐(중복 id 제거) 달 별 격자 2개를 생성
- 상단 바: ‹ › 이동 + 기간 제목 + [오늘], 달마다 머리글/월 출고 합계 배지
- 셀 UI 정리: 오늘=검은 원, 선택=링, 출고일=건수/센터수 태그, 타월은 흐리게
- 범례 추가, 1200px 이하에서는 두 달을 세로로 배치

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 12:20:41 +09:00

1180 lines
52 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901g" />{% endblock %}
{% block content %}
<section class="cpg">
<div class="erp-page-actions">
<a class="erp-btn erp-btn-primary" href="/cupang/">◀◀ 달력</a>
{% if box_rules %}
<button type="button" class="erp-btn erp-btn-outline" id="cpg-calc-save">임시 저장</button>
<button type="button" class="erp-btn erp-btn-outline" id="cpg-calc-load">불러오기</button>
{% endif %}
</div>
{% if not box_rules %}
<div class="erp-card cpg-form-card">
<p class="erp-muted">
등록된 박스 입수량 규칙이 없습니다.
<a href="/cupang/box-rules">박스 입수량 설정</a>에서 먼저 제품별 입수량을 등록하세요.
</p>
</div>
{% else %}
<div class="cpg-calc3">
<!-- ① 박스 계산 — 제품명 + 수량만 입력 -->
<div class="erp-card cpg-form-card cpg-calc-card">
<div class="cpg-card-head">
<h2>① 박스 계산</h2>
<span class="erp-muted">수량 칸에서 Tab = 다음 상품 수량, Enter = 계산</span>
</div>
<div class="erp-table-wrap">
<table class="erp-table cpg-calc-table">
<colgroup>
<col class="cpg-col-name" />
<col class="cpg-col-qty" />
<col class="cpg-col-act" />
</colgroup>
<thead>
<tr>
<th>제품명</th>
<th>수량</th>
<th></th>
</tr>
</thead>
<tbody id="cpg-calc-rows"></tbody>
</table>
</div>
<div class="erp-page-actions cpg-calc-actions">
<button type="button" class="erp-btn erp-btn-primary" id="cpg-calc-run">계산 / 재계산</button>
<button type="button" class="erp-btn erp-btn-outline" id="cpg-calc-add">+ 제품 추가</button>
<button type="button" class="erp-btn erp-btn-outline" id="cpg-calc-reset">초기화</button>
<span class="erp-muted" id="cpg-calc-msg"></span>
</div>
</div>
<!-- ② 박스 요약 — 센터에 배분하고 남은 수량이 실시간 반영 -->
<div class="erp-card cpg-form-card cpg-calc-sum">
<div class="cpg-card-head">
<h2>② 박스 요약</h2>
<span class="erp-muted">카드를 ③ 센터로 끌어다 놓거나, 카드를 클릭해 담습니다.</span>
</div>
<div id="cpg-sum-empty" class="erp-muted">왼쪽에서 제품과 수량을 넣고 계산하세요.</div>
<div id="cpg-sum-body" hidden>
<div class="cpg-sum-kpis" id="cpg-sum-kpis"></div>
<h3 class="cpg-sum-h3">제품별 박스 <span class="erp-muted">잔여 기준</span></h3>
<div class="cpg-sum-prods" id="cpg-sum-prods"></div>
<h3 class="cpg-sum-h3">자투리 혼합 박스 <span class="erp-muted" id="cpg-sum-mix-note"></span></h3>
<div class="cpg-sum-boxes" id="cpg-sum-boxes"></div>
</div>
</div>
<!-- ③ 센터 분배 — 선택한 센터만 표시 -->
<div class="erp-card cpg-form-card cpg-dist-card">
<div class="cpg-card-head cpg-dist-head-bar">
<h2>③ 센터 분배</h2>
<span class="erp-muted">담을 센터를 골라 추가하세요.</span>
<button type="button" class="erp-btn erp-btn-primary cpg-confirm-btn" id="cpg-confirm" disabled
title="모든 박스를 배분하고 센터마다 출고방식을 고르면 활성화됩니다.">분배 확정</button>
</div>
{% if not centers %}
<p class="erp-muted">
등록된 입고센터가 없습니다. <a href="/cupang/centers">입고센터 관리</a>에서 먼저 센터를 추가하세요.
</p>
{% else %}
<div class="cpg-center-pick">
<select class="erp-select" id="cpg-center-pick" aria-label="센터 추가">
<option value="">— 센터를 고르면 바로 추가 —</option>
{% for c in centers %}
<option value="{{ c.id }}">{{ c.name }}</option>
{% endfor %}
</select>
</div>
<div class="cpg-dist-list" id="cpg-dist-list"></div>
<p class="erp-muted" id="cpg-dist-empty">추가한 센터가 여기에 표시됩니다.</p>
{% endif %}
</div>
</div><!-- /cpg-calc3 -->
<!-- 담을 박스 수량 입력 대화상자 -->
<div class="cpg-modal" id="cpg-dlg" hidden>
<div class="cpg-modal-back" data-dlg-close></div>
<div class="cpg-modal-box" role="dialog" aria-modal="true" aria-labelledby="cpg-dlg-title">
<h3 id="cpg-dlg-title">센터에 담기</h3>
<p class="cpg-dlg-item" id="cpg-dlg-item"></p>
<div class="erp-field"><span>센터</span>
<div class="cpg-dlg-centers" id="cpg-dlg-centers" role="group" aria-label="담을 센터"></div></div>
<label class="erp-field"><span>박스 수량</span>
<input class="erp-input" type="number" id="cpg-dlg-qty" min="1" step="1" /></label>
<p class="erp-muted" id="cpg-dlg-hint"></p>
<div class="erp-page-actions cpg-dlg-actions">
<button type="button" class="erp-btn erp-btn-primary" id="cpg-dlg-ok">담기</button>
<button type="button" class="erp-btn erp-btn-outline" id="cpg-dlg-all">잔여 전부 담기</button>
<button type="button" class="erp-btn erp-btn-outline" data-dlg-close>취소</button>
</div>
</div>
</div>
<!-- 분배 확정 — 출고일자 선택 -->
<div class="cpg-modal" id="cpg-cfm-dlg" hidden>
<div class="cpg-modal-back" data-cfm-close></div>
<div class="cpg-modal-box cpg-cfm-box" role="dialog" aria-modal="true" aria-labelledby="cpg-cfm-title">
<h3 id="cpg-cfm-title">분배 확정 — 출고일자 선택</h3>
<div class="cpg-cal">
<div class="cpg-cal-head">
<button type="button" class="erp-btn erp-btn-outline cpg-btn-sm" id="cpg-cal-prev" aria-label="이전 달"></button>
<strong id="cpg-cal-label"></strong>
<button type="button" class="erp-btn erp-btn-outline cpg-btn-sm" id="cpg-cal-next" aria-label="다음 달"></button>
</div>
<div class="cpg-cal-dow">
<span></span><span></span><span></span><span></span><span></span><span></span><span></span>
</div>
<div class="cpg-cal-grid" id="cpg-cal-grid"></div>
</div>
<p class="cpg-cfm-pick" id="cpg-cfm-pick"></p>
<div class="cpg-cfm-list" id="cpg-cfm-list"></div>
<p class="erp-muted cpg-cfm-note">센터입고일은 출고일과 같게 저장됩니다. 필요하면 출고 상세에서 고치세요.</p>
<div class="cpg-dlg-actions cpg-cfm-act">
<span class="erp-muted" id="cpg-cfm-msg"></span>
<button type="button" class="erp-btn erp-btn-primary" id="cpg-cfm-ok" disabled>확인</button>
<button type="button" class="erp-btn erp-btn-outline" data-cfm-close>취소</button>
</div>
</div>
</div>
<!-- 임시 저장 / 불러오기 -->
<div class="cpg-modal" id="cpg-draft-dlg" hidden>
<div class="cpg-modal-back" data-draft-close></div>
<div class="cpg-modal-box cpg-draft-box" role="dialog" aria-modal="true" aria-labelledby="cpg-draft-title">
<h3 id="cpg-draft-title">임시 저장</h3>
<label class="erp-field"><span>제목</span>
<input class="erp-input" type="text" id="cpg-draft-name" maxlength="100"
placeholder="예: 2026.08.31(월) 오후 04시 50분" autocomplete="off" /></label>
<div class="cpg-draft-saveact">
<button type="button" class="erp-btn erp-btn-primary" id="cpg-draft-save">이 내용 저장</button>
<span class="erp-muted" id="cpg-draft-msg"></span>
</div>
<h4 class="cpg-draft-h4">저장된 목록</h4>
<div class="cpg-draft-list" id="cpg-draft-list">
<p class="erp-muted">불러오는 중…</p>
</div>
<div class="cpg-dlg-actions cpg-draft-foot">
<button type="button" class="erp-btn erp-btn-outline" data-draft-close>닫기</button>
</div>
</div>
</div>
<script type="application/json" id="cpg-calc-rules">{{ box_rules | tojson }}</script>
<script type="application/json" id="cpg-centers">{{ centers | tojson }}</script>
<script>
// 박스 계산 3열 화면.
// ① 제품명·수량 입력 → 서버(POST /cupang/api/box-calc)에서 계산
// ② 계산 결과 요약 — 드래그(또는 클릭) 소스
// ③ 선택한 센터에만 분배. 담은 수량만큼 ② 의 잔여가 즉시 줄어든다.
// 분배 내역은 화면 안에서만 유지된다(DB 저장 없음).
(function () {
var tbody = document.getElementById("cpg-calc-rows");
if (!tbody) return;
var runBtn = document.getElementById("cpg-calc-run");
var addBtn = document.getElementById("cpg-calc-add");
var resetBtn = document.getElementById("cpg-calc-reset");
var msg = document.getElementById("cpg-calc-msg");
var sumEmpty = document.getElementById("cpg-sum-empty");
var sumBody = document.getElementById("cpg-sum-body");
var sumKpis = document.getElementById("cpg-sum-kpis");
var sumProds = document.getElementById("cpg-sum-prods");
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 dlg = document.getElementById("cpg-dlg");
var dlgItem = document.getElementById("cpg-dlg-item");
var dlgCenters = document.getElementById("cpg-dlg-centers");
var dlgCenterId = null; // 대화상자에서 고른 센터
var dlgQty = document.getElementById("cpg-dlg-qty");
var dlgHint = document.getElementById("cpg-dlg-hint");
var dlgOk = document.getElementById("cpg-dlg-ok");
var dlgAll = document.getElementById("cpg-dlg-all");
function readJson(id) {
var el = document.getElementById(id);
try { return JSON.parse((el && el.textContent) || "[]"); } catch (e) { return []; }
}
var rules = readJson("cpg-calc-rules");
var centers = readJson("cpg-centers");
function esc(s) { var d = document.createElement("div"); d.textContent = s == null ? "" : s; return d.innerHTML; }
var optionsHtml = '<option value="">— 제품명 선택 —</option>';
rules.forEach(function (r) {
optionsHtml += '<option value="' + esc(r.product_code) + '">' +
esc(r.product_name_snapshot || r.product_code) + "</option>";
});
// ── 상태 ─────────────────────────────────────────
var calc = { results: [], mixes: [] }; // 서버 계산 결과
var units = {}; // key -> 박스 1개당 상품 수
var contents = {}; // key -> 혼합 박스 내용물 [{product_name, quantity}]
var labels = {}; // key -> 표시 이름
var openCenters = []; // ③ 에 추가한 센터 id (문자열)
var alloc = {}; // centerId -> [{id, key, count}]
var methods = {}; // centerId -> 출고방식(택배/파렛트)
var seq = 0;
var pending = null; // 대화상자 대상 {key, centerId}
function prodKey(code) { return "p:" + code; }
function mixKey(boxName, idx) { return "m:" + boxName + "#" + idx; }
function centerName(cid) {
var hit = centers.filter(function (c) { return String(c.id) === String(cid); })[0];
return hit ? hit.name : cid;
}
// ── ① 입력 표 ─────────────────────────────────────
function addRow(code, qty) {
var tr = document.createElement("tr");
tr.className = "cpg-calc-row";
tr.innerHTML =
'<td><select class="erp-select cpg-calc-name">' + optionsHtml + "</select></td>" +
'<td><input class="erp-input cpg-calc-qty" type="number" min="0" step="1" value="' + (qty || 0) + '" /></td>' +
'<td class="cpg-calc-act"><button type="button" class="erp-btn erp-btn-danger cpg-calc-del" title="행 삭제" aria-label="행 삭제">✕</button></td>';
tbody.appendChild(tr);
if (code) tr.querySelector(".cpg-calc-name").value = code;
return tr;
}
function rowsArr() {
return Array.prototype.slice.call(tbody.querySelectorAll(".cpg-calc-row"));
}
function collect() {
var items = [];
rowsArr().forEach(function (tr) {
var code = tr.querySelector(".cpg-calc-name").value;
if (!code) return;
items.push({ product_code: code, quantity: parseInt(tr.querySelector(".cpg-calc-qty").value, 10) || 0 });
});
return items;
}
function run(restore) {
var items = collect();
if (!items.length) { msg.textContent = "제품명을 하나 이상 선택하세요."; return; }
msg.textContent = "계산 중…";
runBtn.disabled = true;
fetch("/cupang/api/box-calc", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ items: items })
})
.then(function (r) { if (!r.ok) throw new Error("http " + r.status); return r.json(); })
.then(function (data) {
calc.results = ((data && data.results) || []).filter(function (r) { return r.configured; });
calc.mixes = (data && data.mixes) || [];
units = {};
labels = {};
contents = {};
calc.results.forEach(function (r) {
units[prodKey(r.product_code)] = r.units_per_box;
labels[prodKey(r.product_code)] = r.product_name;
});
calc.mixes.forEach(function (m) {
m.boxes.forEach(function (b, i) {
var n = 0;
b.items.forEach(function (it) { n += it.quantity; });
units[mixKey(m.box_name, i)] = n;
labels[mixKey(m.box_name, i)] = m.box_name + " 혼합 #" + (i + 1);
contents[mixKey(m.box_name, i)] = b.items.map(function (it) {
return { product_code: it.product_code, product_name: it.product_name, quantity: it.quantity };
});
});
});
var had = Object.keys(alloc).some(function (k) { return alloc[k].length; });
alloc = {};
stamped = {};
allStamped = false; // 계산이 바뀌면 이전 배분은 근거가 사라지므로 비운다.
var skipped = restore ? applyRestore(restore) : 0;
render();
if (restore) {
msg.textContent = "불러오기 완료 (" + calc.results.length + "건)" +
(skipped ? " — 담을 수 없는 " + skipped + "건은 제외" : "");
} else {
msg.textContent = "계산 완료 (" + calc.results.length + "건)" + (had ? " — 센터 분배 초기화됨" : "");
}
})
.catch(function () { msg.textContent = "계산 실패 — 새로고침 후 다시 시도하세요."; })
.then(function () { runBtn.disabled = false; });
}
// ── 배분 집계 ─────────────────────────────────────
function allocatedFor(key) {
var n = 0;
Object.keys(alloc).forEach(function (cid) {
alloc[cid].forEach(function (a) { if (a.key === key) n += a.count; });
});
return n;
}
function totalFor(key) {
if (key.indexOf("p:") === 0) {
var code = key.slice(2);
var hit = calc.results.filter(function (r) { return r.product_code === code; })[0];
return hit ? hit.full_boxes : 0;
}
return 1; // 혼합 박스는 1장 = 1박스
}
function remainFor(key) { return Math.max(0, totalFor(key) - allocatedFor(key)); }
function addAlloc(cid, key, count) {
cid = String(cid);
var room = remainFor(key);
var n = Math.min(Math.max(parseInt(count, 10) || 0, 0), room);
if (n <= 0) return 0;
alloc[cid] = alloc[cid] || [];
var same = alloc[cid].filter(function (a) { return a.key === key; })[0];
if (same) { same.count += n; }
else { seq += 1; alloc[cid].push({ id: seq, key: key, count: n }); }
return n;
}
// ── ② 요약 렌더 ───────────────────────────────────
function boxSvg(fill, label) {
var f = Math.max(0, Math.min(100, fill || 0));
var h = 40 * f / 100;
return '' +
'<svg class="cpg-box-svg" viewBox="0 0 72 64" role="img" aria-label="' + esc(label) + '">' +
'<rect class="cpg-box-fill" x="10" y="' + (54 - h) + '" width="52" height="' + h + '" rx="2" />' +
'<path class="cpg-box-body" d="M10 20 H62 V54 H10 Z" />' +
'<path class="cpg-box-flapL" d="M10 20 L2 8 H30 L36 20 Z" />' +
'<path class="cpg-box-flapR" d="M62 20 L70 8 H42 L36 20 Z" />' +
'<line class="cpg-box-seam" x1="36" y1="20" x2="36" y2="54" />' +
'<text class="cpg-box-label" x="36" y="42" text-anchor="middle">쿠팡박스</text>' +
"</svg>";
}
// 배분이 끝난 카드에 찍는 도장. 애니메이션은 "이번에 막 완료된" 것만.
var stamped = {}; // key -> 이미 도장이 찍혀 있던 항목
function stampHtml(key) {
var fresh = !stamped[key];
stamped[key] = true;
return '<span class="cpg-stamp' + (fresh ? " is-new" : "") + '" aria-label="배분 완료">배분 완료</span>';
}
var allStamped = false; // 전체 배분 완료 도장이 이미 찍혀 있었는지
function kpi(label, value, hint, extra) {
return '<div class="cpg-kpi"><span class="cpg-kpi-label">' + esc(label) + "</span>" +
'<strong class="cpg-kpi-value">' + esc(value) + "</strong>" +
'<span class="cpg-kpi-hint">' + esc(hint) + "</span>" +
(extra || "") + "</div>";
}
function renderSummary() {
if (!calc.results.length) {
sumEmpty.hidden = false;
sumBody.hidden = true;
return;
}
sumEmpty.hidden = true;
sumBody.hidden = false;
var totalProd = 0, remainProd = 0, leftoverUnits = 0;
calc.results.forEach(function (r) {
totalProd += r.full_boxes;
remainProd += remainFor(prodKey(r.product_code));
leftoverUnits += r.remainder_units;
});
var totalMix = 0, remainMix = 0;
calc.mixes.forEach(function (m) {
m.boxes.forEach(function (b, i) {
totalMix += 1;
remainMix += remainFor(mixKey(m.box_name, i));
});
});
var assigned = (totalProd + totalMix) - (remainProd + remainMix);
// 모든 박스를 센터에 배분하면 "센터 배분" 칸에 전체 완료 도장을 찍는다.
var totalBoxes = totalProd + totalMix;
var allDone = totalBoxes > 0 && (remainProd + remainMix) === 0;
var allStamp = "";
if (allDone) {
allStamp = '<span class="cpg-stamp cpg-stamp-all' + (allStamped ? "" : " is-new") + '"' +
' aria-label="전체 배분 완료">전체 배분 완료</span>';
}
allStamped = allDone;
sumKpis.innerHTML =
kpi("총 박스", totalBoxes + "박스", "제품별 " + totalProd + " + 혼합 " + totalMix) +
kpi("센터 배분", assigned + "박스", "③ 에 담긴 수량", allStamp) +
kpi("미배분 잔여", (remainProd + remainMix) + "박스", "자투리 " + leftoverUnits + "개 포함");
var ph = "";
calc.results.forEach(function (r) {
var key = prodKey(r.product_code);
var remain = remainFor(key);
var used = r.full_boxes - remain;
if (remain) delete stamped[key];
ph += '<div class="cpg-sum-prod' + (remain ? " is-draggable" : " is-done") + '"' +
' data-key="' + esc(key) + '">' +
'<div class="cpg-sum-prod-head">' +
'<span class="cpg-sum-prod-name">' + esc(r.product_name) + "</span>" +
(remain ? "" : stampHtml(key)) +
"</div>" +
'<div class="cpg-sum-prod-nums">' +
"<strong>" + remain + "박스</strong>" +
'<span class="erp-muted">' + (remain * r.units_per_box) + "개 · 총 " + r.full_boxes + "박스" +
(used ? " · 배분 " + used : "") + "</span>" +
(r.remainder_units
? '<span class="cpg-sum-left">자투리 ' + r.remainder_units + "개</span>"
: '<span class="cpg-sum-exact">딱 맞음</span>') +
"</div>" +
"</div>";
});
sumProds.innerHTML = ph;
if (!calc.mixes.length) {
sumMixNote.textContent = "— 자투리 없음";
sumBoxes.innerHTML = '<p class="erp-muted">남는 낱개가 없습니다.</p>';
} else {
sumMixNote.textContent = "— 1장 = 1박스";
var bh = "", n = 0;
calc.mixes.forEach(function (m) {
m.boxes.forEach(function (b, i) {
var key = mixKey(m.box_name, i);
var remain = remainFor(key);
if (remain) delete stamped[key];
var items = b.items.map(function (it) {
return "<li><span>" + esc(it.product_name) + "</span><b>" + it.quantity + "개</b></li>";
}).join("");
bh += '<div class="cpg-box-card' + (remain ? " is-draggable" : " is-done") + '"' +
' data-key="' + esc(key) + '"' +
' style="animation-delay:' + (n * 60) + 'ms">' +
'<div class="cpg-box-art">' + boxSvg(b.fill_percent, m.box_name) + "</div>" +
'<div class="cpg-box-info">' +
'<div class="cpg-box-title">' + esc(m.box_name) + " 혼합 #" + (i + 1) +
(remain ? "" : stampHtml(key)) + "</div>" +
'<ul class="cpg-box-items">' + items + "</ul>" +
'<div class="cpg-box-bar"><span style="width:' + b.fill_percent + '%"></span></div>' +
'<div class="cpg-box-fillnum">' + b.fill_percent + "% 채움 · " + units[key] + "개</div>" +
"</div>" +
"</div>";
n += 1;
});
});
sumBoxes.innerHTML = bh;
}
}
// 혼합 박스는 안에 무엇이 몇 개 들어가는지 트리로 펼쳐 보여준다.
function treeHtml(entry) {
var list = contents[entry.key];
if (!list || !list.length) return "";
return '<ul class="cpg-dist-tree">' + list.map(function (it) {
return '<li><span class="cpg-tree-name">' + esc(it.product_name) + "</span>" +
'<span class="cpg-tree-qty">' + (it.quantity * entry.count) + "개</span>" +
(entry.count > 1 ? '<span class="cpg-tree-per">(' + it.quantity + "개 × " + entry.count + "박스)</span>" : "") +
"</li>";
}).join("") + "</ul>";
}
// ── ③ 센터 분배 렌더 ──────────────────────────────
function renderDist() {
if (!distList) return;
if (distEmpty) distEmpty.hidden = openCenters.length > 0;
var html = "";
openCenters.forEach(function (cid) {
var rows = alloc[cid] || [];
var boxes = 0, pieces = 0, items = "";
rows.forEach(function (a) {
var per = units[a.key] || 0;
boxes += a.count;
pieces += a.count * per;
items += '<li class="cpg-dist-item" data-entry="' + a.id + '">' +
'<div class="cpg-dist-line">' +
'<span class="cpg-dist-name">' + esc(labels[a.key] || a.key) + "</span>" +
'<input class="erp-input cpg-dist-qty" type="number" min="1" max="999" step="1" inputmode="numeric" value="' + a.count + '" />' +
'<span class="cpg-dist-unit">박스 · ' + (a.count * per) + "개</span>" +
'<button type="button" class="erp-btn erp-btn-danger cpg-dist-del" title="빼기" aria-label="빼기">✕</button>' +
"</div>" +
treeHtml(a) +
"</li>";
});
var method = methods[String(cid)] || "";
html += '<div class="cpg-dist-center' + (rows.length ? " has-items" : "") + '" data-drop-for="' + esc(cid) + '">' +
'<div class="cpg-dist-head">' +
"<strong>" + esc(centerName(cid)) + "</strong>" +
'<select class="erp-select cpg-dist-method' + (method ? "" : " is-unset") + '"' +
' data-method-for="' + esc(cid) + '" aria-label="출고방식">' +
'<option value=""' + (method ? "" : " selected") + '>출고방식</option>' +
'<option value="택배"' + (method === "택배" ? " selected" : "") + '>택배</option>' +
'<option value="파렛트"' + (method === "파렛트" ? " selected" : "") + '>파렛트</option>' +
"</select>" +
'<span class="cpg-dist-sum">' +
'<span class="erp-badge erp-badge-neutral">' + boxes + "박스</span>" +
'<span class="erp-badge erp-badge-neutral">' + pieces + "개</span>" +
'<button type="button" class="erp-btn erp-btn-outline cpg-dist-close" title="센터 빼기" aria-label="센터 빼기">✕</button>' +
"</span>" +
"</div>" +
(rows.length
? '<ul class="cpg-dist-items">' + items + "</ul>"
: '<p class="cpg-dist-hint">여기로 박스를 끌어다 놓기</p>') +
"</div>";
});
distList.innerHTML = html;
syncConfirm();
}
function render() { renderSummary(); renderDist(); }
// ── 수량 대화상자 ─────────────────────────────────
function openDialog(key, centerId) {
if (!openCenters.length) { msg.textContent = "먼저 ③ 에서 센터를 추가하세요."; return; }
var remain = remainFor(key);
if (remain <= 0) { msg.textContent = "남은 박스가 없습니다."; return; }
pending = { key: key };
dlgItem.textContent = (labels[key] || key) + " — 잔여 " + remain + "박스 (" + (remain * (units[key] || 0)) + "개)";
dlgCenterId = String(centerId != null ? centerId : openCenters[0]);
renderDialogCenters();
dlgQty.max = remain;
dlgQty.value = key.indexOf("m:") === 0 ? 1 : remain;
dlgHint.textContent = "1박스 = " + (units[key] || 0) + "개 · 최대 " + remain + "박스";
dlg.hidden = false;
dlgQty.focus();
dlgQty.select();
}
function renderDialogCenters() {
dlgCenters.innerHTML = openCenters.map(function (cid) {
var on = String(cid) === String(dlgCenterId);
return '<button type="button" class="erp-btn ' + (on ? "erp-btn-primary" : "erp-btn-outline") +
' cpg-dlg-center-btn" data-center="' + esc(cid) + '"' + (on ? ' aria-pressed="true"' : '') + ">" +
esc(centerName(cid)) + "</button>";
}).join("");
}
dlgCenters.addEventListener("click", function (e) {
var btn = e.target.closest(".cpg-dlg-center-btn");
if (!btn) return;
dlgCenterId = btn.getAttribute("data-center");
renderDialogCenters();
});
function closeDialog() { dlg.hidden = true; pending = null; }
function commitDialog(all) {
if (!pending) return;
var key = pending.key;
var cid = dlgCenterId;
var want = all ? remainFor(key) : parseInt(dlgQty.value, 10);
var got = addAlloc(cid, key, want);
closeDialog();
render();
msg.textContent = got
? centerName(cid) + " ← " + (labels[key] || key) + " " + got + "박스"
: "담을 수량이 없습니다.";
}
dlgOk.addEventListener("click", function () { commitDialog(false); });
dlgAll.addEventListener("click", function () { commitDialog(true); });
dlg.addEventListener("click", function (e) {
if (e.target.hasAttribute && e.target.hasAttribute("data-dlg-close")) closeDialog();
});
dlgQty.addEventListener("keydown", function (e) {
if (e.key === "Enter") { e.preventDefault(); commitDialog(false); }
});
document.addEventListener("keydown", function (e) {
if (e.key === "Escape" && !dlg.hidden) closeDialog();
});
// ── 드래그 (마우스 이벤트 기반) ───────────────────
// HTML5 네이티브 DnD(draggable + dragstart)는 브라우저에 따라 드래그가
// 아예 시작되지 않는다(웨일에서 미리보기조차 안 생김) → 직접 구현한다.
// · mousedown 후 4px 이상 움직이면 드래그 시작, 카드 복제본(고스트)이 커서를 따라온다
// · 커서 아래 요소를 elementFromPoint 로 찾아 센터 패널을 강조
// · mouseup 위치의 센터에 놓으면 수량 대화상자가 열린다
// · 드래그 없이 클릭하면 첫 번째 센터를 기본값으로 대화상자가 열린다
var drag = null; // {key, card, ghost, startX, startY, moved}
var suppressClickOn = null; // 드래그로 끝난 카드의 잔여 click 1회 무시
var DRAG_THRESHOLD = 4;
function clearOver() {
if (!distList) return;
Array.prototype.forEach.call(distList.querySelectorAll(".is-over"), function (el) {
el.classList.remove("is-over");
});
}
function zoneAt(x, y) {
if (!distList) return null;
var el = document.elementFromPoint ? document.elementFromPoint(x, y) : null;
var zone = el && el.closest ? el.closest("[data-drop-for]") : null;
if (zone) return zone;
// ③ 영역 안(패널 사이 여백 포함)에서 놓았고 센터가 하나뿐이면 그 센터로.
if (el && el.closest && el.closest(".cpg-dist-card") && openCenters.length === 1) {
return distList.querySelector("[data-drop-for]");
}
return null;
}
function moveGhost(x, y) {
if (!drag || !drag.ghost) return;
drag.ghost.style.left = (x + 12) + "px";
drag.ghost.style.top = (y + 12) + "px";
}
function startDrag(card, x, y) {
var ghost = card.cloneNode(true);
ghost.className = card.className + " cpg-drag-ghost";
ghost.removeAttribute("style");
ghost.style.width = card.offsetWidth ? card.offsetWidth + "px" : "240px";
document.body.appendChild(ghost);
drag.ghost = ghost;
drag.moved = true;
card.classList.add("is-dragging");
document.body.classList.add("cpg-dragging");
if (distCard) distCard.classList.add("is-drop-ready");
moveGhost(x, y);
}
function endDrag(commit, x, y, upTarget) {
if (!drag) return;
var key = drag.key, moved = drag.moved, card = drag.card;
if (drag.ghost && drag.ghost.parentNode) drag.ghost.parentNode.removeChild(drag.ghost);
if (drag.card) drag.card.classList.remove("is-dragging");
document.body.classList.remove("cpg-dragging");
if (distCard) distCard.classList.remove("is-drop-ready");
clearOver();
drag = null;
if (!moved) return; // 움직이지 않았으면 클릭으로 처리
// 카드 위에서 손을 뗀 경우에만 뒤따르는 click 을 1회 무시한다.
suppressClickOn = (card && upTarget && card.contains(upTarget)) ? card : null;
if (!commit) return;
var zone = zoneAt(x, y);
if (!zone) { msg.textContent = "센터 위에 놓아야 담깁니다."; return; }
openDialog(key, zone.getAttribute("data-drop-for"));
}
sumBody.addEventListener("mousedown", function (e) {
if (e.button !== 0) return;
var card = e.target.closest("[data-key]");
if (!card || card.classList.contains("is-done")) return;
drag = { key: card.getAttribute("data-key"), card: card, startX: e.clientX, startY: e.clientY, moved: false, ghost: null };
e.preventDefault(); // 텍스트 선택 방지
});
document.addEventListener("mousemove", function (e) {
if (!drag) return;
if (!drag.moved) {
if (Math.abs(e.clientX - drag.startX) < DRAG_THRESHOLD &&
Math.abs(e.clientY - drag.startY) < DRAG_THRESHOLD) return;
startDrag(drag.card, e.clientX, e.clientY);
}
moveGhost(e.clientX, e.clientY);
var zone = zoneAt(e.clientX, e.clientY);
clearOver();
if (zone) zone.classList.add("is-over");
});
document.addEventListener("mouseup", function (e) {
if (!drag) return;
endDrag(true, e.clientX, e.clientY, e.target);
});
document.addEventListener("keydown", function (e) {
if (e.key === "Escape" && drag) endDrag(false, 0, 0, null);
});
// 드래그하지 않고 클릭한 경우 — 첫 센터를 기본값으로 대화상자를 연다.
// (드래그를 끝낸 카드에서 브라우저가 한 번 더 쏘는 click 은 1회만 무시)
sumBody.addEventListener("click", function (e) {
var card = e.target.closest("[data-key]");
if (!card) return;
if (card === suppressClickOn) { suppressClickOn = null; return; }
openDialog(card.getAttribute("data-key"), 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");
var cid = e.target.closest("[data-drop-for]").getAttribute("data-drop-for");
var entry = (alloc[cid] || []).filter(function (a) { return String(a.id) === li.getAttribute("data-entry"); })[0];
if (!entry) return;
var want = parseInt(e.target.value, 10);
if (isNaN(want) || want < 1) want = 1;
var max = entry.count + remainFor(entry.key);
if (want > max) { want = max; e.target.value = max; }
entry.count = want;
li.querySelector(".cpg-dist-unit").textContent = "박스 · " + (want * (units[entry.key] || 0)) + "개";
var tree = li.querySelector(".cpg-dist-tree");
if (tree) tree.outerHTML = treeHtml(entry);
renderSummary();
updateCenterTotals(cid);
syncConfirm();
});
distList.addEventListener("click", function (e) {
var zone = e.target.closest("[data-drop-for]");
if (!zone) return;
var cid = zone.getAttribute("data-drop-for");
if (e.target.closest(".cpg-dist-close")) { // 센터 자체를 목록에서 뺀다
openCenters = openCenters.filter(function (x) { return x !== cid; });
delete alloc[cid];
delete methods[cid];
render();
return;
}
if (e.target.closest(".cpg-dist-del")) { // 담은 항목 제거 → 잔여로 복귀
var li = e.target.closest(".cpg-dist-item");
alloc[cid] = (alloc[cid] || []).filter(function (a) { return String(a.id) !== li.getAttribute("data-entry"); });
render();
}
});
}
function updateCenterTotals(cid) {
var zone = distList.querySelector('[data-drop-for="' + cid + '"]');
if (!zone) return;
var boxes = 0, pieces = 0;
(alloc[cid] || []).forEach(function (a) {
boxes += a.count;
pieces += a.count * (units[a.key] || 0);
});
var badges = zone.querySelectorAll(".cpg-dist-sum .erp-badge");
if (badges[0]) badges[0].textContent = boxes + "박스";
if (badges[1]) badges[1].textContent = pieces + "개";
}
if (centerPick) {
// 목록에서 센터를 고르는 즉시 ③ 에 추가한다(별도 추가 버튼 없음).
centerPick.addEventListener("change", function () {
var cid = centerPick.value;
centerPick.value = ""; // 같은 센터를 다시 고를 수 있게 초기화
if (!cid) return;
if (openCenters.indexOf(cid) >= 0) { msg.textContent = "이미 추가된 센터입니다."; return; }
openCenters.push(cid);
renderDist();
msg.textContent = centerName(cid) + " 추가됨";
});
}
// ── 입력 표 이벤트 ────────────────────────────────
tbody.addEventListener("click", function (e) {
if (!e.target.closest(".cpg-calc-del")) return;
if (rowsArr().length <= 1) addRow("", 0);
e.target.closest("tr").remove();
msg.textContent = "";
});
tbody.addEventListener("input", function (e) {
if (e.target.classList.contains("cpg-calc-qty")) msg.textContent = "수량 변경됨 — 재계산하세요.";
});
tbody.addEventListener("change", function (e) {
if (e.target.classList.contains("cpg-calc-name")) msg.textContent = "제품 변경됨 — 재계산하세요.";
});
// 수량 칸: Tab = 다음 행 수량(마지막 행이면 새 행 추가), Enter = 계산
tbody.addEventListener("keydown", function (e) {
if (!e.target.classList.contains("cpg-calc-qty")) return;
if (e.key === "Enter") { e.preventDefault(); run(); return; }
if (e.key !== "Tab" || e.shiftKey) return;
e.preventDefault();
var rows = rowsArr();
var idx = rows.indexOf(e.target.closest("tr"));
var next = rows[idx + 1] || addRow("", 0);
var input = next.querySelector(".cpg-calc-qty");
input.focus();
input.select();
});
runBtn.addEventListener("click", run);
addBtn.addEventListener("click", function () {
addRow("", 0).querySelector(".cpg-calc-name").focus();
});
resetBtn.addEventListener("click", function () {
tbody.innerHTML = "";
addRow("", 0);
calc = { results: [], mixes: [] };
alloc = {};
render();
msg.textContent = "";
});
// ── 분배 확정 ─────────────────────────────────────
var confirmBtn = document.getElementById("cpg-confirm");
var cfmDlg = document.getElementById("cpg-cfm-dlg");
var cfmOk = document.getElementById("cpg-cfm-ok");
var cfmMsg = document.getElementById("cpg-cfm-msg");
var cfmPick = document.getElementById("cpg-cfm-pick");
var cfmList = document.getElementById("cpg-cfm-list");
var calGrid = document.getElementById("cpg-cal-grid");
var calLabel = document.getElementById("cpg-cal-label");
var picked = ""; // 선택한 출고일 (YYYY-MM-DD)
var calY = 0, calM = 0; // 달력이 보여주는 연/월
function pad2(n) { return (n < 10 ? "0" : "") + n; }
function iso(y, m, d) { return y + "-" + pad2(m + 1) + "-" + pad2(d); }
// 배분이 남았는지 — ② 요약과 같은 기준
function boxTotals() {
var total = 0, remain = 0;
calc.results.forEach(function (r) {
total += r.full_boxes;
remain += remainFor(prodKey(r.product_code));
});
calc.mixes.forEach(function (m) {
m.boxes.forEach(function (b, i) {
total += 1;
remain += remainFor(mixKey(m.box_name, i));
});
});
return { total: total, remain: remain };
}
function filledCenters() {
return openCenters.filter(function (cid) { return (alloc[cid] || []).length > 0; });
}
// 확정 가능 여부 + 왜 안 되는지 사유
function confirmBlockReason() {
var t = boxTotals();
if (!t.total) return "먼저 ① 에서 계산하세요.";
if (t.remain) return "아직 " + t.remain + "박스가 남았습니다.";
var cids = filledCenters();
if (!cids.length) return "센터에 담긴 박스가 없습니다.";
var missing = cids.filter(function (cid) { return !methods[String(cid)]; });
if (missing.length) return "출고방식 미선택: " + missing.map(centerName).join(", ");
return "";
}
function syncConfirm() {
if (!confirmBtn) return;
var why = confirmBlockReason();
confirmBtn.disabled = !!why;
confirmBtn.title = why || "출고일자를 골라 출고 묶음을 만듭니다.";
}
// 센터별 전송 payload — 혼합 박스는 내용물 제품으로 풀어서 담는다.
function buildPlans() {
return filledCenters().map(function (cid) {
var rows = alloc[cid] || [];
var items = [];
var boxes = 0;
rows.forEach(function (a) {
boxes += a.count;
if (a.key.indexOf("p:") === 0) {
items.push({ product_code: a.key.slice(2), quantity: a.count * (units[a.key] || 0) });
} else {
(contents[a.key] || []).forEach(function (it) {
items.push({ product_code: it.product_code, quantity: it.quantity * a.count });
});
}
});
return {
center_id: cid,
ship_method: methods[String(cid)] || "",
boxes: boxes,
items: items
};
});
}
function renderCal() {
if (!calGrid) return;
calLabel.textContent = calY + "년 " + (calM + 1) + "월";
var first = new Date(calY, calM, 1).getDay();
var days = new Date(calY, calM + 1, 0).getDate();
var now = new Date();
var todayIso = iso(now.getFullYear(), now.getMonth(), now.getDate());
var html = "";
for (var i = 0; i < first; i++) html += '<span class="cpg-cal-cell is-blank"></span>';
for (var d = 1; d <= days; d++) {
var v = iso(calY, calM, d);
var dow = (first + d - 1) % 7;
html += '<button type="button" class="cpg-cal-cell' +
(v === picked ? " is-picked" : "") +
(v === todayIso ? " is-today" : "") +
(dow === 0 ? " is-sun" : (dow === 6 ? " is-sat" : "")) +
'" data-day="' + v + '">' + d + "</button>";
}
calGrid.innerHTML = html;
cfmPick.textContent = picked ? "출고일: " + picked : "달력에서 출고일자를 고르세요.";
cfmOk.disabled = !picked;
}
function openConfirm() {
var why = confirmBlockReason();
if (why) { msg.textContent = why; return; }
var t = new Date();
picked = iso(t.getFullYear(), t.getMonth(), t.getDate());
calY = t.getFullYear();
calM = t.getMonth();
cfmMsg.textContent = "";
cfmList.innerHTML = buildPlans().map(function (pl) {
var pieces = pl.items.reduce(function (n, it) { return n + it.quantity; }, 0);
return '<div class="cpg-cfm-row">' +
'<span class="cpg-cfm-cname">' + esc(centerName(pl.center_id)) + "</span>" +
'<span class="cpg-cfm-cmeta">' + esc(pl.ship_method) + " · " + pl.boxes + "박스 · " + pieces + "개</span>" +
"</div>";
}).join("");
renderCal();
cfmDlg.hidden = false;
cfmOk.focus();
}
function closeConfirm() { cfmDlg.hidden = true; }
function submitConfirm() {
if (!picked) return;
var plans = buildPlans();
if (!plans.length) { cfmMsg.textContent = "담긴 품목이 없습니다."; return; }
cfmOk.disabled = true;
cfmMsg.textContent = "저장 중…";
fetch("/cupang/api/box-calc/confirm", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ship_date: picked, centers: plans })
})
.then(function (r) {
if (!r.ok) {
return r.json().catch(function () { return {}; }).then(function (e) {
throw new Error(e.detail || "http " + r.status);
});
}
return r.json();
})
.then(function (data) {
var d = (data && data.ship_date) || picked;
var parts = d.split("-");
// 달력으로 이동해 방금 만든 출고 묶음을 보여준다.
window.location.href = "/cupang/?year=" + parts[0] + "&month=" + parseInt(parts[1], 10) + "&date=" + d;
})
.catch(function (err) {
cfmMsg.textContent = (err && err.message) || "확정 실패 — 다시 시도하세요.";
cfmOk.disabled = false;
});
}
if (confirmBtn) confirmBtn.addEventListener("click", openConfirm);
if (cfmDlg) {
Array.prototype.forEach.call(cfmDlg.querySelectorAll("[data-cfm-close]"), function (el) {
el.addEventListener("click", closeConfirm);
});
calGrid.addEventListener("click", function (e) {
var cell = e.target.closest("[data-day]");
if (!cell) return;
picked = cell.getAttribute("data-day");
renderCal();
});
document.getElementById("cpg-cal-prev").addEventListener("click", function () {
calM -= 1; if (calM < 0) { calM = 11; calY -= 1; } renderCal();
});
document.getElementById("cpg-cal-next").addEventListener("click", function () {
calM += 1; if (calM > 11) { calM = 0; calY += 1; } renderCal();
});
cfmOk.addEventListener("click", submitConfirm);
}
document.addEventListener("keydown", function (e) {
if (e.key === "Escape" && cfmDlg && !cfmDlg.hidden) closeConfirm();
});
// 센터 헤더의 출고방식 선택
if (distList) {
distList.addEventListener("change", function (e) {
var sel = e.target.closest("[data-method-for]");
if (!sel) return;
var cid = String(sel.getAttribute("data-method-for"));
if (sel.value) { methods[cid] = sel.value; } else { delete methods[cid]; }
sel.classList.toggle("is-unset", !sel.value);
syncConfirm();
});
}
// ── 임시 저장 / 불러오기 ──────────────────────────
var saveBtn = document.getElementById("cpg-calc-save");
var loadBtn = document.getElementById("cpg-calc-load");
var draftDlg = document.getElementById("cpg-draft-dlg");
var draftName = document.getElementById("cpg-draft-name");
var draftSave = document.getElementById("cpg-draft-save");
var draftMsg = document.getElementById("cpg-draft-msg");
var draftList = document.getElementById("cpg-draft-list");
function snapshot() {
return {
items: collect(),
openCenters: openCenters.slice(),
alloc: alloc,
methods: methods,
stamped: stamped,
allStamped: allStamped
};
}
// 불러온 분배 내역을 현재 계산 결과 위에 다시 얹는다.
// 계산이 달라져 사라진 항목은 건너뛰고 그 건수를 돌려준다.
function applyRestore(snap) {
var skipped = 0;
openCenters = [];
(snap.openCenters || []).forEach(function (cid) {
cid = String(cid);
if (openCenters.indexOf(cid) < 0) openCenters.push(cid);
});
var src = snap.alloc || {};
Object.keys(src).forEach(function (cid) {
(src[cid] || []).forEach(function (a) {
if (!a || !(a.key in units)) { skipped += 1; return; }
if (openCenters.indexOf(String(cid)) < 0) openCenters.push(String(cid));
if (addAlloc(cid, a.key, a.count) <= 0) skipped += 1;
});
});
methods = snap.methods && typeof snap.methods === "object" ? snap.methods : {};
stamped = snap.stamped && typeof snap.stamped === "object" ? snap.stamped : {};
allStamped = !!snap.allStamped;
return skipped;
}
var DOW_KO = ["일", "월", "화", "수", "목", "금", "토"];
function defaultTitle() {
var d = new Date();
var h = d.getHours();
var ampm = h < 12 ? "오전" : "오후";
var h12 = h % 12 || 12;
return d.getFullYear() + "." + pad2(d.getMonth() + 1) + "." + pad2(d.getDate()) +
"(" + DOW_KO[d.getDay()] + ") " + ampm + " " + pad2(h12) + "시 " + pad2(d.getMinutes()) + "분";
}
function closeDraft() { draftDlg.hidden = true; }
function openDraft(mode) {
draftMsg.textContent = "";
document.getElementById("cpg-draft-title").textContent =
mode === "load" ? "저장된 내용 불러오기" : "임시 저장";
if (mode !== "load") draftName.value = defaultTitle();
draftDlg.hidden = false;
loadDrafts();
if (mode !== "load") { draftName.focus(); draftName.select(); }
}
function loadDrafts() {
draftList.innerHTML = '<p class="erp-muted">불러오는 중…</p>';
fetch("/cupang/api/box-calc/drafts")
.then(function (r) { if (!r.ok) throw new Error("http " + r.status); return r.json(); })
.then(function (data) {
var rows = (data && data.drafts) || [];
if (!rows.length) {
draftList.innerHTML = '<p class="erp-muted">저장된 내용이 없습니다.</p>';
return;
}
draftList.innerHTML = rows.map(function (d) {
return '<div class="cpg-draft-row">' +
'<span class="cpg-draft-rname">' + esc(d.title) + "</span>" +
'<span class="cpg-draft-rmeta">' + esc((d.updated_at || "").replace("T", " ").slice(5, 16)) +
(d.created_by ? " · " + esc(d.created_by) : "") + "</span>" +
'<button type="button" class="erp-btn erp-btn-outline cpg-btn-sm" data-draft-load="' + d.id + '">불러오기</button>' +
'<button type="button" class="erp-btn erp-btn-danger cpg-btn-sm" data-draft-del="' + d.id + '">삭제</button>' +
"</div>";
}).join("");
})
.catch(function () { draftList.innerHTML = '<p class="erp-muted">목록을 불러오지 못했습니다.</p>'; });
}
function doSave() {
var title = (draftName.value || "").trim();
if (!title) { draftMsg.textContent = "제목을 입력하세요."; draftName.focus(); return; }
draftSave.disabled = true;
draftMsg.textContent = "저장 중…";
fetch("/cupang/api/box-calc/drafts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: title, payload: snapshot() })
})
.then(function (r) { if (!r.ok) throw new Error("http " + r.status); return r.json(); })
.then(function () {
draftMsg.textContent = "저장됨 (같은 제목은 덮어씀)";
msg.textContent = '"' + title + '" 임시 저장됨';
loadDrafts();
})
.catch(function () { draftMsg.textContent = "저장 실패 — 다시 시도하세요."; })
.then(function () { draftSave.disabled = false; });
}
function doLoad(id) {
draftMsg.textContent = "불러오는 중…";
fetch("/cupang/api/box-calc/drafts/" + id)
.then(function (r) { if (!r.ok) throw new Error("http " + r.status); return r.json(); })
.then(function (data) {
var d = (data && data.draft) || {};
var snap = d.payload || {};
var items = snap.items || [];
tbody.innerHTML = "";
items.forEach(function (it) { addRow(it.product_code, it.quantity); });
if (!items.length) addRow("", 0);
draftName.value = d.title || "";
closeDraft();
// 박스 수는 서버에서 다시 계산한 뒤 분배 내역을 얹는다.
run(snap);
})
.catch(function () { draftMsg.textContent = "불러오기 실패 — 다시 시도하세요."; });
}
function doDelete(id) {
fetch("/cupang/api/box-calc/drafts/" + id, { method: "DELETE" })
.then(function (r) { if (!r.ok) throw new Error("http " + r.status); return r.json(); })
.then(function () { draftMsg.textContent = "삭제됨"; loadDrafts(); })
.catch(function () { draftMsg.textContent = "삭제 실패 — 다시 시도하세요."; });
}
if (saveBtn) saveBtn.addEventListener("click", function () { openDraft("save"); });
if (loadBtn) loadBtn.addEventListener("click", function () { openDraft("load"); });
if (draftSave) draftSave.addEventListener("click", doSave);
if (draftName) {
draftName.addEventListener("keydown", function (e) {
if (e.key === "Enter") { e.preventDefault(); doSave(); }
});
}
if (draftDlg) {
Array.prototype.forEach.call(draftDlg.querySelectorAll("[data-draft-close]"), function (el) {
el.addEventListener("click", closeDraft);
});
draftDlg.addEventListener("click", function (e) {
var l = e.target.closest("[data-draft-load]");
if (l) { doLoad(l.getAttribute("data-draft-load")); return; }
var x = e.target.closest("[data-draft-del]");
if (x) { doDelete(x.getAttribute("data-draft-del")); }
});
}
document.addEventListener("keydown", function (e) {
if (e.key === "Escape" && draftDlg && !draftDlg.hidden) closeDraft();
});
addRow("", 0);
render();
})();
</script>
{% endif %}
</section>
{% endblock %}