/* ============================================================================= * M02_MasterTemplete_UI_Side_Precombo.ts * 좌측 「일위대가 사전조합」 컨테이너(50-3 ③ · 51-4 · 134-3) — 구조물 조합 아래 · 층 단추(페이지가 만듦 · * 페이지 층과 따로 · 마스터 · 회사 · 내 개인) + 그 층 사전조합 나무. * 나무 = 자유 폴더(158-1 · 구조물 조합 탐색기와 같은 꼴 · 깊이 자유 · 층마다 따로 · 자리 없는 문서 = 뿌리) — * 폴더 오른쪽 클릭 [새 폴더] · [여기에 새 사전조합] · [이름 바꾸기] · [지우기](빈 폴더만) · 빈 자리 오른쪽 * 클릭 = 뿌리 메뉴 · 문서 · 폴더 끌어 옮기기. 폴더 자리만 서버(`precombo-tree`)에 적음 — 문서 파일은 안 옮김. * 구분 · 상세구분은 문서 칸(상세 머리 · 고르기 창 거름 · `precomboClasses`) — 나무 폴더와 무관. * 문서 누름 = 편집 칸(UI_Main 종류 precombo = 일위대가 상세 모드) · 켜진 층 단추 다시 누름 = 목록표 * (`Library_List` · 생성 · 복사 · 삭제는 거기) · 그 층 주인(`owner`) · 고칠 권한(`canWrite`)은 고름에 실어 보냄. * ========================================================================== */ import { el, showConfirmDialog, showToast } from "@ui/ui_template_elements"; import { t as L } from "@ui/ui_template_locale"; import { createTreeRow, openTreeMenu, renderTree, type TreeMenuItem, type TreeNode, } from "@ui/ui_template_tree"; import { StaleError, editPrecomboTree, fetchPrecomboTree, listTemplates, newLibrary, type Kind, type Layer, type PrecomboTree, } from "./M02_MasterTemplete_Api_Fetch"; import { nameDialog } from "./M02_MasterTemplete_Library_List"; import type { Root } from "./M02_MasterTemplete_Layer"; import type { StructDef } from "./StructOptions/M02_SO_Def_Model"; import { tc } from "./M02_MasterTemplete_Labor_Text"; import { askText } from "./M02_MasterTemplete_TreePanel_Dialogs"; import { attachTreeDrag } from "./M02_MasterTemplete_TreePanel_Drag"; import { folderIdOf, samePath } from "./M02_MasterTemplete_TreePanel_Model"; import type { Target } from "./M02_MasterTemplete_TreePanel_Pick"; import type { Selection } from "./M02_MasterTemplete_UI_Main"; export type PrecomboKind = Extract; export const PRECOMBO_TITLE = "일위대가 사전조합"; export const isPrecombo = (kind: Kind): kind is PrecomboKind => kind === "precombo"; /** 여러 구조물에 쓰는 묶음(콘크리트 · 거푸집 등) — 군마다 첫 상세구분(134-5) */ export const COMMON = "공통"; /** 사전조합 구분 · 상세구분(134-5) — 구조물군 A~G › 그 군 구조물 종류 + 「공통」(원천 `구조물정의.json` · * B05 구조물 배치와 같은 갈래 · 꺼진 종류도 넣음) */ export const precomboClasses = ( def: Pick, ): { name: string; details: string[] }[] => Object.entries(def.구조물군) .filter(([key]) => /^[A-G]$/.test(key)) .map(([key, name]) => ({ name, details: [COMMON, ...new Set(def.types.filter((t) => t.group === key).map((t) => t.name))], })); /** 사전조합 층(134-3) — 페이지 층과 따로 · `owner` null = 자기 층 */ export interface PrecomboScope { root: Root; layer: Layer; owner: string | null; canWrite: boolean; } /** 그 층 목록표 고름(켜진 층 단추 다시 누름) */ export const precomboList = (scope: PrecomboScope): Selection => ({ layer: scope.layer, owner: scope.owner, canWrite: scope.canWrite, projectId: null, kind: "precombo", name: "", label: tc("Lib_PrecomboAll"), }); export interface PrecomboOptions { scope: () => PrecomboScope; /** 층 단추 줄(페이지가 만듦 · 그릴 때마다 새로 — 켜진 단추가 바뀜) */ bar: () => HTMLElement; confirmLeave: () => Promise; onOpen: (sel: Selection | null) => void; getOpen: () => Selection | null; } export interface PrecomboHandle { body: HTMLElement; /** 고름 표시만 */ paint: () => void; /** 층 단추 · 나무를 그 층 목록으로 다시 그림 */ refresh: () => Promise; } interface Row { 이름: string; 종류: string; 제목?: string | null; 구분?: string | null; 상세구분?: string | null; } export interface PrecomboFolder { path: string[]; folders: PrecomboFolder[]; docs: Row[]; } /** 폴더 나무(158-1) — 적어 둔 폴더(그 차례) + 문서 자리(없으면 뿌리 · 모르는 폴더면 그 폴더를 만듦) */ export function buildPrecomboFolders( rows: Row[], view: Pick, ): PrecomboFolder { const root: PrecomboFolder = { path: [], folders: [], docs: [] }; const at = (path: string[]): PrecomboFolder => path.reduce((node, name, i) => { let next = node.folders.find((f) => f.path[i] === name); if (!next) { next = { path: path.slice(0, i + 1), folders: [], docs: [] }; node.folders.push(next); } return next; }, root); view.폴더.forEach(at); for (const row of rows) at(view.자리[row.이름] ?? []).docs.push(row); return root; } /** 폴더 안 문서 수(아래 폴더까지) */ export const docCount = (node: PrecomboFolder): number => node.docs.length + node.folders.reduce((n, f) => n + docCount(f), 0); type TreeBody = Parameters[1]; const why = (error: unknown): string => (error instanceof Error ? error.message : ""); export function buildPrecomboSection(opt: PrecomboOptions): PrecomboHandle { const barHost = el("div"); const treeHost = el("div", { className: "ui-tree", attrs: { "data-precombo-tree": "" } }); const leaves = new Map(); const toggled = new Map(); // 손으로 펴고 접은 폴더 — 다시 그려도 그대로 const targets = new Map(); let rows: Row[] = []; let view: PrecomboTree | null = null; let seq = 0; let busy = false; const open = async (row: Row): Promise => { const at = opt.scope(); if (!(await opt.confirmLeave())) return; opt.onOpen({ ...precomboList(at), name: row.이름, label: row.제목 ? `${row.이름} ${row.제목}` : row.이름, }); }; /** 나무 고침 한 번 — 판 다름 = 다시 읽음 · 막힘 까닭(빈 폴더 아님 · 같은 이름) 그대로 */ const edit = async (body: TreeBody): Promise => { const at = opt.scope(); if (!view || busy) return; busy = true; try { view = await editPrecomboTree(at.layer, body, view.판, at.owner); for (const path of [body.경로, body.새경로]) path?.forEach((_, i) => toggled.set(folderIdOf(path.slice(0, i + 1)), true)); draw(); showToast(L("M02_TreeDone"), "success"); } catch (error) { 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 make = (path: string[]): void => { const at = opt.scope(); nameDialog(tc("Lib_NewHere"), "", async (name) => { const 문서 = { 이름: name, 구분: "", 상세구분: "", 단위: null, 담은로직: [] }; const got = await newLibrary(at.layer, "precombo", { 문서 }, at.owner); showToast(L("M02_Created"), "success"); await refresh(); if (path.length) await edit({ op: "move", 키: got.이름, 새경로: path }); await open({ 이름: got.이름, 종류: "precombo", 제목: name }); }); }; const addFolder = async (parent: string[]): Promise => { const got = await askText(L("M02_TreeAddFolder"), [ { key: "name", label: L("M02_TreeFolderName") }, ]); if (got) await edit({ op: "add-folder", 경로: parent, 이름: got.name }); }; const rename = async (path: string[]): Promise => { const was = path[path.length - 1]; const got = await askText(L("M02_TreeRename"), [ { key: "name", label: L("M02_TreeFolderName"), value: was }, ]); if (got && got.name !== was) await edit({ op: "rename", 경로: path, 이름: got.name }); }; /** 지우기 — 빈 폴더만(안에 문서가 있으면 알림만) */ const remove = async (path: string[], count: number): Promise => { if (count) return void showToast(tc("Lib_FolderFull").replace("{n}", String(count)), "error"); const ask = tc("Lib_FolderDelete").replace("{value}", path[path.length - 1]); if (await showConfirmDialog(ask, L("M02_Delete"))) await edit({ op: "delete", 경로: path }); }; const menuAt = (path: string[], count: number): TreeMenuItem[] => { const off = opt.scope().canWrite ? undefined : tc("ReadOnly"); const item = (label: string, run: () => void): TreeMenuItem => ({ label, run, disabledReason: off, }); return [ item(L("M02_TreeAddFolder"), () => void addFolder(path)), item(tc("Lib_NewHere"), () => make(path)), ...(path.length ? [ item(L("M02_TreeRename"), () => void rename(path)), item(L("M02_Delete"), () => void remove(path, count)), ] : []), ]; }; const leaf = (row: Row, folder: string[]): TreeNode => { const id = `doc:${row.이름}`; const label = row.제목 || row.이름; const item = { key: row.이름, 열: row.이름, 경로: [...folder, label], 이름: label }; targets.set(id, { kind: "item", item }); return { id, label, tip: row.이름, count: null, run: () => void open(row) }; }; const folderNode = (node: PrecomboFolder): TreeNode => { const id = folderIdOf(node.path); const count = docCount(node); targets.set(id, { kind: "folder", path: node.path }); return { id, label: node.path[node.path.length - 1], count, folder: true, open: toggled.get(id) ?? count > 0, children: kids(node), menu: () => menuAt(node.path, count), }; }; const kids = (node: PrecomboFolder): TreeNode[] => [ ...node.folders.map(folderNode), ...node.docs.map((row) => leaf(row, node.path)), ]; /** 끌어 옮기기(구조물 조합 나무와 같은 부품) — 폴더 줄 위 = 그 안 · 빈 자리 = 뿌리 · 제자리면 안 함 */ const drag = attachTreeDrag({ body: treeHost, targetOf: (id) => targets.get(id), listFor: (id) => (targets.has(id) ? [targets.get(id)!] : null), move: ([t], dest) => { const from = t.kind === "item" ? t.item.경로.slice(0, -1) : t.path.slice(0, -1); if (samePath(from, dest)) return; void edit( t.kind === "item" ? { op: "move", 키: t.item.key, 새경로: dest } : { op: "move", 경로: t.path, 새경로: dest }, ); }, }); const draw = (): void => { leaves.clear(); targets.clear(); const tree = buildPrecomboFolders(rows, view ?? { 폴더: [], 자리: {} }); if (!tree.docs.length && !tree.folders.length) return void treeHost.replaceChildren( el("p", { className: "m02-side__empty", text: tc("Lib_None") }), ); const canWrite = opt.scope().canWrite; treeHost.replaceChildren( ...renderTree(kids(tree), (node, caret, depth, onClick) => { const row = createTreeRow(node, caret, depth, () => { onClick(); if (!node.children?.length) return; toggled.set(node.id, row.querySelector(".ui-tree__caret")?.textContent !== "▸"); }); row.dataset.node = node.id; if (node.run) { row.dataset.precomboKey = node.id.slice(4); leaves.set(node.id.slice(4), row); } if (canWrite) drag.arm(row, node.id); return row; }), ); paint(); }; // 빈 자리 오른쪽 클릭 = 뿌리 메뉴(새 폴더 · 새 사전조합) treeHost.addEventListener("contextmenu", (event) => { if (!view || (event.target as HTMLElement).closest("[data-node]")) return; event.preventDefault(); openTreeMenu(event, menuAt([], 0)); }); const paint = (): void => { const cur = opt.getOpen(); const at = opt.scope(); const mine = cur && isPrecombo(cur.kind) && cur.layer === at.layer && (cur.owner ?? null) === at.owner; for (const [key, row] of leaves) row.classList.toggle("is-active", !!mine && cur.name === key); }; const refresh = async (): Promise => { const mine = ++seq; const at = opt.scope(); barHost.replaceChildren(opt.bar()); treeHost.replaceChildren(el("p", { className: "m02-side__empty", text: tc("Loading") })); const [got, tree] = await Promise.all([ // 못 받으면 빈 나무(목록표에서 까닭) · 폴더를 못 받으면 문서는 모두 뿌리 listTemplates(at.layer, null, at.owner).then( (list): Row[] => list.filter((r) => r.종류 === "precombo"), (): Row[] => [], ), fetchPrecomboTree(at.layer, at.owner).catch(() => null), ]); if (mine !== seq) return; rows = got; view = tree; draw(); }; const body = el("div", { className: "m02-side__items ui-sidebar-list", attrs: { "data-precombo": "" }, children: [barHost, treeHost], }); return { body, paint, refresh }; }