/* ============================================================================= * M02_MasterTemplete_ComboPanel.ts * 면적산출 · 재료산출 편집 화면 오른쪽 안쪽 패널 — 좌측 패널과 같은 공용 틀(사이드바 카드 · 접기) · * 공용 폭 손잡이(`ui_template_resizer` · 폭 기억 · 최소 폭) · 공용 접기 손잡이(`ui_template_overlay`). * · 「이 열의 조합」 — 면적 = 일위대가 조합 줄 + [조합 고치기](`M02_MasterTemplete_Combo.ts`) · * 재료 = 담은 자재 표(`M02_MasterTemplete_Materials.ts`) — 넣는 쪽이 `setMine` 으로 갈아 끼움 * · 조합 목록 — 면적 = 일위대가 조합(UA) · 재료 = 자재 조합(UM) · 찾기 · 이 열 조합 강조 · * 하나 골라 [이 열에 쓰기] = 집계표 그 열 키만 바꿈(서버 `use-combo` · 조합은 그대로) * ========================================================================== */ import { createButton, createInputField, el, showConfirmDialog, showToast, } from "@ui/ui_template_elements"; import { attachCollapsible } from "@ui/ui_template_collapsible"; import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; import { createPanelResizer } from "@ui/ui_template_resizer"; import { readStateRaw, stateKey, writeStateRaw } from "../A00_Common/b_page_state"; import { fetchCombos } from "../M01_MasterData/M01_MasterData_UI_Combo_Api"; import { listMaterialCombos, setColumnCombo } from "./M02_MasterTemplete_Api_Fetch"; import { readColumnBase, reasonOf } from "./M02_MasterTemplete_Combo_Api"; import "./M02_MasterTemplete_ComboPanel.css"; const TEXT = { handle: "조합", mine: "이 열의 조합", noneArea: "이 열에 일위대가 조합 없음", listArea: "일위대가 조합 목록", listMaterial: "자재 조합 목록", search: "키 · 이름으로 찾기", logics: "로직 {n}", items: "자재 {n}", origin: "본뜬 {value}", current: "이 열", empty: "맞는 조합 없음", none: "조합 없음", use: "이 열에 쓰기", useAsk: "이 열이 {value} 을 쓰게 바꿈 — 구조물집계표에 바로 적힘 · 조합은 그대로", useOk: "바꿈", used: "이 열이 {value} 을 씀", loadFailed: "조합 목록을 못 받음 — {value}", failed: "이 열에 못 씀 — {value}", }; const fill = (text: string, value: string | number): string => text.replace("{n}", String(value)).replace("{value}", String(value)); /** 최소 폭(px) · 최대 = 편집 칸 폭의 비율 */ const MIN_WIDTH = 280; const MAX_RATIO = 0.6; interface Row { 키: string; 이름: string; 수: number; 본뜬키: string | null; } export interface ComboPanelOptions { kind: "area" | "material"; /** 집계표 열 id */ column: string; isAdmin: boolean; /** [이 열에 쓰기] 뒤 — 알약 · 「이 열의 조합」을 새 조합으로 다시 그림 */ onUsed: () => Promise | void; } export interface ComboPanel { root: HTMLElement; /** 「이 열의 조합」 부분을 갈아 끼움(면적 = 조합 줄 · 재료 = 담은 자재 표) */ setMine: (node: HTMLElement | null) => void; /** 목록 · 이 열 조합 키를 다시 받음(갈라 낸 뒤 등) */ refresh: () => Promise; destroy: () => void; } export function buildComboPanel(opts: ComboPanelOptions): ComboPanel { const area = opts.kind === "area"; let rows: Row[] = []; let current: string | null = null; let picked: string | null = null; let q = ""; let seq = 0; const card = (title: string, ...children: HTMLElement[]): HTMLElement => el("section", { className: "ui-collapsible ui-sidebar-section ui-sidebar-card", children: [el("h3", { className: "ui-collapsible__title", text: title }), ...children], }); const mine = el("div", { className: "m02-cpanel__mine" }); const find = createInputField({ type: "search", placeholder: TEXT.search }); const list = el("div", { className: "ui-sidebar-list m02-cpanel__list" }); const use = createButton({ label: TEXT.use, variant: "filled", size: "sm" }); const body = el("div", { className: "m02-cpanel__body ui-sidebar-panel", children: [ card(TEXT.mine, mine), card(area ? TEXT.listArea : TEXT.listMaterial, find.root, list), ], }); attachCollapsible(body); const root = el("aside", { className: "m02-cpanel", attrs: { "data-kind": opts.kind } }); const handle = createWorkflowPanelHandle("side", "right", TEXT.handle); // 왼쪽 경계를 끌어 폭 조절 — 폭은 브라우저에 기억 · 더블클릭 = 기본 폭 const resizer = createPanelResizer({ axis: "horizontal", target: root, cssVar: "--m02-cpanel-width", direction: -1, min: MIN_WIDTH, max: () => (root.parentElement?.clientWidth ?? window.innerWidth) * MAX_RATIO, storageKey: stateKey("m02-combo-panel-width") ?? undefined, }); root.append(handle.root, resizer.root, body); const setCollapsed = (collapsed: boolean): void => { root.classList.toggle("is-collapsed", collapsed); handle.setOpen(!collapsed); writeStateRaw("m02-combo-panel-collapsed", String(collapsed)); }; handle.root.addEventListener("click", () => setCollapsed(!root.classList.contains("is-collapsed")), ); setCollapsed(readStateRaw("m02-combo-panel-collapsed") === "true"); find.input.addEventListener("input", () => { q = find.input.value.trim().toLowerCase(); paint(); }); function paint(): void { const shown = rows.filter((r) => !q || `${r.키} ${r.이름}`.toLowerCase().includes(q)); list.replaceChildren( ...(shown.length ? shown.map((r) => { const meta = [ fill(area ? TEXT.logics : TEXT.items, r.수), r.본뜬키 ? fill(TEXT.origin, r.본뜬키) : "", r.키 === current ? TEXT.current : "", ]; const b = el("button", { className: "m02-side__item ui-sidebar-row m02-cpanel__row" + (r.키 === picked ? " is-active" : "") + (r.키 === current ? " is-current" : ""), attrs: { type: "button", "data-combo-key": r.키, title: `${r.키} ${r.이름}` }, children: [ el("span", { className: "m02-cpanel__key", text: r.키 }), el("span", { className: "m02-cpanel__name", text: r.이름 }), el("span", { className: "m02-cpanel__meta", text: meta.filter(Boolean).join(" · "), }), ], }); b.addEventListener("click", () => { picked = r.키 === picked ? null : r.키; paint(); }); // [이 열에 쓰기]는 고른 줄 바로 아래(목록 안) — 목록 밖 단추가 스크롤된 줄 글과 겹치지 않게 return opts.isAdmin && r.키 === picked && r.키 !== current ? el("div", { className: "m02-cpanel__pick", children: [b, use] }) : b; }) : [el("p", { className: "m02-side__empty", text: q ? TEXT.empty : TEXT.none })]), ); use.disabled = false; if (use.isConnected) use.scrollIntoView({ block: "nearest" }); } async function refresh(): Promise { const mineSeq = ++seq; try { const [base, got] = await Promise.all([ readColumnBase(opts.column), area ? fetchCombos().then((cs) => cs.map((c) => ({ 키: c.키, 이름: c.이름, 수: c.count, // 목록 요약엔 담은로직이 없음 — 수만 옴 본뜬키: c.본뜬키 ?? null, })), ) : listMaterialCombos().then((d) => d.줄.map((m) => ({ 키: m.키, 이름: m.이름, 수: m.담은자재.length, 본뜬키: m.본뜬키, })), ), ]); if (mineSeq !== seq) return; current = (area ? base.일위대가 : base.자재조합) ?? null; rows = got; } catch (error) { if (mineSeq !== seq) return; showToast(fill(TEXT.loadFailed, reasonOf(error)), "error"); } if (picked === current) picked = null; paint(); } use.addEventListener("click", async () => { const row = rows.find((r) => r.키 === picked); if (!row || row.키 === current) return; if (!(await showConfirmDialog(fill(TEXT.useAsk, `${row.키} ${row.이름}`), TEXT.useOk))) return; use.disabled = true; try { const got = await setColumnCombo(opts.column, { 종류: area ? "일위대가" : "자재조합", 키: row.키, }); showToast(fill(TEXT.used, `${got.키} ${got.이름}`), "success"); picked = null; await opts.onUsed(); await refresh(); } catch (error) { showToast(fill(TEXT.failed, reasonOf(error)), "error"); paint(); } }); paint(); void refresh(); return { root, setMine: (node) => mine.replaceChildren(node ?? el("p", { className: "m02-side__empty", text: TEXT.noneArea })), refresh, destroy: () => { ++seq; resizer.dispose(); root.remove(); }, }; }