feat(z01): 서버가 주는 거르기 상자 — 표에 filters 가 오면 찾기 칸 옆에 고르기 상자를 세우고 고른 값만 key=value 로 보냄

- 이름·선택지·줄 수·기본값도, 어느 줄을 거를지 판정도 서버 것(화면은 판정 안 함 · 2026-09-17 브레인)
- 안 골랐으면 서버 default 를 보임 — 첫 선택지를 보이면 「전부」로 보이며 걸러진 목록이 뜸
- 고르면 1쪽부터 · 다른 표로 가면 고른 것 비움 · filters 없는 표(산림 등)는 상자 없음
- 화면 확인: 진짜 건설 공종 축 위에 가짜 filters 얹어 기본 「임도가 쓰는 것」 → 「전부」 전환 · 산림 상자 없음
- 시험: 기본값 = 서버 default(첫 선택지 아님) · 고르면 고른 값

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
This commit is contained in:
2026-09-17 13:42:00 +09:00
co-authored by Claude Opus 5
parent 0d56e727cb
commit b890fba04a
4 changed files with 69 additions and 3 deletions
@@ -51,6 +51,8 @@ export interface RowsQuery {
q: string; q: string;
/** 열 제목을 눌렀을 때만 — 없으면 서버가 원본 차례로 줌 */ /** 열 제목을 눌렀을 때만 — 없으면 서버가 원본 차례로 줌 */
sort?: SortOrder | null; sort?: SortOrder | null;
/** 사람이 고른 거르기만(`TableMeta.filters`) — 안 고른 것은 서버 기본값 */
filters?: Record<string, string>;
} }
/** /**
@@ -87,6 +89,7 @@ export function fetchBasePrices(kind: BasePriceKind, query: RowsQuery): Promise<
params.set("sort", query.sort.key); params.set("sort", query.sort.key);
params.set("desc", query.sort.desc ? "1" : "0"); params.set("desc", query.sort.desc ? "1" : "0");
} }
for (const [key, value] of Object.entries(query.filters ?? {})) params.set(key, value);
return request<MasterRows>(`/master-data/base-prices/${kind}?${params}`); return request<MasterRows>(`/master-data/base-prices/${kind}?${params}`);
} }
+18
View File
@@ -134,8 +134,26 @@ export function boldSegments(text: string): { text: string; bold: boolean }[] {
.filter((part) => part.text !== ""); .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, string>): string {
return picked[filter.key] ?? filter.default;
}
/** 기초단가 표 머리 — 열 하나에 한 문장이라 표에 한 번만 옴(2026-09-15 브레인 ④). */ /** 기초단가 표 머리 — 열 하나에 한 문장이라 표에 한 번만 옴(2026-09-15 브레인 ④). */
export interface TableMeta { export interface TableMeta {
filters?: TableFilter[];
/** 한 표가 모은 원본마다 한 줄 */ /** 한 표가 모은 원본마다 한 줄 */
source?: TableSource[]; source?: TableSource[];
/** /**
+35 -2
View File
@@ -10,6 +10,7 @@
import { import {
createButton, createButton,
createInputField, createInputField,
createSelectField,
el, el,
showConfirmDialog, showConfirmDialog,
showToast, showToast,
@@ -21,6 +22,7 @@ import {
cellInfo, cellInfo,
clampPage, clampPage,
columnTitle, columnTitle,
filterValue,
latestOnly, latestOnly,
nextSort, nextSort,
noticeLines, noticeLines,
@@ -90,7 +92,12 @@ function stateText(info: CellInfo, column: MasterColumn): string {
export function buildRowsView(): RowsView { export function buildRowsView(): RowsView {
let source: RowsSource | null = null; 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<string, string>,
};
let showHidden = false; let showHidden = false;
let last: MasterRows | null = null; let last: MasterRows | null = null;
let selected: { index: number; key: string } | 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 prev = pagerButton("Z01_MasterData_Prev", () => goTo(state.page - 1));
const next = pagerButton("Z01_MasterData_Next", () => goTo(state.page + 1)); const next = pagerButton("Z01_MasterData_Next", () => goTo(state.page + 1));
const end = pagerButton("Z01_MasterData_Last", () => goTo(Infinity)); const end = pagerButton("Z01_MasterData_Last", () => goTo(Infinity));
const filterBox = el("div", { className: "z01-master__toolbar" });
const toolbar = el("div", { const toolbar = el("div", {
className: "z01-master__toolbar", className: "z01-master__toolbar",
children: [search.root, hiddenLabel], children: [search.root, filterBox, hiddenLabel],
}); });
const pager = el("div", { const pager = el("div", {
className: "z01-master__pager", className: "z01-master__pager",
@@ -180,6 +188,7 @@ export function buildRowsView(): RowsView {
size: PAGE_SIZE, size: PAGE_SIZE,
q: state.q, q: state.q,
sort: state.sort, sort: state.sort,
filters: state.filters,
}); });
} catch (error) { } catch (error) {
grid.classList.remove("is-loading"); grid.classList.remove("is-loading");
@@ -250,6 +259,28 @@ export function buildRowsView(): RowsView {
const outdated = outdatedBanner(meta.source); const outdated = outdatedBanner(meta.source);
banner.replaceChildren(...outdated.map((text) => el("p", { text: `${text}` }))); banner.replaceChildren(...outdated.map((text) => el("p", { text: `${text}` })));
banner.hidden = outdated.length === 0; 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 columns = shownColumns(meta.columns, showHidden);
const headRow = el("tr", { children: columns.map((column) => headCell(meta, column)) }); const headRow = el("tr", { children: columns.map((column) => headCell(meta, column)) });
const body = el("tbody"); const body = el("tbody");
@@ -429,6 +460,8 @@ export function buildRowsView(): RowsView {
source = picked; source = picked;
state.page = 1; state.page = 1;
state.sort = null; state.sort = null;
state.filters = {};
filterBox.replaceChildren();
title.textContent = picked.title; title.textContent = picked.title;
titleKey.textContent = picked.key; titleKey.textContent = picked.key;
notice.hidden = true; notice.hidden = true;
+13 -1
View File
@@ -25,7 +25,7 @@ _RUNNER = """
import { writeFileSync } from "node:fs"; import { writeFileSync } from "node:fs";
import { import {
cellInfo, cellText, clampPage, columnTitle, latestOnly, pageCount, parseCellInput, rowCells, 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"; } from "./Z01_MasterData_UI_Cells.js";
// kind 목록은 서버가 줌 — 화면이 제 목록을 들면 kind 가 늘 때마다 빠짐(2026-09-16 브레인) // 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 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 = [ const sorts = [
nextSort(null, "price_krw"), nextSort(null, "price_krw"),
@@ -207,6 +216,7 @@ writeFileSync(process.argv[2], JSON.stringify({
bolds, bolds,
sorts, sorts,
grouped, grouped,
filtered,
})); }));
""" """
@@ -332,3 +342,5 @@ def test_칸_글자와_숨김_열(tmp_path: Path) -> None:
["공종 축", ["forest"]], ["공종 축", ["forest"]],
["later_group", ["brand_new"]], ["later_group", ["brand_new"]],
] ]
# 거르기 상자 — 첫 선택지가 아니라 서버 기본값(어긋나면 「전부」로 보이며 걸러진 목록이 뜸)
assert got["filtered"] == ["road", "all"]