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
This commit is contained in:
2026-09-29 12:00:13 +09:00
co-authored by Claude Sonnet 5.5
parent 38b3459efb
commit 927e85d110
11 changed files with 875 additions and 59 deletions
+1 -1
View File
@@ -178,7 +178,7 @@ export function buildSide(
children: [
el("span", {
children: [
el("span", { className: "m01-tree__caret", text: caret }),
el("span", { className: "ui-tree__caret", text: caret }),
el("span", { text: node.label }),
],
}),
@@ -17,24 +17,6 @@
color: var(--color-accent);
}
.m01-tree__kids {
display: flex;
flex-direction: column;
gap: 2px;
}
.m01-tree__branch {
display: flex;
flex-direction: column;
gap: 2px;
}
.m01-tree__caret {
display: inline-block;
width: var(--spacing-12);
color: var(--color-text-muted);
}
.m01-side__muted,
.m01-master__muted {
color: var(--color-text-muted);
+2 -40
View File
@@ -1,44 +1,6 @@
/* =============================================================================
* M01_MasterData_UI_Tree.ts
* 왼쪽 패널 트리 — 부모(접기/펴기) · 자식(들여쓰기) · 줄 수 표시
* 왼쪽 패널 트리 — 공용 `ui_template_tree` 를 그대로 다시 내보냄(부르는 쪽 import 불변)
* ========================================================================== */
import { el } from "@ui/ui_template_elements";
export interface TreeNode {
id: string;
label: string;
/** 줄 수 — null 이면 안 보임 */
count: number | null;
/** 눌렀을 때 — 없으면 접기/펴기만 */
run?: () => void;
children?: TreeNode[];
/** 처음부터 펴짐 */
open?: boolean;
}
/** 줄(버튼) 만들기 — 돌려받은 것 = 그 버튼 · click 은 부르는 쪽이 이음 */
export type MakeRow = (
node: TreeNode,
caret: string,
depth: number,
onClick: () => void,
) => HTMLElement;
export function renderTree(nodes: TreeNode[], make: MakeRow, depth = 0): HTMLElement[] {
return nodes.map((node) => {
if (!node.children?.length) return make(node, "", depth, () => node.run?.());
const kids = el("div", {
className: "m01-tree__kids",
children: renderTree(node.children, make, depth + 1),
});
kids.hidden = !node.open;
const row = make(node, kids.hidden ? "▸" : "▾", depth, () => {
kids.hidden = !kids.hidden;
const caret = row.querySelector(".m01-tree__caret");
if (caret) caret.textContent = kids.hidden ? "▸" : "▾";
node.run?.();
});
return el("div", { className: "m01-tree__branch", children: [row, kids] });
});
}
export { renderTree, type MakeRow, type TreeNode } from "@ui/ui_template_tree";
@@ -0,0 +1,139 @@
/**
* 항목 고르기 자료 쪽 검증 — 나무 · 찾기 · 돌려줄 summary_item · 폴더 줄 · 가짜 응답(21-1 모양).
* 실행: node --experimental-strip-types resources/tester/ui/item-picker-data.test.ts
*/
import assert from "node:assert/strict";
import {
buildTree,
filterRows,
folderLines,
isCurrent,
pathIds,
rowId,
toSummary,
type TreeLabels,
} from "../../../ui_template/ui_template_item_picker_data.ts";
import { fakeCopy, fakeItems } from "../../../ui_template/ui_template_item_picker_fake.ts";
const labels: TreeLabels = {
layers: { system: "마스터", company: "회사", personal: "개인" },
unclassified: "분류 없음",
};
const rows = fakeItems();
// 나무 — 그룹은 늘 마스터 · 회사 · 개인 차례 · 그룹 → 종류 → 분류1 → 잎
const tree = buildTree(rows, labels);
assert.deepEqual(
tree.map((g) => [g.label, g.count]),
[
["마스터", 5],
["회사", 2],
["개인", 1],
],
);
const system = tree[0];
assert.deepEqual(
system.children?.map((k) => [k.label, k.count]),
[
["돌기슭막이", 3],
["콘크리트포장", 1],
["관보호공", 1],
],
);
const cat = system.children?.[0].children?.[0];
assert.equal(cat?.label, "메쌓기");
assert.equal(cat?.count, 2);
const leaf = cat?.children?.[0];
assert.equal(leaf?.label, "돌기슭막이 메쌓기 1.2m 기초유");
assert.equal(leaf?.tip, "SS000001"); // 키는 툴팁
assert.equal(leaf?.count, null);
assert.ok(leaf?.row);
// 분류가 빈 항목은 「분류 없음」 한 칸에 모임
const company = tree[1];
assert.equal(company.children?.[1].children?.[0].label, "분류 없음");
// 빈 그룹도 한 줄은 남음
const onlySystem = buildTree(
rows.filter((r) => r.layer === "system"),
labels,
);
assert.deepEqual(
onlySystem.map((g) => [g.id, g.count, g.children?.length]),
[
["system", 5, 3],
["company", 0, 0],
["personal", 0, 0],
],
);
// 잎 id 는 층 · 주인 · 키 — 같은 키라도 층이 다르면 다른 줄
const twin = { ...rows[0], layer: "company" as const, owner: "샘플회사" };
assert.notEqual(rowId(rows[0]), rowId(twin));
// 찾기 — 이름 · 키 · 연결 이름 · 여러 낱말은 모두 들어 있어야 함 · 빈 글은 전부
assert.equal(filterRows(rows, "").length, rows.length);
assert.equal(filterRows(rows, " ").length, rows.length);
assert.deepEqual(
filterRows(rows, "옹벽").map((r) => r.key),
["SS000102"],
);
assert.deepEqual(
filterRows(rows, "ss000004").map((r) => r.key),
["SS000004"],
);
assert.deepEqual(
filterRows(rows, "돌기슭막이 개인").map((r) => r.key),
["SS000201"],
);
assert.equal(filterRows(rows, "없는이름").length, 0);
// 돌려줄 summary_item — 마스터는 owner null · 회사 · 개인은 주인
assert.deepEqual(toSummary(rows[0]), {
layer: "system",
owner: null,
key: "SS000001",
name: "돌기슭막이 메쌓기 1.2m 기초유",
});
const mine = rows.find((r) => r.layer === "personal");
assert.ok(mine);
assert.deepEqual(toSummary(mine), {
layer: "personal",
owner: "샘플회사/사용자",
key: "SS000201",
name: "돌기슭막이 메쌓기 1.2m(개인)",
});
// layer 없는 줄(옛 서버)은 마스터로 봄
assert.equal(toSummary({ key: "SS000009", 열: "임시" }).layer, "system");
// 지금 지정된 항목인지
assert.ok(isCurrent(toSummary(mine), mine));
assert.ok(!isCurrent(toSummary(mine), rows[0]));
assert.ok(!isCurrent(null, rows[0]));
// 처음 펼칠 자리
assert.deepEqual(pathIds(rows[0], labels), [
"system",
"system|돌기슭막이",
"system|돌기슭막이|메쌓기",
]);
// 폴더 모양 — 도면 · 면적산출 · 재료산출은 항목 이름 · 번호는 툴팁 · 값이 없으면 빈 글
const lines = folderLines(rows[0]);
assert.deepEqual(
lines.map((l) => l.kind),
["drawing", "area", "material", "combo", "unitPrice"],
);
assert.equal(lines[0].text, "돌기슭막이 메쌓기 1.2m 기초유");
assert.equal(lines[0].tip, "SD000001");
assert.equal(folderLines({ key: "SS1", 열: "빈 항목" })[3].text, "");
// 가짜 복사 — 새 개인 줄이 생기고 키가 겹치지 않음
const before = fakeItems();
const copied = fakeCopy(before[0]);
const after = fakeItems();
assert.equal(after.length, before.length + 1);
assert.equal(copied.layer, "personal");
assert.equal(new Set(after.map(rowId)).size, after.length);
console.log("항목 고르기 자료 시험 통과");
+101
View File
@@ -0,0 +1,101 @@
/* 항목 고르기 모달 — 왼쪽 나무 · 오른쪽 폴더 모양 · 아래 단추 */
.ui-item-picker {
width: min(900px, 94vw);
}
.ui-item-picker .ui-modal__body {
height: min(60vh, 520px);
}
.ui-item-picker__cols {
display: grid;
grid-template-columns: minmax(240px, 5fr) minmax(260px, 6fr);
gap: var(--spacing-12);
flex: 1;
min-height: 0;
}
.ui-item-picker__left {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
min-height: 0;
min-width: 0;
}
.ui-item-picker__tree {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
min-height: 0;
padding: var(--spacing-4);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
overflow: auto;
}
.ui-item-picker__detail {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
min-width: 0;
min-height: 0;
padding: var(--spacing-12);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
overflow: auto;
}
.ui-item-picker__name {
margin: 0;
font-size: var(--text-body);
color: var(--color-text-heading);
overflow-wrap: anywhere;
}
.ui-item-picker__where,
.ui-item-picker__hint {
margin: 0;
color: var(--color-text-muted);
font-size: var(--text-caption);
}
.ui-item-picker__hint {
padding: var(--spacing-8);
}
.ui-item-picker__folder {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
margin: 0;
padding: 0 0 0 var(--spacing-12);
border-left: 2px solid var(--color-border);
list-style: none;
}
.ui-item-picker__folder li {
display: flex;
gap: var(--spacing-8);
align-items: baseline;
min-width: 0;
color: var(--color-text-body);
font-size: var(--text-caption);
overflow-wrap: anywhere;
}
.ui-item-picker__folder-label {
flex: 0 0 5.5em;
color: var(--color-text-muted);
}
.ui-item-picker__none {
color: var(--color-text-muted);
}
.ui-item-picker__actions {
display: flex;
justify-content: flex-end;
gap: var(--spacing-8);
}
+289
View File
@@ -0,0 +1,289 @@
/* =============================================================================
* 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;
});
}
+161
View File
@@ -0,0 +1,161 @@
/* =============================================================================
* ui_template_item_picker_data.ts
* 항목 고르기의 자료 쪽(화면 없음) — 항목 목록 → 나무 · 찾기 · 폴더 줄 · 돌려줄 `summary_item`.
* 모양은 PLAN.md 21-1 약속 그대로: 항목 목록 API 한 줄 = `PickerRow`, 참조 = `SummaryItem`.
* ========================================================================== */
export type PickerLayer = "system" | "company" | "personal";
/** 21-1 `summary_item` — 구조물 기록에 붙는 항목 참조 */
export interface SummaryItem {
layer: PickerLayer;
owner: string | null;
key: string;
name: string;
}
/** `GET /api/m02/items` 한 줄(21-1) — `?all=1` 은 줄마다 `layer` · `owner` 가 붙음 */
export interface PickerRow {
key: string;
열: string;
종류?: string;
분류1?: string;
분류2?: string;
분류3?: string;
단위?: string;
일위대가?: string;
자재조합?: string;
도번?: string;
면적번?: string;
재료번?: string;
layer?: PickerLayer;
owner?: string | null;
}
/** 그룹 차례 = 마스터 · 회사 · 개인 */
export const PICKER_LAYERS: readonly PickerLayer[] = ["system", "company", "personal"];
export interface PickNode {
id: string;
/** 그룹 · 종류 · 분류 이름 — 잎은 항목 이름 */
label: string;
/** 마우스를 올렸을 때 — 잎은 키 */
tip?: string;
/** 아래 잎 수 — 잎은 null */
count: number | null;
children?: PickNode[];
/** 잎이면 그 항목 */
row?: PickerRow;
}
export const layerOf = (row: PickerRow): PickerLayer => row.layer ?? "system";
export const ownerOf = (row: PickerRow): string | null =>
layerOf(row) === "system" ? null : (row.owner ?? null);
/** 같은 항목의 자리표 — 층 · 주인 · 키 */
export const rowId = (row: PickerRow): string => `${layerOf(row)}|${ownerOf(row) ?? ""}|${row.key}`;
export function toSummary(row: PickerRow): SummaryItem {
return { layer: layerOf(row), owner: ownerOf(row), key: row.key, name: row.열 };
}
export function isCurrent(current: SummaryItem | null, row: PickerRow): boolean {
return (
!!current &&
current.layer === layerOf(row) &&
(current.owner ?? null) === ownerOf(row) &&
current.key === row.key
);
}
/** 찾기 — 이름 · 종류 · 분류 · 키 · 연결 이름에 글이 들어 있는 줄(빈 글이면 전부) */
export function filterRows(rows: PickerRow[], text: string): PickerRow[] {
const words = text.trim().toLowerCase().split(/\s+/).filter(Boolean);
if (!words.length) return rows;
return rows.filter((row) => {
const hay = [
row.열,
row.종류,
row.분류1,
row.분류2,
row.분류3,
row.key,
row.일위대가,
row.자재조합,
]
.join(" ")
.toLowerCase();
return words.every((word) => hay.includes(word));
});
}
export interface TreeLabels {
layers: Record<PickerLayer, string>;
/** 종류 · 분류1 이 비었을 때 */
unclassified: string;
}
/** 그룹 → 종류 → 분류1 → 항목(잎). 서버가 준 차례를 지킴 · 빈 그룹도 한 줄 남김. */
export function buildTree(rows: PickerRow[], labels: TreeLabels): PickNode[] {
return PICKER_LAYERS.map((layer) => {
const mine = rows.filter((row) => layerOf(row) === layer);
const kinds = new Map<string, Map<string, PickerRow[]>>();
for (const row of mine) {
const kind = row.종류 || labels.unclassified;
const cat = row.분류1 || labels.unclassified;
const cats = kinds.get(kind) ?? new Map<string, PickerRow[]>();
cats.set(cat, [...(cats.get(cat) ?? []), row]);
kinds.set(kind, cats);
}
const kindNodes: PickNode[] = [...kinds].map(([kind, cats]) => {
const catNodes: PickNode[] = [...cats].map(([cat, leaves]) => ({
id: `${layer}|${kind}|${cat}`,
label: cat,
count: leaves.length,
children: leaves.map((row) => ({
id: rowId(row),
label: row.열,
tip: row.key,
count: null,
row,
})),
}));
return {
id: `${layer}|${kind}`,
label: kind,
count: catNodes.reduce((sum, node) => sum + (node.count ?? 0), 0),
children: catNodes,
};
});
return { id: layer, label: labels.layers[layer], count: mine.length, children: kindNodes };
});
}
/** 잎까지 가는 길의 나무 줄 id — 처음부터 펼칠 자리 */
export function pathIds(row: PickerRow, labels: TreeLabels): string[] {
const layer = layerOf(row);
const kind = row.종류 || labels.unclassified;
const cat = row.분류1 || labels.unclassified;
return [layer, `${layer}|${kind}`, `${layer}|${kind}|${cat}`];
}
export type FolderKind = "drawing" | "area" | "material" | "combo" | "unitPrice";
export interface FolderLine {
kind: FolderKind;
/** 보이는 이름 */
text: string;
/** 마우스를 올렸을 때 — 키 · 번호 */
tip: string;
}
/** 오른쪽 폴더 모양 — 도면 · 면적산출 · 재료산출은 그 항목(열) 이름 · 자재조합 · 일위대가는 서버 값 */
export function folderLines(row: PickerRow): FolderLine[] {
return [
{ kind: "drawing", text: row.열, tip: row.도번 ?? "" },
{ kind: "area", text: row.열, tip: row.면적번 ?? "" },
{ kind: "material", text: row.열, tip: row.재료번 ?? "" },
{ kind: "combo", text: row.자재조합 ?? "", tip: row.자재조합 ?? "" },
{ kind: "unitPrice", text: row.일위대가 ?? "", tip: row.일위대가 ?? "" },
];
}
@@ -0,0 +1,60 @@
/* =============================================================================
* ui_template_item_picker_fake.ts
* 항목 목록 서버(`GET /api/m02/items` · 복사 `POST /api/m02/layers/personal/items/copy`, 21-2)가
* 아직 없을 때만 쓰는 가짜 응답 — 21-1 모양 그대로. 실서버가 오면 이 파일과 부르는 자리를 지움.
* ========================================================================== */
import type { PickerRow } from "./ui_template_item_picker_data";
const row = (
key: string,
열: string,
종류: string,
분류1: string,
layer: PickerRow["layer"],
extra: Partial<PickerRow> = {},
): PickerRow => ({
key,
열,
종류,
분류1,
분류2: "",
분류3: "",
단위: "m",
일위대가: `${열} 일위대가`,
자재조합: `${열} 자재`,
도번: `SD${key.slice(2)}`,
면적번: `SA${key.slice(2)}`,
재료번: `SM${key.slice(2)}`,
layer,
owner: layer === "company" ? "샘플회사" : layer === "personal" ? "샘플회사/사용자" : null,
...extra,
});
let personalSeq = 900;
const memory: PickerRow[] = [
row("SS000001", "돌기슭막이 메쌓기 1.2m 기초유", "돌기슭막이", "메쌓기", "system"),
row("SS000002", "돌기슭막이 메쌓기 1.5m 기초유", "돌기슭막이", "메쌓기", "system"),
row("SS000003", "돌기슭막이 찰쌓기 1.2m", "돌기슭막이", "찰쌓기", "system"),
row("SS000004", "콘크리트포장 T=20cm B=3m", "콘크리트포장", "일반", "system", { 단위: "㎡" }),
row("SS000005", "관보호공 콘크리트 유입", "관보호공", "콘크리트", "system", { 단위: "개소" }),
row("SS000101", "돌기슭막이 메쌓기 1.2m(회사)", "돌기슭막이", "메쌓기", "company"),
row("SS000102", "옹벽 H=2m(회사)", "옹벽", "", "company"),
row("SS000201", "돌기슭막이 메쌓기 1.2m(개인)", "돌기슭막이", "메쌓기", "personal"),
];
export const fakeItems = (): PickerRow[] => memory.map((item) => ({ ...item }));
/** 복사 — 새 개인 항목을 맨 뒤에 더하고 그 줄을 돌려줌(서버는 새 SS 키를 돌려줄 것) */
export function fakeCopy(from: PickerRow): PickerRow {
personalSeq += 1;
const copy: PickerRow = {
...from,
key: `SS000${personalSeq}`,
layer: "personal",
owner: "샘플회사/사용자",
};
memory.push(copy);
return { ...copy };
}
+23
View File
@@ -56,4 +56,27 @@ export const ui_locales_m2 = {
M02_ActionFailed: ["안 됨 — {value}", "Failed — {value}"],
M02_StructureNone: ["없음", "None"],
M02_Back: ["돌아가기", "Back"],
M02_PickerTitle: ["항목 고르기", "Pick an item"],
M02_PickerSearch: ["항목 찾기", "Search items"],
M02_PickerSystem: ["마스터", "Master"],
M02_PickerCompany: ["회사", "Company"],
M02_PickerPersonal: ["개인", "Personal"],
M02_PickerNoClass: ["분류 없음", "Unclassified"],
M02_PickerHint: ["왼쪽에서 항목을 고를 것", "Pick an item on the left"],
M02_PickerNoMatch: ["찾는 항목 없음", "No matching items"],
M02_PickerLoading: ["읽는 중…", "Loading…"],
M02_PickerLoadFailed: ["항목을 못 읽음 — {value}", "Failed to load items — {value}"],
M02_PickerDrawing: ["도면", "Drawing"],
M02_PickerArea: ["면적산출", "Area takeoff"],
M02_PickerMaterial: ["재료산출", "Material takeoff"],
M02_PickerCombo: ["자재조합", "Material combo"],
M02_PickerUnitPrice: ["일위대가", "Unit price"],
M02_PickerUnit: ["단위", "Unit"],
M02_PickerNone: ["없음", "None"],
M02_PickerApply: ["적용", "Apply"],
M02_PickerClear: ["해제", "Clear"],
M02_PickerCopy: ["개인용으로 복사", "Copy to personal"],
M02_PickerManage: ["관리 화면에서 고치기", "Edit in manager"],
M02_PickerCopied: ["개인용으로 복사했음", "Copied to personal"],
M02_PickerCopyFailed: ["복사 못 함 — {value}", "Copy failed — {value}"],
} as const;
+23
View File
@@ -0,0 +1,23 @@
/* 공용 나무 보기 — 줄 모양은 공용 ui-sidebar-row(ui_template_overlay.css) */
.ui-tree__kids {
display: flex;
flex-direction: column;
gap: 2px;
}
.ui-tree__branch {
display: flex;
flex-direction: column;
gap: 2px;
}
.ui-tree__caret {
display: inline-block;
width: var(--spacing-12);
color: var(--color-text-muted);
}
.ui-tree__count {
color: var(--color-text-muted);
font-size: var(--text-caption);
}
+76
View File
@@ -0,0 +1,76 @@
/* =============================================================================
* ui_template_tree.ts
* 공용 나무 보기 — 부모(접기/펴기) · 자식(깊이 들임) · 잎 고름. M01 왼쪽 패널과 항목 고르기가 함께 씀.
* 줄 모양은 부르는 쪽이 `MakeRow` 로 정함(기본 줄 = `createTreeRow`).
* ========================================================================== */
import "./ui_template_tree.css";
import { el } from "./ui_template_elements";
export interface TreeNode {
id: string;
label: string;
/** 줄 수 — null 이면 안 보임 */
count: number | null;
/** 눌렀을 때 — 없으면 접기/펴기만 */
run?: () => void;
children?: TreeNode[];
/** 처음부터 펴짐 */
open?: boolean;
/** 마우스를 올렸을 때 글(기본 줄) */
tip?: string;
}
/** 줄(버튼) 만들기 — 돌려받은 것 = 그 버튼 · click 은 부르는 쪽이 이음 */
export type MakeRow = (
node: TreeNode,
caret: string,
depth: number,
onClick: () => void,
) => HTMLElement;
export function renderTree(nodes: TreeNode[], make: MakeRow, depth = 0): HTMLElement[] {
return nodes.map((node) => {
if (!node.children?.length) return make(node, "", depth, () => node.run?.());
const kids = el("div", {
className: "ui-tree__kids",
children: renderTree(node.children, make, depth + 1),
});
kids.hidden = !node.open;
const row = make(node, kids.hidden ? "▸" : "▾", depth, () => {
kids.hidden = !kids.hidden;
const caret = row.querySelector(".ui-tree__caret");
if (caret) caret.textContent = kids.hidden ? "▸" : "▾";
node.run?.();
});
return el("div", { className: "ui-tree__branch", children: [row, kids] });
});
}
/** 기본 줄 — 화살표 · 글 · 줄 수 · 깊이 들임. 잎 고름 표시(`is-active`)는 부르는 쪽이 켬. */
export function createTreeRow(
node: TreeNode,
caret: string,
depth: number,
onClick: () => void,
): HTMLButtonElement {
const button = el("button", {
className: "ui-tree__row ui-sidebar-row",
attrs: { type: "button" },
children: [
el("span", {
children: [
el("span", { className: "ui-tree__caret", text: caret }),
el("span", { text: node.label }),
],
}),
...(node.count === null
? []
: [el("span", { className: "ui-tree__count", text: node.count.toLocaleString("ko-KR") })]),
],
});
button.style.paddingLeft = `calc(var(--spacing-8) + ${depth} * var(--spacing-16))`;
if (node.tip) button.title = node.tip;
button.addEventListener("click", onClick);
return button;
}