/* ============================================================================= * 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 { LinkItem } from "../A00_Common/spreadsheet/spreadsheet_links"; import type { CellLink } from "../A00_Common/spreadsheet/spreadsheet_types"; import { branchMaterialCombo, fetchMaterialCombo, type MaterialCombo, type MaterialItem, } from "./M02_MasterTemplete_Api_Fetch"; import { askBranch, branchName, branchToast } from "./M02_MasterTemplete_Combo"; import { readColumnBase, reasonOf } from "./M02_MasterTemplete_Combo_Api"; const TEXT = { toggle: "자재 목록", combo: "자재 조합", branched: "본뜬 조합 {value}", none: "자재 조합 없음 — 자재를 더하면 새로 만듦", code: "코드", name: "이름", spec: "규격", unit: "단위", cell: "이은 칸", remove: "빼기", add: "자재 더하기", apply: "적용", undo: "되돌리기", empty: "담은 자재가 없음", noItems: "담은 자재가 없어 적용 안 됨 — 자재를 하나 이상 담음", already: "이미 담은 자재 — {value}", unlinkAsk: "{value} 자재는 {cell} 칸에 이어져 있음 — 빼면 연결도 풂", unlinkOk: "빼고 연결 풂", loadFailed: "자재 조합을 못 읽음 — {value}", askBody: "이 자재 조합은 열 {n} 개와 모든 프로젝트가 같이 씀 — 고친 내용으로 새 자재 조합을 만들고 이 열만 새 조합을 가리킴 · 같은 내용이면 있는 조합을 씀 · 원래 조합은 그대로", askFirst: "이 열에 새 자재 조합을 만듦 — 담은 자재 목록으로 새 조합을 만들고 이 열이 가리킴", 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 MaterialState { key: string; others: number; combo: MaterialCombo | null; } export async function loadMaterialState(column: string): Promise { const base = await readColumnBase(column); const key = base.자재조합 ?? ""; let combo: MaterialCombo | null = null; if (key) { try { combo = await fetchMaterialCombo(key); } catch (error) { showToast(fill(TEXT.loadFailed, { value: reasonOf(error) }), "error"); } } return { key, others: base.다른열자재조합, combo }; } /** 알약 막대 자재 항목 ↔ 담은 자재 — 알약 키 = 자재 코드 */ export const toPills = (items: MaterialItem[]): LinkItem[] => items.map((m) => { const 자재 = { 코드: m.코드, 이름: m.이름 ?? "", 규격: m.규격 ?? "", 단위: m.단위 ?? "", }; return { 키: m.코드, 이름: 자재.이름, 단위: 자재.단위, 자재 }; }); export interface MaterialPanel { root: HTMLElement; /** 알약 막대에서 목록이 바뀜 — 표만 다시 그림(알약은 이미 맞음) */ setItems: (items: MaterialItem[]) => void; /** 연결이 바뀜(이음 · 풂 · 되돌리기) — 이은 칸을 다시 그림 */ refresh: () => void; } export interface MaterialPanelOptions { /** 집계표 열 id */ column: string; state: MaterialState; /** 표에서 더하기 · 빼기 · 되돌리기 — 알약 막대를 이 목록으로 맞춤 */ onChange: (items: MaterialItem[]) => void; /** 이은 칸이 있는 줄을 누름 — 알약을 눌러 그 칸으로 감(깜빡임) */ goTo: (code: string) => void; /** 구조물 이름 — 새 이름 자동 미리 채움 */ 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 function buildMaterialPanel(opts: MaterialPanelOptions): MaterialPanel { let key = opts.state.key; let others = opts.state.others; let combo = opts.state.combo; 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.이름 ?? m.코드, cell: cells.join(", ") }), TEXT.unlinkOk, )) ) return; items.splice(i, 1); if (cells.length) opts.unlink([m.코드]); // 확인 글대로 바로 풂(알약 막대 「목록에서 빼기」와 같음) paint(); opts.onChange(items); }); acts.append(del); } const row = el("div", { className: `m02-mat__row${cells.length ? " is-linked" : ""}`, attrs: { "data-material": m.코드 }, children: [m.코드, m.이름 ?? "", m.규격 ?? "", m.단위 ?? "", cells.join(", ")] .map((t) => el("span", { text: t })) .concat(acts), }); if (cells.length) row.addEventListener("click", () => opts.goTo(m.코드)); return row; }); table.replaceChildren( head, ...(rows.length ? rows : [el("p", { className: "m02-struct__empty", text: TEXT.empty })]), ); paintFoot(); paintToggle(); }; 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 showToast(fill(TEXT.already, { value: m.코드 }), "info"); items.push(m); paint(); opts.onChange(items); }), ); const buttons = [add]; if (sig(items) !== sig(saved)) { const apply = createButton({ label: TEXT.apply, variant: "filled", size: "sm" }); apply.addEventListener("click", async () => { if (!items.length) return showToast(TEXT.noItems, "error"); const message = !key ? TEXT.askFirst : others ? fill(TEXT.askBody, { n: others + 1 }) : TEXT.askAlone; const name = await askBranch(message, branchName(combo?.이름 ?? "", opts.label)); if (name === null) return; apply.disabled = true; try { const result = await branchMaterialCombo(opts.column, { 본뜬키: key, 담은자재: items, 이름: name, }); branchToast(result); 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]; paint(); opts.onChange(items); }); buttons.push(apply, undo); } foot.replaceChildren(...buttons); }; paint(); return { root, setItems: (list) => { items = [...list]; paint(); }, refresh: paint, }; }