diff --git a/M01_MasterData/EasyCalc/M01_EasyCalc_Cards.ts b/M01_MasterData/EasyCalc/M01_EasyCalc_Cards.ts index 666bb13a9..7c9efb7b9 100644 --- a/M01_MasterData/EasyCalc/M01_EasyCalc_Cards.ts +++ b/M01_MasterData/EasyCalc/M01_EasyCalc_Cards.ts @@ -173,13 +173,14 @@ async function repick(ctx: Ctx, card: EasyCard): Promise { if (picking || card.kind === "locked" || card.kind === "percent") return; picking = true; try { - const pick = await openEasyPick(card.kind); + const pick = await openEasyPick(card.kind, { qty: card.qty }); if (!pick) return; const next = updateCard(ctx.doc, card.id, { name: pick.name, spec: pick.spec, unit: pick.unit, element: pick.element, + qty: pick.qty, }); ctx.on.change(withInputs(next, pick.needInputs ?? [])); } finally { diff --git a/M01_MasterData/EasyCalc/M01_EasyCalc_Entry.ts b/M01_MasterData/EasyCalc/M01_EasyCalc_Entry.ts index 5b052cb8f..79366262c 100644 --- a/M01_MasterData/EasyCalc/M01_EasyCalc_Entry.ts +++ b/M01_MasterData/EasyCalc/M01_EasyCalc_Entry.ts @@ -1,8 +1,10 @@ /* ============================================================================= * M01_EasyCalc_Entry.ts - * 쉬운 단가산출 시험 길 — 들어가는 단추 · 큰 창 틀 · 시작 화면 · 고치는 화면 틀(위 띠 · 자리들) + * 쉬운 단가산출 시험 길 — 들어가는 단추 · 큰 창 틀 · 시작 화면 · 고치는 화면(위 띠) · 모든 파일 잇기 * 모양은 `docs/raw/M01_쉬운단가/설계.md` · 이 폴더째 지워도 됨(폴더 밖 참조는 LogicLab.ts 두 줄). - * 1걸음 = 틀만 — 카드 · 설계 때 정하는 값 · 실무 일위대가표 · 저장 · 지우기 · 되돌리기는 2걸음에 이음. + * 잇기: 열기(openEasy) → toDoc → renderCards · renderSheet · renderTrialStrip → 바뀔 때마다 + * EasyHistory.push · calcLater → 저장(새로 newEasy · 내 것 editEasy · 품셈 기본은 copyEasy 뒤 그 복사본) + * · 지우기(usesOf 먼저 · 물음 한 번) · Ctrl+Z · Ctrl+S * ========================================================================== */ import { currentLanguageIndex } from "@ui/ui_template_locale"; @@ -16,15 +18,32 @@ import { 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 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, - type EasyOpened, } from "./M01_EasyCalc_Api"; -import type { EasyDoc } from "./M01_EasyCalc_Model"; +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 = { @@ -42,10 +61,8 @@ const TEXT = { 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}"], + CannotOpen: ["쉬운 길로 못 엶", "Not for the easy view"], Name: ["이름", "Name"], NamePlaceholder: ["예: 거푸집 설치", "e.g. formwork setup"], Spec: ["크기", "Size"], @@ -55,15 +72,27 @@ const TEXT = { 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"], - Trial: ["설계 때 정하는 값", "Values set at design time"], + 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"], - NotYet: ["준비 중", "Coming soon"], + 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?", @@ -82,68 +111,6 @@ const te = (key: TextKey, vars: Record = {}): 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」 은 그대로 */ @@ -152,50 +119,82 @@ const amountOf = (resultUnit: string): string => { 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: EasyDoc | null = null; - let dirty = false; + /** 고치는 화면이 열려 있을 때만 */ + let paper: { history: EasyHistory; save: () => Promise; undo: () => void } | null = null; let logics: EasyListItem[] | null = null; let query = ""; /** 저장 안 한 것이 있으면 한 번 물음 — 닫아도 되면 true */ const mayLeave = async (): Promise => - !paper || !dirty || showConfirmDialog(te("Leave_Ask"), te("Leave_Ok")); + !paper?.history.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)) 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 ok = item.canOpen; const row = el("button", { className: "m01-easycalc__item", - attrs: { type: "button" }, + 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: ok ? te("Per", { u: amountOf(item.resultUnit) }) : item.resultUnit, + text: item.canOpen ? te("Per", { u: amountOf(item.resultUnit) }) : item.resultUnit, }), - ...(ok ? [] : [createTag(item.whyNot ?? te("CannotOpen"), "warning")]), + ...(item.canOpen ? [] : [createTag(te("CannotOpen"), "warning")]), ], }); - row.disabled = !ok; - row.title = item.where; + 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; - 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" }); @@ -252,9 +251,9 @@ function openEasyCalc(): void { search.input.focus(); try { - logics ??= await loadList(); + logics ??= await listEasy(""); } catch (error) { - status.textContent = te("LoadFailed", { v: plain(error) }); + status.textContent = te("LoadFailed", { v: plainError(error) }); return; } const mine = logics.filter((x) => !x.isBase); @@ -266,85 +265,73 @@ function openEasyCalc(): void { 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); + return [...count.entries()] + .filter(([u, n]) => n >= 5 && !/[\s()「·]/.test(u)) + .sort((a, b) => b[1] - a[1]) + .map(([u]) => u); }; - const showPaper = (): void => { - const p = paper; - if (!p) return; - const readOnly = p.isBase; + /* ---------- 고치는 화면 ---------- */ + + 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"); - 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 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"), - value: p.name, - onInput: (v) => { - p.name = v; - touched(); - }, + onInput: (v) => edit({ ...doc, name: v }), }); - 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); + 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: units.map((u) => ({ value: u, text: unitOf(u) })), - value: p.resultUnit, - disabled: readOnly, - onChange: (v) => { - p.resultUnit = v; - drawTotal(); - touched(); - }, + 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(), }); - 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", @@ -353,62 +340,210 @@ function openEasyCalc(): void { 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", + const actions = el("div", { className: "m01-easycalc__actions" }); + const baseBar = el("div", { + className: "m01-easycalc__base", children: [ - el("summary", { text: te("Sheet") }), - el("p", { className: "m01-easycalc__muted", text: te("NotYet") }), + el("p", { text: te("BaseNote") }), + createBigButton(te("BaseCopy"), () => void doCopy()), ], }); - drawTotal(); + /** 위 띠 칸을 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, - el("div", { className: "m01-easycalc__actions", children: actions }), - ], + 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], }), - ...(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(); + fillStrip(); + calc(); + if (!doc.isBase) name.input.focus(); }; // 화면 함수들을 다 만든 뒤에 엶 — mount 가 바로 showStart 를 부름 @@ -419,6 +554,7 @@ function openEasyCalc(): void { beforeClose: mayLeave, mount: (host) => { body = host; + (host.parentElement ?? host).addEventListener("keydown", onKey); void showStart(); }, }); diff --git a/M01_MasterData/EasyCalc/M01_EasyCalc_Pick.ts b/M01_MasterData/EasyCalc/M01_EasyCalc_Pick.ts index 570e67dda..04d8a375d 100644 --- a/M01_MasterData/EasyCalc/M01_EasyCalc_Pick.ts +++ b/M01_MasterData/EasyCalc/M01_EasyCalc_Pick.ts @@ -15,12 +15,14 @@ import { } from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; import { fetchPick, fetchSubs, type SubInfo } from "../M01_MasterData_Api_Fetch"; -import { fetchMaterials } from "../M01_MasterData_UI_Logic_Api"; +import { fetchLogic, fetchMaterials, type ElementBrief } from "../M01_MasterData_UI_Logic_Api"; import { listEasy, listMachines, machineElement, plainError } from "./M01_EasyCalc_Api"; import type { PickResult } from "./M01_EasyCalc_Model"; import "./M01_EasyCalc_Pick.css"; type Kind = PickResult["kind"]; +/** 후보 줄 — 서버는 구분 · 상세구분도 주지만 ElementBrief 타입에는 없음 */ +type Brief = ElementBrief & { 구분?: string; 상세구분?: string }; const TEXT = { Title: ["넣을 것 고르기", "Pick what to add"], @@ -96,6 +98,23 @@ interface Source { load: (f: Filter) => Promise<{ total: number; items: Candidate[] } | null>; } +/** 찾는 말과 이름이 똑같으면 0 · 그 말로 시작하고 말이 거기서 끝나면(「모래, 서울」 「모래(건조)」) 1 · + * 더 이어지면(「모래적사장」) 2 · 나머지 3 — 앞에 놓음(같은 차례끼리 순서는 그대로) */ +const byName = (items: T[], q: string): T[] => { + const squash = (t: string): string => t.replace(/\s+/g, "").toLowerCase(); + const w = squash(q); + const rank = (it: T): number => { + const n = squash(it.이름 ?? ""); + if (n === w) return 0; + if (!n.startsWith(w)) return 3; + return /[가-힣a-z0-9]/.test(n.charAt(w.length)) ? 2 : 1; + }; + return items + .map((it, i) => ({ it, i, r: rank(it) })) + .sort((a, b) => a.r - b.r || a.i - b.i) + .map((x) => x.it); +}; + const num = (v: unknown): number | null => (typeof v === "number" && Number.isFinite(v) ? v : null); const SOURCES: Partial> = { @@ -135,14 +154,14 @@ const SOURCES: Partial> = { const found = f.q ? await fetchPick("price", f.q).then((r) => ({ total: r.total, - items: r.items - .filter((it) => !f.sub || (it as { 구분?: string }).구분 === f.sub) - .map((it) => ({ ...it, 구분: (it as { 구분?: string }).구분, 상세구분: undefined })), + items: byName(r.items, f.q) + .filter((it) => !f.sub || (it as Brief).구분 === f.sub) + .map((it) => ({ ...it, 구분: (it as Brief).구분 })), })) : await fetchMaterials({ sub: f.sub, ...(f.detail ? { detail: f.detail } : {}) }).then( (r) => ({ total: r.total, - items: r.items.map((it) => ({ ...it, 구분: it.구분 ?? f.sub })), + items: r.items.map((it) => ({ ...it, 구분: (it as Brief).구분 ?? f.sub })), }), ); return { @@ -153,7 +172,7 @@ const SOURCES: Partial> = { const unit = typeof it.단위 === "string" ? it.단위 : ""; const price = num(it.값); const element: Record = { 구분: it.구분 ?? f.sub }; - const detail = (it as { 상세구분?: string }).상세구분; + const detail = (it as Brief).상세구분; if (detail) element["상세구분"] = detail; element["검색어"] = `${name} ${spec}`.trim(); return { @@ -215,13 +234,20 @@ const SOURCES: Partial> = { price: null, priceText: "", tag: e.owner, - make: async () => ({ - kind: "logic", - name: e.name, - spec: "", - unit, - element: `로직(${e.key})`, - }), + make: async () => { + // 그 일위대가가 받는 값은 같은 이름으로 넘기고 「설계 때 정하는 값」으로 돌려줌(기계와 같은 꼴) + const needInputs = (await fetchLogic(e.key)).logic.입력; + const quote = (n: string): string => (/^[\w가-힣]+$/.test(n) ? n : `'${n}'`); + const rest = needInputs.map((i) => `, ${quote(i.이름)}=${quote(i.이름)}`).join(""); + return { + kind: "logic", + name: e.name, + spec: "", + unit, + element: `로직(${e.key}${rest})`, + needInputs, + }; + }, }; }), }; @@ -230,7 +256,10 @@ const SOURCES: Partial> = { }; /** 물음 셋 — 고른 것 · 취소는 null */ -export function openEasyPick(kind?: Kind): Promise { +export function openEasyPick( + kind?: Kind, + opts: { qty?: number | null } = {}, +): Promise { return new Promise((resolve) => { let settled = false; const finish = (r: PickResult | null): void => { @@ -337,6 +366,7 @@ export function openEasyPick(kind?: Kind): Promise { placeholder: te("QtyPlace"), }); qty.input.inputMode = "decimal"; + if (opts.qty != null) qty.input.value = String(opts.qty); // 다시 고르기 — 지금 수량 미리 채움 const rough = el("p", { className: "m01-easypick__rough" }); const parse = (): number | null => { const n = Number(qty.input.value.trim().replace(/,/g, "")); diff --git a/M01_MasterData/EasyCalc/M01_EasyCalc_Style.css b/M01_MasterData/EasyCalc/M01_EasyCalc_Style.css index e23ed19f7..5f98a5da2 100644 --- a/M01_MasterData/EasyCalc/M01_EasyCalc_Style.css +++ b/M01_MasterData/EasyCalc/M01_EasyCalc_Style.css @@ -157,3 +157,19 @@ cursor: pointer; font-weight: 600; } + +/* 고친 뒤 금액을 다시 셈하는 동안 옛 금액을 흐리게 */ +.m01-easycalc .is-pending { + opacity: 0.45; +} + +.m01-easycalc__note { + margin: 0; + color: var(--color-text-secondary); + font-size: var(--text-body-sm); + white-space: pre-line; +} + +.m01-easycalc__note:empty { + display: none; +}