/* ============================================================================= * M01_MasterData_UI_LogicLab.ts * 관리자 화면 — 로직 칸. 로직을 열면 두 칸 — 왼쪽 수식(줄 쌓기 `A00_Common/line_stack_screen.ts` · 804-6) · 단가산출 상세 / 오른쪽 원문 * 셈 = 화면 칸 없이 구동(`_Logic_Drive.ts` · 799-2) — 열 때 첫 셈 · 입력 · 시험 고침 · 재료 고름이 `drive.run` * 로직 목록(원문·장 고르기 · 찾기 · 막힘 거름)은 왼쪽 패널 「단가산출 로직」 컨테이너(`listHost`) 안 * * 데이터 흐름(CLAUDE.md 5장) — 고친 것은 캐시(sessionStorage)에 쌓고 [저장] 한 번에 `POST /save`. * 409 = 그 사이 파일이 바뀜 · 422 = 검사 걸림(아무것도 안 씀) — 까닭은 서버 글 그대로 보임. * 머리 단추(804-7 · 요구 687-2) = [신규 로직] · [로직 복사/생성] · [로직 삭제] · [되돌림] · [저장] · 목록에선 [신규 로직]만(요구 693) * [저장] = 기본정보 · 별칭(로직 줄 `/save`) + 수식(줄 쌓기 `/logic/doc/save`) 한 번에 · [되돌림] = 둘 다 저장 뒤 상태로 * [로직 삭제] = 확인 → 서버 정본에서 바로(`/logic/remove` — 로직 줄 + 수식 문서 · 부르는 곳 있으면 막고 까닭) → 로직 목록 * ========================================================================== */ import { t as L } from "@ui/ui_template_locale"; import { createButton, el, hideLoadingOverlay, showConfirmDialog, showLoadingOverlay, showToast, } from "@ui/ui_template_elements"; import { ApiError, fetchLogic, fetchLogicFiles, fetchLogics, fetchLogicSubs, fetchLogicUsers, removeLogic, saveFiles, type CalcLine, type TextAnswer, type ElementBrief, type LogicRow, type LogicSummary, type SaveFile, } from "./M01_MasterData_UI_Logic_Api"; import { createDrive } from "./M01_MasterData_UI_Logic_Drive"; import { buildList, logicId, type ListItem, type ListMark } from "./M01_MasterData_UI_Logic_List"; import type { SideHandle } from "./M01_MasterData_UI_Side"; import { tx } from "./M01_MasterData_UI_Logic_Text"; import { buildLabDetail, isOwnLogic, trial } from "./M01_MasterData_UI_LogicLab_Detail"; import { setLogicOpener } from "./M01_MasterData_UI_LogicLab_Modal"; import { openCopyScreen } from "./M01_MasterData_UI_LogicLab_Copy"; import { tl } from "./M01_MasterData_UI_LogicLab_Text"; import { askBasicInfo } from "./M01_MasterData_UI_LogicLab_Basic"; import { saveRevised } from "./M01_MasterData_UI_LogicLab_Revise"; import { buildLogicSide } from "./M01_MasterData_UI_LogicLab_Side"; import { createViewPicker } from "./M01_MasterData_UI_LogicLab_View"; import { createStackScreen } from "../A00_Common/line_stack_screen"; import { buildHeadBar } from "./M01_MasterData_UI_Head"; import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch"; import "./M01_MasterData_UI_Logic_Style.css"; import { createEmpty } from "@ui/ui_template_empty"; /** 저장 안 한 로직 하나 — 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; text: TextAnswer | null; /** 마지막으로 잘 받은 읽는 식 — 멈춘 뒤에도 수량 칸 조각을 이걸로 */ lastText?: TextAnswer; ownHost: HTMLElement; values: Record; } const OWN_FILE = "로직_자체.json"; /** 시험 계산에 넘길 줄 — 시험 고침(722-2) 덮은 사본 */ const calcRow = (row: LogicRow | null): LogicRow | null => trial.apply(row); const cacheKey = "m01_logic_drafts_lab"; const ownHost = (): HTMLElement => el("div", { className: "m01lab__own" }); const clone = (v: T): T => JSON.parse(JSON.stringify(v)) as T; function loadDrafts(): Record { try { return JSON.parse(sessionStorage.getItem(cacheKey) ?? "{}") as Record; } catch { return {}; } } export interface LogicHandle { /** 밖(요소 화면)에서 이 키를 바로 엶 — 소요량·계수 표의 「쓰는 로직」 눌렀을 때. */ openKey: (key: string) => Promise; } export async function mountM01LogicLab( host: HTMLElement, side: SideHandle, openKey?: string, ): Promise { let drafts = loadDrafts(); let opened: Opened | null = null; let calcInputs = ""; const admin = await fetchSessionUser() .then((u) => u?.role === "SYSTEM_ADMIN") .catch(() => false); let recalc: number | undefined; const drive = createDrive(); // 수식 = 줄 쌓기(804-2 · 804-6 옛 수식 문서 탭 걷음) — 수식 문서 · 캐시 m01_doc_drafts const stack = createStackScreen({ base: "/m01/logic/doc", admin, cache: "m01_doc_drafts", page: () => persist(), }); const leave = (): Promise => stack.canLeave(); const editor = el("div", { className: "m01-logic__editor" }); // 로직 상세 — 왼쪽 지금 내용 · 오른쪽 접는 패널 「원문」 const panes = buildLogicSide(editor); // 보기 고르기 — 머리줄 [신규 로직] 왼쪽 const views = createViewPicker(editor, (v) => { if (v === "stack") stack.draw(opened?.origKey ?? "", true); }); const errors = el("div", { className: "ui-banner ui-banner--danger m01-logic__reasons", attrs: { hidden: "" }, }); const filterHost = el("div"); const waiting = el("p", { className: "ui-text ui-text--muted", text: tx("Loading") }); let loaded = false; // 첫 목록이 오기 전에는 「0 줄」 빈 표를 보이지 않음 const pending = el("span", { className: "ui-text ui-text--muted" }); const list = buildList( (item) => void open(item.id, item.sub, item.key), (item, alias) => void onAlias(item, alias), ); const back = createButton({ label: tx("Bar_Back"), variant: "ghost", onClick: () => void leave().then((ok) => ok && show(null)), }); const persist = (): void => { try { sessionStorage.setItem(cacheKey, JSON.stringify(drafts)); } catch { /* 캐시가 막혀도 화면 안의 고친 것은 남음 */ } const marks = new Map(); const extra: ListItem[] = []; const aliases = new Map(); for (const [id, d] of Object.entries(drafts)) { marks.set(id, d.origKey === null ? "new" : d.row === null ? "deleted" : "edited"); if (d.row) aliases.set(id, d.row.별칭 ?? ""); if (d.origKey === null && d.row) { extra.push({ id, sub: d.sub, detail: "", key: d.row.키, number: d.row.원문번호, name: d.row.이름, alias: d.row.별칭 ?? "", unit: d.row.결과단위, source: d.row.출처, blocked: false, reasons: [], }); } } list.setMarks(marks, extra, aliases); const n = Object.keys(drafts).length; pending.textContent = n ? tx("Bar_Pending", { n }) : ""; saveButton.disabled = discardButton.disabled = n === 0 && !stack.dirty(); // 로직 줄 · 수식 둘 다 봄 }; 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(); const inputs = JSON.stringify(calcRow(opened.row)?.입력 ?? []); if (inputs !== calcInputs) drawCalc(); if (isOwnLogic(opened.row)) { // 자체 로직 — 고친 뒤 잠시 쉬면 다시 계산해 호표 소계와 텍스트 수식을 새로 그림 window.clearTimeout(recalc); recalc = window.setTimeout(() => void drive.run(), 700); } }; 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) { drive.stop(); return; } const current = opened; calcInputs = JSON.stringify(calcRow(current.row)?.입력 ?? []); drive.start({ savedKey: current.origKey, file: current.file, row: calcRow(current.row) as LogicRow, dirty: () => current.id in drafts || trial.has(current.row), values: current.values, onText: (text) => { current.text = text; if (text.ok) current.lastText = text; drawEditor(); // [계산] 전 미리보기 응답도 왼쪽 이름 식 칸에 바로 비치게 — onLines 와 같은 자리 }, onLines: (lines) => { current.lines = lines; drawEditor(); }, }); }; const drawEditor = (): void => { const listing = !opened; // 고른 로직이 없으면 목록 · 있으면 호표 표 + 시험 계산 list.root.hidden = !listing || !loaded; waiting.hidden = loaded; panes.root.hidden = !opened; back.hidden = views.root.hidden = listing; // 로직을 골라야 뜻 있는 단추 = 목록에선 숨김(요구 693 · [신규 로직]만 보임) copyButton.hidden = deleteButton.hidden = discardButton.hidden = saveButton.hidden = listing; if (!opened) return; if (!opened.row) { editor.replaceChildren(createEmpty({ text: `● ${tx("List_Deleted")}`, small: true })); return; } const current = opened; buildLabDetail(editor, { row: current.row as LogicRow, file: current.file, reasons: current.reasons, prices: current.prices, lines: current.lines, text: current.text, lastText: current.lastText, values: current.values, ownHost: current.ownHost, onChange: touch, onInput: (name, v) => syncLeft(current, { [name]: v }), onTrial: drawCalc, rerun: () => void drive.run(), admin, stack: stack.root, }); stack.draw(current.origKey ?? ""); }; /** 단가산출 상세 옵션 입력 → 시험 입력(이름이 있는 칸만) · 바뀌면 다시 셈 */ const syncLeft = (o: Opened, ex: Record): void => { const names = new Set((calcRow(o.row)?.입력 ?? []).map((x) => x.이름)); const changed = Object.entries(ex).filter(([k, v]) => names.has(k) && o.values[k] !== v); if (!changed.length) return; for (const [k, v] of changed) o.values[k] = v; void drive.run(); }; const show = (next: Opened | null): void => { opened = next; panes.showSource(next?.row?.출처 ?? ""); 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, text: null, ownHost: ownHost(), 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, text: null, ownHost: ownHost(), values: {}, }); } catch (error) { showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error"); } }; let allLogics: LogicSummary[] = []; let saving = false; // [저장] 뒤 목록 다시 읽는 중 — 거름 되살림이 연 로직을 떠나지 않게(저장 뒤 다시 엶) const reload = async (): Promise => { const [logics, subs] = await Promise.all([fetchLogics(), fetchLogicSubs()]); allLogics = logics; list.setItems(logics); // 왼쪽 「전체」 + 구분 → 상세구분 거름 — 고르면 목록으로 돌아와 그 범위만 filterHost.replaceChildren( side.filter({ id: "로직|", store: "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 && !saving) void leave().then((ok) => ok && show(null)); }, }), ); persist(); }; /** 목록 「별칭」 칸 고침(208-1) — 그 줄의 별칭만 바꿔 캐시에 · [저장] 때 다른 고침과 함께 나감 */ const onAlias = async (item: ListItem, alias: string): Promise => { const draft = drafts[item.id]; if (draft && !draft.row) return; // 지운 줄 try { const one = draft?.origKey === null ? null : await fetchLogic(item.key); const row = clone((draft?.row ?? one?.logic) as LogicRow); if (alias) row.별칭 = alias; else delete row.별칭; if (one && JSON.stringify(row) === JSON.stringify(one.logic)) delete drafts[item.id]; else drafts[item.id] = draft ? { ...draft, row } : { sub: item.sub, file: one!.file, version: one!.version, origKey: item.key, row }; persist(); } catch (error) { showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error"); } }; /** 부르는 곳 까닭 띠 */ const usedBy = (used: string[]): void => { errors.replaceChildren( el("strong", { text: tx("Delete_Used") }), ...used.map((x) => el("div", { text: x })), ); errors.hidden = false; }; const onDelete = async (): Promise => { if (!opened?.row) return; if (opened.origKey) { const used = (await fetchLogicUsers(opened.origKey).catch(() => null))?.쓰는곳 ?? []; if (used.length) return usedBy(used); // 확인 창 전에 막음 } if ( !(await showConfirmDialog( tx("Confirm_Delete", { v: opened.row.원문번호 || opened.row.키 }), tx("Bar_Delete"), )) ) return; const was = opened; if (was.origKey === null) { delete drafts[was.id]; persist(); show(null); return; } const key = was.origKey; showLoadingOverlay(); try { const one = await fetchLogic(key); // 지금 판(캐시에 옛 판이 남았어도) const done = await saveRevised((ok) => removeLogic(key, one.file, one.version, ok)); if (!done) return; // 개정 확인 취소 delete drafts[was.id]; await stack.discard(); // 그 로직 수식 캐시도 비움 await reload(); show(null); // 로직 목록 showToast(tx("Delete_Done", { v: was.row?.원문번호 || key }), "success"); } catch (error) { const used = error instanceof ApiError ? (error.detail as { 쓰는곳?: string[] })?.쓰는곳 : null; if (used?.length) usedBy(used); else failed(error); } finally { hideLoadingOverlay(); } }; const onDiscard = async (): Promise => { if (!(await showConfirmDialog(tx("Confirm_Discard"), tx("Bar_Discard")))) return; drafts = {}; await stack.discard(); 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) { // 수식만 고침 — 줄 쌓기 정본 저장 if (!stack.dirty()) return void showToast(tx("Save_Nothing"), "info"); if (await stack.save()) showToast(tx("Save_Done"), "success"); persist(); return; } showLoadingOverlay(); try { // 키 없는 새 줄은 서버가 새 키를 줌(added) — 저장 뒤 그 로직을 엶 const saved = await saveRevised((ok) => saveFiles([...byFile.values()], ok)); if (!saved) return; // 개정 확인 취소 = 캐시 그대로 const was = opened; drafts = {}; saving = true; await reload().finally(() => (saving = false)); showToast(tx("Save_Done"), "success"); const newKey = was?.row && !was.row.키 ? saved.added?.at(-1)?.key : undefined; const made = newKey ? allLogics.find((x) => x.키 === newKey) : undefined; if (made) await open(logicId(made.구분, made.키), made.구분, made.키); else if (was?.row?.키) await open(logicId(was.sub, was.row.키), was.sub, was.row.키); else show(null); if (opened) await stack.save(); // 수식 고침도 같은 [저장]에(여는 중이면 다 읽고 · 새 로직 = 새 키로 이어 받은 뒤 · 고침 없으면 그냥) persist(); } 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 onNew = async (): Promise => { const units = [...new Set(allLogics.map((x) => x.결과단위).filter(Boolean))]; const info = await askBasicInfo(units.length ? units : ["원"]); if (!info) return; try { const own = (await fetchLogicFiles()).find((f) => f.file === OWN_FILE); const row: LogicRow = { 키: "", ...info, 입력: [], 중간: [], 호표: [], // 빈 시트 줄 — 자체 로직 편집기 · 시트로 채움 끝수: null, }; const id = logicId("자체", `새${Date.now()}`); drafts[id] = { sub: "자체", file: OWN_FILE, version: own?.version ?? "", origKey: null, row }; persist(); await open(id, "자체", ""); } catch (error) { showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error"); } }; const saveButton = createButton({ label: tx("Bar_Save"), iconStart: "check", onClick: () => void onSave(), }); const discardButton = createButton({ label: tx("Bar_Discard"), variant: "ghost", iconStart: "undo", onClick: () => void onDiscard(), }); const copyButton = createButton({ label: tl("Copy_Open"), variant: "ghost", iconStart: "copy", onClick: () => opened?.origKey ? void openCopyScreen({ key: opened.origKey, afterSave: async (newKey) => { await reload(); await open(logicId("자체", newKey), "자체", newKey); }, }) : showToast(tl("Copy_NeedOpen"), "info"), }); const deleteButton = createButton({ label: tx("Bar_Delete"), variant: "danger", iconStart: "trash", onClick: () => void onDelete(), }); // 제목 줄 = 요소 화면(인력 · 재료 …)과 같은 공용 조각 const bar = buildHeadBar({ lead: [back], title: tx("Title"), status: [pending], buttons: [ views.root, createButton({ label: tl("New"), variant: "filled", iconStart: "plus", onClick: () => void onNew(), }), copyButton, deleteButton, discardButton, saveButton, ], }).root; host.replaceChildren( el("div", { className: "m01-logic", children: [ el("main", { className: "m01-logic__main", children: [bar, errors, waiting, list.root, panes.root], }), ], }), ); side.logicHost.replaceChildren(filterHost); show(null); showLoadingOverlay(); const openKeyOn = async (key: string): Promise => { if (!(await leave())) return; 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(); } setLogicOpener((key) => void openKeyOn(key)); return { openKey: openKeyOn }; }