/* ============================================================================= * calc_sheet.ts * 공용 「단가산출 계산서」(PLAN 714-2 · 요구서 내역12 4장 5 · 6) — 704-2 M02 계산서 보기를 올린 것. * 쓰는 곳 = M01 「인력·재료·경비」 보기 · M02 기준 목록 계산서 보기(704-2) · (뒤에) 일위대가 모달 · B09 단가산출근거 * 꼴 둘 — * 비목(기본 · 704-2) = 비목 묶음마다 식 줄(이름 | 단가 × 품 | 금액) · 소계 · 맨 아래 계 * 묶음(`bundle`) = 인력 · 재료 · 경비(장비) · 하위 — 줄의 `묶음`(714-1 서버 덧칸) 으로 다시 묶음 · * 장비 · 하위 로직 = 한 줄(노 · 재 · 경 칸 · 합) + 누르면 펼침(그 줄의 노 · 재 · 경 식) · * 묶음 머리 = 이름 · 기준단위 · 환산 근거 · 그 묶음이 쓰는 입력 칸(`쓰는입력`) · 맨 아래 비목 소계 셋 + 계 * 값 = 서버 셈 그대로(묶음 소계만 서버 줄 금액의 합) · 변수 칸 바꾸면 `onInput(이름, 값)` — 부르는 쪽이 다시 셈 * 부품 = `ui_template_calc_row`(값 줄 · 식 줄) · 입력 · 고르기 · 글 — 날 단추 · 표 · 페이지 CSS 없음 * ========================================================================== */ import { createCalcFormula, createCalcFormulaRow, createCalcRow } from "@ui/ui_template_calc_row"; import { el } from "@ui/ui_template_elements"; import { createInputField, createSelectField } from "@ui/ui_template_input"; import { currentLanguageIndex } from "@ui/ui_template_locale"; import { createText } from "@ui/ui_template_text"; import "./calc_sheet.css"; export interface SheetLine { 이름: string; /** 서버 값 식 「단가 * 품 = 금액」 — 「= 금액」 은 떼고 「*」 → 「×」 */ 글: string; 금액: number | null; /** 714-1 서버 덧칸 — 그 줄 값이 쓰는 로직 입력 · 묶음 · 장비 · 찾는 표 기준 · 환산 근거 */ 쓰는입력?: string[]; 묶음?: string | null; 장비?: boolean | null; 기준단위?: string | null; 환산근거?: string; /** 714-1b 장비 줄 펼침 — 부른 기계경비 로직의 시간당 줄(조종원 · 주연료 · 잡재료비 · 기계손료) */ 펼침?: SheetUnfold | null; } export interface SheetUnfold { 키: string; 이름: string; 단위: string; 줄: { 비목: string; 이름: string; 글: string; 금액: number | null }[]; 계: number | null; } export interface SheetGroup { 비목: string; 소계: number | null; 끝수?: string; 줄: SheetLine[]; } export interface SheetInput { 이름: string; 단위?: string; 고르기?: (string | number)[]; } export interface SheetOptions { 묶음: SheetGroup[]; 계: number | null; /** 맨 아래 계 줄 이름(「계 (원/㎡)」) */ sumLabel: string; /** 인력 · 재료 · 경비(장비) 묶음으로 다시 묶음(없으면 서버 비목 묶음 그대로 · 704-2) */ bundle?: boolean; /** 변수 칸 — 입력 꼴 · 지금 값 · 바꾼 뒤 할 일(없으면 변수 칸 없음) · 칸 = 묶음 줄들의 `쓰는입력` */ inputs?: SheetInput[]; values?: Record; onInput?: (name: string, value: string) => void; } /* ── 순수 셈 ── */ /** 값 식 → 식 칸 글 — 「268,908 * 0.12 = 32,269」 → 「268,908 × 0.12」 */ export const sheetFormula = (text: string): string => text.replace(/ = [^=]*$/, "").replaceAll(" * ", " × "); /** 장비 · 하위 로직 한 줄 — 비목마다 몫 줄(서버 줄 그대로) · 합 */ export interface SheetMerged { 이름: string; 몫: { 비목: string; 줄: SheetLine }[]; 금액: number | null; } export interface SheetBundle { 묶음: string; 줄: (SheetLine | SheetMerged)[]; 소계: number; 쓰는입력: string[]; 기준단위: string[]; 환산근거: string[]; } const ORDER = ["인력", "재료", "경비", "하위"]; const OF_COST: Record = { 노무비: "인력", 재료비: "재료", 경비: "경비" }; const add = (a: number | null, b: number | null): number | null => a === null && b === null ? null : (a ?? 0) + (b ?? 0); const merges = (l: SheetLine): boolean => !!l.장비 || l.묶음 === "하위"; /** 비목 묶음 → 인력 · 재료 · 경비 · 하위 묶음 — 장비 · 하위 줄은 비목마다 갈린 몫을 한 줄로 * (같은 이름 k 번째끼리 짝 — 서버 줄 차례 = 호표 차례 · 비목마다 같은 차례) */ export function bundleLines(groups: SheetGroup[]): SheetBundle[] { const bundles = new Map(); const merged = new Map(); const take = (name: string): SheetBundle => { let b = bundles.get(name); if (!b) { b = { 묶음: name, 줄: [], 소계: 0, 쓰는입력: [], 기준단위: [], 환산근거: [] }; bundles.set(name, b); } return b; }; for (const g of groups) { const seen = new Map(); for (const line of g.줄) { const b = take(line.묶음 || OF_COST[g.비목] || g.비목); b.소계 += line.금액 ?? 0; for (const n of line.쓰는입력 ?? []) if (!b.쓰는입력.includes(n)) b.쓰는입력.push(n); if (line.기준단위 && !b.기준단위.includes(line.기준단위)) b.기준단위.push(line.기준단위); if (line.환산근거 && !b.환산근거.includes(line.환산근거)) b.환산근거.push(line.환산근거); if (!merges(line)) { b.줄.push(line); continue; } const k = seen.get(line.이름) ?? 0; seen.set(line.이름, k + 1); const id = `${b.묶음}\n${line.이름}\n${k}`; let m = merged.get(id); if (!m) { m = { 이름: line.이름, 몫: [], 금액: null }; merged.set(id, m); b.줄.push(m); } m.몫.push({ 비목: g.비목, 줄: line }); m.금액 = add(m.금액, line.금액); } } const rank = (x: string): number => (ORDER.includes(x) ? ORDER.indexOf(x) : ORDER.length); return [...bundles.values()].sort((a, b) => rank(a.묶음) - rank(b.묶음)); } /* ── 순수 셈 끝 ── */ const TEXT = { 인력: ["인력", "Labor"], 재료: ["재료", "Material"], 경비: ["경비(장비)", "Expense (equipment)"], 하위: ["하위 로직", "Sub logic"], Basis: ["기준", "Basis"], Short: ["노 {a} · 재 {b} · 경 {c}", "L {a} · M {b} · E {c}"], Fold: ["누르면 펼침", "Click to expand"], Per: ["{n} — {v} 원/{u}", "{n} — {v} KRW/{u}"], } as const satisfies Record; const t = (k: keyof typeof TEXT): string => TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0]; const bundleName = (b: string): string => (b in TEXT ? t(b as keyof typeof TEXT) : b); const won = (v: number | null, digits = 0): string => v === null ? "-" : v.toLocaleString("ko-KR", { maximumFractionDigits: digits }); const out = (v: number | null, key = false, digits = 0): HTMLElement => el("span", { className: `ui-calc-row__out${key ? " is-key" : ""}`, text: won(v, digits) }); const lineRow = (line: SheetLine, label = line.이름): HTMLElement => createCalcFormulaRow(label, createCalcFormula(sheetFormula(line.글)), out(line.금액, false, 1)); /** 변수 칸 하나 — 고르기 있으면 고르기 · 아니면 글 칸(Enter · 떠남 때 넘김) */ function varRow(spec: SheetInput, value: string, onInput: (v: string) => void): HTMLElement { let control: HTMLElement; if (spec.고르기?.length) { control = createSelectField({ options: spec.고르기.map((o) => ({ value: String(o), text: String(o) })), value, compact: true, ariaLabel: spec.이름, onChange: onInput, }).root; } else { const box = createInputField({ value, compact: true }); box.input.setAttribute("aria-label", spec.이름); box.input.addEventListener("change", () => { if (box.input.value.trim() && box.input.value !== value) onInput(box.input.value.trim()); }); control = box.root; } const row = createCalcRow(spec.단위 ? `${spec.이름} (${spec.단위})` : spec.이름, control); row.dataset.sheetVar = spec.이름; return row; } /** 장비 · 하위 한 줄 — 노 · 재 · 경 칸 + 합 · 누르면(Enter · Space) 몫 식 줄 펼침 */ function mergedRows(m: SheetMerged): HTMLElement[] { const share = (cost: string): number | null => m.몫.filter((x) => x.비목 === cost).reduce((s, x) => add(s, x.줄.금액), null); const short = t("Short") .replace("{a}", won(share("노무비"), 1)) .replace("{b}", won(share("재료비"), 1)) .replace("{c}", won(share("경비"), 1)); const head = createCalcFormulaRow(`▸ ${m.이름}`, createCalcFormula(short), out(m.금액, false, 1)); // 펼침 = 부른 기계경비 로직 시간당 줄(손료 · 주연료 · 잡품 · 운전사 · 714-1b) → 이 줄 노 · 재 · 경 몫 식 const unfold = m.몫.find((x) => x.줄.펼침)?.줄.펼침; const parts = [ ...(unfold ? [ createText( t("Per") .replace("{n}", unfold.이름) .replace("{v}", won(unfold.계, 1)) .replace("{u}", unfold.단위 || "-"), "hint", ), ...unfold.줄.map((x) => lineRow(x, `${x.이름} (${x.비목})`)), ] : []), ...m.몫.map((x) => lineRow(x.줄, `${x.비목}`)), ]; for (const p of parts) { p.hidden = true; p.dataset.sheetPart = m.이름; } head.dataset.sheetEquip = m.이름; head.title = t("Fold"); head.tabIndex = 0; head.setAttribute("role", "button"); head.setAttribute("aria-expanded", "false"); const label = head.firstElementChild as HTMLElement; const toggle = (): void => { const open = head.getAttribute("aria-expanded") !== "true"; head.setAttribute("aria-expanded", String(open)); label.textContent = `${open ? "▾" : "▸"} ${m.이름}`; parts.forEach((p) => (p.hidden = !open)); }; head.addEventListener("click", toggle); head.addEventListener("keydown", (ev) => { if (ev.key !== "Enter" && ev.key !== " ") return; ev.preventDefault(); toggle(); }); return [head, ...parts]; } /** 묶음 머리 — 이름 · 기준 · 환산 근거 · 쓰는 입력 칸 */ function headRows( name: string, uses: string[], o: SheetOptions, specs: Map, basis: string[] = [], notes: string[] = [], ): HTMLElement[] { const vars = o.onInput ? uses.filter((n) => specs.has(n)) : []; if (!vars.length && !o.bundle) return []; const title = [name, basis.length ? `${t("Basis")} ${basis.join(" · ")}` : ""].filter(Boolean); const head = createText(title.join(" — "), "muted", 14); head.dataset.sheetHead = name; const rows: HTMLElement[] = [head]; if (notes.length) rows.push(createText(notes.join(" · "), "hint")); const onInput = o.onInput; if (onInput) for (const n of vars) rows.push(varRow(specs.get(n)!, o.values?.[n] ?? "", (v) => onInput(n, v))); return rows; } const subtotal = (g: SheetGroup): HTMLElement => createCalcFormulaRow(`${g.비목}${g.끝수 ? ` (${g.끝수})` : ""}`, null, out(g.소계, true)); /** 계산서 줄들 — 부르는 쪽 세로 칸(flex column)에 그대로 붙임 */ export function calcSheetRows(o: SheetOptions): HTMLElement[] { const rows: HTMLElement[] = []; const specs = new Map((o.inputs ?? []).map((s) => [s.이름, s])); if (o.bundle) { for (const b of bundleLines(o.묶음)) { const name = bundleName(b.묶음); rows.push(...headRows(name, b.쓰는입력, o, specs, b.기준단위, b.환산근거)); for (const line of b.줄) rows.push(...("몫" in line ? mergedRows(line) : [lineRow(line)])); rows.push(createCalcFormulaRow(name, null, out(b.소계, true, 1))); } rows.push(...o.묶음.map(subtotal)); // 맨 아래 비목 소계(서버 끝수 그대로) — 장비 노무는 여기서 인력 노무와 더해짐 } else { for (const g of o.묶음) { const uses = [...new Set(g.줄.flatMap((l) => l.쓰는입력 ?? []))]; rows.push(...headRows(g.비목, uses, o, specs)); rows.push(...g.줄.map((line) => lineRow(line)), subtotal(g)); } } rows.push(createCalcFormulaRow(o.sumLabel, null, out(o.계, true))); return rows; }