/* ============================================================================= * M02_CF_Entry.ts * 단가산출 다이어그램(시험) — 들어가는 단추 · 화면 전체를 덮는 시험판 페이지 · 시작 화면 · 고치는 화면 틀 * 모양은 `docs/raw/M01_쉬운단가/원가다이어그램_정함.md` 10 · 12 · 13 · 이 폴더째 지워도 됨 * (폴더 밖 참조는 M01 LogicLab.ts 두 줄 + 서버 등록 한 줄). CostFlow 의 다른 파일은 import() 로 늦게. * 1걸음 = 틀 — 다이어그램 · 고른 상자 판 · 원가 합계 · 저장 · 지우기 · 실행 취소 · 견본은 2걸음에 이음. * 바깥 페이지 하나만 세로로 밀림(안쪽 스크롤 없음). * ========================================================================== */ import { currentLanguageIndex } from "@ui/ui_template_locale"; import { createButton, createInputField, createSelectField, createTag, el, showConfirmDialog, showToast, } from "@ui/ui_template_elements"; import { fetchLogics } from "../../M01_MasterData/M01_MasterData_UI_Logic_Api"; import type { CfListItem } from "./M02_CF_Api"; import type { CfLogic } from "./M02_CF_Model"; import "./M02_CF_Entry.css"; const TEXT = { Open: ["단가산출 다이어그램(시험)", "Cost diagram (trial)"], Title: ["단가산출 다이어그램", "Cost diagram"], Trial: ["시험판 — 임시 폴더에 저장", "Trial — saved in a temporary folder"], Close: ["닫기", "Close"], Loading: ["불러오는 중", "Loading"], Start_New: ["새로 만들기", "New"], Start_Mine: ["내 로직", "My logics"], Start_MineNone: ["아직 없음", "None yet"], Start_Samples: ["견본", "Samples"], Name: ["로직 이름", "Logic name"], NamePlaceholder: ["예: 메쌓기 내 방식", "e.g. my dry stone wall"], Unit: ["결과 단위", "Result unit"], Undo: ["실행 취소", "Undo"], Save: ["저장", "Save"], Delete: ["지우기", "Delete"], Unsaved: ["저장 안 함", "Not saved"], Diagram: ["다이어그램", "Diagram"], Box: ["고른 상자", "Chosen box"], Panel_Input: ["입력", "Input"], Panel_Function: ["함수", "Function"], Panel_Output: ["출력", "Output"], Summary: ["원가", "Cost"], NotYet: ["준비 중", "Coming soon"], 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): string => (TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0]) as string; const muted = (text: string): HTMLElement => el("p", { className: "m02-cf__muted", text }); /* ponytail: Model(sub7) · Api(sub4) · Sample(sub6) 속이 「준비 중」 인 동안만 대신함 — 채워지면 catch 를 지움 */ const blankLogic = (name: string, unit: string): Promise => import("./M02_CF_Model").then((m) => m.newLogic(name, unit)).catch(() => null); const loadList = (): Promise => import("./M02_CF_Api").then((m) => m.cfList()).catch(() => []); /** 결과 단위 후보 — 품셈 로직에 다섯 번 넘게 쓰인 「원/…」 차례(설명 붙은 드문 단위는 뺌) */ async function unitChoices(): Promise { const count = new Map(); try { for (const x of await fetchLogics()) { if (x.결과단위.startsWith("원/")) count.set(x.결과단위, (count.get(x.결과단위) ?? 0) + 1); } } catch { /* 못 받으면 기본 하나 */ } 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 : ["원/㎡"]; } /** M01 단가산출 막대의 다른 시험판 단추 옆에 놓는 단추 — 누르면 시험판 페이지 */ export function createCostFlowButton(): HTMLButtonElement { return createButton({ label: te("Open"), variant: "filled", onClick: openCostFlow }); } function openCostFlow(): void { /** 고치는 화면이 열려 있을 때만 */ let paper: { dirty(): boolean } | null = null; let units: string[] | null = null; const mayLeave = async (): Promise => !paper?.dirty() || showConfirmDialog(te("Leave_Ask"), te("Leave_Ok")); const body = el("main", { className: "m02-cf__body" }); const strip = el("div", { className: "m02-cf__strip" }); const close = createButton({ label: te("Close"), variant: "ghost", onClick: () => void (async () => { if (await mayLeave()) shut(); })(), }); const page = el("div", { className: "m02-cf", attrs: { role: "dialog", "aria-modal": "true", "aria-label": te("Title") }, children: [ el("header", { className: "m02-cf__head", children: [ el("h2", { className: "m02-cf__title", text: te("Title") }), el("span", { className: "m02-cf__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.key !== "Escape" || ev.defaultPrevented) return; void (async () => { if (await mayLeave()) shut(); })(); }); /* ---------- 시작 화면 ---------- */ const listRow = (item: CfListItem): HTMLElement => { const row = el("button", { className: "m02-cf__item", attrs: { type: "button", title: item.saved }, children: [ el("span", { className: "m02-cf__item-name", text: item.name }), el("span", { className: "m02-cf__item-unit", text: item.resultUnit }), ], }); row.addEventListener("click", () => showToast(te("NotYet"), "info")); return row; }; const showStart = (): void => { paper = null; strip.replaceChildren(); const mineHost = el("div", { className: "m02-cf__list", children: [muted(te("Loading"))] }); const sampleHost = el("div", { className: "m02-cf__list", children: [muted(te("Loading"))] }); void loadList().then((all) => { const mine = all.filter((x) => !x.sample); const samples = all.filter((x) => x.sample); mineHost.replaceChildren( ...(mine.length ? mine.map(listRow) : [muted(te("Start_MineNone"))]), ); sampleHost.replaceChildren( ...(samples.length ? samples.map(listRow) : [muted(te("Start_MineNone"))]), ); }); body.replaceChildren( el("div", { className: "m02-cf__start", children: [ el("section", { className: "m02-cf__start-col", children: [ bigButton(te("Start_New"), () => void openNew()), el("h3", { text: te("Start_Mine") }), mineHost, ], }), el("section", { className: "m02-cf__start-col", children: [el("h3", { text: te("Start_Samples") }), sampleHost], }), ], }), ); }; /* ---------- 고치는 화면 ---------- */ const openNew = async (): Promise => { units ??= await unitChoices(); const lg = await blankLogic("", units[0]); showPaper(lg, units); }; const showPaper = (lg: CfLogic | null, choices: string[]): void => { let dirty = false; paper = { dirty: () => dirty }; 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"), value: lg?.name ?? "", onInput: touched, }); name.root.classList.add("m02-cf__name"); const unit = createSelectField({ label: te("Unit"), options: choices.map((u) => ({ value: u, text: u.replace(/^원\//, "") })), value: lg?.resultUnit ?? choices[0], onChange: touched, }); const notYet = (): void => showToast(te("NotYet"), "info"); const undo = createButton({ label: te("Undo"), variant: "ghost", onClick: notYet }); undo.disabled = true; strip.replaceChildren( name.root, unit.root, unsaved, el("div", { className: "m02-cf__actions", children: [ undo, createButton({ label: te("Save"), onClick: notYet }), createButton({ label: te("Delete"), variant: "danger", onClick: notYet }), ], }), ); body.replaceChildren( el("div", { className: "m02-cf__layout", children: [ part(te("Diagram"), "m02-cf__diagram", [muted(te("NotYet"))]), el("div", { className: "m02-cf__lower", children: [ part(te("Box"), "m02-cf__box", [ el("div", { className: "m02-cf__box-slots", children: [ slot(te("Panel_Input")), slot(te("Panel_Function")), slot(te("Panel_Output")), ], }), ]), part(te("Summary"), "m02-cf__summary", [muted(te("NotYet"))]), ], }), ], }), ); name.input.focus(); }; 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-cf__part ${className}`, children: [el("h3", { text: title }), el("div", { className: "m02-cf__part-body", children })], }); } /** 고른 상자 판의 종류별 자리 — 2걸음에 입력 · 함수 · 출력 판이 들어옴 */ function slot(title: string): HTMLElement { return el("div", { className: "m02-cf__slot", children: [el("h4", { text: title }), muted(te("NotYet"))], }); } function bigButton(label: string, onClick: () => void): HTMLButtonElement { const button = createButton({ label, variant: "filled", onClick }); button.classList.add("m02-cf__big"); return button; }