/* ============================================================================= * M01_MasterData_UI_Logic_Page.ts * 관리자 화면 — 로직 칸. 가운데 일위대가표(호표) 고치기 / 오른쪽 시험 계산 * 로직 목록(원문·장 고르기 · 찾기 · 막힘 거름)은 왼쪽 패널 「일위대가 로직」 컨테이너(`listHost`) 안 * * 데이터 흐름(CLAUDE.md 5장) — 고친 것은 캐시(sessionStorage)에 쌓고 [저장] 한 번에 `POST /save`. * 409 = 그 사이 파일이 바뀜 · 422 = 검사 걸림(아무것도 안 씀) — 까닭은 서버 글 그대로 보임. * ========================================================================== */ import { t as L } from "@ui/ui_template_locale"; import { createButton, el, hideLoadingOverlay, showConfirmDialog, showLoadingOverlay, showToast, } from "@ui/ui_template_elements"; import { ApiError, copyLogic, fetchLogic, fetchLogicFiles, fetchLogics, fetchLogicSubs, saveFiles, type CalcLine, type ElementBrief, type LogicFile, type LogicRow, type LogicSummary, type SaveFile, } from "./M01_MasterData_UI_Logic_Api"; import { buildCalc } from "./M01_MasterData_UI_Logic_Calc"; import { buildEditor } from "./M01_MasterData_UI_Logic_Edit"; import { render as renderCView } from "./M01_MasterData_UI_Logic_CView"; import { isOwnLogic, mountFlow } from "./M01_MasterData_UI_Logic_Flow"; import { buildList, logicId, type ListItem, type ListMark } from "./M01_MasterData_UI_Logic_List"; import { openLogicWizard } from "./M01_MasterData_UI_Logic_Wizard"; import { openPicker } from "./M01_MasterData_UI_Logic_Pick"; import type { SideHandle } from "./M01_MasterData_UI_Side"; import { tx } from "./M01_MasterData_UI_Logic_Text"; import "./M01_MasterData_UI_Logic_Style.css"; /** 저장 안 한 로직 하나 — origKey null = 새 로직 · row null = 지움 */ interface Draft { /** 구분(원문 + 부문) — 목록 id 를 세움 */ sub: string; file: string; version: string; origKey: string | null; row: LogicRow | null; } interface Opened extends Draft { id: string; row: LogicRow | null; original: string | null; prices: Record; reasons: string[]; lines: CalcLine[] | null; values: Record; } /** 세 컨테이너(옛 호표 화면 · 쉽게 보기·만들기 · 흐름 보기)가 서로의 저장 안 한 것을 덮지 않게 캐시 칸을 가름 */ export type LogicMode = "classic" | "easy" | "cview"; const SUFFIX: Record = { classic: "", easy: "_easy", cview: "_cview" }; const cacheKey = (mode: LogicMode): string => `m01_logic_drafts${SUFFIX[mode]}`; const clone = (v: T): T => JSON.parse(JSON.stringify(v)) as T; function loadDrafts(mode: LogicMode): Record { try { return JSON.parse(sessionStorage.getItem(cacheKey(mode)) ?? "{}") as Record; } catch { return {}; } } export interface LogicHandle { /** 밖(요소 화면)에서 이 키를 바로 엶 — 소요량·계수 표의 「쓰는 로직」 눌렀을 때. */ openKey: (key: string) => Promise; } export async function mountM01Logic( host: HTMLElement, side: SideHandle, openKey?: string, mode: LogicMode = "classic", ): Promise { let drafts = loadDrafts(mode); let files: LogicFile[] = []; let opened: Opened | null = null; let calcInputs = ""; /** 옛 컨테이너 = 호표 표 + 시험 계산 칸 · 쉽게 보기·만들기 컨테이너 = 흐름 그림 */ const easy = mode === "easy"; const view: "flow" | "advanced" | "cview" = easy ? "flow" : mode === "cview" ? "cview" : "advanced"; const editor = el("div", { className: "m01-logic__editor" }); const errors = el("div", { className: "m01-logic__reasons", attrs: { hidden: "" } }); const calc = el("aside", { className: "m01-logic__calc" }); const filterHost = el("div"); const waiting = el("p", { className: "m01-logic__muted", text: tx("Loading") }); let loaded = false; // 첫 목록이 오기 전에는 「0 줄」 빈 표를 보이지 않음 const pending = el("span", { className: "m01-logic__muted" }); const list = buildList((item) => void open(item.id, item.sub, item.key)); const back = createButton({ label: tx("Bar_Back"), variant: "ghost", onClick: () => show(null) }); const persist = (): void => { try { sessionStorage.setItem(cacheKey(mode), JSON.stringify(drafts)); } catch { /* 캐시가 막혀도 화면 안의 고친 것은 남음 */ } const marks = new Map(); const extra: ListItem[] = []; for (const [id, d] of Object.entries(drafts)) { marks.set(id, d.origKey === null ? "new" : d.row === null ? "deleted" : "edited"); if (d.origKey === null && d.row) { extra.push({ id, sub: d.sub, detail: "", key: d.row.키, number: d.row.원문번호, name: d.row.이름, unit: d.row.결과단위, source: d.row.출처, blocked: false, reasons: [], }); } } list.setMarks(marks, extra); const n = Object.keys(drafts).length; pending.textContent = n ? tx("Bar_Pending", { n }) : ""; saveButton.disabled = discardButton.disabled = n === 0; }; const touch = (): void => { if (!opened?.row) return; const now = JSON.stringify(opened.row); if (opened.origKey !== null && now === opened.original) delete drafts[opened.id]; else drafts[opened.id] = pick(opened, clone(opened.row)); persist(); if (view !== "advanced") return; // 흐름 그림은 제 안에서 다시 셈 — 다시 그리면 펼친 상자가 접힘 const inputs = JSON.stringify(opened.row.입력 ?? []); if (inputs !== calcInputs) drawCalc(); }; const pick = (o: Opened, row: LogicRow | null): Draft => ({ sub: o.sub, file: o.file, version: o.version, origKey: o.origKey, row, }); const drawCalc = (): void => { if (!opened?.row || view !== "advanced") { calc.replaceChildren(); return; } const current = opened; calcInputs = JSON.stringify(current.row?.입력 ?? []); buildCalc(calc, { savedKey: current.origKey, file: current.file, row: current.row as LogicRow, dirty: () => current.id in drafts, values: current.values, onLines: (lines) => { current.lines = lines; drawEditor(); }, }); }; const drawEditor = (): void => { // 고른 로직이 없으면 목록 · 있으면 흐름 그림(기본) 또는 고급 = 호표 표 + 시험 계산 const listing = !opened; list.root.hidden = !listing || !loaded; waiting.hidden = loaded; editor.hidden = back.hidden = listing; calc.hidden = listing || view !== "advanced"; if (!opened) return; if (!opened.row) { editor.replaceChildren( el("p", { className: "m01-logic__empty", text: `● ${tx("List_Deleted")}` }), ); return; } const current = opened; const shown = current.row as LogicRow; if (view === "cview") { renderCView(editor, current.origKey ?? current.id); return; } if (view === "flow") { mountFlow(editor, { one: { file: current.file, version: current.version, logic: shown, blocked: false, reasons: current.reasons, prices: current.prices, }, row: shown, editable: isOwnLogic(current.file, shown), onChange: touch, onCopy: () => void onCopy(current), }); return; } buildEditor(editor, { row: current.row as LogicRow, file: current.file, isNew: current.origKey === null, files: files.map((f) => f.file), reasons: current.reasons, prices: current.prices, lines: current.lines, onChange: touch, onFile: (file) => { const f = files.find((x) => x.file === file); if (!f) return; Object.assign(current, { file: f.file, sub: subOf(f), version: f.version }); touch(); }, onPick: openPicker, }); }; /** 로직 파일 → 구분(원문 + 부문) — 「공통 03장 토공사」 의 앞말이 부문 */ const subOf = (f: LogicFile): string => f.book === "건설품셈" ? `${f.book} ${f.chapter.split(" ")[0]}` : String(f.book); const show = (next: Opened | null): void => { opened = next; errors.hidden = true; drawEditor(); drawCalc(); }; const open = async (id: string, sub: string, key: string): Promise => { const draft = drafts[id]; if (draft?.origKey === null) { show({ ...draft, id, original: null, prices: {}, reasons: [], lines: null, values: {} }); return; } try { const one = await fetchLogic(key); show({ id, sub, file: draft?.file ?? one.file, version: draft?.version ?? one.version, origKey: key, row: draft ? (draft.row === null ? null : clone(draft.row)) : clone(one.logic), original: JSON.stringify(one.logic), prices: one.prices, reasons: one.reasons, lines: null, values: {}, }); } catch (error) { showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error"); } }; let allLogics: LogicSummary[] = []; const reload = async (): Promise => { const [logics, logicFiles, subs] = await Promise.all([ fetchLogics(), fetchLogicFiles(), fetchLogicSubs(), ]); files = logicFiles; allLogics = logics; list.setItems(logics); // 왼쪽 「전체」 + 구분 → 상세구분 거름 — 고르면 목록으로 돌아와 그 범위만 filterHost.replaceChildren( side.filter({ id: easy ? "쉬움|" : mode === "cview" ? "흐름|" : "로직|", store: easy ? "m01.filter.로직쉬움" : mode === "cview" ? "m01.filter.로직흐름" : "m01.filter.로직", subs, total: list.total(), subLabel: L("M01_LaborSub"), detailLabel: L("M01_LaborDetail"), restore: true, onPick: (sub, detail) => { list.setPick(sub, detail); if (opened) show(null); }, }), ); persist(); }; /** 「새로 만들기」 모달 — 저장·본떠 저장이 끝나면 그 새 자체 로직을 흐름 그림으로 바로 엶 */ const openWizard = (): void => openLogicWizard({ onSaved: (key) => void reload() .then(() => open(logicId("자체", key), "자체", key)) .catch(failed), }); /** 옛 컨테이너의 「새로 만들기」 — 빈 줄 하나를 초안으로 세움(저장 때 서버가 키를 줌) */ const onDraftNew = (): void => { const f = files.find((x) => x.file === opened?.file) ?? files[0]; if (!f) return; const id = `new:${Date.now()}`; // 원문번호는 절 번호만(부문은 구분 칸) · 키는 저장 때 서버가 줌 const row: LogicRow = { 키: "", 원문번호: `${tx("New_Key")} ${Object.keys(drafts).length + 1}`, 이름: "", 결과단위: "원/", 출처: "", 소유: "공용", 입력: [], 중간: [], 호표: [], 덧줄: [], 끝수: null, }; const next: Opened = { id, sub: subOf(f), file: f.file, version: f.version, origKey: null, row, original: null, prices: {}, reasons: [], lines: null, values: {}, }; drafts[id] = pick(next, clone(row)); persist(); show(next); }; const onNew = easy ? openWizard : onDraftNew; /** 「본떠 만들기」 — 정본을 자체 파일로 복제하고(계약 5장) 그 새 로직을 바로 엶 */ const onCopy = async (from: Opened): Promise => { if (!from.origKey) return; showLoadingOverlay(); try { const made = await copyLogic(from.origKey); await reload(); showToast(tx("Edit_Copied", { v: made.key }), "success"); await open(logicId("자체", made.key), "자체", made.key); } catch (error) { failed(error); } finally { hideLoadingOverlay(); } }; const onDelete = async (): Promise => { if (!opened?.row) return; if ( !(await showConfirmDialog( tx("Confirm_Delete", { v: opened.row.원문번호 || opened.row.키 }), tx("Bar_Delete"), )) ) return; if (opened.origKey === null) { delete drafts[opened.id]; persist(); show(null); return; } drafts[opened.id] = pick(opened, null); opened.row = null; persist(); show(opened); }; const onDiscard = async (): Promise => { if (!(await showConfirmDialog(tx("Confirm_Discard"), tx("Bar_Discard")))) return; drafts = {}; persist(); const was = opened; if (was && was.origKey !== null) await open(logicId(was.sub, was.origKey), was.sub, was.origKey); else show(null); }; const onSave = async (): Promise => { const byFile = new Map(); for (const d of Object.values(drafts)) { const part = byFile.get(d.file) ?? { file: d.file, version: d.version, changes: [] }; if (d.origKey === null) { if (d.row) part.changes.push({ op: "add", row: d.row }); } else if (d.row === null) part.changes.push({ op: "delete", key: d.origKey }); else part.changes.push({ op: "edit", key: d.origKey, row: d.row }); byFile.set(d.file, part); } if (!byFile.size) { showToast(tx("Save_Nothing"), "info"); return; } showLoadingOverlay(); try { await saveFiles([...byFile.values()]); const was = opened; drafts = {}; await reload(); showToast(tx("Save_Done"), "success"); if (was?.row?.키) await open(logicId(was.sub, was.row.키), was.sub, was.row.키); else show(null); } catch (error) { failed(error); } finally { hideLoadingOverlay(); } }; const failed = (error: unknown): void => { const detail = error instanceof ApiError ? (error.detail as { stale?: string[]; errors?: string[] }) : null; if (error instanceof ApiError && error.status === 409) { showToast(tx("Save_Stale", { v: (detail?.stale ?? []).join(", ") }), "error"); } else if (error instanceof ApiError && error.status === 422) { errors.replaceChildren( el("strong", { text: tx("Save_Errors") }), ...(detail?.errors ?? []).map((x) => el("div", { text: x })), ); errors.hidden = false; } else { showToast(error instanceof Error ? error.message : tx("Save_Failed"), "error"); } }; const saveButton = createButton({ label: tx("Bar_Save"), onClick: () => void onSave() }); const discardButton = createButton({ label: tx("Bar_Discard"), variant: "ghost", onClick: () => void onDiscard(), }); const bar = el("div", { className: "m01-logic__bar", children: [ back, el("h2", { text: tx("Title") }), // 흐름 보기 = 읽기 + 시험 계산만 — 만들기·지우기·저장 단추 없음 ...(mode === "cview" ? [] : [ pending, createButton({ label: tx("Bar_New"), variant: "ghost", onClick: onNew }), createButton({ label: tx("Bar_Delete"), variant: "danger", onClick: () => void onDelete(), }), discardButton, saveButton, ]), ], }); host.replaceChildren( el("div", { className: "m01-logic", children: [ el("main", { className: "m01-logic__main", children: [bar, errors, waiting, list.root, editor], }), calc, ], }), ); ({ classic: side.logicHost, easy: side.easyHost, cview: side.cviewHost })[mode].replaceChildren( filterHost, ); show(null); showLoadingOverlay(); const openKeyOn = async (key: string): Promise => { const found = allLogics.find((x) => x.키 === key); if (found) await open(logicId(found.구분, found.키), found.구분, found.키); else showToast(tx("Load_Failed"), "error"); }; try { await reload(); loaded = true; drawEditor(); if (openKey) await openKeyOn(openKey); } catch (error) { showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error"); } finally { hideLoadingOverlay(); } return { openKey: openKeyOn }; }