From 499e52ca7331babf89ed175a71ea2b2e5bd9fbdc Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 13:21:15 +0900 Subject: [PATCH] =?UTF-8?q?feat(m01-sl):=20=EC=8B=9C=ED=8A=B8=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=9E=87=EA=B8=B0=20=E2=80=94=20=EC=97=B4=EA=B8=B0?= =?UTF-8?q?=20=C2=B7=20=EB=B0=94=EA=B9=A5=20=EA=B0=92=20=C2=B7=20=EB=8B=A4?= =?UTF-8?q?=EC=9D=B4=EC=96=B4=EA=B7=B8=EB=9E=A8=20=C2=B7=20=EC=8B=9C?= =?UTF-8?q?=ED=8A=B8=20=ED=8C=90=20=C2=B7=20=ED=91=9C/=ED=95=AD=EB=AA=A9?= =?UTF-8?q?=20=EC=86=90=EC=9E=A1=EC=9D=B4=20=C2=B7=20=EA=B2=B0=EA=B3=BC=20?= =?UTF-8?q?=C2=B7=20=EC=A0=80=EC=9E=A5(=EC=84=9C=EB=B2=84=20=EA=B0=92=20?= =?UTF-8?q?=EB=8C=80=EC=A1=B0)=20=C2=B7=20=EC=A7=80=EC=9A=B0=EA=B8=B0=20?= =?UTF-8?q?=C2=B7=20=EC=8B=A4=ED=96=89=20=EC=B7=A8=EC=86=8C=20=C2=B7=20?= =?UTF-8?q?=EA=B0=80=EC=A0=B8=EC=98=A4=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- M01_MasterData/SheetLogic/M01_SL_Entry.css | 11 + M01_MasterData/SheetLogic/M01_SL_Entry.ts | 274 ++++++------ .../SheetLogic/M01_SL_Entry_Paper.ts | 393 ++++++++++++++++++ 3 files changed, 526 insertions(+), 152 deletions(-) create mode 100644 M01_MasterData/SheetLogic/M01_SL_Entry_Paper.ts diff --git a/M01_MasterData/SheetLogic/M01_SL_Entry.css b/M01_MasterData/SheetLogic/M01_SL_Entry.css index 4849660f5..5f6cd2c1a 100644 --- a/M01_MasterData/SheetLogic/M01_SL_Entry.css +++ b/M01_MasterData/SheetLogic/M01_SL_Entry.css @@ -167,3 +167,14 @@ overflow: auto; } } + +.m01-sl__note { + margin: 0; + color: var(--color-text-secondary); + font-size: var(--text-body-sm); + white-space: pre-line; +} + +.m01-sl__note:empty { + display: none; +} diff --git a/M01_MasterData/SheetLogic/M01_SL_Entry.ts b/M01_MasterData/SheetLogic/M01_SL_Entry.ts index 3398ed630..978af0f4e 100644 --- a/M01_MasterData/SheetLogic/M01_SL_Entry.ts +++ b/M01_MasterData/SheetLogic/M01_SL_Entry.ts @@ -1,33 +1,34 @@ /* ============================================================================= * M01_SL_Entry.ts - * 시트 로직(시험) — 들어가는 단추 · 큰 창 · 시작 화면 · 고치는 화면 틀(위 다이어그램 · 아래 시트 판 · 결과) · 위 띠 + * 시트 로직(시험) — 들어가는 단추 · 큰 창 · 시작 화면(내 시트 로직 · 새로 · 기존 로직 가져오기) · 잇기 * 모양은 `docs/raw/M01_쉬운단가/시트로직_정함.md` · 이 폴더째 지워도 됨(폴더 밖 참조는 LogicLab.ts 두 줄 + 서버 등록 한 줄). - * 1걸음 = 틀 — 다이어그램 · 시트 판 · 결과 · 저장 · 지우기 · 실행 취소 · 가져오기는 2걸음에 이음. + * 고치는 화면은 M01_SL_Entry_Paper.ts · SheetLogic 의 다른 파일은 창을 열 때 import() 로 늦게 불러옴. + * 열기 = slGet → 바깥 값(slOutside → applyOutside) → 고치는 화면 · 가져오기 = 기존 로직 줄 + 품셈 표 → importLogicReport. * ========================================================================== */ import { currentLanguageIndex } from "@ui/ui_template_locale"; import { createButton, createInputField, - createSelectField, - createTag, el, showConfirmDialog, showToast, } from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; -import { listEasy, plainError, type EasyListItem } from "../EasyCalc/M01_EasyCalc_Api"; +import { listEasy, openEasy, plainError, type EasyListItem } from "../EasyCalc/M01_EasyCalc_Api"; import type { SlListItem } from "./M01_SL_Api"; import type { SheetLogic } from "./M01_SL_Model"; +import type { SlImportTable } from "./M01_SL_Import"; +import { fillOutside, loadMods, showPaper, type Mods } from "./M01_SL_Entry_Paper"; import "./M01_SL_Entry.css"; const TEXT = { Open: ["시트 로직(시험)", "Sheet logic (trial)"], Title: ["시트 로직", "Sheet logic"], CloseWindow: ["창 닫기", "Close window"], - Close: ["닫기", "Close"], Loading: ["불러오는 중", "Loading"], LoadFailed: ["목록을 못 불러옴 — {v}", "Could not load the list — {v}"], + OpenFailed: ["열지 못함 — {v}", "Could not open — {v}"], Trial: ["시험판 — 임시 폴더에 저장", "Trial — saved in a temporary folder"], Start_Mine: ["내 시트 로직", "My sheet logics"], Start_MineNone: ["아직 없음", "None yet"], @@ -38,17 +39,7 @@ const TEXT = { Start_Count: ["{n} 개 중 {m} 개 보임 — 더 좁혀 찾기", "Showing {m} of {n} — narrow the search"], Start_None: ["찾은 것 없음", "Nothing found"], Per: ["{u} 당", "per {u}"], - Name: ["이름", "Name"], - NamePlaceholder: ["예: 돌쌓기 내 방식", "e.g. my stone wall"], - Unit: ["결과 단위", "Result unit"], - Undo: ["실행 취소", "Undo"], - Save: ["저장", "Save"], - Delete: ["지우기", "Delete"], - Unsaved: ["저장 안 함", "Not saved"], - Diagram: ["상자와 선", "Boxes and lines"], - Panel: ["고른 상자의 시트", "Sheet of the chosen box"], - Result: ["결과", "Result"], - NotYet: ["준비 중", "Coming soon"], + Outside: ["바깥 값을 못 받음 — {v}", "Couldn't get outside values — {v}"], Leave_Ask: [ "저장 안 한 것이 있음 — 저장하지 않고 닫을까요?", "There are unsaved changes — close without saving?", @@ -69,19 +60,13 @@ const TRIAL_KEYS = ["GF000208", "GF000219", "GF000025", "GF000249", "GF000133", /** 찾은 것 목록에 한 번에 보이는 줄 수 */ const SHOWN = 50; -/** 「원/㎡」 → 「㎡」 · 「100㎡」 은 그대로 앞에 1 을 안 붙임 */ +/** 「원/㎡」 → 「1㎡」 · 「100㎡」 은 앞에 1 을 안 붙임 */ const amountOf = (resultUnit: string): string => { const unit = resultUnit.replace(/^원\//, ""); return /^\d/.test(unit) ? unit : `1${unit}`; }; const isMoney = (resultUnit: string): boolean => resultUnit.startsWith("원/"); - -/* SheetLogic 파일은 늦게 불러옴(import()) — 스프레드시트 엔진이 M01 첫 묶음에 안 들어가게. - * ponytail: SL Api(sub4) · Model(sub7) 속이 「준비 중」 인 동안만 빈 목록 · null 로 대신함 — 채워지면 catch 를 지움 */ -const loadMine = (): Promise => - import("./M01_SL_Api").then((m) => m.slList()).catch(() => []); -const blankLogic = (name: string, unit: string): Promise => - import("./M01_SL_Model").then((m) => m.newLogic(name, unit)).catch(() => null); +const muted = (text: string): HTMLElement => el("p", { className: "m01-sl__muted", text }); /** 「로직 만들기(시험)」 옆에 놓는 단추 — 누르면 큰 창 */ export function createSheetLogicButton(): HTMLButtonElement { @@ -91,58 +76,137 @@ export function createSheetLogicButton(): HTMLButtonElement { function openSheetLogic(): void { let body: HTMLElement; /** 고치는 화면이 열려 있을 때만 */ - let paper: { dirty(): boolean; sl: SheetLogic | null } | null = null; + let paper: ReturnType | null = null; let logics: EasyListItem[] | null = null; let query = ""; + const mods = loadMods(); const mayLeave = async (): Promise => !paper?.dirty() || showConfirmDialog(te("Leave_Ask"), te("Leave_Ok")); - const notYet = (): void => showToast(te("NotYet"), "info"); + + /** Ctrl+Z · Ctrl+S — 이 창 안에서만(시트 편집기가 먼저 먹은 키는 그쪽 몫) */ + const onKey = (ev: KeyboardEvent): void => { + if (!paper || !(ev.ctrlKey || ev.metaKey) || ev.defaultPrevented) return; + const key = ev.key.toLowerCase(); + if (key === "z" && !ev.shiftKey) { + ev.preventDefault(); + paper.undo(); + } else if (key === "s") { + ev.preventDefault(); + void paper.save(); + } + }; + + /** 결과 단위 후보 — 품셈 로직에 다섯 번 넘게 쓰인 「원/…」 차례(설명 붙은 드문 단위는 뺌) */ + const unitChoices = (): string[] => { + const count = new Map(); + for (const x of logics ?? []) { + if (isMoney(x.resultUnit)) count.set(x.resultUnit, (count.get(x.resultUnit) ?? 0) + 1); + } + const units = [...count.entries()] + .filter(([u, n]) => n >= 5 && !/[\s()「·]/.test(u)) + .sort((a, b) => b[1] - a[1]) + .map(([u]) => u); + return units.length ? units : ["원/㎡"]; + }; + + /** 고치는 화면 열기 — 바깥 값을 넣은 뒤 · 새로 · 가져오기는 처음부터 「저장 안 함」 */ + const open = async ( + M: Mods, + sl: SheetLogic, + saved: boolean, + notes: string[] = [], + ): Promise => { + let filled = sl; + try { + const got = await fillOutside(M, sl); + filled = got.sl; + if (got.errors.length) notes = [...notes, te("Outside", { v: got.errors.join(" · ") })]; + } catch (e) { + notes = [...notes, te("Outside", { v: M.api.slPlain(e) })]; + } + paper = showPaper(M, body, filled, { + saved, + units: unitChoices(), + notes, + onClose: () => + void (async () => { + if (await mayLeave()) void showStart(); + })(), + }); + }; + + const run = (job: (M: Mods) => Promise): void => { + body.replaceChildren(muted(te("Loading"))); + void mods.then(job).catch((e: unknown) => { + showToast(te("OpenFailed", { v: plainError(e) }), "error"); + void showStart(); + }); + }; + + const openSaved = (key: string): void => run(async (M) => open(M, await M.api.slGet(key), true)); + + const openNew = (): void => + run(async (M) => open(M, M.model.newLogic("", unitChoices()[0]), false)); + + /** 기존 로직 가져오기 — 로직 줄 + 그 줄이 쓰는 품셈 표(열 때 받음) → 시트 로직(저장 전) */ + const doImport = (key: string): void => + run(async (M) => { + const { row } = await openEasy(key); + const lb = await import("../LogicBuilder/M01_LB_Api"); + const keys = [...new Set(JSON.stringify(row).match(/Q[A-Z]\d{6}/g) ?? [])]; + const got = await Promise.all( + keys.map((k) => + lb.fetchLbTable(k).then( + (t) => [k, t.table] as const, + () => null, + ), + ), + ); + // 서버 표 통째(조건 · 값칸 · 줄 포함) — LB Api 타입이 느슨해 Import 모양으로 봄 + const tables = Object.fromEntries(got.filter((x) => x !== null)) as unknown as Record< + string, + SlImportTable + >; + const report = M.imp.importLogicReport(row, { tables }); + await open(M, report.sl, false, report.problems); + }); /* ---------- 시작 화면 ---------- */ - const importRow = (item: EasyListItem): HTMLElement => { + const listButton = (name: string, unit: string, onClick: () => void, title = ""): HTMLElement => { const row = el("button", { className: "m01-sl__item", - attrs: { type: "button", title: item.where }, + attrs: { type: "button", title }, children: [ - el("span", { className: "m01-sl__item-name", text: item.name }), - el("span", { - className: "m01-sl__item-unit", - text: isMoney(item.resultUnit) - ? te("Per", { u: amountOf(item.resultUnit) }) - : item.resultUnit, - }), + el("span", { className: "m01-sl__item-name", text: name }), + el("span", { className: "m01-sl__item-unit", text: unit }), ], }); - row.addEventListener("click", notYet); - return row; - }; - - const mineRow = (item: SlListItem): HTMLElement => { - const row = el("button", { - className: "m01-sl__item", - attrs: { type: "button" }, - children: [ - el("span", { className: "m01-sl__item-name", text: item.이름 }), - el("span", { className: "m01-sl__item-unit", text: item.결과단위 }), - ], - }); - row.addEventListener("click", notYet); + row.addEventListener("click", onClick); return row; }; + const perText = (u: string): string => (isMoney(u) ? te("Per", { u: amountOf(u) }) : u); + const importRow = (item: EasyListItem): HTMLElement => + listButton(item.name, perText(item.resultUnit), () => doImport(item.key), item.where); + const mineRow = (item: SlListItem): HTMLElement => + listButton(item.이름, perText(item.결과단위), () => openSaved(item.key), item.saved); const showStart = async (): Promise => { paper = null; const status = el("p", { className: "m01-sl__muted", text: te("Loading") }); const trialHost = el("div", { className: "m01-sl__list" }); const foundHost = el("div", { className: "m01-sl__list" }); - const mineHost = el("div", { className: "m01-sl__list" }); - void loadMine().then((mine) => - mineHost.replaceChildren( - ...(mine.length ? mine.map(mineRow) : [muted(te("Start_MineNone"))]), - ), - ); + const mineHost = el("div", { className: "m01-sl__list", children: [muted(te("Loading"))] }); + void mods + .then((M) => M.api.slList()) + .then( + (mine) => + mineHost.replaceChildren( + ...(mine.length ? mine.map(mineRow) : [muted(te("Start_MineNone"))]), + ), + (e: unknown) => mineHost.replaceChildren(muted(te("LoadFailed", { v: plainError(e) }))), + ); const search = createInputField({ type: "search", placeholder: te("Start_Search"), @@ -177,7 +241,7 @@ function openSheetLogic(): void { el("div", { className: "m01-sl__start-col", children: [ - bigButton(te("Start_New"), () => showPaper()), + bigButton(te("Start_New"), openNew), el("h4", { text: te("Start_Mine") }), mineHost, ], @@ -214,88 +278,6 @@ function openSheetLogic(): void { drawFound(); }; - /** 결과 단위 후보 — 품셈 로직에 다섯 번 넘게 쓰인 「원/…」 차례(설명 붙은 드문 단위는 뺌) */ - const unitChoices = (): string[] => { - const count = new Map(); - for (const x of logics ?? []) { - if (isMoney(x.resultUnit)) count.set(x.resultUnit, (count.get(x.resultUnit) ?? 0) + 1); - } - const units = [...count.entries()] - .filter(([u, n]) => n >= 5 && !/[\s()「·]/.test(u)) - .sort((a, b) => b[1] - a[1]) - .map(([u]) => u); - return units.length ? units : ["원/㎡"]; - }; - - /* ---------- 고치는 화면 ---------- */ - - const showPaper = (): void => { - let dirty = false; - const units = unitChoices(); - const mine: { dirty(): boolean; sl: SheetLogic | null } = { dirty: () => dirty, sl: null }; - paper = mine; - void blankLogic("", units[0]).then((sl) => (mine.sl = sl)); - - const unsaved = createTag(te("Unsaved"), "warning"); - unsaved.hidden = true; - const touched = (): void => { - dirty = true; - unsaved.hidden = false; - }; - const name = createInputField({ - label: te("Name"), - placeholder: te("NamePlaceholder"), - onInput: touched, - }); - name.root.classList.add("m01-sl__name"); - const unit = createSelectField({ - label: te("Unit"), - options: units.map((u) => ({ value: u, text: u.replace(/^원\//, "") })), - value: units[0], - onChange: touched, - }); - const undo = createButton({ label: te("Undo"), variant: "ghost", onClick: notYet }); - undo.disabled = true; - const close = createButton({ - label: te("Close"), - variant: "ghost", - onClick: () => - void (async () => { - if (await mayLeave()) void showStart(); - })(), - }); - - body.replaceChildren( - el("div", { - className: "m01-sl__strip", - children: [ - name.root, - unit.root, - unsaved, - el("div", { - className: "m01-sl__actions", - children: [ - undo, - createButton({ label: te("Save"), onClick: notYet }), - createButton({ label: te("Delete"), variant: "danger", onClick: notYet }), - close, - ], - }), - ], - }), - el("p", { className: "m01-sl__trial-note", text: te("Trial") }), - el("div", { - className: "m01-sl__layout", - children: [ - part(te("Diagram"), "m01-sl__diagram"), - part(te("Panel"), "m01-sl__panel"), - part(te("Result"), "m01-sl__result"), - ], - }), - ); - name.input.focus(); - }; - // 화면 함수들을 다 만든 뒤에 엶 — mount 가 바로 showStart 를 부름 openModal({ title: te("Title"), @@ -304,24 +286,12 @@ function openSheetLogic(): void { beforeClose: mayLeave, mount: (host) => { body = host; + (host.parentElement ?? host).addEventListener("keydown", onKey); void showStart(); }, }); } -const muted = (text: string): HTMLElement => el("p", { className: "m01-sl__muted", text }); - -/** 고치는 화면의 자리 하나 — 제목 + 몸(2걸음에 다이어그램 · 시트 판 · 결과가 들어옴) */ -function part(title: string, className: string): HTMLElement { - return el("section", { - className: `m01-sl__part ${className}`, - children: [ - el("h4", { text: title }), - el("div", { className: "m01-sl__part-body", children: [muted(te("NotYet"))] }), - ], - }); -} - function bigButton(label: string, onClick: () => void): HTMLButtonElement { const button = createButton({ label, variant: "filled", onClick }); button.classList.add("m01-sl__big"); diff --git a/M01_MasterData/SheetLogic/M01_SL_Entry_Paper.ts b/M01_MasterData/SheetLogic/M01_SL_Entry_Paper.ts new file mode 100644 index 000000000..a5012a1b3 --- /dev/null +++ b/M01_MasterData/SheetLogic/M01_SL_Entry_Paper.ts @@ -0,0 +1,393 @@ +/* ============================================================================= + * 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"), + ]).then(([model, ev, api, diagram, panel, table, item, result, imp]) => ({ + model, + ev, + api, + diagram, + panel, + table, + item, + result, + imp, + })); +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()}`; + 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 }); + } + M.imp.outsideOf(sl).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 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" }); + const panelHost = el("div"); + 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 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; + edit(got.sl, true); + return null; + }, + disconnect: (edgeId: string): void => edit(M.model.disconnect(sl, edgeId), true), + add: (kind: SlKind): void => { + const got = M.model.addNode(sl, kind, kindName(kind)); + selected = got.node.id; + edit(got.sl, true); + }, + remove: (nodeId: string): void => { + if (selected === nodeId) selected = null; + edit(M.model.removeNode(sl, nodeId), true); + }, + }; + + /** 항목 단가 — 고른 뒤 서버에서 받아 넣음(같은 조작의 뒤끝이라 기록을 더 쌓지 않음) */ + 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.replaceChildren(muted(tp("PickBox"))); + toolsHost.replaceChildren(); + return; + } + 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(); + 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: [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 }; +}