/* ============================================================================= * M01_EasyCalc_Entry.ts * 쉬운 단가산출 시험 길 — 들어가는 단추 · 큰 창 틀 · 시작 화면 · 고치는 화면 틀(위 띠 · 자리들) * 모양은 `docs/raw/M01_쉬운단가/설계.md` · 이 폴더째 지워도 됨(폴더 밖 참조는 LogicLab.ts 두 줄). * 1걸음 = 틀만 — 카드 · 설계 때 정하는 값 · 실무 일위대가표 · 저장 · 지우기 · 되돌리기는 2걸음에 이음. * ========================================================================== */ 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 { fetchLogic, fetchLogics, type LogicRow } from "../M01_MasterData_UI_Logic_Api"; import { listEasy, openEasy, plainError, type EasyListItem, type EasyOpened, } from "./M01_EasyCalc_Api"; import type { EasyDoc } from "./M01_EasyCalc_Model"; import "./M01_EasyCalc_Style.css"; const TEXT = { Open: ["쉬운 단가산출(시험)", "Easy unit-price (trial)"], Title: ["쉬운 단가산출", "Easy unit-price"], CloseWindow: ["창 닫기", "Close window"], Close: ["닫기", "Close"], Loading: ["불러오는 중", "Loading"], LoadFailed: ["목록을 못 불러옴 — {v}", "Could not load the list — {v}"], Start_Ask: ["무엇과 비슷한가요?", "What is it similar to?"], Start_Search: ["이름으로 찾기 — 예: 거푸집", "Search by name — e.g. formwork"], Start_Blank: ["빈 종이로 새로 만들기", "Start from a blank sheet"], Start_Mine: ["내가 만든 것", "Made by me"], Start_MineNone: ["아직 없음", "None yet"], Start_Found: ["찾은 것", "Found"], Start_Count: ["{n} 개 중 {m} 개 보임 — 더 좁혀 찾기", "Showing {m} of {n} — narrow the search"], Start_None: ["찾은 것 없음", "Nothing found"], Base: ["품셈 기본", "Standard"], Mine: ["내가 만든 것", "Made by me"], CannotOpen: ["쉬운 길로 못 엶", "Not for the easy view"], Per: ["{u} 당", "per {u}"], Name: ["이름", "Name"], NamePlaceholder: ["예: 거푸집 설치", "e.g. formwork setup"], Spec: ["크기", "Size"], Unit: ["단위", "Unit"], Total: ["{u} 만드는 데 {v} 원", "{v} won to make {u}"], Undo: ["되돌리기", "Undo"], Save: ["저장", "Save"], Delete: ["지우기", "Delete"], Unsaved: ["저장 안 함", "Not saved"], BaseNote: [ "품셈 기본은 읽기만 — 고치려면 아래 단추로 내 것을 만듦", "Standard items are read-only — use the button below to make your own copy", ], BaseCopy: ["이걸 바탕으로 고치기", "Edit a copy of this"], Trial: ["설계 때 정하는 값", "Values set at design time"], Cards: ["무엇이 들어가나", "What goes in"], Sheet: ["실무 일위대가표로 보기", "View as a unit-cost sheet"], 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, 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 SHOWN = 50; /* ponytail: Api(sub4) 속이 「준비 중」 인 동안만 Logic_Api 로 대신함 — Api 가 채워지면 아래 셋의 catch 를 지움 */ const loadList = (): Promise => listEasy("").catch(() => fetchLogics().then((all) => all.map((x) => { const isBase = !x.키.startsWith("GX"); const canOpen = x.결과단위.startsWith("원/"); return { key: x.키, name: x.이름, isBase, owner: isBase ? te("Base") : te("Mine"), resultUnit: x.결과단위, canOpen, whyNot: canOpen ? null : te("CannotOpen"), where: `${x.구분} ${x.상세구분}`.trim(), }; }), ), ); const loadOne = (key: string): Promise => openEasy(key).catch(() => fetchLogic(key).then((one) => ({ row: one.logic, version: one.version, isBase: !one.logic.키.startsWith("GX"), trial: {}, })), ); const plain = (error: unknown): string => { try { return plainError(error); } catch { return error instanceof Error ? error.message : String(error); } }; /** 빈 종이 — 꼭 채울 칸만(검토 A 「꼭 채울 것」) · 구분 · 상세구분 · 소유 · 키는 서버가 채움 */ const blankRow = (resultUnit: string): LogicRow => ({ 키: "", 원문번호: "", 이름: "", 결과단위: resultUnit, 출처: "자체", 입력: [], 중간: [], 호표: [], 덧줄: [], 끝수: null, }); const docOf = (opened: EasyOpened, key: string | null): EasyDoc => ({ key, version: opened.version, isBase: opened.isBase, name: opened.row.이름, spec: "", resultUnit: opened.row.결과단위, inputs: opened.row.입력 ?? [], cards: [], row: opened.row, }); /** 「원/㎡」 → 「㎡」 */ const unitOf = (resultUnit: string): string => resultUnit.replace(/^원\//, ""); /** 「㎡」 → 「1㎡」 · 「100m」 은 그대로 */ const amountOf = (resultUnit: string): string => { const unit = unitOf(resultUnit); return /^\d/.test(unit) ? unit : `1${unit}`; }; /** 「추가」 옆에 놓는 단추 — 누르면 큰 창 */ export function createEasyCalcButton(): HTMLButtonElement { return createButton({ label: te("Open"), variant: "filled", onClick: openEasyCalc }); } function openEasyCalc(): void { let body: HTMLElement; let paper: EasyDoc | null = null; let dirty = false; let logics: EasyListItem[] | null = null; let query = ""; /** 저장 안 한 것이 있으면 한 번 물음 — 닫아도 되면 true */ const mayLeave = async (): Promise => !paper || !dirty || showConfirmDialog(te("Leave_Ask"), te("Leave_Ok")); const notYet = (): void => showToast(te("NotYet"), "info"); /* ---------- 시작 화면 ---------- */ const listRow = (item: EasyListItem): HTMLElement => { const ok = item.canOpen; const row = el("button", { className: "m01-easycalc__item", attrs: { type: "button" }, children: [ el("span", { className: "m01-easycalc__item-name", text: item.name }), createTag(item.owner, item.isBase ? "neutral" : "accent"), el("span", { className: "m01-easycalc__item-unit", text: ok ? te("Per", { u: amountOf(item.resultUnit) }) : item.resultUnit, }), ...(ok ? [] : [createTag(item.whyNot ?? te("CannotOpen"), "warning")]), ], }); row.disabled = !ok; row.title = item.where; row.addEventListener("click", () => void openPaper(item.key)); return row; }; const showStart = async (): Promise => { paper = null; dirty = false; const status = el("p", { className: "m01-easycalc__muted", text: te("Loading") }); const mineHost = el("div", { className: "m01-easycalc__list" }); const foundHost = el("div", { className: "m01-easycalc__list" }); const search = createInputField({ type: "search", placeholder: te("Start_Search"), value: query, onInput: (v) => { query = v; drawFound(); }, }); search.root.classList.add("m01-easycalc__search"); const drawFound = (): void => { if (!logics) return; const words = query.trim().toLowerCase().split(/\s+/).filter(Boolean); const hits = words.length ? logics.filter((x) => words.every((w) => x.name.toLowerCase().includes(w))) : logics; status.textContent = !hits.length ? te("Start_None") : hits.length > SHOWN ? te("Start_Count", { n: hits.length.toLocaleString(), m: SHOWN }) : ""; foundHost.replaceChildren(...hits.slice(0, SHOWN).map(listRow)); }; body.replaceChildren( el("div", { className: "m01-easycalc__start", children: [ el("div", { className: "m01-easycalc__start-col", children: [ el("h3", { className: "m01-easycalc__ask", text: te("Start_Ask") }), search.root, el("h4", { text: te("Start_Found") }), status, foundHost, ], }), el("div", { className: "m01-easycalc__start-col m01-easycalc__start-side", children: [ createBigButton(te("Start_Blank"), () => openBlank()), el("h4", { text: te("Start_Mine") }), mineHost, ], }), ], }), ); search.input.focus(); try { logics ??= await loadList(); } catch (error) { status.textContent = te("LoadFailed", { v: plain(error) }); return; } const mine = logics.filter((x) => !x.isBase); mineHost.replaceChildren( ...(mine.length ? mine.map(listRow) : [el("p", { className: "m01-easycalc__muted", text: te("Start_MineNone") })]), ); drawFound(); }; /* ---------- 고치는 화면 ---------- */ const openBlank = (): void => { const row = blankRow(unitChoices()[0] ?? "원/㎡"); paper = { ...docOf({ row, version: "", isBase: false, trial: {} }, null), version: null }; showPaper(); }; const openPaper = async (key: string): Promise => { body.replaceChildren(el("p", { className: "m01-easycalc__muted", text: te("Loading") })); try { paper = docOf(await loadOne(key), key); showPaper(); } catch (error) { showToast(plain(error), "error"); void showStart(); } }; /** 단위 고르기 후보 — 목록에 많이 쓰인 「원/…」 차례 */ const unitChoices = (): string[] => { const count = new Map(); for (const x of logics ?? []) { if (x.canOpen) count.set(x.resultUnit, (count.get(x.resultUnit) ?? 0) + 1); } return [...count.entries()].sort((a, b) => b[1] - a[1]).map(([u]) => u); }; const showPaper = (): void => { const p = paper; if (!p) return; const readOnly = p.isBase; const unsaved = createTag(te("Unsaved"), "warning"); unsaved.hidden = true; const total = el("div", { className: "m01-easycalc__total" }); const drawTotal = (): void => { total.textContent = te("Total", { u: amountOf(p.resultUnit), v: "—" }); }; const touched = (): void => { dirty = true; unsaved.hidden = false; }; const name = createInputField({ label: te("Name"), placeholder: te("NamePlaceholder"), value: p.name, onInput: (v) => { p.name = v; touched(); }, }); const spec = createInputField({ label: te("Spec"), value: p.spec, onInput: (v) => { p.spec = v; touched(); }, }); const units = unitChoices(); if (!units.includes(p.resultUnit)) units.unshift(p.resultUnit); const unit = createSelectField({ label: te("Unit"), options: units.map((u) => ({ value: u, text: unitOf(u) })), value: p.resultUnit, disabled: readOnly, onChange: (v) => { p.resultUnit = v; drawTotal(); touched(); }, }); name.input.disabled = readOnly; spec.input.disabled = readOnly; name.root.classList.add("m01-easycalc__name"); 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(); })(), }); const actions = readOnly ? [close] : [ undo, createButton({ label: te("Save"), onClick: notYet }), ...(p.key && !p.isBase ? [createButton({ label: te("Delete"), variant: "danger", onClick: notYet })] : []), close, ]; const section = (title: string, extra = ""): HTMLElement => el("section", { className: `m01-easycalc__part ${extra}`.trim(), children: [ el("h4", { text: title }), el("p", { className: "m01-easycalc__muted", text: te("NotYet") }), ], }); const sheet = el("details", { className: "m01-easycalc__part m01-easycalc__sheet", children: [ el("summary", { text: te("Sheet") }), el("p", { className: "m01-easycalc__muted", text: te("NotYet") }), ], }); drawTotal(); body.replaceChildren( el("div", { className: "m01-easycalc__strip", children: [ name.root, spec.root, unit.root, total, unsaved, el("div", { className: "m01-easycalc__actions", children: actions }), ], }), ...(readOnly ? [ el("div", { className: "m01-easycalc__base", children: [ el("p", { text: te("BaseNote") }), createBigButton(te("BaseCopy"), notYet), ], }), ] : []), section(te("Trial"), "m01-easycalc__trial"), section(te("Cards"), "m01-easycalc__cards"), sheet, ); if (!readOnly) name.input.focus(); }; // 화면 함수들을 다 만든 뒤에 엶 — mount 가 바로 showStart 를 부름 openModal({ title: te("Title"), closeLabel: te("CloseWindow"), dialogClass: "m01-easycalc", beforeClose: mayLeave, mount: (host) => { body = host; void showStart(); }, }); } function createBigButton(label: string, onClick: () => void): HTMLButtonElement { const button = createButton({ label, variant: "filled", onClick }); button.classList.add("m01-easycalc__big"); return button; }