/* ============================================================================= * ui_template_item_picker.ts * 항목 고르기 모달 — 구조물집계표 항목(SS 한 줄)을 마스터 · 회사 · 개인 세 그룹 나무에서 고름. * 키(SS · UA …)는 앞에 내세우지 않고 이름으로 보임 · 키는 마우스를 올리면(PLAN.md 21-0). * * const result = await openItemPicker({ current, canCopy, projectId, structureId }); * result.action = "apply"(result.item = 21-1 `summary_item`) | "clear" | "cancel" * * 26-3b — 카드 · 편집 · [재적용] · [개인에 저장] 은 **고르는 구조물의 복사본**(`structureId`) 기준. * 나무 개인 그룹 · 폴더를 오른쪽 클릭하면 「새 항목」(프로젝트에서 새 항목 → 개인 층에도 만들고 폼에 지정). * ========================================================================== */ 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, isCurrent, layerOf, ownerOf, pathIds, rowId, toSummary, 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 TreeMenuItem, type TreeNode } from "./ui_template_tree"; import { askFields } from "./ui_template_item_picker_ask"; import { projectItems, type CreatedItem } from "./ui_template_item_picker_project"; import { createItemDetail, type EditRequest } from "./ui_template_item_picker_detail"; import { createEditView, type EditView } from "./ui_template_item_picker_edit"; export type { SummaryItem } from "./ui_template_item_picker_data"; export interface ItemPickerOptions { /** 지금 지정된 항목 — 없으면 null */ current: SummaryItem | null; /** [개인용으로 복사]를 쓸 수 있나 — 개인 층에 쓸 수 있을 때(회사 등록 뒤) */ canCopy: boolean; /** 프로젝트 안에서 부르면 그 id — 카드 [수정] 이 프로젝트 복사본을 고침(없으면 원본 보기만 · 25-2d) */ projectId?: string | null; /** 고르는 구조물의 id(`structure_id` · 관 지점 `pipe@측점`) — 복사본이 이 구조물 것 · 없으면(추가 전) 원본 보기만 */ structureId?: string | null; } export type ItemPickerResult = { action: "apply"; item: SummaryItem } | { action: "clear" } | { action: "cancel" }; /** 관리 화면 진입 해시(PLAN 27-1d) — 구조물 조합 · 개인 뿌리가 열린 채 */ const MANAGE_HASH = `#/m02-master-template?${new URLSearchParams({ open: "구조물조합", root: "personal" })}`; 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 NEW_TEXT = { menu: "새 항목", title: "새 항목", note: "{value} 안에 새 항목을 만듦 — 개인 층에도 저장되고 이 구조물에 지정됨", name: "이름", unit: "단위", ok: "만들기", cancel: "취소", empty: "{label} 을(를) 넣을 것", made: "새 항목을 만들어 지정했음 — 구조물 [저장]을 눌러야 이 구조물 복사본이 남음", failed: "새 항목을 만들지 못함 — {value}", noStructure: "구조물을 먼저 추가해야 새 항목을 이 구조물에 지정할 수 있음", saveLine: "[적용] 뒤 구조물 [저장]을 눌러야 이 구조물 복사본이 남음", }; const fill = (text: string, value: string): string => text.replace("{value}", value); 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 detailView = createItemDetail({ projectId: opts.projectId ?? null, layerLabels: labels().layers, hint: t("M02_PickerHint"), unit: t("M02_PickerUnit"), structureId: opts.structureId ?? null, onEdit: (request) => openEdit(request), // 개인에 저장 — 나무를 다시 받고 새 항목을 고름(개인 그룹에 뜸) onCreated: (created) => void load(created.key), }); const cols = el("div", { className: "ui-item-picker__cols", children: [ el("div", { className: "ui-item-picker__left", children: [search.root, treeHost] }), detailView.root, ], }); const editHost = el("div", { className: "ui-item-picker__edit-host", attrs: { hidden: "" } }); let editView: EditView | null = null; 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 => { detailView.show(chosen); 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 menuOf = (node: PickNode): (() => TreeMenuItem[]) | undefined => { const folder = node.folder; if (!folder || folder.layer !== "personal" || !opts.projectId) return undefined; return () => [ { label: NEW_TEXT.menu, run: () => void createNew(folder.path), disabledReason: opts.structureId ? undefined : NEW_TEXT.noStructure, }, ]; }; const toTree = (node: PickNode, searching: boolean): TreeNode => ({ id: node.id, label: node.label, count: node.count, tip: node.tip, menu: menuOf(node), 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; } }); /** 프로젝트에서 새 항목 — 개인 층에도 만들고 돌려받은 `summary_item` 을 폼에 지정 */ async function createNew(path: string[]): Promise { if (!opts.projectId || !opts.structureId) return; const where = [labels().layers.personal, ...path].join(" › "); const got = await askFields({ title: NEW_TEXT.title, note: fill(NEW_TEXT.note, where), fields: [ { key: "name", label: NEW_TEXT.name, required: true }, { key: "unit", label: NEW_TEXT.unit, placeholder: "m · ㎥ · 개소", required: true }, ], okLabel: NEW_TEXT.ok, cancelLabel: NEW_TEXT.cancel, closeLabel: t("M02_Close"), emptyText: NEW_TEXT.empty, }); if (!got) return; try { const made: CreatedItem = await projectItems(opts.projectId).newItem( opts.structureId, path, got.name, got.unit, ); showToast(NEW_TEXT.made, "success"); finish({ action: "apply", item: made.summary_item as SummaryItem }); } catch (error) { showToast(fill(NEW_TEXT.failed, message(error)), "error"); // 회사 없음 409 = 「회사부터 등록」 그대로 } } const actions = el("div", { className: "ui-item-picker__actions", children: [ ...(opts.projectId ? [el("span", { className: "ui-item-picker__saveline", text: NEW_TEXT.saveLine })] : []), copyButton, manageButton, clearButton, applyButton, ], }); /** 카드 [수정] — 모달 안에서 편집 화면으로 바뀜(나무 · 카드 · 단추는 숨김) */ function openEdit(request: EditRequest): void { const view = createEditView(request, closeEdit); editView = view; editHost.replaceChildren(view.root); cols.hidden = true; actions.hidden = true; editHost.hidden = false; view.start(); } /** [뒤로] — 선택지로 돌아와 고른 항목을 다시 읽음(고친 값이 카드에 보임) */ function closeEdit(saved: boolean): void { editView?.destroy(); editView = null; editHost.hidden = true; cols.hidden = false; actions.hidden = false; if (saved) detailView.markEdited(); detailView.reload(); } close = openModal({ title: t("M02_PickerTitle"), closeLabel: t("M02_Close"), dialogClass: "ui-item-picker", // 닫기 단추 · Esc · 바깥 누르기 = 취소 — 편집 중 저장 안 한 고침이 있으면 물음 beforeClose: async () => { if (editView && !(await editView.leave())) return false; editView?.destroy(); resolve({ action: "cancel" }); return true; }, mount: (body) => { body.append(cols, editHost, actions); paintDetail(); void load(null); }, }).close; }); }