Files
dbx-main/app/modules/cupang/templates/cupang/box_calc.html
T
king f781204094 fix(cupang): 매출 조회 조건이 세로로 쌓이던 문제, 주차 없음 안내
- .cpg > form { flex-direction: column } 에 눌려 세로 배치되던 것을
  form.cpg-sales-filter 로 가로(row) 고정, 높이도 내용만큼만
- 고른 주차에 자료가 없으면 "<주차> 주차에 등록된 매출 자료가 없습니다" 표시
- 주차 목록은 최근 주차(출고일 최댓값 기준)부터

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 16:18:37 +09:00

1857 lines
80 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=20260904c" />{% 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>
</div>
<!-- 쿠팡 발주서(xlsx) 업로드 → 센터별 수량 합산 → ②③ 자동 채움 -->
<div class="cpg-po-box">
<div class="cpg-po-row">
<label class="erp-btn erp-btn-primary cpg-po-pick" for="cpg-po-file">쿠팡 발주파일 업로드</label>
<input class="cpg-po-file" type="file" id="cpg-po-file"
accept=".xlsx,.xlsm" multiple aria-label="쿠팡 발주 엑셀 선택" />
<span class="erp-muted cpg-po-msg" id="cpg-po-msg"></span>
</div>
<div class="cpg-po-result" id="cpg-po-result" hidden></div>
</div>
<h3 class="cpg-sum-h3">출고일 <span class="erp-muted">업로드한 발주서 기준 · 날짜를 골라 확정합니다</span></h3>
<div class="cpg-date-list" id="cpg-date-list"></div>
<p class="erp-muted" id="cpg-date-empty">발주 파일을 업로드하면 출고일이 여기에 표시됩니다.</p>
<p class="erp-muted cpg-calc-msg" id="cpg-calc-msg"></p>
</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>
<div class="cpg-sum-scroll">
<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>
<!-- ③ 센터 분배 — 선택한 센터만 표시 -->
<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">
등록된 입고센터가 없습니다. 관리자에게 문의하세요.
</p>
{% else %}
<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-low-dlg" hidden>
<div class="cpg-modal-back" data-low-close></div>
<div class="cpg-modal-box cpg-low-box" role="dialog" aria-modal="true" aria-labelledby="cpg-low-title">
<h3 id="cpg-low-title">상자 변경 필요</h3>
<p class="cpg-low-lead">
아래 혼합 상자는 채움률이 60% 이하입니다. 더 작은 상자로 바꾸거나 내용물을 합치세요.
</p>
<ul class="cpg-low-list" id="cpg-low-list"></ul>
<div class="cpg-dlg-actions">
<button type="button" class="erp-btn erp-btn-primary" data-low-close>확인</button>
</div>
</div>
</div>
<!-- 저장 중 -->
<div class="cpg-modal cpg-saving" id="cpg-saving" hidden>
<div class="cpg-modal-back"></div>
<div class="cpg-modal-box cpg-saving-box" role="alertdialog" aria-live="assertive"
aria-labelledby="cpg-saving-title">
<div class="cpg-spinner" aria-hidden="true"></div>
<strong id="cpg-saving-title">저장 중…</strong>
<span class="erp-muted" id="cpg-saving-note"></span>
</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 msg = document.getElementById("cpg-calc-msg");
if (!msg) return;
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; }
// 혼합 상자 키는 위치가 아니라 상자마다 붙는 고유 id 로 잡는다.
// (상자를 새로 만들거나 지워도 이미 담긴 배분이 어긋나지 않게)
var mixSeq = 0;
function mixKeyFor(b) {
if (!b.uid) { mixSeq += 1; b.uid = "b" + mixSeq; }
return "m:" + b.uid;
}
// 자투리 혼합 상자에 쓸 상자 종류 — 요약 카드에서 고른다(표시용).
var MIX_TYPES = ["쿠팡상자", "3호상자", "6호상자"];
var mixType = {}; // mixKey -> 고른 상자 종류
var mixOwner = {}; // mixKey -> 센터명(업로드로 만들어진 경우)
function mixTypeOf(key) { return mixType[key] || MIX_TYPES[0]; }
function mixLabel(key, idx) {
return mixTypeOf(key) + " 혼합 #" + (idx + 1) +
(mixOwner[key] ? " · " + mixOwner[key] : "");
}
function mixTypeSelect(key) {
var cur = mixTypeOf(key);
return '<select class="erp-select cpg-box-type" data-type-for="' + esc(key) + '"' +
' aria-label="상자 종류">' +
MIX_TYPES.map(function (t) {
return '<option value="' + esc(t) + '"' + (t === cur ? " selected" : "") + ">" + esc(t) + "</option>";
}).join("") + "</select>";
}
function centerName(cid) {
var hit = centers.filter(function (c) { return String(c.id) === String(cid); })[0];
return hit ? hit.name : cid;
}
// ── 계산 ─────────────────────────────────────────
// 품목은 업로드(또는 임시 저장 불러오기)로만 들어온다. 직접 입력 표는 없앴다.
function currentItems() {
return calc.results.map(function (r) {
return { product_code: r.product_code, quantity: r.quantity };
});
}
function run(restore, items) {
var list = items || currentItems();
if (!list.length) { msg.textContent = "먼저 쿠팡 발주 파일을 업로드하세요."; return; }
msg.textContent = "계산 중…";
fetch("/cupang/api/box-calc", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ items: list })
})
.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;
});
mixOwner = {};
calc.mixes.forEach(function (m) {
m.boxes.forEach(function (b, i) {
var k = mixKeyFor(b);
var n = 0;
b.items.forEach(function (it) { n += it.quantity; });
units[k] = n;
labels[k] = mixLabel(k, i);
contents[k] = b.items.map(function (it) {
return { product_code: it.product_code, product_name: it.product_name, quantity: it.quantity };
});
});
});
alloc = {};
stamped = {};
allStamped = false; // 계산이 바뀌면 이전 배분은 근거가 사라지므로 비운다.
var skipped = restore ? applyRestore(restore) : 0;
render();
if (activeDate) { saveActive(); }
renderTabs();
msg.textContent = restore
? "불러오기 완료 (" + calc.results.length + "건)" +
(skipped ? " — 담을 수 없는 " + skipped + "건은 제외" : "")
: "계산 완료 (" + calc.results.length + "건)";
warnLowMixes();
})
.catch(function () { msg.textContent = "계산 실패 — 새로고침 후 다시 시도하세요."; });
}
// ── 쿠팡 발주 엑셀 업로드 ─────────────────────────
// 업로드 → 센터별 수량 합산(서버) → 센터마다 상자 계산 → ②③ 자동 채움.
// 상자는 센터별로 포장하므로 계산도 센터 단위로 한다(자투리 혼합 상자 포함).
var poFile = document.getElementById("cpg-po-file");
var poMsg = document.getElementById("cpg-po-msg");
var poResult = document.getElementById("cpg-po-result");
var uploadShipDate = ""; // F13 − 1일 (확정 대화상자 기본값)
function calcForItems(items) {
return 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();
});
}
// ── 출고일별 작업 세트 ────────────────────────────
// 업로드한 발주서의 출고일이 다르면 날짜마다 ①②③ 상태를 따로 들고,
// 탭으로 전환한다. 확정은 현재 보고 있는 날짜만 대상으로 한다.
var sets = {}; // "YYYY-MM-DD" -> 상태 스냅샷
var dateOrder = []; // 탭 순서
var activeDate = ""; // "" = 업로드 전(수동 입력 모드)
var tabsList = document.getElementById("cpg-date-list");
var tabsEmpty = document.getElementById("cpg-date-empty");
function centersOf(allocObj) {
return Object.keys(allocObj || {}).filter(function (cid) {
return (allocObj[cid] || []).length > 0;
});
}
function boxesOf(allocObj) {
var n = 0;
Object.keys(allocObj || {}).forEach(function (cid) {
(allocObj[cid] || []).forEach(function (a) { n += a.count; });
});
return n;
}
var DOW = ["일", "월", "화", "수", "목", "금", "토"];
function dateLabel(d) {
var p = (d || "").split("-");
if (p.length !== 3) return d || "날짜 미확인";
var dt = new Date(+p[0], +p[1] - 1, +p[2]);
return d + "(" + DOW[dt.getDay()] + ")";
}
function renderTabs() {
if (!tabsList) return;
if (tabsEmpty) tabsEmpty.hidden = dateOrder.length > 0;
if (dateOrder.length < 1) { tabsList.innerHTML = ""; return; }
tabsList.innerHTML = dateOrder.map(function (d) {
var st = sets[d] || {};
var cn = centersOf(st.alloc).length;
var bx = boxesOf(st.alloc);
var qty = 0;
(st.items || []).forEach(function (it) { qty += it.quantity; });
return '<button type="button" class="cpg-date-tab' + (d === activeDate ? " is-active" : "") + '"' +
' data-date="' + esc(d) + '">' +
'<strong>' + esc(dateLabel(d)) + "</strong>" +
'<span>센터 ' + cn + "곳 · " + bx + "상자 · " + qty + "개</span>" +
"</button>";
}).join("");
}
// 현재 화면 상태를 활성 날짜 세트에 저장
function saveActive() {
if (!activeDate) return;
sets[activeDate] = {
items: currentItems(),
calc: { results: calc.results, mixes: calc.mixes },
units: units, labels: labels, contents: contents,
openCenters: openCenters.slice(), alloc: alloc, methods: methods,
stamped: stamped, allStamped: allStamped,
mixType: mixType, mixOwner: mixOwner
};
}
// 날짜 세트를 화면(①②③)에 올린다
function loadDate(d) {
var st = sets[d];
if (!st) return;
activeDate = d;
uploadShipDate = d;
calc.results = st.calc.results;
calc.mixes = st.calc.mixes;
units = st.units; labels = st.labels; contents = st.contents;
openCenters = st.openCenters.slice();
alloc = st.alloc; methods = st.methods;
stamped = st.stamped; allStamped = st.allStamped;
mixType = st.mixType || {}; mixOwner = st.mixOwner || {};
render();
renderTabs();
warnLowMixes();
}
if (tabsList) {
tabsList.addEventListener("click", function (e) {
var btn = e.target.closest("[data-date]");
if (!btn) return;
var d = btn.getAttribute("data-date");
if (d === activeDate) return;
saveActive();
loadDate(d);
msg.textContent = "출고일 " + (d || "미확인") + " 작업으로 전환했습니다.";
});
}
// 센터별 계산 결과를 하나의 ② 요약으로 합치고, 각 상자를 그 센터에 자동 배분한다.
// (한 출고일 안에서만 합친다 — 상자는 센터 단위로 포장)
function buildDataset(groups, perCenter) {
var mergedProds = {}; // code -> 합산 결과
var mergedMixes = {}; // box_name -> {box_name, boxes:[]}
var st = {
items: [], calc: { results: [], mixes: [] },
units: {}, labels: {}, contents: {},
openCenters: [], alloc: {}, methods: {},
stamped: {}, allStamped: false,
mixType: {}, mixOwner: {}
};
function put(cid, key, count) {
if (count <= 0) return;
st.alloc[cid] = st.alloc[cid] || [];
var same = st.alloc[cid].filter(function (a) { return a.key === key; })[0];
if (same) { same.count += count; return; }
seq += 1;
st.alloc[cid].push({ id: seq, key: key, count: count });
}
groups.forEach(function (g, gi) {
var cid = String(g.center_id);
var data = perCenter[gi] || {};
if (st.openCenters.indexOf(cid) < 0) st.openCenters.push(cid);
(data.results || []).filter(function (r) { return r.configured; }).forEach(function (r) {
var m = mergedProds[r.product_code];
if (!m) {
m = mergedProds[r.product_code] = {
product_code: r.product_code, product_name: r.product_name,
box_name: r.box_name, units_per_box: r.units_per_box,
configured: true, quantity: 0, full_boxes: 0,
remainder_units: 0, required_boxes: 0
};
}
m.quantity += r.quantity;
m.full_boxes += r.full_boxes;
m.remainder_units += r.remainder_units;
m.required_boxes += r.required_boxes;
put(cid, prodKey(r.product_code), r.full_boxes);
});
(data.mixes || []).forEach(function (mx) {
var bucket = mergedMixes[mx.box_name];
if (!bucket) bucket = mergedMixes[mx.box_name] = { box_name: mx.box_name, boxes: [] };
mx.boxes.forEach(function (b) {
var idx = bucket.boxes.length;
bucket.boxes.push(b);
var k = mixKeyFor(b);
st.mixOwner[k] = g.center_name;
put(cid, k, 1);
});
});
});
st.calc.results = Object.keys(mergedProds).map(function (k) { return mergedProds[k]; });
st.calc.mixes = Object.keys(mergedMixes).map(function (k) { return mergedMixes[k]; });
st.calc.results.forEach(function (r) {
st.units[prodKey(r.product_code)] = r.units_per_box;
st.labels[prodKey(r.product_code)] = r.product_name;
});
st.calc.mixes.forEach(function (m) {
m.boxes.forEach(function (b, i) {
var n = 0;
b.items.forEach(function (it) { n += it.quantity; });
var k = mixKeyFor(b);
st.units[k] = n;
st.mixType[k] = MIX_TYPES[0];
st.labels[k] = st.mixType[k] + " 혼합 #" + (i + 1) +
(st.mixOwner[k] ? " · " + st.mixOwner[k] : "");
st.contents[k] = b.items.map(function (it) {
return { product_code: it.product_code, product_name: it.product_name, quantity: it.quantity };
});
});
});
st.items = st.calc.results.slice().sort(function (a, b) {
return String(a.product_name).localeCompare(String(b.product_name), "ko");
}).map(function (r) {
return { product_code: r.product_code, quantity: r.quantity };
});
return st;
}
// 분석 결과 상세는 화면에 두지 않는다. 놓치면 안 되는 경고만 남긴다.
function renderPoResult(data, skipped) {
if (!poResult) return;
var warns = (data.warnings || []).slice();
if (skipped && skipped.length) {
warns.push("등록되지 않은 센터라 자동 배분에서 제외: " + skipped.join(", "));
}
if (!warns.length) { poResult.hidden = true; poResult.innerHTML = ""; return; }
poResult.innerHTML = '<ul class="cpg-po-warn">' + warns.map(function (w) {
return "<li>" + esc(w) + "</li>";
}).join("") + "</ul>";
poResult.hidden = false;
}
function runUpload() {
if (!poFile || !poFile.files || !poFile.files.length) {
poMsg.textContent = "엑셀 파일을 선택하세요."; return;
}
var fd = new FormData();
Array.prototype.forEach.call(poFile.files, function (f) { fd.append("files", f); });
poMsg.textContent = "분석 중… (" + poFile.files.length + "개)";
fetch("/cupang/api/box-calc/upload", { method: "POST", body: fd })
.then(function (r) {
return r.json().catch(function () { return {}; }).then(function (d) {
if (!r.ok) throw new Error(d.detail || "업로드 실패 (http " + r.status + ")");
return d;
});
})
.then(function (data) {
var dateGroups = (data.groups || []).map(function (g) {
return {
ship_date: g.ship_date || "",
centers: (g.centers || []).filter(function (c) { return c.center_id != null; }),
skipped: (g.centers || []).filter(function (c) { return c.center_id == null; })
.map(function (c) { return c.center_name; })
};
}).filter(function (g) { return g.centers.length; });
var allSkipped = [];
(data.groups || []).forEach(function (g) {
(g.centers || []).forEach(function (c) {
if (c.center_id == null && allSkipped.indexOf(c.center_name) < 0) {
allSkipped.push(c.center_name);
}
});
});
if (!dateGroups.length) {
renderPoResult(data, allSkipped);
poMsg.textContent = "배분할 수 있는 센터가 없습니다.";
return;
}
// 날짜 × 센터 단위로 상자 계산 (상자는 센터별로 포장되므로)
var jobs = [];
dateGroups.forEach(function (g) {
g.centers.forEach(function (c) {
jobs.push(calcForItems(c.items.map(function (it) {
return { product_code: it.product_code, quantity: it.quantity };
})));
});
});
return Promise.all(jobs).then(function (all) {
var at = 0;
sets = {}; dateOrder = []; activeDate = "";
dateGroups.forEach(function (g) {
var per = all.slice(at, at + g.centers.length);
at += g.centers.length;
sets[g.ship_date] = buildDataset(g.centers, per);
dateOrder.push(g.ship_date);
});
loadDate(dateOrder[0]);
renderPoResult(data, allSkipped);
var pieces = 0, centerCount = 0;
dateGroups.forEach(function (g) {
centerCount += g.centers.length;
g.centers.forEach(function (c) {
c.items.forEach(function (it) { pieces += it.quantity; });
});
});
poMsg.textContent = "분석 완료 — 출고일 " + dateOrder.length + "개 · 센터 " +
centerCount + "곳 · " + pieces + "개 자동 배분";
msg.textContent = dateOrder.length > 1
? "출고일이 " + dateOrder.length + "개입니다. 위 탭에서 날짜를 골라 확정하세요."
: "업로드 결과로 계산했습니다. 센터마다 출고방식을 고르면 확정할 수 있습니다.";
});
})
.catch(function (err) { poMsg.textContent = (err && err.message) || "업로드 실패"; });
}
// 파일을 고르고 [열기] 를 누르면 곧바로 분석한다(별도 버튼 없음).
if (poFile) {
poFile.addEventListener("change", function () {
if (poFile.files && poFile.files.length) runUpload();
});
}
// ── 배분 집계 ─────────────────────────────────────
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;
}
// ── 혼합 상자 편집 ────────────────────────────────
// 상자 용량은 부피 합으로 본다: 제품 1개 = 1/입수량 상자.
// 빼낸 수량은 센터별 "미배치 자투리" 풀에 남고, 거기서 다시 담는다.
function upbOf(code) {
var hit = calc.results.filter(function (r) { return r.product_code === code; })[0];
return hit ? (hit.units_per_box || 0) : 0;
}
function mixRef(key) {
var found = null;
calc.mixes.forEach(function (m) {
m.boxes.forEach(function (b) { if (mixKeyFor(b) === key) found = { m: m, b: b }; });
});
return found;
}
function mixFill(key) {
var vol = 0;
(contents[key] || []).forEach(function (it) {
var u = upbOf(it.product_code);
if (u) vol += it.quantity / u;
});
return vol;
}
function mixRoomFor(key, code) {
var u = upbOf(code);
if (!u) return 0;
return Math.max(0, Math.floor((1 - mixFill(key)) * u + 1e-9));
}
// 내용물이 바뀐 상자를 ②(요약) 쪽 데이터와 맞춘다. 비면 상자를 없앤다.
function syncMix(key) {
var list = (contents[key] || []).filter(function (it) { return it.quantity > 0; });
contents[key] = list;
var ref = mixRef(key);
if (!list.length) {
if (ref) {
var idx = ref.m.boxes.indexOf(ref.b);
if (idx >= 0) ref.m.boxes.splice(idx, 1);
if (!ref.m.boxes.length) {
calc.mixes = calc.mixes.filter(function (m) { return m !== ref.m; });
}
}
Object.keys(alloc).forEach(function (cid) {
alloc[cid] = (alloc[cid] || []).filter(function (a) { return a.key !== key; });
});
delete units[key]; delete labels[key]; delete contents[key];
delete mixType[key]; delete mixOwner[key]; delete stamped[key];
return;
}
var total = 0;
list.forEach(function (it) { total += it.quantity; });
units[key] = total;
if (ref) {
ref.b.items = list.map(function (it) {
return { product_code: it.product_code, product_name: it.product_name, quantity: it.quantity };
});
ref.b.fill_percent = Math.round(mixFill(key) * 1000) / 10;
}
}
function centerOfKey(key) {
var hit = "";
Object.keys(alloc).forEach(function (cid) {
(alloc[cid] || []).forEach(function (a) { if (a.key === key) hit = String(cid); });
});
return hit;
}
function mixKeysOf(cid) {
return (alloc[String(cid)] || [])
.filter(function (a) { return a.key.indexOf("m:") === 0; })
.map(function (a) { return a.key; });
}
function mixItem(key, code) {
return (contents[key] || []).filter(function (it) { return it.product_code === code; })[0];
}
// 상자에 담기 — 용량은 표시용일 뿐, 이동은 막지 않는다(넘치면 100% 초과 표시).
function mixPut(key, code, name, qty) {
var put = qty;
if (put <= 0) return 0;
var hit = mixItem(key, code);
if (hit) { hit.quantity += put; }
else { (contents[key] = contents[key] || []).push({ product_code: code, product_name: name, quantity: put }); }
syncMix(key);
return put;
}
// 상자에서 덜어내기(내부용) — 호출한 쪽이 곧바로 다른 상자에 담는다.
function mixPull(key, code, qty) {
var hit = mixItem(key, code);
if (!hit) return 0;
var take = Math.min(hit.quantity, qty);
if (take <= 0) return 0;
hit.quantity -= take;
syncMix(key);
return take;
}
// 자리가 있는 다른 상자 찾기 (없으면 "")
function boxWithRoom(cid, code, exceptKey) {
var found = "";
mixKeysOf(cid).forEach(function (k) {
if (found || k === exceptKey) return;
if (mixRoomFor(k, code) > 0) found = k;
});
return found;
}
// 그 제품이 담겨 있는 다른 상자 찾기 (없으면 "")
function boxHaving(cid, code, exceptKey) {
var found = "";
mixKeysOf(cid).forEach(function (k) {
if (found || k === exceptKey) return;
if (mixItem(k, code)) found = k;
});
return found;
}
// 상자 → 상자 이동. 용량과 무관하게 요청한 수량을 옮긴다.
function moveUnits(fromKey, toKey, code, name, qty) {
var pulled = mixPull(fromKey, code, qty);
if (!pulled) return 0;
mixPut(toKey, code, name, pulled);
return pulled;
}
// 새 혼합 상자 (같은 센터, 비어 있는 상태로 추가)
function newMixBox(cid, boxName) {
var name = boxName || (calc.mixes[0] && calc.mixes[0].box_name) || MIX_TYPES[0];
var bucket = calc.mixes.filter(function (m) { return m.box_name === name; })[0];
if (!bucket) { bucket = { box_name: name, boxes: [] }; calc.mixes.push(bucket); }
var b = { items: [], fill_percent: 0 };
bucket.boxes.push(b);
var key = mixKeyFor(b);
contents[key] = [];
units[key] = 0;
mixType[key] = MIX_TYPES[0];
mixOwner[key] = centerName(cid);
labels[key] = mixLabel(key, bucket.boxes.length - 1);
seq += 1;
(alloc[String(cid)] = alloc[String(cid)] || []).push({ id: seq, key: key, count: 1 });
return key;
}
// ── ② 요약 렌더 ───────────────────────────────────
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">' + esc(label || "") + "</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 LOW_FILL = 60;
function fillPercentOf(key) { return Math.round(mixFill(key) * 1000) / 10; }
function isLowMix(key) { return fillPercentOf(key) <= LOW_FILL; }
// 배분 완료 대신 찍는 경고 도장(오렌지).
function stampLowHtml(key) {
var fresh = !stamped[key];
stamped[key] = true;
return '<span class="cpg-stamp cpg-stamp-warn' + (fresh ? " is-new" : "") + '"' +
' aria-label="상자 변경 필요">상자 변경 필요</span>';
}
// ── 채움률이 낮은 상자 안내 ───────────────────────
var lowDlg = document.getElementById("cpg-low-dlg");
var lowList = document.getElementById("cpg-low-list");
var lowSeen = ""; // 같은 상태로 팝업을 반복해서 띄우지 않도록
function lowMixes() {
var out = [];
calc.mixes.forEach(function (m) {
m.boxes.forEach(function (b, i) {
var key = mixKeyFor(b);
if (!isLowMix(key)) return;
out.push({
key: key,
name: mixTypeOf(key) + " 혼합 #" + (i + 1) + (mixOwner[key] ? " · " + mixOwner[key] : ""),
fill: fillPercentOf(key),
units: units[key] || 0
});
});
});
return out;
}
function warnLowMixes() {
if (!lowDlg || !lowList) return;
var rows = lowMixes();
var sign = rows.map(function (r) { return r.key + ":" + r.fill; }).join("|");
if (!rows.length) { lowSeen = ""; return; }
if (sign === lowSeen) return; // 이미 안내한 그대로면 다시 띄우지 않는다
lowSeen = sign;
lowList.innerHTML = rows.map(function (r) {
return "<li><span class=\"cpg-low-name\">" + esc(r.name) + "</span>" +
"<span class=\"cpg-low-fill\">" + r.fill + "% · " + r.units + "개</span></li>";
}).join("");
lowDlg.hidden = false;
}
if (lowDlg) {
Array.prototype.forEach.call(lowDlg.querySelectorAll("[data-low-close]"), function (el) {
el.addEventListener("click", function () { lowDlg.hidden = true; });
});
document.addEventListener("keydown", function (e) {
if (e.key === "Escape" && !lowDlg.hidden) lowDlg.hidden = true;
});
}
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(mixKeyFor(b));
});
});
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 = mixKeyFor(b);
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, mixTypeOf(key)) + "</div>" +
'<div class="cpg-box-info">' +
'<div class="cpg-box-title">' + esc(mixTypeOf(key)) + " 혼합 #" + (i + 1) +
(isLowMix(key) ? stampLowHtml(key)
: (remain ? "" : stampHtml(key))) + "</div>" +
'<div class="cpg-box-typerow">' + mixTypeSelect(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 moveOptions(fromKey, cid, code) {
var opts = '<option value="">이동…</option>';
mixKeysOf(cid).forEach(function (k) {
if (k === fromKey) return;
var room = mixRoomFor(k, code);
opts += '<option value="' + esc(k) + '">' +
esc(labels[k] || k) + (room ? " (여유 " + room + "개)" : " (가득)") + "</option>";
});
opts += '<option value="__new__">+ 새 상자로</option>';
return opts;
}
function treeHtml(entry) {
var list = contents[entry.key];
if (!list || !list.length) return "";
var editable = entry.key.indexOf("m:") === 0 && entry.count === 1;
var cid = editable ? centerOfKey(entry.key) : "";
return '<ul class="cpg-dist-tree">' + list.map(function (it) {
var qty = it.quantity * entry.count;
if (!editable) {
return '<li><span class="cpg-tree-name">' + esc(it.product_name) + "</span>" +
'<span class="cpg-tree-qty">' + qty + "개</span>" +
(entry.count > 1 ? '<span class="cpg-tree-per">(' + it.quantity + "개 × " + entry.count + "상자)</span>" : "") +
"</li>";
}
var canAdd = !!boxHaving(cid, it.product_code, entry.key);
return '<li><span class="cpg-tree-name">' + esc(it.product_name) + "</span>" +
'<span class="cpg-mix-ctl">' +
'<button type="button" class="cpg-mix-step" data-mix-dec data-key="' + esc(entry.key) +
'" data-code="' + esc(it.product_code) + '" title="다른 상자로 1개 옮기기"></button>' +
'<span class="cpg-tree-qty">' + qty + "개</span>" +
'<button type="button" class="cpg-mix-step" data-mix-inc data-key="' + esc(entry.key) +
'" data-code="' + esc(it.product_code) + '"' + (canAdd ? "" : " disabled") +
' title="다른 상자에서 1개 가져오기">+</button>' +
'<select class="erp-select cpg-mix-move" data-key="' + esc(entry.key) +
'" data-code="' + esc(it.product_code) + '" aria-label="이동">' +
moveOptions(entry.key, cid, it.product_code) +
"</select>" +
"</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;
var isMix = a.key.indexOf("m:") === 0;
var fillPct = isMix ? Math.round(mixFill(a.key) * 100) : 0;
var fillTxt = isMix ? fillPct + "% 채움" : "";
var fillCls = fillPct > 100 ? " is-over" : "";
items += '<li class="cpg-dist-item' + (isMix ? " is-mix" : "") + '" data-entry="' + a.id + '">' +
'<div class="cpg-dist-line">' +
'<span class="cpg-dist-name">' + esc(labels[a.key] || a.key) +
(fillTxt ? ' <em class="cpg-mix-fill' + fillCls + '">' + fillTxt + "</em>" : "") + "</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>') +
(rows.length
? '<div class="cpg-dist-foot">' +
'<button type="button" class="erp-btn erp-btn-outline cpg-mix-new" data-center="' +
esc(cid) + '">+ 새 혼합 상자</button>' +
"</div>"
: "") +
"</div>";
});
distList.innerHTML = html;
syncConfirm();
}
// ── 스크롤 영역 높이 ──────────────────────────────
// ①② 만 대상. ③ 센터 분배는 카드 자체가 스크롤러다(CSS).
// 넓은 화면(3열): 카드 아래끝까지를 높이로 준다.
// 좁은 화면(2열/1열): 높이를 풀고 페이지가 스크롤.
var FRAME_MIN_WIDTH = 1501;
function fitScrollAreas() {
var wide = window.innerWidth >= FRAME_MIN_WIDTH;
var panes = [
[".cpg-date-list", document.getElementById("cpg-date-list")],
[".cpg-sum-scroll", document.querySelector(".cpg-calc-sum .cpg-sum-scroll")]
];
panes.forEach(function (pair) {
var el = pair[1];
if (!el) return;
if (!wide) { // 좁은 화면: 제한 해제(페이지가 스크롤)
el.style.removeProperty("max-height");
el.style.removeProperty("overflow-y");
el.removeAttribute("data-fit");
return;
}
var card = el.closest(".erp-card");
var top = el.getBoundingClientRect().top;
var bottom = card ? card.getBoundingClientRect().bottom - 16 : window.innerHeight - 16;
var h = Math.max(160, Math.floor(Math.min(bottom, window.innerHeight - 16) - top));
el.style.setProperty("max-height", h + "px", "important");
el.style.setProperty("overflow-y", "auto", "important");
el.style.setProperty("min-height", "0", "important");
el.setAttribute("data-fit", h);
if (window.CPG_DEBUG) {
console.log("[cpg fit]", pair[0], "top=", Math.round(top), "cardBottom=", Math.round(bottom),
"set=", h, "client=", el.clientHeight, "scroll=", el.scrollHeight);
}
});
}
window.addEventListener("resize", fitScrollAreas);
window.cpgFit = fitScrollAreas; // 콘솔에서 수동 확인용
function render() {
renderSummary();
renderDist();
// 레이아웃이 확정된 다음 프레임에 높이를 잡는다
if (window.requestAnimationFrame) { window.requestAnimationFrame(fitScrollAreas); }
else { fitScrollAreas(); }
}
// ── 수량 대화상자 ─────────────────────────────────
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;
if (e.target.closest(".cpg-box-type")) 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) {
if (e.target.closest(".cpg-box-type")) return; // 상자 종류 고르는 중
var card = e.target.closest("[data-key]");
if (!card) return;
if (card === suppressClickOn) { suppressClickOn = null; return; }
openDialog(card.getAttribute("data-key"), openCenters[0]);
});
// 상자 종류 변경 — 라벨(②③)만 바뀌고 배분 수량은 그대로 유지된다.
sumBody.addEventListener("change", function (e) {
var sel = e.target.closest(".cpg-box-type");
if (!sel) return;
var key = sel.getAttribute("data-type-for");
mixType[key] = sel.value;
calc.mixes.forEach(function (m) {
m.boxes.forEach(function (b, i) {
var k = mixKeyFor(b);
if (k === key) labels[k] = mixLabel(k, i);
});
});
render();
msg.textContent = "상자 종류를 " + sel.value + " 로 바꿨습니다.";
});
// ── 혼합 상자 내용물 편집 이벤트 ──────────────────
// 수량은 상자 사이에서만 오간다(총량 보존). 갈 곳이 없으면 새 상자를 만든다.
if (distList) {
distList.addEventListener("click", function (e) {
// 혼합 상자의 ✕ — 내용물을 다른 상자로 옮기고 상자를 없앤다.
var del = e.target.closest(".cpg-dist-del");
if (del) {
var li = del.closest(".cpg-dist-item");
var zone = del.closest("[data-drop-for]");
var dcid = zone && zone.getAttribute("data-drop-for");
var entry = (alloc[dcid] || []).filter(function (a) {
return String(a.id) === li.getAttribute("data-entry");
})[0];
if (entry && entry.key.indexOf("m:") === 0) {
e.stopImmediatePropagation(); // 뒤 핸들러가 또 지우지 않게
(contents[entry.key] || []).slice().forEach(function (it) {
var rest = mixKeysOf(dcid).filter(function (k) { return k !== entry.key; });
var dest = boxWithRoom(dcid, it.product_code, entry.key) || rest[0] || newMixBox(dcid);
moveUnits(entry.key, dest, it.product_code, it.product_name, it.quantity);
});
syncMix(entry.key);
render();
msg.textContent = "혼합 상자를 비우고 내용물을 다른 상자로 옮겼습니다.";
}
return;
}
var dec = e.target.closest("[data-mix-dec]");
if (dec) {
var key = dec.getAttribute("data-key");
var code = dec.getAttribute("data-code");
var cid = centerOfKey(key);
var item = mixItem(key, code);
if (!item) return;
var others = mixKeysOf(cid).filter(function (k) { return k !== key; });
var to = boxWithRoom(cid, code, key) || others[0] || newMixBox(cid);
var n = moveUnits(key, to, code, item.product_name, 1);
if (!n) { msg.textContent = "옮길 자리가 없습니다."; return; }
render();
msg.textContent = "1개를 " + (labels[to] || "다른 상자") + " 로 옮겼습니다.";
return;
}
var inc = e.target.closest("[data-mix-inc]");
if (inc) {
var ikey = inc.getAttribute("data-key");
var icode = inc.getAttribute("data-code");
var icid = centerOfKey(ikey);
var from = boxHaving(icid, icode, ikey);
if (!from) { msg.textContent = "다른 상자에 이 제품이 없습니다."; return; }
var src = mixItem(from, icode);
var got = moveUnits(from, ikey, icode, src.product_name, 1);
if (!got) { msg.textContent = "가져올 수 없습니다."; return; }
render();
msg.textContent = "1개를 " + (labels[from] || "다른 상자") + " 에서 가져왔습니다.";
return;
}
var neu = e.target.closest(".cpg-mix-new");
if (neu) {
newMixBox(neu.getAttribute("data-center"));
render();
msg.textContent = "빈 혼합 상자를 추가했습니다.";
}
});
distList.addEventListener("change", function (e) {
var mv = e.target.closest(".cpg-mix-move");
if (!mv) return;
var fromKey = mv.getAttribute("data-key");
var code = mv.getAttribute("data-code");
var target = mv.value;
mv.value = "";
if (!target) return;
var src = mixItem(fromKey, code);
if (!src) return;
var qty = src.quantity;
var name = src.product_name;
var cid = centerOfKey(fromKey);
var toKey = target === "__new__" ? newMixBox(cid) : target;
var moved = moveUnits(fromKey, toKey, code, name, qty);
if (!moved) { msg.textContent = "옮길 자리가 없습니다."; render(); return; }
render();
msg.textContent = name + " " + moved + "개를 " + (labels[toKey] || "새 상자") + " 로 옮겼습니다." +
(moved < qty ? " (자리가 부족해 " + (qty - moved) + "개는 남음)" : "");
});
}
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) + " 추가됨";
});
}
// ── 분배 확정 ─────────────────────────────────────
var confirmBtn = document.getElementById("cpg-confirm");
var savingDlg = document.getElementById("cpg-saving");
var savingNote = document.getElementById("cpg-saving-note");
function pad2(n) { return (n < 10 ? "0" : "") + n; }
function iso(y, m, d) { return y + "-" + pad2(m + 1) + "-" + pad2(d); }
// 확정에 쓸 출고일 — 업로드한 발주서의 출고일(F13 − 1일), 없으면 오늘.
function shipDateForConfirm() {
if (activeDate) return activeDate;
if (uploadShipDate) return uploadShipDate;
var t = new Date();
return iso(t.getFullYear(), t.getMonth(), t.getDate());
}
function showSaving(text) {
if (!savingDlg) return;
if (savingNote) savingNote.textContent = text || "";
savingDlg.hidden = false;
}
function hideSaving() { if (savingDlg) savingDlg.hidden = true; }
// 배분이 남았는지 — ② 요약과 같은 기준
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(mixKeyFor(b));
});
});
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 || ("출고일 " + shipDateForConfirm() + " 로 바로 확정합니다.");
}
// 센터별 전송 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 });
});
}
});
// 출고 엑셀 "출고" 칸 — 상자 종류별 상자 수 (예: "쿠팡상자, 41개")
// 종류가 여럿이면 " / " 로 잇는다 (예: "쿠팡상자, 41개 / 3호상자, 2개")
var byType = {};
var order = [];
rows.forEach(function (a) {
var t;
if (a.key.indexOf("m:") === 0) {
t = mixTypeOf(a.key);
} else {
var code = a.key.slice(2);
var hit = calc.results.filter(function (r) { return r.product_code === code; })[0];
t = (hit && hit.box_name) || "쿠팡상자";
}
if (!(t in byType)) { byType[t] = 0; order.push(t); }
byType[t] += a.count;
});
var boxSummary = order.map(function (t) {
return t + ", " + byType[t] + "개";
}).join(" / ");
// 확정 후 상세 화면에서 "어느 상자에 무엇이" 를 보여주기 위한 스냅샷
var boxPlan = rows.map(function (a) {
if (a.key.indexOf("m:") === 0) {
return {
kind: "mix",
name: labels[a.key] || a.key,
box_type: mixTypeOf(a.key),
count: a.count,
units: units[a.key] || 0,
items: (contents[a.key] || []).map(function (it) {
return {
product_code: it.product_code,
product_name: it.product_name,
quantity: it.quantity * a.count
};
})
};
}
return {
kind: "product",
name: labels[a.key] || a.key,
product_code: a.key.slice(2),
count: a.count,
units: units[a.key] || 0
};
});
return {
center_id: cid,
ship_method: methods[String(cid)] || "",
boxes: boxes,
box_summary: boxSummary,
box_plan: boxPlan,
items: items
};
});
}
function submitConfirm() {
var why = confirmBlockReason();
if (why) { msg.textContent = why; return; }
var plans = buildPlans();
if (!plans.length) { msg.textContent = "담긴 품목이 없습니다."; return; }
var shipDate = shipDateForConfirm();
if (confirmBtn) confirmBtn.disabled = true;
showSaving(shipDate + " 출고 " + plans.length + "건");
fetch("/cupang/api/box-calc/confirm", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ship_date: shipDate, 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) || shipDate;
// 출고리스트는 구글 시트에 기록한다(설정돼 있을 때만).
// 엑셀이 필요하면 /cupang/export.xlsx?date=YYYY-MM-DD 로 따로 받는다.
var sh = (data && data.sheet) || {};
var sheetNote = sh.ok
? "구글 시트 " + (sh.title || "") + " 기록 완료"
: "구글 시트 기록 안 됨 — " + (sh.reason || "사유 불명");
// 업로드한 출고일이 여러 개면 남은 날짜 작업을 이어서 한다.
var idx = dateOrder.indexOf(activeDate);
if (idx >= 0 && dateOrder.length > 1) {
var done = activeDate;
dateOrder.splice(idx, 1);
delete sets[done];
activeDate = "";
loadDate(dateOrder[Math.min(idx, dateOrder.length - 1)]);
hideSaving();
syncConfirm();
msg.textContent = done + " 출고 확정 완료 — 남은 출고일 " + dateOrder.length + "개 · " + sheetNote;
poMsg.textContent = done + " 확정됨. 남은 날짜: " + dateOrder.join(", ");
return;
}
// 저장이 끝났으면 창을 닫고 달력으로 이동한다.
hideSaving();
var parts = d.split("-");
window.location.href = "/cupang/?year=" + parts[0] +
"&month=" + parseInt(parts[1], 10) + "&date=" + d;
})
.catch(function (err) {
hideSaving();
if (confirmBtn) confirmBtn.disabled = false;
msg.textContent = (err && err.message) || "확정 실패 — 다시 시도하세요.";
});
}
if (confirmBtn) confirmBtn.addEventListener("click", submitConfirm);
// 센터 헤더의 출고방식 선택
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: currentItems(),
ship_date: activeDate || uploadShipDate || "",
openCenters: openCenters.slice(),
alloc: alloc,
methods: methods,
stamped: stamped,
allStamped: allStamped,
mixType: mixType
};
}
// 불러온 분배 내역을 현재 계산 결과 위에 다시 얹는다.
// 계산이 달라져 사라진 항목은 건너뛰고 그 건수를 돌려준다.
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;
if (snap.mixType && typeof snap.mixType === "object") {
Object.keys(snap.mixType).forEach(function (k) {
if (k in units) mixType[k] = snap.mixType[k];
});
calc.mixes.forEach(function (m) {
m.boxes.forEach(function (b, i) {
var k = mixKeyFor(b);
labels[k] = mixLabel(k, i);
});
});
}
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 || [];
draftName.value = d.title || "";
closeDraft();
// 저장해 둔 출고일을 그대로 이어 쓴다(없으면 확정 시 오늘 날짜).
sets = {}; dateOrder = []; activeDate = "";
uploadShipDate = snap.ship_date || "";
if (uploadShipDate) { activeDate = uploadShipDate; dateOrder = [uploadShipDate]; }
// 상자 수는 서버에서 다시 계산한 뒤 분배 내역을 얹는다.
run(snap, items);
})
.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();
});
render();
renderTabs();
})();
</script>
{% endif %}
</section>
{% endblock %}