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:
2026-11-07 23:19:38 +09:00
co-authored by Claude Opus 5.5
parent d59874d4b4
commit 3e64aa2a79
12 changed files with 1633 additions and 1 deletions
+165
View File
@@ -0,0 +1,165 @@
// 줄 쌓기 단가산출 시험 — 왼쪽 줄 쌓기 편집(종류마다 필요한 칸만 · 칸은 data-p 경로로 doc 에 바로 씀)
import { LABOR, MACHINE, MATERIAL, SANGEUN, TEMPLATES } from "./samples.js";
import { esc } from "./render.js";
export const KINDS = {
mach: "① 기계",
ref: "② 다른 단산 참조",
labor: "③ 인력",
rate: "④ 율",
mat: "⑤ 재료",
formula: "⑥ 공식",
text: "⑦ 글 · 근거",
};
const COLSEL = { l: "노무비", m: "재료비", e: "경비" };
const inp = (p, v, cls = "", ph = "") =>
`<input data-p="${p}" value="${esc(v ?? "")}" class="${cls}" placeholder="${esc(ph)}" />`;
const sel = (p, v, opts) =>
`<select data-p="${p}">${Object.entries(opts)
.map(([k, t]) => `<option value="${k}"${k === v ? " selected" : ""}>${esc(t)}</option>`)
.join("")}</select>`;
const pick = (list, act, label) =>
`<select data-act="${act}"><option value="">${label}</option>${list
.map((r, i) => `<option value="${i}">${esc(r.no ? `단산 ${r.no} ${r.name}` : r.name)}</option>`)
.join("")}</select>`;
export function allTemplates(mine) {
return [...TEMPLATES, ...mine];
}
/** 「+ 줄」 고르개 — 공식은 틀마다 하나씩 */
export function addLineSelect(i, mine) {
const tpls = allTemplates(mine)
.map((t) => `<option value="formula:${t.id}">⑥ 공식 — ${esc(t.name)}</option>`)
.join("");
return `<select data-act="addline" data-i="${i}"><option value="">+ 줄 ▾</option>
<option value="mach">${KINDS.mach}</option><option value="ref">${KINDS.ref}</option>
<option value="labor">${KINDS.labor}</option><option value="rate">${KINDS.rate}</option>
<option value="mat">${KINDS.mat}</option>${tpls}
<option value="formula:">⑥ 공식 — 새 공식(빈 묶음)</option><option value="text">${KINDS.text}</option></select>`;
}
function opsEdit(p, ops) {
const one = (o, k) =>
`<span class="op">${sel(`${p}.ops.${k}.op`, o.op, { "×": "×", "÷": "÷", "/": "/" })}${inp(
`${p}.ops.${k}.v`,
o.v,
"v",
"값 · 이름",
)}${inp(`${p}.ops.${k}.u`, o.u, "u", "단위")}<button data-act="delop" data-p="${p}" data-k="${k}" title="지움">×</button></span>`;
return `<div class="ops">${(ops ?? []).map(one).join("")}<button data-act="addop" data-p="${p}">+ 곱나눔</button></div>`;
}
const copsNote = (ln) =>
ln.cops
? `<div class="note">비목 따로 꼬리(붙여넣기): ${Object.entries(ln.cops)
.map(
([c, ops]) =>
`${COLSEL[c]} ${ops.map((o) => `${o.op} ${esc(o.v)} ${esc(o.u)}`).join(" ")}`,
)
.join(" · ")}</div>`
: "";
/** 공식 줄 — 접힘 = 값 칸만 · 풀림 = 「이름 = 식」 글 줄 전부 */
function formulaEdit(p, ln, r, mine) {
const tpl = allTemplates(mine).find((t) => t.id === ln.tpl);
const changed = tpl && JSON.stringify(shape(tpl.rows)) !== JSON.stringify(shape(ln.rows));
const label = !tpl
? ln.tpl === null && !ln.rows?.some((x) => x.x)
? "새 공식"
: "내 공식"
: changed
? `내 틀 (${esc(tpl.name)} 에서 변경)`
: esc(tpl.name);
const rows = (ln.rows ?? [])
.map((row, k) => {
const rr = r?.rows?.[k] ?? {};
const rp = `${p}.rows.${k}`;
const plain = /^-?[\d.]+$/.test(String(row.x ?? "").trim());
const val = `<span class="val ${rr.err ? "bad" : ""}" data-val="${rp}">${rr.err ? esc(rr.err) : (rr.val ?? "")}</span>`;
const src = row.src ? `<span class="src" title="${esc(row.src)}">표</span>` : "";
const table = `<button data-act="table" data-p="${rp}" title="표에서 고르기">표</button>`;
if (!ln.open) {
if (plain || !row.x)
return `<div class="frow"><b>${esc(row.n || "?")}</b> = ${inp(`${rp}.x`, row.x, "num")} <i>${esc(row.u)}</i> ${table}${src}</div>`;
return `<div class="frow calc"><b>${esc(row.n)}</b> = <code>${esc(row.x)}</code> = ${val} <i>${esc(row.u)}</i></div>`;
}
return `<div class="frow open">${inp(`${rp}.n`, row.n, "name", "이름")} = ${inp(`${rp}.x`, row.x, `expr ${rr.err ? "bad" : ""}`, "값 또는 식")}${inp(
`${rp}.u`,
row.u,
"u",
"단위",
)}${val}${table}${src}<button data-act="delrow" data-p="${p}" data-k="${k}" title="줄 지움">×</button></div>`;
})
.join("");
return `<div class="fhead"><span class="tpl">${label}</span>
<button data-act="fold" data-p="${p}">${ln.open ? "접기" : "식으로 풀기"}</button>
<button data-act="savetpl" data-p="${p}">틀로 저장</button></div>${rows}
${ln.open ? `<button data-act="addrow" data-p="${p}">+ 「이름 = 식」 줄</button>` : ""}`;
}
/** 틀과 견줄 꼴 — 값 줄은 이름만 · 식 줄은 이름과 식 */
export function shape(rows) {
return (rows ?? []).map((x) =>
/^-?[\d.]+$/.test(String(x.x).trim()) ? [x.n] : [x.n, String(x.x).replace(/\s/g, "")],
);
}
function lineBody(p, ln, r, mine) {
const price3 = () =>
`<div class="row">노무 ${inp(`${p}.res.l`, ln.res?.l, "num")} 재료 ${inp(`${p}.res.m`, ln.res?.m, "num")} 경비 ${inp(
`${p}.res.e`,
ln.res?.e,
"num",
)}</div>`;
switch (ln.k) {
case "mach":
return `<div class="row">${pick(MACHINE, `res:mach:${p}`, "견본 기계 ▾")}${inp(`${p}.res.name`, ln.res?.name, "wide", "기계 이름")}</div>${price3()}${opsEdit(p, ln.ops)}${copsNote(ln)}`;
case "ref":
return `<div class="row">${pick(SANGEUN, `res:ref:${p}`, "견본 단산 ▾")} 단산 ${inp(`${p}.res.no`, ln.res?.no, "num s")}${inp(
`${p}.res.name`,
ln.res?.name,
"wide",
"단가산출 이름",
)}</div>${price3()}${opsEdit(p, ln.ops)}${copsNote(ln)}`;
case "labor":
case "mat":
return `<div class="row">${pick(ln.k === "labor" ? LABOR : MATERIAL, `res:${ln.k}:${p}`, ln.k === "labor" ? "견본 노임 ▾" : "견본 자재 ▾")}${inp(
`${p}.res.name`,
ln.res?.name,
"wide",
"이름",
)} 단가 ${inp(`${p}.res.price`, ln.res?.price, "num")}${ln.k === "mat" ? ` → ${sel(`${p}.col`, ln.col || "m", COLSEL)}` : ""}</div>${opsEdit(p, ln.ops)}`;
case "rate":
return `<div class="row">${inp(`${p}.name`, ln.name, "wide", "이름(예: 공구손료 및 경장비)")}</div>
<div class="row">기준 ${sel(`${p}.scope`, ln.scope || "item", { item: "이 항목 위 줄", all: "위 전부" })} 의 ${sel(`${p}.baseCol`, ln.baseCol || "l", COLSEL)}
× ${inp(`${p}.rate`, ln.rate, "num s")} % → ${sel(`${p}.col`, ln.col || "m", COLSEL)}</div>
<div class="row">글 「( ${inp(`${p}.of`, ln.of, "s2", "노무비")}의 % )」 · 소계 이름 ${inp(`${p}.baseName`, ln.baseName, "s2", "비우면 안 찍음")}</div>`;
case "formula":
return formulaEdit(p, ln, r, mine);
case "text":
return inp(`${p}.text`, ln.text, "full", "< 품셈 근거 > · ※ 메모 · 별산");
}
return "";
}
export function renderEdit(doc, res, mine) {
const items = (doc.items ?? [])
.map((item, i) => {
const lines = (item.lines ?? [])
.map((ln, j) => {
const p = `items.${i}.lines.${j}`;
const r = res.items[i]?.lines[j];
return `<div class="line k-${ln.k}" data-loc="${i}.${j}"><div class="lh"><span class="grip" draggable="true" data-loc="${i}.${j}" title="끌어 순서 바꿈">⠿</span><span class="kind">${KINDS[ln.k]}</span>${
r?.err ? `<span class="bad">${esc(r.err)}</span>` : ""
}<button data-act="delline" data-loc="${i}.${j}" title="줄 지움">지움</button></div>${lineBody(p, ln, r, mine)}</div>`;
})
.join("");
return `<div class="item" data-i="${i}"><div class="ih" data-drop="${i}">${inp(`items.${i}.title`, item.title, "title", "항목 이름(비우면 번호 없음)")}
<label><input type="checkbox" data-p="items.${i}.sub" ${item.sub ? "checked" : ""}/>가나다</label>${addLineSelect(i, mine)}
<button data-act="delitem" data-i="${i}">항목 지움</button></div>${lines || '<div class="empty" data-drop="' + i + '">줄 없음 — [+ 줄 ▾]</div>'}</div>`;
})
.join("");
return `<div class="dochead">이름 ${inp("name", doc.name, "wide")} 단위 ${inp("unit", doc.unit, "s")}</div>${items}<button data-act="additem">+ 항목</button>`;
}