feat(z01): 뒤처진 자료 띠(서버 source.outdated 만 · 「이 자료는 ○ 판 · 최신은 ○ 공표 · 받는 자리」) · 고친 것 모아 보기는 서버 쪽 나눔 그대로(page·size · 검색 칸 숨김 · 값에 unit) · 부동소수 끝자리 떠돌이 자름(손료계수 0.0001811) · 실제 API 로 노임 한 칸 고치고 되돌림 확인(브레인)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
This commit is contained in:
@@ -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<OverrideItem[]> {
|
||||
return (await request<{ items: OverrideItem[] }>("/master-data/overrides")).items;
|
||||
/** 고친 것 모아 보기 — 서버가 source_changed → orphan → edited 차례로 정렬하고 쪽도 나눠 줌. */
|
||||
export function fetchOverrides(
|
||||
query: Pick<RowsQuery, "page" | "size">,
|
||||
): Promise<{ items: OverrideItem[]; total: number }> {
|
||||
const params = new URLSearchParams({ page: String(query.page), size: String(query.size) });
|
||||
return request(`/master-data/overrides?${params}`);
|
||||
}
|
||||
|
||||
/** 칸 고치기 — 값 null = 덮개에서 그 칸을 빼 원래 값으로 되돌림. 응답은 고친 뒤 그 줄. */
|
||||
|
||||
@@ -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<string, string>;
|
||||
|
||||
@@ -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<MasterRows>;
|
||||
/** 있으면 editable 칸을 고침 — 응답은 고친 뒤 그 줄 */
|
||||
save?: (rowId: string, values: Row) => Promise<Row>;
|
||||
@@ -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;
|
||||
|
||||
@@ -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<MasterRows> {
|
||||
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<typeof L>[0]) => ({ key, label: L(label) });
|
||||
return {
|
||||
columns: [
|
||||
@@ -146,17 +143,18 @@ async function loadOverrides(query: RowsQuery): Promise<MasterRows> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user