feat(ui_template): 항목 고르기를 실서버에 이음 — 가짜 응답 지움 (PLAN 21-9)

- GET /api/m02/items?all=1 = [{layer, owner, items}] 를 줄마다 layer · owner 를 붙여 폄 · 404 갈래와 ui_template_item_picker_fake.ts 지움
- 보이는 항목 이름 = 종류 + 분류1~3 (열은 집계표 열 id) · summary_item.name 도 같은 이름
- 폴더 줄은 서버 names{일위대가 · 자재조합 · 도번 · 면적번 · 재료번} 이름을 보임(키는 툴팁) · 이름이 없으면 키를 작은 글로
- 복사 응답 {key} 의 개인 항목을 바로 고름
- 자료 시험을 서버 줄 모양으로 다시 씀

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:33:55 +09:00
co-authored by Claude Sonnet 5.5
parent eb3ede5350
commit 10cc5dd373
5 changed files with 131 additions and 115 deletions
+74 -24
View File
@@ -1,5 +1,5 @@
/**
* 항목 고르기 자료 쪽 검증 — 나무 · 찾기 · 돌려줄 summary_item · 폴더 줄 · 가짜 응답(21-1 모양).
* 항목 고르기 자료 쪽 검증 — 나무 · 찾기 · 돌려줄 summary_item · 폴더 줄(21-1 · 21-9 서버 모양).
* 실행: node --experimental-strip-types resources/tester/ui/item-picker-data.test.ts
*/
import assert from "node:assert/strict";
@@ -8,18 +8,69 @@ import {
filterRows,
folderLines,
isCurrent,
itemName,
pathIds,
rowId,
toSummary,
type PickerRow,
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();
/** 서버 줄 그대로 — `열` 은 집계표 열 id · 보이는 이름은 종류 + 분류 */
const row = (
key: string,
열: string,
종류: string,
분류: [string, string, string],
layer: PickerRow["layer"],
extra: Partial<PickerRow> = {},
): PickerRow => ({
key,
열,
종류,
분류1: 분류[0],
분류2: 분류[1],
분류3: 분류[2],
단위: "m",
일위대가: "UA000004",
자재조합: "UM000001",
도번: "SD01010100",
면적번: "SA01010100",
재료번: "SM01010100",
layer,
owner: layer === "company" ? "샘플회사" : layer === "personal" ? "샘플회사/사용자" : null,
...extra,
});
const rows: PickerRow[] = [
row("SS000001", "rv", "돌기슭막이", ["메쌓기", "1.2m", "기초유"], "system", {
names: {
일위대가: "돌기슭막이 메쌓기 일위대가",
자재조합: null,
도번: "돌기슭막이 도면",
면적번: "돌기슭막이 면적산출",
재료번: "돌기슭막이 재료산출",
},
}),
row("SS000002", "rv2", "돌기슭막이", ["메쌓기", "1.5m", "기초유"], "system"),
row("SS000003", "rv3", "돌기슭막이", ["찰쌓기", "1.2m", ""], "system"),
row("SS000004", "pv", "콘크리트포장", ["일반", "T=20cm", ""], "system"),
row("SS000005", "pg", "관보호공", ["콘크리트", "", ""], "system"),
row("SS000101", "c1", "돌기슭막이", ["메쌓기", "1.2m", "기초유"], "company"),
row("SS000102", "c2", "옹벽", ["", "H=2m", ""], "company"),
row("SS000201", "p1", "돌기슭막이", ["메쌓기", "1.2m", "기초유"], "personal"),
];
// 이름 = 종류 + 분류 이음 · 비면 열 id · 키
assert.equal(itemName(rows[0]), "돌기슭막이 메쌓기 1.2m 기초유");
assert.equal(itemName(rows[6]), "옹벽 H=2m");
assert.equal(itemName({ key: "SS000009", 열: "zz" }), "zz");
assert.equal(itemName({ key: "SS000009", 열: "" }), "SS000009");
// 나무 — 그룹은 늘 마스터 · 회사 · 개인 차례 · 그룹 → 종류 → 분류1 → 잎
const tree = buildTree(rows, labels);
@@ -50,10 +101,9 @@ assert.equal(leaf?.count, null);
assert.ok(leaf?.row);
// 분류가 빈 항목은 「분류 없음」 한 칸에 모임
const company = tree[1];
assert.equal(company.children?.[1].children?.[0].label, "분류 없음");
assert.equal(tree[1].children?.[1].children?.[0].label, "분류 없음");
// 빈 그룹도 한 줄은 남음
// 빈 그룹도 한 줄은 남음(회사 없는 사람의 회사 · 개인 그룹)
const onlySystem = buildTree(
rows.filter((r) => r.layer === "system"),
labels,
@@ -83,25 +133,28 @@ assert.deepEqual(
["SS000004"],
);
assert.deepEqual(
filterRows(rows, "돌기슭막이 개인").map((r) => r.key),
["SS000201"],
filterRows(rows, "돌기슭막이 1.5m").map((r) => r.key),
["SS000002"],
);
assert.deepEqual(
filterRows(rows, "돌기슭막이 도면").map((r) => r.key),
["SS000001"],
); // 서버가 준 이름(names)으로도 찾음
assert.equal(filterRows(rows, "없는이름").length, 0);
// 돌려줄 summary_item — 마스터는 owner null · 회사 · 개인은 주인
// 돌려줄 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);
const mine = rows[7];
assert.deepEqual(toSummary(mine), {
layer: "personal",
owner: "샘플회사/사용자",
key: "SS000201",
name: "돌기슭막이 메쌓기 1.2m(개인)",
name: "돌기슭막이 메쌓기 1.2m 기초유",
});
// layer 없는 줄(옛 서버)은 마스터로 봄
assert.equal(toSummary({ key: "SS000009", 열: "임시" }).layer, "system");
@@ -118,22 +171,19 @@ assert.deepEqual(pathIds(rows[0], labels), [
"system|돌기슭막이|메쌓기",
]);
// 폴더 모양 — 도면 · 면적산출 · 재료산출은 항목 이름 · 번호는 툴팁 · 값이 없으면 빈 글
// 폴더 모양 — 다섯 줄 · 이름은 서버 names · 없으면 키만(작은 글) · 둘 다 없으면 빈 키
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);
assert.deepEqual(lines[0], { kind: "drawing", name: "돌기슭막이 도면", key: "SD01010100" });
assert.deepEqual(lines[3], { kind: "combo", name: null, key: "UM000001" }); // names 에 없음
assert.deepEqual(folderLines(rows[1])[0], { kind: "drawing", name: null, key: "SD01010100" }); // names 자체가 없음
assert.deepEqual(folderLines({ key: "SS1", 열: "x" })[4], {
kind: "unitPrice",
name: null,
key: "",
});
console.log("항목 고르기 자료 시험 통과");
+5
View File
@@ -90,6 +90,11 @@
color: var(--color-text-muted);
}
.ui-item-picker__key {
color: var(--color-text-muted);
font-size: 11px;
}
.ui-item-picker__none {
color: var(--color-text-muted);
}
+19 -11
View File
@@ -10,12 +10,12 @@
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,
itemName,
layerOf,
ownerOf,
pathIds,
@@ -23,6 +23,7 @@ import {
toSummary,
type FolderKind,
type PickNode,
type PickerLayer,
type PickerRow,
type SummaryItem,
type TreeLabels,
@@ -68,14 +69,17 @@ function reasonOf(body: unknown, status: number): string {
return typeof detail === "string" && detail ? detail : `HTTP ${status}`;
}
/** 실서버 응답 — 아직 없으면(404 · 405) 21-1 모양 가짜 응답. ponytail: 21-2 서버가 오면 가짜를 지움 */
/** `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" });
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 ?? []);
| { 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) */
@@ -86,7 +90,6 @@ async function copyToPersonal(row: PickerRow): Promise<string | null> {
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;
@@ -132,7 +135,7 @@ export function openItemPicker(opts: ItemPickerOptions): Promise<ItemPickerResul
detail.replaceChildren(
el("h4", {
className: "ui-item-picker__name",
text: chosen.열,
text: itemName(chosen),
attrs: { title: chosen.key },
}),
small(where),
@@ -141,15 +144,20 @@ export function openItemPicker(opts: ItemPickerOptions): Promise<ItemPickerResul
className: "ui-item-picker__folder",
children: folderLines(chosen).map((line) =>
el("li", {
attrs: line.tip ? { title: line.tip } : {},
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.text ? "" : "ui-item-picker__none",
text: line.text || t("M02_PickerNone"),
className: line.name
? ""
: line.key
? "ui-item-picker__key"
: "ui-item-picker__none",
text: line.name || line.key || t("M02_PickerNone"),
}),
],
}),
+33 -20
View File
@@ -14,7 +14,11 @@ export interface SummaryItem {
name: string;
}
/** `GET /api/m02/items` 한 줄(21-1) — `?all=1` 은 줄마다 `layer` · `owner` 가 붙음 */
/** 항목 줄이 가리키는 키 칸 — 서버가 `names` 로 이름을 함께 주면 이름을 보임 */
export type NameField = "일위대가" | "자재조합" | "도번" | "면적번" | "재료번";
/** 목록 한 줄(21-1) — 서버는 `?all=1` 을 `[{layer, owner, items}]` 로 주고, 줄마다 `layer` · `owner` 를 붙여 폄.
* `열` 은 집계표 열 id(rv …) — 보이는 이름은 종류 · 분류를 이은 `itemName`. */
export interface PickerRow {
key: string;
열: string;
@@ -28,6 +32,8 @@ export interface PickerRow {
도번?: string;
면적번?: string;
재료번?: string;
/** 위 키 칸의 이름(21-9 서버가 더함) — 없으면 키를 작은 글로 보임 */
names?: Partial<Record<NameField, string | null>>;
layer?: PickerLayer;
owner?: string | null;
}
@@ -48,6 +54,10 @@ export interface PickNode {
row?: PickerRow;
}
/** 보이는 항목 이름 — 종류 · 분류1~3 을 이음(비면 열 id · 키) */
export const itemName = (row: PickerRow): string =>
[row.종류, row.분류1, row.분류2, row.분류3].filter(Boolean).join(" ") || row.열 || row.key;
export const layerOf = (row: PickerRow): PickerLayer => row.layer ?? "system";
export const ownerOf = (row: PickerRow): string | null =>
layerOf(row) === "system" ? null : (row.owner ?? null);
@@ -56,7 +66,7 @@ export const ownerOf = (row: PickerRow): string | 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.열 };
return { layer: layerOf(row), owner: ownerOf(row), key: row.key, name: itemName(row) };
}
export function isCurrent(current: SummaryItem | null, row: PickerRow): boolean {
@@ -74,14 +84,11 @@ export function filterRows(rows: PickerRow[], text: string): PickerRow[] {
if (!words.length) return rows;
return rows.filter((row) => {
const hay = [
row.열,
row.종류,
row.분류1,
row.분류2,
row.분류3,
itemName(row),
row.key,
row.일위대가,
row.자재조합,
...Object.values(row.names ?? {}),
]
.join(" ")
.toLowerCase();
@@ -114,7 +121,7 @@ export function buildTree(rows: PickerRow[], labels: TreeLabels): PickNode[] {
count: leaves.length,
children: leaves.map((row) => ({
id: rowId(row),
label: row.열,
label: itemName(row),
tip: row.key,
count: null,
row,
@@ -143,19 +150,25 @@ export type FolderKind = "drawing" | "area" | "material" | "combo" | "unitPrice"
export interface FolderLine {
kind: FolderKind;
/** 보이는 이름 */
text: string;
/** 마우스를 올렸을 때 — 키 · 번호 */
tip: string;
/** 서버가 준 이름 — 없으면 null(키를 작은 글로) */
name: string | null;
/** 키 · 번호 — 이름이 있으면 마우스를 올릴 때, 없으면 작은 글로 */
key: string;
}
/** 오른쪽 폴더 모양 — 도면 · 면적산출 · 재료산출은 그 항목(열) 이름 · 자재조합 · 일위대가는 서버 값 */
const FOLDER_FIELDS: readonly [FolderKind, NameField][] = [
["drawing", "도번"],
["area", "면적번"],
["material", "재료번"],
["combo", "자재조합"],
["unitPrice", "일위대가"],
];
/** 오른쪽 폴더 모양 — 다섯 줄 모두 서버의 키 칸 + `names` 의 이름 */
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.일위대가 ?? "" },
];
return FOLDER_FIELDS.map(([kind, field]) => ({
kind,
name: row.names?.[field] || null,
key: row[field] ?? "",
}));
}
@@ -1,60 +0,0 @@
/* =============================================================================
* 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 };
}