diff --git a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts index 52499b89..3a3d6919 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts @@ -403,3 +403,39 @@ export const saveInputLinks = ( 줄: InputLine[], ): Promise<{ 판: string }> => call(itemPath(layer, key, "input-links"), { method: "PUT", body: JSON.stringify({ 판, 줄 }) }); + +/* --- 항목 나무(PLAN 26-1 · 26-3a) — 폴더 경로 깊이 자유 · 고침은 층 쓰기 권한 --- */ +export interface TreeItem { + key: string; + 열: string; + 경로: string[]; + 이름: string; + 단위?: string | null; + 일위대가?: string | null; +} +export interface TreeNote { + op: string; + 경로?: string[]; + 열?: string; + 지운항목?: string[]; +} +export interface TreeView { + 판: string; + 폴더: string[][]; + items: TreeItem[]; + 알림?: TreeNote; +} +export interface TreeEdit { + op: "add-folder" | "add-item" | "rename" | "move" | "delete"; + 경로?: string[]; + 이름?: string; + 새경로?: string[]; + 키?: string; + 단위?: string; +} + +export const fetchTree = (layer: Layer): Promise => call(`/layers/${layer}/tree`); + +/** 판이 다르면 409(`StaleError`) — 부르는 쪽이 다시 읽음 */ +export const editTree = (layer: Layer, edit: TreeEdit, 판: string): Promise => + call(`/layers/${layer}/tree`, { method: "POST", body: JSON.stringify({ ...edit, 판 }) }); diff --git a/M02_MasterTemplete/M02_MasterTemplete_TreePanel.css b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.css new file mode 100644 index 00000000..7efac906 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.css @@ -0,0 +1,40 @@ +/* 관리 화면 항목 나무 — 줄 모양은 공용 ui-tree · ui-sidebar-row */ +.m02-tree { + padding-top: var(--spacing-8); + display: flex; + flex-direction: column; + gap: var(--spacing-8); + min-width: 0; +} + +.m02-tree__body { + display: flex; + flex-direction: column; + gap: 2px; + max-height: 55vh; + min-height: 4rem; + overflow: auto; + outline: none; +} + +.m02-tree__body .ui-tree__row { + min-width: 0; + overflow-wrap: anywhere; +} + +.m02-tree__hint { + margin: 0; + padding: var(--spacing-8); + color: var(--color-text-muted); + font-size: var(--text-body-sm); +} + +.m02-treedlg { + width: min(24rem, calc(100vw - 2 * var(--spacing-16))); +} + +.m02-treedlg .ui-modal__body { + display: flex; + flex-direction: column; + gap: var(--spacing-12); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts new file mode 100644 index 00000000..80ece6bb --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts @@ -0,0 +1,372 @@ +/* ============================================================================= + * 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) { + if (error instanceof StaleError) { + 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"), optional: true }, + ]); + 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 }; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Dialogs.ts b/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Dialogs.ts new file mode 100644 index 00000000..962b31c4 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Dialogs.ts @@ -0,0 +1,142 @@ +/* ============================================================================= + * M02_MasterTemplete_TreePanel_Dialogs.ts + * 항목 나무 오른쪽 클릭 메뉴가 띄우는 작은 창 — 글 받기 · 옮길 폴더 고르기 · 저장/버림 물음. + * 모두 약속(Promise)으로 답함 — 닫기 · Esc · 바깥 누르기 = 취소(null / "cancel"). + * ========================================================================== */ + +import { + createButton, + createInputField, + createSelectField, + el, + showToast, +} from "@ui/ui_template_elements"; +import { t as L } from "@ui/ui_template_locale"; +import { openModal } from "@ui/ui_template_modal"; + +export interface TextField { + key: string; + label: string; + value?: string; + /** 비워도 됨(항목 단위) */ + optional?: boolean; +} + +/** 글 칸 하나 이상을 받음 — 확인이면 칸 이름 → 글(앞뒤 공백 뺌) */ +export function askText( + title: string, + fields: TextField[], + okLabel = L("M02_TreeOk"), +): Promise | null> { + return new Promise((resolve) => { + let done = false; + const finish = (value: Record | null): void => { + if (done) return; + done = true; + resolve(value); + }; + openModal({ + title, + closeLabel: L("M02_Close"), + dialogClass: "m02-modal m02-treedlg", + beforeClose: () => (finish(null), true), + mount: (body, close) => { + const inputs = fields.map((f) => ({ + f, + field: createInputField({ label: f.label, value: f.value }), + })); + const ok = createButton({ label: okLabel, variant: "filled" }); + const submit = (): void => { + const out: Record = {}; + for (const { f, field } of inputs) { + out[f.key] = field.input.value.trim(); + if (!out[f.key] && !f.optional) return showToast(L("M02_NameNeeded"), "error"); + } + finish(out); + close(); + }; + ok.addEventListener("click", submit); + for (const { field } of inputs) { + field.input.addEventListener("keydown", (event) => { + if (event.key === "Enter" && !event.isComposing) submit(); + }); + } + body.append(...inputs.map(({ field }) => field.root), ok); + inputs[0]?.field.input.select(); + }, + }); + }); +} + +/** 옮길 폴더 고르기 — `folders` 에서 `blocked` 인 것은 뺌 · 맨 위(폴더 밖)는 늘 있음 */ +export function askFolder( + title: string, + folders: string[][], + blocked: (path: string[]) => boolean, + current: string[], +): Promise { + return new Promise((resolve) => { + let done = false; + const finish = (value: string[] | null): void => { + if (done) return; + done = true; + resolve(value); + }; + const choices = [[], ...folders.filter((path) => !blocked(path))]; + openModal({ + title, + closeLabel: L("M02_Close"), + dialogClass: "m02-modal m02-treedlg", + beforeClose: () => (finish(null), true), + mount: (body, close) => { + const field = createSelectField({ + label: L("M02_TreeMoveTo"), + options: choices.map((path, i) => ({ + value: String(i), + text: path.length ? path.join(" / ") : L("M02_TreeTop"), + })), + value: String(choices.findIndex((path) => path.join("|") === current.join("|"))), + }); + const ok = createButton({ label: L("M02_TreeOk"), variant: "filled" }); + ok.addEventListener("click", () => { + finish(choices[Number(field.select.value)] ?? []); + close(); + }); + body.append(field.root, ok); + }, + }); + }); +} + +export type Settle = "save" | "discard" | "cancel"; + +/** 저장 안 한 표 고침 — 저장하고 계속 · 버리고 계속 · 취소 */ +export function askSettle(): Promise { + return new Promise((resolve) => { + let done = false; + const finish = (value: Settle): void => { + if (done) return; + done = true; + resolve(value); + }; + openModal({ + title: L("M02_TreeUnsaved"), + closeLabel: L("M02_TreeCancel"), + dialogClass: "m02-modal m02-treedlg", + beforeClose: () => (finish("cancel"), true), + mount: (body, close) => { + const choose = (value: Settle) => () => { + finish(value); + close(); + }; + const save = createButton({ label: L("M02_TreeSaveGo"), variant: "filled" }); + const discard = createButton({ label: L("M02_TreeDiscardGo"), variant: "danger" }); + const cancel = createButton({ label: L("M02_TreeCancel"), variant: "ghost" }); + save.addEventListener("click", choose("save")); + discard.addEventListener("click", choose("discard")); + cancel.addEventListener("click", choose("cancel")); + body.append(el("div", { className: "m02-side__row", children: [save, discard, cancel] })); + }, + }); + }); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Model.ts b/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Model.ts new file mode 100644 index 00000000..4643cf08 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Model.ts @@ -0,0 +1,67 @@ +/* ============================================================================= + * M02_MasterTemplete_TreePanel_Model.ts + * 관리 화면 항목 나무의 자료 쪽(화면 없음) — 서버 나무(`폴더` + `items`)를 폴더 나무로 · 찾기 · + * 폴더 목록. 항목 경로는 폴더 이름 배열 + 마지막 마디가 항목 이름(PLAN 26-1 · 깊이 자유). + * ========================================================================== */ + +import type { TreeItem, TreeView } from "./M02_MasterTemplete_Api_Fetch"; + +export interface FolderNode { + /** 이 폴더까지의 경로 — 맨 위 = 빈 배열 */ + path: string[]; + folders: FolderNode[]; + items: TreeItem[]; +} + +export const samePath = (a: string[], b: string[]): boolean => + a.length === b.length && a.every((part, i) => part === b[i]); + +/** `path` 가 `folder` 와 같거나 그 안인가 */ +export const isUnder = (path: string[], folder: string[]): boolean => + folder.length <= path.length && folder.every((part, i) => part === path[i]); + +export const folderIdOf = (path: string[]): string => `f|${path.join("|")}`; +export const itemIdOf = (item: TreeItem): string => `i|${item.열}`; +export const leafName = (item: TreeItem): string => item.경로[item.경로.length - 1] ?? item.이름; +export const folderOf = (item: TreeItem): string[] => item.경로.slice(0, -1); + +/** 찾기 — 경로 · 키 · 일위대가 키에 모든 낱말이 들어 있는 항목(빈 글이면 전부) */ +export function matches(item: TreeItem, text: string): boolean { + const words = text.trim().toLowerCase().split(/\s+/).filter(Boolean); + const hay = [...item.경로, item.key, item.일위대가 ?? ""].join(" ").toLowerCase(); + return words.every((word) => hay.includes(word)); +} + +/** 폴더 나무 — 찾는 글이 있으면 맞는 항목과 그 위 폴더만 · 없으면 빈 폴더까지 전부 */ +export function buildFolders(view: TreeView, text = ""): FolderNode { + const searching = text.trim() !== ""; + const root: FolderNode = { path: [], folders: [], items: [] }; + const at = (path: string[]): FolderNode => + 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: [], items: [] }; + node.folders.push(next); + } + return next; + }, root); + if (!searching) view.폴더.forEach(at); + for (const item of view.items) if (matches(item, text)) at(folderOf(item)).items.push(item); + return root; +} + +export const countItems = (node: FolderNode): number => + node.items.length + node.folders.reduce((sum, f) => sum + countItems(f), 0); + +/** 모든 폴더 경로(맨 위 제외) — 옮길 곳 고르기 */ +export function listFolders(root: FolderNode): string[][] { + return root.folders.flatMap((f) => [f.path, ...listFolders(f)]); +} + +/** 그 열이 들어 있는 폴더 길 — 나무를 그 항목까지 펼칠 때 */ +export function ancestorsOf(view: TreeView, colId: string): string[][] { + const item = view.items.find((one) => one.열 === colId); + if (!item) return []; + const folder = folderOf(item); + return folder.map((_, i) => folder.slice(0, i + 1)); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts index cad56f18..93af24d6 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts @@ -24,6 +24,7 @@ import { type StructurePanelHandle, } from "./M02_MasterTemplete_Structure"; import { inputPills } from "./M02_MasterTemplete_Inputs"; +import { askSettle } from "./M02_MasterTemplete_TreePanel_Dialogs"; import { buildComboBar } from "./M02_MasterTemplete_Combo"; import { buildComboPanel, type ComboPanel } from "./M02_MasterTemplete_ComboPanel"; import { @@ -91,6 +92,10 @@ export interface MainHandle { confirmLeave: () => Promise; /** 양식 밖 화면(좌측 자재 조합 상세)을 띄움 — 머리는 제목만 · 고침 표시 손잡이를 돌려줌 */ showView: (label: string, node: HTMLElement) => (dirty: boolean) => void; + /** 구조물집계표를 열고 그 열을 고름 — 이미 표면 다시 안 읽음(`fresh` 면 다시 읽음) */ + openTable: (layer: Layer, colId?: string | null, fresh?: boolean) => Promise; + /** 나무를 고치기 전 — 저장 안 한 고침이 있으면 저장 · 버림 · 취소를 물음 */ + settle: () => Promise; } /** 이 층에서 고칠 수 있나 — `isAdmin` = 이 화면 층의 고칠 권한(페이지가 층별로 정함) */ @@ -501,6 +506,28 @@ export function createMain( return (d) => (dirty = d); } + async function openTable(layer: Layer, colId?: string | null, fresh = false): Promise { + const inTable = sel?.kind === "table" && sel.name === STRUCTURE_TABLE && !fresh; + if (!inTable) { + if (!fresh && !(await confirmLeave())) return; + await open({ layer, projectId: null, kind: "table", name: STRUCTURE_TABLE }); + } + if (colId && sel?.kind === "table") sheet?.selectColumn(colId); + } + + async function settle(): Promise { + if (!dirty) return "clean"; + const choice = await askSettle(); + if (choice === "cancel") return false; + if (choice === "save") { + await doSave(); + return dirty ? false : "changed"; // 저장이 막히면(판 다름 등) 나무도 안 고침 + } + dirty = false; // 버림 — 화면을 서버 그대로 다시 읽음 + if (sel) await open(sel); + return "changed"; + } + save.addEventListener("click", () => void doSave()); back.addEventListener("click", async () => { const column = sel?.name; @@ -518,5 +545,5 @@ export function createMain( }); bar(); empty(""); - return { root, open, current: () => sel, confirmLeave, showView }; + return { root, open, current: () => sel, confirmLeave, showView, openTable, settle }; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts index eca6beac..0870ed02 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts @@ -39,7 +39,13 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise isAdmin, () => void side.refresh(), () => side.paint(), - { top: readOnlyBar?.root, onColumn: (column) => readOnlyBar?.setColumn(column) }, + { + top: readOnlyBar?.root, + onColumn: (column) => { + readOnlyBar?.setColumn(column); + side.selectItem(column); + }, + }, ); // 첫 화면 · 닫은 뒤(양식 지움 · 자재 조합 상세 닫음) = 구조물집계표 — 빈 화면 없음 const home: Selection = { @@ -55,6 +61,11 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise getOpen: main.current, confirmLeave: main.confirmLeave, showView: main.showView, + tree: { + onPick: (item) => void main.openTable(layer, item.열), + settle: main.settle, + onChanged: (colId) => main.openTable(layer, colId, true), + }, }); const layout = el("div", { className: "ui-workflow-layout m02-master" }); diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts index 4597dd7a..73b1a840 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts @@ -38,6 +38,7 @@ import { } from "./M02_MasterTemplete_Structure"; import { buildMatsetSection } from "./M02_MasterTemplete_Matset"; import { buildOptionsSection } from "./M02_MasterTemplete_Options"; +import { createTreePanel, type TreePanelOptions } from "./M02_MasterTemplete_TreePanel"; import { canEdit, type Selection } from "./M02_MasterTemplete_UI_Main"; const KINDS: { kind: Kind; label: () => string }[] = [ @@ -71,6 +72,8 @@ export interface SideOptions { confirmLeave: () => Promise; /** 메인 칸에 양식 밖 화면(자재 조합 상세)을 띄움 */ showView: (label: string, node: HTMLElement) => (dirty: boolean) => void; + /** 항목 나무(26-3a) — 항목 누름 · 고치기 전 저장 처리 · 고친 뒤 표 다시 읽기 */ + tree: Pick; } export interface SideHandle { @@ -78,6 +81,8 @@ export interface SideHandle { refresh: () => Promise; /** 고름 표시를 지금 열린 것 한 줄로 — 편집 칸이 무엇을 열든(카드 [도면 수정] 등) 부름 */ paint: () => void; + /** 표에서 고른 열을 항목 나무에 표시 */ + selectItem: (colId: string | null) => void; } /** 서버 이름 규칙과 같게 — 화면에서 먼저 막음 */ @@ -150,10 +155,15 @@ export function buildSide(opt: SideOptions): SideHandle { ); summary.classList.remove("is-collapsed"); summary.classList.add("m02-side__summary"); + // 항목 나무 — 구조물 집계 양식 아래 · 고르기 모달과 같은 폴더 나무(세 층 공용) + const tree = createTreePanel({ layer, canEdit: canEdit(layer, opt.isAdmin), ...opt.tree }); + const treeSection = section(L("M02_TreeTitle"), tree.root); + treeSection.classList.remove("is-collapsed"); const listHost = el("div", { className: "m02-side__lists ui-sidebar-panel", children: [ summary, + treeSection, // 구조물 옵션 보이기 · 도면 양식은 시스템 층(마스터 템플릿)에만 ...(layer === "system" ? [ @@ -279,7 +289,7 @@ export function buildSide(opt: SideOptions): SideHandle { if (mine !== seq) return; paintList(); paintButtons(); - await Promise.all([refreshStructure(), matset.refresh()]); + await Promise.all([refreshStructure(), matset.refresh(), tree.refresh()]); } /* --- 시스템 층 새로 · 본떠 · 지우기(도면 양식만 — 구조물 도면은 집계표 열이 정함) --- */ @@ -360,5 +370,5 @@ export function buildSide(opt: SideOptions): SideHandle { } void refresh(); - return { root, refresh, paint }; + return { root, refresh, paint, selectItem: tree.select }; } diff --git a/resources/tester/helper_m02_treepanel.cjs b/resources/tester/helper_m02_treepanel.cjs new file mode 100644 index 00000000..2b7a3be9 --- /dev/null +++ b/resources/tester/helper_m02_treepanel.cjs @@ -0,0 +1,54 @@ +/* M02 항목 나무 자료(`M02_MasterTemplete_TreePanel_Model.ts`) 단위검증 헬퍼 — 트랜스파일해 Node 로 돌리고 JSON 으로 냄. */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const file = path.join(__dirname, "..", "..", "M02_MasterTemplete", "M02_MasterTemplete_TreePanel_Model.ts"); +const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText; +const box = { exports: {} }; +new Function("exports", "module", "require", js)(box.exports, box, require); +const m = box.exports; + +const item = (열, 경로, key) => ({ key, 열, 경로, 이름: 경로.join(" "), 단위: "m", 일위대가: null }); +const view = { + 판: "v1", + 폴더: [["빈 폴더"], ["돌기슭막이", "빈 아래"]], + items: [ + item("c1", ["돌기슭막이", "메쌓기", "1.2m"], "SS000001"), + item("c2", ["돌기슭막이", "찰쌓기"], "SS000002"), + item("c3", ["다섯", "둘", "셋", "넷", "잎"], "SS000003"), + item("c4", ["최상위 항목"], "SS000004"), + ], +}; +const shape = (n) => ({ + path: n.path, + items: n.items.map((i) => i.열), + folders: n.folders.map(shape), +}); +const all = m.buildFolders(view); +const found = m.buildFolders(view, "찰쌓기"); + +process.stdout.write( + JSON.stringify({ + all: shape(all), + count: m.countItems(all), + found: shape(found), + foundCount: m.countItems(found), + none: shape(m.buildFolders(view, "없는말")), + folders: m.listFolders(all), + ancestors: m.ancestorsOf(view, "c3"), + ancestorsTop: m.ancestorsOf(view, "c4"), + ancestorsNone: m.ancestorsOf(view, "zz"), + under: [ + m.isUnder(["a", "b"], ["a"]), + m.isUnder(["a"], ["a"]), + m.isUnder(["a"], ["a", "b"]), + m.isUnder(["x"], ["a"]), + ], + leaf: m.leafName(view.items[0]), + dir: m.folderOf(view.items[0]), + byKey: m.buildFolders(view, "ss000003").items.length + m.buildFolders(view, "SS000003").folders.length, + }), +); diff --git a/resources/tester/test_m02_layer_screen.py b/resources/tester/test_m02_layer_screen.py index febeedee..6bd2f25f 100644 --- a/resources/tester/test_m02_layer_screen.py +++ b/resources/tester/test_m02_layer_screen.py @@ -104,7 +104,7 @@ def test_층_길_접두어와_고정_풀림(): def test_읽기만_회사용_개인용으로_복사_막대(): page = _text(M02 / "M02_MasterTemplete_UI_Page.ts") assert 'layer === "company" && !isAdmin ? buildReadOnlyBar(user)' in page - assert "onColumn: (column) => readOnlyBar?.setColumn(column)" in page + assert "readOnlyBar?.setColumn(column)" in page # 열 고름 → 막대 · 항목 나무가 함께 받음 assert "access.needCompany" in page and "buildNeedCompany()" in page assert "titleOf(layer)" in page layer = _text(LAYER) diff --git a/resources/tester/test_m02_treepanel.py b/resources/tester/test_m02_treepanel.py new file mode 100644 index 00000000..798f1391 --- /dev/null +++ b/resources/tester/test_m02_treepanel.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8 -*- +"""M02 관리 화면 항목 나무(PLAN 26-3a) 자료 셈 회귀검증 — 폴더 나무 · 빈 폴더 · 깊이 5 · 찾기 · 폴더 목록. + +항목 경로 = 폴더 이름 배열 + 마지막 마디가 항목 이름 · 찾는 글이 있으면 맞는 항목과 위 폴더만. +Node 헬퍼(`helper_m02_treepanel.cjs`)가 TS 를 트랜스파일해 돌리고, 여기서 값을 판정한다. +""" + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) + + +@pytest.fixture(scope="module") +def results(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_m02_treepanel.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_빈_폴더도_나무에_있음(results): + tops = [f["path"] for f in results["all"]["folders"]] + assert ["빈 폴더"] in tops + dol = next(f for f in results["all"]["folders"] if f["path"] == ["돌기슭막이"]) + assert ["돌기슭막이", "빈 아래"] in [f["path"] for f in dol["folders"]] + + +def test_맨_위_항목은_폴더_밖(results): + assert results["all"]["items"] == ["c4"] + assert results["count"] == 4 + + +def test_깊이_5_항목까지_이어짐(results): + node = next(f for f in results["all"]["folders"] if f["path"] == ["다섯"]) + for depth in range(2, 5): + node = node["folders"][0] + assert len(node["path"]) == depth + assert node["items"] == ["c3"] + + +def test_찾기는_맞는_항목과_위_폴더만(results): + assert results["foundCount"] == 1 + assert [f["path"] for f in results["found"]["folders"]] == [["돌기슭막이"]] + assert results["found"]["folders"][0]["items"] == ["c2"] # 빈 폴더는 안 보임 + assert results["none"] == {"path": [], "items": [], "folders": []} + + +def test_키로도_찾음(results): + assert results["byKey"] == 1 + + +def test_폴더_목록과_위_길(results): + assert ["다섯", "둘", "셋", "넷"] in results["folders"] + assert ["빈 폴더"] in results["folders"] + assert results["ancestors"] == [ + ["다섯"], + ["다섯", "둘"], + ["다섯", "둘", "셋"], + ["다섯", "둘", "셋", "넷"], + ] + assert results["ancestorsTop"] == [] + assert results["ancestorsNone"] == [] + + +def test_안_또는_같음_판정(results): + assert results["under"] == [True, True, False, False] + + +def test_잎_이름과_폴더(results): + assert results["leaf"] == "1.2m" + assert results["dir"] == ["돌기슭막이", "메쌓기"] diff --git a/ui_template/ui_template_locale_m2.ts b/ui_template/ui_template_locale_m2.ts index f9b30d72..e6db41f1 100644 --- a/ui_template/ui_template_locale_m2.ts +++ b/ui_template/ui_template_locale_m2.ts @@ -97,4 +97,42 @@ export const ui_locales_m2 = { M02_PickerManage: ["관리 화면에서 고치기", "Edit in manager"], M02_PickerCopied: ["개인용으로 복사했음", "Copied to personal"], M02_PickerCopyFailed: ["복사 못 함 — {value}", "Copy failed — {value}"], + M02_TreeTitle: ["항목 나무", "Item tree"], + M02_TreeSearch: ["항목 찾기", "Find item"], + M02_TreeEmptyEdit: [ + "항목이 없음 — 맨 위 줄을 오른쪽 클릭해 폴더 · 항목을 더함", + "No items — right-click the top row to add a folder or item", + ], + M02_TreeEmpty: ["항목이 없음", "No items"], + M02_TreeNoHit: ["찾은 항목이 없음", "No matching item"], + M02_TreeAddFolder: ["폴더 더하기", "Add folder"], + M02_TreeAddItem: ["항목 더하기", "Add item"], + M02_TreeRename: ["이름 바꾸기", "Rename"], + M02_TreeMove: ["옮기기", "Move"], + M02_TreeFolderName: ["폴더 이름", "Folder name"], + M02_TreeItemName: ["항목 이름", "Item name"], + M02_TreeUnit: ["단위", "Unit"], + M02_TreeMoveTo: ["옮길 폴더", "Move to"], + M02_TreeTop: ["맨 위 (폴더 밖)", "Top (no folder)"], + M02_TreeOk: ["확인", "OK"], + M02_TreeCancel: ["취소", "Cancel"], + M02_TreeDeleteFolder: [ + "「{value}」 폴더와 안의 항목 {n}개를 지움 — 이미 쓰는 프로젝트에는 영향 없음(프로젝트는 복사본을 씀)", + "Delete folder “{value}” and its {n} item(s) — projects already using them are not affected (they keep their own copies)", + ], + M02_TreeDeleteItem: [ + "「{value}」 항목을 지움 — 이미 쓰는 프로젝트에는 영향 없음(프로젝트는 복사본을 씀)", + "Delete item “{value}” — projects already using it are not affected (they keep their own copies)", + ], + M02_TreeStale: [ + "그 사이 나무가 바뀜 — 다시 읽었음, 한 번 더 해 볼 것", + "The tree changed meanwhile — reloaded, try again", + ], + M02_TreeUnsaved: [ + "저장 안 한 표 고침이 있음 — 나무를 고치기 전에 처리", + "Unsaved table edits — settle them before changing the tree", + ], + M02_TreeSaveGo: ["저장하고 계속", "Save and continue"], + M02_TreeDiscardGo: ["버리고 계속", "Discard and continue"], + M02_TreeDone: ["나무를 고침", "Tree updated"], } as const;