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
+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 };
}