Files
Aislo/ui_template/ui_template_item_picker.ts
T
eomsangdonandClaude Opus 5.5 59cb28ce58 feat(M02): 구조물 라이브러리 한 화면 · 뿌리 셋 · 층 권한 한 벌 · 남의 층 목록 · 해시 진입 (PLAN 27-1)
- 층 권한 한 벌 M02_Layer_Access — 나무 · 항목(Router_Items) · 양식(Router_Layers) 같은 규칙 · owner 인자 · 남의 층은 읽기만 · 읽기로 남의 층에 뼈대 안 씀
- 남의 층 목록 GET /api/m02/layers/{company|personal}/owners — 시스템 관리자 회사 목록 · 회사 관리자 같은 회사 사람
- ?layer= 페이지 분리 폐기 → 한 화면 뿌리 셋(마스터 · 회사 · 개인) · 뿌리 부품 TreePanel_Roots(펼칠 때 불러옴) · TreePanel 은 owner · api 를 받는 공용 부품
- 진입 해시 ?open=구조물조합|일위대가|옵션|도면&root=&owner= · 항목 고르기 [관리 화면에서 고치기]도 이 해시
- 「항목 나무」 → 「구조물 라이브러리」
- 시험 test_m02_layer_access 새로 · layer_screen · template_layers 새 규칙으로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 13:19:29 +09:00

363 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* ui_template_item_picker.ts
* 항목 고르기 모달 — 구조물집계표 항목(SS 한 줄)을 마스터 · 회사 · 개인 세 그룹 나무에서 고름.
* 키(SS · UA …)는 앞에 내세우지 않고 이름으로 보임 · 키는 마우스를 올리면(PLAN.md 21-0).
*
* const result = await openItemPicker({ current, canCopy, projectId, structureId });
* result.action = "apply"(result.item = 21-1 `summary_item`) | "clear" | "cancel"
*
* 26-3b — 카드 · 편집 · [재적용] · [개인에 저장] 은 **고르는 구조물의 복사본**(`structureId`) 기준.
* 나무 개인 그룹 · 폴더를 오른쪽 클릭하면 「새 항목」(프로젝트에서 새 항목 → 개인 층에도 만들고 폼에 지정).
* ========================================================================== */
import "./ui_template_item_picker.css";
import { API_BASE_URL } from "@config/config_frontend";
import { createButton, createInputField, el, showToast } from "./ui_template_elements";
import {
buildTree,
filterRows,
isCurrent,
layerOf,
ownerOf,
pathIds,
rowId,
toSummary,
type PickNode,
type PickerLayer,
type PickerRow,
type SummaryItem,
type TreeLabels,
} from "./ui_template_item_picker_data";
import { t } from "./ui_template_locale";
import { openModal } from "./ui_template_modal";
import { createTreeRow, renderTree, type TreeMenuItem, type TreeNode } from "./ui_template_tree";
import { askFields } from "./ui_template_item_picker_ask";
import { projectItems, type CreatedItem } from "./ui_template_item_picker_project";
import { createItemDetail, type EditRequest } from "./ui_template_item_picker_detail";
import { createEditView, type EditView } from "./ui_template_item_picker_edit";
export type { SummaryItem } from "./ui_template_item_picker_data";
export interface ItemPickerOptions {
/** 지금 지정된 항목 — 없으면 null */
current: SummaryItem | null;
/** [개인용으로 복사]를 쓸 수 있나 — 개인 층에 쓸 수 있을 때(회사 등록 뒤) */
canCopy: boolean;
/** 프로젝트 안에서 부르면 그 id — 카드 [수정] 이 프로젝트 복사본을 고침(없으면 원본 보기만 · 25-2d) */
projectId?: string | null;
/** 고르는 구조물의 id(`structure_id` · 관 지점 `pipe@측점`) — 복사본이 이 구조물 것 · 없으면(추가 전) 원본 보기만 */
structureId?: string | null;
}
export type ItemPickerResult =
{ action: "apply"; item: SummaryItem } | { action: "clear" } | { action: "cancel" };
/** 관리 화면 진입 해시(PLAN 27-1d) — 구조물 조합 · 개인 뿌리가 열린 채 */
const MANAGE_HASH = `#/m02-master-template?${new URLSearchParams({ open: "구조물조합", root: "personal" })}`;
const labels = (): TreeLabels => ({
layers: {
system: t("M02_PickerSystem"),
company: t("M02_PickerCompany"),
personal: t("M02_PickerPersonal"),
},
unclassified: t("M02_PickerNoClass"),
});
/** 서버가 막은 까닭 그대로 */
function reasonOf(body: unknown, status: number): string {
const detail = (body as { detail?: unknown } | null)?.detail;
return typeof detail === "string" && detail ? detail : `HTTP ${status}`;
}
/** `GET /api/m02/items?all=1` = `[{layer, owner, items}]` — 줄마다 `layer` · `owner` 를 붙여 하나로 폄 */
async function loadRows(): Promise<PickerRow[]> {
const response = await fetch(`${API_BASE_URL}/m02/items?all=1`, { credentials: "include" });
const body = (await response.json().catch(() => null)) as
| { layer: PickerLayer; owner: string | null; items: PickerRow[] }[]
| { detail?: unknown }
| null;
if (!response.ok || !Array.isArray(body)) throw new Error(reasonOf(body, response.status));
return body.flatMap((group) =>
group.items.map((item) => ({ ...item, layer: group.layer, owner: group.owner })),
);
}
/** 개인용으로 복사 — 새로 생긴 줄의 키를 돌려줌(서버가 안 주면 null) */
async function copyToPersonal(row: PickerRow): Promise<string | null> {
const response = await fetch(`${API_BASE_URL}/m02/layers/personal/items/copy`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ from_layer: layerOf(row), from_owner: ownerOf(row), key: row.key }),
});
const body = (await response.json().catch(() => null)) as { key?: string } | null;
if (!response.ok) throw new Error(reasonOf(body, response.status));
return body?.key ?? null;
}
const NEW_TEXT = {
menu: "새 항목",
title: "새 항목",
note: "{value} 안에 새 항목을 만듦 — 개인 층에도 저장되고 이 구조물에 지정됨",
name: "이름",
unit: "단위",
ok: "만들기",
cancel: "취소",
empty: "{label} 을(를) 넣을 것",
made: "새 항목을 만들어 지정했음 — 구조물 [저장]을 눌러야 이 구조물 복사본이 남음",
failed: "새 항목을 만들지 못함 — {value}",
noStructure: "구조물을 먼저 추가해야 새 항목을 이 구조물에 지정할 수 있음",
saveLine: "[적용] 뒤 구조물 [저장]을 눌러야 이 구조물 복사본이 남음",
};
const fill = (text: string, value: string): string => text.replace("{value}", value);
const message = (error: unknown): string =>
error instanceof Error ? error.message : String(error);
export function openItemPicker(opts: ItemPickerOptions): Promise<ItemPickerResult> {
return new Promise((resolve) => {
let rows: PickerRow[] = [];
let chosen: PickerRow | null = null;
/** 접었다 펼친 것을 다시 그려도 남기려고 펼친 줄 id 를 기억 */
const opened = new Set<string>();
const leafButtons = new Map<string, HTMLElement>();
const search = createInputField({ placeholder: t("M02_PickerSearch"), compact: true });
const treeHost = el("div", { className: "ui-item-picker__tree" });
const detailView = createItemDetail({
projectId: opts.projectId ?? null,
layerLabels: labels().layers,
hint: t("M02_PickerHint"),
unit: t("M02_PickerUnit"),
structureId: opts.structureId ?? null,
onEdit: (request) => openEdit(request),
// 개인에 저장 — 나무를 다시 받고 새 항목을 고름(개인 그룹에 뜸)
onCreated: (created) => void load(created.key),
});
const cols = el("div", {
className: "ui-item-picker__cols",
children: [
el("div", { className: "ui-item-picker__left", children: [search.root, treeHost] }),
detailView.root,
],
});
const editHost = el("div", { className: "ui-item-picker__edit-host", attrs: { hidden: "" } });
let editView: EditView | null = null;
const applyButton = createButton({ label: t("M02_PickerApply"), variant: "filled" });
const clearButton = createButton({ label: t("M02_PickerClear"), variant: "ghost" });
const copyButton = createButton({ label: t("M02_PickerCopy"), variant: "ghost" });
const manageButton = createButton({ label: t("M02_PickerManage"), variant: "ghost" });
let close = (): void => {};
const finish = (result: ItemPickerResult): void => {
resolve(result);
close();
};
const hint = (text: string): HTMLElement =>
el("p", { className: "ui-item-picker__hint", text });
const paintDetail = (): void => {
detailView.show(chosen);
const layer = chosen ? layerOf(chosen) : null;
applyButton.disabled = !chosen;
clearButton.disabled = !opts.current;
copyButton.hidden = !(chosen && layer !== "personal" && opts.canCopy);
manageButton.hidden = !(chosen && layer === "personal");
};
const choose = (row: PickerRow): void => {
chosen = row;
for (const [id, button] of leafButtons)
button.classList.toggle("is-active", id === rowId(row));
paintDetail();
};
/** 오른쪽 클릭 — 개인 그룹 · 폴더만 「새 항목」(경로 = 누른 폴더) */
const menuOf = (node: PickNode): (() => TreeMenuItem[]) | undefined => {
const folder = node.folder;
if (!folder || folder.layer !== "personal" || !opts.projectId) return undefined;
return () => [
{
label: NEW_TEXT.menu,
run: () => void createNew(folder.path),
disabledReason: opts.structureId ? undefined : NEW_TEXT.noStructure,
},
];
};
const toTree = (node: PickNode, searching: boolean): TreeNode => ({
id: node.id,
label: node.label,
count: node.count,
tip: node.tip,
menu: menuOf(node),
open: searching || opened.has(node.id),
run: node.row ? () => choose(node.row as PickerRow) : undefined,
children: node.children?.map((child) => toTree(child, searching)),
});
const paintTree = (): void => {
leafButtons.clear();
const text = search.input.value;
const searching = text.trim() !== "";
const groups = buildTree(filterRows(rows, text), labels())
// 찾는 중에는 결과가 없는 그룹을 감춤
.filter((group) => !searching || (group.count ?? 0) > 0)
.map((group) => toTree(group, searching));
if (!groups.length) {
treeHost.replaceChildren(hint(t("M02_PickerNoMatch")));
return;
}
treeHost.replaceChildren(
...renderTree(groups, (node, caret, depth, onClick) => {
const button = createTreeRow(node, caret, depth, () => {
onClick();
if (!node.children?.length) return;
if (button.querySelector(".ui-tree__caret")?.textContent === "▾") opened.add(node.id);
else opened.delete(node.id);
});
if (node.run) {
leafButtons.set(node.id, button);
if (chosen && node.id === rowId(chosen)) button.classList.add("is-active");
}
return button;
}),
);
};
const load = async (pickKey: string | null): Promise<void> => {
treeHost.replaceChildren(hint(t("M02_PickerLoading")));
try {
rows = await loadRows();
} catch (error) {
treeHost.replaceChildren(
hint(t("M02_PickerLoadFailed").replace("{value}", message(error))),
);
return;
}
const start = pickKey
? rows.find((row) => layerOf(row) === "personal" && row.key === pickKey)
: rows.find((row) => isCurrent(opts.current, row));
if (start) {
for (const id of pathIds(start, labels())) opened.add(id);
chosen = start;
} else {
opened.add("system"); // 지정된 게 없으면 첫 그룹만 펼침
}
paintTree();
paintDetail();
treeHost.querySelector(".is-active")?.scrollIntoView({ block: "nearest" });
};
search.input.addEventListener("input", paintTree);
applyButton.addEventListener(
"click",
() => chosen && finish({ action: "apply", item: toSummary(chosen) }),
);
clearButton.addEventListener("click", () => finish({ action: "clear" }));
manageButton.addEventListener("click", () => {
finish({ action: "cancel" });
location.hash = MANAGE_HASH;
});
copyButton.addEventListener("click", async () => {
if (!chosen) return;
copyButton.disabled = true;
try {
const key = await copyToPersonal(chosen);
showToast(t("M02_PickerCopied"), "success");
await load(key);
} catch (error) {
showToast(t("M02_PickerCopyFailed").replace("{value}", message(error)), "error");
} finally {
copyButton.disabled = false;
}
});
/** 프로젝트에서 새 항목 — 개인 층에도 만들고 돌려받은 `summary_item` 을 폼에 지정 */
async function createNew(path: string[]): Promise<void> {
if (!opts.projectId || !opts.structureId) return;
const where = [labels().layers.personal, ...path].join(" › ");
const got = await askFields({
title: NEW_TEXT.title,
note: fill(NEW_TEXT.note, where),
fields: [
{ key: "name", label: NEW_TEXT.name, required: true },
{ key: "unit", label: NEW_TEXT.unit, placeholder: "m · ㎥ · 개소", required: true },
],
okLabel: NEW_TEXT.ok,
cancelLabel: NEW_TEXT.cancel,
closeLabel: t("M02_Close"),
emptyText: NEW_TEXT.empty,
});
if (!got) return;
try {
const made: CreatedItem = await projectItems(opts.projectId).newItem(
opts.structureId,
path,
got.name,
got.unit,
);
showToast(NEW_TEXT.made, "success");
finish({ action: "apply", item: made.summary_item as SummaryItem });
} catch (error) {
showToast(fill(NEW_TEXT.failed, message(error)), "error"); // 회사 없음 409 = 「회사부터 등록」 그대로
}
}
const actions = el("div", {
className: "ui-item-picker__actions",
children: [
...(opts.projectId
? [el("span", { className: "ui-item-picker__saveline", text: NEW_TEXT.saveLine })]
: []),
copyButton,
manageButton,
clearButton,
applyButton,
],
});
/** 카드 [수정] — 모달 안에서 편집 화면으로 바뀜(나무 · 카드 · 단추는 숨김) */
function openEdit(request: EditRequest): void {
const view = createEditView(request, closeEdit);
editView = view;
editHost.replaceChildren(view.root);
cols.hidden = true;
actions.hidden = true;
editHost.hidden = false;
view.start();
}
/** [뒤로] — 선택지로 돌아와 고른 항목을 다시 읽음(고친 값이 카드에 보임) */
function closeEdit(saved: boolean): void {
editView?.destroy();
editView = null;
editHost.hidden = true;
cols.hidden = false;
actions.hidden = false;
if (saved) detailView.markEdited();
detailView.reload();
}
close = openModal({
title: t("M02_PickerTitle"),
closeLabel: t("M02_Close"),
dialogClass: "ui-item-picker",
// 닫기 단추 · Esc · 바깥 누르기 = 취소 — 편집 중 저장 안 한 고침이 있으면 물음
beforeClose: async () => {
if (editView && !(await editView.leave())) return false;
editView?.destroy();
resolve({ action: "cancel" });
return true;
},
mount: (body) => {
body.append(cols, editHost, actions);
paintDetail();
void load(null);
},
}).close;
});
}