From 6f1b569cd9bb71d80ea33007d1afcace63f56db7 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 09:34:04 +0900 Subject: [PATCH] =?UTF-8?q?feat(M01-=EC=89=AC=EC=9A=B4=EB=8B=A8=EA=B0=80):?= =?UTF-8?q?=20=EC=8B=A4=EB=AC=B4=20=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80?= =?UTF-8?q?=ED=91=9C=20=C2=B7=20=EC=84=A4=EA=B3=84=20=EB=95=8C=20=EC=A0=95?= =?UTF-8?q?=ED=95=98=EB=8A=94=20=EA=B0=92=20=EB=9D=A0(Sheet)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 읽기만 하는 일위대가표(명칭·규격·수량·단위·합계·노무비·재료비·경비·비고·합계 줄)와 시험값 띠 추가. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr --- .../EasyCalc/M01_EasyCalc_Sheet.css | 112 ++++++++ M01_MasterData/EasyCalc/M01_EasyCalc_Sheet.ts | 246 ++++++++++++++++++ 2 files changed, 358 insertions(+) create mode 100644 M01_MasterData/EasyCalc/M01_EasyCalc_Sheet.css create mode 100644 M01_MasterData/EasyCalc/M01_EasyCalc_Sheet.ts diff --git a/M01_MasterData/EasyCalc/M01_EasyCalc_Sheet.css b/M01_MasterData/EasyCalc/M01_EasyCalc_Sheet.css new file mode 100644 index 000000000..3a57c2b6f --- /dev/null +++ b/M01_MasterData/EasyCalc/M01_EasyCalc_Sheet.css @@ -0,0 +1,112 @@ +.m01-easysheet__title { + margin: 0 0 var(--spacing-8); + color: var(--color-text-body); + font-size: var(--text-body); + font-weight: 600; +} + +.m01-easysheet__status, +.m01-easysheet__muted { + margin: 0; + color: var(--color-text-muted); + font-size: var(--text-body-sm); +} + +/* 좁은 폭은 표 안에서만 가로 스크롤 — 쪽 전체는 안 밀림 */ +.m01-easysheet__scroll { + max-width: 100%; + overflow-x: auto; +} + +.m01-easysheet__table { + width: 100%; + min-width: 920px; + border-collapse: collapse; + color: var(--color-text-body); + font-size: var(--text-body-sm); +} + +.m01-easysheet__table th, +.m01-easysheet__table td { + padding: var(--spacing-4) var(--spacing-8); + border: 1px solid var(--color-border); + text-align: left; + vertical-align: middle; +} + +.m01-easysheet__table thead th { + background: var(--color-surface); + color: var(--color-text-secondary); + font-weight: 600; + white-space: nowrap; +} + +.m01-easysheet__table .m01-easysheet__group { + text-align: center; +} + +.m01-easysheet__table .m01-easysheet__num { + text-align: right; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.m01-easysheet__table .m01-easysheet__note { + color: var(--color-text-muted); + font-size: var(--text-caption); +} + +.m01-easysheet__table tfoot th, +.m01-easysheet__table tfoot td { + background: var(--color-surface); + font-weight: 700; +} + +.m01-easysheet__strip-title { + color: var(--color-text-body); +} + +.m01-easysheet__fields { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-8) var(--spacing-16); + margin: var(--spacing-8) 0; +} + +.m01-easysheet__field { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + min-width: 0; + color: var(--color-text-secondary); + font-size: var(--text-body-sm); +} + +.m01-easysheet__ctl { + display: flex; + flex-direction: column; + gap: var(--spacing-4); +} + +.m01-easysheet__input { + box-sizing: border-box; + width: 160px; + max-width: 100%; + height: var(--control-height-sm); + padding: 3px 6px; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-canvas); + color: var(--color-text-body); + font: inherit; + font-size: var(--text-body-sm); +} + +.m01-easysheet__input[aria-invalid] { + border-color: var(--color-danger); +} + +.m01-easysheet__warn { + color: var(--color-danger); + font-size: var(--text-caption); +} diff --git a/M01_MasterData/EasyCalc/M01_EasyCalc_Sheet.ts b/M01_MasterData/EasyCalc/M01_EasyCalc_Sheet.ts new file mode 100644 index 000000000..ca04ab25e --- /dev/null +++ b/M01_MasterData/EasyCalc/M01_EasyCalc_Sheet.ts @@ -0,0 +1,246 @@ +/* ============================================================================= + * 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") }), + ); +}