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>
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
// 줄 쌓기 단가산출 시험 페이지(요구 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();
|
||||
Reference in New Issue
Block a user