/* ============================================================================= * M02_MasterTemplete_Matset.ts * 좌측 「자재 조합」 컨테이너 — 찾기 · 목록(키 맨 앞) · 새로 · 본떠 만들기 · 지우기(컨테이너 안) · * 줄을 누르면 메인 칸에 상세: 이름 · 비고 · 본뜬키 · 담은 자재 표(빼기) · [자재 더하기] · [저장]. * 표 = 마스터 템플릿 `자재조합.json`(키 UM · 서버 `M02_MasterTemplete_Matset.py`) · 지우기는 쓰는 열이 * 있으면 서버가 막음. 재료산출 옆 판이 갈라 낸 조합도 같은 표라 여기 보임(펼칠 때 새로 받음). * ========================================================================== */ import { createButton, createInputField, el, showConfirmDialog, showToast, } from "@ui/ui_template_elements"; import { t as L } from "@ui/ui_template_locale"; import { createMaterialCombo, deleteMaterialCombo, editMaterialCombo, listMaterialCombos, StaleError, type MaterialCombo, } from "./M02_MasterTemplete_Api_Fetch"; import { pickMaterial } from "./M02_MasterTemplete_Materials"; import { tm } from "./M02_MasterTemplete_Matset_Text"; export interface MatsetSectionOptions { isAdmin: boolean; /** 저장 안 한 고침을 버릴지 확인 — 취소면 false */ confirmLeave: () => Promise; /** 메인 칸에 상세를 띄움 — 고침 표시 손잡이를 돌려받음 */ show: (label: string, node: HTMLElement) => (dirty: boolean) => void; /** 메인 칸을 비움(지운 뒤) */ close: () => void; /** 상세를 열었음 — 좌측 다른 줄 고름 표시를 지움 */ onOpen: () => void; } export interface MatsetSection { title: string; body: HTMLElement; /** 새로 · 본떠 만들기 · 지우기 줄(관리자만 · 아니면 빈 목록) */ actions: HTMLElement[]; /** 표를 새로 받아 목록을 다시 그림 */ refresh: () => Promise; /** 다른 양식을 열었음 — 고름 표시만 지움 */ clear: () => void; } const why = (error: unknown): string => (error instanceof Error ? error.message : ""); export function buildMatsetSection(opt: MatsetSectionOptions): MatsetSection { let all: MaterialCombo[] = []; let version = ""; let used: Record = {}; let q = ""; /** 열린 상세의 키 — 새(저장 전)는 빈 글 · 안 열렸으면 null */ let openKey: string | null = null; const find = createInputField({ type: "search", placeholder: tm("Search") }); const list = el("div", { className: "m02-side__items ui-sidebar-list" }); const body = el("div", { className: "m02-side__items", children: [find.root, list] }); find.input.addEventListener("input", () => { q = find.input.value.trim().toLowerCase(); paintList(); }); const btnNew = createButton({ label: L("M02_New"), variant: "filled", size: "sm" }); const btnCopy = createButton({ label: L("M02_Copy"), variant: "ghost", size: "sm" }); const btnDel = createButton({ label: L("M02_Delete"), variant: "danger", size: "sm" }); const actions = opt.isAdmin ? [el("div", { className: "m02-side__row", children: [btnNew, btnCopy, btnDel] })] : []; const paintButtons = (): void => { btnCopy.disabled = btnDel.disabled = !openKey; }; function paintList(): void { const shown = all.filter((x) => !q || `${x.키} ${x.이름}`.toLowerCase().includes(q)); list.replaceChildren( ...(shown.length ? shown.map((x) => { const b = el("button", { className: `m02-side__item ui-sidebar-row${x.키 === openKey ? " is-active" : ""}`, text: `${x.키} ${x.이름}`, attrs: { type: "button", "data-matset-key": x.키 }, }); b.addEventListener("click", () => void select(x.키)); return b; }) : [el("p", { className: "m02-side__empty", text: tm("Empty") })]), ); } async function refresh(): Promise { try { ({ 판: version, 줄: all, 쓰는곳: used } = await listMaterialCombos()); } catch (error) { showToast(tm("Load_Failed", why(error)), "error"); } paintList(); paintButtons(); } async function select(key: string): Promise { if (key === openKey || !(await opt.confirmLeave())) return; const row = all.find((x) => x.키 === key); if (row) draw(structuredClone(row), false); } /** 메인 칸 상세 — `row` 는 고칠 사본(저장 전까지 표에 안 들어감) */ function draw(row: MaterialCombo, isNew: boolean): void { openKey = isNew ? "" : row.키; const edit = opt.isAdmin; const field = (label: string, name: "이름" | "비고") => { const f = createInputField({ type: "text", label }); f.input.value = row[name] ?? ""; f.input.disabled = !edit; f.input.setAttribute("data-matset-field", name); f.input.addEventListener("input", () => { if (name === "이름") row.이름 = f.input.value; else row.비고 = f.input.value || null; setDirty(true); }); return f.root; }; const table = el("div", { className: "m02-matset__table" }); const paintItems = (): void => { const head = el("div", { className: "m02-mat__row m02-mat__row--head", children: [tm("Col_Code"), tm("Col_Name"), tm("Col_Spec"), tm("Col_Unit"), "", ""].map( (t) => el("span", { text: t }), ), }); const rows = row.담은자재.map((m, i) => { const acts = el("span"); if (edit) { const del = createButton({ label: tm("Remove"), variant: "ghost", size: "sm" }); del.addEventListener("click", () => { row.담은자재.splice(i, 1); setDirty(true); paintItems(); }); acts.append(del); } return el("div", { className: "m02-mat__row", attrs: { "data-matset-item": m.코드 }, children: [m.코드, m.이름 ?? "", m.규격 ?? "", m.단위 ?? "", ""] .map((t) => el("span", { text: t })) .concat(acts), }); }); table.replaceChildren( head, ...(rows.length ? rows : [el("p", { className: "m02-side__empty", text: tm("NoItem") })]), ); }; paintItems(); const add = createButton({ label: tm("AddItem"), variant: "ghost" }); add.addEventListener("click", () => pickMaterial((m) => { if (row.담은자재.some((x) => x.코드 === m.코드)) return; row.담은자재.push({ 코드: m.코드, 이름: m.이름, 규격: m.규격, 단위: m.단위 }); setDirty(true); paintItems(); }), ); const save = createButton({ label: tm("Save"), variant: "filled" }); save.addEventListener("click", async () => { if (!row.이름.trim()) return showToast(tm("NeedName"), "error"); if (!row.담은자재.length) return showToast(tm("NeedItem"), "error"); save.disabled = true; // 연달아 눌러도 한 번만 try { const { 키, ...rest } = row; const done = isNew ? await createMaterialCombo(version, rest) : await editMaterialCombo(키, version, row); setDirty(false); showToast(tm("Saved"), "success"); await refresh(); draw(structuredClone(done.줄), false); } catch (error) { if (error instanceof StaleError) { showToast(tm("Stale"), "error"); await refresh(); } else showToast(tm("Failed", why(error)), "error"); } finally { save.disabled = false; } }); const node = el("div", { className: "m02-matset", children: [ el("div", { className: "m02-matset__fields", children: [ field(tm("Head_Name"), "이름"), field(tm("Head_Note"), "비고"), el("p", { className: "m02-side__empty", text: `${tm("Head_Origin")}: ${row.본뜬키 ?? tm("Origin_Self")}`, attrs: { "data-matset-origin": row.본뜬키 ?? "" }, }), ], }), el("h3", { className: "m02-matset__head", text: tm("Items_Title") }), table, ...(edit ? [el("div", { className: "m02-side__row", children: [add, save] })] : []), ], }); const setDirty = opt.show(isNew ? tm("New") : `${row.키} ${row.이름}`, node); if (isNew) { setDirty(true); node.querySelector('[data-matset-field="이름"]')?.focus(); // 바로 이름 적기 } opt.onOpen(); paintList(); paintButtons(); } btnNew.addEventListener("click", async () => { if (!(await opt.confirmLeave())) return; draw({ 키: "", 이름: "", 담은자재: [], 본뜬키: null, 소유: "현장", 비고: null }, true); }); btnCopy.addEventListener("click", async () => { const from = all.find((x) => x.키 === openKey); if (!from || !(await opt.confirmLeave())) return; const copy = structuredClone(from); draw({ ...copy, 키: "", 이름: "", 본뜬키: from.키, 소유: "현장" }, true); // 이름은 새로 적음 }); btnDel.addEventListener("click", async () => { const key = openKey; if (!key) return; await refresh(); // 최신 판 · 쓰는 열로 // 쓰는 열이 있으면 확인 창 없이 바로 알림(서버도 막음) const users = used[key] ?? []; if (users.length) return showToast(tm("InUse", users.join(" · ")), "error"); if (!(await showConfirmDialog(tm("DeleteAsk", key)))) return; try { await deleteMaterialCombo(key, version); showToast(tm("Deleted"), "success"); openKey = null; opt.close(); } catch (error) { showToast(tm("Failed", why(error)), "error"); } await refresh(); }); paintButtons(); return { title: tm("Title"), body, actions, refresh, clear: () => { if (openKey === null) return; openKey = null; paintList(); paintButtons(); }, }; }