From 10cc5dd373a8cb7170d8a08583b72d2cb39a64da Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 29 Sep 2026 12:33:55 +0900 Subject: [PATCH] =?UTF-8?q?feat(ui=5Ftemplate):=20=ED=95=AD=EB=AA=A9=20?= =?UTF-8?q?=EA=B3=A0=EB=A5=B4=EA=B8=B0=EB=A5=BC=20=EC=8B=A4=EC=84=9C?= =?UTF-8?q?=EB=B2=84=EC=97=90=20=EC=9D=B4=EC=9D=8C=20=E2=80=94=20=EA=B0=80?= =?UTF-8?q?=EC=A7=9C=20=EC=9D=91=EB=8B=B5=20=EC=A7=80=EC=9B=80=20(PLAN=202?= =?UTF-8?q?1-9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_01K7JS47SacjPxZ718QsZKyr --- resources/tester/ui/item-picker-data.test.ts | 98 +++++++++++++++----- ui_template/ui_template_item_picker.css | 5 + ui_template/ui_template_item_picker.ts | 30 +++--- ui_template/ui_template_item_picker_data.ts | 53 +++++++---- ui_template/ui_template_item_picker_fake.ts | 60 ------------ 5 files changed, 131 insertions(+), 115 deletions(-) delete mode 100644 ui_template/ui_template_item_picker_fake.ts diff --git a/resources/tester/ui/item-picker-data.test.ts b/resources/tester/ui/item-picker-data.test.ts index 4b4f04d0..8e0c62bc 100644 --- a/resources/tester/ui/item-picker-data.test.ts +++ b/resources/tester/ui/item-picker-data.test.ts @@ -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 => ({ + 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("항목 고르기 자료 시험 통과"); diff --git a/ui_template/ui_template_item_picker.css b/ui_template/ui_template_item_picker.css index 8bcfc3d5..26356c04 100644 --- a/ui_template/ui_template_item_picker.css +++ b/ui_template/ui_template_item_picker.css @@ -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); } diff --git a/ui_template/ui_template_item_picker.ts b/ui_template/ui_template_item_picker.ts index 7cc780b0..f441d22e 100644 --- a/ui_template/ui_template_item_picker.ts +++ b/ui_template/ui_template_item_picker.ts @@ -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 { 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 { 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 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"), }), ], }), diff --git a/ui_template/ui_template_item_picker_data.ts b/ui_template/ui_template_item_picker_data.ts index 8974b363..3da723e7 100644 --- a/ui_template/ui_template_item_picker_data.ts +++ b/ui_template/ui_template_item_picker_data.ts @@ -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>; 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] ?? "", + })); } diff --git a/ui_template/ui_template_item_picker_fake.ts b/ui_template/ui_template_item_picker_fake.ts deleted file mode 100644 index 5f123c57..00000000 --- a/ui_template/ui_template_item_picker_fake.ts +++ /dev/null @@ -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 => ({ - 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 }; -}