/* ============================================================================= * M01_MasterData_UI_LogicLab_Ho.ts * 공용 「단가산출 상세」(PLAN 722-1 · 요구서 내역16 2장 6 · 7) — M01 단가산출 로직 상세가 씀 · (724) M02 단가산출 · 일위대가 모달도. * 하위 박스 = 서버 `묶음`(인력 · 재료 · 경비(장비) · 하위 — `/text` 줄 덧칸 714-1) · 계산 전 = 호표 종류로 * · 장비 · 하위 로직 줄 = 박스 안 한 줄(노 · 재 · 경 몫 합) + 누르면 펼침(부른 로직 시간당 줄 · 몫 식 줄) * 박스 제목 바로 아래 = 그 박스 줄들이 쓰는 입력 고르기(`calc_sheet` 변수 칸 꼴) · 어느 박스도 안 쓰는 입력 = 맨 위 「공통 옵션」 * 「계산식」 열 = 값 든 식(「268,908 × 0.12」 · 서버 `글`) + 표 · 요소 · 로직 알약 + 그 줄 입력 칩 · 변수 이름 식(`이름글`)은 마우스 올리면 * 맨 아래 = 비목 소계 셋(노무비 · 재료비 · 경비 — 장비 노무는 여기서 인력과 더해짐) + 계 * 줄 누름 = 그 줄 자료 모달(`_LogicLab_Modal.ts`) · 고침 고리(`edit`) 있으면 모달 안 「그 자리 고침」(`_LogicLab_Cover.ts`) * 값 = 서버 셈 그대로(박스 소계 · 비목 소계만 줄 금액 합) · 화면에서 다시 셈 안 함 * ========================================================================== */ import { createBadge } from "@ui/ui_template_badge"; import { createCalcFormulaRow } from "@ui/ui_template_calc_row"; import { createCard } from "@ui/ui_template_card"; import { el } from "@ui/ui_template_elements"; import { currentLanguageIndex } from "@ui/ui_template_locale"; import { emptyRow, tableRow, tableShell, type Column } from "@ui/ui_template_table"; import { createText } from "@ui/ui_template_text"; import { sheetFormula, varRow, type SheetUnfold } from "../A00_Common/calc_sheet"; import type { CalcLine, ElementBrief, HoLine, LogicRow, NamedFormula, TextAnswer, TextLine, TextPiece, } from "./M01_MasterData_UI_Logic_Api"; import { pctView } from "./M01_MasterData_UI_Logic_Api"; import { formatNumber } from "./M01_MasterData_UI_Logic_Edit"; import { formatMoney, formatPrice } from "./M01_MasterData_UI_Logic_Money"; import { tx } from "./M01_MasterData_UI_Logic_Text"; import { pickMaterial } from "./M01_MasterData_UI_Logic_CalcPick"; import { coverForm, type CoverPart, type LineCover } from "./M01_MasterData_UI_LogicLab_Cover"; import { inputChip } from "./M01_MasterData_UI_LogicLab_Formula"; import { condText, openMaterialModal, openNoteModal, openRefModal, openLogic, openTableModal, type PickCond, } from "./M01_MasterData_UI_LogicLab_Modal"; import { fragmentRow, type Fragment } from "./M01_MasterData_UI_LogicLab_Pill"; import { tl } from "./M01_MasterData_UI_LogicLab_Text"; /* ── 순수 셈 ── */ /** 박스로 묶을 줄 — 호표 차례 · 박스 이름 · 한 줄로 합칠지(장비 · 하위) · 금액 */ export interface HoRowLike { idx: number; bundle: string; merge: boolean; amount: number | null; } export interface HoMerged { idx: number; 몫: T[]; 금액: number | null; } export interface HoBox { 묶음: string; 줄: (T | HoMerged)[]; 소계: number; } const BOX_ORDER = ["인력", "재료", "경비", "하위"]; /** 줄 → 박스(인력 · 재료 · 경비 · 하위 차례) — 합칠 줄은 같은 박스 · 같은 호표 차례끼리 한 줄(몫 차례 그대로) · * `always` 박스는 줄이 없어도 빈 박스로 */ export function boxesOf(list: T[], always: string[] = []): HoBox[] { const boxes = new Map>(); const take = (name: string): HoBox => { let b = boxes.get(name); if (!b) boxes.set(name, (b = { 묶음: name, 줄: [], 소계: 0 })); return b; }; always.forEach(take); const merged = new Map>(); for (const e of list) { const b = take(e.bundle); b.소계 += e.amount ?? 0; if (!e.merge) { b.줄.push(e); continue; } const id = `${e.bundle}\n${e.idx}`; let m = merged.get(id); if (!m) { merged.set(id, (m = { idx: e.idx, 몫: [], 금액: null })); b.줄.push(m); } m.몫.push(e); m.금액 = m.금액 === null && e.amount === null ? null : (m.금액 ?? 0) + (e.amount ?? 0); } const rank = (x: string): number => (BOX_ORDER.includes(x) ? BOX_ORDER.indexOf(x) : 9); return [...boxes.values()].sort((a, b) => rank(a.묶음) - rank(b.묶음)); } /* ── 순수 셈 끝 ── */ const TEXT = { 인력: ["인력", "Labor"], 재료: ["재료", "Material"], 경비: ["경비(장비)", "Expense (equipment)"], 하위: ["하위 로직", "Sub logic"], Common: ["공통 옵션", "Common options"], Basis: ["기준", "Basis"], Formula: ["계산식", "Formula"], Short: ["노 {a} · 재 {b} · 경 {c}", "L {a} · M {b} · E {c}"], Fold: ["누르면 펼침", "Click to expand"], Per: ["{n} — {v} 원/{u}", "{n} — {v} KRW/{u}"], Edited: ["시험 고침", "Trial edit"], EditedM02: ["변경 항목", "Override"], } as const satisfies Record; const th = (k: keyof typeof TEXT): string => TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0]; const boxName = (b: string): string => (b in TEXT ? th(b as keyof typeof TEXT) : b); /** 고침 고리 — M01 = 시험 값(줄 사본) · M02 = 덮개 보관 · 열쇠 = 호표 줄 차례 */ export interface HoEdit { covers: Record; set: (idx: number, cover: LineCover | null) => void; scope?: "m01" | "m02"; /** (M02) 모달 고침 칸 아래 덧 동작(줄 빼기 · 별도계상 등) */ more?: (idx: number, close: () => void) => HTMLElement[]; } /** 고친 줄 꼬리표 — M01 = 시험 고침 · M02 = 변경 항목 · 고침 없음 = "" */ const editTag = (ctx: HoContext, idx: number): string => ctx.edit?.covers[idx] ? th(ctx.edit.scope === "m02" ? "EditedM02" : "Edited") : ""; export interface HoContext { row: LogicRow; prices: Record; /** 마지막 시험 계산의 줄 — 호표 차례 뒤에 덧줄 */ lines: CalcLine[] | null; /** 마지막 시험 계산의 읽는 식 줄(서버가 줌) */ text: TextAnswer | null; /** 마지막으로 잘 받은 읽는 식 — `text` 가 멈춤이면 조각을 이걸로 */ lastText?: TextAnswer; values: Record; /** 박스 옵션 · 입력 칩 고침 — 시험 계산 입력 고침 + 다시 셈(없으면 고르기 · 칩 없음) */ onInput?: (name: string, value: string) => void; /** 시험 계산 칸 — 재료 후보 고르기가 씀(없으면 후보만 보임) */ calcHost?: HTMLElement; /** 그 자리 고침 고리(없으면 모달은 보기만) */ edit?: HoEdit; } type Group = "labor" | "material" | "cost" | "other"; const OF_KIND: Record = { 인력: "labor", 재료: "material", 기계: "cost" }; const OF_COST: Record = { 노무비: "labor", 재료비: "material", 경비: "cost" }; const BOX_OF: Record = { labor: "인력", material: "재료", cost: "경비", other: "하위", }; const COST_OF: Record = { labor: "노무비", material: "재료비", cost: "경비" }; const PART_OF: Record = { labor: "labor", material: "material", cost: "expense", other: "expense", }; /** 줄 종류가 셋 중 어디에도 안 들어가면 「그 밖」 — 다른 로직을 부르는 줄은 비목으로 */ const groupOfHo = (item: HoLine): Group => OF_KIND[item.종류] ?? (item.종류 === "로직" ? OF_COST[item.비목 ?? ""] : undefined) ?? "other"; interface Entry extends HoRowLike { group: Group; name: string; spec: string; unit: string; qty: string; /** 고른 수량 값(계산 답 `CalcLine.수량` · % 덧줄은 율) — 계산 전 「—」 */ qtyValue: string; qtyFrag: Fragment[]; /** 한 줄이 비목 몫으로 갈렸을 때 이 몫 이름(예 「노무비」) */ share?: string; price: string; /** 갈리기 전 줄 단가(장비 한 줄 머리) */ linePrice: string; extra: boolean; /** 계산 답 줄 · 서버 읽는 식 줄(붙기 전엔 없음) */ line?: CalcLine; textLine?: TextLine; /** 고침 칸 — 로직 줄 = 노 · 재 · 경 · 그 밖 = 그 줄 비목 */ logic: boolean; part: CoverPart; open: (self: Entry) => void; unitFrag?: Fragment[]; piecesFrag?: Fragment[]; unitRef?: string; unitText?: string; warn?: string; } /** 서버 조각(`/text` 줄의 `조각`) → 칸 조각 — 요소·표·로직은 알약(글 = 조각 글) · 나머지는 글자 */ function toFragments(pieces: TextPiece[], ctx: HoContext): Fragment[] { return pieces.map((piece): Fragment => { const dim = piece.깊이 ? { dim: true } : {}; if (piece.kind !== "요소" && piece.kind !== "표" && piece.kind !== "로직") return { text: piece.글, ...dim }; const ref = piece.참조; const open = (): void => { if (!ref) return openNoteModal(piece.글, tl("Pill_NoPick")); if (piece.kind === "표") openTableModal({ title: piece.글, table: ref.키, row: ref.줄 ?? ref.행, col: ref.열 }); else if (piece.kind === "요소") openRefModal({ title: piece.글, ref: ref.키, prices: ctx.prices }); else openLogic(ref.키); }; return { pill: { label: piece.글, onClick: open }, ...dim }; }); } /** 재료 고르기 줄 — `요소` 가 글 대신 {구분·상세구분·규격·대표} 객체 */ const condOf = (item: HoLine): PickCond | null => typeof item.요소 === "object" && item.요소 !== null ? (item.요소 as unknown as PickCond) : null; const condLabel = (cond: PickCond): string => [cond.구분, cond.상세구분, cond.규격].filter(Boolean).join(" · "); const nameOf = (item: HoLine): string => item.이름 ?? (condOf(item) ? condLabel(condOf(item)!) : item.요소); /** 수량 칸 = 계산 답의 고른 수량(% 덧줄은 율) · 계산 전 「—」 */ const qtyOf = (line?: CalcLine): string => (line ? formatNumber(pctView(line).qty) : "—"); /** 계산된 줄은 서버가 준 비목별 몫으로 나눔 — 로직을 부르는 줄 · 장비 줄도 노무비·재료비·경비로 갈림 */ const spread = (e: Entry, line?: CalcLine): Entry[] => { const parts = Object.entries(line?.비목 ?? {}).filter(([, v]) => v !== 0); if (!parts.length) return [e]; return parts.map(([k, v]) => ({ ...e, group: OF_COST[k] ?? "other", bundle: BOX_OF[OF_COST[k] ?? "other"], amount: v, ...(parts.length > 1 ? { share: k, // 몫 줄 단가 = 단가 × (몫 금액 ÷ 줄 금액) — 곱하기 수량이 몫 금액과 맞게 ...(line && line.금액 && line.단가 != null ? { price: formatPrice((line.단가 * v) / line.금액) } : {}), } : {}), })); }; /** 줄 모달 — 자료 보기 + (고리 있으면) 그 자리 고침 */ function modalOf(e: Entry, ctx: HoContext, extra: { cond?: PickCond; item?: HoLine }): void { const middles: NamedFormula[] = ctx.row.중간 ?? []; const { cond, item } = extra; const line = e.line; const edit = ctx.edit; const perUnit = (k: string): number | null => line && line.수량 ? (line.비목?.[k] ?? 0) / line.수량 : null; openMaterialModal({ unitFrag: e.unitFrag, unitRef: line?.품목?.키 ?? e.unitRef, unitText: e.unitText, qtyFrag: e.piecesFrag, title: e.name, expr: e.qty, ...(item ? (cond ? { cond, unit: item.단위 } : { element: item.요소 }) : {}), prices: ctx.prices, middles, values: ctx.values, // 재료 줄 = 시험 계산 자재 칸과 같은 모달 — 고르면 시험 입력으로 다시 셈(정본 안 바뀜) kind: item?.종류, usedSlot: line?.품목?.값칸, ...(item?.종류 === "재료" && ctx.calcHost ? { onPick: (it) => pickMaterial(ctx.calcHost!, ctx.values, e.idx, it) } : {}), // 덧줄(%) = M02 덮개만 율 고침(M01 시험 값은 호표 줄만) ...(edit && (e.extra ? edit.scope === "m02" : item) ? { edit: (close: () => void) => { const form = coverForm({ qty: line ? pctView(line).qty : null, prices: e.extra ? {} : e.logic ? { labor: perUnit("노무비"), material: perUnit("재료비"), expense: perUnit("경비"), } : { [e.part]: line ? line.단가 : null }, cover: edit.covers[e.idx], scope: edit.scope, onApply: (c) => (close(), edit.set(e.idx, c)), }); const more = edit.more?.(e.idx, close) ?? []; return more.length ? el("div", { children: [form, ...more] }) : form; }, } : {}), }); } function entries(ctx: HoContext): Entry[] { const ho = ctx.row.호표 ?? []; const out: Entry[] = ho.flatMap((item, i) => { const line = ctx.lines?.[i]; const cond = condOf(item); const brief = cond ? ctx.prices[condText(cond)] : ctx.prices[item.요소]; const price = line ? line.단가 : brief?.값; const group = groupOfHo(item); const shown = price === undefined || price === null ? "" : formatPrice(price); const entry: Entry = { idx: i, group, bundle: BOX_OF[group], merge: false, name: nameOf(item), spec: item.규격 ?? brief?.규격 ?? "", unit: item.단위 ?? "", qty: item.수량, qtyValue: qtyOf(line), qtyFrag: [{ text: item.수량 }], price: shown, linePrice: shown, amount: line ? line.금액 : null, warn: line?.단위경고, extra: false, line, logic: item.종류 === "로직", part: OF_COST[item.비목 ?? ""] ? PART_OF[OF_COST[item.비목 ?? ""]] : PART_OF[group], open: (self) => modalOf(self, ctx, { cond: cond ?? undefined, item }), }; return spread(entry, line); }); (ctx.row.덧줄 ?? []).forEach((extra, i) => { const line = ctx.lines?.[ho.length + i]; const group = OF_COST[extra.비목 ?? ""] ?? "other"; const entry: Entry = { idx: ho.length + i, group, bundle: BOX_OF[group], merge: false, name: extra.이름, spec: "", unit: line ? pctView(line).unit : "", qty: extra.식, qtyValue: qtyOf(line), qtyFrag: [{ text: extra.식 }], price: "", linePrice: "", amount: line ? line.금액 : null, extra: true, line, logic: false, part: PART_OF[group], open: (self) => modalOf(self, ctx, {}), }; out.push(...spread(entry, line)); }); attachPieces(out, ctx); return out; } /** 서버 `/text` 줄을 호표 줄 차례(덧줄은 덧줄 차례)로 이음 — 서버는 비목마다 호표 차례대로 줄을 주므로 * 이름을 차례로 따라가며 줄 차례에 맞춤 · 붙은 줄의 `묶음` · `장비` 로 박스를 정함 · * 끝내 못 찾으면 날식 대신 빈칸(계산 전 · 서버 실패면 식 글자 그대로) */ function attachPieces(all: Entry[], ctx: HoContext): void { const source = ctx.text?.ok ? ctx.text : ctx.lastText; if (!source?.ok) { if (ctx.text) for (const e of all) e.qtyFrag = e.piecesFrag = []; // 멈춤 · 받은 조각 없음 — 날식 대신 빈칸 return; } const names = [...(ctx.row.호표 ?? []).map(nameOf), ...(ctx.row.덧줄 ?? []).map((x) => x.이름)]; const found = new Map(); for (const g of source.groups) { let from = 0; for (const line of g.줄) { const at = names.findIndex((n, i) => i >= from && n === line.이름); if (at < 0) continue; found.set(`${g.비목}|${at}`, line); from = at + 1; } } const costs = Object.keys(OF_COST); const fresh = ctx.lines !== null; // 묶음은 지금 셈 답에서만(옛 답의 묶음으로 박스를 안 옮김) for (const e of all) { const own = costs.find((c) => OF_COST[c] === e.group); const line = [own, ...costs].map((c) => found.get(`${c}|${e.idx}`)).find(Boolean); const pieces = line?.조각; e.qtyFrag = e.piecesFrag = pieces?.length ? toFragments(pieces, ctx) : []; if (line?.요소조각?.length) { e.unitFrag = toFragments(line.요소조각, ctx); const first = line.요소조각.find((piece) => piece.kind === "요소"); e.unitRef = first?.참조?.키; e.unitText = first?.글; } if (!line || !fresh) continue; e.textLine = line; if (line.묶음) e.bundle = line.묶음; e.merge = !!line.장비 || line.묶음 === "하위"; } } const COLS: Column[] = [ { label: tl("Col_Name"), width: "20%" }, { label: tl("Col_Qty"), width: "9%", align: "end" }, { label: tl("Col_Unit"), width: "7%" }, { label: th("Formula"), width: "38%" }, { label: tl("Col_Price"), width: "13%", align: "end" }, { label: tl("Col_Amount"), width: "13%", align: "end" }, ]; const money = (v: number | null): string => (v === null ? "" : formatMoney(v)); /** 이 줄이 쓰는 입력 칩 — 서버 `쓰는입력` ∩ 로직 입력 */ function chips(e: Entry, ctx: HoContext): HTMLElement[] { const onInput = ctx.onInput; const uses = e.textLine?.쓰는입력 ?? []; const specs = (ctx.row.입력 ?? []).filter((s) => uses.includes(s.이름)); if (!onInput || !specs.length) return []; return [ el("div", { className: "m01lab-fx__vars", children: specs.map((s) => inputChip(s, ctx.values[s.이름] ?? "", (v) => onInput(s.이름, v))), }), ]; } /** 계산식 칸 — 값 든 식(계산 뒤) · 계산 전 = 수량 식 · 알약 · 입력 칩 · 이름 식은 풍선 */ function formulaCell(e: Entry, ctx: HoContext, text = ""): HTMLElement { const tline = e.textLine; const main = text || (tline ? sheetFormula(tline.글) : e.qty); const pills = e.qtyFrag.some((f) => "pill" in f) ? [fragmentRow(e.qtyFrag)] : []; const cell = el("div", { className: "m01lab__qty-cell", children: [ el("div", { className: "m01lab__qty-text", text: main }), ...(text ? [] : [...pills, ...chips(e, ctx)]), ], }); cell.title = tline?.이름글 ?? e.qty; return cell; } function nameCell(e: Entry, label: string, tag: string): HTMLElement { return el("div", { children: [ el("div", { text: label }), ...(e.share && label === e.name ? [createBadge(`${e.share} ${tl("Share")}`, "accent")] : []), ...(e.spec ? [el("div", { className: "ui-text ui-text--muted", text: e.spec })] : []), ...(e.warn ? [createBadge(`${tx("Calc_UnitWarn")} · ${e.warn}`, "warning")] : []), ...(tag ? [createBadge(tag, "accent")] : []), ], }); } /** 못 셈 줄(725 · M02 단가산출) — 금액 「—」 + 까닭 풍선 */ const gapCell = (why: string): HTMLElement => el("span", { text: "—", attrs: { title: why } }); /** 보통 줄 · 몫 줄 — 누르면 그 줄 모달 */ function lineRow(e: Entry, ctx: HoContext, label = e.name): HTMLTableRowElement { const tag = editTag(ctx, e.idx); const tr = tableRow(COLS, [ nameCell(e, label, e.share ? "" : tag), e.qtyValue, e.unit, formulaCell(e, ctx), e.price, e.line?.까닭 ? gapCell(e.line.까닭) : money(e.amount), ]); tr.classList.add("m01lab__line"); if (e.extra) tr.classList.add("m01lab__line--extra"); if (tag) tr.classList.add("is-changed"); tr.tabIndex = 0; tr.dataset.line = e.name; tr.addEventListener("click", () => e.open(e)); tr.addEventListener("keydown", (ev) => ev.key === "Enter" && e.open(e)); return tr; } /** 펼친 장비 줄 — 로직 줄 객체마다 기억(다시 셈 · 다시 그려도 펼친 채) */ const opened = new WeakMap>(); const openOf = (row: object): Set => { let got = opened.get(row); if (!got) opened.set(row, (got = new Set())); return got; }; /** 서버 줄 덧칸 — 장비 줄 펼침(714-1b · 부른 기계경비 로직 시간당 줄) */ type Unfoldable = TextLine & { 펼침?: SheetUnfold | null }; /** 장비 · 하위 한 줄 — 머리(노 · 재 · 경 몫 + 합) · 누르면 펼침(부른 로직 시간당 줄 · 몫 줄) */ function mergedRows(m: HoMerged, ctx: HoContext): HTMLTableRowElement[] { const first = m.몫[0]; const share = (cost: string): string => formatMoney( m.몫 .filter((x) => (x.share ?? COST_OF[x.group]) === cost) .reduce((s, x) => s + (x.amount ?? 0), 0), ); const short = th("Short") .replace("{a}", share("노무비")) .replace("{b}", share("재료비")) .replace("{c}", share("경비")); const tag = editTag(ctx, m.idx); const head = tableRow(COLS, [ nameCell(first, `▸ ${first.name}`, tag), first.qtyValue, first.unit, formulaCell(first, ctx, short), first.linePrice, money(m.금액), ]); head.classList.add("m01lab__line"); if (tag) head.classList.add("is-changed"); head.dataset.equip = first.name; head.title = th("Fold"); head.tabIndex = 0; head.setAttribute("aria-expanded", "false"); const unfold = m.몫.map((x) => (x.textLine as Unfoldable | undefined)?.펼침).find(Boolean); const subs: HTMLTableRowElement[] = [ ...(unfold ? [ tableRow(COLS, [ { content: createText( th("Per") .replace("{n}", unfold.이름) .replace("{v}", formatPrice(unfold.계 ?? 0)) .replace("{u}", unfold.단위 || "-"), "hint", ), colSpan: COLS.length, }, ]), ...unfold.줄.map((x) => tableRow(COLS, [ `${x.이름} (${x.비목})`, "", "", sheetFormula(x.글), "", money(x.금액), ]), ), ] : []), ...m.몫.map((x) => lineRow(x, ctx, `${x.share ?? first.name}`)), ]; for (const s of subs) { s.classList.add("is-sub"); s.hidden = true; s.dataset.part = first.name; } const label = head.querySelector("td > div > div") as HTMLElement; const keep = openOf(ctx.row); const show = (open: boolean): void => { head.setAttribute("aria-expanded", String(open)); label.textContent = `${open ? "▾" : "▸"} ${first.name}`; subs.forEach((s) => (s.hidden = !open)); if (open) keep.add(m.idx); else keep.delete(m.idx); }; const toggle = (): void => show(head.getAttribute("aria-expanded") !== "true"); if (keep.has(m.idx)) show(true); head.addEventListener("click", toggle); head.addEventListener("keydown", (ev) => { if (ev.key !== "Enter" && ev.key !== " ") return; ev.preventDefault(); toggle(); }); return [head, ...subs]; } /** 옵션 고르기 줄들 — 이름 차례 · 고르기 있으면 고르기 · 아니면 글 칸 */ function optionRows(names: string[], ctx: HoContext): HTMLElement[] { const onInput = ctx.onInput; if (!onInput) return []; return (ctx.row.입력 ?? []) .filter((s) => names.includes(s.이름)) .map((s) => varRow(s, ctx.values[s.이름] ?? "", (v) => onInput(s.이름, v))); } const usesOf = (b: HoBox): string[] => [ ...new Set( b.줄.flatMap((r) => ("몫" in r ? r.몫 : [r]).flatMap((e) => e.textLine?.쓰는입력 ?? [])), ), ]; function boxCard(b: HoBox, ctx: HoContext, calculated: boolean): HTMLElement { const name = boxName(b.묶음); const lines = b.줄.flatMap((r) => ("몫" in r ? r.몫 : [r])); const basis = [...new Set(lines.map((e) => e.textLine?.기준단위).filter(Boolean))]; const notes = [...new Set(lines.map((e) => e.textLine?.환산근거).filter(Boolean))]; const { root, tbody } = tableShell(COLS, { density: "data" }); root.classList.add("m01lab__ho"); tbody.append( ...(b.줄.length ? b.줄.flatMap((r) => ("몫" in r ? mergedRows(r, ctx) : [lineRow(r, ctx)])) : [emptyRow(COLS, tl("Empty_Group"))]), ); const sub = tableRow(COLS, [ { content: `${name} ${tl("Subtotal")}`, colSpan: 5 }, calculated ? formatMoney(b.소계) : "—", ]); sub.classList.add("m01lab__subtotal"); sub.dataset.box = b.묶음; sub.dataset.subtotal = calculated ? String(b.소계) : ""; tbody.append(sub); const card = createCard({ title: name, pad: 12, body: [ ...(basis.length ? [createText(`${th("Basis")} ${basis.join(" · ")}`, "muted")] : []), ...(notes.length ? [createText(notes.join(" · "), "hint")] : []), ...optionRows(usesOf(b), ctx), root, ], }); card.classList.add("m01lab__ho-box"); card.dataset.hoBox = b.묶음; return card; } /** 어느 박스 줄도 안 쓰는 입력 — 맨 위 한 카드 */ function commonCard(rows: HTMLElement[]): HTMLElement { const card = createCard({ title: th("Common"), pad: 12, body: rows }); card.classList.add("m01lab__ho-box"); card.dataset.hoBox = "common"; return card; } /** 공용 단가산출 상세 몸 — 공통 옵션 · 박스들 · 비목 소계 셋 · 계 */ export function buildHo(ctx: HoContext): HTMLElement[] { const all = entries(ctx); const calculated = ctx.lines !== null; const boxes = boxesOf(all, ["인력", "재료", "경비"]); const used = new Set(boxes.flatMap(usesOf)); const common = optionRows( (ctx.row.입력 ?? []).map((s) => s.이름).filter((n) => !used.has(n)), ctx, ); const sums = (["labor", "material", "cost"] as Group[]).map((g) => { const v = all.filter((e) => e.group === g).reduce((s, e) => s + (e.amount ?? 0), 0); const out = el("span", { className: "ui-calc-row__out is-key", text: calculated ? formatMoney(v) : "—", }); const row = createCalcFormulaRow(COST_OF[g], null, out); row.dataset.group = g; row.dataset.subtotal = calculated ? String(v) : ""; return row; }); const total = all.reduce((s, e) => s + (e.amount ?? 0), 0); return [ ...(common.length ? [commonCard(common)] : []), ...boxes.map((b) => boxCard(b, ctx, calculated)), el("div", { className: "m01lab__sums", children: sums }), el("p", { className: "m01lab__total", attrs: { "data-sum": calculated ? String(total) : "" }, text: `${tl("Total")} ${calculated ? formatMoney(total) : "—"}`, }), ]; }