- ui_template_tree: M01 renderTree 를 공용으로 올림(M01 은 같은 이름으로 그대로 부름) · 기본 줄 createTreeRow · 클래스 ui-tree__*
- ui_template_item_picker: openItemPicker({current, canCopy}) → apply(summary_item) | clear | cancel · 왼쪽 마스터 · 회사 · 개인 → 종류 → 분류1 → 항목 나무 + 찾기 · 오른쪽 폴더 모양(키는 툴팁) · 개인용으로 복사 · 관리 화면 이동
- 목록 GET /api/m02/items?all=1 · 복사 POST /api/m02/layers/personal/items/copy — 서버가 없으면(404) 21-1 모양 가짜 응답
- 자료 쪽(나무 · 찾기 · 폴더 줄)은 화면 없는 모듈로 나눠 시험
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K7JS47SacjPxZ718QsZKyr
290 lines
11 KiB
TypeScript
290 lines
11 KiB
TypeScript
/* =============================================================================
|
||
* ui_template_item_picker.ts
|
||
* 항목 고르기 모달 — 구조물집계표 항목(SS 한 줄)을 마스터 · 회사 · 개인 세 그룹 나무에서 고름.
|
||
* 키(SS · UA …)는 앞에 내세우지 않고 이름으로 보임 · 키는 마우스를 올리면(PLAN.md 21-0).
|
||
*
|
||
* const result = await openItemPicker({ current, canCopy });
|
||
* result.action = "apply"(result.item = 21-1 `summary_item`) | "clear" | "cancel"
|
||
* ========================================================================== */
|
||
|
||
import "./ui_template_item_picker.css";
|
||
import { API_BASE_URL } from "@config/config_frontend";
|
||
import { createButton, createInputField, el, showToast } from "./ui_template_elements";
|
||
import { fakeCopy, fakeItems } from "./ui_template_item_picker_fake";
|
||
import {
|
||
buildTree,
|
||
filterRows,
|
||
folderLines,
|
||
isCurrent,
|
||
layerOf,
|
||
ownerOf,
|
||
pathIds,
|
||
rowId,
|
||
toSummary,
|
||
type FolderKind,
|
||
type PickNode,
|
||
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 TreeNode } from "./ui_template_tree";
|
||
|
||
export type { SummaryItem } from "./ui_template_item_picker_data";
|
||
|
||
export interface ItemPickerOptions {
|
||
/** 지금 지정된 항목 — 없으면 null */
|
||
current: SummaryItem | null;
|
||
/** [개인용으로 복사]를 쓸 수 있나 — 개인 층에 쓸 수 있을 때(회사 등록 뒤) */
|
||
canCopy: boolean;
|
||
}
|
||
|
||
export type ItemPickerResult =
|
||
{ action: "apply"; item: SummaryItem } | { action: "clear" } | { action: "cancel" };
|
||
|
||
const MANAGE_HASH = "#/m02-master-template?layer=personal";
|
||
|
||
const FOLDER_LABEL: Record<FolderKind, () => string> = {
|
||
drawing: () => t("M02_PickerDrawing"),
|
||
area: () => t("M02_PickerArea"),
|
||
material: () => t("M02_PickerMaterial"),
|
||
combo: () => t("M02_PickerCombo"),
|
||
unitPrice: () => t("M02_PickerUnitPrice"),
|
||
};
|
||
|
||
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}`;
|
||
}
|
||
|
||
/** 실서버 응답 — 아직 없으면(404 · 405) 21-1 모양 가짜 응답. ponytail: 21-2 서버가 오면 가짜를 지움 */
|
||
async function loadRows(): Promise<PickerRow[]> {
|
||
const response = await fetch(`${API_BASE_URL}/m02/items?all=1`, { credentials: "include" });
|
||
if (response.status === 404 || response.status === 405) return fakeItems();
|
||
const body = (await response.json().catch(() => null)) as
|
||
PickerRow[] | { items?: PickerRow[]; detail?: unknown } | null;
|
||
if (!response.ok) throw new Error(reasonOf(body, response.status));
|
||
return Array.isArray(body) ? body : (body?.items ?? []);
|
||
}
|
||
|
||
/** 개인용으로 복사 — 새로 생긴 줄의 키를 돌려줌(서버가 안 주면 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 }),
|
||
});
|
||
if (response.status === 404 || response.status === 405) return fakeCopy(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 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 detail = el("div", { className: "ui-item-picker__detail" });
|
||
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 => {
|
||
if (!chosen) {
|
||
detail.replaceChildren(hint(t("M02_PickerHint")));
|
||
} else {
|
||
const where = [labels().layers[layerOf(chosen)], chosen.종류, chosen.분류1]
|
||
.filter(Boolean)
|
||
.join(" › ");
|
||
const small = (text: string): HTMLElement =>
|
||
el("p", { className: "ui-item-picker__where", text });
|
||
detail.replaceChildren(
|
||
el("h4", {
|
||
className: "ui-item-picker__name",
|
||
text: chosen.열,
|
||
attrs: { title: chosen.key },
|
||
}),
|
||
small(where),
|
||
...(chosen.단위 ? [small(`${t("M02_PickerUnit")} ${chosen.단위}`)] : []),
|
||
el("ul", {
|
||
className: "ui-item-picker__folder",
|
||
children: folderLines(chosen).map((line) =>
|
||
el("li", {
|
||
attrs: line.tip ? { title: line.tip } : {},
|
||
children: [
|
||
el("span", {
|
||
className: "ui-item-picker__folder-label",
|
||
text: FOLDER_LABEL[line.kind](),
|
||
}),
|
||
el("span", {
|
||
className: line.text ? "" : "ui-item-picker__none",
|
||
text: line.text || t("M02_PickerNone"),
|
||
}),
|
||
],
|
||
}),
|
||
),
|
||
}),
|
||
);
|
||
}
|
||
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 toTree = (node: PickNode, searching: boolean): TreeNode => ({
|
||
id: node.id,
|
||
label: node.label,
|
||
count: node.count,
|
||
tip: node.tip,
|
||
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;
|
||
}
|
||
});
|
||
|
||
close = openModal({
|
||
title: t("M02_PickerTitle"),
|
||
closeLabel: t("M02_Close"),
|
||
dialogClass: "ui-item-picker",
|
||
// 닫기 단추 · Esc · 바깥 누르기 = 취소
|
||
beforeClose: () => {
|
||
resolve({ action: "cancel" });
|
||
return true;
|
||
},
|
||
mount: (body) => {
|
||
body.append(
|
||
el("div", {
|
||
className: "ui-item-picker__cols",
|
||
children: [
|
||
el("div", { className: "ui-item-picker__left", children: [search.root, treeHost] }),
|
||
detail,
|
||
],
|
||
}),
|
||
el("div", {
|
||
className: "ui-item-picker__actions",
|
||
children: [copyButton, manageButton, clearButton, applyButton],
|
||
}),
|
||
);
|
||
paintDetail();
|
||
void load(null);
|
||
},
|
||
}).close;
|
||
});
|
||
}
|