/* ============================================================================= * ui_template_item_picker.ts * 항목 고르기 모달 — 구조물집계표 항목(SS 한 줄)을 마스터 · 회사 · 개인 세 그룹 나무에서 고름. * 키(SS · UA …)는 앞에 내세우지 않고 이름으로 보임 · 키는 마우스를 올리면(PLAN.md 21-0). * * const result = await openItemPicker({ current, canCopy }); * result.action = "apply"(result.item = 21-1 `summary_item`) | "clear" | "cancel" * ========================================================================== */ import "./ui_template_item_picker.css"; import { API_BASE_URL } from "@config/config_frontend"; import { createButton, createInputField, el, showToast } from "./ui_template_elements"; import { buildTree, filterRows, folderLines, isCurrent, itemName, layerOf, ownerOf, pathIds, rowId, rowTip, shown, toSummary, type FolderKind, type PickNode, type PickerLayer, type PickerRow, type SummaryItem, type TreeLabels, } from "./ui_template_item_picker_data"; import { t } from "./ui_template_locale"; import { openModal } from "./ui_template_modal"; import { createTreeRow, renderTree, type TreeNode } from "./ui_template_tree"; export type { SummaryItem } from "./ui_template_item_picker_data"; export interface ItemPickerOptions { /** 지금 지정된 항목 — 없으면 null */ current: SummaryItem | null; /** [개인용으로 복사]를 쓸 수 있나 — 개인 층에 쓸 수 있을 때(회사 등록 뒤) */ canCopy: boolean; } export type ItemPickerResult = { action: "apply"; item: SummaryItem } | { action: "clear" } | { action: "cancel" }; const MANAGE_HASH = "#/m02-master-template?layer=personal"; const FOLDER_LABEL: Record string> = { drawing: () => t("M02_PickerDrawing"), area: () => t("M02_PickerArea"), material: () => t("M02_PickerMaterial"), combo: () => t("M02_PickerCombo"), unitPrice: () => t("M02_PickerUnitPrice"), }; const labels = (): TreeLabels => ({ layers: { system: t("M02_PickerSystem"), company: t("M02_PickerCompany"), personal: t("M02_PickerPersonal"), }, unclassified: t("M02_PickerNoClass"), }); /** 서버가 막은 까닭 그대로 */ function reasonOf(body: unknown, status: number): string { const detail = (body as { detail?: unknown } | null)?.detail; return typeof detail === "string" && detail ? detail : `HTTP ${status}`; } /** `GET /api/m02/items?all=1` = `[{layer, owner, items}]` — 줄마다 `layer` · `owner` 를 붙여 하나로 폄 */ async function loadRows(): Promise { const response = await fetch(`${API_BASE_URL}/m02/items?all=1`, { credentials: "include" }); const body = (await response.json().catch(() => null)) as | { layer: PickerLayer; owner: string | null; items: PickerRow[] }[] | { detail?: unknown } | null; if (!response.ok || !Array.isArray(body)) throw new Error(reasonOf(body, response.status)); return body.flatMap((group) => group.items.map((item) => ({ ...item, layer: group.layer, owner: group.owner })), ); } /** 개인용으로 복사 — 새로 생긴 줄의 키를 돌려줌(서버가 안 주면 null) */ async function copyToPersonal(row: PickerRow): Promise { const response = await fetch(`${API_BASE_URL}/m02/layers/personal/items/copy`, { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ from_layer: layerOf(row), from_owner: ownerOf(row), key: row.key }), }); const body = (await response.json().catch(() => null)) as { key?: string } | null; if (!response.ok) throw new Error(reasonOf(body, response.status)); return body?.key ?? null; } const message = (error: unknown): string => error instanceof Error ? error.message : String(error); export function openItemPicker(opts: ItemPickerOptions): Promise { return new Promise((resolve) => { let rows: PickerRow[] = []; let chosen: PickerRow | null = null; /** 접었다 펼친 것을 다시 그려도 남기려고 펼친 줄 id 를 기억 */ const opened = new Set(); const leafButtons = new Map(); const search = createInputField({ placeholder: t("M02_PickerSearch"), compact: true }); const treeHost = el("div", { className: "ui-item-picker__tree" }); const detail = el("div", { className: "ui-item-picker__detail" }); const applyButton = createButton({ label: t("M02_PickerApply"), variant: "filled" }); const clearButton = createButton({ label: t("M02_PickerClear"), variant: "ghost" }); const copyButton = createButton({ label: t("M02_PickerCopy"), variant: "ghost" }); const manageButton = createButton({ label: t("M02_PickerManage"), variant: "ghost" }); let close = (): void => {}; const finish = (result: ItemPickerResult): void => { resolve(result); close(); }; const hint = (text: string): HTMLElement => el("p", { className: "ui-item-picker__hint", text }); const paintDetail = (): void => { if (!chosen) { detail.replaceChildren(hint(t("M02_PickerHint"))); } else { const where = [labels().layers[layerOf(chosen)], shown(chosen.종류), shown(chosen.분류1)] .filter(Boolean) .join(" › "); const small = (text: string): HTMLElement => el("p", { className: "ui-item-picker__where", text }); detail.replaceChildren( el("h4", { className: "ui-item-picker__name", text: itemName(chosen), attrs: { title: rowTip(chosen, labels()) }, }), small(where), ...(chosen.단위 ? [small(`${t("M02_PickerUnit")} ${chosen.단위}`)] : []), el("ul", { className: "ui-item-picker__folder", children: folderLines(chosen).map((line) => el("li", { attrs: line.name && line.key ? { title: line.key } : {}, children: [ el("span", { className: "ui-item-picker__folder-label", text: FOLDER_LABEL[line.kind](), }), // 이름이 있으면 이름(키는 툴팁) · 없으면 키를 작은 글로 · 둘 다 없으면 「없음」 el("span", { className: line.name ? "" : line.key ? "ui-item-picker__key" : "ui-item-picker__none", text: line.name || line.key || t("M02_PickerNone"), }), ], }), ), }), ); } const layer = chosen ? layerOf(chosen) : null; applyButton.disabled = !chosen; clearButton.disabled = !opts.current; copyButton.hidden = !(chosen && layer !== "personal" && opts.canCopy); manageButton.hidden = !(chosen && layer === "personal"); }; const choose = (row: PickerRow): void => { chosen = row; for (const [id, button] of leafButtons) button.classList.toggle("is-active", id === rowId(row)); paintDetail(); }; const toTree = (node: PickNode, searching: boolean): TreeNode => ({ id: node.id, label: node.label, count: node.count, tip: node.tip, open: searching || opened.has(node.id), run: node.row ? () => choose(node.row as PickerRow) : undefined, children: node.children?.map((child) => toTree(child, searching)), }); const paintTree = (): void => { leafButtons.clear(); const text = search.input.value; const searching = text.trim() !== ""; const groups = buildTree(filterRows(rows, text), labels()) // 찾는 중에는 결과가 없는 그룹을 감춤 .filter((group) => !searching || (group.count ?? 0) > 0) .map((group) => toTree(group, searching)); if (!groups.length) { treeHost.replaceChildren(hint(t("M02_PickerNoMatch"))); return; } treeHost.replaceChildren( ...renderTree(groups, (node, caret, depth, onClick) => { const button = createTreeRow(node, caret, depth, () => { onClick(); if (!node.children?.length) return; if (button.querySelector(".ui-tree__caret")?.textContent === "▾") opened.add(node.id); else opened.delete(node.id); }); if (node.run) { leafButtons.set(node.id, button); if (chosen && node.id === rowId(chosen)) button.classList.add("is-active"); } return button; }), ); }; const load = async (pickKey: string | null): Promise => { treeHost.replaceChildren(hint(t("M02_PickerLoading"))); try { rows = await loadRows(); } catch (error) { treeHost.replaceChildren( hint(t("M02_PickerLoadFailed").replace("{value}", message(error))), ); return; } const start = pickKey ? rows.find((row) => layerOf(row) === "personal" && row.key === pickKey) : rows.find((row) => isCurrent(opts.current, row)); if (start) { for (const id of pathIds(start, labels())) opened.add(id); chosen = start; } else { opened.add("system"); // 지정된 게 없으면 첫 그룹만 펼침 } paintTree(); paintDetail(); treeHost.querySelector(".is-active")?.scrollIntoView({ block: "nearest" }); }; search.input.addEventListener("input", paintTree); applyButton.addEventListener( "click", () => chosen && finish({ action: "apply", item: toSummary(chosen) }), ); clearButton.addEventListener("click", () => finish({ action: "clear" })); manageButton.addEventListener("click", () => { finish({ action: "cancel" }); location.hash = MANAGE_HASH; }); copyButton.addEventListener("click", async () => { if (!chosen) return; copyButton.disabled = true; try { const key = await copyToPersonal(chosen); showToast(t("M02_PickerCopied"), "success"); await load(key); } catch (error) { showToast(t("M02_PickerCopyFailed").replace("{value}", message(error)), "error"); } finally { copyButton.disabled = false; } }); close = openModal({ title: t("M02_PickerTitle"), closeLabel: t("M02_Close"), dialogClass: "ui-item-picker", // 닫기 단추 · Esc · 바깥 누르기 = 취소 beforeClose: () => { resolve({ action: "cancel" }); return true; }, mount: (body) => { body.append( el("div", { className: "ui-item-picker__cols", children: [ el("div", { className: "ui-item-picker__left", children: [search.root, treeHost] }), detail, ], }), el("div", { className: "ui-item-picker__actions", children: [copyButton, manageButton, clearButton, applyButton], }), ); paintDetail(); void load(null); }, }).close; }); }