/* ============================================================================= * M02_MasterTemplete_Labor_Table.ts * M02 일위대가(서고 · 사전조합) 상세 = 일위대가표만(PLAN 723-2 · 내역 16 · 17 · 요구서_내역16 2장 3~5). * · 차례 = 정보(이름 · 구분 · 단위 · 본뜬) → 일위대가표(엑셀 꼴 격자 · `Labor_Grid.laborBlock`) · 오른쪽 원문 패널 * · 옵션 값 = 규격 칸 · 「빈 값 n」 = 비고 칸 붉게(입력 빈 로직 줄) * · 수량 칸 = 확인용 임시 입력(723-1 · 휘발 · 문서 · 저장 · dirty 안 건드림) → 그 줄 금액 · 합계 미리 보기 * (짝 셈 `Labor_Calc.settle` · 서버 안 부름) · 문서 안 줄 수량은 늘 null(쓰는 쪽 알약으로 이음) * · 머리 단추 = [로직 추가] [자재 추가] · 줄 우클릭 = 위로 · 아래로 · 별칭 · 삭제 * · 줄 누름 = 줄 모달(`Labor_RowModal` — 옵션 · 메모 · 켜쌓기율 · 로직 자재 바꾸기 · 세 칸 · 단가산출) · * 사전조합 참조 줄 = 서고 원본 모달(`Labor_RefEdit`) · 사용자 자재 줄 = 자재 모달(세 칸) * · 금액 = 서버 calc(최신 응답만 · 저장 안 함) · [저장]은 UI_Main(서버가 수량 걷고 다시 셈) * 구조물 조합 일위대가(카드 · 열 상세)는 그대로 `Labor_Detail`(면적 잇기 · 수량산출). * ========================================================================== */ import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements"; import { createSidePanels } from "@ui/ui_template_side_panels"; import { createHopyoGrid, hopyoCellClass, hopyoGrid } from "@ui/ui_template_hopyo_grid"; import { openTreeMenu, type TreeMenuItem } from "@ui/ui_template_tree"; import { askText, showConfirmDialog } from "@ui/ui_template_modal"; import { createText } from "@ui/ui_template_text"; import { currentLanguageIndex } from "@ui/ui_template_locale"; import { createSourceView } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Source"; import { getActiveLayer, getActiveOwner, type ItemLogic } from "./M02_MasterTemplete_Api_Fetch"; import { lineId, settle, type LaborFixed, type LaborRef } from "./M02_MasterTemplete_Labor_Calc"; import { calcLabor, fetchLogicInputs, normalizeLabor, type LaborCalc, type LaborDoc, type LaborRow, } from "./M02_MasterTemplete_Labor_Api"; import type { LaborDetailHandle, LaborDetailOptions } from "./M02_MasterTemplete_Labor_Detail"; import { laborBlock } from "./M02_MasterTemplete_Labor_Grid"; import { pasteInto } from "./M02_MasterTemplete_Labor_Paste"; import { openLogicPicker, withHopyo } from "./M02_MasterTemplete_Labor_Pick"; import { openRefEdit } from "./M02_MasterTemplete_Labor_RefEdit"; import { openMatModal, openRowModal } from "./M02_MasterTemplete_Labor_RowModal"; import { tc } from "./M02_MasterTemplete_Labor_Text"; import { insideKeys } from "./M02_Labor_LogicMats_Model"; import { valueLine } from "./M02_Labor_Values_Model"; import { addMaterialButton } from "./M02_Material_Pick"; import { createUnitPick, NO_UNIT } from "./DataFlow/M02_DF_UnitPick"; import { libMeta } from "./M02_MasterTemplete_Labor_LibMeta"; 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"; const TEXT = { TempHint: [ "수량 칸 = 확인용 임시 입력(저장 안 함) — 실제 수량은 구조물 조합 · 프로젝트에서 이음", "Quantity = temporary check only (not saved) — real quantities link in structures · projects", ], Alias: ["별칭", "Alias"], AliasAsk: ["별칭(비우면 지움)", "Alias (empty = clear)"], RemoveAsk: ["「{value}」 줄을 지울까?", "Delete line “{value}”?"], Empty: ["빈 값 {n}", "{n} empty"], NotNumber: ["수가 아님", "Not a number"], } as const satisfies Record; const t = (k: keyof typeof TEXT): string => TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0]; /** 비율 고정 줄(705-3) — 수량 = 율(문서에 남음 · 임시 입력 아님) */ const isRate = (row: LaborRow): boolean => !!row.고정?.비율; const matKey = (code: string): string => `자재|${code}`; export function mountLaborTable( host: HTMLElement, doc: unknown, opt: LaborDetailOptions, ): LaborDetailHandle { const cur = normalizeLabor(doc, opt.column); // 723-1 — 옛 문서 손 수량은 안 씀(서버도 저장 때 걷음) · 비율 줄 율만 for (const r of cur.담은로직) if (!isRate(r)) r.수량 = null; for (const m of cur.자재) m.수량 = null; const saved = JSON.stringify(cur); const edit = !opt.readOnly; const logics = new Map(); /** 임시 수량(휘발) — 줄 id · 「자재|코드」 → 수 */ const temp = new Map(); let calc: LaborCalc | null = null; let last: LaborCalc | null = null; let fail: string | null = null; let seq = 0; let timer = 0; let alive = true; let picked: string | null = null; const dirty = (): boolean => JSON.stringify(cur) !== saved; /** 줄 모달이 받는 셈 답(다시 셀 때마다) */ const watchers = new Set<(c: LaborCalc | null) => void>(); const watch = (fn: (c: LaborCalc | null) => void): (() => void) => { watchers.add(fn); fn(calc ?? last); return () => watchers.delete(fn); }; /** 셈 몸 = 문서 + 임시 수량(저장 안 함) */ const withTemp = (): LaborDoc => ({ ...cur, 담은로직: cur.담은로직.map((r) => { const v = temp.get(lineId(r) ?? ""); return v === undefined ? r : { ...r, 수량: { 손: v } }; }), 자재: cur.자재.map((m) => { const v = temp.get(matKey(m.코드)); return v === undefined ? m : { ...m, 수량: { 손: v } }; }), }); const gridHost = el("div", { attrs: { "data-labor-grid": "", "data-labor-table": "" } }); const source = createSourceView({ path: "/m02/logic-lib/source", pdf: "/m02/logic-lib/source-pdf", }); const sourceHost = el("div", { attrs: { "data-labor-source": "" } }); const side = createSidePanels({ prefKey: "m02-labor-table-side", // 일위대가표는 넓게 — 원문은 처음 접힘(누르면 엶) defaultOpen: null, follow: false, 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(createText(tc("Labor_SourceHint"))); source.show(logics.get(picked)?.출처 ?? ""); if (sourceHost.firstChild !== source.root) sourceHost.replaceChildren(source.root); } /** 일위대가표 — 줄 누름 · 우클릭 · 수량 칸(임시) · 빈 값 붉게 */ const draw = (): void => { const body = withTemp(); const block = laborBlock(body, calc ?? last, logics); const empties = new Set(); block.lines.forEach((ln, n) => { const row = body.담은로직[n]; if (!row) return void (ln.note = ln.note === tc("Grid_NoQty") ? "" : ln.note); // 자재 줄 const empty = row.로직 ? valueLine(logics.get(row.로직)?.입력 ?? [], row.입력값).empty : 0; if (empty) empties.add(n); if (empty) ln.note = t("Empty").replace("{n}", String(empty)); else if (ln.note === tc("Grid_NoQty")) ln.note = ""; // 수량 없음 = 서고에선 보통(723-1) }); const have = calc ?? last; // 빈 줄 = 단가 모름(까닭) · 빈 값만 — 임시 수량 없는 줄은 안 셈 block.blank = have ? block.lines.filter((l) => l.prices.sum == null).length + empties.size : 0; if (fail && block.lines[0]) block.lines[0].note = fail; const g = hopyoGrid([block]); const at = (i: number): number | null => g.at.get(i)?.[1] ?? null; const grid = createHopyoGrid( [block], { onRowClick: (i) => { const n = at(i); if (n !== null) openAt(n); }, editable: (i, key) => { const n = at(i); if (key !== "qty" || n === null) return false; const row = cur.담은로직[n]; return !row || !isRate(row); // 임시 입력이라 읽기만 층도 됨 }, onEdit: (i, _key, text) => { const n = at(i); if (n !== null) setTemp(n, text); }, cellClass: (i, key) => { const n = at(i); if (key === "note" && n !== null && empties.has(n)) return "ui-hopyo-grid__blank"; return hopyoCellClass(g.rows, i, key); }, }, edit ? (text) => void pasteInto(cur, text, afterPaste) : undefined, ); grid.root.addEventListener("contextmenu", (ev) => { const tr = (ev.target as Element).closest("tr[data-i]"); const n = tr ? at(Number(tr.dataset.i)) : null; if (n === null || !edit) return; ev.preventDefault(); // 칸 초점이 표를 밀면(scroll) 메뉴가 바로 닫힘 — 밀림이 끝난 다음 그림에 엶 requestAnimationFrame(() => requestAnimationFrame(() => openTreeMenu(ev, menuAt(n)))); }); gridHost.replaceChildren(grid.root); showSource(); for (const fn of watchers) fn(calc ?? last); }; /** 서버 다시 셈 — 늦게 온 답은 버림 */ const recalc = (): void => { window.clearTimeout(timer); timer = window.setTimeout(async () => { const mine = ++seq; calc = null; fail = null; draw(); try { const body = withTemp(); const got = opt.calc ? await opt.calc(body) : await calcLabor(opt.layer, opt.column, body, undefined, opt.owner); if (!alive || mine !== seq) return; calc = last = got; } catch (error) { if (!alive || mine !== seq) return; fail = error instanceof Error ? error.message : tc("Labor_Calc_Fail"); } draw(); }, 150); }; /** `qtyOnly` = 수량만(임시) — 짝 셈으로 바로 */ const changed = (qtyOnly = false): void => { opt.onChanged(dirty()); if (qtyOnly && calc) { calc = settle(calc, withTemp(), {}); draw(); } else recalc(); }; /** 임시 수량(휘발) — 빈 글 = 지움 · 수 아님 = 알림 */ function setTemp(n: number, text: string): void { const row = cur.담은로직[n]; const key = row ? (lineId(row) ?? "") : matKey(cur.자재[n - cur.담은로직.length]?.코드 ?? ""); const raw = text.replace(/,/g, "").trim(); if (!raw) temp.delete(key); else if (Number.isFinite(Number(raw))) temp.set(key, Number(raw)); else return showToast(t("NotNumber"), "error"); calc = calc && settle(calc, withTemp(), {}); draw(); } /** 붙여넣기(705-2) 뒤 — 엑셀 수량은 임시 칸으로(문서엔 안 남김) */ function afterPaste(): void { for (const r of cur.담은로직) { const v = r.수량?.손; if (isRate(r) || v == null) continue; temp.set(lineId(r) ?? "", v); r.수량 = null; } void loadLogics().then(() => changed()); } 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"); } }; const lineOf = (row: LaborRow) => (calc ?? last)?.줄.find((l) => lineId(l) === lineId(row)); const nameOf = (row: LaborRow): string => row.별칭 || row.고정?.이름 || (row.참조 ? lineOf(row)?.이름 : logics.get(row.로직)?.이름) || row.참조?.키 || row.로직; /** 줄 누름 — 줄 갈래마다 */ function openAt(n: number): void { const row = cur.담은로직[n]; if (!row) return openMat(n - cur.담은로직.length); picked = row.로직 || null; showSource(); if (row.참조 && row.참조.키.startsWith("SC-")) return void openRefEdit({ ref: row.참조, title: nameOf(row), readOnly: !edit || row.참조.층 !== opt.layer, closed: () => alive && recalc(), }); if (row.고정) return; // 고정 줄 = 숫자 그대로(이름 · 별칭은 우클릭) openRowModal({ row, logic: logics.get(row.로직) ?? null, title: nameOf(row), head: [String((cur as { 이름?: string }).이름 ?? ""), cur.단위 ? `1 ${cur.단위} 당` : ""] .filter(Boolean) .join(" · "), unit: lineOf(row)?.분모 ?? "", watch, scope: { layer: opt.layer, owner: library.owner }, readOnly: !edit, inject: !!opt.calc, userMats: cur.자재.map((m) => m.코드), done: (again) => changed(!again), }); } function openMat(i: number): void { const mat = cur.자재[i]; if (!mat) return; openMatModal({ mat, watch, readOnly: !edit, done: (money) => changed(!money), }); } /** 우클릭 — 위로 · 아래로 · 별칭 · 삭제 */ function menuAt(n: number): TreeMenuItem[] { const rows = cur.담은로직.length; const isMat = n >= rows; const name = isMat ? (cur.자재[n - rows]?.이름 ?? "") : nameOf(cur.담은로직[n]); const items: TreeMenuItem[] = []; if (!isMat) { if (n > 0) items.push({ label: tc("Up"), run: () => move(n, n - 1) }); if (n < rows - 1) items.push({ label: tc("Down"), run: () => move(n, n + 1) }); items.push({ label: t("Alias"), run: () => void alias(n) }); } items.push({ label: tc("Remove"), run: () => void showConfirmDialog(t("RemoveAsk").replace("{value}", name), tc("Remove")).then( (ok) => ok && remove(n), ), }); return items; } function move(from: number, to: number): void { cur.담은로직.splice(to, 0, ...cur.담은로직.splice(from, 1)); changed(); } async function alias(n: number): Promise { const row = cur.담은로직[n]; const got = await askText({ message: t("AliasAsk"), value: row.별칭 ?? "" }); if (got === null || got === undefined) return; row.별칭 = got.trim() || null; opt.onChanged(dirty()); draw(); } function remove(n: number): void { const rows = cur.담은로직.length; if (n < rows) { temp.delete(lineId(cur.담은로직[n]) ?? ""); cur.담은로직.splice(n, 1); } else { temp.delete(matKey(cur.자재[n - rows]?.코드 ?? "")); cur.자재.splice(n - rows, 1); } changed(); } /** 서고 고르기 — 참조 줄 · 고정 줄(721-2 새 고르기 창 · 인력 · 재료) */ const library = { layer: opt.layer, owner: opt.owner !== undefined ? opt.owner : opt.layer === getActiveLayer() ? getActiveOwner() : null, pick: (ref: LaborRef, name: string, 별칭: string | null = null) => { const id = lineId({ 참조: ref }); if (cur.담은로직.some((r) => lineId(r) === id)) return showToast(tc("Labor_AlreadyRef").replace("{value}", name), "error"); cur.담은로직.push({ 로직: "", 참조: ref, 별칭, 메모: null, 입력값: {}, 수량: null }); changed(); }, fixed: (fx: LaborFixed, name: string) => { const id = lineId({ 고정: fx }); if (cur.담은로직.some((r) => lineId(r) === id)) return showToast(tc("Labor_AlreadyRef").replace("{value}", name), "error"); cur.담은로직.push({ 로직: "", 고정: fx, 별칭: null, 메모: 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"); cur.담은로직.push({ 로직: x.키, 별칭: null, 메모: null, 입력값: {}, 수량: null }); void loadLogics().then(() => changed()); }, withHopyo(library)), }); const addMat = addMaterialButton( tc("Labor_AddMat"), (m) => { const why = insideKeys((calc ?? last)?.줄).has(m.코드) ? "Labor_InLogicMat" : cur.자재.some((x) => x.코드 === m.코드) && "Labor_AlreadyMat"; if (why) return showToast(tc(why).replace("{value}", `${m.코드} ${m.이름 ?? ""}`), "error"); cur.자재.push({ 코드: m.코드, 이름: m.이름, 규격: m.규격, 단위: m.단위, 수량: null }); changed(); }, opt.layer === "personal" ? { owner: library.owner } : undefined, ); // 정보 칸 — 이름 · 구분 · 상세구분 · 단위 · 본뜬 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()); }); const unit = el("div", { className: "ui-field", attrs: { "data-labor-field": "단위" } }); const unit0 = cur.단위; const drawUnit = (): void => unit.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(); draw(); }, }), ); drawUnit(); const origin = createInputField({ type: "text", label: tc("Labor_Origin") }); origin.input.value = cur.본뜬?.키 ? `${cur.본뜬.층} ${cur.본뜬.키}` : tc("Origin_Self"); origin.input.readOnly = true; host.replaceChildren( el("div", { className: "ui-side-panels-row", children: [ 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: [ nameField.root, ...libMeta(cur, edit, () => opt.onChanged(dirty())), unit, origin.root, ], }), ], }), el("section", { className: "m01lab__box m01-logic__section", children: [ el("div", { className: "m01-logic__section-head", children: [ el("h3", { text: tc("Grid_Title") }), ...(edit ? [el("div", { className: "ui-btn-group", children: [addLogic, addMat] })] : []), ], }), createText(t("TempHint"), "hint"), gridHost, ], }), ], }), side.root, ], }), ); draw(); void loadLogics().then(() => alive && recalc()); return { getDoc: () => { const out: Record = { ...cur, 종류: "사전조합" }; delete out.열; return out as unknown as LaborDoc; }, saveArea: async () => false, destroy: () => { alive = false; window.clearTimeout(timer); host.replaceChildren(); }, }; }