/* ============================================================================= * M01_EasyCalc_Sheet.ts * 쉬운 단가산출 — 실무 일위대가표(읽기만) · 「설계 때 정하는 값」 띠(시험값만, 저장 안 됨) * 계약 = docs/raw/M01_쉬운단가/설계.md 3절 Sheet. 이 폴더째 지워도 됨. * ========================================================================== */ import { currentLanguageIndex } from "@ui/ui_template_locale"; import { createSelectField, el } from "@ui/ui_template_elements"; import { searchElements, type CalcAnswer, type CalcLine, type LogicInput, } from "../M01_MasterData_UI_Logic_Api"; import { formatMoney, formatPrice } from "../M01_MasterData_UI_Logic_Money"; import { safeReason } from "../M01_MasterData_UI_Logic_Text"; import "./M01_EasyCalc_Sheet.css"; const TEXT = { Busy: ["계산 중", "Calculating"], Stopped: ["계산하지 못했어요", "Could not calculate"], Name: ["명칭", "Name"], Spec: ["규격", "Size"], Qty: ["수량", "Qty"], Unit: ["단위", "Unit"], Total: ["합계", "Total"], Labor: ["노무비", "Labor"], Material: ["재료비", "Material"], Expense: ["경비", "Expense"], Note: ["비고", "Note"], Price: ["단가", "Price"], Amount: ["금액", "Amount"], Strip: ["설계 때 정하는 값", "Values set at design time"], Trial: ["여기 값은 시험용 — 저장 안 됨", "Trial values only — not saved"], Range: ["{a} ∼ {b} 사이", "between {a} and {b}"], Pick: ["고르세요", "Choose"], Bad: ["숫자를 넣어 주세요", "Enter a number"], OutOfRange: ["범위 밖이에요", "Out of range"], } as const; const ts = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex]; const COSTS = ["노무비", "재료비", "경비"] as const; const COST_HEAD = { 노무비: "Labor", 재료비: "Material", 경비: "Expense" } as const; const qty = (n: number): string => n.toLocaleString("ko-KR", { maximumFractionDigits: 6 }); const cell = (text: string, className = ""): HTMLElement => el("td", { className, text }); const money = (text: string): HTMLElement => cell(text, "m01-easysheet__num"); /** 준용 · 산정 글에 붙은 마스터 번호는 뺌 */ const note = (line: CalcLine): string => [line.출처?.replace(/[A-Z]{2}\d{6}\./g, ""), line.단위경고].filter(Boolean).join(" · "); function lineRow(line: CalcLine): HTMLElement { // 비목별 단가 = 줄 단가 × (그 비목 금액 ÷ 줄 금액) — 금액 반올림 오차가 단가에 안 섞임 const per = (k: string): number => line.금액 ? (line.단가 * (line.비목?.[k] ?? 0)) / line.금액 : 0; return el("tr", { children: [ cell(line.이름), cell(line.품목?.규격 ?? ""), money(qty(line.수량)), cell(line.단위), money(formatPrice(line.단가)), money(formatMoney(line.금액)), ...COSTS.flatMap((k) => [ money(formatPrice(per(k))), money(formatMoney(line.비목?.[k] ?? 0)), ]), cell(note(line), "m01-easysheet__note"), ], }); } /** 실무 일위대가표 — 읽기만 · 맨 아래 합계 줄 */ export function renderSheet( host: HTMLElement, head: { name: string; resultUnit: string }, ans: CalcAnswer | null, ): void { const title = el("p", { className: "m01-easysheet__title", text: [head.name, head.resultUnit].filter(Boolean).join(" · "), }); if (!ans || !ans.ok) { host.replaceChildren( title, el("p", { className: "m01-easysheet__status", text: ans ? `${ts("Stopped")} — ${safeReason(ans.reason)}` : ts("Busy"), }), ); return; } const lines = ans.lines ?? []; const sums = ans.sums ?? {}; const group = (label: string): HTMLElement => el("th", { className: "m01-easysheet__group", text: label, attrs: { colspan: "2" } }); const one = (label: string): HTMLElement => el("th", { text: label, attrs: { rowspan: "2" } }); const pair = (): HTMLElement[] => [ts("Price"), ts("Amount")].map((h) => el("th", { text: h })); const table = el("table", { className: "m01-easysheet__table", children: [ el("thead", { children: [ el("tr", { children: [ one(ts("Name")), one(ts("Spec")), one(ts("Qty")), one(ts("Unit")), group(ts("Total")), ...COSTS.map((k) => group(ts(COST_HEAD[k]))), one(ts("Note")), ], }), el("tr", { children: [...pair(), ...pair(), ...pair(), ...pair()] }), ], }), el("tbody", { children: lines.map(lineRow) }), el("tfoot", { children: [ el("tr", { children: [ el("th", { text: ts("Total"), attrs: { colspan: "4" } }), cell(""), money(formatMoney(sums["계"] ?? 0)), ...COSTS.flatMap((k) => [cell(""), money(formatMoney(sums[k] ?? 0))]), cell(""), ], }), ], }), ], }); host.replaceChildren(title, el("div", { className: "m01-easysheet__scroll", children: [table] })); } /** 마스터 번호 꼴 고르기 값 → 이름(한 번 물은 것은 다시 안 물음) */ const KEY_RE = /^[A-Z]{2}\d{6}$/; const KEY_GROUP: Record = { MT: "재료", EQ: "기계", LB: "인력" }; const nameCache = new Map(); async function keyName(key: string): Promise { if (nameCache.has(key)) return nameCache.get(key)!; const group = KEY_GROUP[key.slice(0, 2)]; let name: string | null = null; if (group) { try { const hit = (await searchElements(group, key)).items.find((it) => it.ref === key); name = hit ? `${hit.이름 ?? ""}${hit.규격 ? ` ${hit.규격}` : ""}`.trim() || null : null; } catch { name = null; } } nameCache.set(key, name); return name; } /** state = 띠가 들고 있는 시험값(고칠 때마다 같은 객체를 바꿈) · emit = 바뀐 값을 밖에 알림 */ type Trial = Record; function numberBox(spec: LogicInput, state: Trial, emit: () => void): HTMLElement { const [lo, hi] = spec.범위 ?? []; const hint = spec.범위 ? ts("Range").replace("{a}", String(lo)).replace("{b}", String(hi)) : ""; const box = el("input", { className: "m01-easysheet__input", attrs: { type: "text", inputmode: "decimal", placeholder: hint }, }); const warn = el("span", { className: "m01-easysheet__warn" }); box.value = state[spec.이름] === undefined ? "" : String(state[spec.이름]); box.addEventListener("change", () => { const raw = box.value.trim(); const num = Number(raw); const bad = raw !== "" && Number.isNaN(num) ? ts("Bad") : ""; const out = !bad && raw !== "" && spec.범위 && (num < lo! || num > hi!) ? ts("OutOfRange") : ""; warn.textContent = bad || out; box.toggleAttribute("aria-invalid", !!(bad || out)); if (bad) return; if (raw === "") delete state[spec.이름]; else state[spec.이름] = num; emit(); }); return el("span", { className: "m01-easysheet__ctl", children: [box, warn] }); } function pickBox(spec: LogicInput, state: Trial, emit: () => void): HTMLElement { const picks = spec.고르기 ?? []; const optionText = (o: string | number): string => (typeof o === "string" && nameCache.get(o)) || String(o); const options = (): { value: string; text: string }[] => [ { value: "", text: ts("Pick") }, ...picks.map((o) => ({ value: String(o), text: optionText(o) })), ]; const cur = state[spec.이름] === undefined ? "" : String(state[spec.이름]); const field = createSelectField({ options: options(), value: cur, compact: true, ariaLabel: spec.이름, onChange: (v) => { const hit = picks.find((o) => String(o) === v); // 원래 값(수) 그대로 보냄 if (hit === undefined) delete state[spec.이름]; else state[spec.이름] = hit; emit(); }, }); const keys = picks.filter((o): o is string => typeof o === "string" && KEY_RE.test(o)); if (keys.length) { void Promise.all(keys.map(keyName)).then(() => field.setOptions(options(), cur)); } return field.root; } /** 「설계 때 정하는 값」 띠 — 바꾸면 onChange(새 시험값) · 값이 없으면 띠를 숨김 */ export function renderTrialStrip( host: HTMLElement, inputs: LogicInput[], trial: Record, onChange: (trial: Record) => void, ): void { if (!inputs.length) { host.replaceChildren(); host.hidden = true; return; } host.hidden = false; const state = { ...trial }; const emit = (): void => onChange({ ...state }); host.replaceChildren( el("strong", { className: "m01-easysheet__strip-title", text: ts("Strip") }), el("div", { className: "m01-easysheet__fields", children: inputs.map((spec) => el("label", { className: "m01-easysheet__field", children: [ el("span", { text: spec.단위 ? `${spec.이름} (${spec.단위})` : spec.이름 }), spec.고르기?.length ? pickBox(spec, state, emit) : numberBox(spec, state, emit), ], }), ), }), el("p", { className: "m01-easysheet__muted", text: ts("Trial") }), ); }