/* ============================================================================= * 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, } from "./M01_MasterData_UI_Logic_Api"; import { buildEditor, formatNumber, qtyKind } from "./M01_MasterData_UI_Logic_Edit"; 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 { openMaterialModal } from "./M01_MasterData_UI_LogicLab_Modal"; import { tl, type LabTextKey } from "./M01_MasterData_UI_LogicLab_Text"; import "./M01_MasterData_UI_LogicLab_Style.css"; 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 { group: Group; name: string; spec: string; unit: string; qty: string; qtyTag: string; price: string; amount: number | null; /** 덧줄이면 true */ extra: boolean; open: () => void; } 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], }), ], }), ]); } function entries(ctx: DetailContext): Entry[] { const middles: NamedFormula[] = ctx.row.중간 ?? []; const ho = ctx.row.호표 ?? []; const out: Entry[] = ho.map((item, i) => { const line = ctx.lines?.[i]; const brief = ctx.prices[item.요소]; const price = line ? line.단가 : brief?.값; return { group: groupOfHo(item), name: item.이름 ?? item.요소, spec: item.규격 ?? brief?.규격 ?? "", unit: item.단위 ?? "", qty: item.수량, qtyTag: qtyKind(item.수량), price: price === undefined || price === null ? "" : formatNumber(price), amount: line ? line.금액 : null, extra: false, open: () => openMaterialModal({ title: item.이름 ?? item.요소, expr: item.수량, element: item.요소, prices: ctx.prices, middles, values: ctx.values, }), }; }); (ctx.row.덧줄 ?? []).forEach((extra, i) => { const line = ctx.lines?.[ho.length + i]; out.push({ group: OF_COST[extra.비목 ?? ""] ?? "other", name: extra.이름, spec: "", unit: "", qty: extra.식, qtyTag: tl("Extra"), price: "", amount: line ? line.금액 : null, extra: true, open: () => openMaterialModal({ title: extra.이름, expr: extra.식, prices: ctx.prices, middles, values: ctx.values, }), }); }); return out; } 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.spec ? [el("div", { className: "m01-logic__muted", text: e.spec })] : []), ], }), cellOf(e.unit), el("td", { children: [ el("span", { className: "m01-logic__tag", text: e.qtyTag }), el("span", { className: "m01lab__qty", text: ` ${e.qty}` }), ], }), cellOf(e.price, "m01-logic__money"), cellOf(e.amount === null ? "" : formatNumber(e.amount), "m01-logic__money"), ], }); tr.addEventListener("click", e.open); tr.addEventListener("keydown", (ev) => ev.key === "Enter" && e.open()); 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 ? formatNumber(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 ? formatNumber(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); } }