diff --git a/Z01_MasterData/Z01_MasterData_Api_Fetch.ts b/Z01_MasterData/Z01_MasterData_Api_Fetch.ts index 78735084..e4daa002 100644 --- a/Z01_MasterData/Z01_MasterData_Api_Fetch.ts +++ b/Z01_MasterData/Z01_MasterData_Api_Fetch.ts @@ -80,12 +80,16 @@ export interface OverrideItem { original: unknown; current?: unknown; value: unknown; + unit?: string; state: OverrideState; } -/** 고친 것 모아 보기 — 서버가 source_changed → orphan → edited 차례로 정렬해 줌. */ -export async function fetchOverrides(): Promise { - return (await request<{ items: OverrideItem[] }>("/master-data/overrides")).items; +/** 고친 것 모아 보기 — 서버가 source_changed → orphan → edited 차례로 정렬하고 쪽도 나눠 줌. */ +export function fetchOverrides( + query: Pick, +): Promise<{ items: OverrideItem[]; total: number }> { + const params = new URLSearchParams({ page: String(query.page), size: String(query.size) }); + return request(`/master-data/overrides?${params}`); } /** 칸 고치기 — 값 null = 덮개에서 그 칸을 빼 원래 값으로 되돌림. 응답은 고친 뒤 그 줄. */ diff --git a/Z01_MasterData/Z01_MasterData_UI_Cells.ts b/Z01_MasterData/Z01_MasterData_UI_Cells.ts index 75d03625..e7a1edf3 100644 --- a/Z01_MasterData/Z01_MasterData_UI_Cells.ts +++ b/Z01_MasterData/Z01_MasterData_UI_Cells.ts @@ -30,6 +30,8 @@ export function cellText(value: unknown, unit?: string | null): string { return value.toLocaleString("ko-KR", { maximumFractionDigits: 10 }); } if (typeof value === "object") return JSON.stringify(value); + // 부동소수 끝자리 떠돌이(0.00018109999999999998)를 15자리에서 잘라 원래 수로 보임. + if (typeof value === "number") return String(Number(value.toPrecision(15))); return String(value); } @@ -54,8 +56,30 @@ export function valueWithUnit(value: unknown, unit?: string | null): string { return text && unit ? `${text} ${unit}` : text; } +/** 자료 출처표 한 줄 — 어디서 받고 최신이 무엇인지(데스크탑 서브 출처표 · 메인이 표 수준에 실음). */ +export interface TableSource { + publisher: string; + where: string; + cycle?: string; + latest_published?: string; + our_edition?: string; + checked_at?: string; + /** 뒤처짐 판정 — 서버만 함 */ + outdated?: boolean; +} + +/** + * 뒤처진 자료 띠 글자 — 서버가 outdated 라 할 때만. 화면은 날짜를 견주지 않음(판정 두 벌 금지). + * 받는 자리를 꼭 적음 — 최신이 무엇인지·어디서 받는지가 없어 건설 노임이 반 년 뒤처졌음. + */ +export function outdatedBanner(source?: TableSource): string | null { + if (source?.outdated !== true) return null; + return `이 자료는 ${source.our_edition ?? "판 모름"} 판 · 최신은 ${source.latest_published ?? "?"} 공표 · ${source.publisher} ${source.where}`; +} + /** 기초단가 표 머리 — 열 하나에 한 문장이라 표에 한 번만 옴(2026-09-15 브레인 ④). */ export interface TableMeta { + source?: TableSource; editable?: string[]; /** 열key → 왜 못 고치나 */ locked?: Record; diff --git a/Z01_MasterData/Z01_MasterData_UI_Rows.ts b/Z01_MasterData/Z01_MasterData_UI_Rows.ts index 07340c49..737bdd81 100644 --- a/Z01_MasterData/Z01_MasterData_UI_Rows.ts +++ b/Z01_MasterData/Z01_MasterData_UI_Rows.ts @@ -15,6 +15,7 @@ import { clampPage, columnTitle, latestOnly, + outdatedBanner, pageCount, parseCellInput, rowCells, @@ -34,6 +35,8 @@ export interface RowsSource { key: string; /** 표 위 알림 한 줄(요율 — 법이 정한 값) */ notice?: string; + /** false 면 검색 칸을 숨김 — 서버에 검색이 없는 표(고친 것 모아 보기) */ + searchable?: boolean; load: (query: RowsQuery) => Promise; /** 있으면 editable 칸을 고침 — 응답은 고친 뒤 그 줄 */ save?: (rowId: string, values: Row) => Promise; @@ -97,6 +100,7 @@ export function buildRowsView(): RowsView { children: [hiddenToggle, L("Z01_MasterData_ShowHidden")], }); const notice = el("p", { className: "z01-master__notice" }); + const banner = el("p", { className: "z01-master__outdated" }); const detail = el("div", { className: "z01-master__detail" }); const grid = el("div", { className: "z01-master__grid-wrap" }); const pageInput = el("input", { @@ -122,12 +126,21 @@ export function buildRowsView(): RowsView { }); const root = el("section", { className: "z01-master__panel", - children: [el("div", { children: [title, titleKey] }), notice, toolbar, detail, grid, pager], + children: [ + el("div", { children: [title, titleKey] }), + banner, + notice, + toolbar, + detail, + grid, + pager, + ], }); toolbar.hidden = true; pager.hidden = true; detail.hidden = true; notice.hidden = true; + banner.hidden = true; function goTo(page: number): void { const pages = pageCount(last?.total ?? 0, PAGE_SIZE); @@ -177,6 +190,10 @@ export function buildRowsView(): RowsView { function draw(): void { if (!last) return; const meta = last; + // 뒤처진 자료 띠 — 서버 outdated 만 봄(날짜 비교 안 함). + const outdated = outdatedBanner(meta.source); + banner.textContent = outdated ? `⚠ ${outdated}` : ""; + banner.hidden = !outdated; const columns = shownColumns(meta.columns, showHidden); const headRow = el("tr", { children: columns.map((column) => headCell(meta, column)) }); const body = el("tbody"); @@ -343,6 +360,8 @@ export function buildRowsView(): RowsView { titleKey.textContent = picked.key; notice.textContent = picked.notice ?? ""; notice.hidden = !picked.notice; + banner.hidden = true; + search.root.hidden = picked.searchable === false; toolbar.hidden = false; pager.hidden = false; detail.hidden = true; diff --git a/Z01_MasterData/Z01_MasterData_UI_Side.ts b/Z01_MasterData/Z01_MasterData_UI_Side.ts index 41eb24ef..7d1bb97a 100644 --- a/Z01_MasterData/Z01_MasterData_UI_Side.ts +++ b/Z01_MasterData/Z01_MasterData_UI_Side.ts @@ -62,6 +62,7 @@ export function buildSidePanel(groups: MasterGroup[], view: RowsView): HTMLEleme view.show({ title: `${baseTitle} › ${L("Z01_MasterData_Overrides")}`, key: "overrides", + searchable: false, load: loadOverrides, }); }); @@ -126,15 +127,11 @@ const STATE_LABEL = { } as const; /** - * 고친 것 목록을 표 한 벌로 — 서버 정렬(원본 바뀜 → 주인 없음 → 고쳐짐)을 그대로 두고 쪽만 자름. - * 쪽을 서버에서 나눠 받으면 맨 위가 그 쪽 안에서만 맨 위가 됨(2026-09-15 브레인 ②). + * 고친 것 목록을 표 한 벌로 — 정렬(원본 바뀜 → 주인 없음 → 고쳐짐)과 쪽 나눔은 서버가 함. + * 화면이 다시 줄 세우면 맨 위가 그 쪽 안에서만 맨 위가 됨(2026-09-15 브레인 ②). 서버에 검색이 없어 검색 칸은 숨김. */ async function loadOverrides(query: RowsQuery): Promise { - const needle = query.q.toLowerCase(); - const items = (await fetchOverrides()).filter( - (item) => !needle || JSON.stringify(item).toLowerCase().includes(needle), - ); - const start = (query.page - 1) * query.size; + const { items, total } = await fetchOverrides(query); const column = (key: string, label: Parameters[0]) => ({ key, label: L(label) }); return { columns: [ @@ -146,17 +143,18 @@ async function loadOverrides(query: RowsQuery): Promise { column("original", "Z01_MasterData_List_Original"), column("current", "Z01_MasterData_List_Current"), ], - rows: items.slice(start, start + query.size).map((item) => ({ + rows: items.map((item) => ({ ...item, - // 값은 쉼표를 붙여 — 목록 줄에는 단위가 안 실려 옴. - value: valueWithUnit(item.value), - original: valueWithUnit(item.original), - ...(Object.hasOwn(item, "current") ? { current: valueWithUnit(item.current) } : {}), + value: valueWithUnit(item.value, item.unit), + original: valueWithUnit(item.original, item.unit), + ...(Object.hasOwn(item, "current") + ? { current: valueWithUnit(item.current, item.unit) } + : {}), // state 가 안 오면 때우지 않고 드러냄(서버 버그). state: L(STATE_LABEL[item.state] ?? "Z01_MasterData_State_Missing"), kind: L(`Z01_MasterData_Base_${item.kind}` as const), })), - total: items.length, + total, }; } diff --git a/Z01_MasterData/Z01_MasterData_UI_Style.css b/Z01_MasterData/Z01_MasterData_UI_Style.css index 824dbfff..d9b8be4e 100644 --- a/Z01_MasterData/Z01_MasterData_UI_Style.css +++ b/Z01_MasterData/Z01_MasterData_UI_Style.css @@ -125,6 +125,17 @@ font-size: var(--text-body); } +/* 뒤처진 자료 띠 — 받는 자리까지 적힘 · 또렷이 */ +.z01-master__outdated { + margin: 0; + padding: var(--spacing-8) var(--spacing-12); + border-left: 4px solid var(--color-danger); + background: color-mix(in srgb, var(--color-danger) 12%, var(--color-canvas)); + color: var(--color-danger); + font-size: var(--text-body-sm); + font-weight: var(--font-weight-bold); +} + /* 요율 알림 — 막지 않고 알리기만 */ .z01-master__notice { margin: 0; diff --git a/resources/tester/test_z01_master_cells.py b/resources/tester/test_z01_master_cells.py index 7fcce953..3dbf2b1d 100644 --- a/resources/tester/test_z01_master_cells.py +++ b/resources/tester/test_z01_master_cells.py @@ -25,9 +25,25 @@ _RUNNER = """ import { writeFileSync } from "node:fs"; import { cellInfo, cellText, clampPage, columnTitle, latestOnly, pageCount, parseCellInput, rowCells, - shownColumns, valueWithUnit, + outdatedBanner, shownColumns, valueWithUnit, } from "./Z01_MasterData_UI_Cells.js"; +// 뒤처짐 띠 — 판정은 서버 outdated 만(화면이 날짜를 안 견줌) · 받는 자리를 띠에 적음(2026-09-15 브레인) +const laborSource = { + publisher: "대한건설협회", + where: "cak.or.kr [지원·사업]>[건설적산기준]>[건설임금]", + cycle: "반년", + latest_published: "2026-09-01", + our_edition: "2026-01-01", + checked_at: "2026-09-15", + outdated: true, +}; +const banners = [ + outdatedBanner(laborSource), + outdatedBanner({ ...laborSource, outdated: false }), + outdatedBanner(undefined), +]; + // 기준일 — 한 표에 시기가 다른 판이 섞이니 줄마다 보임 · 없으면 「판 모름」(2026-09-15 브레인 ①) const dated = [ { key: "occupation_name", label: "직종명", unit: null, hidden: false }, @@ -91,6 +107,8 @@ writeFileSync(process.argv[2], JSON.stringify({ cellText({ a: 1 }, null), cellText(true, null), cellText("보통인부", null), + // 소수 끝 떠돌이 자리(부동소수) — 기계 손료계수 0.0001811 이 0.00018109999999999998 로 보이던 것 + cellText(0.00018109999999999998, null), ], pages: [pageCount(0, 50), pageCount(6999, 50), pageCount(50, 50)], row: rowCells({ extra: "x", daily_wage_krw: 250000, occupation_name: null }, columns), @@ -122,6 +140,7 @@ writeFileSync(process.argv[2], JSON.stringify({ valueWithUnit(null, "원"), valueWithUnit("5억 미만", ""), ], + banners, })); """ @@ -160,7 +179,16 @@ def test_칸_글자와_숨김_열(tmp_path: Path) -> None: assert got["shown"] == ["occupation_name", "daily_wage_krw"] assert got["shownAll"] == ["id", "occupation_name", "daily_wage_krw"] assert got["titles"] == ["id", "직종명", "일 노임 (원)"] - assert got["cells"] == ["", "250,000", "1.23456", "2026", '{"a":1}', "예", "보통인부"] + assert got["cells"] == [ + "", + "250,000", + "1.23456", + "2026", + '{"a":1}', + "예", + "보통인부", + "0.0001811", + ] assert got["pages"] == [1, 140, 1] # columns 차례 · id 는 줄에 없어 「—」 · null 은 빈칸 · extra 는 안 그림 assert got["row"] == ["—", "", "250,000"] @@ -197,3 +225,10 @@ def test_칸_글자와_숨김_열(tmp_path: Path) -> None: assert [row[1] for row in got["editions"]] == ["2026-01-01", "판 모름", "판 모름"] # 단추 글자·칸 설명의 값 — 쉼표 + 단위(이름표) · 단위 없으면 수만 assert got["withUnit"] == ["17 원", "41,500,000 원", "0.17", "12,345.678", "", "5억 미만"] + # 날짜가 뒤처져 보여도 서버가 outdated=false 면 띠 없음 · 출처가 안 오면 띠 없음 + assert got["banners"] == [ + "이 자료는 2026-01-01 판 · 최신은 2026-09-01 공표 · " + "대한건설협회 cak.or.kr [지원·사업]>[건설적산기준]>[건설임금]", + None, + None, + ]