/* ============================================================================= * M02_MasterTemplete_Labor_Detail.ts * 일위대가 상세(39-3) — 구조물 열마다 하나(`labor/<열>.json`) · UI_Main 종류 `labor` 가 이 부품을 붙임 * (양식 길 읽기 · [저장] · [다시 읽기]는 UI_Main). 차례 = 정보(단위 · 본뜬) → 담은 로직 → 자재목록 → 다이어그램. * · 담은 로직 줄 누름 = 입력 모달(`Labor_Input`) · [확인] = 화면 캐시만 · 저장은 [저장] * · 금액 = 서버 calc(기준 ③ · 최신 응답만 · 저장 안 함) · 고칠 때마다 뒤에서 다시 셈 * · 같은 로직 두 번 · 같은 자재 두 번 막음 · 빈 일위대가도 저장 됨 * ========================================================================== */ import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements"; import { ROUTES } from "@config/config_frontend"; import { navigateTo } from "../A00_Common/router"; import { setPendingLogicKey } from "../M01_MasterData/M01_MasterData_UI_Side"; import { readTemplate, type ItemLogic, type Layer } from "./M02_MasterTemplete_Api_Fetch"; import { settle } from "./M02_MasterTemplete_Labor_Calc"; import { areaLinks, calcLabor, fetchLogicInputs, normalizeLabor, type LaborCalc, type LaborDoc, type LaborRow, } from "./M02_MasterTemplete_Labor_Api"; import { buildLaborDiagram, diagramTitle, renderComboDiagram, } from "./M02_MasterTemplete_Labor_Diagram"; import { openLaborInput, openQtyModal, type GoArea } from "./M02_MasterTemplete_Labor_Input"; import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick"; import { logicTable, matTable } from "./M02_MasterTemplete_Labor_Rows"; import { tc } from "./M02_MasterTemplete_Labor_Text"; import { keepKnown } from "./M02_Labor_Values_Model"; import { pickMaterial } from "./M02_Material_Pick"; import "../M01_MasterData/M01_MasterData_UI_Logic_Style.css"; import "../M01_MasterData/M01_MasterData_UI_LogicLab_Style.css"; import "@ui/ui_template_general_layout.css"; import "./M02_MasterTemplete_Labor.css"; export interface LaborDetailOptions { layer: Layer; column: string; readOnly: boolean; /** 저장 안 한 고침이 생기거나 사라짐 */ onChanged: (dirty: boolean) => void; /** 「면적산출에서 잇기」 — 그 열 면적산출 상세로(옮겨 가면 참 · 취소 = 거짓 · 없으면 단추 없음) */ goArea: GoArea | null; } export interface LaborDetailHandle { getDoc: () => LaborDoc; destroy: () => void; } export function mountLaborDetail( host: HTMLElement, doc: unknown, opt: LaborDetailOptions, ): LaborDetailHandle { const cur = normalizeLabor(doc, opt.column); const saved = JSON.stringify(cur); const edit = !opt.readOnly; const logics = new Map(); let calc: LaborCalc | null = null; let fail: string | null = null; let seq = 0; let timer = 0; let alive = true; /** 그 열 면적산출의 알약 → 칸 · 값(저장본 · 서버 calc 와 같은 것) */ let links = new Map(); const cells = (): Map => new Map([...links].map(([k, v]) => [k, v.칸])); const given = (): Record => Object.fromEntries([...links].flatMap(([k, v]) => (v.값 === null ? [] : [[k, v.값]]))); const logicHost = el("div"); const matHost = el("div"); const dgHost = el("div"); const dgBox = el("details", { className: "m01lab__box m01-logic__section m02-labor__dg", attrs: { open: "" }, children: [el("summary", { text: diagramTitle() }), dgHost], }); const lineOf = (key: string) => calc?.줄.find((l) => l.로직 === key); const matOf = (code: string) => calc?.자재.find((m) => m.코드 === code); const drawDg = (): void => { if (!dgBox.open) return; const data = buildLaborDiagram({ rows: cur.담은로직, logics: [...logics.values()], calc, mats: cur.자재.length, }); renderComboDiagram(dgHost, data, { focusInput: (key) => { const i = cur.담은로직.findIndex((r) => r.로직 === key); if (i >= 0) openRow(i); }, // 로직 카드 → 단가산출 로직 화면(떠날 때 물음은 UI_Main 의 leave guard) openLogic: (key) => { setPendingLogicKey(key); navigateTo(ROUTES.M01_MASTER_DATA); }, }); }; dgBox.addEventListener("toggle", drawDg); const draw = (): void => { logicHost.replaceChildren( logicTable( { rows: cur.담은로직, logics, calc, fail, cells: cells() }, // 읽기만이면 순서 · 빼기 없음(줄 누름 = 보기 모달은 둠) edit ? { open: openRow, move, remove: removeRow } : { open: openRow }, ), ); matHost.replaceChildren( matTable( { mats: cur.자재, calc, fail, cells: cells() }, edit ? { open: openMat, remove: removeMat } : { open: openMat }, ), ); drawDg(); }; /** 서버 다시 셈 — 늦게 온 답은 버림(최신 응답만) */ const recalc = (): void => { window.clearTimeout(timer); timer = window.setTimeout(async () => { const mine = ++seq; calc = null; fail = null; draw(); try { const got = await calcLabor(opt.layer, opt.column, cur); if (!alive || mine !== seq) return; calc = got; } catch (error) { if (!alive || mine !== seq) return; fail = error instanceof Error ? error.message : tc("Labor_Calc_Fail"); } draw(); }, 150); }; /** `qtyOnly` = 수량만 바뀜 — 기준은 그대로라 짝 셈(`Labor_Calc.settle`)으로 바로 곱함(서버 안 부름) */ const changed = (qtyOnly = false): void => { opt.onChanged(JSON.stringify(cur) !== saved); if (qtyOnly && calc) { calc = settle(calc, cur, given()); draw(); } else recalc(); }; /** 이은 칸 글 「B6 = 12.5 ㎡」 — 알약이 칸에 안 이어졌으면 null */ const linkedText = (pill: string | null | undefined, unit: string): string | null => { const at = pill ? links.get(pill) : undefined; if (!at) return null; return `${at.칸} = ${at.값 === null ? "—" : at.값.toLocaleString("ko-KR")}${unit ? ` ${unit}` : ""}`; }; /** 아직 안 받은 로직 입력 목록만 받음 */ const loadLogics = async (): Promise => { const want = cur.담은로직.map((r) => r.로직).filter((k) => !logics.has(k)); if (!want.length) return; try { const got = await fetchLogicInputs(want); for (const l of got.로직) logics.set(l.로직, l); } catch (error) { showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error"); } }; function move(i: number, d: number): void { const j = i + d; if (j < 0 || j >= cur.담은로직.length) return; [cur.담은로직[i], cur.담은로직[j]] = [cur.담은로직[j], cur.담은로직[i]]; changed(); } function removeRow(i: number): void { cur.담은로직.splice(i, 1); changed(); } function removeMat(i: number): void { cur.자재.splice(i, 1); changed(); } function openRow(i: number): void { const row: LaborRow = cur.담은로직[i]; const logic = logics.get(row.로직) ?? null; const line = lineOf(row.로직); openLaborInput({ title: `${row.로직} ${logic?.이름 ?? ""}`.trim(), logic, values: row.입력값, memo: row.메모, seat: { qty: row.수량, linked: linkedText(line?.알약, line?.분모 ?? ""), unit: line?.분모 ?? "", }, readOnly: !edit, goArea: opt.goArea, done: (next) => { const values = logic ? keepKnown(logic.입력, next.입력값).kept : next.입력값; const qtyOnly = JSON.stringify(values) === JSON.stringify(row.입력값); row.입력값 = values; row.메모 = next.메모; row.수량 = next.수량; changed(qtyOnly); }, }); } function openMat(i: number): void { const mat = cur.자재[i]; const line = matOf(mat.코드); openQtyModal( `${mat.코드} ${mat.이름 ?? ""}`.trim(), { qty: mat.수량, linked: linkedText(line?.알약, mat.단위 ?? ""), unit: mat.단위 ?? "", }, !edit, opt.goArea, (qty) => { mat.수량 = qty; changed(true); }, ); } const addLogic = createButton({ label: tc("AddLogic"), variant: "filled", size: "sm", onClick: () => openLogicPicker((x) => { if (cur.담은로직.some((r) => r.로직 === x.키)) return showToast(tc("Labor_Already").replace("{value}", `${x.키} ${x.이름}`), "error"); cur.담은로직.push({ 로직: x.키, 메모: null, 입력값: {}, 수량: null }); void loadLogics().then(() => changed()); }), }); const addMat = createButton({ label: tc("Labor_AddMat"), variant: "filled", size: "sm", onClick: () => pickMaterial((m) => { if (cur.자재.some((x) => x.코드 === m.코드)) return showToast( tc("Labor_AlreadyMat").replace("{value}", `${m.코드} ${m.이름 ?? ""}`), "error", ); cur.자재.push({ 코드: m.코드, 이름: m.이름, 규격: m.규격, 단위: m.단위, 수량: null }); changed(); }), }); const unit = createInputField({ type: "text", label: tc("Labor_Unit") }); unit.input.value = cur.단위 ?? ""; unit.input.readOnly = !edit; unit.input.maxLength = 20; unit.input.setAttribute("data-labor-field", "단위"); unit.input.addEventListener("input", () => { cur.단위 = unit.input.value.trim() || null; opt.onChanged(JSON.stringify(cur) !== saved); }); const origin = createInputField({ type: "text", label: tc("Labor_Origin") }); origin.input.value = cur.본뜬?.키 ? `${cur.본뜬.층} ${cur.본뜬.키}` : tc("Origin_Self"); origin.input.readOnly = true; const section = (title: string, button: HTMLElement | null, body: HTMLElement): HTMLElement => el("section", { className: "m01lab__box m01-logic__section", children: [ el("div", { className: "m01-logic__section-head", children: [el("h3", { text: title }), ...(edit && button ? [button] : [])], }), body, ], }); host.replaceChildren( el("div", { className: "m01-logic__editor m01-combo m02-labor__edit", attrs: { "data-labor": opt.column }, children: [ el("section", { className: "m01lab__box m01-logic__section", children: [el("div", { className: "m01lab__info", children: [unit.root, origin.root] })], }), section(tc("Logics_Title"), addLogic, logicHost), section(tc("Labor_Mats"), addMat, matHost), dgBox, ], }), ); draw(); // 로직 입력 목록 · 면적산출 이은 칸(없으면 빈 것 — 새 구조물)을 받은 뒤 첫 계산 void Promise.all([ loadLogics(), readTemplate(opt.layer, "area", opt.column, null).then( (got) => (links = areaLinks(got.문서)), () => undefined, ), ]).then(() => { if (alive) recalc(); }); return { getDoc: () => cur, destroy: () => { alive = false; window.clearTimeout(timer); host.replaceChildren(); }, }; }