/* ============================================================================= * M02_MasterTemplete_Sangeun_Detail.ts * M02 단가산출(SK) 상세(PLAN 717-2b · 요구서 4장 3 · 4 · 13 · 17 · 24) — M01 「단가산출 로직」 상세 부품을 M02 길로. * 보기 넷(`LogicLab_View`) = 단가산출 상세(고침 표 · `Sangeun_Cover`) · 인력·재료·경비(공용 계산서 · SK 셈 답) · * 시험 계산(`Logic_Calc` · 바탕 M01 로직) · 텍스트 수식(바탕 M01 로직 식) · 기본정보 = 바탕 로직(맨 위 접힘) * 고침 = SK 덮개 · 더한줄(`Sangeun_Pick`) · 뺀줄(꼴 = 서버 `M02_Sangeun_Doc`) · 값 = 서버 셈 길 최신 응답만 * 시험 입력 = SK 셈 `입력값`(저장 안 함) · [저장] = 화면 문서 → 서버가 다시 셈(원값 · 바탕판) * 바탕 없는 SK = [M01 로직 고름] → 바탕 · 단위 · 바탕 없이 줄만도 됨 * ========================================================================== */ import { createBadge } from "@ui/ui_template_badge"; import { createBanner } from "@ui/ui_template_banner"; import { createButton } from "@ui/ui_template_button"; import { el } from "@ui/ui_template_elements"; import { createInputField } from "@ui/ui_template_input"; import { t as L } from "@ui/ui_template_locale"; import { createText } from "@ui/ui_template_text"; import { calcSheetRows } from "../A00_Common/calc_sheet"; import { buildCalc } from "../M01_MasterData/M01_MasterData_UI_Logic_Calc"; import { fetchLogic, setLogicApiPaths, type LogicOne, type LogicRow, type TextAnswer, } from "../M01_MasterData/M01_MasterData_UI_Logic_Api"; import { buildLabDetail } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Detail"; import { createViewPicker } from "../M01_MasterData/M01_MasterData_UI_LogicLab_View"; import type { Layer } from "./M02_MasterTemplete_Api_Fetch"; import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick"; import { calcSangeun, coverTable } from "./M02_MasterTemplete_Sangeun_Cover"; import { openSangeunLinePicker } from "./M02_MasterTemplete_Sangeun_Pick"; import { dropLine, restoreLine, rowsOf, setPrice, setQty, sheetGroups, type SkAnswer, type SkDoc, } from "./logic/M02_Sangeun_Cover_Model"; export interface SangeunDetailOptions { layer: Layer; owner: string | null; readOnly: boolean; onChanged: (dirty: boolean) => void; } const LIB = { read: "/m02/logic-lib", calc: "/m02/logic-lib" }; const clone = (v: T): T => JSON.parse(JSON.stringify(v)) as T; /** 결과단위 「원/10㎡」 → SK 단위 「㎡」(1 단위 값 · 배는 서버가 나눔) */ const unitOf = (result: string): string => result.replace(/^원\s*\/\s*[\d.,]*\s*/, "").trim(); export function mountSangeunDetail( host: HTMLElement, doc0: unknown, opt: SangeunDetailOptions, ): { getDoc: () => SkDoc; destroy: () => void } { setLogicApiPaths(LIB); // 상세 부품 길 = M02 로그인 길(717-1) const doc = clone((doc0 ?? {}) as SkDoc); let saved = JSON.stringify(doc); const edit = !opt.readOnly; const scope = { layer: opt.layer, owner: opt.owner }; const values: Record = Object.fromEntries( Object.entries(doc.입력값 ?? {}).map(([k, v]) => [k, String(v)]), ); let base: LogicOne | null = null; let text: TextAnswer | null = null; let lastText: TextAnswer | undefined; let ans: SkAnswer | null = null; let bare: SkAnswer | null = null; let fail = ""; let seq = 0; let alive = true; const head = el("div", { className: "m02-sk__head", attrs: { "data-sk-head": "" } }); const notes = el("div"); const editor = el("div", { className: "m01-logic__editor" }); const views = createViewPicker(editor); const calcHost = el("div", { className: "m01lab__calc" }); const ownHost = el("div"); const addButton = createButton({ label: L("M02_SangeunAddLine"), variant: "ghost", size: 28, onClick: () => openSangeunLinePicker(scope, (line) => { doc.더한줄 = [ ...(doc.더한줄 ?? []), line as unknown as NonNullable[number], ]; touch(); }), }); addButton.hidden = !edit; const baseHint = createText(L("M02_SangeunBaseViews")); host.replaceChildren( el("div", { className: "m02-sk", attrs: { "data-sangeun-detail": doc.키 ?? "" }, children: [ head, notes, el("div", { className: "m02-sk__head", children: [views.root, addButton, baseHint] }), editor, ], }), ); const opened = (): boolean => (doc.열린입력 ?? []).length > 0; const baseKey = (): string | null => doc.바탕?.로직 ?? null; /** 바탕 없는 · 참조 바탕 SK 의 기본정보 자리 줄 */ const standIn = (): LogicRow => ({ 키: doc.키 ?? "", 원문번호: "", 이름: doc.이름 ?? "", 결과단위: `원/${doc.단위 ?? ""}`, 출처: doc.바탕?.참조?.키 ?? L("M02_SangeunNoBase"), 입력: [], 중간: [], 호표: [], 덧줄: [], }); const touch = (again = true): void => { opt.onChanged(edit && JSON.stringify(doc) !== saved); if (again) void recalc(); else drawHead(); }; async function recalc(): Promise { const mine = ++seq; const plain = { ...doc, 덮개: {}, 뺀줄: [], 더한줄: [] }; try { const [got, was] = await Promise.all([ calcSangeun(scope, doc, values), doc.바탕 ? calcSangeun(scope, plain, values) : Promise.resolve(null), ]); if (mine !== seq || !alive) return; // 늦게 온 답 — 버림 [ans, bare, fail] = [got, was, ""]; } catch (error) { if (mine !== seq || !alive) return; [ans, fail] = [null, error instanceof Error ? error.message : String(error)]; } draw(); } function drawHead(): void { const field = (label: string, key: "이름" | "단위"): HTMLElement => { const box = createInputField({ label, value: doc[key] ?? "", compact: true, size: 28 }); box.input.dataset.sk = key; box.input.disabled = !edit; box.input.addEventListener("change", () => { doc[key] = box.input.value.trim(); box.setError(doc[key] ? undefined : L("M02_SangeunNeed")); touch(key === "단위"); }); return box.root; }; const baseName = base ? `${base.logic.키} ${base.logic.이름}` : null; const baseText = baseName ?? doc.바탕?.참조?.키 ?? L("M02_SangeunNoBase"); const revised = !!(doc.바탕판 && ans?.바탕판 && doc.바탕판 !== ans.바탕판); const pick = createButton({ label: L("M02_SangeunPickBase"), variant: "filled", size: 28, onClick: () => openLogicPicker((x) => { doc.바탕 = { 로직: x.키 }; delete doc.바탕판; // 새 바탕 — 개정 견줄 판 없음 if (!doc.단위 || doc.단위 === "식") doc.단위 = unitOf(x.결과단위) || doc.단위; touch(false); void loadBase(); }), }); pick.hidden = !edit || !!doc.바탕 || !!doc.뺀줄?.length || !!Object.keys(doc.덮개 ?? {}).length; head.replaceChildren( field(L("M02_Name"), "이름"), field(L("M02_SangeunUnit"), "단위"), el("div", { className: "m02-sk__tags", attrs: { "data-sk-base": baseKey() ?? "" }, children: [ createText(`${L("M02_SangeunBase")} · ${baseText}`, "muted", 14), ...(ans?.머리 ? [createBadge(ans.머리, "info")] : []), ...(revised ? [createBadge(L("M02_SangeunRevisedBase"), "danger")] : []), ...(opened() ? [createBadge(L("M02_SangeunScaleOnly"), "warning")] : []), ], }), pick, ); } function drawNotes(): void { const lost = ans?.짝잃음 ?? []; const revised = !!(doc.바탕판 && ans?.바탕판 && doc.바탕판 !== ans.바탕판); notes.replaceChildren( ...(fail || ans?.까닭 ? [createBanner({ tone: "danger", text: fail || String(ans?.까닭) })] : []), ...(revised ? [createBanner({ tone: "warning", text: L("M02_SangeunRevisedNote") })] : []), ...(lost.length ? [ createBanner({ tone: "warning", text: L("M02_SangeunLost"), details: lost.map((x) => `${x.cid} — ${x.까닭}`).join("\n"), }), ] : []), ); } function draw(): void { if (!alive) return; drawHead(); drawNotes(); baseHint.hidden = !base; const rows = ans ? rowsOf(ans, bare, doc) : []; const names = new Map((bare ? rowsOf(bare, null, {}) : []).map((r) => [r.cid, r.이름])); const ho = coverTable(rows, { edit, opened: opened(), tag: L("M02_SangeunEditedHere"), total: ans?.기준?.계 ?? null, removed: (doc.뺀줄 ?? []).map((cid) => ({ cid, 이름: names.get(cid) ?? cid })), onQty: (r, v) => (setQty(doc, r, v, opened()), touch()), onPrice: (r, v) => (setPrice(doc, r, v), touch()), onDrop: (r) => (dropLine(doc, r), touch()), onRestore: (cid) => (restoreLine(doc, cid), touch()), }); const row = base?.logic ?? standIn(); const est = el("div", { attrs: { "data-est": "" }, children: ans ? calcSheetRows({ 묶음: sheetGroups(rows), 계: ans.기준?.계 ?? null, sumLabel: `${L("M02_SangeunTotal")} (${row.결과단위 || "원"})`, bundle: true, inputs: row.입력, values, onInput, }) : [createText(L("M02_SangeunWait"))], }); buildLabDetail(editor, { row, file: base?.file ?? "", reasons: base?.reasons ?? [], prices: base?.prices ?? {}, lines: null, text, lastText, values, ownHost, onChange: () => undefined, calcHost, onInput, cover: { ho, est }, }); } /** 텍스트 수식 · 계산서 입력 단추 — 시험 입력 고침 → 시험 계산 다시 그림 · 셈 */ function onInput(name: string, v: string): void { values[name] = v; drawCalc(); calcHost.querySelector("[data-calc-run]")?.click(); } function drawCalc(): void { if (!base) { calcHost.replaceChildren(createText(L("M02_SangeunNoInputs"))); return; } buildCalc(calcHost, { savedKey: base.logic.키, file: base.file, row: base.logic, prices: base.prices, dirty: () => false, values, onText: (t) => { text = t; if (t.ok) lastText = t; draw(); }, onLines: () => void recalc(), // 시험 입력이 바뀜 — SK 도 같은 입력으로 }); } async function loadBase(): Promise { const key = baseKey(); base = null; text = null; if (key) { try { base = await fetchLogic(key); } catch (error) { fail = error instanceof Error ? error.message : String(error); } } if (!alive) return; drawCalc(); draw(); await recalc(); } void loadBase(); return { getDoc: () => { if (ans?.바탕판 && doc.바탕) doc.바탕판 = ans.바탕판; // 저장 = 지금 바탕 확인(서버가 다시 적음) saved = JSON.stringify(doc); return clone(doc); }, destroy: () => { alive = false; host.replaceChildren(); }, }; }