From b890fba04a15ebf98534a7340e607c05d4389129 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 17 Sep 2026 13:42:00 +0900 Subject: [PATCH] =?UTF-8?q?feat(z01):=20=EC=84=9C=EB=B2=84=EA=B0=80=20?= =?UTF-8?q?=EC=A3=BC=EB=8A=94=20=EA=B1=B0=EB=A5=B4=EA=B8=B0=20=EC=83=81?= =?UTF-8?q?=EC=9E=90=20=E2=80=94=20=ED=91=9C=EC=97=90=20filters=20?= =?UTF-8?q?=EA=B0=80=20=EC=98=A4=EB=A9=B4=20=EC=B0=BE=EA=B8=B0=20=EC=B9=B8?= =?UTF-8?q?=20=EC=98=86=EC=97=90=20=EA=B3=A0=EB=A5=B4=EA=B8=B0=20=EC=83=81?= =?UTF-8?q?=EC=9E=90=EB=A5=BC=20=EC=84=B8=EC=9A=B0=EA=B3=A0=20=EA=B3=A0?= =?UTF-8?q?=EB=A5=B8=20=EA=B0=92=EB=A7=8C=20key=3Dvalue=20=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=B4=EB=83=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 이름·선택지·줄 수·기본값도, 어느 줄을 거를지 판정도 서버 것(화면은 판정 안 함 · 2026-09-17 브레인) - 안 골랐으면 서버 default 를 보임 — 첫 선택지를 보이면 「전부」로 보이며 걸러진 목록이 뜸 - 고르면 1쪽부터 · 다른 표로 가면 고른 것 비움 · filters 없는 표(산림 등)는 상자 없음 - 화면 확인: 진짜 건설 공종 축 위에 가짜 filters 얹어 기본 「임도가 쓰는 것」 → 「전부」 전환 · 산림 상자 없음 - 시험: 기본값 = 서버 default(첫 선택지 아님) · 고르면 고른 값 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq --- Z01_MasterData/Z01_MasterData_Api_Fetch.ts | 3 ++ Z01_MasterData/Z01_MasterData_UI_Cells.ts | 18 +++++++++++ Z01_MasterData/Z01_MasterData_UI_Rows.ts | 37 ++++++++++++++++++++-- resources/tester/test_z01_master_cells.py | 14 +++++++- 4 files changed, 69 insertions(+), 3 deletions(-) diff --git a/Z01_MasterData/Z01_MasterData_Api_Fetch.ts b/Z01_MasterData/Z01_MasterData_Api_Fetch.ts index 2c93abcc..b4da3fc7 100644 --- a/Z01_MasterData/Z01_MasterData_Api_Fetch.ts +++ b/Z01_MasterData/Z01_MasterData_Api_Fetch.ts @@ -51,6 +51,8 @@ export interface RowsQuery { q: string; /** 열 제목을 눌렀을 때만 — 없으면 서버가 원본 차례로 줌 */ sort?: SortOrder | null; + /** 사람이 고른 거르기만(`TableMeta.filters`) — 안 고른 것은 서버 기본값 */ + filters?: Record; } /** @@ -87,6 +89,7 @@ export function fetchBasePrices(kind: BasePriceKind, query: RowsQuery): Promise< params.set("sort", query.sort.key); params.set("desc", query.sort.desc ? "1" : "0"); } + for (const [key, value] of Object.entries(query.filters ?? {})) params.set(key, value); return request(`/master-data/base-prices/${kind}?${params}`); } diff --git a/Z01_MasterData/Z01_MasterData_UI_Cells.ts b/Z01_MasterData/Z01_MasterData_UI_Cells.ts index e0995b09..7e6f4e0a 100644 --- a/Z01_MasterData/Z01_MasterData_UI_Cells.ts +++ b/Z01_MasterData/Z01_MasterData_UI_Cells.ts @@ -134,8 +134,26 @@ export function boldSegments(text: string): { text: string; bold: boolean }[] { .filter((part) => part.text !== ""); } +/** + * 서버가 주는 거르기 한 벌(건설품셈 「임도가 쓰는 것」 따위) — 이름·선택지·줄 수·기본값 모두 서버 것. + * 어느 줄을 거를지도 서버가 판정함 — 화면은 고른 값을 `key=value` 로 보내기만(2026-09-17 브레인). + */ +export interface TableFilter { + key: string; + label: string; + options: { value: string; label: string; rows?: number }[]; + /** 값을 안 보냈을 때 서버가 쓴 선택지 — 화면이 이것을 보여야 보이는 것과 걸린 것이 같음 */ + default: string; +} + +/** 거르기 상자에 보일 값 — 사람이 고른 것 · 안 골랐으면 서버가 이미 건 기본값. */ +export function filterValue(filter: TableFilter, picked: Record): string { + return picked[filter.key] ?? filter.default; +} + /** 기초단가 표 머리 — 열 하나에 한 문장이라 표에 한 번만 옴(2026-09-15 브레인 ④). */ export interface TableMeta { + filters?: TableFilter[]; /** 한 표가 모은 원본마다 한 줄 */ source?: TableSource[]; /** diff --git a/Z01_MasterData/Z01_MasterData_UI_Rows.ts b/Z01_MasterData/Z01_MasterData_UI_Rows.ts index 203e52ab..1d36d640 100644 --- a/Z01_MasterData/Z01_MasterData_UI_Rows.ts +++ b/Z01_MasterData/Z01_MasterData_UI_Rows.ts @@ -10,6 +10,7 @@ import { createButton, createInputField, + createSelectField, el, showConfirmDialog, showToast, @@ -21,6 +22,7 @@ import { cellInfo, clampPage, columnTitle, + filterValue, latestOnly, nextSort, noticeLines, @@ -90,7 +92,12 @@ function stateText(info: CellInfo, column: MasterColumn): string { export function buildRowsView(): RowsView { let source: RowsSource | null = null; - const state = { page: 1, q: "", sort: null as SortOrder | null }; + const state = { + page: 1, + q: "", + sort: null as SortOrder | null, + filters: {} as Record, + }; let showHidden = false; let last: MasterRows | null = null; let selected: { index: number; key: string } | null = null; @@ -137,9 +144,10 @@ export function buildRowsView(): RowsView { const prev = pagerButton("Z01_MasterData_Prev", () => goTo(state.page - 1)); const next = pagerButton("Z01_MasterData_Next", () => goTo(state.page + 1)); const end = pagerButton("Z01_MasterData_Last", () => goTo(Infinity)); + const filterBox = el("div", { className: "z01-master__toolbar" }); const toolbar = el("div", { className: "z01-master__toolbar", - children: [search.root, hiddenLabel], + children: [search.root, filterBox, hiddenLabel], }); const pager = el("div", { className: "z01-master__pager", @@ -180,6 +188,7 @@ export function buildRowsView(): RowsView { size: PAGE_SIZE, q: state.q, sort: state.sort, + filters: state.filters, }); } catch (error) { grid.classList.remove("is-loading"); @@ -250,6 +259,28 @@ export function buildRowsView(): RowsView { const outdated = outdatedBanner(meta.source); banner.replaceChildren(...outdated.map((text) => el("p", { text: `⚠ ${text}` }))); banner.hidden = outdated.length === 0; + // 거르기 — 선택지·이름·줄 수·기본값도, 어느 줄을 거를지 판정도 서버 것(화면은 고른 값만 보냄). + filterBox.replaceChildren( + ...(meta.filters ?? []).map( + (filter) => + createSelectField({ + label: filter.label, + options: filter.options.map((option) => ({ + value: option.value, + text: + option.rows == null + ? option.label + : `${option.label} (${option.rows.toLocaleString("ko-KR")})`, + })), + value: filterValue(filter, state.filters), + onChange: (value) => { + state.filters[filter.key] = value; + state.page = 1; + void load(); + }, + }).root, + ), + ); const columns = shownColumns(meta.columns, showHidden); const headRow = el("tr", { children: columns.map((column) => headCell(meta, column)) }); const body = el("tbody"); @@ -429,6 +460,8 @@ export function buildRowsView(): RowsView { source = picked; state.page = 1; state.sort = null; + state.filters = {}; + filterBox.replaceChildren(); title.textContent = picked.title; titleKey.textContent = picked.key; notice.hidden = true; diff --git a/resources/tester/test_z01_master_cells.py b/resources/tester/test_z01_master_cells.py index be23448e..62943293 100644 --- a/resources/tester/test_z01_master_cells.py +++ b/resources/tester/test_z01_master_cells.py @@ -25,7 +25,7 @@ _RUNNER = """ import { writeFileSync } from "node:fs"; import { cellInfo, cellText, clampPage, columnTitle, latestOnly, pageCount, parseCellInput, rowCells, - boldSegments, groupKinds, nextSort, noticeLines, outdatedBanner, shownColumns, valueWithUnit, + boldSegments, filterValue, groupKinds, nextSort, noticeLines, outdatedBanner, shownColumns, valueWithUnit, } from "./Z01_MasterData_UI_Cells.js"; // kind 목록은 서버가 줌 — 화면이 제 목록을 들면 kind 가 늘 때마다 빠짐(2026-09-16 브레인) @@ -41,6 +41,15 @@ const served = [ ]; const grouped = groupKinds(served).map((g) => [g.label, g.items.map((i) => i.kind)]); +// 거르기 — 안 골랐으면 서버가 이미 건 기본값을 보임(보이는 것 = 걸린 것) · 고르면 고른 값 +const roadFilter = { + key: "use", + label: "보기", + options: [{ value: "all", label: "전부", rows: 995 }, { value: "road", label: "임도가 쓰는 것", rows: 120 }], + default: "road", +}; +const filtered = [filterValue(roadFilter, {}), filterValue(roadFilter, { use: "all" })]; + // 열 제목 누르기 — 없음 → 오름 → 내림 → 없음(원본 차례) · 다른 열을 누르면 그 열 오름부터 const sorts = [ nextSort(null, "price_krw"), @@ -207,6 +216,7 @@ writeFileSync(process.argv[2], JSON.stringify({ bolds, sorts, grouped, + filtered, })); """ @@ -332,3 +342,5 @@ def test_칸_글자와_숨김_열(tmp_path: Path) -> None: ["공종 축", ["forest"]], ["later_group", ["brand_new"]], ] + # 거르기 상자 — 첫 선택지가 아니라 서버 기본값(어긋나면 「전부」로 보이며 걸러진 목록이 뜸) + assert got["filtered"] == ["road", "all"]