From 3a5132d9054b0b1efb3d5080f33c7dec13421411 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 15 Sep 2026 22:01:09 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(z01):=20=EA=B8=B0=EC=A4=80=EC=9D=BC=20?= =?UTF-8?q?=EC=B9=B8(=EC=97=86=EC=9C=BC=EB=A9=B4=20=E3=80=8C=ED=8C=90=20?= =?UTF-8?q?=EB=AA=A8=EB=A6=84=E3=80=8D)=20=C2=B7=20=EB=8B=A8=EC=B6=94=20?= =?UTF-8?q?=EA=B8=80=EC=9E=90=C2=B7=EC=B9=B8=20=EC=84=A4=EB=AA=85=20?= =?UTF-8?q?=EA=B0=92=EC=97=90=20=EC=89=BC=ED=91=9C=EC=99=80=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84(=E3=80=8C=EC=9B=90=EB=9E=98=20=EA=B0=92=2017=20L=20?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=90=98=EB=8F=8C=EB=A6=AC=EA=B8=B0?= =?UTF-8?q?=E3=80=8D)=20=C2=B7=20=EC=A0=80=EC=9E=A5=EC=9D=B4=20=EC=95=88?= =?UTF-8?q?=20=EB=90=98=EB=A9=B4=20=EC=B9=B8=20=EC=84=A4=EB=AA=85=20?= =?UTF-8?q?=EC=A4=84=EC=97=90=20=EA=B9=8C=EB=8B=AD=EC=9D=B4=20=EB=82=A8?= =?UTF-8?q?=EC=9D=8C(=EC=88=98=20=EB=AA=BB=20=EC=9D=BD=EC=9D=8C=20=C2=B7?= =?UTF-8?q?=20=EC=84=9C=EB=B2=84=20=EA=B1=B0=EC=A0=88)=20=C2=B7=20?= =?UTF-8?q?=EA=B3=A0=EC=B9=9C=20=EA=B2=83=20=EB=AA=A9=EB=A1=9D=20=EA=B0=92?= =?UTF-8?q?=EC=97=90=20=EC=89=BC=ED=91=9C=20=C2=B7=20=EA=B3=A0=EC=B9=9C=20?= =?UTF-8?q?=EB=92=A4=20=EC=84=A0=ED=83=9D=20=ED=85=8C=EB=91=90=EB=A6=AC=20?= =?UTF-8?q?=EC=9C=A0=EC=A7=80(=EB=B8=8C=EB=A0=88=EC=9D=B8=20=EC=85=8B)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq --- Z01_MasterData/Z01_MasterData_UI_Cells.ts | 20 ++++++++++-- Z01_MasterData/Z01_MasterData_UI_Rows.ts | 39 ++++++++++++++++------- Z01_MasterData/Z01_MasterData_UI_Side.ts | 5 +++ resources/tester/test_z01_master_cells.py | 26 ++++++++++++++- ui_template/ui_template_locale_b3.ts | 6 +++- 5 files changed, 80 insertions(+), 16 deletions(-) diff --git a/Z01_MasterData/Z01_MasterData_UI_Cells.ts b/Z01_MasterData/Z01_MasterData_UI_Cells.ts index 09a9b098..75d03625 100644 --- a/Z01_MasterData/Z01_MasterData_UI_Cells.ts +++ b/Z01_MasterData/Z01_MasterData_UI_Cells.ts @@ -33,11 +33,25 @@ export function cellText(value: unknown, unit?: string | null): string { return String(value); } +/** 줄마다 판의 기준일 — 기초단가 한 표에 시기가 다른 판이 섞여 줄마다 보임(2026-09-15 브레인). */ +export const EFFECTIVE_DATE_KEY = "effective_date"; + /** 한 줄의 칸들 — columns 차례대로만(columns 에 없는 열은 안 그림) · 줄에 그 열이 없으면 「—」. */ export function rowCells(row: Record, columns: MasterColumn[]): string[] { - return columns.map((column) => - Object.hasOwn(row, column.key) ? cellText(row[column.key], column.unit) : "—", - ); + return columns.map((column) => { + // 기준일 없는 자료(machine_operating 등)는 빈칸이 아니라 모른다고 말함. + if (column.key === EFFECTIVE_DATE_KEY) return cellText(row[column.key]) || "판 모름"; + return Object.hasOwn(row, column.key) ? cellText(row[column.key], column.unit) : "—"; + }); +} + +/** 단추 글자·칸 설명에 넣는 값 — 수는 늘 쉼표 · 단위(이름표)를 뒤에 붙여 무엇인지 보이게. */ +export function valueWithUnit(value: unknown, unit?: string | null): string { + const text = + typeof value === "number" + ? value.toLocaleString("ko-KR", { maximumFractionDigits: 10 }) + : cellText(value, unit); + return text && unit ? `${text} ${unit}` : text; } /** 기초단가 표 머리 — 열 하나에 한 문장이라 표에 한 번만 옴(2026-09-15 브레인 ④). */ diff --git a/Z01_MasterData/Z01_MasterData_UI_Rows.ts b/Z01_MasterData/Z01_MasterData_UI_Rows.ts index 75e6cca4..07340c49 100644 --- a/Z01_MasterData/Z01_MasterData_UI_Rows.ts +++ b/Z01_MasterData/Z01_MasterData_UI_Rows.ts @@ -12,7 +12,6 @@ import { t as L } from "@ui/ui_template_locale"; import type { MasterRows, RowsQuery } from "./Z01_MasterData_Api_Fetch"; import { cellInfo, - cellText, clampPage, columnTitle, latestOnly, @@ -20,6 +19,7 @@ import { parseCellInput, rowCells, shownColumns, + valueWithUnit, type CellInfo, type MasterColumn, } from "./Z01_MasterData_UI_Cells"; @@ -51,10 +51,10 @@ function fill(key: Parameters[0], value: string): string { /** 덮개 판정 한 줄 — 서버 state 그대로 · state 가 없으면 버그로 드러냄. */ function stateText(info: CellInfo, column: MasterColumn): string { - const original = cellText(info.original, column.unit); + const original = valueWithUnit(info.original, column.unit); switch (info.state) { case "source_changed": - return `${fill("Z01_MasterData_State_SourceChanged", cellText(info.current, column.unit))} (${fill("Z01_MasterData_Edit_OriginalWas", original)})`; + return `${fill("Z01_MasterData_State_SourceChanged", valueWithUnit(info.current, column.unit))} (${fill("Z01_MasterData_Edit_OriginalWas", original)})`; case "orphan": return L("Z01_MasterData_State_Orphan"); case "missing": @@ -201,6 +201,7 @@ export function buildRowsView(): RowsView { td.classList.add("is-selected"); td.addEventListener("click", () => { if (td.querySelector("input")) return; + grid.querySelector("td.is-selected")?.classList.remove("is-selected"); selectCell(index, column); td.classList.add("is-selected"); if (canEdit) startEdit(td, index, column); @@ -225,15 +226,22 @@ export function buildRowsView(): RowsView { next.disabled = end.disabled = state.page >= pages; } - /** 칸 설명 줄 — 고칠 수 있나 · 못 고치는 까닭 · 원래 값(되돌리기) · 식과 값 넣은 식. */ - function selectCell(index: number, column: MasterColumn): void { + /** + * 칸 설명 줄 — 고칠 수 있나 · 못 고치는 까닭 · 원래 값(되돌리기) · 식과 값 넣은 식. + * `failure` = 방금 저장이 안 된 까닭 — 토스트만 뜨고 사라지면 사람은 저장된 줄 앎(브레인 ③). + */ + function selectCell(index: number, column: MasterColumn, failure?: string): void { if (!last) return; - grid.querySelector("td.is-selected")?.classList.remove("is-selected"); selected = { index, key: column.key }; const row = last.rows[index]; const info = cellInfo(last, row, column.key); const lines: HTMLElement[] = [el("strong", { text: columnTitle(column) })]; const line = (text: string) => el("span", { className: "z01-master__detail-line", text }); + if (failure) { + const failed = line(fill("Z01_MasterData_Edit_NotSaved", failure)); + failed.classList.add("is-warning"); + lines.push(failed); + } if (source?.save) { lines.push( line( @@ -250,10 +258,10 @@ export function buildRowsView(): RowsView { // 단추 글자에 무엇으로 바뀌는지 값을 넣음 — 안 보이고 누르면 금액이 말없이 바뀜(브레인). const target = info.state === "source_changed" - ? fill("Z01_MasterData_Edit_TakeSource", cellText(info.current, column.unit)) + ? fill("Z01_MasterData_Edit_TakeSource", valueWithUnit(info.current, column.unit)) : info.state === "orphan" ? null - : fill("Z01_MasterData_Edit_RevertTo", cellText(info.original, column.unit)); + : fill("Z01_MasterData_Edit_RevertTo", valueWithUnit(info.original, column.unit)); if (target && source?.save) { lines.push( createButton({ @@ -283,8 +291,14 @@ export function buildRowsView(): RowsView { if (finished) return; finished = true; const value = save ? parseCellInput(input.value, current) : current; - if (value === undefined) showToast(L("Z01_MasterData_Edit_Invalid"), "error"); - if (value === undefined || value === current) { + if (value === undefined) { + const why = fill("Z01_MasterData_Edit_Invalid", input.value); + showToast(why, "error"); + draw(); + selectCell(index, column, why); + return; + } + if (value === current) { draw(); return; } @@ -314,8 +328,11 @@ export function buildRowsView(): RowsView { "success", ); } catch (error) { - showToast(error instanceof Error ? error.message : L("Z01_MasterData_Edit_Failed"), "error"); + const why = error instanceof Error ? error.message : L("Z01_MasterData_Edit_Failed"); + showToast(why, "error"); + if (from !== source || table !== last) return; draw(); + selectCell(index, column, why); } } diff --git a/Z01_MasterData/Z01_MasterData_UI_Side.ts b/Z01_MasterData/Z01_MasterData_UI_Side.ts index b47e4fc4..41eb24ef 100644 --- a/Z01_MasterData/Z01_MasterData_UI_Side.ts +++ b/Z01_MasterData/Z01_MasterData_UI_Side.ts @@ -20,6 +20,7 @@ import { saveBasePrice, VALUES_TABLE_ID, } from "./Z01_MasterData_Api_Fetch"; +import { valueWithUnit } from "./Z01_MasterData_UI_Cells"; import type { RowsView } from "./Z01_MasterData_UI_Rows"; export function buildSidePanel(groups: MasterGroup[], view: RowsView): HTMLElement { @@ -147,6 +148,10 @@ async function loadOverrides(query: RowsQuery): Promise { ], rows: items.slice(start, start + query.size).map((item) => ({ ...item, + // 값은 쉼표를 붙여 — 목록 줄에는 단위가 안 실려 옴. + value: valueWithUnit(item.value), + original: valueWithUnit(item.original), + ...(Object.hasOwn(item, "current") ? { current: valueWithUnit(item.current) } : {}), // state 가 안 오면 때우지 않고 드러냄(서버 버그). state: L(STATE_LABEL[item.state] ?? "Z01_MasterData_State_Missing"), kind: L(`Z01_MasterData_Base_${item.kind}` as const), diff --git a/resources/tester/test_z01_master_cells.py b/resources/tester/test_z01_master_cells.py index d031a4de..7fcce953 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, - shownColumns, + shownColumns, valueWithUnit, } from "./Z01_MasterData_UI_Cells.js"; +// 기준일 — 한 표에 시기가 다른 판이 섞이니 줄마다 보임 · 없으면 「판 모름」(2026-09-15 브레인 ①) +const dated = [ + { key: "occupation_name", label: "직종명", unit: null, hidden: false }, + { key: "effective_date", label: "기준일", unit: null, hidden: false }, +]; +const editions = [ + rowCells({ occupation_name: "보통인부", effective_date: "2026-01-01" }, dated), + rowCells({ occupation_name: "기계운전", effective_date: null }, dated), + rowCells({ occupation_name: "굴착기" }, dated), +]; + // 기초단가 — 표 수준 editable·locked·formula · 줄 수준 @id·@overrides·@formula (2026-09-15 브레인) const meta = { editable: ["daily_wage_krw"], @@ -102,6 +113,15 @@ writeFileSync(process.argv[2], JSON.stringify({ parseCellInput("보통인부2", "보통인부"), parseCellInput("3.5", null), ].map((v) => (v === undefined ? "invalid" : v)), + editions, + withUnit: [ + valueWithUnit(17, "원"), + valueWithUnit(41500000, "원"), + valueWithUnit(0.17, ""), + valueWithUnit(12345.678, null), + valueWithUnit(null, "원"), + valueWithUnit("5억 미만", ""), + ], })); """ @@ -173,3 +193,7 @@ def test_칸_글자와_숨김_열(tmp_path: Path) -> None: assert plain["editable"] is False and plain["reason"] is None and plain["rule"] is None # 수 칸은 쉼표를 떼고 수로 · 못 읽으면 저장 안 함 · 글자 칸은 글자 그대로 · 빈 칸에 수를 적으면 수 assert got["parsed"] == [12340, "invalid", "invalid", "보통인부2", 3.5] + # 기준일이 없으면 빈칸·「—」가 아니라 「판 모름」 + assert [row[1] for row in got["editions"]] == ["2026-01-01", "판 모름", "판 모름"] + # 단추 글자·칸 설명의 값 — 쉼표 + 단위(이름표) · 단위 없으면 수만 + assert got["withUnit"] == ["17 원", "41,500,000 원", "0.17", "12,345.678", "", "5억 미만"] diff --git a/ui_template/ui_template_locale_b3.ts b/ui_template/ui_template_locale_b3.ts index a170ecde..02460588 100644 --- a/ui_template/ui_template_locale_b3.ts +++ b/ui_template/ui_template_locale_b3.ts @@ -161,7 +161,11 @@ export const ui_locales_b3 = { Z01_MasterData_Edit_RevertTo: ["원래 값 {value} 으로 되돌리기", "Revert to original {value}"], Z01_MasterData_Edit_TakeSource: ["새 원본 값 {value} 으로 받기", "Take new source value {value}"], Z01_MasterData_Edit_OriginalWas: ["고칠 때 원본 {value}", "source when edited {value}"], - Z01_MasterData_Edit_Invalid: ["수로 못 읽음 — 저장 안 함", "Not a number — not saved"], + Z01_MasterData_Edit_Invalid: [ + "「{value}」 를 수로 못 읽음", + "Cannot read 「{value}」 as a number", + ], + Z01_MasterData_Edit_NotSaved: ["⚠ 저장 안 됨 — {value}", "⚠ Not saved — {value}"], Z01_MasterData_Edit_Saved: ["저장했음 — 덮개 파일에 쌓임", "Saved to the override file"], Z01_MasterData_Edit_Reverted: ["덮개를 뺐음 — 원본 값을 씀", "Override removed — using source"], Z01_MasterData_Edit_Failed: ["저장 못 함", "Save failed"], From 43cb7c90d0fa09e903e794b80ee7c6a33fe9d365 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 15 Sep 2026 22:08:08 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(z01):=20=EB=92=A4=EC=B2=98=EC=A7=84=20?= =?UTF-8?q?=EC=9E=90=EB=A3=8C=20=EB=9D=A0(=EC=84=9C=EB=B2=84=20source.outd?= =?UTF-8?q?ated=20=EB=A7=8C=20=C2=B7=20=E3=80=8C=EC=9D=B4=20=EC=9E=90?= =?UTF-8?q?=EB=A3=8C=EB=8A=94=20=E2=97=8B=20=ED=8C=90=20=C2=B7=20=EC=B5=9C?= =?UTF-8?q?=EC=8B=A0=EC=9D=80=20=E2=97=8B=20=EA=B3=B5=ED=91=9C=20=C2=B7=20?= =?UTF-8?q?=EB=B0=9B=EB=8A=94=20=EC=9E=90=EB=A6=AC=E3=80=8D)=20=C2=B7=20?= =?UTF-8?q?=EA=B3=A0=EC=B9=9C=20=EA=B2=83=20=EB=AA=A8=EC=95=84=20=EB=B3=B4?= =?UTF-8?q?=EA=B8=B0=EB=8A=94=20=EC=84=9C=EB=B2=84=20=EC=AA=BD=20=EB=82=98?= =?UTF-8?q?=EB=88=94=20=EA=B7=B8=EB=8C=80=EB=A1=9C(page=C2=B7size=20=C2=B7?= =?UTF-8?q?=20=EA=B2=80=EC=83=89=20=EC=B9=B8=20=EC=88=A8=EA=B9=80=20=C2=B7?= =?UTF-8?q?=20=EA=B0=92=EC=97=90=20unit)=20=C2=B7=20=EB=B6=80=EB=8F=99?= =?UTF-8?q?=EC=86=8C=EC=88=98=20=EB=81=9D=EC=9E=90=EB=A6=AC=20=EB=96=A0?= =?UTF-8?q?=EB=8F=8C=EC=9D=B4=20=EC=9E=90=EB=A6=84(=EC=86=90=EB=A3=8C?= =?UTF-8?q?=EA=B3=84=EC=88=98=200.0001811)=20=C2=B7=20=EC=8B=A4=EC=A0=9C?= =?UTF-8?q?=20API=20=EB=A1=9C=20=EB=85=B8=EC=9E=84=20=ED=95=9C=20=EC=B9=B8?= =?UTF-8?q?=20=EA=B3=A0=EC=B9=98=EA=B3=A0=20=EB=90=98=EB=8F=8C=EB=A6=BC=20?= =?UTF-8?q?=ED=99=95=EC=9D=B8(=EB=B8=8C=EB=A0=88=EC=9D=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq --- Z01_MasterData/Z01_MasterData_Api_Fetch.ts | 10 ++++-- Z01_MasterData/Z01_MasterData_UI_Cells.ts | 24 +++++++++++++ Z01_MasterData/Z01_MasterData_UI_Rows.ts | 21 +++++++++++- Z01_MasterData/Z01_MasterData_UI_Side.ts | 24 ++++++------- Z01_MasterData/Z01_MasterData_UI_Style.css | 11 ++++++ resources/tester/test_z01_master_cells.py | 39 ++++++++++++++++++++-- 6 files changed, 110 insertions(+), 19 deletions(-) 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, + ]