feat(716-4): 서고 나무 폴더 — 종류 인자 · 안에 든 것째 지움 · 쓰는 곳 409 · 여럿 · 복사 붙여넣기
- 서버 `M02_Precombo_Folders` — 종류 인자(precombo · sangeun · 자리 파일 `{종류}_폴더.json`) · 폴더 지우기 = 안의 문서 파일째(모두 검사 뒤 한 번에 · 바깥에서 쓰면 409 + 그 이름 · 지우는 것끼리 참조는 됨) · 문서 지우기 · `대상` 여럿 옮기기 · 지우기 · 붙여넣기(새 키 · 본뜬 = 원본 · 같은 폴더 이름 「(1)」)
- 길 `M02_Labor_Router` — `/layers/{층}/{종류}-tree` GET · POST · `/paste`(옛 precombo-tree 주소 그대로)
- 화면 새 `UI_Side_Library.ts` — 일위대가 · 단가산출 두 칸이 같이 쓰는 서고 칸(Ctrl · Shift 고름 · 오른쪽 클릭 · Ctrl+C · Ctrl+V · Delete · 지우기 확인 창 · 끌어 옮기기 여럿) · `UI_Side_Precombo.ts` = 일위대가 몫만 넘김
- `Api_Fetch` — 나무 부르기 종류 인자 · `pasteLibraryTree` · `newLibrary` 종류 sangeun
- 시험 — 새 `test_716_4_library_folders.py`(임시 storage) · 낡은 것 고침: `test_m02_precombo_folders`(빈 폴더만 지움 → 안에 든 것째 · 「없는 사전조합」 → 「없는 문서」 · FILE → file_of) · `test_m02_precombo_scope` + helper(나무 짜기 · 서고 길 글이 Library.ts 로 옮김)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,44 +1,22 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_UI_Side_Precombo.ts
|
||||
* 좌측 「일위대가」 컨테이너(50-3 ③ · 51-4 · 134-3) — 구조물 조합 아래 · 층 단추(페이지가 만듦 ·
|
||||
* 페이지 층과 따로 · 마스터 · 회사 · 내 개인) + 그 층 일위대가 나무.
|
||||
* 나무 = 자유 폴더(158-1 · 구조물 조합 탐색기와 같은 꼴 · 깊이 자유 · 층마다 따로 · 자리 없는 문서 = 뿌리) —
|
||||
* 폴더 오른쪽 클릭 [새 폴더] · [여기에 새 일위대가] · [이름 바꾸기] · [지우기](빈 폴더만) · 빈 자리 오른쪽
|
||||
* 클릭 = 뿌리 메뉴 · 문서 · 폴더 끌어 옮기기. 폴더 자리만 서버(`precombo-tree`)에 적음 — 문서 파일은 안 옮김.
|
||||
* 좌측 「일위대가」 칸(50-3 ③ · 51-4 · 134-3) — 서고 칸(`UI_Side_Library` · 716-4)에 일위대가 몫만 줌:
|
||||
* 종류 precombo · 그 층 목록표 고름 · 새 문서 몸 · 글. 층 단추는 페이지가 만듦(페이지 층과 따로).
|
||||
* 구분 · 상세구분은 문서 칸(상세 머리 · 고르기 창 거름 · `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 { TreeMenuItem } from "@ui/ui_template_tree";
|
||||
import type { Kind } from "./M02_MasterTemplete_Api_Fetch";
|
||||
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";
|
||||
import { createEmpty } from "@ui/ui_template_empty";
|
||||
import {
|
||||
buildLibrarySection,
|
||||
type LibraryHandle,
|
||||
type LibraryScope,
|
||||
} from "./M02_MasterTemplete_UI_Side_Library";
|
||||
|
||||
export type PrecomboKind = Extract<Kind, "precombo">;
|
||||
|
||||
@@ -60,12 +38,7 @@ export const precomboClasses = (
|
||||
}));
|
||||
|
||||
/** 사전조합 층(134-3) — 페이지 층과 따로 · `owner` null = 자기 층 */
|
||||
export interface PrecomboScope {
|
||||
root: Root;
|
||||
layer: Layer;
|
||||
owner: string | null;
|
||||
canWrite: boolean;
|
||||
}
|
||||
export type PrecomboScope = LibraryScope;
|
||||
|
||||
/** 그 층 목록표 고름(켜진 층 단추 다시 누름) */
|
||||
export const precomboList = (scope: PrecomboScope): Selection => ({
|
||||
@@ -89,257 +62,13 @@ export interface PrecomboOptions {
|
||||
more?: (key: string, name: string) => TreeMenuItem[];
|
||||
}
|
||||
|
||||
export interface PrecomboHandle {
|
||||
body: HTMLElement;
|
||||
/** 고름 표시만 */
|
||||
paint: () => void;
|
||||
/** 층 단추 · 나무를 그 층 목록으로 다시 그림 */
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
export type PrecomboHandle = LibraryHandle;
|
||||
|
||||
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<PrecomboTree, "폴더" | "자리">,
|
||||
): 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<typeof editPrecomboTree>[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<string, HTMLElement>();
|
||||
const toggled = new Map<string, boolean>(); // 손으로 펴고 접은 폴더 — 다시 그려도 그대로
|
||||
const targets = new Map<string, Target>();
|
||||
let rows: Row[] = [];
|
||||
let view: PrecomboTree | null = null;
|
||||
let seq = 0;
|
||||
let busy = false;
|
||||
|
||||
const open = async (row: Row): Promise<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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 });
|
||||
const menu = () => opt.more?.(row.이름, label) ?? [];
|
||||
return { id, label, tip: row.이름, count: null, run: () => void open(row), menu };
|
||||
};
|
||||
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 },
|
||||
);
|
||||
},
|
||||
export const buildPrecomboSection = (opt: PrecomboOptions): PrecomboHandle =>
|
||||
buildLibrarySection({
|
||||
...opt,
|
||||
kind: "precombo",
|
||||
list: precomboList,
|
||||
blank: (name) => ({ 이름: name, 구분: "", 상세구분: "", 단위: null, 담은로직: [] }),
|
||||
text: { none: tc("Lib_None"), newHere: tc("Lib_NewHere") },
|
||||
});
|
||||
|
||||
const draw = (): void => {
|
||||
leaves.clear();
|
||||
targets.clear();
|
||||
const tree = buildPrecomboFolders(rows, view ?? { 폴더: [], 자리: {} });
|
||||
if (!tree.docs.length && !tree.folders.length)
|
||||
return void treeHost.replaceChildren(createEmpty({ text: tc("Lib_None"), small: true }));
|
||||
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<void> => {
|
||||
const mine = ++seq;
|
||||
const at = opt.scope();
|
||||
barHost.replaceChildren(opt.bar());
|
||||
treeHost.replaceChildren(createEmpty({ text: tc("Loading"), small: true }));
|
||||
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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user