feat(cupang): 박스 계산 3열 작업공간 + 센터 분배 드래그

① 박스 계산: 제품명·수량만 입력(결과 열 제거). 수량 칸에서 Tab 은 다음
   행 수량으로 이동하고 마지막 행이면 새 행을 추가한다. Enter 는 계산.
② 박스 요약: 제품별 박스/자투리 혼합 박스 카드가 드래그 소스. 표시되는
   수치는 센터에 배분하고 남은 "잔여" 기준으로 실시간 갱신되며, KPI 는
   총 박스 / 센터 배분 / 미배분 잔여 3개로 바꿨다.
③ 센터 분배: 활성 입고센터(cupang_centers)만 표시. 카드를 끌어다 놓으면
   해당 센터에 1박스가 담기고, 박스 수량을 직접 입력할 수 있다(잔여를
   넘으면 자동으로 잘림). ✕ 로 빼면 잔여로 되돌아간다.

혼합 박스는 내용이 서로 달라 1장 = 1박스로 취급한다. 분배 내역은 화면
안에서만 유지(DB 저장 없음)하며, 재계산하면 근거가 바뀌므로 초기화한다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-29 02:47:48 +09:00
parent fe344f9fda
commit 2de019a203
9 changed files with 401 additions and 153 deletions
+1
View File
@@ -592,6 +592,7 @@ async def box_calc_page(request: Request) -> HTMLResponse:
"page_title": "쿠팡 밀크런 — 박스 계산", "page_title": "쿠팡 밀크런 — 박스 계산",
"page_subtitle": "제품명과 수량을 넣으면 박스 수와 남은 낱개를 계산합니다.", "page_subtitle": "제품명과 수량을 넣으면 박스 수와 남은 낱개를 계산합니다.",
"box_rules": store.list_box_rules(), "box_rules": store.list_box_rules(),
"centers": store.list_centers(),
}, },
) )
+289 -114
View File
@@ -1,6 +1,6 @@
{% extends "erp_base.html" %} {% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260829a" />{% endblock %} {% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260829b" />{% endblock %}
{% block content %} {% block content %}
<section class="cpg"> <section class="cpg">
@@ -8,6 +8,7 @@
<div class="erp-page-actions"> <div class="erp-page-actions">
<a class="erp-btn erp-btn-primary" href="/cupang/">◀◀ 달력</a> <a class="erp-btn erp-btn-primary" href="/cupang/">◀◀ 달력</a>
<a class="erp-btn erp-btn-outline" href="/cupang/box-rules">박스 입수량 설정</a> <a class="erp-btn erp-btn-outline" href="/cupang/box-rules">박스 입수량 설정</a>
<a class="erp-btn erp-btn-outline" href="/cupang/centers">입고센터 관리</a>
</div> </div>
{% if not box_rules %} {% if not box_rules %}
@@ -19,12 +20,13 @@
</div> </div>
{% else %} {% else %}
<div class="cpg-calc-layout"> <div class="cpg-calc3">
<!-- ① 박스 계산 — 제품명 + 수량만 입력 -->
<div class="erp-card cpg-form-card cpg-calc-card"> <div class="erp-card cpg-form-card cpg-calc-card">
<div class="cpg-card-head"> <div class="cpg-card-head">
<h2>박스 계산</h2> <h2>박스 계산</h2>
<span class="erp-muted">수량을 고치고 다시 계산하면 결과가 갱신됩니다.</span> <span class="erp-muted">수량 칸에서 Tab = 다음 상품 수량, Enter = 계산</span>
</div> </div>
<div class="erp-table-wrap"> <div class="erp-table-wrap">
@@ -32,20 +34,12 @@
<colgroup> <colgroup>
<col class="cpg-col-name" /> <col class="cpg-col-name" />
<col class="cpg-col-qty" /> <col class="cpg-col-qty" />
<col class="cpg-col-box" />
<col class="cpg-col-n" />
<col class="cpg-col-n" />
<col class="cpg-col-n" />
<col class="cpg-col-act" /> <col class="cpg-col-act" />
</colgroup> </colgroup>
<thead> <thead>
<tr> <tr>
<th>제품명</th> <th>제품명</th>
<th class="cpg-calc-num">수량</th> <th>수량</th>
<th>박스 / 입수량</th>
<th class="cpg-calc-num">박스</th>
<th class="cpg-calc-num">낱개</th>
<th class="cpg-calc-num">필요</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
@@ -61,27 +55,64 @@
</div> </div>
</div> </div>
<div class="erp-card cpg-form-card cpg-calc-sum" id="cpg-calc-sum-card" hidden> <!-- ② 박스 요약 — 센터에 배분하고 남은 수량이 실시간 반영 -->
<div class="erp-card cpg-form-card cpg-calc-sum">
<div class="cpg-card-head"> <div class="cpg-card-head">
<h2>박스 요약</h2> <h2>박스 요약</h2>
<span class="erp-muted">제품별 박스 + 자투리를 모아 담은 혼합 박스</span> <span class="erp-muted">카드를 ③ 센터로 끌어다 놓으면 잔여가 줄어듭니다.</span>
</div> </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-kpis" id="cpg-sum-kpis"></div>
<h3 class="cpg-sum-h3">제품별 박스</h3> <h3 class="cpg-sum-h3">제품별 박스 <span class="erp-muted">잔여 기준</span></h3>
<div class="cpg-sum-prods" id="cpg-sum-prods"></div> <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> <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 class="cpg-sum-boxes" id="cpg-sum-boxes"></div>
</div> </div>
</div>
</div><!-- /cpg-calc-layout --> <!-- ③ 센터 분배 — 드래그해서 담고 수량 입력 -->
<div class="erp-card cpg-form-card cpg-dist-card">
<div class="cpg-card-head">
<h2>③ 센터 분배</h2>
<span class="erp-muted">입고센터 관리에 등록한 센터만 표시됩니다.</span>
</div>
{% if not centers %}
<p class="erp-muted">
등록된 입고센터가 없습니다. <a href="/cupang/centers">입고센터 관리</a>에서 먼저 센터를 추가하세요.
</p>
{% else %}
<div class="cpg-dist-list" id="cpg-dist-list">
{% for c in centers %}
<div class="cpg-dist-center" data-center-id="{{ c.id }}" data-center-name="{{ c.name }}">
<div class="cpg-dist-head">
<strong>{{ c.name }}</strong>
<span class="erp-badge erp-badge-neutral cpg-dist-total" data-total-for="{{ c.id }}">0박스</span>
</div>
<div class="cpg-dist-drop" data-drop-for="{{ c.id }}">
<span class="cpg-dist-hint">여기로 박스를 끌어다 놓기</span>
<ul class="cpg-dist-items" data-items-for="{{ c.id }}"></ul>
</div>
</div>
{% endfor %}
</div>
{% endif %}
</div>
</div><!-- /cpg-calc3 -->
<script type="application/json" id="cpg-calc-rules">{{ box_rules | tojson }}</script> <script type="application/json" id="cpg-calc-rules">{{ box_rules | tojson }}</script>
<script> <script>
// 박스 계산기 — 행 추가/삭제, 수량 수정 후 재계산. // 박스 계산 3열 화면.
// 계산 자체는 서버(POST /cupang/api/box-calc, store.compute_boxes)에서 수행한다. // ① 제품명·수량 입력 → 서버(POST /cupang/api/box-calc)에서 계산
// ② 계산 결과 요약(제품별 박스 / 자투리 혼합 박스) — 드래그 소스
// ③ 센터별 분배 — 드롭 대상. 배분한 수량만큼 ② 의 잔여가 줄어든다.
// 분배 내역은 화면 안에서만 유지된다(DB 저장 없음).
(function () { (function () {
var tbody = document.getElementById("cpg-calc-rows"); var tbody = document.getElementById("cpg-calc-rows");
if (!tbody) return; if (!tbody) return;
@@ -89,144 +120,168 @@
var addBtn = document.getElementById("cpg-calc-add"); var addBtn = document.getElementById("cpg-calc-add");
var resetBtn = document.getElementById("cpg-calc-reset"); var resetBtn = document.getElementById("cpg-calc-reset");
var msg = document.getElementById("cpg-calc-msg"); var msg = document.getElementById("cpg-calc-msg");
var sumCard = document.getElementById("cpg-calc-sum-card"); var sumEmpty = document.getElementById("cpg-sum-empty");
var sumBody = document.getElementById("cpg-sum-body");
var sumKpis = document.getElementById("cpg-sum-kpis"); var sumKpis = document.getElementById("cpg-sum-kpis");
var sumProds = document.getElementById("cpg-sum-prods"); var sumProds = document.getElementById("cpg-sum-prods");
var sumBoxes = document.getElementById("cpg-sum-boxes"); var sumBoxes = document.getElementById("cpg-sum-boxes");
var sumMixNote = document.getElementById("cpg-sum-mix-note"); var sumMixNote = document.getElementById("cpg-sum-mix-note");
var distList = document.getElementById("cpg-dist-list");
var rules = []; var rules = [];
try { rules = JSON.parse(document.getElementById("cpg-calc-rules").textContent || "[]"); } catch (e) {} try { rules = JSON.parse(document.getElementById("cpg-calc-rules").textContent || "[]"); } catch (e) {}
var optionsHtml = '<option value="">— 제품명 선택 —</option>';
rules.forEach(function (r) {
var label = r.product_name_snapshot || r.product_code;
optionsHtml += '<option value="' + esc(r.product_code) + '">' + esc(label) + "</option>";
});
function esc(s) { var d = document.createElement("div"); d.textContent = s == null ? "" : s; return d.innerHTML; } 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 alloc = {}; // centerId -> [{id, key, kind, label, count}]
var seq = 0;
// ── ① 입력 표 ─────────────────────────────────────
function addRow(code, qty) { function addRow(code, qty) {
var tr = document.createElement("tr"); var tr = document.createElement("tr");
tr.className = "cpg-calc-row"; tr.className = "cpg-calc-row";
tr.innerHTML = tr.innerHTML =
'<td><select class="erp-select cpg-calc-name">' + optionsHtml + "</select></td>" + '<td><select class="erp-select cpg-calc-name">' + optionsHtml + "</select></td>" +
'<td class="cpg-calc-num"><input class="erp-input cpg-calc-qty" type="number" min="0" step="1" value="' + (qty || 0) + '" /></td>' + '<td><input class="erp-input cpg-calc-qty" type="number" min="0" step="1" value="' + (qty || 0) + '" /></td>' +
'<td class="cpg-calc-box">—</td>' +
'<td class="cpg-calc-num cpg-calc-boxes">—</td>' +
'<td class="cpg-calc-num cpg-calc-rem">—</td>' +
'<td class="cpg-calc-num cpg-calc-req">—</td>' +
'<td class="cpg-calc-act"><button type="button" class="erp-btn erp-btn-danger cpg-calc-del" title="행 삭제" aria-label="행 삭제">✕</button></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); tbody.appendChild(tr);
if (code) tr.querySelector(".cpg-calc-name").value = code; if (code) tr.querySelector(".cpg-calc-name").value = code;
return tr; return tr;
} }
function clearResults() { function rowsArr() {
Array.prototype.forEach.call(tbody.querySelectorAll(".cpg-calc-row"), function (tr) { return Array.prototype.slice.call(tbody.querySelectorAll(".cpg-calc-row"));
tr.querySelector(".cpg-calc-box").textContent = "—";
tr.querySelector(".cpg-calc-boxes").textContent = "—";
tr.querySelector(".cpg-calc-rem").textContent = "—";
tr.querySelector(".cpg-calc-req").textContent = "—";
tr.classList.remove("cpg-calc-warn");
});
sumCard.hidden = true;
sumKpis.innerHTML = "";
sumProds.innerHTML = "";
sumBoxes.innerHTML = "";
sumMixNote.textContent = "";
} }
function collect() { function collect() {
var items = []; var items = [];
Array.prototype.forEach.call(tbody.querySelectorAll(".cpg-calc-row"), function (tr) { rowsArr().forEach(function (tr) {
var code = tr.querySelector(".cpg-calc-name").value; var code = tr.querySelector(".cpg-calc-name").value;
if (!code) return; if (!code) return;
items.push({ product_code: code, quantity: parseInt(tr.querySelector(".cpg-calc-qty").value, 10) || 0, _tr: tr }); items.push({ product_code: code, quantity: parseInt(tr.querySelector(".cpg-calc-qty").value, 10) || 0 });
}); });
return items; return items;
} }
function run() { function run() {
var items = collect(); var items = collect();
if (!items.length) { clearResults(); msg.textContent = "제품명을 하나 이상 선택하세요."; return; } if (!items.length) { msg.textContent = "제품명을 하나 이상 선택하세요."; return; }
msg.textContent = "계산 중…"; msg.textContent = "계산 중…";
runBtn.disabled = true; runBtn.disabled = true;
fetch("/cupang/api/box-calc", { fetch("/cupang/api/box-calc", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ items: items.map(function (it) { return { product_code: it.product_code, quantity: it.quantity }; }) }) body: JSON.stringify({ items: items })
}) })
.then(function (r) { if (!r.ok) throw new Error("http " + r.status); return r.json(); }) .then(function (r) { if (!r.ok) throw new Error("http " + r.status); return r.json(); })
.then(function (data) { .then(function (data) {
var results = (data && data.results) || []; calc.results = ((data && data.results) || []).filter(function (r) { return r.configured; });
results.forEach(function (res, i) { calc.mixes = (data && data.mixes) || [];
var tr = items[i] && items[i]._tr; var had = Object.keys(alloc).some(function (k) { return alloc[k].length; });
if (!tr) return; alloc = {}; // 계산이 바뀌면 이전 배분은 근거가 사라지므로 비운다.
tr.querySelector(".cpg-calc-box").textContent = res.units_per_box render();
? (res.box_name || "-") + " · " + res.units_per_box + "" msg.textContent = "계산 완료 (" + calc.results.length + "건)" + (had ? " — 센터 분배 초기화됨" : "");
: "미설정";
if (res.configured) {
tr.querySelector(".cpg-calc-boxes").textContent = res.full_boxes;
tr.querySelector(".cpg-calc-rem").textContent = res.remainder_units;
tr.querySelector(".cpg-calc-req").textContent = res.required_boxes;
tr.classList.remove("cpg-calc-warn");
} else {
tr.querySelector(".cpg-calc-boxes").textContent = "—";
tr.querySelector(".cpg-calc-rem").textContent = "—";
tr.querySelector(".cpg-calc-req").textContent = "—";
tr.classList.add("cpg-calc-warn");
}
});
renderSummary(results, (data && data.mixes) || [], (data && data.grand_total_boxes) || 0);
msg.textContent = "계산 완료 (" + results.length + "건)";
}) })
.catch(function () { msg.textContent = "계산 실패 — 새로고침 후 다시 시도하세요."; }) .catch(function () { msg.textContent = "계산 실패 — 새로고침 후 다시 시도하세요."; })
.then(function () { runBtn.disabled = false; }); .then(function () { runBtn.disabled = false; });
} }
// ── 요약 렌더 ───────────────────────────────────────── // ── 배분 집계 ─────────────────────────────────────
// 상자 그림: fill(0~100) 만큼 안쪽이 차오르고, 뚜껑이 살짝 열린 채 표시. 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 mixKey(boxName, idx) { return "m:" + boxName + "#" + idx; }
function prodKey(code) { return "p:" + code; }
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 boxSvg(fill, label) { function boxSvg(fill, label) {
var f = Math.max(0, Math.min(100, fill || 0)); var f = Math.max(0, Math.min(100, fill || 0));
var h = 40 * f / 100; var h = 40 * f / 100;
return '' + return '' +
'<svg class="cpg-box-svg" viewBox="0 0 72 64" role="img" aria-label="' + esc(label) + ' 박스">' + '<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" />' + '<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-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-flapL" d="M10 20 L2 8 H30 L36 20 Z" />' +
'<path class="cpg-box-flapR" d="M62 20 L70 8 H42 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" />' + '<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>" + '<text class="cpg-box-label" x="36" y="42" text-anchor="middle">쿠팡박스</text>' +
"</svg>"; "</svg>";
} }
function kpi(label, value, hint) {
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></div>";
}
function renderSummary() {
if (!calc.results.length) {
sumEmpty.hidden = false;
sumBody.hidden = true;
return;
}
sumEmpty.hidden = true;
sumBody.hidden = false;
function renderSummary(results, mixes, grandTotal) { var totalProd = 0, remainProd = 0, leftoverUnits = 0;
var ok = results.filter(function (r) { return r.configured; }); calc.results.forEach(function (r) {
if (!ok.length) { sumCard.hidden = true; return; } totalProd += r.full_boxes;
remainProd += remainFor(prodKey(r.product_code));
var fullSum = 0, mixSum = 0, leftoverSum = 0; leftoverUnits += r.remainder_units;
ok.forEach(function (r) { fullSum += r.full_boxes; leftoverSum += r.remainder_units; }); });
mixes.forEach(function (m) { mixSum += m.box_count; }); 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);
sumKpis.innerHTML = sumKpis.innerHTML =
kpi("총 박스", (grandTotal || (fullSum + mixSum)) + "박스", "제품별 박스 + 혼합 박스") + kpi("총 박스", (totalProd + totalMix) + "박스", "제품별 " + totalProd + " + 혼합 " + totalMix) +
kpi("제품별 박스", fullSum + "박스", "한 제품만 가득 채운 박스") + kpi("센터 배분", assigned + "박스", "③ 에 담긴 수량") +
kpi("혼합 박스", mixSum + "박스", "자투리 " + leftoverSum + "개를 모아 담음"); kpi("미배분 잔여", (remainProd + remainMix) + "박스", "자투리 " + leftoverUnits + "개 포함");
// 제품별
var ph = ""; var ph = "";
ok.forEach(function (r) { calc.results.forEach(function (r) {
ph += '<div class="cpg-sum-prod">' + var key = prodKey(r.product_code);
var remain = remainFor(key);
var used = r.full_boxes - remain;
ph += '<div class="cpg-sum-prod' + (remain ? "" : " is-done") + '"' +
(remain ? ' draggable="true"' : "") +
' data-key="' + esc(key) + '" data-kind="product" data-label="' + esc(r.product_name) + '">' +
'<div class="cpg-sum-prod-head">' + '<div class="cpg-sum-prod-head">' +
'<span class="cpg-sum-prod-name">' + esc(r.product_name) + "</span>" + '<span class="cpg-sum-prod-name">' + esc(r.product_name) + "</span>" +
'<span class="erp-badge erp-badge-neutral">' + esc(r.box_name) + "</span>" + '<span class="erp-badge erp-badge-neutral">' + esc(r.box_name) + "</span>" +
"</div>" + "</div>" +
'<div class="cpg-sum-prod-nums">' + '<div class="cpg-sum-prod-nums">' +
'<strong>' + r.full_boxes + "박스</strong>" + "<strong>" + remain + "박스</strong>" +
'<span class="erp-muted">' + r.quantity + "개 ÷ " + r.units_per_box + "</span>" + '<span class="erp-muted">' + r.full_boxes + "박스" + (used ? " · 배분 " + used : "") + "</span>" +
(r.remainder_units (r.remainder_units
? '<span class="cpg-sum-left">자투리 ' + r.remainder_units + "개</span>" ? '<span class="cpg-sum-left">자투리 ' + r.remainder_units + "개</span>"
: '<span class="cpg-sum-exact">딱 맞음</span>') + : '<span class="cpg-sum-exact">딱 맞음</span>') +
@@ -235,22 +290,28 @@
}); });
sumProds.innerHTML = ph; sumProds.innerHTML = ph;
// 자투리 혼합 박스 if (!calc.mixes.length) {
if (!mixes.length) {
sumMixNote.textContent = "— 자투리 없음"; sumMixNote.textContent = "— 자투리 없음";
sumBoxes.innerHTML = '<p class="erp-muted">남는 낱개가 없습니다. 모든 박스가 딱 맞습니다.</p>'; sumBoxes.innerHTML = '<p class="erp-muted">남는 낱개가 없습니다.</p>';
} else { } else {
sumMixNote.textContent = "— 같은 박스명끼리만 섞습니다"; sumMixNote.textContent = "— 1장 = 1박스";
var bh = "", n = 0; var bh = "", n = 0;
mixes.forEach(function (m) { calc.mixes.forEach(function (m) {
m.boxes.forEach(function (b, i) { m.boxes.forEach(function (b, i) {
var key = mixKey(m.box_name, i);
var remain = remainFor(key);
var items = b.items.map(function (it) { var items = b.items.map(function (it) {
return '<li><span>' + esc(it.product_name) + "</span><b>" + it.quantity + "개</b></li>"; return "<li><span>" + esc(it.product_name) + "</span><b>" + it.quantity + "개</b></li>";
}).join(""); }).join("");
bh += '<div class="cpg-box-card" style="animation-delay:' + (n * 70) + 'ms">' + bh += '<div class="cpg-box-card' + (remain ? "" : " is-done") + '"' +
'<div class="cpg-box-art">' + boxSvg(b.fill_percent, "쿠팡박스") + "</div>" + (remain ? ' draggable="true"' : "") +
' data-key="' + esc(key) + '" data-kind="mix"' +
' data-label="' + esc(m.box_name + " 혼합 #" + (i + 1)) + '"' +
' 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-info">' +
'<div class="cpg-box-title">' + esc(m.box_name) + " 혼합 #" + (i + 1) + "</div>" + '<div class="cpg-box-title">' + esc(m.box_name) + " 혼합 #" + (i + 1) +
(remain ? "" : ' <span class="erp-badge erp-badge-success">배분됨</span>') + "</div>" +
'<ul class="cpg-box-items">' + items + "</ul>" + '<ul class="cpg-box-items">' + items + "</ul>" +
'<div class="cpg-box-bar"><span style="width:' + b.fill_percent + '%"></span></div>' + '<div class="cpg-box-bar"><span style="width:' + b.fill_percent + '%"></span></div>' +
'<div class="cpg-box-fillnum">' + b.fill_percent + "% 채움</div>" + '<div class="cpg-box-fillnum">' + b.fill_percent + "% 채움</div>" +
@@ -261,45 +322,159 @@
}); });
sumBoxes.innerHTML = bh; sumBoxes.innerHTML = bh;
} }
sumCard.hidden = false;
} }
function kpi(label, value, hint) { // ── ③ 센터 분배 렌더 ──────────────────────────────
return '<div class="cpg-kpi"><span class="cpg-kpi-label">' + esc(label) + "</span>" + function renderDist() {
'<strong class="cpg-kpi-value">' + esc(value) + "</strong>" + if (!distList) return;
'<span class="cpg-kpi-hint">' + esc(hint) + "</span></div>"; Array.prototype.forEach.call(distList.querySelectorAll(".cpg-dist-center"), function (box) {
var cid = box.getAttribute("data-center-id");
var list = box.querySelector("[data-items-for]");
var totalEl = box.querySelector("[data-total-for]");
var rows = alloc[cid] || [];
var total = 0;
var html = "";
rows.forEach(function (a) {
total += a.count;
var max = a.count + remainFor(a.key);
html += '<li class="cpg-dist-item" data-entry="' + a.id + '">' +
'<span class="cpg-dist-name">' + esc(a.label) + "</span>" +
'<input class="erp-input cpg-dist-qty" type="number" min="1" max="' + max + '" value="' + a.count + '" />' +
'<span class="cpg-dist-unit">박스</span>' +
'<button type="button" class="erp-btn erp-btn-danger cpg-dist-del" title="빼기" aria-label="빼기">✕</button>' +
"</li>";
});
list.innerHTML = html;
box.classList.toggle("has-items", rows.length > 0);
totalEl.textContent = total + "박스";
});
} }
function render() { renderSummary(); renderDist(); }
// ── 드래그 앤 드롭 ────────────────────────────────
sumBody.addEventListener("dragstart", function (e) {
var card = e.target.closest("[data-key]");
if (!card || card.getAttribute("draggable") !== "true") return;
var payload = {
key: card.getAttribute("data-key"),
kind: card.getAttribute("data-kind"),
label: card.getAttribute("data-label")
};
e.dataTransfer.setData("text/plain", JSON.stringify(payload));
e.dataTransfer.effectAllowed = "copy";
card.classList.add("is-dragging");
});
sumBody.addEventListener("dragend", function (e) {
var card = e.target.closest("[data-key]");
if (card) card.classList.remove("is-dragging");
});
if (distList) {
distList.addEventListener("dragover", function (e) {
var zone = e.target.closest("[data-drop-for]");
if (!zone) return;
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
zone.classList.add("is-over");
});
distList.addEventListener("dragleave", function (e) {
var zone = e.target.closest("[data-drop-for]");
if (zone && !zone.contains(e.relatedTarget)) zone.classList.remove("is-over");
});
distList.addEventListener("drop", function (e) {
var zone = e.target.closest("[data-drop-for]");
if (!zone) return;
e.preventDefault();
zone.classList.remove("is-over");
var data;
try { data = JSON.parse(e.dataTransfer.getData("text/plain") || "{}"); } catch (err) { return; }
if (!data.key) return;
if (remainFor(data.key) <= 0) { msg.textContent = "남은 박스가 없습니다."; return; }
var cid = zone.getAttribute("data-drop-for");
alloc[cid] = alloc[cid] || [];
var same = alloc[cid].filter(function (a) { return a.key === data.key; })[0];
if (same) { same.count += 1; }
else {
seq += 1;
alloc[cid].push({ id: seq, key: data.key, kind: data.kind, label: data.label, count: 1 });
}
render();
});
distList.addEventListener("input", function (e) {
if (!e.target.classList.contains("cpg-dist-qty")) return;
var li = e.target.closest(".cpg-dist-item");
var centerEl = e.target.closest(".cpg-dist-center");
var cid = centerEl.getAttribute("data-center-id");
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;
// 입력 중에는 목록을 다시 그리지 않는다(포커스 유지) — 합계와 요약만 갱신.
var sum = 0;
(alloc[cid] || []).forEach(function (a) { sum += a.count; });
centerEl.querySelector("[data-total-for]").textContent = sum + "박스";
renderSummary();
});
distList.addEventListener("click", function (e) {
if (!e.target.closest(".cpg-dist-del")) return;
var li = e.target.closest(".cpg-dist-item");
var cid = e.target.closest(".cpg-dist-center").getAttribute("data-center-id");
alloc[cid] = (alloc[cid] || []).filter(function (a) { return String(a.id) !== li.getAttribute("data-entry"); });
render();
});
}
// ── 입력 표 이벤트 ────────────────────────────────
tbody.addEventListener("click", function (e) { tbody.addEventListener("click", function (e) {
var del = e.target.closest(".cpg-calc-del"); if (!e.target.closest(".cpg-calc-del")) return;
if (!del) return; if (rowsArr().length <= 1) addRow("", 0);
var rows = tbody.querySelectorAll(".cpg-calc-row"); e.target.closest("tr").remove();
if (rows.length <= 1) { addRow("", 0); }
del.closest("tr").remove();
msg.textContent = ""; msg.textContent = "";
}); });
// 수량/제품을 바꾸면 이전 결과는 지운다 — 낡은 숫자 오독 방지.
tbody.addEventListener("input", function (e) { tbody.addEventListener("input", function (e) {
if (e.target.classList.contains("cpg-calc-qty")) { msg.textContent = "수량 변경됨 — 재계산하세요."; } if (e.target.classList.contains("cpg-calc-qty")) msg.textContent = "수량 변경됨 — 재계산하세요.";
}); });
tbody.addEventListener("change", function (e) { tbody.addEventListener("change", function (e) {
if (e.target.classList.contains("cpg-calc-name")) { msg.textContent = "제품 변경됨 — 재계산하세요."; } if (e.target.classList.contains("cpg-calc-name")) msg.textContent = "제품 변경됨 — 재계산하세요.";
}); });
// 수량 칸: Tab = 다음 행 수량(마지막 행이면 새 행 추가), Enter = 계산
tbody.addEventListener("keydown", function (e) { tbody.addEventListener("keydown", function (e) {
if (e.key === "Enter" && e.target.classList.contains("cpg-calc-qty")) { e.preventDefault(); run(); } 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); runBtn.addEventListener("click", run);
addBtn.addEventListener("click", function () { addRow("", 0); }); addBtn.addEventListener("click", function () {
addRow("", 0).querySelector(".cpg-calc-name").focus();
});
resetBtn.addEventListener("click", function () { resetBtn.addEventListener("click", function () {
tbody.innerHTML = ""; tbody.innerHTML = "";
addRow("", 0); addRow("", 0);
clearResults(); calc = { results: [], mixes: [] };
alloc = {};
render();
msg.textContent = ""; msg.textContent = "";
}); });
addRow("", 0); addRow("", 0);
render();
})(); })();
</script> </script>
{% endif %} {% endif %}
@@ -1,6 +1,6 @@
{% extends "erp_base.html" %} {% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260829a" />{% endblock %} {% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260829b" />{% endblock %}
{% block content %} {% block content %}
<section class="cpg"> <section class="cpg">
@@ -1,6 +1,6 @@
{% extends "erp_base.html" %} {% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260829a" />{% endblock %} {% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260829b" />{% endblock %}
{% block content %} {% block content %}
<section class="cpg"> <section class="cpg">
@@ -1,6 +1,6 @@
{% extends "erp_base.html" %} {% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260829a" />{% endblock %} {% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260829b" />{% endblock %}
{% block content %} {% block content %}
<section class="cpg"> <section class="cpg">
@@ -1,6 +1,6 @@
{% extends "erp_base.html" %} {% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260829a" />{% endblock %} {% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260829b" />{% endblock %}
{% block content %} {% block content %}
<section class="cpg"> <section class="cpg">
@@ -1,6 +1,6 @@
{% extends "erp_base.html" %} {% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260829a" />{% endblock %} {% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260829b" />{% endblock %}
{% block content %} {% block content %}
<section class="cpg"> <section class="cpg">
@@ -1,6 +1,6 @@
{% extends "erp_base.html" %} {% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260829a" />{% endblock %} {% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260829b" />{% endblock %}
{% block content %} {% block content %}
<section class="cpg"> <section class="cpg">
+72
View File
@@ -577,3 +577,75 @@
stroke-width: 3px; stroke-width: 3px;
stroke-linejoin: round; stroke-linejoin: round;
} }
/* ── 박스 계산 3열 작업 영역 (① 계산 / ② 요약 / ③ 센터 분배) ── */
.cpg-calc3 {
display: grid;
grid-template-columns: minmax(0, 0.85fr) minmax(0, 1.25fr) minmax(0, 0.9fr);
gap: 16px;
align-items: stretch;
}
.cpg-calc3 > .erp-card { margin-bottom: 0; display: flex; flex-direction: column; }
@media (max-width: 1500px) {
.cpg-calc3 { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
.cpg-calc3 > .cpg-dist-card { grid-column: 1 / -1; }
}
@media (max-width: 1000px) {
.cpg-calc3 { grid-template-columns: minmax(0, 1fr); }
.cpg-calc3 > .cpg-dist-card { grid-column: auto; }
}
/* ② 요약: 카드가 드래그 가능함을 알린다 */
.cpg-sum-prod[draggable="true"], .cpg-box-card[draggable="true"] { cursor: grab; }
.cpg-sum-prod[draggable="true"]:hover, .cpg-box-card[draggable="true"]:hover {
border-color: var(--color-rich-black);
}
.cpg-sum-prod.is-dragging, .cpg-box-card.is-dragging { opacity: .45; }
.cpg-sum-prod.is-done, .cpg-box-card.is-done { opacity: .5; }
/* ③ 센터 분배 */
.cpg-dist-list {
display: flex;
flex-direction: column;
gap: 10px;
overflow-y: auto;
max-height: min(72vh, 820px);
}
.cpg-dist-center {
border: 1px solid var(--color-subtle-ash);
border-radius: 10px;
padding: 10px 12px;
}
.cpg-dist-center.has-items { border-color: var(--color-rich-black); }
.cpg-dist-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
font-size: 14px;
}
.cpg-dist-drop {
border: 1.5px dashed var(--color-subtle-ash);
border-radius: 8px;
padding: 8px;
min-height: 52px;
transition: background .15s ease, border-color .15s ease;
}
.cpg-dist-drop.is-over {
border-color: var(--color-rich-black);
background: var(--color-ghost-gray);
}
.cpg-dist-hint { font-size: 12px; color: var(--color-midtone-gray); }
.cpg-dist-center.has-items .cpg-dist-hint { display: none; }
.cpg-dist-items { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
.cpg-dist-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
}
.cpg-dist-name { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cpg-dist-qty { width: 62px; min-width: 0; text-align: right; padding: 2px 6px; }
.cpg-dist-unit { font-size: 12px; color: var(--color-midtone-gray); }
.cpg-dist-del { padding: 2px 8px; line-height: 1.4; }