/* ============================================================================= * M01_MasterData_UI_Matset.ts * 자재 조합 화면 — 왼쪽 컨테이너(찾기) / 오른쪽 목록 · 상세 (일위대가 조합 화면과 같은 틀) * 상세 = 이름 · 비고 + 본뜬키 줄 + 담은 자재 표(코드 · 이름 · 규격 · 단위 · 빼기) + [자재 더하기] * 저장 · 지우기는 서버 자재 조합 마스터(`자재조합.json` · 키 UM) — 길은 `Matset_Api` 한 곳 * ========================================================================== */ import { createButton, createInputField, el, showConfirmDialog, showToast, } from "@ui/ui_template_elements"; import { searchMaterials } from "./M01_MasterData_Api_Fetch"; import { ApiError } from "./M01_MasterData_UI_Logic_Api"; import { deleteMatset, editMatset, fetchMatset, fetchMatsets, newMatset, type MatItem, type Matset, type MatsetSummary, } from "./M01_MasterData_UI_Matset_Api"; import { tm } from "./M01_MasterData_UI_Matset_Text"; import type { SideHandle } from "./M01_MasterData_UI_Side"; import "./M01_MasterData_UI_Logic_Style.css"; import "./M01_MasterData_UI_LogicLab_Style.css"; const blank = (): Matset => ({ 키: "", 이름: "", 담은자재: [], 본뜬키: null, 소유: "현장", 비고: null, }); /** 서버가 준 까닭 — 422 · 409(지우기 막음) 는 글 목록 · 409 낡은 판본은 stale */ function reason(error: unknown): string { if (error instanceof ApiError && typeof error.detail === "object" && error.detail) { const d = error.detail as { errors?: string[]; stale?: string[] }; if (d.errors) return d.errors.join(" / "); if (d.stale) return tm("Stale"); } return error instanceof Error ? error.message : tm("Load_Failed"); } const fail = (error: unknown): void => showToast(error instanceof Error ? error.message : tm("Load_Failed"), "error"); /** [자재 더하기] 창 — 낱말로 자재DB 를 찾아 하나씩 고름 */ function openMaterialPicker(have: Set, done: (item: MatItem) => void): void { const search = createInputField({ type: "search", placeholder: tm("Pick_Search") }); const list = el("div", { className: "m01-logic__pick-list" }); const hint = el("p", { className: "m01-logic__muted", text: tm("Pick_Hint") }); const close = (): void => backdrop.remove(); const find = async (): Promise => { try { const found = await searchMaterials(search.input.value.trim()); hint.textContent = found.length ? String(found.length) : tm("Pick_None"); list.replaceChildren( ...found.map((x) => { const taken = have.has(x.코드); const row = el("button", { className: "m01-logic__pick-row m01-logic__pick-row--button", attrs: { type: "button", "data-pick": x.코드, ...(taken ? { disabled: "" } : {}) }, children: [ el("div", { className: "m01-logic__stack", children: [ el("strong", { text: x.이름 }), el("span", { className: "m01-logic__muted", text: `${x.코드} · ${x.규격}` }), ], }), el("span", { text: taken ? tm("Pick_Have") : x.단위 }), ], }); row.addEventListener("click", () => { close(); done(x); }); return row; }), ); } catch (error) { fail(error); } }; search.input.addEventListener("keydown", (ev) => ev.key === "Enter" && void find()); const dialog = el("div", { className: "m01-logic__pick", attrs: { role: "dialog", "aria-label": tm("Pick_Title") }, children: [ el("div", { className: "m01-logic__section-head", children: [ el("h3", { text: tm("Pick_Title") }), createButton({ label: tm("Pick_Close"), variant: "ghost", onClick: close }), ], }), el("div", { className: "m01-logic__filters", children: [ search.root, createButton({ label: tm("Pick_Find"), variant: "filled", onClick: () => void find() }), ], }), hint, 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()); document.body.append(backdrop); search.input.focus(); } export interface MatsetHandle { /** 컨테이너를 펼칠 때 — 목록을 새로 받아 그림 */ show: () => Promise; } export function mountM01Matset(host: HTMLElement, side: SideHandle): MatsetHandle { let items: MatsetSummary[] = []; let q = ""; /** 화면을 바꾼 횟수 — 늦게 온 답은 이 값이 달라졌으면 버림 */ let nav = 0; /** 열어 둔 상세 — 다른 화면에 갔다 와도 그대로 돌아오게 기억 */ let current: { matset: Matset; version: string } | null = null; const view = el("div", { className: "m01-logic__editor" }); host.replaceChildren(view); const find = createInputField({ type: "search", placeholder: tm("Search") }); find.input.addEventListener("input", () => { q = find.input.value.trim().toLowerCase(); if (!current) drawList(); }); side.matsetHost.replaceChildren(find.root); const load = async (redraw = true): Promise => { const my = nav; try { items = await fetchMatsets(); } catch (error) { fail(error); } if (redraw && my === nav) drawList(); }; const show = async (): Promise => { if (!items.length && !current) { view.replaceChildren(el("p", { className: "m01-logic__muted", text: tm("Loading") })); } await load(false); if (current) draw(current.matset, current.version); else drawList(); }; /* --- 목록 --- */ const drawList = (): void => { nav++; current = null; const shown = items.filter((x) => !q || `${x.키} ${x.이름}`.toLowerCase().includes(q)); const heads = [ tm("Head_Key"), tm("Head_Name"), tm("Head_Count"), tm("Head_Origin"), tm("Head_Owner"), ]; const rows = shown.map((x) => { const tr = el("tr", { className: "m01-master__table-row", attrs: { "data-matset-key": x.키 }, children: [x.키, x.이름, String(x.count), x.본뜬키 ?? tm("Origin_Self"), x.소유 ?? ""].map( (v) => el("td", { text: v }), ), }); tr.addEventListener("click", () => void open(x.키)); return tr; }); view.replaceChildren( el("div", { className: "m01-logic__section-head", children: [ createButton({ label: tm("New"), variant: "filled", onClick: () => draw(blank(), "") }), ], }), shown.length ? el("div", { className: "m01-master__grid-wrap", children: [ el("table", { className: "m01-master__grid", children: [ el("thead", { children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })], }), el("tbody", { children: rows }), ], }), ], }) : el("p", { className: "m01-logic__muted", text: tm("Empty") }), ); }; const open = async (key: string): Promise => { try { const my = ++nav; const one = await fetchMatset(key); if (my !== nav) return; draw(one.matset, one.version); } catch (error) { fail(error); } }; /* --- 상세 --- */ const draw = (matset: Matset, version: string): void => { nav++; current = { matset, version }; const field = (label: string, name: "이름" | "비고") => { const f = createInputField({ type: "text", label }); f.input.value = matset[name] ?? ""; f.input.setAttribute("data-matset-field", name); f.input.addEventListener("input", () => { if (name === "이름") matset.이름 = f.input.value; else matset.비고 = f.input.value || null; }); return f.root; }; const table = el("div", { className: "m01-master__grid-wrap" }); const drawTable = (): void => { const trs = matset.담은자재.map((m, i) => el("tr", { attrs: { "data-matset-item": m.코드 }, children: [ el("td", { text: m.코드 }), el("td", { text: m.이름 }), el("td", { text: m.규격 }), el("td", { text: m.단위 }), el("td", { children: [ createButton({ label: tm("Remove"), variant: "ghost", size: "sm", onClick: () => { matset.담은자재.splice(i, 1); drawTable(); }, }), ], }), ], }), ); const heads = [ tm("Col_Code"), tm("Col_Name"), tm("Col_Spec"), tm("Col_Unit"), tm("Col_Move"), ]; table.replaceChildren( el("table", { className: "m01-master__grid", children: [ el("thead", { children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })], }), el("tbody", { children: trs }), ], }), ...(trs.length ? [] : [el("p", { className: "m01-logic__muted", text: tm("NoItem") })]), ); }; drawTable(); const add = createButton({ label: tm("AddItem"), variant: "filled", onClick: () => openMaterialPicker(new Set(matset.담은자재.map((m) => m.코드)), (x) => { matset.담은자재.push({ 코드: x.코드, 이름: x.이름, 규격: x.규격, 단위: x.단위 }); drawTable(); }), }); const save = createButton({ label: tm("Save"), variant: "filled", onClick: async () => { if (!matset.이름.trim()) return showToast(tm("NeedName"), "error"); if (!matset.담은자재.length) return showToast(tm("NeedItem"), "error"); const my = nav; try { const key = matset.키 ? matset.키 : (await newMatset(matset)).key; if (matset.키) await editMatset(matset.키, version, matset); showToast(tm("Saved"), "success"); await load(false); if (my !== nav) { if (current?.matset === matset) current = null; // 저장 사이에 나감 — 옛 판본을 붙들지 않음 return; } await open(key); } catch (error) { showToast(reason(error), "error"); } }, }); const remove = createButton({ label: tm("Delete"), variant: "ghost", onClick: async () => { if (!matset.키 || !(await showConfirmDialog(tm("DeleteAsk")))) return; try { await deleteMatset(matset.키, version); showToast(tm("Deleted"), "success"); await load(); } catch (error) { showToast(reason(error), "error"); } }, }); const back = createButton({ label: "←", variant: "ghost", onClick: () => drawList() }); view.replaceChildren( el("div", { className: "m01-logic__section-head", children: [back, el("h3", { text: matset.키 || tm("New") }), remove, save], }), el("section", { className: "m01lab__box m01-logic__section", children: [ el("div", { className: "m01lab__info", children: [ field(tm("Head_Name"), "이름"), field(tm("Head_Note"), "비고"), el("p", { className: "m01-logic__muted", text: `${tm("Head_Origin")}: ${matset.본뜬키 ?? tm("Origin_Self")}`, attrs: { "data-matset-origin": matset.본뜬키 ?? "" }, }), ], }), ], }), el("section", { className: "m01lab__box m01-logic__section", children: [ el("div", { className: "m01-logic__section-head", children: [el("h3", { text: tm("Items_Title") }), add], }), table, ], }), ); }; return { show }; }