/* ============================================================================= * M01_SL_Result.ts * 시트 로직 — 오른쪽 결과 칸: 큰 합계 · 노무비 · 재료비 · 경비 · 일위대가표(EasyCalc Sheet 에 값을 맞춰 넣음) * · 예시값 띠(입력값 상자의 예시값을 여기서도 바꿈) · 오류 · 빈 입력 한 줄 * 값은 화면 계산(Eval `evalLogic`) 결과를 받음 — 이 파일은 셈하지 않고 읽어서 보여 줌. * 모양은 docs/raw/M01_쉬운단가/시트로직_정함.md 8. 이 폴더째 지워도 됨. * ========================================================================== */ import { currentLanguageIndex } from "@ui/ui_template_locale"; import { createSelectField, el } from "@ui/ui_template_elements"; import type { Cell, DataValidationRule, Sheet, } from "../../A00_Common/spreadsheet/spreadsheet_types"; import type { CalcAnswer, CalcLine } from "../M01_MasterData_UI_Logic_Api"; import { formatMoney } from "../M01_MasterData_UI_Logic_Money"; import { renderSheet } from "../EasyCalc/M01_EasyCalc_Sheet"; import type { SlEvalResult } from "./M01_SL_Eval"; import { PILL_AMOUNT, PILL_PRICE, USED, itemRefOf } from "./M01_SL_Item"; import type { SheetLogic, SlNode, SlPill } from "./M01_SL_Model"; import "./M01_SL_Result.css"; const TEXT = { Per: ["{per} 당", "per {per}"], Total: ["합계", "Total"], Won: ["원", "KRW"], Labor: ["노무비", "Labor cost"], Material: ["재료비", "Material cost"], Expense: ["경비", "Expense"], Sample: ["예시값", "Sample values"], SampleHint: [ "여기서 바꾸면 입력값 상자의 예시값도 바뀌어요", "Changing here changes the input box too", ], Problems: ["고칠 것", "To fix"], Empty: ["「{box}」 상자의 「{pill}」 칸이 비었어요", "“{pill}” in box “{box}” is empty"], Bad: ["숫자를 넣어 주세요", "Enter a number"], Range: ["{a} ∼ {b} 사이", "between {a} and {b}"], Cant: ["지금은 바꿀 수 없어요", "Cannot change this now"], Pick: ["고르세요", "Choose"], RateOf: ["{base}의 %", "% of {base}"], } as const; type TextKey = keyof typeof TEXT; const tr = (key: TextKey, fill: Record = {}): string => TEXT[key][currentLanguageIndex].replace(/\{(\w+)\}/g, (m, k: string) => fill[k] ?? m); const COSTS = ["노무비", "재료비", "경비"] as const; const n0 = (v: unknown): number => (typeof v === "number" && Number.isFinite(v) ? v : 0); const sheetOf = (sl: SheetLogic, node: SlNode): Sheet | undefined => sl.workbook.시트.find((s) => s.id === node.sheetId); const colOf = (cell: string): { r: number; c: number } => ({ r: Number(cell.replace(/\D/g, "")) - 1, c: cell.charCodeAt(0) - 65, }); /** 일위대가표 줄 — 항목 상자 하나 = 줄 하나(출력 알약을 읽음) */ function linesOf(sl: SheetLogic, values: SlEvalResult): CalcLine[] { return sl.nodes .filter((n) => n.kind === "item") .map((n) => { const ref = itemRefOf(n); const p = values.pills[n.id] ?? {}; const unit = n.pillsOut.find((x) => x.name === USED)?.unit ?? ""; const pickName = (name: string): number => n0(p[name]); return { 이름: n.name, 단위: unit, 수량: pickName(USED), 단가: pickName(PILL_PRICE), 금액: pickName(PILL_AMOUNT), 비목: Object.fromEntries(COSTS.map((c) => [c, pickName(c)])), ...(ref?.what === "비율" ? { 품목: { 키: "", 규격: tr("RateOf", { base: ref.base ?? "노무비" }) } } : ref?.spec ? { 품목: { 키: "", 규격: ref.spec } } : {}), }; }); } /** 빈 입력 — 입력 알약 칸에 값도 참조도 없는 것 */ function emptyInputs(sl: SheetLogic): string[] { return sl.nodes.flatMap((n) => { const cells = sheetOf(sl, n)?.칸 ?? {}; return n.pillsIn .filter( (p) => !p.outside && !(cells[p.cell] && ("값" in cells[p.cell] || "식" in cells[p.cell])), ) .map((p) => tr("Empty", { box: n.name, pill: p.name })); }); } /** 예시값 칸 — 선택 목록(유효성)이 걸린 칸은 고르기 · 수범위는 범위 안내 */ function ruleAt(sheet: Sheet, cell: string): DataValidationRule["규칙"] | null { const { r, c } = colOf(cell); const hit = (sheet.유효성 ?? []).find((v) => v.범위.some((g) => r >= g.r0 && r <= g.r1 && c >= g.c0 && c <= g.c1), ); return hit?.규칙 ?? null; } function sampleField( sl: SheetLogic, node: SlNode, pill: SlPill, put: (cell: string, v: number | string) => void, msg: HTMLElement, ): HTMLElement { const sheet = sheetOf(sl, node); const cur: Cell | undefined = sheet?.칸[pill.cell]; const rule = sheet ? ruleAt(sheet, pill.cell) : null; const label = el("span", { text: pill.unit ? `${pill.name} (${pill.unit})` : pill.name }); let control: HTMLElement; if (rule?.종류 === "목록") { const field = createSelectField({ options: [{ value: "", text: tr("Pick") }, ...rule.값들.map((v) => ({ value: v, text: v }))], value: cur?.값 === undefined ? "" : String(cur.값), compact: true, ariaLabel: pill.name, onChange: (v) => { if (v !== "") put(pill.cell, v); }, }); control = field.root; } else { const box = el("input", { className: "m01-slresult__input", attrs: { type: "text", inputmode: "decimal", "aria-label": pill.name }, }); if (rule?.종류 === "수범위") { box.placeholder = tr("Range", { a: String(rule.아래 ?? ""), b: String(rule.위 ?? "") }); } const linked = cur?.식 !== undefined; box.value = linked || cur?.값 === undefined ? "" : String(cur.값); box.readOnly = linked; box.addEventListener("change", () => { const raw = box.value.trim(); const n = Number(raw); if (raw === "" || Number.isNaN(n)) { msg.textContent = tr("Bad"); return; } put(pill.cell, n); }); control = box; } return el("label", { className: "m01-slresult__field", children: [label, control] }); } /** 결과 칸 — values = 화면 계산 결과 · 예시값을 바꾸면 on.change(새 시트 로직) */ export function renderResult( host: HTMLElement, sl: SheetLogic, values: SlEvalResult, on: { change(sl: SheetLogic): void }, ): void { const { 계 } = values.result; const per = sl.resultUnit.includes("/") ? sl.resultUnit.split("/").slice(1).join("/") : ""; const big = el("p", { className: "m01-slresult__big", children: [ el("span", { text: per ? tr("Per", { per: `1${per}` }) : tr("Total") }), el("strong", { text: 계 === null ? "—" : `${formatMoney(계)} ${tr("Won")}` }), ], }); const costs = el("dl", { className: "m01-slresult__costs", children: COSTS.flatMap((c, i) => [ el("dt", { text: tr((["Labor", "Material", "Expense"] as const)[i]) }), el("dd", { text: values.result[c] === null ? "—" : formatMoney(values.result[c]) }), ]), }); const head = { name: sl.name, resultUnit: sl.resultUnit }; const problems = [...values.errors, ...emptyInputs(sl)]; const ans: CalcAnswer = values.errors.length || 계 === null ? { ok: false, reason: values.errors[0] ?? emptyInputs(sl)[0] ?? "" } : { ok: true, lines: linesOf(sl, values), sums: { 노무비: n0(values.result.노무비), 재료비: n0(values.result.재료비), 경비: n0(values.result.경비), 계: n0(계), }, middle: {}, }; const sheetHost = el("div", { className: "m01-slresult__sheet" }); renderSheet(sheetHost, head, ans); const msg = el("p", { className: "m01-slresult__msg" }); const put = (node: SlNode) => (cell: string, v: number | string): void => { msg.textContent = ""; try { const 시트 = sl.workbook.시트.map((s) => s.id === node.sheetId ? { ...s, 칸: { ...s.칸, [cell]: { 값: v } } } : s, ); on.change({ ...sl, workbook: { ...sl.workbook, 시트 } }); } catch { msg.textContent = tr("Cant"); } }; const fields = sl.nodes .filter((n) => n.kind === "input") .flatMap((n) => n.pillsOut.map((p) => sampleField(sl, n, p, put(n), msg))); const strip = fields.length ? [ el("div", { className: "m01-slresult__strip", children: [ el("strong", { text: tr("Sample") }), el("div", { className: "m01-slresult__fields", children: fields }), el("p", { className: "m01-slresult__muted", text: tr("SampleHint") }), msg, ], }), ] : []; host.replaceChildren( big, costs, ...strip, ...(problems.length ? [ el("div", { className: "m01-slresult__problems", children: [ el("strong", { text: tr("Problems") }), el("ul", { children: problems.map((t) => el("li", { text: t })) }), ], }), ] : []), sheetHost, ); }