/* ============================================================================= * M01_EasyCalc_Entry.ts * 쉬운 단가산출 시험 길 — 들어가는 단추 · 큰 창 틀 · 시작 화면 · 고치는 화면(위 띠) · 모든 파일 잇기 * 모양은 `docs/raw/M01_쉬운단가/설계.md` · 이 폴더째 지워도 됨(폴더 밖 참조는 LogicLab.ts 두 줄). * 잇기: 열기(openEasy) → toDoc → renderCards · renderSheet · renderTrialStrip → 바뀔 때마다 * EasyHistory.push · calcLater → 저장(새로 newEasy · 내 것 editEasy · 품셈 기본은 copyEasy 뒤 그 복사본) * · 지우기(usesOf 먼저 · 물음 한 번) · 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 type { CalcAnswer, LogicInput } from "../M01_MasterData_UI_Logic_Api"; import { formatMoney } from "../M01_MasterData_UI_Logic_Money"; import { calcLater, copyEasy, deleteEasy, editEasy, listEasy, newEasy, openEasy, plainError, usesOf, type EasyListItem, } from "./M01_EasyCalc_Api"; import { renderCards } from "./M01_EasyCalc_Cards"; import { checkName, EasyHistory, newDoc, setEasyModelLanguage, toDoc, toRow, type EasyCard, type EasyDoc, } from "./M01_EasyCalc_Model"; import { renderSheet, renderTrialStrip } from "./M01_EasyCalc_Sheet"; 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"], Per: ["{u} 당", "per {u}"], CannotOpen: ["쉬운 길로 못 엶", "Not for the easy view"], 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"], Saving: ["저장 중", "Saving"], Saved: ["저장했음", "Saved"], SaveFailed: ["저장 못 함", "Not saved"], BaseNote: [ "품셈 기본은 읽기만 — 고치려면 아래 단추로 내 것을 만듦", "Standard items are read-only — use the button below to make your own copy", ], BaseCopy: ["이걸 바탕으로 고치기", "Edit a copy of this"], Copying: ["내 것으로 만드는 중", "Making your copy"], Copied: ["내 것으로 만들었음 — 이제 고칠 수 있음", "Your copy is ready — you can edit it now"], Cards: ["무엇이 들어가나", "What goes in"], Sheet: ["실무 일위대가표로 보기", "View as a unit-cost sheet"], BadLines: [ "⚠ 표시 줄을 먼저 고쳐 주세요 — 빈 이름 · 숫자 아님 · 0 · 0 보다 작은 수는 저장 안 됨", "Fix the ⚠ lines first — empty names, non-numbers, 0 or negative amounts can't be saved", ], Uses_Wait: ["쓰는 곳을 찾는 중 — 10초쯤 걸림", "Looking for places that use it — about 10 s"], Uses_Block: ["쓰는 곳이 있어 못 지움 — {v}", "Cannot delete — it is used by {v}"], Delete_Ask: ["「{v}」 을(를) 지울까요? 되살릴 수 없음", "Delete “{v}”? This cannot be undone"], Delete_Ok: ["지우기", "Delete"], Deleted: ["지웠음", "Deleted"], 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; /** 「원/㎡」 → 「㎡」 */ 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}`; }; /** 저장을 막는 줄 — 이름 빔 · 수량이 숫자 아님 · 0 · 음수(서버가 0 · 음수를 받아 줌) · 같은 줄 · 너무 큼은 경고만 */ const blocksSave = (c: EasyCard): boolean => !c.name.trim() || (c.qtyFormula === null && !(c.qty !== null && Number.isFinite(c.qty) && c.qty > 0)); /** 카드를 넣어 새로 생긴 설계 값의 시험값 — 서버 자동값(`master_auto.values`)과 같은 규칙: * 고르기 = 첫째 · 범위 = 낮은 끝 · 그 밖 숫자 0 · 값이 「원」 인 가격 값은 비워 둠(0 원처럼 안 보이게) */ function fillNew( inputs: LogicInput[], trial: Record, known: Set, ): Record { const out = { ...trial }; for (const spec of inputs) { if (known.has(spec.이름)) continue; known.add(spec.이름); if (out[spec.이름] !== undefined || String(spec.단위 ?? "").includes("원")) continue; out[spec.이름] = spec.고르기?.length ? spec.고르기[0] : (spec.범위?.[0] ?? 0); } return out; } /** 「추가」 옆에 놓는 단추 — 누르면 큰 창 */ export function createEasyCalcButton(): HTMLButtonElement { return createButton({ label: te("Open"), variant: "filled", onClick: openEasyCalc }); } function openEasyCalc(): void { setEasyModelLanguage(currentLanguageIndex); let body: HTMLElement; /** 고치는 화면이 열려 있을 때만 */ let paper: { history: EasyHistory; save: () => Promise; undo: () => void } | null = null; let logics: EasyListItem[] | null = null; let query = ""; /** 저장 안 한 것이 있으면 한 번 물음 — 닫아도 되면 true */ const mayLeave = async (): Promise => !paper?.history.dirty || showConfirmDialog(te("Leave_Ask"), te("Leave_Ok")); /** Ctrl+Z · Ctrl+S — 이 창 안에서만(고르기 창 등 다른 창은 제 몫) */ const onKey = (ev: KeyboardEvent): void => { 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 listRow = (item: EasyListItem): HTMLElement => { const row = el("button", { className: "m01-easycalc__item", attrs: { type: "button", title: item.where }, 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: item.canOpen ? te("Per", { u: amountOf(item.resultUnit) }) : item.resultUnit, }), ...(item.canOpen ? [] : [createTag(te("CannotOpen"), "warning")]), ], }); if (item.whyNot) row.title = item.whyNot; row.disabled = !item.canOpen; row.addEventListener("click", () => void openPaper(item.key)); return row; }; const showStart = async (): Promise => { paper = null; 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 listEasy(""); } catch (error) { status.textContent = te("LoadFailed", { v: plainError(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 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()] .filter(([u, n]) => n >= 5 && !/[\s()「·]/.test(u)) .sort((a, b) => b[1] - a[1]) .map(([u]) => u); }; /* ---------- 고치는 화면 ---------- */ const openBlank = (): void => showPaper(newDoc("", unitOf(unitChoices()[0] ?? "원/㎡")), {}); const openPaper = async (key: string): Promise => { body.replaceChildren(el("p", { className: "m01-easycalc__muted", text: te("Loading") })); try { const opened = await openEasy(key); showPaper( toDoc(opened.row, { key, version: opened.version, isBase: opened.isBase }), opened.trial, ); } catch (error) { showToast(plainError(error), "error"); void showStart(); } }; const showPaper = (first: EasyDoc, firstTrial: Record): void => { let doc = first; let trial = firstTrial; let ans: CalcAnswer | null = null; let history = new EasyHistory(); history.push(doc); let busy = false; const unsaved = createTag(te("Unsaved"), "warning"); const total = el("div", { className: "m01-easycalc__total" }); const note = el("p", { className: "m01-easycalc__note", attrs: { role: "status" } }); const trialHost = el("div", { className: "m01-easycalc__part m01-easycalc__trial" }); const cardsHost = el("div", { className: "m01-easycalc__cards" }); const sheetHost = el("div"); let trialShape = ""; /** 연 때 있던 설계 값 — 서버 자동값을 그대로 둠 · 이 밖에 새로 생긴 값만 채움 */ const known = new Set(doc.inputs.map((i) => i.이름)); const name = createInputField({ label: te("Name"), placeholder: te("NamePlaceholder"), onInput: (v) => edit({ ...doc, name: v }), }); name.root.classList.add("m01-easycalc__name"); const spec = createInputField({ label: te("Spec"), onInput: (v) => edit({ ...doc, spec: v }) }); const unit = createSelectField({ label: te("Unit"), options: [], onChange: (v) => edit({ ...doc, resultUnit: v }), }); const undo = createButton({ label: te("Undo"), variant: "ghost", onClick: () => doUndo() }); const saveButton = createButton({ label: te("Save"), onClick: () => void doSave() }); const deleteButton = createButton({ label: te("Delete"), variant: "danger", onClick: () => void doDelete(), }); const close = createButton({ label: te("Close"), variant: "ghost", onClick: () => void (async () => { if (await mayLeave()) void showStart(); })(), }); const actions = el("div", { className: "m01-easycalc__actions" }); const baseBar = el("div", { className: "m01-easycalc__base", children: [ el("p", { text: te("BaseNote") }), createBigButton(te("BaseCopy"), () => void doCopy()), ], }); /** 위 띠 칸을 doc 로 — 열 때 · 되돌릴 때 · 저장 뒤 */ const fillStrip = (): void => { const units = unitChoices(); if (!units.includes(doc.resultUnit)) units.unshift(doc.resultUnit); unit.setOptions( units.map((u) => ({ value: u, text: unitOf(u) })), doc.resultUnit, ); name.input.value = doc.name; spec.input.value = doc.spec; for (const box of [name.input, spec.input, unit.select]) box.disabled = doc.isBase; actions.replaceChildren( ...(doc.isBase ? [] : [undo, saveButton]), ...(doc.key && !doc.isBase ? [deleteButton] : []), close, ); baseBar.hidden = !doc.isBase; }; /** 금액 · 카드 · 표 — 바뀔 때마다(pending) · 계산 답이 올 때마다(answer) */ const draw = (pending: boolean, answer = false): void => { const value = ans?.ok ? formatMoney(ans.result ?? ans.sums?.["계"] ?? 0) : "—"; total.textContent = te("Total", { u: amountOf(doc.resultUnit), v: value }); total.classList.toggle("is-pending", pending); sheetHost.classList.toggle("is-pending", pending); unsaved.hidden = !history.dirty; undo.disabled = !history.canUndo; const shape = JSON.stringify(doc.inputs); if (shape !== trialShape) { trialShape = shape; trial = fillNew(doc.inputs, trial, known); renderTrialStrip(trialHost, doc.inputs, trial, (next) => { trial = next; calc(); }); } drawCards(answer); renderSheet(sheetHost, { name: doc.name, resultUnit: doc.resultUnit }, ans); }; /** 계산 답만 왔을 때 카드 칸에 글을 넣는 중이면 그 칸을 떠난 뒤 그림(넣던 글이 안 날아가게). * doc 가 바뀌면 늘 바로 그림 — 카드가 옛 doc 를 쥐고 있으면 그다음 조작이 옛 판본 위에 쌓임 */ let waiting = false; const drawCards = (answer = false): void => { const active = document.activeElement; if (answer && active instanceof HTMLInputElement && cardsHost.contains(active)) { if (!waiting) { waiting = true; cardsHost.addEventListener( "focusout", () => setTimeout(() => { waiting = false; drawCards(true); }), { once: true }, ); } return; } renderCards(cardsHost, doc, ans, { change: edit, trial: () => trial }); }; const calc = (): void => { draw(true); calcLater(toRow(doc), trial, (next, plain) => { ans = next; note.textContent = plain ?? ""; draw(false, true); }); }; const edit = (next: EasyDoc): void => { doc = next; history.push(doc); calc(); }; const doUndo = (): void => { const back = history.undo(); if (!back) return; doc = back; fillStrip(); calc(); }; /** 저장 뒤 · 본뜬 뒤 — 서버가 준 줄로 다시 엶 · 되돌리기 기록은 여기서 새로 */ const reopen = (next: EasyDoc): void => { doc = next; history = new EasyHistory(); history.push(doc); logics = null; fillStrip(); calc(); }; const doSave = async (): Promise => { if (doc.isBase || busy) return; const mine = (logics ?? []).filter((x) => !x.isBase && x.key !== doc.key).map((x) => x.name); const bad = checkName(doc.name, mine); name.setError(bad ?? undefined); if (bad) return void name.input.focus(); if (doc.cards.some(blocksSave)) return void (note.textContent = te("BadLines")); busy = true; saveButton.disabled = true; note.textContent = te("Saving"); try { const row = toRow(doc); const saved = doc.key && doc.version ? await editEasy(doc.key, doc.version, row) : await newEasy(row); reopen(toDoc(saved.row, { key: saved.key, version: saved.version, isBase: false })); note.textContent = ""; showToast(te("Saved"), "success"); } catch (error) { note.textContent = `${te("SaveFailed")} — ${plainError(error)}`; } finally { busy = false; saveButton.disabled = false; } }; const doCopy = async (): Promise => { if (!doc.key || busy) return; busy = true; note.textContent = te("Copying"); try { const saved = await copyEasy(doc.key); reopen(toDoc(saved.row, { key: saved.key, version: saved.version, isBase: false })); showToast(te("Copied"), "success"); } catch (error) { note.textContent = plainError(error); } finally { busy = false; } }; const doDelete = async (): Promise => { if (!doc.key || !doc.version || doc.isBase || busy) return; busy = true; deleteButton.disabled = true; note.textContent = te("Uses_Wait"); try { const uses = await usesOf(doc.key); const blocking = uses.filter((u) => u.key); if (blocking.length) { note.textContent = te("Uses_Block", { v: blocking.map((u) => (u.where ? `${u.where} ${u.name}` : u.name)).join(", "), }); return; } note.textContent = uses.map((u) => u.name).join(" · "); if (!(await showConfirmDialog(te("Delete_Ask", { v: doc.name }), te("Delete_Ok")))) { return; } await deleteEasy(doc.key, doc.version); logics = null; paper = null; showToast(te("Deleted"), "success"); void showStart(); } catch (error) { note.textContent = plainError(error); } finally { busy = false; deleteButton.disabled = false; } }; paper = { get history() { return history; }, save: doSave, undo: doUndo, }; body.replaceChildren( el("div", { className: "m01-easycalc__strip", children: [name.root, spec.root, unit.root, total, unsaved, actions], }), note, baseBar, trialHost, el("section", { className: "m01-easycalc__part", children: [el("h4", { text: te("Cards") }), cardsHost], }), el("details", { className: "m01-easycalc__part m01-easycalc__sheet", children: [el("summary", { text: te("Sheet") }), sheetHost], }), ); fillStrip(); calc(); if (!doc.isBase) name.input.focus(); }; // 화면 함수들을 다 만든 뒤에 엶 — mount 가 바로 showStart 를 부름 openModal({ title: te("Title"), closeLabel: te("CloseWindow"), dialogClass: "m01-easycalc", beforeClose: mayLeave, mount: (host) => { body = host; (host.parentElement ?? host).addEventListener("keydown", onKey); void showStart(); }, }); } function createBigButton(label: string, onClick: () => void): HTMLButtonElement { const button = createButton({ label, variant: "filled", onClick }); button.classList.add("m01-easycalc__big"); return button; }