/* ============================================================================= * M02_MasterTemplete_Materials.ts * 재료산출 편집 화면 — 엑셀 옆 접히는 판: 그 열의 자재 조합(UM) 담은 자재 표 + [자재 더하기] · [빼기] · [적용]. * 자재 조합도 여러 열이 같이 쓸 수 있어 [적용] 때 일위대가처럼 모달 후 새 조합으로 갈라 냄 * (서버 `branchMaterialCombo`) · 원래 조합은 그대로. 빼는 자재가 칸에 이어져 있으면 확인 후 연결도 풂. * ========================================================================== */ import { createButton, createInputField, el, showConfirmDialog, showToast, } from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; import { searchMaterials } from "../M01_MasterData/M01_MasterData_Api_Fetch"; import type { CellLink } from "../A00_Common/spreadsheet/spreadsheet_types"; import { askBranch, branchToast } from "./M02_MasterTemplete_Combo"; import { branchMaterialCombo, fetchMaterialCombo, readColumnBase, reasonOf, type MaterialCombo, type MaterialItem, } from "./M02_MasterTemplete_Combo_Api"; const TEXT = { toggle: "자재 목록", combo: "자재 조합", branched: "본뜬 조합 {value}", none: "자재 조합 없음 — 자재를 더하면 새로 만듦", code: "코드", name: "이름", spec: "규격", unit: "단위", cell: "이은 칸", remove: "빼기", add: "자재 더하기", apply: "적용", undo: "되돌리기", empty: "담은 자재가 없음", unlinkAsk: "{value} 자재는 {cell} 칸에 이어져 있음 — 빼면 연결도 풂", unlinkOk: "빼고 연결 풂", loadFailed: "자재 조합을 못 읽음 — {value}", askBody: "이 자재 조합은 열 {n} 개와 모든 프로젝트가 같이 씀 — 고친 내용으로 새 자재 조합을 만들고 이 열만 새 조합을 가리킴 · 같은 내용이면 있는 조합을 씀 · 원래 조합은 그대로", askAlone: "이 자재 조합은 모든 프로젝트가 같이 씀 — 고친 내용으로 새 자재 조합을 만들고 이 열만 새 조합을 가리킴 · 같은 내용이면 있는 조합을 씀", failed: "자재 조합을 못 고침 — {value}", pickTitle: "자재 더하기", pickClose: "닫기", search: "자재 이름 · 규격 · 코드", searching: "찾는 중…", noMatch: "맞는 자재 없음", searchFailed: "자재 목록을 못 받음", }; const fill = (text: string, values: Record): string => Object.entries(values).reduce((s, [k, v]) => s.replace(`{${k}}`, String(v)), text); export interface MaterialPanelOptions { /** 집계표 열 id */ column: string; /** 구조물 이름 — 새 이름 자동 미리 채움 */ label: string; isAdmin: boolean; /** 지금 시트 문서의 연결 전부 */ links: () => CellLink[]; /** 자재 코드에 이어진 칸 연결을 풂(시트 문서를 고치고 고침 표시) */ unlink: (codes: string[]) => void; } /** 자재 고르기 창 — 이름 · 규격 · 코드 낱말로 찾아 하나 고름 */ function pickMaterial(done: (item: MaterialItem) => void): void { openModal({ title: TEXT.pickTitle, closeLabel: TEXT.pickClose, dialogClass: "m02-modal m02-pick", mount: (body, close) => { const search = createInputField({ placeholder: TEXT.search, type: "search" }); const list = el("div", { className: "m02-pick__list" }); let seq = 0; const paint = async (): Promise => { const mine = ++seq; list.replaceChildren(el("p", { className: "m02-struct__empty", text: TEXT.searching })); try { const hits = await searchMaterials(search.input.value.trim()); if (mine !== seq) return; list.replaceChildren( ...(hits.length ? hits.map((m) => { const row = el("button", { className: "m02-pick__item", attrs: { type: "button", "data-material": m.코드 }, children: [ el("span", { className: "m02-pick__key", text: m.코드 }), `${m.이름} ${m.규격} ${m.단위}`.replace(/\s+/g, " "), ], }); row.addEventListener("click", () => { done(m); close(); }); return row; }) : [el("p", { className: "m02-struct__empty", text: TEXT.noMatch })]), ); } catch { if (mine === seq) showToast(TEXT.searchFailed, "error"); } }; let timer = 0; search.input.addEventListener("input", () => { window.clearTimeout(timer); timer = window.setTimeout(() => void paint(), 250); }); body.append(search.root, list); void paint(); search.input.focus(); }, }); } /** 옆 판 — 이 열에 자재 조합이 없어도 판은 뜸(더하면 새로 만듦) */ export async function buildMaterialPanel(opts: MaterialPanelOptions): Promise { const base = await readColumnBase(opts.column); let key = base.자재조합 ?? ""; let others = base.다른열자재조합; let combo: (MaterialCombo & { 본뜬키?: string | null }) | null = null; if (key) { try { combo = await fetchMaterialCombo(key); } catch (error) { showToast(fill(TEXT.loadFailed, { value: reasonOf(error) }), "error"); } } let saved: MaterialItem[] = combo?.담은자재 ?? []; let items: MaterialItem[] = [...saved]; const sig = (list: MaterialItem[]): string => JSON.stringify(list.map((m) => m.코드)); const toggle = createButton({ label: "", variant: "ghost", size: "sm" }); const title = el("div", { className: "m02-mat__title" }); const table = el("div", { className: "m02-mat__table" }); const foot = el("div", { className: "m02-mat__foot" }); const body = el("div", { className: "m02-mat__body", children: [title, table, foot] }); const root = el("aside", { className: "m02-mat", children: [toggle, body] }); toggle.addEventListener("click", () => { root.classList.toggle("is-collapsed"); paintToggle(); }); const paintToggle = (): void => { const open = !root.classList.contains("is-collapsed"); toggle.querySelector(".ui-btn__label")!.textContent = `${open ? "▸" : "◂"} ${TEXT.toggle} ${items.length}`; }; const cellsOf = (code: string): string[] => opts .links() .filter((l) => (l.자재?.코드 ?? l.키) === code) .map((l) => l.칸); const paint = (): void => { title.replaceChildren( el("span", { text: key ? `${TEXT.combo} ${key} ${combo?.이름 ?? ""}`.trim() : TEXT.none }), ...(opts.isAdmin && combo?.본뜬키 ? [ el("span", { className: "m02-combo__origin", text: fill(TEXT.branched, { value: combo.본뜬키 }), }), ] : []), ); const head = el("div", { className: "m02-mat__row m02-mat__row--head", children: [TEXT.code, TEXT.name, TEXT.spec, TEXT.unit, TEXT.cell, ""].map((t) => el("span", { text: t }), ), }); const rows = items.map((m, i) => { const cells = cellsOf(m.코드); const acts = el("span", { className: "m02-mat__acts" }); if (opts.isAdmin) { const del = createButton({ label: TEXT.remove, variant: "ghost", size: "sm" }); del.addEventListener("click", async () => { if ( cells.length && !(await showConfirmDialog( fill(TEXT.unlinkAsk, { value: m.이름, cell: cells.join(", ") }), TEXT.unlinkOk, )) ) return; items.splice(i, 1); removed.push(m.코드); paint(); }); acts.append(del); } return el("div", { className: "m02-mat__row", attrs: { "data-material": m.코드 }, children: [m.코드, m.이름, m.규격, m.단위, cells.join(", ")] .map((t) => el("span", { text: t })) .concat(acts), }); }); table.replaceChildren( head, ...(rows.length ? rows : [el("p", { className: "m02-struct__empty", text: TEXT.empty })]), ); paintFoot(); paintToggle(); }; /** 이번에 뺀 자재 코드 — 적용하면 이은 칸 연결도 풂 */ let removed: string[] = []; const paintFoot = (): void => { if (!opts.isAdmin) return foot.replaceChildren(); const add = createButton({ label: TEXT.add, variant: "ghost", size: "sm" }); add.addEventListener("click", () => pickMaterial((m) => { if (items.some((x) => x.코드 === m.코드)) return; items.push(m); removed = removed.filter((c) => c !== m.코드); paint(); }), ); const buttons = [add]; if (sig(items) !== sig(saved)) { const apply = createButton({ label: TEXT.apply, variant: "filled", size: "sm" }); apply.addEventListener("click", async () => { const message = others ? fill(TEXT.askBody, { n: others + 1 }) : TEXT.askAlone; const name = await askBranch( message, `${combo?.이름 ?? ""} · ${opts.label}`.replace(/^ · /, ""), ); if (name === null) return; apply.disabled = true; try { const result = await branchMaterialCombo(opts.column, { 본뜬키: key, 담은자재: items, 이름: name, }); branchToast(result); if (removed.length) opts.unlink(removed); removed = []; key = result.키; others = 0; combo = await fetchMaterialCombo(key).catch(() => null); saved = combo?.담은자재 ?? [...items]; items = [...saved]; paint(); } catch (error) { apply.disabled = false; showToast(fill(TEXT.failed, { value: reasonOf(error) }), "error"); } }); const undo = createButton({ label: TEXT.undo, variant: "ghost", size: "sm" }); undo.addEventListener("click", () => { items = [...saved]; removed = []; paint(); }); buttons.push(apply, undo); } foot.replaceChildren(...buttons); }; paint(); return root; }