/* ============================================================================= * M01_MasterData_UI_Page.ts * 마스터 요소 화면 — 좌측 도킹 패널(컨테이너: 인력·재료·기계·소요량·계수·환율·요율·단가산출 로직·일위대가 조합·로직 테스트) * / 우측 요소 표 · 표 목록 · 로직 편집 * * 시스템 관리자만. 고친 것은 초안(sessionStorage)에 쌓이고 [저장] 한 번에 서버로 — * 자동저장 없음. * ========================================================================== */ import "@ui/ui_template_workflow_layout.css"; import { createButton, createInputField, el, showConfirmDialog, showToast, } from "@ui/ui_template_elements"; import { t as L } from "@ui/ui_template_locale"; import { createWorkflowOverlays } from "@ui/ui_template_overlay"; import { ROUTES } from "@config/config_frontend"; import { navigateTo } from "../A00_Common/router"; import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch"; import { saveFiles, type FileInfo } from "./M01_MasterData_Api_Fetch"; import { discard, isStale, onDraftChange, payload, totalCount } from "./M01_MasterData_Draft"; import { renderRows } from "./M01_MasterData_UI_Rows"; import { buildSide, fileLabel, loadView, saveView, setMasterJump, setPendingLogicKey, takePendingLogicKey, type MasterJump, type Pick, } from "./M01_MasterData_UI_Side"; import { loadTable } from "./M01_MasterData_UI_Logic_Note"; import { tl } from "./M01_MasterData_UI_LogicLab_Text"; import { renderTables } from "./M01_MasterData_UI_Tables"; import type { LogicHandle } from "./M01_MasterData_UI_Logic_Page"; import type { ComboHandle } from "./M01_MasterData_UI_Combo"; import "./M01_MasterData_UI_Style.css"; const TABLE_GROUPS = ["소요량", "계수"]; export async function renderM01MasterData(root: HTMLElement): Promise { const user = await fetchSessionUser().catch(() => null); if (user?.role !== "SYSTEM_ADMIN") { showToast(L("M01_AdminOnly"), "error"); navigateTo(ROUTES.B01_ACCOUNT); return; } root.innerHTML = ""; root.append(buildPage()); } function buildPage(): HTMLElement { let current: Pick | null = null; let query = ""; let dispose = (): void => {}; let logicMounted = false; let labMounted = false; let comboHandle: ComboHandle | null = null; /* --- 우측: 머리(제목·찾기·저장) + 알림 + 본문 --- */ const title = el("h2", { className: "m01-master__title", text: L("M01_PickFile") }); const searchField = createInputField({ type: "search", placeholder: L("M01_Search") }); const search = searchField.input; const summary = el("span", { className: "m01-master__summary" }); const save = createButton({ label: L("M01_Save"), variant: "filled" }); const drop = createButton({ label: L("M01_Discard"), variant: "ghost" }); const notice = el("div", { className: "m01-master__notice", attrs: { hidden: "" } }); const body = el("div", { className: "m01-master__body" }); const head = el("div", { className: "m01-master__head", children: [title, searchField.root, summary, drop, save], }); const logicHost = el("div", { className: "m01-master__logic", attrs: { hidden: "" } }); const labHost = el("div", { className: "m01-master__logic", attrs: { hidden: "" } }); const comboHost = el("div", { className: "m01-master__logic", attrs: { hidden: "" } }); const elementView = el("div", { className: "m01-master__elements", children: [ head, notice, el("p", { className: "m01-master__hint", text: L("M01_WideHint") }), body, ], }); const showNotice = (nodes: (HTMLElement | string)[]): void => { notice.replaceChildren(...nodes); notice.hidden = nodes.length === 0; }; const refreshBar = (): void => { const n = totalCount(); summary.textContent = n ? L("M01_Changed").replace("{value}", String(n)) : ""; save.disabled = drop.disabled = n === 0; }; onDraftChange(refreshBar); refreshBar(); const showMode = (mode: "elements" | "logic" | "lab" | "combo"): void => { logicHost.hidden = mode !== "logic"; labHost.hidden = mode !== "lab"; comboHost.hidden = mode !== "combo"; elementView.hidden = mode !== "elements"; }; const openFile = (pick: Pick): void => { dispose(); showMode("elements"); if (pick.view) { query = pick.view.q; search.value = query; } else saveView(pick.group, { q: query, page: 1 }); current = { ...pick, view: undefined }; const groupTitle = pick.group === pick.label ? [pick.group] : [pick.group, pick.label]; title.textContent = groupTitle.join(" · "); dispose = TABLE_GROUPS.includes(pick.group) ? renderTables(body, pick, query, openLogicTab) : renderRows(body, pick, query); showNotice(isStale(pick.file.file, pick.file.version) ? [L("M01_FileStale")] : []); }; let searchTimer: number | undefined; search.addEventListener("input", () => { window.clearTimeout(searchTimer); searchTimer = window.setTimeout(() => { query = search.value.trim(); if (current) openFile(current); }, 300); }); drop.addEventListener("click", async () => { if (!(await showConfirmDialog(L("M01_DiscardConfirm")))) return; discard(); showNotice([]); }); /** 저장·되받기 뒤 — 지금 열린 그룹의 새 판본으로 다시 그림 */ const reloadFiles = async (): Promise => { if (!current) return; const pick = current; try { const again = (await side.refresh(pick.group)).find((f) => f.file === pick.file.file); if (again) openFile({ ...pick, file: again, view: loadView(pick.group) }); } catch (error) { showToast(error instanceof Error ? error.message : L("M01_LoadFailed"), "error"); } }; save.addEventListener("click", async () => { save.disabled = true; try { const result = await saveFiles(payload()); if (result.status === 200 && "files" in result) { discard(result.files.map((f) => f.file)); showNotice([]); showToast(L("M01_Saved"), "success"); await reloadFiles(); } else if (result.status === 409 && "stale" in result) { showNotice(staleNotice(result.stale)); } else if (result.status === 422 && "errors" in result) { showNotice([ el("strong", { text: L("M01_CheckErrors") }), ...result.errors.map((e) => el("p", { text: e })), ]); } else if ("detail" in result) { showNotice([L("M01_SaveFailed").replace("{value}", result.detail)]); } } catch (error) { showNotice([ L("M01_SaveFailed").replace("{value}", error instanceof Error ? error.message : ""), ]); } finally { refreshBar(); } }); const staleNotice = (stale: string[]): (HTMLElement | string)[] => { const again = createButton({ label: L("M01_StaleReload"), variant: "ghost" }); again.addEventListener("click", async () => { discard(stale); showNotice([]); await reloadFiles(); }); return [L("M01_Stale").replace("{value}", stale.map(fileLabel).join(", ")), again]; }; let logicHandle: LogicHandle | null = null; /** 로직 탭으로 전환 — `key` 가 있으면 그 로직을 바로 엶. */ const openLogicTab = (key?: string): void => { if (key) setPendingLogicKey(key); dispose(); dispose = (): void => {}; current = null; showMode("logic"); if (logicMounted) { const pending = takePendingLogicKey(); if (pending) void logicHandle?.openKey(pending); return; } logicMounted = true; void import("./M01_MasterData_UI_Logic_Page").then(async (m) => { const pending = takePendingLogicKey(); logicHandle = await m.mountM01Logic(logicHost, side, pending ?? undefined); }); }; /** 「로직 개선 시험」 — 정본 로직 화면의 복사본(개선은 여기서만) */ const openLabTab = (): void => { dispose(); dispose = (): void => {}; current = null; showMode("lab"); if (labMounted) return; labMounted = true; void import("./M01_MasterData_UI_LogicLab").then((m) => m.mountM01LogicLab(labHost, side)); }; /** 「일위대가 조합」 — 목록을 새로 받아 그림 · 로직 줄을 누르면 단가산출 로직 탭으로 */ const openComboTab = (): void => { dispose(); dispose = (): void => {}; current = null; showMode("combo"); if (comboHandle) { void comboHandle.show(); return; } void import("./M01_MasterData_UI_Combo").then((m) => { comboHandle = m.mountM01Combo(comboHost, side, (key) => openLogicTab(key)); void comboHandle.show(); }); }; /** 알약 → 마스터 화면 — 찾기 글을 그 키로 두고 열고, 알림줄에 「시험으로 돌아가기」를 둠 */ const jump = async (target: MasterJump): Promise => { let group = target.kind === "element" ? target.group : ""; let file: FileInfo | undefined; let ref = ""; if (target.kind === "table") { const table = await loadTable(target.key); group = ["소요량", "계수"].find((g) => side.files(g).some((f) => f.file === table?.file)) ?? ""; file = side.files(group).find((f) => f.file === table?.file); ref = target.key; } else { const list = side.files(group); file = list.find((f) => f.file === target.file) ?? list[0]; ref = target.ref; } if (!file) return showToast(L("M01_LoadFailed"), "error"); side.setActive(null); openFile({ group, file, sub: "", detail: "", label: group, view: { q: ref, page: 1 } }); const back = createButton({ label: tl("Pill_Back"), variant: "ghost" }); back.addEventListener("click", () => { showNotice([]); openLabTab(); }); showNotice([back]); if (target.kind === "table") { const key = target.key; const seen = new MutationObserver(() => { const row = body.querySelector(`[data-key="${CSS.escape(key)}"]`); if (!row) return; seen.disconnect(); row.click(); }); seen.observe(body, { childList: true, subtree: true }); window.setTimeout(() => seen.disconnect(), 8000); } }; setMasterJump((target) => void jump(target)); /* --- 좌측: 컨테이너 --- */ const side = buildSide(openFile, () => openLogicTab(), openLabTab, openComboTab); const layout = el("div", { className: "ui-workflow-layout m01-master" }); const main = el("main", { className: "ui-workflow-layout__main", children: [ el("div", { className: "m01-master__panel", children: [elementView, logicHost, labHost, comboHost], }), ], }); const overlays = createWorkflowOverlays({ title: L("M01_Title"), optionsContent: el("div", { className: "m01-master__side", children: [side.root] }), showProjectName: false, onOptionsOpenChange: (isOpen) => layout.classList.toggle("is-options-open", isOpen), }); // 좁은 화면(공용 레이아웃 860px)에서는 패널이 목록을 덮음 — 거름을 다 고르면 접음 side.setOnPicked(() => { if (window.matchMedia("(max-width: 860px)").matches) overlays.setTitleOpen(false); }); layout.append( el("div", { className: "ui-workflow-layout__body", children: [main] }), overlays.root, ); return layout; }