- 카드 · 편집이 그 구조물의 복사본(폴더 = 구조물 id · 관 = pipe@측점)을 읽고 고침 · 같은 항목을 고른 구조물끼리 값이 따로 - [재적용](덮이는 것을 확인 창으로 알리고 덮임 부분을 알림) · [개인에 저장](경로 · 이름 물음) · 상태 줄에 고침 · 마스터가 바뀜 · 원본 없음 표시 - 나무 개인 그룹 · 폴더 오른쪽 클릭 「새 항목」 → 돌려받은 summary_item 을 폼에 지정 · [적용] 뒤 [저장] 안내 한 줄 - B05 구조물 폼이 구조물 id 를 모달에 넘김 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr
362 lines
14 KiB
TypeScript
362 lines
14 KiB
TypeScript
/* =============================================================================
|
||
* 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 · ㎥ · 개소" },
|
||
],
|
||
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;
|
||
});
|
||
}
|