Files
Aislo/ui_template/ui_template_item_picker.ts
T
eomsangdonandClaude Sonnet 5.5 5b04f453a6 fix(ui_template): 나무 화살표 · 줄 수 글자 대비 · 항목 고르기 툴팁 · 자리표 이름 (PLAN 21-10 흠 5~7)
- 나무 화살표 · 줄 수와 항목 고르기의 흐린 글을 var(--color-text-secondary) 로 — 밝은 테마 대비 3.08 → 6.3 이상
- M01 왼쪽 목록 줄 수도 같은 공용 클래스(ui-tree__count)로 — 밝은 테마 2.64 → 5.41
- 항목 고르기: 잎 툴팁 = 층 이름 + 키 · 「새 분류N」 자리표는 이름 · 나무 이름에서 뺌 · 관리 화면 단추는 개인 항목만(확인)
- ghost 단추는 두 테마 모두 6.85 이상이라 색 변경 없음 — 다크 1.06 은 ORCA 탭이 색 전환을 멈춰 생긴 측정값
- 자료 시험에 자리표 · 툴팁 추가

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K7JS47SacjPxZ718QsZKyr
2026-09-29 13:36:36 +09:00

300 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 {
buildTree,
filterRows,
folderLines,
isCurrent,
itemName,
layerOf,
ownerOf,
pathIds,
rowId,
rowTip,
shown,
toSummary,
type FolderKind,
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 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}`;
}
/** `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 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)], shown(chosen.종류), shown(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: itemName(chosen),
attrs: { title: rowTip(chosen, labels()) },
}),
small(where),
...(chosen.단위 ? [small(`${t("M02_PickerUnit")} ${chosen.단위}`)] : []),
el("ul", {
className: "ui-item-picker__folder",
children: folderLines(chosen).map((line) =>
el("li", {
attrs: line.name && line.key ? { title: line.key } : {},
children: [
el("span", {
className: "ui-item-picker__folder-label",
text: FOLDER_LABEL[line.kind](),
}),
// 이름이 있으면 이름(키는 툴팁) · 없으면 키를 작은 글로 · 둘 다 없으면 「없음」
el("span", {
className: line.name
? ""
: line.key
? "ui-item-picker__key"
: "ui-item-picker__none",
text: line.name || line.key || 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;
});
}