- 나무 = 구조물군 › 공통 + 종류 뼈대 먼저(빈 폴더도) · 목록 밖 값 뒤 · 미분류 맨 뒤 - 폴더 수 = 문서 수 · 문서 든 폴더만 처음 펼침 · 손으로 펴고 접은 것 기억 - 폴더 오른쪽 클릭 [여기에 새 사전조합] = 그 분류로 생성 → 상세 엶(못 쓰는 층은 흐림) - 이름 창(nameDialog) 목록표 밖으로 꺼내 함께 씀 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SE9MxeXTu2uaDZ55BJowAY
258 lines
9.7 KiB
TypeScript
258 lines
9.7 KiB
TypeScript
/* =============================================================================
|
|
* M02_MasterTemplete_UI_Side_Precombo.ts
|
|
* 좌측 「일위대가 사전조합」 컨테이너(50-3 ③ · 51-4 · 134-3) — 구조물 조합 아래 · 층 단추(페이지가 만듦 ·
|
|
* 페이지 층과 따로 · 마스터 · 회사 · 내 개인) + 그 층 사전조합 나무(구분 › 상세구분 2단 폴더 · 빈 구분 = 미분류).
|
|
* 폴더 = 분류 뼈대 고정(149-1 · 구조물군 › 공통 + 종류 · 빈 폴더도 · 문서 수 · 문서 든 폴더만 펼침) ·
|
|
* 문서 옮김 = 상세 머리에서 분류 바꿈 · 폴더 오른쪽 클릭 [여기에 새 사전조합] = 그 분류로 생성.
|
|
* 문서 누름 = 편집 칸(UI_Main 종류 precombo = 일위대가 상세 모드) · 켜진 층 단추 다시 누름 = 목록표
|
|
* (`Library_List` · 생성 · 복사 · 삭제는 거기) · 그 층 주인(`owner`) · 고칠 권한(`canWrite`)은 고름에 실어 보냄.
|
|
* ========================================================================== */
|
|
|
|
import { el, showToast } from "@ui/ui_template_elements";
|
|
import { t as L } from "@ui/ui_template_locale";
|
|
import { createTreeRow, renderTree, type TreeNode } from "@ui/ui_template_tree";
|
|
import { listTemplates, newLibrary, type Kind, type Layer } from "./M02_MasterTemplete_Api_Fetch";
|
|
import { nameDialog } from "./M02_MasterTemplete_Library_List";
|
|
import { fetchStructureDef } from "./StructOptions/M02_SO_Api";
|
|
import type { Root } from "./M02_MasterTemplete_Layer";
|
|
import type { StructDef } from "./StructOptions/M02_SO_Def_Model";
|
|
import { tc } from "./M02_MasterTemplete_Labor_Text";
|
|
import type { Selection } from "./M02_MasterTemplete_UI_Main";
|
|
|
|
export type PrecomboKind = Extract<Kind, "precombo">;
|
|
|
|
export const PRECOMBO_TITLE = "일위대가 사전조합";
|
|
export const isPrecombo = (kind: Kind): kind is PrecomboKind => kind === "precombo";
|
|
|
|
/** 여러 구조물에 쓰는 묶음(콘크리트 · 거푸집 등) — 군마다 첫 상세구분(134-5) */
|
|
export const COMMON = "공통";
|
|
|
|
/** 사전조합 구분 · 상세구분(134-5) — 구조물군 A~G › 그 군 구조물 종류 + 「공통」(원천 `구조물정의.json` ·
|
|
* B05 구조물 배치와 같은 갈래 · 꺼진 종류도 넣음) */
|
|
export const precomboClasses = (
|
|
def: Pick<StructDef, "구조물군" | "types">,
|
|
): { name: string; details: string[] }[] =>
|
|
Object.entries(def.구조물군)
|
|
.filter(([key]) => /^[A-G]$/.test(key))
|
|
.map(([key, name]) => ({
|
|
name,
|
|
details: [COMMON, ...new Set(def.types.filter((t) => t.group === key).map((t) => t.name))],
|
|
}));
|
|
|
|
/** 사전조합 층(134-3) — 페이지 층과 따로 · `owner` null = 자기 층 */
|
|
export interface PrecomboScope {
|
|
root: Root;
|
|
layer: Layer;
|
|
owner: string | null;
|
|
canWrite: boolean;
|
|
}
|
|
|
|
/** 그 층 목록표 고름(켜진 층 단추 다시 누름) */
|
|
export const precomboList = (scope: PrecomboScope): Selection => ({
|
|
layer: scope.layer,
|
|
owner: scope.owner,
|
|
canWrite: scope.canWrite,
|
|
projectId: null,
|
|
kind: "precombo",
|
|
name: "",
|
|
label: tc("Lib_PrecomboAll"),
|
|
});
|
|
|
|
export interface PrecomboOptions {
|
|
scope: () => PrecomboScope;
|
|
/** 층 단추 줄(페이지가 만듦 · 그릴 때마다 새로 — 켜진 단추가 바뀜) */
|
|
bar: () => HTMLElement;
|
|
confirmLeave: () => Promise<boolean>;
|
|
onOpen: (sel: Selection | null) => void;
|
|
getOpen: () => Selection | null;
|
|
}
|
|
|
|
export interface PrecomboHandle {
|
|
body: HTMLElement;
|
|
/** 고름 표시만 */
|
|
paint: () => void;
|
|
/** 층 단추 · 나무를 그 층 목록으로 다시 그림 */
|
|
refresh: () => Promise<void>;
|
|
}
|
|
|
|
interface Row {
|
|
이름: string;
|
|
종류: string;
|
|
제목?: string | null;
|
|
구분?: string | null;
|
|
상세구분?: string | null;
|
|
}
|
|
|
|
type Classes = { name: string; details: string[] }[];
|
|
|
|
/** 줄 → 구분 › 상세구분 2단 나무(빈 구분 = 미분류 · 맨 뒤 · 빈 상세구분 = 구분 바로 아래) ·
|
|
* `classes`(149-1) = 분류 뼈대 먼저(그 차례 · 빈 묶음도) → 목록 밖 값은 뒤에 가나다 */
|
|
export function groupRows(
|
|
rows: Row[],
|
|
unsorted: string,
|
|
classes: Classes = [],
|
|
): { 구분: string; 상세: Map<string, Row[]> }[] {
|
|
const out = new Map<string, Map<string, Row[]>>(
|
|
classes.map((c) => [c.name, new Map(c.details.map((d) => [d, []]))]),
|
|
);
|
|
for (const row of rows) {
|
|
const top = row.구분 || "";
|
|
const sub = row.구분 ? row.상세구분 || "" : "";
|
|
if (!out.has(top)) out.set(top, new Map());
|
|
const subs = out.get(top)!;
|
|
subs.set(sub, [...(subs.get(sub) ?? []), row]);
|
|
}
|
|
const order = (a: string, b: string): number =>
|
|
!a ? 1 : !b ? -1 : a.localeCompare(b, "ko-KR", { numeric: true });
|
|
const rank = (list: string[], key: string): number =>
|
|
list.includes(key) ? list.indexOf(key) : list.length;
|
|
const tops = classes.map((c) => c.name);
|
|
return [...out.keys()]
|
|
.sort((a, b) => rank(tops, a) - rank(tops, b) || order(a, b))
|
|
.map((top) => {
|
|
const subs = classes.find((c) => c.name === top)?.details ?? [];
|
|
return {
|
|
구분: top || unsorted,
|
|
상세: new Map(
|
|
[...out.get(top)!].sort(([a], [b]) => rank(subs, a) - rank(subs, b) || order(a, b)),
|
|
),
|
|
};
|
|
});
|
|
}
|
|
|
|
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>(); // 손으로 펴고 접은 폴더 — 다시 그려도 그대로
|
|
let rows: Row[] = [];
|
|
let classes: Promise<Classes> | null = null; // 분류 뼈대(구조물정의 · 한 번 받음 · 못 받으면 다음에 다시)
|
|
let shape: Classes = [];
|
|
let seq = 0;
|
|
|
|
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 leaf = (row: Row): TreeNode => ({
|
|
id: `doc:${row.이름}`,
|
|
label: row.제목 || row.이름,
|
|
tip: row.이름,
|
|
count: null,
|
|
run: () => void open(row),
|
|
});
|
|
/** 폴더 메뉴 — [여기에 새 사전조합] = 그 구분 · 상세구분으로 생성 → 나무 다시 · 상세 엶 */
|
|
const make = (구분: string, 상세구분: 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();
|
|
await open({ 이름: got.이름, 종류: "precombo", 제목: name });
|
|
});
|
|
};
|
|
const docs = (node: TreeNode): number =>
|
|
node.run ? 1 : (node.children ?? []).reduce((n, c) => n + docs(c), 0);
|
|
const folder = (
|
|
id: string,
|
|
label: string,
|
|
children: TreeNode[],
|
|
at: [string, string],
|
|
): TreeNode => {
|
|
const count = children.reduce((n, c) => n + docs(c), 0);
|
|
return {
|
|
id,
|
|
label,
|
|
count,
|
|
folder: true,
|
|
open: toggled.get(id) ?? count > 0,
|
|
children,
|
|
menu: () => [
|
|
{
|
|
label: tc("Lib_NewHere"),
|
|
run: () => make(...at),
|
|
disabledReason: opt.scope().canWrite ? undefined : tc("ReadOnly"),
|
|
},
|
|
],
|
|
};
|
|
};
|
|
|
|
const draw = (): void => {
|
|
leaves.clear();
|
|
if (!rows.length && !shape.length)
|
|
return void treeHost.replaceChildren(
|
|
el("p", { className: "m02-side__empty", text: tc("Lib_None") }),
|
|
);
|
|
const unsorted = tc("Lib_Unsorted");
|
|
const nodes = groupRows(rows, unsorted, shape).map((g) => {
|
|
const top = g.구분 === unsorted ? "" : g.구분;
|
|
return folder(
|
|
`sub:${g.구분}`,
|
|
g.구분,
|
|
[...g.상세].flatMap(([sub, list]) =>
|
|
sub ? [folder(`sub:${g.구분}/${sub}`, sub, list.map(leaf), [top, sub])] : list.map(leaf),
|
|
),
|
|
[top, top ? ([...g.상세.keys()][0] ?? "") : ""],
|
|
);
|
|
});
|
|
treeHost.replaceChildren(
|
|
...renderTree(nodes, (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 !== "▸");
|
|
});
|
|
if (node.run) {
|
|
row.dataset.precomboKey = node.id.slice(4);
|
|
leaves.set(node.id.slice(4), row);
|
|
}
|
|
return row;
|
|
}),
|
|
);
|
|
paint();
|
|
};
|
|
|
|
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(el("p", { className: "m02-side__empty", text: tc("Loading") }));
|
|
const shapeAt = (classes ??= fetchStructureDef().then(precomboClasses, () => {
|
|
classes = null;
|
|
return []; // 못 받으면 문서 값만으로 묶음(지난 모양)
|
|
}));
|
|
let got: Row[] = [];
|
|
try {
|
|
got = (await listTemplates(at.layer, null, at.owner)).filter((r) => r.종류 === "precombo");
|
|
} catch {
|
|
got = []; // 못 받으면 빈 나무(목록표에서 까닭)
|
|
}
|
|
const cls = await shapeAt;
|
|
if (mine !== seq) return;
|
|
rows = got;
|
|
shape = cls;
|
|
draw();
|
|
};
|
|
|
|
const body = el("div", {
|
|
className: "m02-side__items ui-sidebar-list",
|
|
attrs: { "data-precombo": "" },
|
|
children: [barHost, treeHost],
|
|
});
|
|
return { body, paint, refresh };
|
|
}
|