diff --git a/Z01_MasterData/Z01_MasterData_Api_Fetch.ts b/Z01_MasterData/Z01_MasterData_Api_Fetch.ts index ddfd40ae..2c93abcc 100644 --- a/Z01_MasterData/Z01_MasterData_Api_Fetch.ts +++ b/Z01_MasterData/Z01_MasterData_Api_Fetch.ts @@ -6,7 +6,13 @@ * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; -import type { MasterColumn, OverrideState, SortOrder, TableMeta } from "./Z01_MasterData_UI_Cells"; +import type { + MasterColumn, + OverrideState, + ServedKind, + SortOrder, + TableMeta, +} from "./Z01_MasterData_UI_Cells"; export interface MasterTable { id: string; @@ -48,26 +54,14 @@ export interface RowsQuery { } /** - * ① 기초단가 다섯 — 얼마인가. 시장이 정하고 주간~반기로 바뀜. - * 고친 값은 원본이 아니라 덮개 파일에 쌓임(품셈이 갱신돼도 안 날아감). + * 표 한 벌의 이름 — 화면은 목록을 안 가짐. 갈래·kind·이름·줄 수를 서버에서 받아 세움 + * (2026-09-16 브레인 — kind 가 늘 때마다 화면이 빠지던 자리). */ -export const BASE_PRICE_KINDS = ["labor", "machine", "material", "oil", "rate"] as const; +export type BasePriceKind = string; -/** ② 품셈 기준 아홉 — 얼마나 드나. 품셈이 정하고 해마다 한 번 개정(2026-09-16 사용자 ①②). */ -export const SPEC_KINDS = [ - "coef", - "material_surcharge", - "formwork_reuse", - "rebar_complexity", - "timber_structure_class", - "masonry_slope", - "masonry_back_length", - "stone_kind", - "machine_productivity", - "masonry_class", -] as const; - -export type BasePriceKind = (typeof BASE_PRICE_KINDS)[number] | (typeof SPEC_KINDS)[number]; +export function fetchBaseKinds(): Promise { + return request<{ kinds: ServedKind[] }>("/master-data/base-prices").then((data) => data.kinds); +} async function request(path: string, init: RequestInit = {}): Promise { const response = await fetch(`${API_BASE_URL}${path}`, { diff --git a/Z01_MasterData/Z01_MasterData_UI_Cells.ts b/Z01_MasterData/Z01_MasterData_UI_Cells.ts index b4e9b850..c207aa2d 100644 --- a/Z01_MasterData/Z01_MasterData_UI_Cells.ts +++ b/Z01_MasterData/Z01_MasterData_UI_Cells.ts @@ -85,6 +85,25 @@ export function outdatedBanner(sources?: TableSource[]): string[] { }); } +/** 서버가 주는 표 한 벌 — 갈래·kind·한글 이름·줄 수. 화면은 제 목록을 안 가짐(늘면 저절로 섬). */ +export interface ServedKind { + group: string; + kind: string; + label: string; + rows: number; +} + +/** 받은 차례 그대로 갈래별로 묶음 — 갈래 차례도 서버가 낸 순서를 따름. */ +export function groupKinds(kinds: ServedKind[]): { group: string; items: ServedKind[] }[] { + const boxes: { group: string; items: ServedKind[] }[] = []; + for (const kind of kinds) { + const box = boxes.find((each) => each.group === kind.group); + if (box) box.items.push(kind); + else boxes.push({ group: kind.group, items: [kind] }); + } + return boxes; +} + /** 열 제목을 눌러 세운 차례 — null 이면 원본 차례(서버가 sort 없이 주는 그대로). */ export interface SortOrder { key: string; diff --git a/Z01_MasterData/Z01_MasterData_UI_Page.ts b/Z01_MasterData/Z01_MasterData_UI_Page.ts index d077e77e..021e9198 100644 --- a/Z01_MasterData/Z01_MasterData_UI_Page.ts +++ b/Z01_MasterData/Z01_MasterData_UI_Page.ts @@ -13,7 +13,8 @@ import { t as L } from "@ui/ui_template_locale"; import { createWorkflowOverlays } from "@ui/ui_template_overlay"; import { navigateTo } from "../A00_Common/router"; import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch"; -import { fetchMasterTree, type MasterGroup } from "./Z01_MasterData_Api_Fetch"; +import { fetchBaseKinds, fetchMasterTree, type MasterGroup } from "./Z01_MasterData_Api_Fetch"; +import type { ServedKind } from "./Z01_MasterData_UI_Cells"; import { buildRowsView } from "./Z01_MasterData_UI_Rows"; import { buildSidePanel } from "./Z01_MasterData_UI_Side"; import "./Z01_MasterData_UI_Style.css"; @@ -27,24 +28,25 @@ export async function renderZ01MasterData(root: HTMLElement): Promise { } showLoadingOverlay(); let groups: MasterGroup[] = []; + let kinds: ServedKind[] = []; try { - groups = await fetchMasterTree(); + [groups, kinds] = await Promise.all([fetchMasterTree(), fetchBaseKinds()]); } catch (error) { showToast(error instanceof Error ? error.message : L("Z01_MasterData_LoadFailed"), "error"); } finally { hideLoadingOverlay(); } root.innerHTML = ""; - root.append(buildPage(groups)); + root.append(buildPage(groups, kinds)); } -function buildPage(groups: MasterGroup[]): HTMLElement { +function buildPage(groups: MasterGroup[], kinds: ServedKind[]): HTMLElement { const view = buildRowsView(); const layout = el("div", { className: "ui-workflow-layout z01-master" }); const main = el("main", { className: "ui-workflow-layout__main", children: [view.root] }); const overlays = createWorkflowOverlays({ title: L("Z01_MasterData_Title"), - optionsContent: buildSidePanel(groups, view), + optionsContent: buildSidePanel(groups, kinds, view), showProjectName: false, onOptionsOpenChange: (isOpen) => layout.classList.toggle("is-options-open", isOpen), }); diff --git a/Z01_MasterData/Z01_MasterData_UI_Side.ts b/Z01_MasterData/Z01_MasterData_UI_Side.ts index a0fb7fee..d245d1ee 100644 --- a/Z01_MasterData/Z01_MasterData_UI_Side.ts +++ b/Z01_MasterData/Z01_MasterData_UI_Side.ts @@ -10,55 +10,62 @@ import { attachCollapsible } from "@ui/ui_template_collapsible"; import { el } from "@ui/ui_template_elements"; import { t as L } from "@ui/ui_template_locale"; import { - BASE_PRICE_KINDS, fetchBasePrices, fetchMasterRows, fetchOverrides, type MasterGroup, - type BasePriceKind, type MasterRows, type RowsQuery, saveBasePrice, - SPEC_KINDS, VALUES_TABLE_ID, } from "./Z01_MasterData_Api_Fetch"; -import { valueWithUnit, type OverrideState } from "./Z01_MasterData_UI_Cells"; +import { + groupKinds, + valueWithUnit, + type OverrideState, + type ServedKind, +} from "./Z01_MasterData_UI_Cells"; import type { RowsView } from "./Z01_MasterData_UI_Rows"; -export function buildSidePanel(groups: MasterGroup[], view: RowsView): HTMLElement { +export function buildSidePanel( + groups: MasterGroup[], + kinds: ServedKind[], + view: RowsView, +): HTMLElement { const panel = el("div", { className: "z01-master__side" }); const activate = (button: HTMLElement): void => { panel.querySelector(".is-active")?.classList.remove("is-active"); button.classList.add("is-active"); }; - const baseTitle = L("Z01_MasterData_BasePrices"); - const specTitle = L("Z01_MasterData_SpecValues"); - /** 한 상자 몫 단추들 — ①얼마인가 ②얼마나 드나 로 갈라 세움(성격도 갱신 주기도 다름). */ - const kindGrid = (boxTitle: string, kinds: readonly BasePriceKind[]): HTMLElement => { + // 갈래·kind·이름·줄 수 모두 서버 것 — 화면은 목록도 이름도 안 가짐(kind 가 늘면 저절로 섬). + const boxes = groupKinds(kinds).map((box) => { const grid = el("div", { className: "z01-master__base-grid" }); - for (const kind of kinds) { - const label = L(`Z01_MasterData_Base_${kind}` as const); + for (const served of box.items) { const button = el("button", { className: "z01-master__base", attrs: { type: "button" }, - text: label, + children: [ + el("span", { text: served.label }), + el("span", { + className: "z01-master__key", + text: served.rows.toLocaleString("ko-KR"), + }), + ], }); button.addEventListener("click", () => { activate(button); view.show({ - title: `${boxTitle} › ${label}`, - key: `base-prices/${kind}`, - load: (query) => fetchBasePrices(kind, query), - save: (rowId, values) => saveBasePrice(kind, rowId, values), + title: `${box.group} › ${served.label}`, + key: `base-prices/${served.kind}`, + load: (query) => fetchBasePrices(served.kind, query), + save: (rowId, values) => saveBasePrice(served.kind, rowId, values), }); }); grid.append(button); } - return grid; - }; - const baseGrid = kindGrid(baseTitle, BASE_PRICE_KINDS); - const specGrid = kindGrid(specTitle, SPEC_KINDS); + return { group: box.group, grid }; + }); const overridesButton = el("button", { className: "z01-master__base z01-master__base--wide", attrs: { type: "button" }, @@ -67,13 +74,14 @@ export function buildSidePanel(groups: MasterGroup[], view: RowsView): HTMLEleme overridesButton.addEventListener("click", () => { activate(overridesButton); view.show({ - title: `${baseTitle} › ${L("Z01_MasterData_Overrides")}`, + title: L("Z01_MasterData_Overrides"), key: "overrides", searchable: false, - load: loadOverrides, + // 갈래 이름도 서버 것 — 목록 줄의 kind 를 표 이름으로 바꿔 보임. + load: (query) => loadOverrides(query, new Map(kinds.map((k) => [k.kind, k.label]))), }); }); - baseGrid.append(overridesButton); + boxes[0]?.grid.append(overridesButton); const tree = el("nav", { className: "z01-master__tree" }); for (const group of groups) { @@ -123,8 +131,7 @@ export function buildSidePanel(groups: MasterGroup[], view: RowsView): HTMLEleme } panel.append( - section(baseTitle, baseGrid), - section(specTitle, specGrid), + ...boxes.map((box) => section(box.group, box.grid)), section(L("Z01_MasterData_Tree"), tree), ); attachCollapsible(panel); @@ -142,13 +149,16 @@ const STATE_LABEL: Record[0]> = { * 고친 것 목록을 표 한 벌로 — 정렬(원본 바뀜 → 주인 없음 → 고쳐짐)과 쪽 나눔은 서버가 함. * 화면이 다시 줄 세우면 맨 위가 그 쪽 안에서만 맨 위가 됨(2026-09-15 브레인 ②). 서버에 검색이 없어 검색 칸은 숨김. */ -async function loadOverrides(query: RowsQuery): Promise { +async function loadOverrides( + query: RowsQuery, + kindLabels: Map, +): Promise { const { items, total } = await fetchOverrides(query); const column = (key: string, label: Parameters[0]) => ({ key, label: L(label) }); return { columns: [ column("state", "Z01_MasterData_List_State"), - column("kind", "Z01_MasterData_BasePrices"), + column("kind", "Z01_MasterData_List_Kind"), column("row_label", "Z01_MasterData_List_Row"), column("column_label", "Z01_MasterData_List_Column"), column("value", "Z01_MasterData_List_Value"), @@ -164,7 +174,7 @@ async function loadOverrides(query: RowsQuery): Promise { : {}), // state 가 안 오면 때우지 않고 드러냄(서버 버그). state: L(STATE_LABEL[item.state] ?? "Z01_MasterData_State_Missing"), - kind: L(`Z01_MasterData_Base_${item.kind}` as const), + kind: kindLabels.get(item.kind) ?? item.kind, })), total, }; diff --git a/resources/tester/test_z01_master_cells.py b/resources/tester/test_z01_master_cells.py index a88ad823..28d71e34 100644 --- a/resources/tester/test_z01_master_cells.py +++ b/resources/tester/test_z01_master_cells.py @@ -25,9 +25,20 @@ _RUNNER = """ import { writeFileSync } from "node:fs"; import { cellInfo, cellText, clampPage, columnTitle, latestOnly, pageCount, parseCellInput, rowCells, - boldSegments, nextSort, noticeLines, outdatedBanner, shownColumns, valueWithUnit, + boldSegments, groupKinds, nextSort, noticeLines, outdatedBanner, shownColumns, valueWithUnit, } from "./Z01_MasterData_UI_Cells.js"; +// kind 목록은 서버가 줌 — 화면이 제 목록을 들면 kind 가 늘 때마다 빠짐(2026-09-16 브레인) +const served = [ + { group: "base_price", kind: "labor", label: "노임", rows: 261 }, + { group: "base_price", kind: "oil", label: "유가", rows: 36 }, + { group: "pumsem_basis", kind: "coef", label: "토량환산계수", rows: 32 }, + // 서버가 새 kind·새 갈래를 더해도 화면이 그대로 세움 + { group: "pumsem_basis", kind: "machine_productivity", label: "기계 작업량", rows: 47 }, + { group: "later_group", kind: "brand_new", label: "새 갈래 것", rows: 3 }, +]; +const grouped = groupKinds(served).map((g) => [g.group, g.items.map((i) => i.kind)]); + // 열 제목 누르기 — 없음 → 오름 → 내림 → 없음(원본 차례) · 다른 열을 누르면 그 열 오름부터 const sorts = [ nextSort(null, "price_krw"), @@ -193,6 +204,7 @@ writeFileSync(process.argv[2], JSON.stringify({ notices, bolds, sorts, + grouped, })); """ @@ -311,3 +323,9 @@ def test_칸_글자와_숨김_열(tmp_path: Path) -> None: None, {"key": "item_code", "desc": False}, ] + # 서버 차례 그대로 갈래별로 묶임 · 새 kind·새 갈래도 저절로 섬(화면 목록이 없음) + assert got["grouped"] == [ + ["base_price", ["labor", "oil"]], + ["pumsem_basis", ["coef", "machine_productivity"]], + ["later_group", ["brand_new"]], + ] diff --git a/ui_template/ui_template_locale_b3.ts b/ui_template/ui_template_locale_b3.ts index 810d777a..ccc0850a 100644 --- a/ui_template/ui_template_locale_b3.ts +++ b/ui_template/ui_template_locale_b3.ts @@ -141,24 +141,8 @@ export const ui_locales_b3 = { B01_Dashboard_MasterData: ["마스터 데이터", "Master Data"], Z01_MasterData_Title: ["마스터 데이터", "Master Data"], Z01_MasterData_Tree: ["마스터 트리", "Master tree"], - Z01_MasterData_BasePrices: ["기초단가", "Base prices"], - Z01_MasterData_Base_labor: ["노임단가", "Labor"], - Z01_MasterData_Base_machine: ["기계단가", "Machine"], - Z01_MasterData_Base_material: ["자재단가", "Material"], - Z01_MasterData_Base_oil: ["유가", "Fuel"], - Z01_MasterData_Base_rate: ["요율", "Rates"], - /* ② 품셈 기준 아홉 — 얼마나 드나(품셈이 정하고 해마다 한 번 개정) */ - Z01_MasterData_SpecValues: ["품셈 기준", "Standard rates"], - Z01_MasterData_Base_coef: ["토량환산", "Soil conversion"], - Z01_MasterData_Base_material_surcharge: ["자재할증", "Material surcharge"], - Z01_MasterData_Base_formwork_reuse: ["거푸집", "Formwork reuse"], - Z01_MasterData_Base_rebar_complexity: ["철근", "Rebar"], - Z01_MasterData_Base_timber_structure_class: ["목구조", "Timber structure"], - Z01_MasterData_Base_masonry_slope: ["돌쌓기 경사", "Masonry slope"], - Z01_MasterData_Base_masonry_back_length: ["뒷길이", "Back length"], - Z01_MasterData_Base_stone_kind: ["돌종류", "Stone kind"], - Z01_MasterData_Base_machine_productivity: ["기계 작업량", "Machine productivity"], - Z01_MasterData_Base_masonry_class: ["갈래", "Masonry class"], + /* ⚠ 갈래·표 이름은 사전에 두지 않음 — 서버(이름표)가 줌. 화면이 들면 kind 가 늘 때 빠짐 */ + Z01_MasterData_List_Kind: ["표", "Table"], Z01_MasterData_Overrides: ["고친 것 모아 보기", "Edited values"], Z01_MasterData_Edit_Can: [ "고칠 수 있는 칸 — 눌러 고치고 Enter 로 저장 (Esc 취소)",