Files
Aislo/ui_template/ui_template_item_picker.ts
T
eomsangdonandClaude Sonnet 5.5 927e85d110 feat(ui_template): 항목 고르기 모달 · 공용 나무 보기 — PLAN 21-5
- 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
2026-09-29 12:00:13 +09:00

290 lines
11 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 });
* 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;
});
}