// 줄 쌓기 단가산출 시험 페이지(요구 669~676 · PLAN 804-1) — 서버 없음 · 저장 = 이 브라우저 localStorage
// 지우기 = 이 폴더 삭제 + main.py 「/tools/line-stack-test」 마운트 덩이 + vite.config.ts 「/tools」 프록시 줄 + M01 왼쪽 패널 「시험: 줄 쌓기」 덩이
import { compute } from "./calc.js";
import { renderOut, fmtP, esc } from "./render.js";
import { parsePaste } from "./paste.js";
import { LABOR, MACHINE, MATERIAL, SANGEUN, SAMPLES, TABLE_E } from "./samples.js";
import { renderEdit, allTemplates } from "./edit.js";
const KEY_DOC = "lineStackTest.doc";
const KEY_TPL = "lineStackTest.myTemplates";
const $ = (s) => document.querySelector(s);
const clone = (x) => structuredClone(x);
let doc = clone(SAMPLES[0].doc);
let expect = SAMPLES[0].expect;
let expectFrom = SAMPLES[0].label;
let mine = JSON.parse(localStorage.getItem(KEY_TPL) || "[]");
let res = compute(doc);
const get = (path) => path.split(".").reduce((o, k) => o?.[k], doc);
const setPath = (path, v) => {
const ks = path.split(".");
const last = ks.pop();
ks.reduce((o, k) => o[k], doc)[last] = v;
};
/** 칸 값만 바뀜 — 오른쪽 출력 · 합계 · 공식 값 칸만 다시(왼쪽은 그대로 → 입력 자리 안 잃음) */
function update() {
res = compute(doc);
$("#out").innerHTML = renderOut(doc, res);
const t = res.tot;
$("#tot").innerHTML =
["l", "m", "e"]
.map(
(c) =>
`
${{ l: "노무비", m: "재료비", e: "경비" }[c]}${fmtP(t[c])}
`,
)
.join("") + `계${fmtP(t.sum)}
`;
const same = expect && ["l", "m", "e", "sum"].every((c) => t[c] === expect[c]);
$("#cmp").innerHTML = expect
? `${esc(expectFrom)} 거창 파일 ${fmtP(expect.sum)} (노 ${fmtP(expect.l)} · 재 ${fmtP(expect.m)} · 경 ${fmtP(expect.e)}) → ${same ? "같음" : "다름"}`
: "";
document.querySelectorAll("[data-val]").forEach((el) => {
const m = el.dataset.val.match(/^items\.(\d+)\.lines\.(\d+)\.rows\.(\d+)$/);
const rr = res.items[m[1]]?.lines[m[2]]?.rows?.[m[3]];
el.textContent = rr?.err ?? rr?.val ?? "";
el.classList.toggle("bad", !!rr?.err);
el.parentElement.querySelector(".expr")?.classList.toggle("bad", !!rr?.err);
});
}
/** 줄 더함 · 지움 · 옮김 등 꼴이 바뀜 — 왼쪽까지 다시 */
function redraw() {
res = compute(doc);
$("#edit").innerHTML = renderEdit(doc, res, mine);
update();
}
function newLine(kind) {
const [k, tplId] = kind.split(":");
if (k === "formula") {
const tpl = allTemplates(mine).find((t) => t.id === tplId);
return {
k,
tpl: tpl ? tpl.id : null,
rows: tpl ? clone(tpl.rows) : [{ n: "", x: "", u: "" }],
open: !tpl,
};
}
if (k === "mach") return { k, res: { ...MACHINE[0] }, ops: [{ op: "÷", v: "Q", u: "" }] };
if (k === "ref") return { k, res: { ...SANGEUN[0] }, ops: [{ op: "×", v: "Q", u: "" }] };
if (k === "labor") return { k, res: { ...LABOR[0] }, ops: [{ op: "×", v: "1", u: "인" }] };
if (k === "mat")
return { k, col: "m", res: { ...MATERIAL[0] }, ops: [{ op: "×", v: "1", u: "" }] };
if (k === "rate")
return {
k,
name: "공구손료 및 경장비",
of: "노무비",
baseName: "",
baseCol: "l",
scope: "item",
rate: "3",
col: "m",
};
return { k: "text", text: "" };
}
function loadSample(s) {
doc = clone(s.doc);
expect = s.expect;
expectFrom = s.label;
redraw();
}
// ── 머리 단추
$("#samples").innerHTML = SAMPLES.map(
(s, i) => ``,
).join("");
$("#samples").addEventListener("click", (e) => {
const i = e.target.dataset.sample;
if (i !== undefined) loadSample(SAMPLES[i]);
});
$("#save").onclick = () => {
localStorage.setItem(KEY_DOC, JSON.stringify({ doc, expect, expectFrom }));
$("#msg").textContent = "이 브라우저에 저장함";
};
$("#load").onclick = () => {
const s = JSON.parse(localStorage.getItem(KEY_DOC) || "null");
if (!s) return void ($("#msg").textContent = "저장한 것 없음");
({ doc, expect, expectFrom } = s);
redraw();
$("#msg").textContent = "불러옴";
};
// ── 왼쪽 칸 입력
$("#edit").addEventListener("input", (e) => {
const p = e.target.dataset.p;
if (!p) return;
setPath(p, e.target.type === "checkbox" ? e.target.checked : e.target.value);
if (e.target.type === "checkbox") return redraw();
update();
if (/\.rows\.\d+\.[nx]$/.test(p)) {
// 틀 이름표(「내 틀 … 에서 변경」)만 다시 — 입력 자리는 그대로
const lp = p.replace(/\.rows\.\d+\.[nx]$/, "");
const card = e.target.closest(".line");
const fresh = document.createElement("div");
fresh.innerHTML = renderEdit(doc, res, mine);
const [, i, j] = lp.match(/items\.(\d+)\.lines\.(\d+)/);
const tag = fresh.querySelector(`.line[data-loc="${i}.${j}"] .tpl`);
if (tag) card.querySelector(".tpl").innerHTML = tag.innerHTML;
}
});
$("#edit").addEventListener("change", (e) => {
const act = e.target.dataset.act;
if (!act || !e.target.value) return;
if (act === "addline") {
doc.items[e.target.dataset.i].lines.push(newLine(e.target.value));
return redraw();
}
if (act.startsWith("res:")) {
const [, kind, p] = act.split(":");
const list = { mach: MACHINE, ref: SANGEUN, labor: LABOR, mat: MATERIAL }[kind];
get(p).res = { ...list[e.target.value] };
return redraw();
}
});
$("#edit").addEventListener("click", (e) => {
const b = e.target.closest("button");
if (!b) return;
const { act, p, k, loc, i } = b.dataset;
const at = loc?.split(".").map(Number);
if (act === "additem") doc.items.push({ title: "", lines: [] });
else if (act === "delitem") doc.items.splice(Number(i), 1);
else if (act === "delline") doc.items[at[0]].lines.splice(at[1], 1);
else if (act === "addop") (get(p).ops ??= []).push({ op: "×", v: "", u: "" });
else if (act === "delop") get(p).ops.splice(Number(k), 1);
else if (act === "addrow") get(p).rows.push({ n: "", x: "", u: "" });
else if (act === "delrow") get(p).rows.splice(Number(k), 1);
else if (act === "fold") get(p).open = !get(p).open;
else if (act === "savetpl") {
const ln = get(p);
const name = prompt("틀 이름", "내 틀");
if (!name?.trim()) return;
const id = `my${Date.now()}`;
mine.push({ id, name: name.trim(), rows: clone(ln.rows).map(({ src, ...r }) => r) });
localStorage.setItem(KEY_TPL, JSON.stringify(mine));
ln.tpl = id;
} else if (act === "table") return openTable(p);
else return;
redraw();
});
// ── 끌어 순서 바꿈(줄 위 = 그 앞 · 항목 머리 = 그 항목 끝)
let dragFrom = null;
$("#edit").addEventListener("dragstart", (e) => {
dragFrom = e.target.dataset?.loc?.split(".").map(Number) ?? null;
e.dataTransfer?.setData("text/plain", "line");
});
$("#edit").addEventListener("dragover", (e) => {
if (dragFrom && e.target.closest(".line, [data-drop]")) e.preventDefault();
});
$("#edit").addEventListener("drop", (e) => {
if (!dragFrom) return;
e.preventDefault();
const [fi, fj] = dragFrom;
dragFrom = null;
const ln = doc.items[fi].lines.splice(fj, 1)[0];
const card = e.target.closest(".line");
if (card) {
let [ti, tj] = card.dataset.loc.split(".").map(Number);
if (ti === fi && tj > fj) tj--;
doc.items[ti].lines.splice(tj, 0, ln);
} else {
doc.items[Number(e.target.closest("[data-drop]").dataset.drop)].lines.push(ln);
}
redraw();
});
// ── 표에서 고르기(견본 표 하나) — 값 + 출처 꼬리표
function openTable(p) {
const dlg = $("#tbl");
dlg.querySelector("h3").textContent = TABLE_E.title;
dlg.querySelector("table").innerHTML =
`${TABLE_E.head.map((h) => `| ${esc(h)} | `).join("")}
` +
TABLE_E.rows
.map(
(r) =>
`| ${esc(r[0])} | ${r
.slice(1)
.map((v, c) =>
v == null
? " | "
: ` | `,
)
.join("")}
`,
)
.join("");
dlg.onclick = (e) => {
const b = e.target.closest("button[data-v]");
if (b) {
const row = get(p);
row.x = b.dataset.v;
row.src = b.dataset.src;
dlg.close();
redraw();
} else if (e.target.closest("[data-close]")) dlg.close();
};
dlg.showModal();
}
// ── 엑셀 글 붙여넣기
$("#pasteGo").onclick = () => {
const text = $("#paste").value;
if (!text.trim()) return void ($("#pasteMsg").textContent = "붙일 글이 없음");
const { doc: d, expect: ex, stat } = parsePaste(text);
doc = d;
expect = ex;
expectFrom = "붙여넣은 블록";
redraw();
$("#pasteMsg").textContent =
`읽은 줄 ${stat.read} · 글 줄 ${stat.text}${ex ? "" : " · 머리 합계 없음(비교 안 함)"}`;
};
redraw();