Files
Aislo/M01_MasterData/EasyCalc/M01_EasyCalc_Sheet.ts
T
eomsangdonandClaude Sonnet 5.5 6f1b569cd9 feat(M01-쉬운단가): 실무 일위대가표 · 설계 때 정하는 값 띠(Sheet)
읽기만 하는 일위대가표(명칭·규격·수량·단위·합계·노무비·재료비·경비·비고·합계 줄)와 시험값 띠 추가.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr
2026-10-01 09:34:04 +09:00

247 lines
9.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* 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<string, string> = { MT: "재료", EQ: "기계", LB: "인력" };
const nameCache = new Map<string, string | null>();
async function keyName(key: string): Promise<string | null> {
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<string, unknown>;
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<string, unknown>,
onChange: (trial: Record<string, unknown>) => 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") }),
);
}