/* ============================================================================= * M02_DF_Entry.ts * 단가산출 흐름도(시험) — 들어가는 단추 · 화면 전체를 덮는 시험판 페이지 · 시작 화면 · 고치는 화면 · 잇기 * 모양은 `docs/raw/M01_쉬운단가/흐름도_정함.md`(1판 약속 = 원가다이어그램_정함.md) · 이 폴더째 지워도 됨 * (폴더 밖 참조는 M01 LogicLab.ts 두 줄 + 서버 등록 한 줄). DataFlow 의 다른 파일은 import() 로 늦게. * 잇기: 열기 → 입력 값(cfValues) → renderDiagram · 「상세 내용」(상자 · 알약 · 선 고름마다 판을 바꿔 끼움 — * M02_DF_Entry_Detail) · renderSummary → 바뀔 때마다 기록(객체 그대로) · evalLogic → 저장(서버 대조) · 지우기 · * 실행 취소 · Ctrl+Z · Ctrl+S. 단추 · 칸은 공용 작은 크기. 바깥 페이지 하나만 세로로 밀림. * ========================================================================== */ 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 { formatMoney } from "../../M01_MasterData/M01_MasterData_UI_Logic_Money"; import type { CfListItem } from "./M02_DF_Api"; import type { CfBreak, CfEvalOptions, CfEvalResult, CfValues } from "./M02_DF_Eval"; import type { CfKind, CfLogic, CfPort, CfStep } from "./M02_DF_Model"; import type { CfSel } from "./M02_DF_Verify"; import type { DfPillSel } from "./M02_DF_Diagram_Parts"; import { detailFrame, drawHead, drawWarnings, fetchValues, loadLogics, queriesOf, unitChoices, type DfPick, } from "./M02_DF_Entry_Detail"; import "./M02_DF_Entry.css"; /** DataFlow 의 다른 파일 — 페이지를 열 때 한 번에 불러옴(엔진이 M01 첫 묶음에 안 들어가게) */ const loadMods = () => Promise.all([ import("./M02_DF_Model"), import("./M02_DF_Eval"), import("./M02_DF_Api"), import("./M02_DF_Diagram"), import("./M02_DF_FuncPanel"), import("./M02_DF_OutputPanel"), import("./M02_DF_Summary"), import("./M02_DF_Sample"), import("./M02_DF_InputPanel"), import("./M02_DF_PillPanel"), import("./M02_DF_LinkPanel"), import("./M02_DF_Standards"), ]).then(([model, ev, api, diagram, func, output, summary, sample, input, pill, line, std]) => ({ model, ev, api, diagram, func, output, summary, sample, input, pill, line, std, })); type Mods = Awaited>; const TEXT = { Open: ["단가산출 흐름도(시험)", "Cost flow (trial)"], Title: ["단가산출 흐름도", "Cost flow"], Trial: ["시험판 — 임시 폴더에 저장", "Trial — saved in a temporary folder"], Close: ["닫기", "Close"], Loading: ["불러오는 중", "Loading"], OpenFailed: ["열지 못함 — {v}", "Could not open — {v}"], Start_New: ["새로 만들기", "New"], Start_Mine: ["내 로직", "My logics"], Start_MineNone: ["아직 없음", "None yet"], Start_Samples: ["견본 — 복사해서 열기", "Samples — open a copy"], Name: ["로직 이름", "Logic name"], NamePlaceholder: ["예: 메쌓기 내 방식", "e.g. my dry stone wall"], Unit: ["결과 단위", "Result unit"], Undo: ["실행 취소", "Undo"], Save: ["저장", "Save"], Delete: ["지우기", "Delete"], Unsaved: ["저장 안 함", "Not saved"], Diagram: ["다이어그램", "Diagram"], Summary: ["원가", "Cost"], Color: ["상자 색", "Box colour"], BoxName: ["상자 이름", "Box name"], NameTaken: ["「{v}」 — 같은 이름의 상자가 있음", "“{v}” — another box has this name"], Standards: ["표준 표", "Standards"], Logics: ["다른 로직을 못 읽음 — {v}", "Couldn't read other logics — {v}"], NoName: ["로직 이름을 넣어 주세요", "Enter a logic 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"], Values: ["입력 값을 못 받음 — {v}", "Couldn't get input values — {v}"], Leave_Ask: [ "저장 안 한 것이 있음 — 저장하지 않고 닫을까요?", "There are unsaved changes — close without saving?", ], Leave_Ok: ["저장 안 하고 닫기", "Close without saving"], } as const; type TextKey = keyof typeof TEXT; const te = (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: "m02-df__muted", text }); /** M01 단가산출 막대의 다른 시험판 단추 옆에 놓는 단추 — 누르면 시험판 페이지 */ export function createDataFlowButton(): HTMLButtonElement { return createButton({ label: te("Open"), variant: "filled", size: "sm", onClick: openDataFlow }); } function openDataFlow(): void { /** 고치는 화면이 열려 있을 때만 */ let paper: { dirty(): boolean; save(): Promise; undo(): void } | null = null; let units: string[] | null = null; const mods = loadMods(); const mayLeave = async (): Promise => !paper?.dirty() || showConfirmDialog(te("Leave_Ask"), te("Leave_Ok")); const body = el("main", { className: "m02-df__body" }); const strip = el("div", { className: "m02-df__strip" }); const close = createButton({ label: te("Close"), variant: "ghost", size: "sm", onClick: () => void (async () => { if (await mayLeave()) paper ? void showStart() : shut(); })(), }); const page = el("div", { className: "m02-df", attrs: { role: "dialog", "aria-modal": "true", "aria-label": te("Title") }, children: [ el("header", { className: "m02-df__head", children: [ el("h2", { className: "m02-df__title", text: te("Title") }), el("span", { className: "m02-df__trial", text: te("Trial") }), strip, close, ], }), body, ], }); const prevOverflow = document.body.style.overflow; const shut = (): void => { page.remove(); document.body.style.overflow = prevOverflow; }; page.addEventListener("keydown", (ev) => { if (ev.defaultPrevented) return; if (ev.key === "Escape") { void (async () => { if (await mayLeave()) shut(); })(); return; } if (!paper || !(ev.ctrlKey || ev.metaKey)) 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 run = (job: (M: Mods) => Promise): void => { body.replaceChildren(muted(te("Loading"))); void mods.then(job).catch((e: unknown) => { showToast(te("OpenFailed", { v: e instanceof Error ? e.message : String(e) }), "error"); void showStart(); }); }; /* ---------- 시작 화면 ---------- */ const listRow = (name: string, unit: string, onClick: () => void, title = ""): HTMLElement => { const row = el("button", { className: "m02-df__item", attrs: { type: "button", title }, children: [ el("span", { className: "m02-df__item-name", text: name }), el("span", { className: "m02-df__item-unit", text: unit }), ], }); row.addEventListener("click", onClick); return row; }; const showStart = async (): Promise => { paper = null; strip.replaceChildren(); const mineHost = el("div", { className: "m02-df__list", children: [muted(te("Loading"))] }); const sampleHost = el("div", { className: "m02-df__list" }); body.replaceChildren( el("div", { className: "m02-df__start", children: [ el("section", { className: "m02-df__start-col", children: [ bigButton(te("Start_New"), () => run(async (M) => showPaper(M, M.model.newLogic("", (await units_())[0]), false)), ), el("h3", { text: te("Start_Mine") }), mineHost, ], }), el("section", { className: "m02-df__start-col", children: [el("h3", { text: te("Start_Samples") }), sampleHost], }), ], }), ); const M = await mods; sampleHost.replaceChildren( ...M.sample.sampleList().map((s) => listRow( s.name, s.resultUnit, () => run(async (M2) => { const lg = M2.sample.sampleLoad(s.id); if (lg) await showPaper(M2, lg, false); }), s.note, ), ), ); try { const mine = (await M.api.cfList()).filter((x: CfListItem) => !x.sample); mineHost.replaceChildren( ...(mine.length ? mine.map((x) => listRow( x.name, x.resultUnit, () => run(async (M2) => showPaper(M2, await M2.api.cfGet(x.key), true)), x.saved, ), ) : [muted(te("Start_MineNone"))]), ); } catch (e) { mineHost.replaceChildren(muted(M.api.cfPlain(e))); } }; const units_ = async (): Promise => (units ??= await unitChoices()); /* ---------- 고치는 화면 ---------- */ const showPaper = async (M: Mods, first: CfLogic, saved: boolean): Promise => { const choices = await units_(); let lg = first; let savedLg: CfLogic | null = saved ? first : null; let stack: CfLogic[] = []; let pick: DfPick = null; const pickedBox = (): string | null => (pick?.kind === "box" ? pick.id : null); let values: CfValues = {}; let valueSig = ""; let logics: NonNullable = {}; let logicSig = ""; let logicNotes: string[] = []; let broken: CfBreak[] = []; let notes: string[] = []; let result: CfEvalResult; let busy = false; const unsaved = createTag(te("Unsaved"), "warning"); const note = el("p", { className: "m02-df__note", attrs: { role: "status" } }); const diagramHost = el("div"); // 상자마다 색 바꾸기(정함 점검 9) — 고른 상자 판 머리에 하나 const colorBox = el("input", { className: "m02-df__color-input", attrs: { type: "color", "aria-label": te("Color") }, }); colorBox.addEventListener("change", () => { const id = pickedBox(); if (id) edit(M.model.setColor(lg, id, colorBox.value)); }); const colorRow = el("label", { className: "m02-df__color", children: [el("span", { text: te("Color") }), colorBox], }); // 상자 이름 — 입력 · 함수 상자(출력 상자는 출력 판의 이름 칸) · 빈 이름 · 같은 이름은 안 받음 const boxName = createInputField({ label: te("BoxName"), compact: true }); boxName.input.addEventListener("change", () => { const box = lg.boxes.find((b) => b.id === pickedBox()); if (!box) return; const next = boxName.input.value.trim(); const taken = lg.boxes.some((b) => b.id !== box.id && b.name === next); if (taken) note.textContent = te("NameTaken", { v: next }); if (!next || taken || next === box.name) return void (boxName.input.value = box.name); edit(M.model.updateBox(lg, box.id, { name: next })); }); // 판마다 제 상태를 쥠 — 종류마다 따로 두고 숨기기만(갈아끼우면 편집기가 있다고 여김) const funcHost = el("div"); const outHost = el("div"); const inHost = el("div"); const summaryHost = el("div"); const linkHost = el("div"); const pillHost = el("div"); const detail = detailFrame(); detail.settings.append(boxName.root, colorRow); detail.body.append(inHost, funcHost, outHost, linkHost, pillHost); const evalNow = (): CfEvalResult => { try { return M.ev.evalLogic(lg, values, { logics }); } catch (e) { const cost = { 노무비: 0, 재료비: 0, 경비: 0, 값들: {}, 계: 0 }; return { ports: {}, outputs: {}, cost, errors: [M.api.cfPlain(e)] }; } }; const edit = (next: CfLogic): void => { if (next === lg) return; stack = [...stack, lg]; lg = next; redraw(); }; /** 다이어그램이 부른 바꿈 — 다이어그램이 제 옛 그림으로 한 번 더 그려도 다음 틱에 새로 */ const editFromDiagram = (next: CfLogic): void => { edit(next); setTimeout(redraw); }; /** 입력 값 다시 받기 — 물음이 바뀐 때만 · 같은 조작의 뒤끝이라 기록을 더 쌓지 않음 */ const refreshValues = (): void => { // 조건 찾기 재료는 이어진 알약 값(지금 셈)을 함께 물음 — 값이 바뀌면 다시 받음 const pill = (p: CfPort): unknown => result?.ports[p.box]?.[p.name] ?? null; const sig = JSON.stringify(queriesOf(lg, pill, M.model.elementPort).map((a) => a.q)); if (sig === valueSig) return; valueSig = sig; void fetchValues(M.api.cfValues, lg, pill, M.model.elementPort).then( (got) => { if (sig !== valueSig) return; values = got.values; notes = got.errors.length ? [te("Values", { v: got.errors.join(" · ") })] : []; redraw(); }, (e: unknown) => { notes = [te("Values", { v: M.api.cfPlain(e) })]; redraw(); }, ); }; /** 다른 로직 입력이 부르는 로직들 다시 읽기 — 부르는 열쇠가 바뀐 때만 */ const refreshLogics = (): void => { const sig = JSON.stringify(lg.boxes.map((b) => b.input?.logic?.key ?? "")); if (sig === logicSig) return; logicSig = sig; const get = async (key: string): Promise => key.startsWith("DF-") ? M.api.cfGet(key) : M.sample.sampleLoad(key); void loadLogics(lg, get).then((got) => { if (sig !== logicSig) return; logics = got.logics; logicNotes = got.errors.length ? [te("Logics", { v: got.errors.join(" · ") })] : []; redraw(); }); }; /** 고름 — 검증 칸 줄 · 알약 판의 상자 이름 누름 */ const choose = (sel: CfSel): void => { const box = lg.boxes.find((b) => b.id === sel.box); pick = sel.link ? { kind: "link", id: sel.link } : box && sel.port ? { kind: "pill", box: box.id, port: sel.port, dir: M.model.portDir(box, sel.port) } : box ? { kind: "box", id: box.id } : null; redraw(); }; /** 흐름 길 — 알약 = 그 알약에서 앞뒤 · 선 = 보낸 알약 앞 · 받는 알약 뒤 */ const traceNow = (): { back: CfStep[]; forward: CfStep[] } | null => { try { if (pick?.kind === "pill") { const p = { box: pick.box, name: pick.port }; return { back: M.model.traceBack(lg, p), forward: M.model.traceForward(lg, p) }; } const id = pick?.kind === "link" ? pick.id : ""; const link = lg.links.find((l) => l.id === id); if (link) return { back: M.model.traceBack(lg, link.from), forward: M.model.traceForward(lg, link.to), }; } catch { /* 따라갈 수 없으면 칠하지 않음 */ } return null; }; const name = createInputField({ label: te("Name"), placeholder: te("NamePlaceholder"), value: lg.name, compact: true, onInput: (v) => edit({ ...lg, name: v }), }); name.root.classList.add("m02-df__name"); const unitList = choices.includes(lg.resultUnit) ? choices : [lg.resultUnit, ...choices]; const unit = createSelectField({ label: te("Unit"), options: unitList.map((u) => ({ value: u, text: u.replace(/^원\//, "") })), value: lg.resultUnit, compact: true, onChange: (v) => edit({ ...lg, resultUnit: v }), }); const diagramOn = { select: (id: string | null, pill?: DfPillSel): void => { pick = pill ? { kind: "pill", ...pill } : !id ? null : lg.links.some((l) => l.id === id) ? { kind: "link", id } : { kind: "box", id }; // 흐름 길 · 판을 새로 — 다이어그램이 제 그림을 그린 뒤 setTimeout(redraw); }, move: (id: string, x: number, y: number): void => edit(M.model.moveBox(lg, id, x, y)), link: (from: CfPort, to: CfPort): string | null => { const got = M.model.link(lg, from, to); if (got.why) return got.why; editFromDiagram(got.lg); return null; }, unlink: (id: string): void => editFromDiagram(M.model.unlink(lg, id)), add: (kind: CfKind, x: number, y: number): void => { const got = M.model.addBox(lg, kind, x, y); pick = { kind: "box", id: got.box.id }; editFromDiagram(got.lg); }, remove: (id: string): void => { if (pickedBox() === id) pick = null; editFromDiagram(M.model.removeBox(lg, id)); }, }; const drawPanel = (): void => { const boxId = pickedBox(); if (boxId && !lg.boxes.some((b) => b.id === boxId)) pick = null; const box = lg.boxes.find((b) => b.id === pickedBox()); drawHead(detail, lg, pick); // 설정 줄(상자 이름 · 색)은 상자를 고른 때만 — 선 · 알약이면 줄째 숨김 (detail.settings.parentElement as HTMLElement).hidden = !box; colorRow.hidden = !box; boxName.root.hidden = !box || box.kind === "output"; if (box && document.activeElement !== boxName.input) boxName.input.value = box.name; linkHost.hidden = pick?.kind !== "link"; pillHost.hidden = pick?.kind !== "pill"; if (pick?.kind === "link") M.line.renderLinkPanel(linkHost, lg, pick.id, { change: edit }, { values, logics }); if (pick?.kind === "pill") M.pill.renderPillPanel( pillHost, lg, pick.box, pick.port, { pick: (id) => choose({ box: id }) }, { values, logics }, ); drawWarnings(detail, pick, broken); if (box && /^#[0-9a-f]{6}$/i.test(box.color)) colorBox.value = box.color; funcHost.hidden = box?.kind !== "function"; outHost.hidden = box?.kind !== "output"; inHost.hidden = box?.kind !== "input"; if (box?.kind === "input") M.input.renderInputPanel(inHost, lg, box.id, { change: edit }); if (box?.kind === "function") M.func.renderFuncPanel(funcHost, lg, box.id, { change: edit }); if (box?.kind === "output") M.output.renderOutputPanel(outHost, lg, box.id, { change: edit }, { result }); }; const redraw = (): void => { result = evalNow(); refreshValues(); refreshLogics(); const linkId = pick?.kind === "link" ? pick.id : ""; if (linkId && !lg.links.some((l) => l.id === linkId)) pick = null; try { broken = M.ev.breaks(lg, values, { logics }); } catch { broken = result.breaks ?? []; } unsaved.hidden = lg === savedLg; undo.disabled = !stack.length; deleteButton.hidden = !lg.key; M.diagram.renderDiagram(diagramHost, lg, diagramOn, { values: result.ports, selected: pick?.kind === "box" || pick?.kind === "link" ? pick.id : null, selectedPill: pick?.kind === "pill" ? { box: pick.box, port: pick.port, dir: pick.dir } : null, trace: traceNow(), breaks: broken, }); drawPanel(); M.summary.renderSummary( summaryHost, lg, result, { change: edit, select: choose }, { values }, ); note.textContent = [...notes, ...logicNotes, ...result.errors].slice(0, 4).join("\n"); }; const save = async (): Promise => { if (busy) return; if (!lg.name.trim()) { note.textContent = te("NoName"); return void name.input.focus(); } let problems: string[] = []; try { problems = M.ev.checkLogic(lg, values, { logics }); } catch { problems = []; } const ask = te("SaveAnyway", { v: problems.slice(0, 3).join(" · ") }); if (problems.length && !(await showConfirmDialog(ask, te("Save")))) return; busy = true; note.textContent = te("Saving"); const screen = result.cost.계; try { const got = await M.api.cfSave(lg); lg = got.logic; savedLg = lg; stack = []; redraw(); const server = got.values.cost.계; if (Math.abs(server - screen) > 0.5) notes = [te("Differs", { s: formatMoney(server), c: formatMoney(screen) })]; note.textContent = [...notes, ...result.errors].join("\n"); showToast(te("Saved"), "success"); } catch (e) { note.textContent = te("SaveFailed", { v: M.api.cfPlain(e) }); } finally { busy = false; } }; const doUndo = (): void => { const back = stack[stack.length - 1]; if (!back) return; stack = stack.slice(0, -1); lg = back; name.input.value = lg.name; unit.select.value = lg.resultUnit; redraw(); }; const doDelete = async (): Promise => { if (!lg.key || busy) return; if (!(await showConfirmDialog(te("Delete_Ask", { v: lg.name }), te("Delete")))) return; try { await M.api.cfDelete(lg.key); savedLg = lg; showToast(te("Deleted"), "success"); void showStart(); } catch (e) { note.textContent = M.api.cfPlain(e); } }; const undo = createButton({ label: te("Undo"), variant: "ghost", size: "sm", onClick: doUndo }); const deleteButton = createButton({ label: te("Delete"), variant: "danger", size: "sm", onClick: () => void doDelete(), }); strip.replaceChildren( name.root, unit.root, unsaved, el("div", { className: "m02-df__actions", children: [ createButton({ label: te("Standards"), variant: "ghost", size: "sm", onClick: () => openModal({ title: te("Standards"), closeLabel: te("Close"), mount: (host: HTMLElement) => M.std.renderStandards(host, { change: () => redraw() }, { title: false }), }), }), undo, createButton({ label: te("Save"), size: "sm", onClick: () => void save() }), deleteButton, ], }), ); body.replaceChildren( note, el("div", { className: "m02-df__layout", children: [ part(te("Diagram"), "m02-df__diagram", [diagramHost]), el("div", { className: "m02-df__lower", children: [detail.root, part(te("Summary"), "m02-df__summary", [summaryHost])], }), ], }), ); paper = { dirty: () => lg !== savedLg, save, undo: doUndo }; redraw(); if (!lg.name) name.input.focus(); }; void showStart(); document.body.style.overflow = "hidden"; // 뒤 페이지는 안 밀리고 시험판 페이지만 밀림 document.body.append(page); page.tabIndex = -1; page.focus(); } /** 고치는 화면의 큰 자리 — 제목 + 몸(안쪽 스크롤 없음 · 내용만큼 늘어남) */ function part(title: string, className: string, children: HTMLElement[]): HTMLElement { return el("section", { className: `m02-df__part ${className}`, children: [el("h3", { text: title }), el("div", { className: "m02-df__part-body", children })], }); } /** 보통 크기 단추 — 칸 폭으로 늘어나지 않게 감쌈 */ function bigButton(label: string, onClick: () => void): HTMLElement { return el("div", { children: [createButton({ label, variant: "filled", size: "sm", onClick })] }); }