/* ============================================================================= * M01_MasterData_UI_LogicLab_Detail.ts * 「단가산출 로직」 상세 화면 — 컨테이너 넷을 위에서 아래로 * ① 기본정보(정본은 읽기 전용 · 비고만 고침) ② 단가산출 상세(인력·자재·경비·그 밖 묶음 + 소계) * ③ 텍스트 수식(자리 — `_LogicLab_Formula.ts`) ④ 시험 계산(입력값 + 결과값 — `Logic_Calc` 그대로) * 호표 줄을 누르면 그 줄이 쓰는 자료 모달(`_LogicLab_Modal.ts`) * ========================================================================== */ import { el } from "@ui/ui_template_elements"; import type { CalcLine, ElementBrief, HoLine, LogicRow, NamedFormula, TextAnswer, TextLine, TextPiece, } from "./M01_MasterData_UI_Logic_Api"; import { buildEditor, qtyKind } from "./M01_MasterData_UI_Logic_Edit"; import { formatMoney, formatPrice } from "./M01_MasterData_UI_Logic_Money"; import { openPicker } from "./M01_MasterData_UI_Logic_Pick"; import { tx } from "./M01_MasterData_UI_Logic_Text"; import { buildFormula } 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, type LabTextKey } from "./M01_MasterData_UI_LogicLab_Text"; import "./M01_MasterData_UI_LogicLab_Style.css"; import "./M01_MasterData_UI_LogicLab_Pill.css"; /** 서버 조각(`/text` 줄의 `조각`) → 수량 칸 조각 — 요소·표·로직은 알약(글 = 조각 글) · 나머지는 글자 */ function toFragments(pieces: TextPiece[], ctx: DetailContext): 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.행, col: ref.열 }); else if (piece.kind === "요소") openRefModal({ title: piece.글, ref: ref.키, prices: ctx.prices }); else openLogic(ref.키); }; return { pill: { label: piece.글, onClick: open }, ...dim }; }); } export interface DetailContext { row: LogicRow; file: string; reasons: string[]; prices: Record; /** 마지막 시험 계산의 줄 — 호표 차례 뒤에 덧줄 */ lines: CalcLine[] | null; /** 마지막 시험 계산의 읽는 식 줄(서버가 줌) */ text: TextAnswer | null; values: Record; /** 자체 로직 편집기 자리 — 다시 그려도 같은 칸을 씀(고치던 값이 안 날아감) */ ownHost: HTMLElement; onChange: () => void; /** 시험 계산 칸(`buildCalc` 가 채움) — 넷째 컨테이너 안에 놓음 */ calcHost: HTMLElement; } type Group = "labor" | "material" | "cost" | "other"; const GROUPS: Group[] = ["labor", "material", "cost", "other"]; const OF_KIND: Record = { 인력: "labor", 재료: "material", 기계: "cost" }; const OF_COST: Record = { 노무비: "labor", 재료비: "material", 경비: "cost" }; /** 줄 종류가 셋 중 어디에도 안 들어가면 「그 밖」 — 다른 로직을 부르는 줄은 비목으로 */ const groupOfHo = (item: HoLine): Group => OF_KIND[item.종류] ?? (item.종류 === "로직" ? OF_COST[item.비목 ?? ""] : undefined) ?? "other"; interface Entry { /** 호표 줄 차례(덧줄은 호표 수 + 덧줄 차례) — 서버 조각을 찾는 열쇠 */ idx: number; group: Group; name: string; spec: string; unit: string; qty: string; qtyTag: string; qtyFrag: Fragment[]; /** 한 줄이 비목 몫으로 갈렸을 때 이 묶음의 몫 이름(예 「노무비」) */ share?: string; price: string; amount: number | null; /** 덧줄이면 true */ extra: boolean; open: (self: Entry) => void; /** 서버 조각으로 그린 단가 자리 · 수량 풀이 — 줄 모달이 씀(붙기 전엔 없음) */ unitFrag?: Fragment[]; piecesFrag?: Fragment[]; } /** 재료 고르기 줄 — `요소` 가 글 대신 {구분·상세구분·규격·대표} 객체 */ 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 groupName = (group: Group): string => tl(`G_${group}` as LabTextKey); const section = (title: string, body: HTMLElement[], hint = ""): HTMLElement => el("section", { className: "m01-logic__section m01lab__box", children: [ el("div", { className: "m01-logic__section-head", children: [ el("h3", { text: title }), ...(hint ? [el("span", { className: "m01-logic__muted", text: hint })] : []), ], }), ...body, ], }); function infoBox(ctx: DetailContext): HTMLElement { const row = ctx.row; const own = isOwnLogic(row); const field = (label: string, key: "이름" | "원문번호" | "결과단위" | "출처"): HTMLElement => { if (!own) return cell(label, row[key] ?? ""); const box = el("input", { className: "m01-logic__input", attrs: { "data-own": key } }); box.value = row[key] ?? ""; box.addEventListener("input", () => { row[key] = box.value; ctx.onChange(); }); return el("label", { className: "m01lab__cell", children: [el("span", { className: "m01-logic__muted", text: label }), box], }); }; const cell = (label: string, value: string): HTMLElement => el("div", { className: "m01lab__cell", children: [ el("span", { className: "m01-logic__muted", text: label }), el("span", { text: value || "—" }), ], }); const note = el("textarea", { className: "m01-logic__input m01-logic__note" }); note.value = row.비고 ?? ""; note.addEventListener("input", () => { if (note.value.trim()) row.비고 = note.value; else delete row.비고; ctx.onChange(); }); const reasons = ctx.reasons.length ? [ el("div", { className: "m01-logic__reasons", children: [ el("strong", { text: tx("Head_Blocked") }), ...ctx.reasons.map((r) => el("div", { text: r })), ], }), ] : []; return section(tl("Info_Title"), [ el("p", { className: "m01-logic__muted", text: tl(own ? "Info_Own" : "Info_ReadOnly"), }), ...reasons, el("div", { className: "m01lab__info", children: [ cell(tx("Head_File"), ctx.file), cell(tx("Head_Key"), row.키), field(tx("Head_Number"), "원문번호"), field(tx("Head_Name"), "이름"), field(tx("Head_Unit"), "결과단위"), field(tx("Head_Source"), "출처"), cell(tx("Head_Owner"), row.소유 ?? ""), el("label", { className: "m01lab__cell m01lab__cell--wide", children: [el("span", { className: "m01-logic__muted", text: tx("Head_Note") }), note], }), ], }), ]); } /** 계산된 줄은 서버가 준 비목별 몫으로 묶음에 나눠 넣음 — 로직을 부르는 줄도 노무비·재료비·경비로 갈림 */ 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", amount: v, ...(parts.length > 1 ? { share: k, // 몫 줄 단가 = 단가 × (몫 금액 ÷ 줄 금액) — 곱하기 수량이 몫 금액과 맞게 ...(line && line.금액 && line.단가 != null ? { price: formatPrice((line.단가 * v) / line.금액) } : {}), } : {}), })); }; function entries(ctx: DetailContext): Entry[] { const middles: NamedFormula[] = ctx.row.중간 ?? []; 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 entry: Entry = { idx: i, group: groupOfHo(item), name: item.이름 ?? (cond ? condLabel(cond) : item.요소), spec: item.규격 ?? brief?.규격 ?? "", unit: item.단위 ?? "", qty: item.수량, qtyTag: qtyKind(item.수량) === tx("Ho_QtyTable") ? tl("Tag_Table") : qtyKind(item.수량), qtyFrag: [{ text: item.수량 }], price: price === undefined || price === null ? "" : formatPrice(price), amount: line ? line.금액 : null, extra: false, open: (self) => openMaterialModal({ unitFrag: self.unitFrag, qtyFrag: self.piecesFrag, title: item.이름 ?? (cond ? condLabel(cond) : item.요소), expr: item.수량, ...(cond ? { cond, unit: item.단위 } : { element: item.요소 }), prices: ctx.prices, middles, values: ctx.values, }), }; return spread(entry, line); }); (ctx.row.덧줄 ?? []).forEach((extra, i) => { const line = ctx.lines?.[ho.length + i]; const entry: Entry = { idx: ho.length + i, group: OF_COST[extra.비목 ?? ""] ?? "other", name: extra.이름, spec: "", unit: "", qty: extra.식, qtyTag: tl("Extra"), qtyFrag: [{ text: extra.식 }], price: "", amount: line ? line.금액 : null, extra: true, open: (self) => openMaterialModal({ unitFrag: self.unitFrag, qtyFrag: self.piecesFrag, title: extra.이름, expr: extra.식, prices: ctx.prices, middles, values: ctx.values, }), }; out.push(...spread(entry, line)); }); attachPieces(out, ctx); return out; } /** 서버 `/text` 줄의 조각을 호표 줄 차례(덧줄은 덧줄 차례)로 이음 — 서버는 묶음마다 호표 차례대로 줄을 주므로 * 이름을 차례로 따라가며 줄 차례에 맞춤 · 금액 0 로직 부름 줄처럼 묶음이 달라도 찾음 · * 끝내 못 찾으면 날식 대신 빈칸(계산 전 · 서버 실패면 식 글자 그대로) */ function attachPieces(all: Entry[], ctx: DetailContext): void { if (!ctx.text?.ok) return; const ho = ctx.row.호표 ?? []; const names = [ ...ho.map((item) => item.이름 ?? (condOf(item) ? condLabel(condOf(item)!) : item.요소)), ...(ctx.row.덧줄 ?? []).map((extra) => extra.이름), ]; const found = new Map(); for (const g of ctx.text.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); 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 cellOf = (text: string, className = ""): HTMLElement => el("td", { className, text }); function lineRow(e: Entry): HTMLElement { const tr = el("tr", { className: `m01lab__line${e.extra ? " m01lab__line--extra" : ""}`, attrs: { tabindex: "0", "data-line": e.name }, children: [ el("td", { children: [ el("div", { text: e.name }), ...(e.share ? [el("div", { className: "m01-logic__tag", text: `${e.share} ${tl("Share")}` })] : []), ...(e.spec ? [el("div", { className: "m01-logic__muted", text: e.spec })] : []), ], }), cellOf(e.unit), el("td", { className: "m01lab__qty-cell", children: [ el("span", { className: "m01-logic__tag", text: e.qtyTag }), fragmentRow(e.qtyFrag), ], }), cellOf(e.price, "m01-logic__money"), cellOf(e.amount === null ? "" : formatMoney(e.amount), "m01-logic__money"), ], }); tr.addEventListener("click", () => e.open(e)); tr.addEventListener("keydown", (ev) => ev.key === "Enter" && e.open(e)); return tr; } function groupTable(group: Group, mine: Entry[], calculated: boolean): HTMLElement[] { const sub = mine.reduce((s, e) => s + (e.amount ?? 0), 0); const empty = el("tr", { children: [ el("td", { attrs: { colspan: "5" }, className: "m01-logic__muted", text: tl("Empty_Group"), }), ], }); const subtotal = el("tr", { className: "m01lab__subtotal", attrs: { "data-group": group, "data-subtotal": calculated ? String(sub) : "" }, children: [ el("td", { attrs: { colspan: "4" }, text: `${groupName(group)} ${tl("Subtotal")}` }), cellOf(calculated ? formatMoney(sub) : "—", "m01-logic__money"), ], }); const headers = [ tl("Col_Name"), tl("Col_Unit"), tl("Col_Qty"), tl("Col_Price"), tl("Col_Amount"), ]; return [ el("h4", { className: "m01lab__group", text: groupName(group) }), el("div", { className: "m01-logic__scroll", children: [ el("table", { className: "m01-logic__grid m01lab__ho", children: [ el("thead", { children: [el("tr", { children: headers.map((h) => el("th", { text: h })) })], }), el("tbody", { children: [...(mine.length ? mine.map(lineRow) : [empty]), subtotal] }), ], }), ], }), ]; } /** 자체 로직(키 GX…) — 정본은 아님 */ export const isOwnLogic = (row: LogicRow): boolean => row.키.startsWith("GX"); /** 자체 로직은 정본 화면과 같은 편집기를 호표 아래에 폄(머리 칸은 기본정보에서 고치므로 숨김) */ function ownEditor(ctx: DetailContext): HTMLElement { if (!ctx.ownHost.childElementCount) buildEditor(ctx.ownHost, { row: ctx.row, file: ctx.file, isNew: false, files: [], reasons: [], prices: ctx.prices, lines: ctx.lines, onChange: ctx.onChange, onFile: () => undefined, onPick: openPicker, }); return el("details", { className: "m01lab__edit", attrs: { open: "" }, children: [el("summary", { text: tl("Edit_Title") }), ctx.ownHost], }); } function hoBox(ctx: DetailContext): HTMLElement { const money = !("결과" in ctx.row) && (ctx.row.결과단위 ?? "").startsWith("원"); if (!money) { return section(tl("Ho_Title"), [ el("p", { className: "m01-logic__muted", text: tl("Not_Money") }), el("pre", { className: "m01lab__expr", text: ctx.row.결과?.식 ?? "" }), ]); } const all = entries(ctx); const calculated = ctx.lines !== null; const tables = GROUPS.flatMap((group) => { const mine = all.filter((e) => e.group === group); return group === "other" && !mine.length ? [] : groupTable(group, mine, calculated); // 그 밖은 있을 때만 }); const total = all.reduce((s, e) => s + (e.amount ?? 0), 0); const totalLine = el("p", { className: "m01lab__total", attrs: { "data-sum": calculated ? String(total) : "" }, text: `${tl("Total")} ${calculated ? formatMoney(total) : "—"}`, }); // 자체 로직 — 고치는 자리는 편집기 하나 · 읽기용 묶음 표는 접어 둠(펼치면 소계가 보임) const fold = el("details", { className: "m01lab__fold", attrs: ctx.ownHost.dataset.fold ? { open: "" } : {}, children: [el("summary", { text: tl("Ho_Fold") }), ...tables], }); fold.addEventListener("toggle", () => (ctx.ownHost.dataset.fold = fold.open ? "1" : "")); const blocks = isOwnLogic(ctx.row) ? [ownEditor(ctx), fold, totalLine] : [...tables, totalLine]; const hint = calculated ? tl("Click_Hint") : `${tl("Click_Hint")} · ${tl("Calc_First")}`; return section(tl("Ho_Title"), blocks, hint); } /** 상세 화면 넷을 `host` 에 쌓음 */ export function buildLabDetail(host: HTMLElement, ctx: DetailContext): void { const formula = el("div", { className: "m01lab__formula" }); buildFormula(formula, { text: ctx.text }); // 다시 그리면 포커스가 빠짐 — 고치던 칸(편집기 · 기본정보)을 기억해 되돌림 const active = document.activeElement; const kept = active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement ? { node: active, from: active.selectionStart, to: active.selectionEnd } : null; host.replaceChildren( infoBox(ctx), hoBox(ctx), section(tl("Formula_Title"), [formula]), section(tl("Calc_Title"), [ctx.calcHost]), ); const own = kept?.node.dataset.own; // 기본정보 칸은 새로 만들어지므로 이름표로 다시 찾음 const back = own ? host.querySelector(`[data-own="${own}"]`) : kept?.node; if (kept && back && (own || ctx.ownHost.contains(back))) { back.focus(); if (kept.from !== null) back.setSelectionRange(kept.from, kept.to); } }