/* ============================================================================= * M01_MasterData_UI_Logic_Pick.ts * 고르기 두 가지 * ① 요소 찾기 창 — 그룹 전체에서 이름·키·원문번호로 찾아 호표 줄에 넣음(`GET /elements`) * 표 고르기(소요량·계수)는 값칸 단추를 눌러 `찾기(…).칸` 으로 수량에 넣음 * ② 재료·기계 바꿔 고르기 — 구분 → 상세구분 → 후보 순으로 좁힘 * 바꾼 것은 시험 계산용 복사본에만 · 정본 로직은 그대로 * ========================================================================== */ import { createButton, createInputField, createSelectField, el, showToast, } from "@ui/ui_template_elements"; import { fetchMaterials, searchElements, searchPrice, type ElementBrief, type HoLine, type LogicOne, type LogicRow, } from "./M01_MasterData_UI_Logic_Api"; import { formatNumber, type PickDone, type PickMode } from "./M01_MasterData_UI_Logic_Edit"; import { tx } from "./M01_MasterData_UI_Logic_Text"; const GROUPS: Record = { element: ["인력", "재료", "기계", "로직"], table: ["소요량", "계수"], }; export function openPicker(mode: PickMode, group: string, done: PickDone): void { const groups = GROUPS[mode]; const groupField = createSelectField({ options: groups.map((g) => ({ value: g, text: g })), value: groups.includes(group) ? group : groups[0], compact: true, }); const select = groupField.select; const searchField = createInputField({ type: "search", placeholder: tx("Pick_Search") }); const search = searchField.input; const count = el("span", { className: "m01-logic__muted" }); const list = el("div", { className: "m01-logic__pick-list" }); const close = (): void => backdrop.remove(); const dialog = el("div", { className: "m01-logic__pick", attrs: { role: "dialog", "aria-label": tx("Pick_Title") }, children: [ el("div", { className: "m01-logic__section-head", children: [ el("h3", { text: tx("Pick_Title") }), createButton({ label: tx("Pick_Close"), variant: "ghost", onClick: close }), ], }), el("div", { className: "m01-logic__pick-bar", children: [groupField.root, searchField.root, count], }), list, ], }); const backdrop = el("div", { className: "m01-logic__backdrop", children: [dialog] }); backdrop.addEventListener("click", (ev) => ev.target === backdrop && close()); backdrop.addEventListener("keydown", (ev) => ev.key === "Escape" && close()); const choose = (item: ElementBrief, column?: string): void => { close(); done(item, column); }; let seq = 0; const load = async (): Promise => { const mine = ++seq; try { const found = await searchElements(select.value, search.value.trim()); if (mine !== seq) return; // 최신 응답만 count.textContent = tx("Pick_Total", { n: found.total }); list.replaceChildren(...found.items.map((item) => itemRow(item, mode, choose))); } catch (error) { showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error"); } }; let timer = 0; search.addEventListener("input", () => { window.clearTimeout(timer); timer = window.setTimeout(load, 250); }); select.addEventListener("change", load); document.body.append(backdrop); search.focus(); void load(); } function itemRow( item: ElementBrief, mode: PickMode, choose: (item: ElementBrief, column?: string) => void, ): HTMLElement { const text = el("div", { className: "m01-logic__stack", children: [ el("strong", { text: `${item.이름 ?? ""} ${item.규격 ?? ""}`.trim() }), el("span", { className: "m01-logic__muted", text: item.ref }), ], }); if (mode === "table") { const columns = Object.keys(item.값칸 ?? {}); const chips = columns.map((column) => createButton({ label: column, variant: "pill", onClick: () => choose(item, column) }), ); return el("div", { className: "m01-logic__pick-row", children: [text, el("div", { className: "m01-logic__chips", children: chips })], }); } const unit = typeof item.단위 === "string" ? item.단위 : ""; const value = typeof item.값 === "object" && item.값 !== null ? "{…}" : formatNumber(item.값); const row = el("button", { className: "m01-logic__pick-row m01-logic__pick-row--button", attrs: { type: "button" }, children: [ text, el("span", { className: "m01-logic__money", text: `${value} ${unit}`.trim() }), ], }); row.addEventListener("click", () => choose(item)); return row; } /* ── ② 재료·기계 바꿔 고르기 ─────────────────────────────────────────────── */ export type Swaps = Map; const PICKABLE = ["재료", "기계"]; /** 고를 수 있는 줄이 있는지 */ export const hasPickable = (logic: LogicRow): boolean => (logic.호표 ?? []).some((l) => PICKABLE.includes(l.종류)); /** 바꿔 본 줄이 있으면 고친 복사본 — 없으면 undefined(정본 그대로 셈) */ export function swappedRow(logic: LogicRow, swaps: Swaps): LogicRow | undefined { if (!swaps.size) return undefined; return { ...logic, 호표: (logic.호표 ?? []).map((l, i) => { const s = swaps.get(i); return s ? { ...l, 요소: s.ref, 이름: s.이름 ?? l.이름, 규격: s.규격 ?? l.규격 } : l; }), }; } type Brief = ElementBrief & { 구분?: string; 상세구분?: string; 값들?: Record; }; const distinct = (items: Brief[], key: "구분" | "상세구분"): string[] => [ ...new Set(items.map((i) => i[key]).filter((v): v is string => !!v)), ]; /** 후보 글 — 값 열이 여럿이면 있는 열을 다 보임(계산은 그 가운데 낮은 값) */ const label = (it: Brief): string => { const cols = Object.entries(it.값들 ?? {}) .filter(([, v]) => v !== null && v !== undefined) .map(([k, v]) => `${k} ${formatNumber(v)}`); const prices = cols.length ? cols : [formatNumber(it.값) || tx("Mat_NoPrice")]; return [`${it.이름 ?? ""} ${it.규격 ?? ""}`.trim(), ...prices].join(" · "); }; const oldNames = (one: LogicOne, line: HoLine): string[] => { const brief = one.prices[line.요소 as string] as { 이름?: string; 원문번호?: string } | null; const names = [brief?.이름, brief?.원문번호, (line.이름 ?? "").split(/[\s(·]/)[0]]; return [...new Set(names.filter((n): n is string => !!n))]; }; /** 이름을 차례로 찾아 후보가 나오는 첫 결과 — 다 비면 마지막 결과 */ async function firstFound(names: string[]): Promise>> { let last = await searchPrice(names[0] ?? ""); for (const name of names.slice(1)) { if (last.items.length) break; last = await searchPrice(name); } return last; } function swapRow(one: LogicOne, line: HoLine, i: number, swaps: Swaps, onChange: () => void) { const cond = typeof line.요소 === "object" ? (line.요소 as unknown as Record) : null; const price = cond ? undefined : one.prices[line.요소]?.값; let all: Brief[] = []; let sub = ""; let detail = String(cond?.["상세구분"] ?? ""); const subBox = createSelectField({ options: [], value: "", compact: true, onChange: (v) => ((sub = v), (detail = ""), fill()), }); const detailBox = createSelectField({ options: [], value: "", compact: true, onChange: (v) => ((detail = v), fill()), }); const itemBox = createSelectField({ options: [{ value: "", text: tx("Mat_Default") }], value: "", onChange: (ref) => { const item = all.find((it) => it.ref === ref); if (item) swaps.set(i, item); else swaps.delete(i); onChange(); }, }); const subRow = el("div", { className: "m01-test__filters", children: [subBox.root, detailBox.root], }); // 구분 → 상세구분 → 후보 — 앞 단을 고르면 뒷 단 목록이 좁혀짐(구분 칸이 없는 후보면 그 단은 숨김) const fill = (): void => { const subs = distinct(all, "구분"); const inSub = all.filter((it) => !sub || it.구분 === sub); const details = distinct(inSub, "상세구분"); const inDetail = inSub.filter((it) => !detail || it.상세구분 === detail); const all_ = { value: "", text: tx("Pick_All") }; subBox.root.hidden = subs.length < 1; detailBox.root.hidden = details.length < 1; subBox.setOptions([all_, ...subs.map((v) => ({ value: v, text: v }))], sub); detailBox.setOptions([all_, ...details.map((v) => ({ value: v, text: v }))], detail); itemBox.setOptions( [ { value: "", text: tx("Mat_Default") }, ...inDetail.map((it) => ({ value: it.ref, text: label(it) })), ], swaps.get(i)?.ref ?? "", ); }; // 고르기 조건({구분, 상세구분, 규격}) 줄 = 조건 안 후보 · 옛 줄 = 이름으로 찾은 후보 const found = cond ? fetchMaterials({ sub: String(cond["구분"] ?? ""), spec: String(cond["규격"] ?? ""), region: "울진", }) : line.종류 === "재료" ? firstFound(oldNames(one, line)) // 옛 줄(고르기 조건 없음) — 품셈재료 이름 → 원문번호 → 이름 첫 낱말 순으로 찾아 나온 첫 것 : searchElements(line.종류, line.이름 ?? ""); void found .then((r) => { all = r.items as Brief[]; if (cond) sub = String(cond["구분"] ?? ""); fill(); }) .catch(() => undefined); const spec = line.규격 ? ` ${line.규격}` : ""; const cost = price !== undefined && price !== null ? ` · ${tx("Mat_Price")} ${formatNumber(price)}` : ""; return el("div", { className: "m01-test__pick", children: [ el("strong", { text: `${line.종류} · ${line.이름 ?? String(line.요소)}${spec}` }), el("span", { className: "m01c__muted", text: `${line.단위 ?? ""}${cost}` }), subRow, itemBox.root, ], }); } /** 재료·기계 줄마다 한 칸씩 — 한 번 만들어 두고 다시 그릴 때 같은 노드를 씀(고른 것이 안 날아감) */ export function pickPanel(one: LogicOne, swaps: Swaps, onChange: () => void): HTMLElement { const rows = (one.logic.호표 ?? []) .map((line, i) => ({ line, i })) .filter((x) => PICKABLE.includes(x.line.종류)) .map((x) => swapRow(one, x.line, x.i, swaps, onChange)); return el("div", { className: "m01-test__picks", children: rows.length ? rows : [el("p", { text: tx("Mat_None") })], }); }