From 927e85d1107a6ac072821e414110343540b1709e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 29 Sep 2026 12:00:13 +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=20=EB=AA=A8=EB=8B=AC=20=C2=B7=20?= =?UTF-8?q?=EA=B3=B5=EC=9A=A9=20=EB=82=98=EB=AC=B4=20=EB=B3=B4=EA=B8=B0=20?= =?UTF-8?q?=E2=80=94=20PLAN=2021-5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_01K7JS47SacjPxZ718QsZKyr --- M01_MasterData/M01_MasterData_UI_Side.ts | 2 +- M01_MasterData/M01_MasterData_UI_Style.css | 18 -- M01_MasterData/M01_MasterData_UI_Tree.ts | 42 +-- resources/tester/ui/item-picker-data.test.ts | 139 +++++++++ ui_template/ui_template_item_picker.css | 101 +++++++ ui_template/ui_template_item_picker.ts | 289 +++++++++++++++++++ ui_template/ui_template_item_picker_data.ts | 161 +++++++++++ ui_template/ui_template_item_picker_fake.ts | 60 ++++ ui_template/ui_template_locale_m2.ts | 23 ++ ui_template/ui_template_tree.css | 23 ++ ui_template/ui_template_tree.ts | 76 +++++ 11 files changed, 875 insertions(+), 59 deletions(-) create mode 100644 resources/tester/ui/item-picker-data.test.ts create mode 100644 ui_template/ui_template_item_picker.css create mode 100644 ui_template/ui_template_item_picker.ts create mode 100644 ui_template/ui_template_item_picker_data.ts create mode 100644 ui_template/ui_template_item_picker_fake.ts create mode 100644 ui_template/ui_template_tree.css create mode 100644 ui_template/ui_template_tree.ts diff --git a/M01_MasterData/M01_MasterData_UI_Side.ts b/M01_MasterData/M01_MasterData_UI_Side.ts index 2e31508d..4fe29ef8 100644 --- a/M01_MasterData/M01_MasterData_UI_Side.ts +++ b/M01_MasterData/M01_MasterData_UI_Side.ts @@ -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 }), ], }), diff --git a/M01_MasterData/M01_MasterData_UI_Style.css b/M01_MasterData/M01_MasterData_UI_Style.css index 2b909b1b..61c5c965 100644 --- a/M01_MasterData/M01_MasterData_UI_Style.css +++ b/M01_MasterData/M01_MasterData_UI_Style.css @@ -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); diff --git a/M01_MasterData/M01_MasterData_UI_Tree.ts b/M01_MasterData/M01_MasterData_UI_Tree.ts index 21d2c5f9..5fcb4df8 100644 --- a/M01_MasterData/M01_MasterData_UI_Tree.ts +++ b/M01_MasterData/M01_MasterData_UI_Tree.ts @@ -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"; diff --git a/resources/tester/ui/item-picker-data.test.ts b/resources/tester/ui/item-picker-data.test.ts new file mode 100644 index 00000000..4b4f04d0 --- /dev/null +++ b/resources/tester/ui/item-picker-data.test.ts @@ -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("항목 고르기 자료 시험 통과"); diff --git a/ui_template/ui_template_item_picker.css b/ui_template/ui_template_item_picker.css new file mode 100644 index 00000000..8bcfc3d5 --- /dev/null +++ b/ui_template/ui_template_item_picker.css @@ -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); +} diff --git a/ui_template/ui_template_item_picker.ts b/ui_template/ui_template_item_picker.ts new file mode 100644 index 00000000..7cc780b0 --- /dev/null +++ b/ui_template/ui_template_item_picker.ts @@ -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 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 { + 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 { + 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 { + return new Promise((resolve) => { + let rows: PickerRow[] = []; + let chosen: PickerRow | null = null; + /** 접었다 펼친 것을 다시 그려도 남기려고 펼친 줄 id 를 기억 */ + const opened = new Set(); + const leafButtons = new Map(); + + 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 => { + 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; + }); +} diff --git a/ui_template/ui_template_item_picker_data.ts b/ui_template/ui_template_item_picker_data.ts new file mode 100644 index 00000000..8974b363 --- /dev/null +++ b/ui_template/ui_template_item_picker_data.ts @@ -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; + /** 종류 · 분류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>(); + for (const row of mine) { + const kind = row.종류 || labels.unclassified; + const cat = row.분류1 || labels.unclassified; + const cats = kinds.get(kind) ?? new Map(); + 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.일위대가 ?? "" }, + ]; +} diff --git a/ui_template/ui_template_item_picker_fake.ts b/ui_template/ui_template_item_picker_fake.ts new file mode 100644 index 00000000..5f123c57 --- /dev/null +++ b/ui_template/ui_template_item_picker_fake.ts @@ -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 => ({ + 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 }; +} diff --git a/ui_template/ui_template_locale_m2.ts b/ui_template/ui_template_locale_m2.ts index 464cdccf..2a247992 100644 --- a/ui_template/ui_template_locale_m2.ts +++ b/ui_template/ui_template_locale_m2.ts @@ -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; diff --git a/ui_template/ui_template_tree.css b/ui_template/ui_template_tree.css new file mode 100644 index 00000000..228ae44f --- /dev/null +++ b/ui_template/ui_template_tree.css @@ -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); +} diff --git a/ui_template/ui_template_tree.ts b/ui_template/ui_template_tree.ts new file mode 100644 index 00000000..b33cbc3b --- /dev/null +++ b/ui_template/ui_template_tree.ts @@ -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; +}