diff --git a/M01_MasterData/EasyCalc/M01_EasyCalc_Pick.css b/M01_MasterData/EasyCalc/M01_EasyCalc_Pick.css new file mode 100644 index 000000000..3c1b40947 --- /dev/null +++ b/M01_MasterData/EasyCalc/M01_EasyCalc_Pick.css @@ -0,0 +1,100 @@ +.m01-easypick { + --modal-max: 86vh; + width: min(760px, 94vw); + font-size: var(--text-body-lg, 1.125rem); +} +.m01-easypick__ask { + margin: 0; + font-size: 1.35em; + color: var(--color-text); +} +.m01-easypick__muted { + color: var(--color-text-muted); + font-size: var(--text-body-sm); +} +.m01-easypick__kinds { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: var(--spacing-12); +} +.m01-easypick__kind, +.m01-easypick__row { + display: flex; + gap: var(--spacing-8); + padding: var(--spacing-12) var(--spacing-16); + border: 2px solid var(--color-border); + border-radius: var(--radius-cards); + background: var(--color-surface); + color: var(--color-text); + font: inherit; + text-align: left; + cursor: pointer; +} +.m01-easypick__kind { + flex-direction: column; + min-height: 96px; +} +.m01-easypick__kind strong { + font-size: 1.3em; +} +.m01-easypick__kind span { + color: var(--color-text-secondary); + font-size: var(--text-body-sm); +} +.m01-easypick__kind:hover, +.m01-easypick__row:hover, +.m01-easypick__kind:focus-visible, +.m01-easypick__row:focus-visible { + border-color: var(--color-accent); + outline: 3px solid var(--color-accent); + outline-offset: 1px; +} +.m01-easypick__bar { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-8); + align-items: center; +} +.m01-easypick__bar > :last-child { + flex: 1 1 200px; +} +.m01-easypick__list { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + max-height: 46vh; + overflow: auto; +} +.m01-easypick__row { + align-items: center; + justify-content: space-between; + min-height: 52px; +} +.m01-easypick__name { + flex: 1 1 auto; + min-width: 0; +} +.m01-easypick__unit, +.m01-easypick__price { + flex: none; + color: var(--color-text-secondary); + font-size: var(--text-body-sm); +} +.m01-easypick__tag { + margin-left: var(--spacing-8); + padding: 0 var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-pills); + color: var(--color-text-secondary); + font-size: var(--text-caption); +} +.m01-easypick__chosen { + margin: 0; + font-size: 1.15em; +} +.m01-easypick__rough { + min-height: 1.4em; + margin: 0; + color: var(--color-accent); + font-weight: 600; +} diff --git a/M01_MasterData/EasyCalc/M01_EasyCalc_Pick.ts b/M01_MasterData/EasyCalc/M01_EasyCalc_Pick.ts new file mode 100644 index 000000000..e08cb5e85 --- /dev/null +++ b/M01_MasterData/EasyCalc/M01_EasyCalc_Pick.ts @@ -0,0 +1,456 @@ +/* ============================================================================= + * 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 { fetchMaterials } from "../M01_MasterData_UI_Logic_Api"; +import { listEasy, listMachines, machineElement } from "./M01_EasyCalc_Api"; +import type { PickResult } from "./M01_EasyCalc_Model"; +import "./M01_EasyCalc_Pick.css"; + +type Kind = PickResult["kind"]; + +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 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>; +} + +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: r.items + .filter((it) => !f.sub || (it as { 구분?: string }).구분 === f.sub) + .map((it) => ({ ...it, 구분: (it as { 구분?: string }).구분, 상세구분: undefined })), + })) + : await fetchMaterials({ sub: f.sub, detail: f.detail || undefined }).then((r) => ({ + total: r.total, + items: r.items.map((it) => ({ ...it, 구분: it.구분 ?? 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 { 상세구분?: string }).상세구분 ?? f.detail; + 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 () => ({ + kind: "logic", + name: e.name, + spec: "", + unit, + element: `로직(${e.key})`, + }), + }; + }), + }; + }, + }, +}; + +/** 물음 셋 — 고른 것 · 취소는 null */ +export function openEasyPick(kind?: Kind): 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") }), + ); + count.textContent = te("Count", { n: found.total, m: found.items.length }); + list.replaceChildren( + ...(found.items.length + ? found.items.map((c) => + rowOf(c, () => stepQty(k, c, back ? () => stepList(k, canBack) : null)), + ) + : [el("p", { className: "m01-easypick__muted", text: te("None") })]), + ); + } catch { + if (mine === seq) showToast(te("Failed"), "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"; + 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")); + done({ ...(await c.make()), qty: n }); + }; + 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]; +}