Files
Aislo/ui_template/ui_template_item_picker.ts
T
eomsangdonandClaude Sonnet 5.5 2c87f91f36 fix(B05·B06): 고르기 모달 [해제] 가 [저장] 에서 사라지던 흠 · 항목 더하기 창 빈 칸 안내 (PLAN 26장)
- 구조물 저장은 세션 초안을 보냄(횡단 보기에서 한 지정 · 해제가 종단 보기 메모리 때문에 사라지던 것)
- 관 시설 보관소는 서버 응답을 정본으로(집계 항목 칸이 없으면 미지정 · 옛 지정이 되살아나던 것)
- 항목 더하기 · 새 항목 창: 빈 이름 · 빈 단위에 칸 빨간 테두리 + 한 줄

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

362 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" };
const MANAGE_HASH = "#/m02-master-template?layer=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;
});
}