feat(malaysia): 랙 보기 드래그 재배치·저장·인쇄 미리보기

랙 보기에서 칸을 드래그해 다른 칸으로 구성을 옮긴다(채워진 칸끼리 맞교환,
Side A↔B 가능). 칸 번호는 유지되고 수량 합계도 불변(위치만). 저장하면 그
배치로 기록되며, 재고조사 '이전값 불러오기'가 이 최종 위치를 불러온다.

- db.reposition_rack: 칸(cell_code) 배치만 교체, daily_stocktake_line 보존,
  확정본도 허용(취소만 불가). 위치 이동은 전산재고에 영향 없음
- db.previous_rack_entries: 정렬을 랙 저장시각(MAX created_at) 기준으로 통일 →
  '이전값 불러오기'가 랙 보기 최종 위치와 일치
- POST /malaysia/rack/save: latest_rack_stocktake 대상 reposition
- rack_view.html + malaysia_rack_view.js: HTML5 드래그 이동/swap, 칸 체크 선택,
  저장(병렬배열 직렬화), 인쇄 미리보기 모달
- 인쇄: 선택한 칸을 칸마다 A4 가로 1장, 굵은 대형 글씨(@media print),
  미리보기 후 인쇄
- i18n 항목 추가, malaysia.js 캐시버스트 v20260615c

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 18:06:01 +09:00
parent 2c0eeb5502
commit 6763b2af68
6 changed files with 399 additions and 14 deletions
+220
View File
@@ -0,0 +1,220 @@
/* 말레이시아 창고 랙 보기 — 드래그 재배치 + 저장 + 인쇄 미리보기.
*
* - 각 .rv-cell 의 data-entries(JSON)가 진실원천. 드래그 드롭으로 두 칸의
* 구성(entries)을 맞교환(빈 칸이면 이동). 칸 번호(cell)는 그대로.
* - 저장: 모든 칸의 현재 구성을 rk_cell/rk_sku/rk_upb/rk_box 병렬배열 hidden
* 으로 만들어 POST /malaysia/rack/save (서버 reposition_rack).
* - 인쇄: 체크한 칸을 칸마다 A4 가로 1장으로 미리보기 후 window.print().
* - 언어: localStorage('mys_lang'). 셀/미리보기 텍스트를 현재 언어로 렌더.
*/
(function () {
"use strict";
function lang() { return localStorage.getItem("mys_lang") === "en" ? "en" : "ko"; }
function t(ko, en) { return lang() === "en" ? en : ko; }
function fmt(n) { return Number(n || 0).toLocaleString(); }
function nameOf(e) { return (lang() === "en" ? e.en : e.ko) || e.sku; }
function getEntries(cell) {
try { return JSON.parse(cell.getAttribute("data-entries") || "[]"); }
catch (_) { return []; }
}
function setEntries(cell, arr) {
cell.setAttribute("data-entries", JSON.stringify(arr));
}
function renderCell(cell) {
var items = cell.querySelector(".rv-items");
if (!items) return;
var arr = getEntries(cell);
items.innerHTML = "";
if (!arr.length) {
cell.classList.add("is-empty");
var s = document.createElement("span");
s.className = "rv-empty-note";
s.textContent = "—";
items.appendChild(s);
return;
}
cell.classList.remove("is-empty");
arr.forEach(function (e) {
var total = (Number(e.upb) || 0) * (Number(e.box) || 0);
var wrap = document.createElement("div");
wrap.className = "rv-item";
var nm = document.createElement("span");
nm.className = "rv-name";
nm.textContent = nameOf(e);
var calc = document.createElement("span");
calc.className = "rv-calc";
calc.innerHTML = fmt(e.upb) + t("개", "ea") + " × " + fmt(e.box) + t("박스", "box")
+ " = <span class=\"rv-qty\">" + fmt(total) + "</span>";
wrap.appendChild(nm);
wrap.appendChild(document.createElement("br"));
wrap.appendChild(calc);
items.appendChild(wrap);
});
}
function renderAll() {
document.querySelectorAll(".rv-cell").forEach(renderCell);
}
// ── 드래그 재배치 ──
var dragSrc = null;
var dirty = false;
function markDirty() {
dirty = true;
var b = document.getElementById("rv-save-btn");
if (b) b.classList.add("is-dirty");
}
function cleanup() {
if (dragSrc) dragSrc.classList.remove("dragging");
dragSrc = null;
document.querySelectorAll(".rv-cell.is-dragover")
.forEach(function (c) { c.classList.remove("is-dragover"); });
}
function onDragStart(e) {
var cell = e.target.closest(".rv-cell");
if (!cell) return;
dragSrc = cell;
cell.classList.add("dragging");
e.dataTransfer.effectAllowed = "move";
try { e.dataTransfer.setData("text/plain", cell.getAttribute("data-cell")); } catch (_) {}
}
function onDragOver(e) {
if (!dragSrc) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
var cell = e.target.closest(".rv-cell");
if (cell && cell !== dragSrc) cell.classList.add("is-dragover");
}
function onDragLeave(e) {
var cell = e.target.closest(".rv-cell");
if (cell) cell.classList.remove("is-dragover");
}
function onDrop(e) {
e.preventDefault();
var dst = e.target.closest(".rv-cell");
if (!dst || !dragSrc || dst === dragSrc) { cleanup(); return; }
var a = getEntries(dragSrc);
var b = getEntries(dst);
setEntries(dragSrc, b);
setEntries(dst, a);
renderCell(dragSrc);
renderCell(dst);
markDirty();
cleanup();
}
// ── 인쇄 선택(체크박스) → 셀 강조 ──
function onChange(e) {
var cb = e.target;
if (!cb.classList || !cb.classList.contains("rv-pick")) return;
var cell = cb.closest(".rv-cell");
if (cell) cell.classList.toggle("is-selected", cb.checked);
}
// ── 저장 ──
function addHidden(parent, name, val) {
var i = document.createElement("input");
i.type = "hidden";
i.name = name;
i.value = val;
parent.appendChild(i);
}
function save() {
var fields = document.getElementById("rv-save-fields");
var form = document.getElementById("rv-save-form");
if (!fields || !form) return;
fields.innerHTML = "";
document.querySelectorAll(".rv-cell").forEach(function (cell) {
var cc = cell.getAttribute("data-cell");
getEntries(cell).forEach(function (e) {
addHidden(fields, "rk_cell", cc);
addHidden(fields, "rk_sku", e.sku);
addHidden(fields, "rk_upb", e.upb);
addHidden(fields, "rk_box", e.box);
});
});
dirty = false;
form.submit();
}
// ── 인쇄 미리보기 ──
function openPreview() {
var picked = [].slice.call(document.querySelectorAll(".rv-cell")).filter(function (c) {
var cb = c.querySelector(".rv-pick");
return cb && cb.checked;
});
if (!picked.length) {
alert(t("인쇄할 칸을 먼저 선택하세요.", "Select cells to print first."));
return;
}
var pages = document.getElementById("rvp-pages");
pages.innerHTML = "";
picked.forEach(function (cell) {
var arr = getEntries(cell);
var sheet = document.createElement("div");
sheet.className = "rvp-sheet";
var code = document.createElement("div");
code.className = "rvp-cell-code";
code.textContent = cell.getAttribute("data-cell");
sheet.appendChild(code);
var list = document.createElement("div");
list.className = "rvp-list";
if (!arr.length) {
var em = document.createElement("div");
em.className = "rvp-empty";
em.textContent = t("(비어 있음)", "(empty)");
list.appendChild(em);
} else {
arr.forEach(function (e) {
var total = (Number(e.upb) || 0) * (Number(e.box) || 0);
var line = document.createElement("div");
line.className = "rvp-line";
var nm = document.createElement("span");
nm.className = "rvp-name";
nm.textContent = nameOf(e);
var calc = document.createElement("span");
calc.className = "rvp-calc";
calc.textContent = fmt(e.upb) + t("개", "ea") + " × " + fmt(e.box)
+ t("박스", "box") + " = " + fmt(total);
line.appendChild(nm);
line.appendChild(calc);
list.appendChild(line);
});
}
sheet.appendChild(list);
pages.appendChild(sheet);
});
document.getElementById("rvp-overlay").classList.add("is-open");
}
function closePreview() {
document.getElementById("rvp-overlay").classList.remove("is-open");
}
document.addEventListener("DOMContentLoaded", function () {
renderAll();
var board = document.getElementById("rv-board");
if (board) {
board.addEventListener("dragstart", onDragStart);
board.addEventListener("dragover", onDragOver);
board.addEventListener("dragleave", onDragLeave);
board.addEventListener("drop", onDrop);
board.addEventListener("dragend", cleanup);
board.addEventListener("change", onChange);
}
var sb = document.getElementById("rv-save-btn");
if (sb) sb.addEventListener("click", save);
var pb = document.getElementById("rv-print-btn");
if (pb) pb.addEventListener("click", openPreview);
var pc = document.getElementById("rvp-close");
if (pc) pc.addEventListener("click", closePreview);
var pp = document.getElementById("rvp-print");
if (pp) pp.addEventListener("click", function () { window.print(); });
var lt = document.getElementById("mys-lang-toggle");
if (lt) lt.addEventListener("click", function () { setTimeout(renderAll, 0); });
window.addEventListener("beforeunload", function (ev) {
if (dirty) { ev.preventDefault(); ev.returnValue = ""; }
});
});
})();