Files
Aislo/ui_template/ui_template_item_picker_data.ts
eomsangdonandClaude Sonnet 5.5 d6c85a5f28 feat(ui_template): 나무 오른쪽 클릭 메뉴 자리(menu · openTreeMenu) · 항목 경로 나무(폴더 깊이 자유) (PLAN 26-3b)
- TreeNode.menu = 메뉴 항목을 밖에서 주는 자리 · openTreeMenu 공용 작은 메뉴
- PickerRow.경로 · pathOf · buildTree 가 경로 앞부분을 폴더로 · PickNode.folder{layer,path}

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr
2026-09-30 08:05:12 +09:00

203 lines
7.4 KiB
TypeScript

/* =============================================================================
* ui_template_item_picker_data.ts
* 항목 고르기의 자료 쪽(화면 없음) — 항목 목록 → 나무 · 찾기 · 폴더 줄 · 돌려줄 `summary_item`.
* 모양은 PLAN.md 21-1 약속 그대로: 항목 목록 API 한 줄 = `PickerRow`, 참조 = `SummaryItem`.
* ========================================================================== */
export type PickerLayer = "system" | "company" | "personal";
/** 21-1 `summary_item` — 구조물 기록에 붙는 항목 참조 */
export interface SummaryItem {
layer: PickerLayer;
owner: string | null;
key: string;
name: string;
}
/** 항목 줄이 가리키는 키 칸 — 서버가 `names` 로 이름을 함께 주면 이름을 보임 */
export type NameField = "일위대가" | "자재조합" | "도번" | "면적번" | "재료번";
/** 목록 한 줄(21-1) — 서버는 `?all=1` 을 `[{layer, owner, items}]` 로 주고, 줄마다 `layer` · `owner` 를 붙여 폄.
* `열` 은 집계표 열 id(rv …) — 보이는 이름은 종류 · 분류를 이은 `itemName`. */
export interface PickerRow {
key: string;
열: string;
종류?: string;
분류1?: string;
분류2?: string;
분류3?: string;
단위?: string;
일위대가?: string;
자재조합?: string;
도번?: string;
면적번?: string;
재료번?: string;
/** 폴더 이름 배열(깊이 자유 · 26-1) — 없으면 종류 · 분류1~3 중 있는 것으로 이음 */
경로?: string[] | null;
/** 위 키 칸의 이름(21-9 서버가 더함) — 없으면 키를 작은 글로 보임 */
names?: Partial<Record<NameField, string | null>>;
layer?: PickerLayer;
owner?: string | null;
}
/** 그룹 차례 = 마스터 · 회사 · 개인 */
export const PICKER_LAYERS: readonly PickerLayer[] = ["system", "company", "personal"];
export interface PickNode {
id: string;
/** 어느 그룹 · 폴더 이름 배열 — 폴더 줄만(오른쪽 클릭 메뉴가 「누른 폴더」 를 앎) · 그룹 줄 = 빈 배열 */
folder?: { layer: PickerLayer; path: string[] };
/** 그룹 · 종류 · 분류 이름 — 잎은 항목 이름 */
label: string;
/** 마우스를 올렸을 때 — 잎은 키 */
tip?: string;
/** 아래 잎 수 — 잎은 null */
count: number | null;
children?: PickNode[];
/** 잎이면 그 항목 */
row?: PickerRow;
}
/** 「새 분류1」 같은 새로 만들 때의 자리표는 이름이 아님 — 보이는 글에서 뺌 */
const PLACEHOLDER = /^새 분류\s*\d*$/;
export const shown = (part?: string): string =>
part && !PLACEHOLDER.test(part.trim()) ? part : "";
/** 폴더 이름 배열 — 서버가 준 `경로` · 없으면 종류 · 분류1~3 중 있는 것(자리표 · 빈 것은 뺌) */
export function pathOf(row: PickerRow): string[] {
const own = (row.경로 ?? []).map(shown).filter(Boolean);
if (own.length) return own;
return [row.종류, row.분류1, row.분류2, row.분류3].map(shown).filter(Boolean);
}
/** 보이는 항목 이름 — 경로를 이음(비면 열 id · 키) */
export const itemName = (row: PickerRow): string => pathOf(row).join(" ") || row.열 || row.key;
/** 마우스를 올렸을 때 — 층 이름 + 키 */
export const rowTip = (row: PickerRow, labels: TreeLabels): string =>
`${labels.layers[layerOf(row)]} · ${row.key}`;
export const layerOf = (row: PickerRow): PickerLayer => row.layer ?? "system";
export const ownerOf = (row: PickerRow): string | null =>
layerOf(row) === "system" ? null : (row.owner ?? null);
/** 같은 항목의 자리표 — 층 · 주인 · 키 */
export const rowId = (row: PickerRow): string => `${layerOf(row)}|${ownerOf(row) ?? ""}|${row.key}`;
export function toSummary(row: PickerRow): SummaryItem {
return { layer: layerOf(row), owner: ownerOf(row), key: row.key, name: itemName(row) };
}
export function isCurrent(current: SummaryItem | null, row: PickerRow): boolean {
return (
!!current &&
current.layer === layerOf(row) &&
(current.owner ?? null) === ownerOf(row) &&
current.key === row.key
);
}
/** 찾기 — 이름 · 종류 · 분류 · 키 · 연결 이름에 글이 들어 있는 줄(빈 글이면 전부) */
export function filterRows(rows: PickerRow[], text: string): PickerRow[] {
const words = text.trim().toLowerCase().split(/\s+/).filter(Boolean);
if (!words.length) return rows;
return rows.filter((row) => {
const hay = [
itemName(row),
row.key,
row.일위대가,
row.자재조합,
...Object.values(row.names ?? {}),
]
.join(" ")
.toLowerCase();
return words.every((word) => hay.includes(word));
});
}
export interface TreeLabels {
layers: Record<PickerLayer, string>;
/** 종류 · 분류1 이 비었을 때 */
unclassified: string;
}
const folderId = (layer: PickerLayer, path: string[]): string => [layer, ...path].join("|");
/** 그룹 → 폴더(경로 앞부분 · 깊이 자유) → 항목(잎). 서버가 준 차례를 지킴 · 빈 그룹도 한 줄 남김. */
export function buildTree(rows: PickerRow[], labels: TreeLabels): PickNode[] {
return PICKER_LAYERS.map((layer) => {
const root: PickNode = {
id: layer,
label: labels.layers[layer],
count: 0,
children: [],
folder: { layer, path: [] },
};
const folders = new Map<string, PickNode>([[layer, root]]);
for (const row of rows.filter((one) => layerOf(one) === layer)) {
let parent = root;
const path = pathOf(row).slice(0, -1);
path.forEach((name, index) => {
const here = path.slice(0, index + 1);
const id = folderId(layer, here);
let node = folders.get(id);
if (!node) {
node = { id, label: name, count: 0, children: [], folder: { layer, path: here } };
folders.set(id, node);
parent.children?.push(node);
}
parent = node;
});
parent.children?.push({
id: rowId(row),
label: itemName(row),
tip: rowTip(row, labels),
count: null,
row,
});
for (const id of ancestorIds(layer, path)) {
const node = folders.get(id);
if (node) node.count = (node.count ?? 0) + 1;
}
}
return root;
});
}
/** 잎까지 가는 길의 나무 줄 id(그룹 · 폴더) */
const ancestorIds = (layer: PickerLayer, path: string[]): string[] => [
layer,
...path.map((_, index) => folderId(layer, path.slice(0, index + 1))),
];
/** 잎까지 가는 길의 나무 줄 id — 처음부터 펼칠 자리 */
export const pathIds = (row: PickerRow, _labels?: TreeLabels): string[] =>
ancestorIds(layerOf(row), pathOf(row).slice(0, -1));
export type FolderKind = "drawing" | "area" | "material" | "combo" | "unitPrice";
export interface FolderLine {
kind: FolderKind;
/** 서버가 준 이름 — 없으면 null(키를 작은 글로) */
name: string | null;
/** 키 · 번호 — 이름이 있으면 마우스를 올릴 때, 없으면 작은 글로 */
key: string;
}
const FOLDER_FIELDS: readonly [FolderKind, NameField][] = [
["drawing", "도번"],
["area", "면적번"],
["material", "재료번"],
["combo", "자재조합"],
["unitPrice", "일위대가"],
];
/** 오른쪽 폴더 모양 — 다섯 줄 모두 서버의 키 칸 + `names` 의 이름 */
export function folderLines(row: PickerRow): FolderLine[] {
return FOLDER_FIELDS.map(([kind, field]) => ({
kind,
name: row.names?.[field] || null,
key: row[field] ?? "",
}));
}