1cf6ba385d
- index 라우터가 두 달치 출고를 합쳐(중복 id 제거) 달 별 격자 2개를 생성 - 상단 바: ‹ › 이동 + 기간 제목 + [오늘], 달마다 머리글/월 출고 합계 배지 - 셀 UI 정리: 오늘=검은 원, 선택=링, 출고일=건수/센터수 태그, 타월은 흐리게 - 범례 추가, 1200px 이하에서는 두 달을 세로로 배치 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1180 lines
52 KiB
HTML
1180 lines
52 KiB
HTML
{% 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 %}
|