feat(M02): 관리 화면 항목 나무 — 좌측 폴더 나무 · 표 ↔ 나무 고름 · 오른쪽 클릭 더하기 · 이름 바꾸기 · 옮기기 · 지우기 (PLAN 26-3a)

- 좌측 「구조물 집계 양식」 아래 「항목 나무」 카드(세 층 공용 · 깊이 자유 · 검색 · 키보드 위아래 · 펼침 · F2 · Delete)
- 항목 누름 → 표 그 열 고름 + 카드 넷 · 표에서 열 고름 → 나무 표시
- 나무 고침 POST /api/m02/layers/{층}/tree · 판 409 다시 읽고 알림 · 저장 안 한 표 고침은 저장/버림/취소 물음
- 읽기만 층은 메뉴 · 키 없음 · 나무 메뉴 자리는 sub5 의 ui_template_tree 그대로
- 시험 test_m02_treepanel 8 · test_m02_layer_screen 문구 맞춤

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c
This commit is contained in:
2026-09-30 08:28:42 +09:00
co-authored by Claude Sonnet 5.5
parent 53d08087c9
commit 8f55366949
12 changed files with 882 additions and 5 deletions
@@ -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<TreeView> => call(`/layers/${layer}/tree`);
/** 판이 다르면 409(`StaleError`) — 부르는 쪽이 다시 읽음 */
export const editTree = (layer: Layer, edit: TreeEdit, 판: string): Promise<TreeView> =>
call(`/layers/${layer}/tree`, { method: "POST", body: JSON.stringify({ ...edit, 판 }) });
@@ -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);
}
@@ -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<false | "clean" | "changed">;
/** 나무가 바뀜 — 표를 다시 읽고 그 열(`colId`)을 고름 */
onChanged: (colId: string | null) => Promise<void>;
}
export interface TreePanelHandle {
root: HTMLElement;
refresh: () => Promise<void>;
/** 표에서 고른 열을 나무에 표시(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<string>([folderIdOf([])]);
const targets = new Map<string, Target>();
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<TreeNode, "id" | "menu">, 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<HTMLElement>("[data-node]")].find((b) => b.dataset.node === id) ??
null;
async function refresh(): Promise<void> {
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<HTMLElement>("[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<boolean> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<HTMLElement>("[data-node]");
if (!row) return;
const rows = [...body.querySelectorAll<HTMLElement>("[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 };
}
@@ -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<Record<string, string> | null> {
return new Promise((resolve) => {
let done = false;
const finish = (value: Record<string, string> | 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<string, string> = {};
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<string[] | null> {
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<Settle> {
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] }));
},
});
});
}
@@ -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));
}
@@ -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<boolean>;
/** 양식 밖 화면(좌측 자재 조합 상세)을 띄움 — 머리는 제목만 · 고침 표시 손잡이를 돌려줌 */
showView: (label: string, node: HTMLElement) => (dirty: boolean) => void;
/** 구조물집계표를 열고 그 열을 고름 — 이미 표면 다시 안 읽음(`fresh` 면 다시 읽음) */
openTable: (layer: Layer, colId?: string | null, fresh?: boolean) => Promise<void>;
/** 나무를 고치기 전 — 저장 안 한 고침이 있으면 저장 · 버림 · 취소를 물음 */
settle: () => Promise<false | "clean" | "changed">;
}
/** 이 층에서 고칠 수 있나 — `isAdmin` = 이 화면 층의 고칠 권한(페이지가 층별로 정함) */
@@ -501,6 +506,28 @@ export function createMain(
return (d) => (dirty = d);
}
async function openTable(layer: Layer, colId?: string | null, fresh = false): Promise<void> {
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<false | "clean" | "changed"> {
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 };
}
@@ -39,7 +39,13 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<void>
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<void>
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" });
@@ -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<boolean>;
/** 메인 칸에 양식 밖 화면(자재 조합 상세)을 띄움 */
showView: (label: string, node: HTMLElement) => (dirty: boolean) => void;
/** 항목 나무(26-3a) — 항목 누름 · 고치기 전 저장 처리 · 고친 뒤 표 다시 읽기 */
tree: Pick<TreePanelOptions, "onPick" | "settle" | "onChanged">;
}
export interface SideHandle {
@@ -78,6 +81,8 @@ export interface SideHandle {
refresh: () => Promise<void>;
/** 고름 표시를 지금 열린 것 한 줄로 — 편집 칸이 무엇을 열든(카드 [도면 수정] 등) 부름 */
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 };
}
+54
View File
@@ -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,
}),
);
+1 -1
View File
@@ -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)
+80
View File
@@ -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"] == ["돌기슭막이", "메쌓기"]
+38
View File
@@ -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;