Merge remote-tracking branches 'origin/main_laptop_1' and 'origin/sub_laptop_1' into sub_desktop_1

This commit is contained in:
2026-09-16 08:34:21 +09:00
7 changed files with 185 additions and 30 deletions
+7 -3
View File
@@ -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 = 덮개에서 그 칸을 빼 원래 값으로 되돌림. 응답은 고친 뒤 그 줄. */
+41 -3
View File
@@ -30,18 +30,56 @@ 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);
}
/** 줄마다 판의 기준일 — 기초단가 한 표에 시기가 다른 판이 섞여 줄마다 보임(2026-09-15 브레인). */
export const EFFECTIVE_DATE_KEY = "effective_date";
/** 한 줄의 칸들 — columns 차례대로만(columns 에 없는 열은 안 그림) · 줄에 그 열이 없으면 「—」. */
export function rowCells(row: Record<string, unknown>, 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;
}
/** 자료 출처표 한 줄 — 어디서 받고 최신이 무엇인지(데스크탑 서브 출처표 · 메인이 표 수준에 실음). */
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>;
+48 -12
View File
@@ -12,14 +12,15 @@ import { t as L } from "@ui/ui_template_locale";
import type { MasterRows, RowsQuery } from "./Z01_MasterData_Api_Fetch";
import {
cellInfo,
cellText,
clampPage,
columnTitle,
latestOnly,
outdatedBanner,
pageCount,
parseCellInput,
rowCells,
shownColumns,
valueWithUnit,
type CellInfo,
type MasterColumn,
} from "./Z01_MasterData_UI_Cells";
@@ -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>;
@@ -51,10 +54,10 @@ function fill(key: Parameters<typeof L>[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":
@@ -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");
@@ -201,6 +218,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 +243,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 +275,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 +308,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 +345,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);
}
}
@@ -326,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;
+12 -9
View File
@@ -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 {
@@ -61,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,
});
});
@@ -125,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: [
@@ -145,13 +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, 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;
+61 -2
View File
@@ -25,9 +25,36 @@ _RUNNER = """
import { writeFileSync } from "node:fs";
import {
cellInfo, cellText, clampPage, columnTitle, latestOnly, pageCount, parseCellInput, rowCells,
shownColumns,
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 },
{ 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"],
@@ -80,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),
@@ -102,6 +131,16 @@ 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억 미만", ""),
],
banners,
}));
"""
@@ -140,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"]
@@ -173,3 +221,14 @@ 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억 미만"]
# 날짜가 뒤처져 보여도 서버가 outdated=false 면 띠 없음 · 출처가 안 오면 띠 없음
assert got["banners"] == [
"이 자료는 2026-01-01 판 · 최신은 2026-09-01 공표 · "
"대한건설협회 cak.or.kr [지원·사업]>[건설적산기준]>[건설임금]",
None,
None,
]
+5 -1
View File
@@ -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"],