/* ============================================================================= * M02_MasterTemplete_Labor_Detail.ts * 일위대가 상세(39-3) — 구조물 열마다 하나(`labor/<열>.json`) · UI_Main 종류 `labor` 가 이 부품을 붙임 * (양식 길 읽기 · [저장] · [다시 읽기]는 UI_Main). 차례 = 정보(단위 · 본뜬) → 담은 로직 → 자재목록 → 다이어그램. * · 담은 로직 줄 누름 = 고름(하이라이트 · 다시 그려도 유지 · 47-5) + 그 줄 바로 아래 하위 행(51-2 · `Labor_Rows`) · * 같은 줄 다시 누르면 닫힘 · [옵션] = 입력 모달(`Labor_Input`) · * [확인] = 화면 캐시만 · 저장은 [저장] * · 금액 = 서버 calc(기준 ③ · 최신 응답만 · 저장 안 함) · 고칠 때마다 뒤에서 다시 셈 * · 같은 로직 두 번 · 같은 자재 두 번 막음 · 빈 일위대가도 저장 됨 * · 오른쪽 「원문」 접는 패널(47-4 · 상세만 · 공용 `ui_template_side_panels`) = 고른 줄 로직의 출처 원문 * · 서고 모드(50-3 · `mode`) = 단가승격 · 사전조합 — 이름 칸 · 단가산출 로직 표 · 원문만(자재 · 면적 · 다이어그램 없음) · * 정보 칸 구분 · 상세구분(51-12 · 승격 = 읽기만 + 연결 · 사전조합 = 고름) · * 승격은 로직 한 줄(입력값 고정 · 더하기 · 순서 · 수량 없음) · 사전조합 = 일위대가 상세와 같은 [로직 추가] · 표 · 동작(51-11) · 수량 손 입력(기본 1) * ========================================================================== */ import { createButton, createInputField, createSelectField, el, showToast, } from "@ui/ui_template_elements"; import { createSidePanels } from "@ui/ui_template_side_panels"; import { ROUTES } from "@config/config_frontend"; import { navigateTo } from "../A00_Common/router"; import { setPendingLogicKey } from "../M01_MasterData/M01_MasterData_UI_Side"; import { createSourceView } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Source"; import type { SubBrief } from "../M01_MasterData/M01_MasterData_UI_Logic_Api"; import type { Workbook } from "../A00_Common/spreadsheet/spreadsheet"; import { recalcWorkbook } from "../A00_Common/spreadsheet/spreadsheet_recalc"; import { getActiveLayer, getActiveOwner, readTemplate, saveTemplate, type ItemLogic, type Layer, } from "./M02_MasterTemplete_Api_Fetch"; import { linkAreaCell } from "./M02_MasterTemplete_AreaCellPick"; import { lineId, settle, type LaborRef } from "./M02_MasterTemplete_Labor_Calc"; import { areaLinks, calcLabor, fetchLibLogicSubs, fetchLogicInputs, normalizeLabor, type LaborCalc, type LaborDoc, type LaborQty, type LaborRow, } from "./M02_MasterTemplete_Labor_Api"; import { buildLaborDiagram, diagramTitle, renderComboDiagram, } from "./M02_MasterTemplete_Labor_Diagram"; import { openLaborInput, type GoArea } from "./M02_MasterTemplete_Labor_Input"; import { openQtyModal } from "./M02_MasterTemplete_Labor_QtyModal"; import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick"; import { logicTable, matTable, qtyShort } 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 { createUnitPick, NO_UNIT } from "./DataFlow/M02_DF_UnitPick"; 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; /** 구조물 조합 카드 갈래(43-4) — 단가산출 로직 표 + 자재목록만 · 위로 · 아래로 · 로직 추가 · * 자재 추가 · 정보(단위 · 본뜬) · 다이어그램 없음 · 옵션 · 삭제 · 줄 · 수량 모달은 상세와 같음 */ card?: boolean; /** 일위대가 조합 서고(50-3) — 단가승격 · 사전조합 문서(`column` = 키) */ mode?: "promote" | "precombo"; } export interface LaborDetailHandle { getDoc: () => LaborDoc; /** [엑셀에서 고르기]로 바뀐 면적산출 문서를 저장(일위대가 [저장] 앞) · 저장했으면 참(없으면 거짓) — * 서버가 면적 저장 때 그 열 일위대가 계산값을 다시 적어 일위대가 판이 바뀜 → 부르는 쪽이 판을 다시 읽음 */ saveArea: () => Promise; 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 lib = opt.mode ?? null; const promote = lib === "promote"; const logics = new Map(); let calc: LaborCalc | null = null; let fail: string | null = null; let seq = 0; let timer = 0; let alive = true; /** 고른 줄 로직 키(47-5) — 키로 둬서 순서가 바뀌어도 따라감 */ let picked: string | null = null; /** 그 열 면적산출의 알약 → 칸 · 값(저장본 · 서버 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.값]]))); /** 그 열 면적산출 문서 · 판 — [엑셀에서 고르기]로 이으면 바뀜(일위대가 [저장] 때 함께 저장) */ let area: { doc: Workbook; 판: string } | null = null; let areaDirty = false; /** 알약 키 → 이름 · 단위(calc 답 `알약`) — 칸에 이을 때 연결 정보 */ let pills = new Map(); const dirty = (): boolean => areaDirty || JSON.stringify(cur) !== saved; 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], }); /** 원문 패널(47-4 · 카드 갈래엔 없음) — 열려 있을 때만 찾음 · 고른 줄 없으면 안내 한 줄 */ const source = createSourceView(); const sourceHost = el("div", { attrs: { "data-labor-source": "" } }); const side = opt.card ? null : createSidePanels({ prefKey: "m02-labor-side", panels: [{ id: "source", title: tc("Labor_Source"), body: sourceHost }], onChange: () => showSource(), }); function showSource(): void { if (side?.current() !== "source") return; if (!picked) return void sourceHost.replaceChildren( el("p", { className: "m01-logic__muted", text: tc("Labor_SourceHint") }), ); source.show(logics.get(picked)?.출처 ?? ""); if (sourceHost.firstChild !== source.root) sourceHost.replaceChildren(source.root); } const lineOf = (key: string) => calc?.줄.find((l) => lineId(l) === key); const matOf = (code: string) => calc?.자재.find((m) => m.코드 === code); const drawDg = (): void => { if (opt.card || lib || !dgBox.open) return; // 카드 갈래 · 서고 모드엔 다이어그램 없음 const data = buildLaborDiagram({ rows: cur.담은로직.filter((r) => !r.참조), // ponytail: 참조 줄은 다이어그램에 안 그림 — 그릴 때 카드 꼴 더함 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 drawLogic = (): void => logicHost.replaceChildren( logicTable( { rows: cur.담은로직, logics, calc, fail, cells: cells(), selected: picked }, // 읽기만이면 순서 · 삭제 없음([옵션] = 보기 모달은 둠) edit && !promote ? { open: pick, option: openRow, qty: openQty, ...(opt.card ? {} : { move }), remove: removeRow, alias: setAlias, } : { open: pick, option: openRow }, ), ); const draw = (): void => { drawLogic(); matHost.replaceChildren( matTable( { mats: cur.자재, calc, fail, cells: cells() }, edit ? { open: openMat, remove: removeMat } : { open: openMat }, ), ); drawDg(); showSource(); }; /** 서버 다시 셈 — 늦게 온 답은 버림(최신 응답만) */ 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, areaDirty ? given() : undefined); if (!alive || mine !== seq) return; calc = got; pills = new Map( (got.알약 as { 키: string; 이름?: string; 단위?: string }[]).map((p) => [p.키, p]), ); } 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(dirty()); if (qtyOnly && calc) { calc = settle(calc, cur, given()); draw(); } else recalc(); }; /** 칸 잇기 · 풀기(`cell` null) — 화면 문서만(저장은 [저장]) · 됐으면 참 */ const linkCell = (pill: string, cell: string | null, replace = false): boolean => { if (!area) { showToast(tc("Labor_NoArea"), "error"); return false; } const info = pills.get(pill); const next = linkAreaCell( area.doc, pill, cell, { ...(info?.이름 ? { 이름: info.이름 } : {}), ...(info?.단위 ? { 단위: info.단위 } : {}), }, replace, // 그 칸 다른 알약 — 기본 함께 · [바꾸기]면 뺌(물음은 시트 안) ); if (!next) { showToast(tc("Labor_BadCell"), "error"); return false; } next.계산값 = recalcWorkbook(next); // 식 칸 값도 바로 — 저장 때 서버가 다시 셈 area.doc = next; links = areaLinks(next); areaDirty = true; return true; }; /** 수량산출 모달(43-1) — 로직 줄 · 자재 줄 같음 · 손 · 칸 잇기 · 풀기로 `target.수량` 을 바꿈 */ const openQtyFor = ( title: string, target: { 수량: LaborQty }, line: { 수량?: number | null; 수량출처?: string; 알약?: string | null } | undefined, unit: string, ): void => { const pill = line?.알약 ?? null; openQtyModal({ title, now: qtyShort(line, unit, cells()), linked: !!pill && links.has(pill), hand: target.수량?.손 ?? null, unit, readOnly: !edit, sheet: { doc: area?.doc ?? null, layer: opt.layer, column: opt.column, pill }, goArea: opt.goArea, ...(lib ? { handOnly: true } : {}), done: (r) => { if (r.kind === "keep") return; if (r.kind === "hand") target.수량 = { 손: r.손 }; else if (r.kind === "follow") target.수량 = null; else { const cell = r.kind === "cell" ? r.pick.칸 : null; if (!pill || !linkCell(pill, cell, r.kind === "cell" && r.pick.바꾸기 === true)) return; target.수량 = null; // 칸을 따름 } changed(true); }, }); }; /** 아직 안 받은 로직 입력 목록만 받음 */ const loadLogics = async (): Promise => { const want = cur.담은로직.map((r) => r.로직).filter((k) => 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(); } /** 줄 고름(47-5) — 하이라이트 + 하위 행(51-2 · 표만 다시 그림 · 다이어그램 안 다시 그림) + 원문 패널(47-4) · * 고른 줄 다시 누름 = 닫음 · 화면 캐시 안 바뀜 */ function pick(i: number): void { const key = lineId(cur.담은로직[i]) ?? ""; picked = picked === key ? null : key; drawLogic(); showSource(); } function removeRow(i: number): void { if (lineId(cur.담은로직[i]) === picked) picked = null; cur.담은로직.splice(i, 1); changed(); } /** 별칭(44-6) — 화면 캐시만(저장은 [저장]) · 금액과 무관이라 다시 셈 · 다시 그림 없음 */ function setAlias(i: number, value: string | null): void { cur.담은로직[i].별칭 = value; opt.onChanged(dirty()); } 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; openLaborInput({ title: `${row.로직} ${logic?.이름 ?? ""}`.trim(), logic, values: row.입력값, memo: row.메모, readOnly: !edit, done: (next) => { const values = logic ? keepKnown(logic.입력, next.입력값).kept : next.입력값; const same = JSON.stringify(values) === JSON.stringify(row.입력값); row.입력값 = values; row.메모 = next.메모; changed(same); // 입력값이 그대로면(메모만) 서버 안 부름 }, }); } /** 수량산출 칸 누름 — 로직 줄 */ function openQty(i: number): void { const row = cur.담은로직[i]; const line = lineOf(lineId(row) ?? ""); openQtyFor( `${row.참조?.키 ?? row.로직} ${(row.참조 ? line?.이름 : logics.get(row.로직)?.이름) ?? ""}`.trim(), row, line, line?.분모 ?? "", ); } /** 자재 줄 누름 — 수량산출 모달 */ function openMat(i: number): void { const mat = cur.자재[i]; openQtyFor(`${mat.코드} ${mat.이름 ?? ""}`.trim(), mat, matOf(mat.코드), mat.단위 ?? ""); } /** 서고 탭(50-3 ④) — 참조 줄 더함(같은 참조 두 번 막음 · 위 층만은 서버도 막음) */ const library = { layer: opt.layer, owner: opt.layer === getActiveLayer() ? getActiveOwner() : null, pick: (ref: LaborRef, name: string) => { const id = lineId({ 참조: ref }); if (cur.담은로직.some((r) => lineId(r) === id)) return showToast(tc("Labor_AlreadyRef").replace("{value}", `${ref.키} ${name}`), "error"); const 수량 = lib === "precombo" ? { 손: 1 } : null; // 사전조합 줄 기본 1(51-6) cur.담은로직.push({ 로직: "", 참조: ref, 별칭: null, 메모: null, 입력값: {}, 수량 }); changed(); }, }; const addLogic = createButton({ label: tc("AddLogic"), variant: "filled", onClick: () => openLogicPicker( (x) => { if (cur.담은로직.some((r) => r.로직 === x.키)) return showToast(tc("Labor_Already").replace("{value}", `${x.키} ${x.이름}`), "error"); // 사전조합 = 면적산출 없음 → 손 1(51-11) const 수량 = lib === "precombo" ? { 손: 1 } : null; cur.담은로직.push({ 로직: x.키, 별칭: null, 메모: null, 입력값: {}, 수량 }); void loadLogics().then(() => changed()); }, // 사전조합도 같은 창 · 같은 목록(51-11 · 자기 자신 · 순환은 [저장] 때 서버 422) library, ), }); const addMat = createButton({ label: tc("Labor_AddMat"), variant: "filled", 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 = { root: el("div", { className: "ui-field", attrs: { "data-labor-field": "단위" } }), }; const unit0 = cur.단위; const drawUnit = (): void => unit.root.replaceChildren( el("label", { className: "ui-field__label", text: tc("Labor_Unit") }), createUnitPick({ value: cur.단위 || NO_UNIT, extra: [unit0, cur.단위].filter((u): u is string => !!u), readOnly: !edit, ariaLabel: tc("Labor_Unit"), onPick: (u) => { cur.단위 = u === NO_UNIT ? null : u; opt.onChanged(dirty()); drawUnit(); }, }), ); drawUnit(); 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, ], }); // 서고 모드 이름 칸(문서 `이름` · 목록에 보이는 이름) const nameField = createInputField({ type: "text", label: tc("Lib_Name") }); nameField.root.dataset.laborField = "이름"; nameField.input.value = String((cur as { 이름?: unknown }).이름 ?? ""); nameField.input.readOnly = !edit; nameField.input.addEventListener("input", () => { (cur as { 이름?: string }).이름 = nameField.input.value.trim(); opt.onChanged(dirty()); }); /** 서고 구분 · 상세구분(51-12) — 승격 = 원 로직 것 읽기만 + 연결(원 로직 키 · 출처) · * 사전조합 = 로직 구분 목록(logic-lib/subs)에서 고름(구분 바꾸면 상세구분 = 그 첫 줄) */ const meta = cur as { 구분?: string | null; 상세구분?: string | null; 연결?: unknown }; const readField = (label: string, field: string, value: string): HTMLElement => { const f = createInputField({ type: "text", label }); f.root.dataset.laborField = field; f.input.value = value; f.input.readOnly = true; return f.root; }; const libMeta = (): HTMLElement[] => { if (promote) { const link = (meta.연결 ?? {}) as { 로직?: string; 키?: string; 출처?: string }; return [ readField(tc("Sub"), "구분", meta.구분 ?? ""), readField(tc("Detail"), "상세구분", meta.상세구분 ?? ""), readField( tc("Lib_Link"), "연결", [link.로직 ?? link.키, link.출처].filter(Boolean).join(" · "), ), ]; } const sub = createSelectField({ options: [], label: tc("Sub"), disabled: !edit }); const detail = createSelectField({ options: [], label: tc("Detail"), disabled: !edit }); sub.root.dataset.laborField = "구분"; detail.root.dataset.laborField = "상세구분"; let subs: SubBrief[] = []; // 목록에 없는 지금 값도 선택지로 둠 const opts = (names: string[], now: string | null | undefined) => [...new Set([...(now ? [now] : []), ...names])].map((v) => ({ value: v, text: v })); const fill = (): void => { sub.setOptions( opts( subs.map((s) => s.name), meta.구분, ), meta.구분 ?? "", ); const details = subs.find((s) => s.name === meta.구분)?.details ?? []; detail.setOptions(opts(details, meta.상세구분), meta.상세구분 ?? ""); }; sub.select.addEventListener("change", () => { meta.구분 = sub.select.value; meta.상세구분 = subs.find((s) => s.name === meta.구분)?.details[0] ?? null; fill(); opt.onChanged(dirty()); }); detail.select.addEventListener("change", () => { meta.상세구분 = detail.select.value; opt.onChanged(dirty()); }); fill(); void fetchLibLogicSubs().then( (got) => { subs = got; fill(); }, () => undefined, ); return [sub.root, detail.root]; }; const editor = (body: HTMLElement): HTMLElement => side ? el("div", { className: "ui-side-panels-row", children: [body, side.root] }) : body; host.replaceChildren( editor( el("div", { className: "m01-logic__editor m01-combo m02-labor__edit", attrs: { "data-labor": opt.column }, children: [ ...(opt.card ? [] : [ el("section", { className: "m01lab__box m01-logic__section", children: [ el("div", { className: "m01lab__info", children: [ ...(lib ? [nameField.root, ...libMeta()] : []), unit.root, origin.root, ], }), ], }), ]), // 승격 = 로직 한 줄(새로 만들 때 고름) · 사전조합 = 같은 [로직 추가](51-11) section(tc("Logics_Title"), opt.card || promote ? null : addLogic, logicHost), ...(lib ? [] : [section(tc("Labor_Mats"), opt.card ? null : addMat, matHost)]), ...(opt.card || lib ? [] : [dgBox]), ], }), ), ); draw(); // 로직 입력 목록 · 면적산출 이은 칸(없으면 빈 것 — 새 구조물)을 받은 뒤 첫 계산 void Promise.all([ loadLogics(), lib // 서고 모드엔 면적산출 없음 ? undefined : readTemplate(opt.layer, "area", opt.column, null).then( (got) => { area = { doc: got.문서 as Workbook, 판: got.판 ?? "" }; links = areaLinks(got.문서); }, () => undefined, ), ]).then(() => { if (alive) recalc(); }); return { // 서고 모드 = 문서 종류 그대로 · 열 · 자재 없음 getDoc: () => { if (!lib) return cur; const out: Record = { ...cur, 종류: promote ? "단가승격" : "사전조합" }; delete out.열; delete out.자재; return out as unknown as LaborDoc; }, saveArea: async () => { if (!area || !areaDirty) return false; const info = await saveTemplate(opt.layer, "area", opt.column, null, area.판, area.doc); area.판 = info.판; areaDirty = false; return true; }, destroy: () => { alive = false; window.clearTimeout(timer); host.replaceChildren(); }, }; }