// 줄 쌓기 단가산출 시험 페이지(요구 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();