Files
Aislo/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts
T

374 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) {
// 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<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 };
}