/* ============================================================================= * M01_EasyCalc_Pick.ts * 쉬운 단가산출 고르기 창 — 물음 셋(① 무엇 → ② 어떤 것 → ③ 얼마) · 한 화면 한 물음. * 돌려주는 PickResult.element = 호표 「요소」 칸에 들어갈 값 그대로. 모양은 설계.md 3절. * 글 [한, 영] 짝은 아래 TEXT. 이 폴더째 지워도 됨. * ========================================================================== */ import { currentLanguageIndex } from "@ui/ui_template_locale"; import { createButton, createInputField, createSelectField, el, showToast, } from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; import { fetchPick, fetchSubs, type SubInfo } from "../M01_MasterData_Api_Fetch"; 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"], Close: ["닫기", "Close"], Back: ["← 뒤로", "← Back"], AskKind: ["무엇을 넣을까요?", "What do you want to add?"], AskWhich: ["어떤 것인가요?", "Which one?"], AskQty: ["얼마나 쓰나요?", "How much?"], Person: ["사람", "People"], Material: ["재료", "Material"], Machine: ["기계", "Machine"], Logic: ["다른 일위대가", "Another unit-price job"], PersonDesc: ["일하는 사람 — 석공 · 보통인부", "Workers — mason, laborer"], MaterialDesc: ["쓰는 물건 — 돌 · 시멘트 · 관", "Things used — stone, cement, pipe"], MachineDesc: ["쓰는 기계 — 굴삭기 · 덤프트럭", "Machines used — excavator, truck"], LogicDesc: ["이미 만들어 둔 일을 가져다 씀", "Reuse a job already made"], Search: ["이름을 쳐서 찾기", "Type a name to search"], AllSub: ["전체", "All"], SubLabel: ["구분", "Group"], DetailLabel: ["상세구분", "Detail"], Hint: ["구분을 고르거나 이름을 쳐 보세요", "Choose a group or type a name"], Loading: ["찾는 중…", "Searching…"], None: ["찾는 것 없음", "Nothing found"], Count: ["{n} 개 중 앞 {m} 개", "First {m} of {n}"], NoPrice: ["단가 없음", "No price"], PerDay: ["하루 {p} 원", "{p} won per day"], PerUnit: ["{u} 당 {p} 원", "{p} won per {u}"], Picked: ["고른 것", "Chosen"], QtyPlace: ["예: 0.072", "e.g. 0.072"], QtyLabel: ["{u} (숫자)", "{u} (number)"], Rough: ["금액 어림 약 {m} 원", "About {m} won"], Add: ["넣기", "Add"], QtyBad: ["0 보다 큰 숫자를 쳐 주세요", "Enter a number above 0"], Failed: ["불러오지 못함", "Could not load"], } as const; const te = (key: keyof typeof TEXT, vars: Record = {}): string => Object.entries(vars).reduce( (text, [k, v]) => text.replace(`{${k}}`, String(v)), TEXT[key][currentLanguageIndex] as string, ); const won = (n: number): string => Math.round(n).toLocaleString("ko-KR"); /** 목록에 그릴 줄 수 — 더 찾으려면 이름을 쳐서 좁힘 */ const SHOW = 100; const MAT_FILE = "재료_자재품목.json"; /** 목록 한 줄 — 고르면 PickResult(수량 빼고) 를 만들어 줌 */ interface Candidate { name: string; spec: string; unit: string; /** 줄 단위 1 당 단가 · 모르면 null — 금액 어림용 */ price: number | null; /** 줄 오른쪽 글(단가 표시) */ priceText: string; tag?: string; make: () => Promise>; } interface Filter { q: string; sub: string; detail: string; } interface Source { title: string; desc: string; /** 구분 · 상세구분 칸을 보일지(재료만) */ grouped?: boolean; /** 이름 · 구분이 비면 목록을 안 부르고 null(안내 글만) */ 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> = { person: { title: te("Person"), desc: te("PersonDesc"), load: async (f) => { const { total, items } = await fetchPick("job", f.q); return { total, items: items.map((it) => { const price = num(it.값); return { name: it.이름, spec: it.규격, unit: "인", price, priceText: price === null ? te("NoPrice") : te("PerDay", { p: won(price) }), make: async () => ({ kind: "person", name: it.이름, spec: it.규격, unit: "인", element: it.ref, }), }; }), }; }, }, material: { title: te("Material"), desc: te("MaterialDesc"), grouped: true, load: async (f) => { if (!f.q && !f.sub) return null; const found = f.q ? await fetchPick("price", f.q).then((r) => ({ total: r.total, 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 as Brief).구분 ?? f.sub })), }), ); return { total: found.total, items: found.items.map((it) => { const name = it.이름 ?? ""; const spec = it.규격 ?? ""; const unit = typeof it.단위 === "string" ? it.단위 : ""; const price = num(it.값); const element: Record = { 구분: it.구분 ?? f.sub }; const detail = (it as Brief).상세구분; if (detail) element["상세구분"] = detail; element["검색어"] = `${name} ${spec}`.trim(); return { name, spec, unit, price, priceText: price === null ? te("NoPrice") : te("PerUnit", { u: unit || "1", p: won(price) }), tag: element["구분"], make: async () => ({ kind: "material", name, spec, unit, element }), }; }), }; }, }, machine: { title: te("Machine"), desc: te("MachineDesc"), load: async (f) => { const items = await listMachines(f.q); return { total: items.length, items: items.map((m) => ({ name: m.이름, spec: m.규격, unit: m.단위, price: null, priceText: "", make: async () => { const { element, needInputs } = await machineElement(m.분류번호); return { kind: "machine", name: m.이름, spec: m.규격, unit: m.단위, element, needInputs, }; }, })), }; }, }, logic: { title: te("Logic"), desc: te("LogicDesc"), load: async (f) => { // 돈으로 나오는 일위대가만 — 「원/㎡」 꼴 const items = (await listEasy(f.q)).filter((e) => e.resultUnit.startsWith("원/")); return { total: items.length, items: items.map((e) => { const unit = e.resultUnit.slice(2); return { name: e.name, spec: "", unit, price: null, priceText: "", tag: e.owner, 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, }; }, }; }), }; }, }, }; /** 물음 셋 — 고른 것 · 취소는 null */ export function openEasyPick( kind?: Kind, opts: { qty?: number | null } = {}, ): Promise { return new Promise((resolve) => { let settled = false; const finish = (r: PickResult | null): void => { if (!settled) resolve(r); settled = true; }; openModal({ title: te("Title"), closeLabel: te("Close"), dialogClass: "m01-easypick", beforeClose: () => (finish(null), true), mount: (body, close) => { const done = (r: PickResult): void => (finish(r), close()); const stepKinds = (): void => { body.replaceChildren( el("h4", { className: "m01-easypick__ask", text: te("AskKind") }), kindButtons(), ); body.querySelector("button")?.focus(); }; const kindButtons = (): HTMLElement => { const order: Kind[] = ["person", "material", "machine", "logic"]; const grid = el("div", { className: "m01-easypick__kinds" }); order.forEach((k, i) => { const src = SOURCES[k]; const b = el("button", { className: "m01-easypick__kind", attrs: { type: "button", "data-kind": k, "aria-keyshortcuts": String(i + 1) }, children: [ el("strong", { text: src?.title ?? labelOf(k) }), el("span", { text: src?.desc ?? descOf(k) }), ], }); b.addEventListener("click", () => stepList(k, true)); grid.append(b); }); grid.addEventListener("keydown", (ev) => { const k = order[Number(ev.key) - 1]; if (k) stepList(k, true); }); return grid; }; const stepList = (k: Kind, canBack: boolean): void => { const src = SOURCES[k]; if (!src) return; const back = canBack ? stepKinds : null; const search = createInputField({ type: "search", placeholder: te("Search") }); const filter: Filter = { q: "", sub: "", detail: "" }; const list = el("div", { className: "m01-easypick__list", attrs: { role: "listbox" } }); const count = el("span", { className: "m01-easypick__muted" }); const bar: HTMLElement[] = [search.root]; let seq = 0; const load = async (): Promise => { const mine = ++seq; count.textContent = ""; list.replaceChildren( el("p", { className: "m01-easypick__muted", text: te("Loading") }), ); try { const found = await src.load(filter); if (mine !== seq) return; // 최신 응답만 if (!found) return void list.replaceChildren( el("p", { className: "m01-easypick__muted", text: te("Hint") }), ); const shown = found.items.slice(0, SHOW); count.textContent = te("Count", { n: found.total, m: shown.length }); list.replaceChildren( ...(shown.length ? shown.map((c) => rowOf(c, () => stepQty(k, c, back ? () => stepList(k, canBack) : null)), ) : [el("p", { className: "m01-easypick__muted", text: te("None") })]), ); } catch (e) { if (mine === seq) showToast(plainError(e), "error"); } }; if (src.grouped) bar.unshift(...subFilters(filter, load)); let timer = 0; search.input.addEventListener("input", () => { filter.q = search.input.value.trim(); window.clearTimeout(timer); timer = window.setTimeout(load, 250); }); body.replaceChildren( ...(back ? [createButton({ label: te("Back"), variant: "ghost", onClick: back })] : []), el("h4", { className: "m01-easypick__ask", text: `${src.title} — ${te("AskWhich")}` }), el("div", { className: "m01-easypick__bar", children: bar }), count, list, ); body.onkeydown = (ev) => { if (back && ev.altKey && ev.key === "ArrowLeft") back(); else if (ev.key === "ArrowDown" || ev.key === "ArrowUp") moveFocus(body, ev); }; search.input.focus(); void load(); }; const stepQty = (k: Kind, c: Candidate, back: (() => void) | null): void => { const qty = createInputField({ type: "text", label: te("QtyLabel", { u: c.unit || "—" }), 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, "")); return qty.input.value.trim() !== "" && Number.isFinite(n) && n > 0 ? n : null; }; const refresh = (): void => { const n = parse(); rough.textContent = n !== null && c.price !== null ? te("Rough", { m: won(n * c.price) }) : ""; qty.setError(undefined); }; const submit = async (): Promise => { const n = parse(); if (n === null) return qty.setError(te("QtyBad")); try { done({ ...(await c.make()), qty: n }); } catch (e) { qty.setError(plainError(e)); } }; qty.input.addEventListener("input", refresh); qty.input.addEventListener("keydown", (ev) => ev.key === "Enter" && void submit()); body.replaceChildren( ...(back ? [createButton({ label: te("Back"), variant: "ghost", onClick: back })] : []), el("h4", { className: "m01-easypick__ask", text: te("AskQty") }), el("p", { className: "m01-easypick__chosen", children: [ el("span", { className: "m01-easypick__muted", text: te("Picked") }), el("strong", { text: ` ${c.name} ${c.spec}`.trimEnd() }), ], }), qty.root, rough, createButton({ label: te("Add"), variant: "filled", onClick: () => void submit() }), ); body.onkeydown = (ev) => ev.altKey && ev.key === "ArrowLeft" && back?.(); qty.input.focus(); void k; }; if (kind) stepList(kind, false); else stepKinds(); }, }); }); } const labelOf = (k: Kind): string => te(({ person: "Person", material: "Material", machine: "Machine", logic: "Logic" } as const)[k]); const descOf = (k: Kind): string => te( ( { person: "PersonDesc", material: "MaterialDesc", machine: "MachineDesc", logic: "LogicDesc", } as const )[k], ); /** 목록 줄 — 이름 · 크기 · 단위 · 단가 */ function rowOf(c: Candidate, onPick: () => void): HTMLElement { const b = el("button", { className: "m01-easypick__row", attrs: { type: "button", role: "option" }, children: [ el("span", { className: "m01-easypick__name", children: [ el("strong", { text: c.name }), ...(c.spec ? [el("span", { className: "m01-easypick__muted", text: ` ${c.spec}` })] : []), ...(c.tag ? [el("span", { className: "m01-easypick__tag", text: c.tag })] : []), ], }), el("span", { className: "m01-easypick__unit", text: c.unit }), el("span", { className: "m01-easypick__price", text: c.priceText }), ], }); b.addEventListener("click", onPick); return b; } /** 위·아래 화살표로 찾기 칸 ↔ 줄 사이 오감 */ function moveFocus(body: HTMLElement, ev: KeyboardEvent): void { const items = [...body.querySelectorAll("input[type=search], .m01-easypick__row")]; const at = items.indexOf(document.activeElement as HTMLElement); if (at < 0) return; const next = items[at + (ev.key === "ArrowDown" ? 1 : -1)]; if (!next) return; ev.preventDefault(); next.focus(); } /** 재료 구분 → 상세구분 고르기 칸 둘 — 바꾸면 load 다시 */ function subFilters(filter: Filter, load: () => Promise): HTMLElement[] { let subs: SubInfo[] = []; const all = { value: "", text: te("AllSub") }; const subBox = createSelectField({ options: [all], value: "", compact: true, ariaLabel: te("SubLabel"), onChange: (v) => ((filter.sub = v), (filter.detail = ""), fillDetail(), void load()), }); const detailBox = createSelectField({ options: [all], value: "", compact: true, ariaLabel: te("DetailLabel"), onChange: (v) => ((filter.detail = v), void load()), }); const fillDetail = (): void => { const details = subs.find((s) => s.name === filter.sub)?.details ?? []; detailBox.root.hidden = !details.length; detailBox.setOptions([all, ...details.map((d) => ({ value: d, text: d }))], ""); }; fillDetail(); void fetchSubs({ file: MAT_FILE }) .then((r) => { subs = r; subBox.setOptions([all, ...r.map((s) => ({ value: s.name, text: s.name }))], filter.sub); }) .catch(() => showToast(te("Failed"), "error")); return [subBox.root, detailBox.root]; }