Files
Aislo/tools/line-stack-test/app.js
T
eomsangdonandClaude Opus 5.5 3e64aa2a79 feat(804-1): 시험 페이지 「줄 쌓기 단가산출」 tools/line-stack-test + M01 왼쪽 패널 단추
- 왼쪽 줄 쌓기(항목 · 줄 일곱 종류 · 공식 틀 셋 · 식으로 풀기 · 틀로 저장 · 표에서 고르기 · 끌어 순서) · 오른쪽 경쟁사 꼴 산출근거(분수 기호식 = 값식 = 결과 · 소계 · 합계) · 아래 비목 상자 · 엑셀 글 붙여넣기 · 저장 = localStorage
- 견본 셋 금액 = 거창 파일 원 단위(5,323 · 52,796 · 94,215 비목 넷) · 확인 = node tools/line-stack-test/check.mjs
- 접근 = main.py 정적 마운트 /tools/line-stack-test · vite 프록시 한 줄(개발 포트에서 열림) · M01 왼쪽 패널 맨 아래 「시험: 줄 쌓기」 새 탭
- 라이브러리 0(분수 = CSS) · 지우기 = 폴더 + 표시 주석 셋 덩이

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 23:19:38 +09:00

248 lines
8.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 줄 쌓기 단가산출 시험 페이지(요구 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) =>
`<div class="box"><span>${{ l: "노무비", m: "재료비", e: "경비" }[c]}</span><b>${fmtP(t[c])}</b></div>`,
)
.join("") + `<div class="box sum"><span>계</span><b>${fmtP(t.sum)}</b></div>`;
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)}) → <b class="${same ? "ok" : "bad"}">${same ? "같음" : "다름"}</b>`
: "";
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) => `<button data-sample="${i}">${esc(s.label)}</button>`,
).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 =
`<tr>${TABLE_E.head.map((h) => `<th>${esc(h)}</th>`).join("")}</tr>` +
TABLE_E.rows
.map(
(r) =>
`<tr><th>${esc(r[0])}</th>${r
.slice(1)
.map((v, c) =>
v == null
? "<td></td>"
: `<td><button data-v="${v}" data-src="${esc(`${TABLE_E.title} · ${r[0]} · ${TABLE_E.head[c + 1]}`)}">${v}</button></td>`,
)
.join("")}</tr>`,
)
.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();