- 왼쪽 줄 쌓기(항목 · 줄 일곱 종류 · 공식 틀 셋 · 식으로 풀기 · 틀로 저장 · 표에서 고르기 · 끌어 순서) · 오른쪽 경쟁사 꼴 산출근거(분수 기호식 = 값식 = 결과 · 소계 · 합계) · 아래 비목 상자 · 엑셀 글 붙여넣기 · 저장 = 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>
248 lines
8.9 KiB
JavaScript
248 lines
8.9 KiB
JavaScript
// 줄 쌓기 단가산출 시험 페이지(요구 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();
|