읽기만 하는 일위대가표(명칭·규격·수량·단위·합계·노무비·재료비·경비·비고·합계 줄)와 시험값 띠 추가. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr
247 lines
9.1 KiB
TypeScript
247 lines
9.1 KiB
TypeScript
/* =============================================================================
|
||
* 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") }),
|
||
);
|
||
}
|