/* ============================================================================= * M02_MasterTemplete_UI_Side_Precombo.ts * 좌측 「일위대가 조합」 컨테이너(50-3 ③) — 구조물 조합 아래 · 층 단추(페이지가 만듦) · * 하위 둘 「단가승격」(promote · 단가산출 로직 하나를 입력값 고정해 일위대가 꼴로) · * 「사전조합」(precombo · 승격 + 다른 사전조합 묶음) — 목록 · 새로 · 복사 · 지우기. * 상세는 편집 칸(UI_Main 종류 promote · precombo = 일위대가 상세 모드 · 도면 · 면적 · 자재 없음). * 파일 이름 = 키(SP- · SC-) · 화면 이름 = 문서 `이름`(목록 `제목`) · 쓰는 곳 있으면 서버가 지우기를 막음(409 까닭). * ========================================================================== */ import { createButton, createInputField, el, showConfirmDialog, showToast, } from "@ui/ui_template_elements"; import { t as L } from "@ui/ui_template_locale"; import { openModal } from "@ui/ui_template_modal"; import { deleteLayerTemplate, deleteTemplate, getActiveOwner, newLibrary, readTemplate, saveTemplate, type Kind, type Layer, type LibraryBody, type TemplateInfo, } from "./M02_MasterTemplete_Api_Fetch"; import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick"; import type { Selection } from "./M02_MasterTemplete_UI_Main"; export type PrecomboKind = Extract; /** 종류 · 화면 이름(= 문서 `종류` 글 · 키 SP- · SC- 는 서버가 줌) */ const PARTS: { kind: PrecomboKind; label: string }[] = [ { kind: "promote", label: "단가승격" }, { kind: "precombo", label: "사전조합" }, ]; export const PRECOMBO_TITLE = "일위대가 조합"; export const isPrecombo = (kind: Kind): kind is PrecomboKind => kind === "promote" || kind === "precombo"; /** 목록 줄 — 서버가 문서 이름을 `제목` 으로 실어 보냄(없으면 키만) */ type Info = TemplateInfo & { 제목?: string }; export const precomboLabel = (info: Info): string => info.제목 ? `${info.이름} ${info.제목}` : info.이름; export interface PrecomboOptions { layer: Layer; canEdit: boolean; /** 층 단추 줄(페이지가 만듦 · 누르면 그 층 화면 + 이 컨테이너 펼침) */ bar: HTMLElement[]; confirmLeave: () => Promise; onOpen: (sel: Selection | null) => void; isOpen: (kind: Kind, name: string) => boolean; /** 목록 다시 받음(좌측 전체) — 받은 뒤 `paint` 가 불림 */ refresh: () => Promise; } export interface PrecomboHandle { body: HTMLElement; /** 좌측이 받은 층 목록 중 승격 · 사전조합만 그림 */ paint: (items: TemplateInfo[]) => void; } const why = (error: unknown): string => (error instanceof Error ? error.message : ""); const failed = (error: unknown): void => showToast(L("M02_ActionFailed").replace("{value}", why(error)), "error"); export function buildPrecomboSection(opt: PrecomboOptions): PrecomboHandle { let items: Info[] = []; const lists = new Map(); const buttons = new Map(); const mine = (kind: PrecomboKind): Info[] => items.filter((i) => i.종류 === kind); const current = (kind: PrecomboKind): Info | undefined => mine(kind).find((i) => opt.isOpen(kind, i.이름)); const select = async (info: Info): Promise => { if (!opt.isOpen(info.종류, info.이름) && !(await opt.confirmLeave())) return; opt.onOpen({ layer: opt.layer, projectId: null, kind: info.종류, name: info.이름, label: precomboLabel(info), }); }; /** 새로 — 서버가 그 층 다음 키로 씀 → 엶 */ const create = async (kind: PrecomboKind, body: LibraryBody): Promise => { const key = (await newLibrary(opt.layer, kind, body)).이름; showToast(L("M02_Created"), "success"); await opt.refresh(); const info = mine(kind).find((i) => i.이름 === key); if (info) await select(info); }; const nameDialog = (title: string, start: string, onOk: (name: string) => Promise) => { const name = createInputField({ label: L("M02_Name"), placeholder: L("M02_Name") }); name.input.value = start; openModal({ title, closeLabel: L("M02_Close"), dialogClass: "m02-modal", mount: (body, close) => { const ok = createButton({ label: L("M02_Create"), variant: "filled" }); ok.addEventListener("click", async () => { const text = name.input.value.trim(); if (!text) return showToast(L("M02_NameNeeded"), "error"); ok.disabled = true; // 연달아 눌러도 한 번만 try { await onOk(text); close(); } catch (error) { failed(error); } finally { ok.disabled = false; } }); name.input.addEventListener("keydown", (event) => { if (event.key === "Enter" && !event.isComposing) ok.click(); }); body.append(name.root, ok); }, }); }; /** 단가승격 새로 = 단가산출 로직 고르기 → 이름 · 단위는 로직 것(입력값은 상세에서 고정) */ const newPromote = (): void => openLogicPicker((x) => { void opt.confirmLeave().then((go) => !go ? undefined : create("promote", { 문서: { 이름: x.이름, 단위: x.결과단위 || null, 담은로직: [{ 로직: x.키, 입력값: {} }], }, }).catch(failed), ); }); /** 사전조합 새로 = 이름만 · 빈 묶음(승격 · 사전조합은 상세 [로직 추가]로) */ const newPrecombo = (): void => nameDialog(L("M02_NewTitle"), "", async (name) => { if (!(await opt.confirmLeave())) return; await create("precombo", { 문서: { 이름: name, 단위: null, 담은로직: [] } }); }); /** 복사 = 같은 층 새 키(서버 · 본뜬 = 원래 키) → 고른 이름으로 고쳐 씀 */ const copy = (kind: PrecomboKind): void => { const from = current(kind); if (!from) return; nameDialog(L("M02_CopyTitle"), `${from.제목 ?? from.이름} (2)`, async (name) => { if (!(await opt.confirmLeave())) return; const made = await newLibrary(opt.layer, kind, { 본뜬: { 층: opt.layer, 주인: getActiveOwner(), 키: from.이름 }, }); const got = await readTemplate(opt.layer, kind, made.이름, null); await saveTemplate(opt.layer, kind, made.이름, null, made.판, { ...(got.문서 as object), 이름: name, }); showToast(L("M02_Created"), "success"); await opt.refresh(); const info = mine(kind).find((i) => i.이름 === made.이름); if (info) await select(info); }); }; const remove = async (kind: PrecomboKind): Promise => { await opt.refresh(); // 저장 직후여도 최신 판으로 const info = current(kind); if (!info) return; const name = precomboLabel(info); if (!(await showConfirmDialog(L("M02_DeleteConfirm").replace("{value}", name)))) return; try { if (opt.layer === "system") await deleteTemplate(kind, info.이름, info.판); else await deleteLayerTemplate(opt.layer, kind, info.이름, info.판); showToast(L("M02_Deleted"), "success"); opt.onOpen(null); await opt.refresh(); } catch (error) { // 쓰는 곳이 있으면 서버가 막음 — 까닭(쓰는 곳 이름) 그대로 showToast(why(error) || L("M02_ActionFailed").replace("{value}", ""), "error"); } }; const block = (part: (typeof PARTS)[number]): HTMLElement => { const list = el("div", { className: "m02-side__items ui-sidebar-list" }); lists.set(part.kind, list); const row: HTMLElement[] = []; if (opt.canEdit) { const btnNew = createButton({ label: L("M02_New"), variant: "filled", size: "sm" }); const btnCopy = createButton({ label: L("M02_Copy"), variant: "ghost", size: "sm" }); const del = createButton({ label: L("M02_Delete"), variant: "danger", size: "sm" }); buttons.set(part.kind, { copy: btnCopy, del }); btnNew.addEventListener("click", part.kind === "promote" ? newPromote : newPrecombo); btnCopy.addEventListener("click", () => copy(part.kind)); del.addEventListener("click", () => void remove(part.kind)); row.push(el("div", { className: "m02-side__row", children: [btnNew, btnCopy, del] })); } return el("div", { className: "m02-side__sub", attrs: { "data-precombo": part.kind }, children: [el("h4", { className: "m02-side__subtitle", text: part.label }), ...row, list], }); }; const body = el("div", { className: "m02-side__items ui-sidebar-list", children: [...opt.bar, ...PARTS.map(block)], }); function paint(all: TemplateInfo[]): void { items = all.filter((i) => isPrecombo(i.종류)); for (const part of PARTS) { const found = mine(part.kind); lists.get(part.kind)?.replaceChildren( ...(found.length ? found.map((info) => { const b = el("button", { className: `m02-side__item ui-sidebar-row${opt.isOpen(part.kind, info.이름) ? " is-active" : ""}`, text: precomboLabel(info), attrs: { type: "button", title: info.수정일, "data-precombo-key": info.이름 }, }); b.addEventListener("click", () => void select(info)); return b; }) : [el("p", { className: "m02-side__empty", text: L("M02_NoTemplates") })]), ); const b = buttons.get(part.kind); if (b) b.copy.disabled = b.del.disabled = !current(part.kind); } } return { body, paint }; }