/* ============================================================================= * M01_SL_Entry_Paper.ts * 시트 로직 고치는 화면 — 위 띠 · 위 가로 다이어그램 · 아래 시트 판 + 종류별 손잡이 · 결과 · 잇기 * 바뀔 때마다 기록(객체 그대로) → evalLogic(화면 즉시) → 저장 slSave(서버가 다시 셈한 값으로 바꿔 보임) * SheetLogic 의 다른 파일은 Entry 가 import() 로 늦게 불러 `mods` 로 넘김(엔진이 M01 첫 묶음에 안 들어가게). * ========================================================================== */ import { currentLanguageIndex } from "@ui/ui_template_locale"; import { createButton, createInputField, createSelectField, createTag, el, showConfirmDialog, showToast, } from "@ui/ui_template_elements"; import { formatMoney } from "../M01_MasterData_UI_Logic_Money"; import type { SlOutsideAt, SlQuery } from "./M01_SL_Api"; import type { SlEvalResult, SlOutsideValue } from "./M01_SL_Eval"; import type { SheetLogic, SlKind } from "./M01_SL_Model"; /** SheetLogic 의 다른 파일 — 창을 열 때 한 번에 불러옴 */ export const loadMods = () => Promise.all([ import("./M01_SL_Model"), import("./M01_SL_Eval"), import("./M01_SL_Api"), import("./M01_SL_Diagram"), import("./M01_SL_Panel"), import("./M01_SL_Table"), import("./M01_SL_Item"), import("./M01_SL_Result"), import("./M01_SL_Import"), import("../../A00_Common/spreadsheet/spreadsheet_recalc"), ]).then(([model, ev, api, diagram, panel, table, item, result, imp, recalc]) => ({ model, ev, api, diagram, panel, table, item, result, imp, recalc, })); export type Mods = Awaited>; const TEXT = { Name: ["이름", "Name"], NamePlaceholder: ["예: 돌쌓기 내 방식", "e.g. my stone wall"], Unit: ["결과 단위", "Result unit"], Undo: ["실행 취소", "Undo"], Save: ["저장", "Save"], Delete: ["지우기", "Delete"], Close: ["닫기", "Close"], Unsaved: ["저장 안 함", "Not saved"], Trial: ["시험판 — 임시 폴더에 저장", "Trial — saved in a temporary folder"], Diagram: ["상자와 선", "Boxes and lines"], Panel: ["고른 상자의 시트", "Sheet of the chosen box"], PickBox: [ "위에서 상자를 고르면 그 상자의 시트가 여기 보임", "Choose a box above to see its sheet", ], Result: ["결과", "Result"], NoName: ["이름을 넣어 주세요", "Enter a name"], SaveAnyway: [ "고칠 곳이 있음 — {v} · 그래도 저장할까요?", "There are issues — {v} · Save anyway?", ], Saving: ["저장 중", "Saving"], Saved: ["저장했음", "Saved"], SaveFailed: ["저장 못 함 — {v}", "Not saved — {v}"], Differs: [ "저장 때 다시 셈한 값 {s} 원 — 화면 값 {c} 원과 다름", "Recalculated on save: {s} — differs from the screen value {c}", ], Delete_Ask: ["「{v}」 을(를) 지울까요? 되살릴 수 없음", "Delete “{v}”? This cannot be undone"], Deleted: ["지웠음", "Deleted"], Outside: ["바깥 값을 못 받음 — {v}", "Couldn't get outside values — {v}"], Kind_input: ["입력값", "Input"], Kind_const: ["고정값", "Fixed value"], Kind_table: ["표", "Table"], Kind_calc: ["계산", "Calculation"], Kind_item: ["항목", "Item"], Kind_logic: ["다른 로직", "Other logic"], Kind_result: ["결과", "Result"], } as const; type TextKey = keyof typeof TEXT; const tp = (key: TextKey, vars: Record = {}): string => Object.entries(vars).reduce( (text, [k, v]) => text.replace(`{${k}}`, String(v)), (TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0]) as string, ); const muted = (text: string): HTMLElement => el("p", { className: "m01-sl__muted", text }); /** 바깥 값 자리 — 항목 단가 · 출처가 있는 알약 · 가져온 로직의 바깥 값(같은 칸은 앞의 것) */ export function outsidePlaces(M: Mods, sl: SheetLogic): SlOutsideAt[] { const at = new Map(); const put = (p: SlOutsideAt): void => { const k = `${p.sheet}!${p.cell.toUpperCase()}`; // 아직 고르지 않은 상자(빈 번호)는 묻지 않음 const it = p.item as { key?: string; table?: string; element?: string }; if (!(it.key ?? it.table ?? it.element)) return; if (!at.has(k)) at.set(k, p); }; M.item.itemOutsides(sl).forEach(put); for (const c of M.ev.outsideCells(sl)) { const node = sl.nodes.find((n) => n.id === c.node); if (node && c.source) put({ item: { id: c.id, ...c.source } as SlQuery, sheet: node.sheetId, cell: c.cell }); } // 넘길 입력값이 수식 칸이면 계산값이 있어야 함(없으면 null 로 넘어가 단가가 0) M.imp.outsideOf(sl, M.recalc.recalcWorkbook(sl.workbook)).forEach(put); return [...at.values()]; } /** 열 때 바깥 값 넣기 — 받은 것만 넣고 못 받은 것은 쉬운 말 목록 */ export async function fillOutside( M: Mods, sl: SheetLogic, ): Promise<{ sl: SheetLogic; errors: string[] }> { const places = outsidePlaces(M, sl); if (!places.length) return { sl, errors: [] }; const answers = await M.api.slOutside(places.map((p) => p.item)); const values: SlOutsideValue[] = []; const errors: string[] = []; for (const p of places) { const node = sl.nodes.find((n) => n.sheetId === p.sheet); const a = answers.find((x) => x.id === p.item.id); if (!node) continue; if (!a?.ok) errors.push(`${node.name} ${p.cell}${a?.reason ? ` — ${a.reason}` : ""}`); else values.push({ node: node.id, cell: p.cell, value: a.rows ?? a.value ?? null }); } return { sl: M.ev.applyOutside(sl, values), errors }; } /** 서버가 다시 셈한 합계 — 결과 상자 「계」 알약 칸의 서버 계산값(수식이 아니면 칸 값) */ function serverTotal(sl: SheetLogic): number | null { const box = sl.nodes.find((n) => n.kind === "result"); const pill = box?.pillsOut.find((p) => p.name === "계"); if (!box || !pill) return null; const cell = pill.cell.toUpperCase(); const got = sl.workbook.계산값?.[box.sheetId]?.[cell]; if (got && "수" in got) return Number(got.수); const plain = sl.workbook.시트.find((s) => s.id === box.sheetId)?.칸[cell]?.값; return typeof plain === "number" ? plain : null; } export interface PaperCtx { /** 저장해 둔 것을 열었으면 true — 새로 · 가져오기는 처음부터 「저장 안 함」 */ saved: boolean; units: string[]; notes: string[]; onClose(): void; } /** 고치는 화면 — 돌려준 손잡이로 Entry 가 닫기 물음 · Ctrl+Z · Ctrl+S 를 부름 */ export function showPaper( M: Mods, host: HTMLElement, first: SheetLogic, ctx: PaperCtx, ): { dirty(): boolean; save(): Promise; undo(): void } { let sl = first; let savedSl: SheetLogic | null = ctx.saved ? first : null; let stack: SheetLogic[] = []; let selected: string | null = first.nodes[0]?.id ?? null; let values: SlEvalResult; let busy = false; let notes = ctx.notes; /** 마지막으로 받은 바깥 값 물음 — 넘길 입력값이 바뀌면 다시 받음 */ const sigOf = (x: SheetLogic): string => JSON.stringify(outsidePlaces(M, x).map((p) => [p.sheet, p.cell, p.item])); let outsideSig = sigOf(first); let outsideTimer: ReturnType | undefined; /** 바깥 값 다시 받기 — 같은 조작의 뒤끝이라 기록을 더 쌓지 않음 */ const refreshOutside = (): void => { const sig = sigOf(sl); if (sig === outsideSig) return; outsideSig = sig; clearTimeout(outsideTimer); outsideTimer = setTimeout(() => { const from = sl; void fillOutside(M, from).then(({ sl: next, errors }) => { if (sl !== from) { // 그 사이 또 바뀜 — 지금 것으로 다시 받음(옛 답 · 옛 까닭은 버림) outsideSig = ""; return refreshOutside(); } if (savedSl === from) savedSl = next; sl = next; outsideSig = sigOf(next); notes = errors; redraw(); }); }, 300); }; const unsaved = createTag(tp("Unsaved"), "warning"); const note = el("p", { className: "m01-sl__note", attrs: { role: "status" } }); const diagramHost = el("div", { className: "m01-sl__part-body" }); // 시트 판은 Panel 이 제 상태로 쥠 — 안을 갈아끼우지 않고 숨기기만(갈아끼우면 Panel 이 편집기가 있다고 여김) const panelHost = el("div"); const pickHint = muted(tp("PickBox")); const toolsHost = el("div"); const resultHost = el("div", { className: "m01-sl__part-body" }); const evalNow = (): SlEvalResult => { try { return M.ev.evalLogic(sl); } catch (e) { const none = { 노무비: null, 재료비: null, 경비: null, 계: null }; return { pills: {}, pillsIn: {}, result: none, errors: [M.api.slPlain(e)] }; } }; /** 바뀐 것을 받아 쌓음 — 구조가 바뀌면 비율 바탕을 다시 엮음 */ const edit = (next: SheetLogic, structural = false): void => { if (next === sl) return; stack = [...stack, sl]; sl = structural ? M.item.refreshBases(next) : next; redraw(); }; /** 다이어그램이 부른 바꿈 — 다이어그램이 제 옛 그림으로 한 번 더 그리므로 다음 틱에 다시 그림 */ const editFromDiagram = (next: SheetLogic): void => { edit(next, true); setTimeout(redraw); }; const name = createInputField({ label: tp("Name"), placeholder: tp("NamePlaceholder"), value: sl.name, onInput: (v) => edit({ ...sl, name: v }), }); name.root.classList.add("m01-sl__name"); const units = ctx.units.includes(sl.resultUnit) ? ctx.units : [sl.resultUnit, ...ctx.units]; const unit = createSelectField({ label: tp("Unit"), options: units.map((u) => ({ value: u, text: u.replace(/^원\//, "") })), value: sl.resultUnit, onChange: (v) => edit({ ...sl, resultUnit: v }), }); const kindName = (k: SlKind): string => tp(`Kind_${k}` as TextKey); const diagramOn = { select: (id: string | null): void => { selected = id; drawPanel(); }, connect: (from: Parameters[1], to: typeof from): string | null => { const got = M.model.connect(sl, from, to); if (got.why) return got.why; editFromDiagram(got.sl); return null; }, disconnect: (edgeId: string): void => editFromDiagram(M.model.disconnect(sl, edgeId)), add: (kind: SlKind): void => { const got = M.model.addNode(sl, kind, kindName(kind)); selected = got.node.id; editFromDiagram(got.sl); }, remove: (nodeId: string): void => { if (selected === nodeId) selected = null; editFromDiagram(M.model.removeNode(sl, nodeId)); }, }; /** 항목 단가 — 고른 뒤 서버에서 받아 넣음(같은 조작의 뒤끝이라 기록을 더 쌓지 않음) */ const loadPrices = (nodeId: string): void => { void M.item.loadPrices(sl, nodeId).then(({ sl: next, why }) => { if (why) note.textContent = why; if (next !== sl) { sl = next; redraw(); } }); }; const drawPanel = (): void => { const node = sl.nodes.find((n) => n.id === selected); if (!node) { panelHost.hidden = true; pickHint.hidden = false; toolsHost.replaceChildren(); return; } panelHost.hidden = false; pickHint.hidden = true; toolsHost.className = ""; // 표 손잡이가 제 꾸밈 이름을 붙임 — 다른 종류로 바뀌면 지움 M.panel.renderPanel(panelHost, sl, node.id, { change: (next) => edit(next), pill: (nodeId, cell, dir, pillName) => edit(M.model.setPill(sl, nodeId, cell, dir, pillName)), }); if (node.kind === "table") M.table.renderTableTools(toolsHost, sl, node.id, { change: (next) => edit(next, true) }); else if (node.kind === "item") M.item.renderItemTools(toolsHost, sl, node.id, { change: (next) => { edit(next, true); loadPrices(node.id); }, }); else toolsHost.replaceChildren(); }; const redraw = (): void => { values = evalNow(); refreshOutside(); unsaved.hidden = sl === savedSl; undo.disabled = !stack.length; deleteButton.hidden = !sl.key; const shown = Object.fromEntries( sl.nodes.map((n) => [n.id, { ...values.pillsIn[n.id], ...values.pills[n.id] }]), ); M.diagram.renderDiagram(diagramHost, sl, diagramOn, { values: shown, selected }); drawPanel(); M.result.renderResult(resultHost, sl, values, { change: (next) => edit(next) }); note.textContent = [...notes, ...values.errors].slice(0, 4).join("\n"); }; const save = async (): Promise => { if (busy) return; if (!sl.name.trim()) { note.textContent = tp("NoName"); return void name.input.focus(); } let problems: string[] = []; try { problems = M.ev.checkLogic(sl); } catch { problems = []; } const ask = tp("SaveAnyway", { v: problems.slice(0, 3).join(" · ") }); if (problems.length && !(await showConfirmDialog(ask, tp("Save")))) return; busy = true; note.textContent = tp("Saving"); const screen = values.result.계; try { const got = await M.api.slSave({ ...sl, outside: outsidePlaces(M, sl) }); const server = serverTotal(got.logic); sl = got.logic; savedSl = sl; stack = []; notes = []; redraw(); const differs = server !== null && screen !== null && Math.abs(server - screen) > 0.5; if (differs) notes = [tp("Differs", { s: formatMoney(server), c: formatMoney(screen) })]; note.textContent = [...notes, ...values.errors].join("\n"); showToast(tp("Saved"), "success"); } catch (e) { note.textContent = tp("SaveFailed", { v: M.api.slPlain(e) }); } finally { busy = false; } }; const doUndo = (): void => { const back = stack[stack.length - 1]; if (!back) return; stack = stack.slice(0, -1); sl = back; name.input.value = sl.name; unit.select.value = sl.resultUnit; redraw(); }; const doDelete = async (): Promise => { if (!sl.key || busy) return; if (!(await showConfirmDialog(tp("Delete_Ask", { v: sl.name }), tp("Delete")))) return; try { await M.api.slDelete(sl.key); savedSl = sl; showToast(tp("Deleted"), "success"); ctx.onClose(); } catch (e) { note.textContent = M.api.slPlain(e); } }; const undo = createButton({ label: tp("Undo"), variant: "ghost", onClick: doUndo }); const deleteButton = createButton({ label: tp("Delete"), variant: "danger", onClick: () => void doDelete(), }); const close = createButton({ label: tp("Close"), variant: "ghost", onClick: ctx.onClose }); host.replaceChildren( el("div", { className: "m01-sl__strip", children: [ name.root, unit.root, unsaved, el("div", { className: "m01-sl__actions", children: [ undo, createButton({ label: tp("Save"), onClick: () => void save() }), deleteButton, close, ], }), ], }), el("p", { className: "m01-sl__trial-note", text: tp("Trial") }), note, el("div", { className: "m01-sl__layout", children: [ el("section", { className: "m01-sl__part m01-sl__diagram", children: [el("h4", { text: tp("Diagram") }), diagramHost], }), el("section", { className: "m01-sl__part m01-sl__panel", children: [ el("h4", { text: tp("Panel") }), el("div", { className: "m01-sl__part-body", children: [pickHint, toolsHost, panelHost], }), ], }), el("section", { className: "m01-sl__part m01-sl__result", children: [el("h4", { text: tp("Result") }), resultHost], }), ], }), ); redraw(); if (!sl.name) name.input.focus(); return { dirty: () => sl !== savedSl, save, undo: doUndo }; }