- 구조물 저장은 세션 초안을 보냄(횡단 보기에서 한 지정 · 해제가 종단 보기 메모리 때문에 사라지던 것) - 관 시설 보관소는 서버 응답을 정본으로(집계 항목 칸이 없으면 미지정 · 옛 지정이 되살아나던 것) - 항목 더하기 · 새 항목 창: 빈 이름 · 빈 단위에 칸 빨간 테두리 + 한 줄 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr
373 lines
13 KiB
TypeScript
373 lines
13 KiB
TypeScript
/* =============================================================================
|
|
* 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") },
|
|
]);
|
|
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 };
|
|
}
|