/* ============================================================================= * M02_MasterTemplete_TreePanel.ts * 관리 화면(마스터 · 회사 · 개인 공용) 좌측 「항목 나무」 — 폴더 → … → 항목(깊이 자유) · 찾기. * 항목을 누르면 표에서 그 열이 골라짐(카드 넷은 표가 엶) · 표에서 열을 고르면 `select` 로 나무도 그 항목. * 오른쪽 클릭 · 키보드(F2 · Delete)로 폴더 · 항목 더하기 · 이름 바꾸기 · 옮기기 · 지우기 — * `POST /api/m02/layers/{층}/tree`(PLAN 26-1). 읽기만 층은 메뉴 · 키 없음. 나무 자료는 `_Model`. * ========================================================================== */ import "./M02_MasterTemplete_TreePanel.css"; import { createInputField, el, showConfirmDialog, showToast } from "@ui/ui_template_elements"; import { t as L } from "@ui/ui_template_locale"; import { createTreeRow, openTreeMenu, renderTree, type MakeRow, type TreeMenuItem, type TreeNode, } from "@ui/ui_template_tree"; import { StaleError, editTree, fetchTree, type Layer, type TreeEdit, type TreeItem, type TreeNote, type TreeView, } from "./M02_MasterTemplete_Api_Fetch"; import { askFolder, askText } from "./M02_MasterTemplete_TreePanel_Dialogs"; import { ancestorsOf, buildFolders, countItems, folderIdOf, folderOf, isUnder, itemIdOf, leafName, listFolders, samePath, type FolderNode, } from "./M02_MasterTemplete_TreePanel_Model"; export interface TreePanelOptions { layer: Layer; /** 이 층을 고칠 수 있나 — 아니면 메뉴 · 키 없음 */ canEdit: boolean; /** 항목을 누름 — 표에서 그 열을 고름 */ onPick: (item: TreeItem) => void; /** 고치기 전에 저장 안 한 표 고침을 처리 — 취소면 false · 저장했으면 "changed" */ settle: () => Promise; /** 나무가 바뀜 — 표를 다시 읽고 그 열(`colId`)을 고름 */ onChanged: (colId: string | null) => Promise; } export interface TreePanelHandle { root: HTMLElement; refresh: () => Promise; /** 표에서 고른 열을 나무에 표시(null = 지움) */ select: (colId: string | null) => void; } type Target = { kind: "folder"; path: string[] } | { kind: "item"; item: TreeItem }; const why = (error: unknown): string => (error instanceof Error ? error.message : ""); const hint = (text: string): HTMLElement => el("p", { className: "m02-tree__hint", text }); export function createTreePanel(opt: TreePanelOptions): TreePanelHandle { const layerLabel = L( opt.layer === "company" ? "M02_PickerCompany" : opt.layer === "personal" ? "M02_PickerPersonal" : "M02_PickerSystem", ); let view: TreeView | null = null; let text = ""; let activeCol: string | null = null; let seq = 0; let busy = false; const open = new Set([folderIdOf([])]); const targets = new Map(); const search = createInputField({ placeholder: L("M02_TreeSearch"), compact: true }); const body = el("div", { className: "m02-tree__body", attrs: { tabindex: "-1" } }); const root = el("div", { className: "m02-tree", children: [search.root, body] }); /* --- 그리기 --- */ const menuOf = (target: Target): TreeMenuItem[] => { const isTop = target.kind === "folder" && !target.path.length; return [ ...(target.kind === "folder" ? [ { label: L("M02_TreeAddFolder"), run: () => void addFolder(target.path) }, { label: L("M02_TreeAddItem"), run: () => void addItem(target.path) }, ] : []), ...(isTop ? [] : [ { label: L("M02_TreeRename"), run: () => void rename(target) }, { label: L("M02_TreeMove"), run: () => void move(target) }, { label: L("M02_Delete"), run: () => void remove(target) }, ]), ]; }; function node(target: Target, base: Omit, id: string): TreeNode { targets.set(id, target); return { ...base, id, ...(opt.canEdit ? { menu: () => menuOf(target) } : {}) }; } function folderNode(folder: FolderNode, searching: boolean): TreeNode { const id = folderIdOf(folder.path); const kids = [ ...folder.folders.map((one) => folderNode(one, searching)), ...folder.items.map((item) => node( { kind: "item", item }, { label: leafName(item), count: null, tip: `${item.이름} · ${item.key}`, run: () => opt.onPick(item), }, itemIdOf(item), ), ), ]; return node( { kind: "folder", path: folder.path }, { label: folder.path.length ? folder.path[folder.path.length - 1] : layerLabel, count: countItems(folder), children: kids, open: searching || open.has(id), run: () => void (open.has(id) ? open.delete(id) : open.add(id)), }, id, ); } const make: MakeRow = (one, caret, depth, onClick) => { const button = createTreeRow(one, caret, depth, onClick); button.dataset.node = one.id; button.classList.toggle("is-active", !!activeCol && one.id === `i|${activeCol}`); return button; }; function paint(): void { const focused = (document.activeElement as HTMLElement | null)?.dataset?.node; targets.clear(); if (!view) return body.replaceChildren(hint(L("M02_PickerLoading"))); const searching = text.trim() !== ""; const tree = buildFolders(view, text); const none = countItems(tree) === 0 && (searching || !tree.folders.length); body.replaceChildren( ...renderTree([folderNode(tree, searching)], make), ...(none ? [ hint( L(searching ? "M02_TreeNoHit" : opt.canEdit ? "M02_TreeEmptyEdit" : "M02_TreeEmpty"), ), ] : []), ); if (focused) rowOf(focused)?.focus({ preventScroll: true }); } const rowOf = (id: string): HTMLElement | null => [...body.querySelectorAll("[data-node]")].find((b) => b.dataset.node === id) ?? null; async function refresh(): Promise { const mine = ++seq; try { const got = await fetchTree(opt.layer); if (mine !== seq) return; view = got; } catch (error) { if (mine !== seq) return; view = null; body.replaceChildren(hint(L("M02_PickerLoadFailed").replace("{value}", why(error)))); return; } paint(); } function select(colId: string | null): void { activeCol = colId; const need = view && colId ? ancestorsOf(view, colId).filter((p) => !open.has(folderIdOf(p))) : []; if (need.length) { need.forEach((p) => open.add(folderIdOf(p))); paint(); } else { body .querySelectorAll("[data-node]") .forEach((b) => b.classList.toggle("is-active", !!colId && b.dataset.node === `i|${colId}`), ); } if (colId) rowOf(`i|${colId}`)?.scrollIntoView({ block: "nearest" }); } /* --- 고치기 --- */ /** 저장 안 한 표 고침 처리 → (바뀌었으면 나무 다시 읽음) → 참이면 계속 */ async function ready(): Promise { const settled = await opt.settle(); if (!settled) return false; if (settled === "changed") await refresh(); return true; } async function edit(body_: TreeEdit, keepCol: string | null = activeCol): Promise { if (!view || busy) return; busy = true; try { const got = await editTree(opt.layer, body_, view.판); view = got; const note: TreeNote | undefined = got.알림; // 새 항목은 그 열을 고름 · 지운 항목이면 고름 풀림 · 나머지는 지금 고른 열 그대로 const gone = note?.지운항목 ?? []; const col = note?.열 ?? (keepCol && !gone.includes(keepCol) ? keepCol : null); activeCol = col; const parent = note?.경로 ? note.경로.slice(0, -1) : []; parent.forEach((_, i) => open.add(folderIdOf(parent.slice(0, i + 1)))); if (note?.op === "add-folder" || note?.op === "rename" || note?.op === "move") { if (note.경로) open.add(folderIdOf(note.경로)); } paint(); showToast(L("M02_TreeDone"), "success"); await opt.onChanged(col); } catch (error) { // 409 = 판 다름(서버 detail 이 객체 → 「HTTP 409」) 또는 같은 이름(글 까닭 — 그대로 보임) if (error instanceof StaleError && error.message === "HTTP 409") { await refresh(); showToast(L("M02_TreeStale"), "error"); } else { showToast(L("M02_ActionFailed").replace("{value}", why(error)), "error"); } } finally { busy = false; } } const nameOf = (target: Target): string => target.kind === "item" ? leafName(target.item) : target.path[target.path.length - 1]; async function addFolder(parent: string[]): Promise { if (!(await ready())) return; const got = await askText(L("M02_TreeAddFolder"), [ { key: "name", label: L("M02_TreeFolderName") }, ]); if (got) await edit({ op: "add-folder", 경로: parent, 이름: got.name }); } async function addItem(parent: string[]): Promise { if (!(await ready())) return; const got = await askText(L("M02_TreeAddItem"), [ { key: "name", label: L("M02_TreeItemName") }, { key: "unit", label: L("M02_TreeUnit") }, ]); if (got) { await edit({ op: "add-item", 경로: parent, 이름: got.name, ...(got.unit ? { 단위: got.unit } : {}), }); } } async function rename(target: Target): Promise { if (!(await ready())) return; const got = await askText(L("M02_TreeRename"), [ { key: "name", label: L(target.kind === "item" ? "M02_TreeItemName" : "M02_TreeFolderName"), value: nameOf(target), }, ]); if (!got || got.name === nameOf(target)) return; await edit( target.kind === "item" ? { op: "rename", 경로: folderOf(target.item), 키: target.item.key, 이름: got.name } : { op: "rename", 경로: target.path, 이름: got.name }, ); } async function move(target: Target): Promise { if (!(await ready()) || !view) return; const from = target.kind === "item" ? folderOf(target.item) : target.path.slice(0, -1); const to = await askFolder( L("M02_TreeMove"), listFolders(buildFolders(view)), (path) => target.kind === "folder" && isUnder(path, target.path), from, ); if (!to || samePath(to, from)) return; await edit( target.kind === "item" ? { op: "move", 경로: from, 키: target.item.key, 새경로: to } : { op: "move", 경로: target.path, 새경로: to }, ); } async function remove(target: Target): Promise { if (!(await ready()) || !view) return; const folder = target.kind === "folder" ? findFolder(buildFolders(view), target.path) : undefined; const message = target.kind === "item" ? L("M02_TreeDeleteItem").replace("{value}", leafName(target.item)) : L("M02_TreeDeleteFolder") .replace("{value}", nameOf(target)) .replace("{n}", String(folder ? countItems(folder) : 0)); if (!(await showConfirmDialog(message, L("M02_Delete")))) return; await edit( target.kind === "item" ? { op: "delete", 경로: folderOf(target.item), 키: target.item.key } : { op: "delete", 경로: target.path }, ); } const findFolder = (from: FolderNode, path: string[]): FolderNode | undefined => samePath(from.path, path) ? from : from.folders.map((one) => findFolder(one, path)).find(Boolean); /* --- 이음 --- */ search.input.addEventListener("input", () => { text = search.input.value; paint(); }); // 빈 자리 오른쪽 클릭 = 맨 위 메뉴(폴더 · 항목 더하기) body.addEventListener("contextmenu", (event) => { if (!opt.canEdit || (event.target as HTMLElement).closest("[data-node]")) return; event.preventDefault(); openTreeMenu(event, menuOf({ kind: "folder", path: [] }).slice(0, 2)); }); body.addEventListener("keydown", (event) => { const row = (event.target as HTMLElement).closest("[data-node]"); if (!row) return; const rows = [...body.querySelectorAll("[data-node]")].filter( (b) => !b.closest("[hidden]"), ); const at = rows.indexOf(row); const caret = row.querySelector(".ui-tree__caret")?.textContent; const target = targets.get(row.dataset.node ?? ""); if (event.key === "ArrowDown" || event.key === "ArrowUp") { event.preventDefault(); rows[at + (event.key === "ArrowDown" ? 1 : -1)]?.focus(); } else if ( (event.key === "ArrowRight" && caret === "▸") || (event.key === "ArrowLeft" && caret === "▾") ) { event.preventDefault(); row.click(); } else if (opt.canEdit && target && event.key === "F2") { event.preventDefault(); if (!(target.kind === "folder" && !target.path.length)) void rename(target); } else if (opt.canEdit && target && event.key === "Delete") { event.preventDefault(); if (!(target.kind === "folder" && !target.path.length)) void remove(target); } }); return { root, refresh, select }; }