Merge remote-tracking branch 'origin/dev' into main_laptop_1

This commit is contained in:
2026-09-15 22:00:32 +09:00
11 changed files with 1841 additions and 326 deletions
+65 -18
View File
@@ -6,7 +6,7 @@
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import type { MasterColumn } from "./Z01_MasterData_UI_Cells";
import type { MasterColumn, OverrideState, TableMeta } from "./Z01_MasterData_UI_Cells";
export interface MasterTable {
id: string;
@@ -32,36 +32,83 @@ export interface MasterGroup {
files: MasterFile[];
}
export interface MasterRows {
/** 기초단가 표만 editable·locked·formula 를 실음 — 트리 표는 읽기 전용이라 안 옴. */
export interface MasterRows extends TableMeta {
columns: MasterColumn[];
rows: Record<string, unknown>[];
total: number;
}
async function request<T>(path: string): Promise<T> {
const response = await fetch(`${API_BASE_URL}${path}`, { credentials: "include" });
export interface RowsQuery {
page: number;
size: number;
q: string;
}
/** 기초단가 다섯 — 고친 값은 원본이 아니라 덮개 파일에 쌓임(품셈이 갱신돼도 안 날아감). */
export const BASE_PRICE_KINDS = ["labor", "machine", "material", "oil", "rate"] as const;
export type BasePriceKind = (typeof BASE_PRICE_KINDS)[number];
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
const response = await fetch(`${API_BASE_URL}${path}`, {
credentials: "include",
headers: { "Content-Type": "application/json" },
...init,
});
const data = (await response.json()) as { detail?: string } & T;
if (!response.ok) throw new Error(data.detail ?? "Request failed");
return data;
}
const pageParams = (query: RowsQuery): Record<string, string> => ({
page: String(query.page),
size: String(query.size),
q: query.q,
});
export function fetchBasePrices(kind: BasePriceKind, query: RowsQuery): Promise<MasterRows> {
const params = new URLSearchParams(pageParams(query));
return request<MasterRows>(`/master-data/base-prices/${kind}?${params}`);
}
export interface OverrideItem {
kind: BasePriceKind;
row_id: string;
row_label: string;
column: string;
column_label: string;
original: unknown;
current?: unknown;
value: unknown;
state: OverrideState;
}
/** 고친 것 모아 보기 — 서버가 source_changed → orphan → edited 차례로 정렬해 줌. */
export async function fetchOverrides(): Promise<OverrideItem[]> {
return (await request<{ items: OverrideItem[] }>("/master-data/overrides")).items;
}
/** 칸 고치기 — 값 null = 덮개에서 그 칸을 빼 원래 값으로 되돌림. 응답은 고친 뒤 그 줄. */
export function saveBasePrice(
kind: BasePriceKind,
rowId: string,
values: Record<string, unknown>,
): Promise<Record<string, unknown>> {
return request(`/master-data/base-prices/${kind}/${encodeURIComponent(rowId)}`, {
method: "PUT",
body: JSON.stringify({ values }),
});
}
export async function fetchMasterTree(): Promise<MasterGroup[]> {
return (await request<{ groups: MasterGroup[] }>("/master-data/tree")).groups;
}
export function fetchMasterRows(query: {
file: string;
table: string;
page: number;
size: number;
q: string;
}): Promise<MasterRows> {
const params = new URLSearchParams({
file: query.file,
table: query.table,
page: String(query.page),
size: String(query.size),
q: query.q,
});
export function fetchMasterRows(
file: string,
table: string,
query: RowsQuery,
): Promise<MasterRows> {
const params = new URLSearchParams({ file, table, ...pageParams(query) });
return request<MasterRows>(`/master-data/rows?${params}`);
}
+58
View File
@@ -40,6 +40,64 @@ export function rowCells(row: Record<string, unknown>, columns: MasterColumn[]):
);
}
/** 기초단가 표 머리 — 열 하나에 한 문장이라 표에 한 번만 옴(2026-09-15 브레인 ④). */
export interface TableMeta {
editable?: string[];
/** 열key → 왜 못 고치나 */
locked?: Record<string, string>;
/** 열key → 식 자체 */
formula?: Record<string, string>;
}
/** 덮개 판정 낱말 셋 — 서버가 줌 · 이것만 씀(2026-09-15 브레인). */
export type OverrideState = "edited" | "source_changed" | "orphan";
export interface CellInfo {
editable: boolean;
reason: string | null;
overridden: boolean;
/** 고쳐진 칸만 — 사람이 고칠 때의 원본 값 */
original?: unknown;
/** 고친 칸의 서버 판정 — 안 고친 칸 null · 서버가 state 를 안 주면 「missing」(때우지 않음) */
state: OverrideState | "missing" | null;
/** source_changed 칸만 — 지금 원본 값 */
current?: unknown;
rule: string | null;
/** 이 줄 값을 넣은 식 — 줄마다 다를 때만 옴 */
filled: string | null;
}
/** 칸 하나의 고침 상태 — 줄의 `@overrides`·`@formula` 는 해당할 때만 실려 옴. */
export function cellInfo(meta: TableMeta, row: Record<string, unknown>, key: string): CellInfo {
const overrides = row["@overrides"] as
Record<string, { original: unknown; current?: unknown; state?: OverrideState }> | undefined;
const filled = row["@formula"] as Record<string, string> | undefined;
const override = overrides?.[key];
// 판정은 서버 state 만 — 화면이 값을 견줘 따로 정하면 판정이 두 벌이 됨(2026-09-15 브레인).
const state = override ? (override.state ?? "missing") : null;
return {
editable: meta.editable?.includes(key) ?? false,
reason: meta.locked?.[key] ?? null,
overridden: override !== undefined,
...(override ? { original: override.original } : {}),
state,
...(state === "source_changed" ? { current: override?.current } : {}),
rule: meta.formula?.[key] ?? null,
filled: filled?.[key] ?? null,
};
}
const NUMBER_TEXT = /^-?[\d,]+(\.\d+)?$/;
/** 고친 글자를 보낼 값으로 — 수 칸은 쉼표를 떼 수로, 못 읽으면 undefined(저장 안 함). */
export function parseCellInput(text: string, original: unknown): unknown {
const trimmed = text.trim();
const numeric = typeof original === "number" || (original == null && NUMBER_TEXT.test(trimmed));
if (!numeric) return text;
if (!NUMBER_TEXT.test(trimmed)) return undefined;
return Number(trimmed.replaceAll(",", ""));
}
export function pageCount(total: number, size: number): number {
return Math.max(1, Math.ceil(total / size));
}
+21 -250
View File
@@ -1,46 +1,23 @@
/* =============================================================================
* Z01_MasterData_UI_Page.ts
* 마스터 데이터 — 좌 트리(갈래 → 파일 → 표) / 우 고른 표의 실제 줄(쪽 나누기 · 검색)
* 마스터 데이터 — B03~B09 와 같은 틀: 좌측 도킹 사이드바(기초단가 · 마스터 트리) / 우측 표
*
* 시스템 관리자만(서버도 require_system_admin 으로 다시 막음) · 읽기 전용부터
* (2026-09-15 브레인 Z01 — 마스터를 사람이 보고 고칠 수 있게 먼저 만들고 그 위에 일위대가 재조립).
* 시스템 관리자만(서버도 require_system_admin 으로 다시 막음) · 워크플로 밖 화면이라 진행단계
* 패널·프로젝트 이름은 안 세움 (2026-09-15 브레인 Z01 — 좌측 패널 양식 갈음 · 기초단가 칸).
* ========================================================================== */
import "@ui/ui_template_workflow_layout.css";
import { ROUTES } from "@config/config_frontend";
import {
createButton,
createInputField,
el,
hideLoadingOverlay,
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import { createGeneralLayout } from "@ui/ui_template_general_layout";
import { el, hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import { createWorkflowOverlays } from "@ui/ui_template_overlay";
import { navigateTo } from "../A00_Common/router";
import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch";
import {
fetchMasterRows,
fetchMasterTree,
type MasterFile,
type MasterGroup,
type MasterRows,
type MasterTable,
VALUES_TABLE_ID,
} from "./Z01_MasterData_Api_Fetch";
import {
clampPage,
columnTitle,
latestOnly,
pageCount,
rowCells,
shownColumns,
} from "./Z01_MasterData_UI_Cells";
import { fetchMasterTree, type MasterGroup } from "./Z01_MasterData_Api_Fetch";
import { buildRowsView } from "./Z01_MasterData_UI_Rows";
import { buildSidePanel } from "./Z01_MasterData_UI_Side";
import "./Z01_MasterData_UI_Style.css";
const PAGE_SIZE = 50;
const SEARCH_DELAY_MS = 300;
export async function renderZ01MasterData(root: HTMLElement): Promise<void> {
const user = await fetchSessionUser().catch(() => null);
if (user?.role !== "SYSTEM_ADMIN") {
@@ -63,223 +40,17 @@ export async function renderZ01MasterData(root: HTMLElement): Promise<void> {
function buildPage(groups: MasterGroup[]): HTMLElement {
const view = buildRowsView();
const tree = buildTree(groups, view.pick);
const content = el("div", { className: "z01-master__content", children: [tree, view.root] });
return createGeneralLayout({
pageClass: "z01-master",
const layout = el("div", { className: "ui-workflow-layout z01-master" });
const main = el("main", { className: "ui-workflow-layout__main", children: [view.root] });
const overlays = createWorkflowOverlays({
title: L("Z01_MasterData_Title"),
subtitle: L("Z01_MasterData_Subtitle"),
content,
}).root;
}
function buildTree(
groups: MasterGroup[],
pick: (file: MasterFile, table: MasterTable) => void,
): HTMLElement {
const nav = el("nav", { className: "z01-master__tree" });
for (const group of groups) {
const groupBox = folder("z01-master__group", group.label, String(group.files.length));
groupBox.open = true;
// 부산물은 계산이 남긴 기록 — 고칠 정본이 아님(2026-09-15 브레인 갈래 나눔표).
if (group.key === "byproduct") {
groupBox.append(
el("p", { className: "z01-master__note", text: L("Z01_MasterData_Byproduct") }),
);
}
for (const file of group.files) {
const fileBox = folder(
"z01-master__file",
file.label,
file.label === file.key ? "" : file.key,
);
// 낱값 마디는 표들 밑에 — 누르면 여느 표와 같은 길로 그림(2026-09-15 브레인).
const ordered = [...file.tables].sort(
(a, b) => Number(a.id === VALUES_TABLE_ID) - Number(b.id === VALUES_TABLE_ID),
);
for (const table of ordered) {
const button = el("button", {
className: "z01-master__table",
attrs: { type: "button", title: table.key },
children: [
el("span", { text: table.label }),
el("span", {
className: "z01-master__key",
text: table.row_count.toLocaleString("ko-KR"),
}),
],
});
button.addEventListener("click", () => {
nav.querySelector(".z01-master__table.is-active")?.classList.remove("is-active");
button.classList.add("is-active");
pick(file, table);
});
fileBox.append(button);
}
groupBox.append(fileBox);
}
nav.append(groupBox);
}
return nav;
}
/** 접는 칸 — 브라우저 기본 <details> 로 둠. */
function folder(className: string, label: string, aside: string): HTMLDetailsElement {
const summary = el("summary", {
children: [
el("span", { text: label }),
el("span", { className: "z01-master__key", text: aside }),
],
});
return el("details", { className, children: [summary] });
}
interface RowsView {
root: HTMLElement;
pick: (file: MasterFile, table: MasterTable) => void;
}
function buildRowsView(): RowsView {
const state = {
file: null as MasterFile | null,
table: null as MasterTable | null,
page: 1,
q: "",
};
let showHidden = false;
let last: MasterRows | null = null;
let searchTimer = 0;
const fetchLatest = latestOnly(fetchMasterRows);
const title = el("h2", { className: "z01-master__title", text: L("Z01_MasterData_PickTable") });
const titleKey = el("span", { className: "z01-master__key" });
const search = createInputField({
type: "search",
placeholder: L("Z01_MasterData_Search"),
onInput: (value) => {
window.clearTimeout(searchTimer);
searchTimer = window.setTimeout(() => {
state.q = value.trim();
state.page = 1;
void load();
}, SEARCH_DELAY_MS);
},
});
const hiddenToggle = el("input", { attrs: { type: "checkbox" } });
hiddenToggle.addEventListener("change", () => {
showHidden = hiddenToggle.checked;
draw();
});
const hiddenLabel = el("label", {
className: "z01-master__check",
children: [hiddenToggle, L("Z01_MasterData_ShowHidden")],
});
const grid = el("div", { className: "z01-master__grid-wrap" });
const pageInput = el("input", {
className: "ui-input z01-master__page-input",
attrs: { type: "number", min: "1", "aria-label": L("Z01_MasterData_Page") },
});
pageInput.addEventListener("change", () => goTo(Number(pageInput.value)));
const pageInfo = el("span", { className: "z01-master__page-info" });
// 큰 표(수만 줄 · 수백 쪽)는 한 칸씩 못 넘김 — 처음·끝 단추와 쪽 번호 칸을 둠(2026-09-15 브레인).
const pagerButton = (key: Parameters<typeof L>[0], onClick: () => void) =>
createButton({ label: L(key), variant: "ghost", onClick });
const first = pagerButton("Z01_MasterData_First", () => goTo(1));
const prev = pagerButton("Z01_MasterData_Prev", () => goTo(state.page - 1));
const next = pagerButton("Z01_MasterData_Next", () => goTo(state.page + 1));
const end = pagerButton("Z01_MasterData_Last", () => goTo(Infinity));
const toolbar = el("div", {
className: "z01-master__toolbar",
children: [search.root, hiddenLabel],
});
const pager = el("div", {
className: "z01-master__pager",
children: [first, prev, pageInput, pageInfo, next, end],
});
const root = el("section", {
className: "z01-master__panel",
children: [el("div", { children: [title, titleKey] }), toolbar, grid, pager],
});
toolbar.hidden = true;
pager.hidden = true;
function goTo(page: number): void {
const pages = pageCount(last?.total ?? 0, PAGE_SIZE);
state.page = page === Infinity ? pages : clampPage(page, pages);
void load();
}
async function load(): Promise<void> {
if (!state.file || !state.table) return;
grid.classList.add("is-loading");
let data: MasterRows | undefined;
try {
data = await fetchLatest({
file: state.file.id,
table: state.table.id,
page: state.page,
size: PAGE_SIZE,
q: state.q,
});
} catch (error) {
grid.classList.remove("is-loading");
showToast(error instanceof Error ? error.message : L("Z01_MasterData_LoadFailed"), "error");
return;
}
// 옛 요청 — 로딩 표시는 뒤 요청 몫이라 그대로 둠.
if (!data) return;
grid.classList.remove("is-loading");
last = data;
draw();
}
function draw(): void {
if (!last) return;
const columns = shownColumns(last.columns, showHidden);
const headRow = el("tr");
for (const column of columns) {
const th = el("th", { children: [el("span", { text: columnTitle(column) })] });
if (column.label !== column.key) {
th.append(el("span", { className: "z01-master__key", text: column.key }));
}
headRow.append(th);
}
const body = el("tbody");
for (const row of last.rows) {
const tr = el("tr");
for (const text of rowCells(row, columns)) {
tr.append(el("td", { text, attrs: { title: text } }));
}
body.append(tr);
}
grid.replaceChildren(
last.rows.length
? el("table", {
className: "z01-master__grid",
children: [el("thead", { children: [headRow] }), body],
})
: el("p", { className: "z01-master__note", text: L("Z01_MasterData_NoRows") }),
);
const pages = pageCount(last.total, PAGE_SIZE);
pageInput.value = String(state.page);
pageInput.max = String(pages);
pageInfo.textContent = `/ ${pages.toLocaleString("ko-KR")} ${L("Z01_MasterData_Page")} · ${last.total.toLocaleString("ko-KR")} ${L("Z01_MasterData_Rows")}`;
first.disabled = prev.disabled = state.page <= 1;
next.disabled = end.disabled = state.page >= pages;
}
function pick(file: MasterFile, table: MasterTable): void {
state.file = file;
state.table = table;
state.page = 1;
title.textContent = `${file.label} › ${table.label}`;
titleKey.textContent = `${file.key} / ${table.key}`;
toolbar.hidden = false;
pager.hidden = false;
last = null;
grid.replaceChildren();
void load();
}
return { root, pick };
optionsContent: buildSidePanel(groups, view),
showProjectName: false,
onOptionsOpenChange: (isOpen) => layout.classList.toggle("is-options-open", isOpen),
});
layout.append(
el("div", { className: "ui-workflow-layout__body", children: [main] }),
overlays.root,
);
return layout;
}
+339
View File
@@ -0,0 +1,339 @@
/* =============================================================================
* Z01_MasterData_UI_Rows.ts
* 우측 표 보기 — 고른 표(트리 표 · 기초단가)의 줄을 쪽 나누기·검색으로 그리고, 기초단가는 칸을 고침
*
* 한 길로 두 가지를 그림 — 트리 표는 읽기 전용, 기초단가는 `save` 가 있어 editable 칸을 고침.
* 칸을 누르면 표 위 「칸 설명」 줄에 고칠 수 있는지 · 못 고치는 까닭 · 원래 값 · 산출근거가 섬
* (2026-09-15 브레인 — 계산으로 나오는 칸은 못 고치고 왜 못 고치는지 옆에 보일 것).
* ========================================================================== */
import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import type { MasterRows, RowsQuery } from "./Z01_MasterData_Api_Fetch";
import {
cellInfo,
cellText,
clampPage,
columnTitle,
latestOnly,
pageCount,
parseCellInput,
rowCells,
shownColumns,
type CellInfo,
type MasterColumn,
} from "./Z01_MasterData_UI_Cells";
const PAGE_SIZE = 50;
const SEARCH_DELAY_MS = 300;
type Row = Record<string, unknown>;
export interface RowsSource {
title: string;
key: string;
/** 표 위 알림 한 줄(요율 — 법이 정한 값) */
notice?: string;
load: (query: RowsQuery) => Promise<MasterRows>;
/** 있으면 editable 칸을 고침 — 응답은 고친 뒤 그 줄 */
save?: (rowId: string, values: Row) => Promise<Row>;
}
export interface RowsView {
root: HTMLElement;
show: (source: RowsSource) => void;
}
/** 사전 문구의 {value} 자리에 값을 넣음. */
function fill(key: Parameters<typeof L>[0], value: string): string {
return L(key).replace("{value}", value);
}
/** 덮개 판정 한 줄 — 서버 state 그대로 · state 가 없으면 버그로 드러냄. */
function stateText(info: CellInfo, column: MasterColumn): string {
const original = cellText(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)})`;
case "orphan":
return L("Z01_MasterData_State_Orphan");
case "missing":
return L("Z01_MasterData_State_Missing");
default:
return fill("Z01_MasterData_State_Edited", original);
}
}
export function buildRowsView(): RowsView {
let source: RowsSource | null = null;
const state = { page: 1, q: "" };
let showHidden = false;
let last: MasterRows | null = null;
let selected: { index: number; key: string } | null = null;
let searchTimer = 0;
const fetchLatest = latestOnly((from: RowsSource, query: RowsQuery) => from.load(query));
const title = el("h2", { className: "z01-master__title", text: L("Z01_MasterData_PickTable") });
const titleKey = el("span", { className: "z01-master__key" });
const search = createInputField({
type: "search",
placeholder: L("Z01_MasterData_Search"),
onInput: (value) => {
window.clearTimeout(searchTimer);
searchTimer = window.setTimeout(() => {
state.q = value.trim();
state.page = 1;
void load();
}, SEARCH_DELAY_MS);
},
});
const hiddenToggle = el("input", { attrs: { type: "checkbox" } });
hiddenToggle.addEventListener("change", () => {
showHidden = hiddenToggle.checked;
draw();
});
const hiddenLabel = el("label", {
className: "z01-master__check",
children: [hiddenToggle, L("Z01_MasterData_ShowHidden")],
});
const notice = el("p", { className: "z01-master__notice" });
const detail = el("div", { className: "z01-master__detail" });
const grid = el("div", { className: "z01-master__grid-wrap" });
const pageInput = el("input", {
className: "ui-input z01-master__page-input",
attrs: { type: "number", min: "1", "aria-label": L("Z01_MasterData_Page") },
});
pageInput.addEventListener("change", () => goTo(Number(pageInput.value)));
const pageInfo = el("span", { className: "z01-master__page-info" });
// 큰 표(수만 줄 · 수백 쪽)는 한 칸씩 못 넘김 — 처음·끝 단추와 쪽 번호 칸을 둠(2026-09-15 브레인).
const pagerButton = (key: Parameters<typeof L>[0], onClick: () => void) =>
createButton({ label: L(key), variant: "ghost", onClick });
const first = pagerButton("Z01_MasterData_First", () => goTo(1));
const prev = pagerButton("Z01_MasterData_Prev", () => goTo(state.page - 1));
const next = pagerButton("Z01_MasterData_Next", () => goTo(state.page + 1));
const end = pagerButton("Z01_MasterData_Last", () => goTo(Infinity));
const toolbar = el("div", {
className: "z01-master__toolbar",
children: [search.root, hiddenLabel],
});
const pager = el("div", {
className: "z01-master__pager",
children: [first, prev, pageInput, pageInfo, next, end],
});
const root = el("section", {
className: "z01-master__panel",
children: [el("div", { children: [title, titleKey] }), notice, toolbar, detail, grid, pager],
});
toolbar.hidden = true;
pager.hidden = true;
detail.hidden = true;
notice.hidden = true;
function goTo(page: number): void {
const pages = pageCount(last?.total ?? 0, PAGE_SIZE);
state.page = page === Infinity ? pages : clampPage(page, pages);
void load();
}
async function load(): Promise<void> {
if (!source) return;
grid.classList.add("is-loading");
let data: MasterRows | undefined;
try {
data = await fetchLatest(source, { page: state.page, size: PAGE_SIZE, q: state.q });
} catch (error) {
grid.classList.remove("is-loading");
showToast(error instanceof Error ? error.message : L("Z01_MasterData_LoadFailed"), "error");
return;
}
// 옛 요청 — 로딩 표시는 뒤 요청 몫이라 그대로 둠.
if (!data) return;
grid.classList.remove("is-loading");
last = data;
selected = null;
detail.hidden = true;
draw();
}
function headCell(meta: MasterRows, column: MasterColumn): HTMLElement {
const th = el("th", { children: [el("span", { text: columnTitle(column) })] });
const marks: [string, string][] = [];
if (source?.save && meta.editable?.includes(column.key)) {
marks.push(["✎", L("Z01_MasterData_Edit_Can")]);
}
const reason = meta.locked?.[column.key];
if (reason) marks.push(["🔒", reason]);
const rule = meta.formula?.[column.key];
if (rule) marks.push(["ƒ", rule]);
for (const [mark, tip] of marks) {
th.append(el("span", { className: "z01-master__mark", text: mark, attrs: { title: tip } }));
}
if (column.label !== column.key) {
th.append(el("span", { className: "z01-master__key", text: column.key }));
}
return th;
}
function draw(): void {
if (!last) return;
const meta = last;
const columns = shownColumns(meta.columns, showHidden);
const headRow = el("tr", { children: columns.map((column) => headCell(meta, column)) });
const body = el("tbody");
meta.rows.forEach((row, index) => {
const texts = rowCells(row, columns);
const tr = el("tr");
columns.forEach((column, i) => {
const info = cellInfo(meta, row, column.key);
const td = el("td", { text: texts[i], attrs: { title: texts[i] } });
if (info.state) {
// 원본이 바뀐 칸은 줄째 또렷이 — 흐리면 옛 값이 조용히 계속 쓰임(2026-09-15 브레인).
td.classList.add("is-overridden", `is-${info.state.replace("_", "-")}`);
if (info.state !== "edited") {
td.textContent = `⚠ ${texts[i]}`;
tr.classList.add("has-warning");
}
td.title = stateText(info, column);
}
const canEdit = Boolean(source?.save) && info.editable && row["@id"] != null;
if (canEdit) td.classList.add("is-editable");
if (selected?.index === index && selected.key === column.key)
td.classList.add("is-selected");
td.addEventListener("click", () => {
if (td.querySelector("input")) return;
selectCell(index, column);
td.classList.add("is-selected");
if (canEdit) startEdit(td, index, column);
});
tr.append(td);
});
body.append(tr);
});
grid.replaceChildren(
meta.rows.length
? el("table", {
className: "z01-master__grid",
children: [el("thead", { children: [headRow] }), body],
})
: el("p", { className: "z01-master__note", text: L("Z01_MasterData_NoRows") }),
);
const pages = pageCount(meta.total, PAGE_SIZE);
pageInput.value = String(state.page);
pageInput.max = String(pages);
pageInfo.textContent = `/ ${pages.toLocaleString("ko-KR")} ${L("Z01_MasterData_Page")} · ${meta.total.toLocaleString("ko-KR")} ${L("Z01_MasterData_Rows")}`;
first.disabled = prev.disabled = state.page <= 1;
next.disabled = end.disabled = state.page >= pages;
}
/** 칸 설명 줄 — 고칠 수 있나 · 못 고치는 까닭 · 원래 값(되돌리기) · 식과 값 넣은 식. */
function selectCell(index: number, column: MasterColumn): 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 (source?.save) {
lines.push(
line(
info.editable
? L("Z01_MasterData_Edit_Can")
: `${L("Z01_MasterData_Edit_Locked")} — ${info.reason ?? L("Z01_MasterData_Edit_NotListed")}`,
),
);
}
if (info.state) {
const stateLine = line(stateText(info, column));
if (info.state !== "edited") stateLine.classList.add("is-warning");
lines.push(stateLine);
// 단추 글자에 무엇으로 바뀌는지 값을 넣음 — 안 보이고 누르면 금액이 말없이 바뀜(브레인).
const target =
info.state === "source_changed"
? fill("Z01_MasterData_Edit_TakeSource", cellText(info.current, column.unit))
: info.state === "orphan"
? null
: fill("Z01_MasterData_Edit_RevertTo", cellText(info.original, column.unit));
if (target && source?.save) {
lines.push(
createButton({
label: target,
variant: "ghost",
onClick: () => void commit(index, column, null),
}),
);
}
}
if (info.rule) lines.push(line(`${L("Z01_MasterData_Formula")} ${info.rule}`));
if (info.filled) lines.push(line(info.filled));
detail.replaceChildren(...lines);
detail.hidden = false;
}
function startEdit(td: HTMLElement, index: number, column: MasterColumn): void {
if (!last) return;
const current = last.rows[index][column.key];
const input = el("input", { className: "ui-input z01-master__cell-input" });
input.value = current == null ? "" : String(current);
td.replaceChildren(input);
input.focus();
input.select();
let finished = false;
const finish = (save: boolean): void => {
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) {
draw();
return;
}
void commit(index, column, value);
};
input.addEventListener("keydown", (event) => {
if (event.key === "Enter") finish(true);
if (event.key === "Escape") finish(false);
});
input.addEventListener("blur", () => finish(true));
}
/** 저장 — null 은 덮개에서 그 칸을 빼 원래 값으로 되돌림. */
async function commit(index: number, column: MasterColumn, value: unknown): Promise<void> {
const from = source;
const table = last;
const rowId = table?.rows[index]["@id"];
if (!from?.save || !table || rowId == null) return;
try {
const updated = await from.save(String(rowId), { [column.key]: value });
if (from !== source || table !== last) return;
table.rows[index] = updated;
draw();
selectCell(index, column);
showToast(
L(value === null ? "Z01_MasterData_Edit_Reverted" : "Z01_MasterData_Edit_Saved"),
"success",
);
} catch (error) {
showToast(error instanceof Error ? error.message : L("Z01_MasterData_Edit_Failed"), "error");
draw();
}
}
function show(picked: RowsSource): void {
source = picked;
state.page = 1;
title.textContent = picked.title;
titleKey.textContent = picked.key;
notice.textContent = picked.notice ?? "";
notice.hidden = !picked.notice;
toolbar.hidden = false;
pager.hidden = false;
detail.hidden = true;
last = null;
selected = null;
grid.replaceChildren();
void load();
}
return { root, show };
}
+177
View File
@@ -0,0 +1,177 @@
/* =============================================================================
* Z01_MasterData_UI_Side.ts
* 좌측 패널 — B03~B09 와 같은 도킹 사이드바 안에 접히는 상자 둘(기초단가 · 마스터 트리)
*
* 상자 겉모습·접기는 공용(`ui-collapsible ui-sidebar-section`) · 누른 단추 하나만 강조
* (두 상자를 가로질러 한 개) (2026-09-15 브레인 — 좌측 패널 양식 갈음 · 기초단가 칸).
* ========================================================================== */
import { attachCollapsible } from "@ui/ui_template_collapsible";
import { el } from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import {
BASE_PRICE_KINDS,
fetchBasePrices,
fetchMasterRows,
fetchOverrides,
type MasterGroup,
type MasterRows,
type RowsQuery,
saveBasePrice,
VALUES_TABLE_ID,
} from "./Z01_MasterData_Api_Fetch";
import type { RowsView } from "./Z01_MasterData_UI_Rows";
export function buildSidePanel(groups: MasterGroup[], view: RowsView): HTMLElement {
const panel = el("div", { className: "z01-master__side" });
const activate = (button: HTMLElement): void => {
panel.querySelector(".is-active")?.classList.remove("is-active");
button.classList.add("is-active");
};
const baseTitle = L("Z01_MasterData_BasePrices");
const baseGrid = el("div", { className: "z01-master__base-grid" });
for (const kind of BASE_PRICE_KINDS) {
const label = L(`Z01_MasterData_Base_${kind}` as const);
const button = el("button", {
className: "z01-master__base",
attrs: { type: "button" },
text: label,
});
button.addEventListener("click", () => {
activate(button);
view.show({
title: `${baseTitle} › ${label}`,
key: `base-prices/${kind}`,
// 요율은 구간 줄에 코드가 없어 구간 이름으로 이음 — 법이 바뀌면 고친 값이 주인을 잃기 쉬움.
notice: kind === "rate" ? L("Z01_MasterData_Rate_Notice") : undefined,
load: (query) => fetchBasePrices(kind, query),
save: (rowId, values) => saveBasePrice(kind, rowId, values),
});
});
baseGrid.append(button);
}
const overridesButton = el("button", {
className: "z01-master__base z01-master__base--wide",
attrs: { type: "button" },
text: L("Z01_MasterData_Overrides"),
});
overridesButton.addEventListener("click", () => {
activate(overridesButton);
view.show({
title: `${baseTitle} › ${L("Z01_MasterData_Overrides")}`,
key: "overrides",
load: loadOverrides,
});
});
baseGrid.append(overridesButton);
const tree = el("nav", { className: "z01-master__tree" });
for (const group of groups) {
const groupBox = folder("z01-master__group", group.label, String(group.files.length));
groupBox.open = true;
// 부산물은 계산이 남긴 기록 — 고칠 정본이 아님(2026-09-15 브레인 갈래 나눔표).
if (group.key === "byproduct") {
groupBox.append(
el("p", { className: "z01-master__note", text: L("Z01_MasterData_Byproduct") }),
);
}
for (const file of group.files) {
const fileBox = folder(
"z01-master__file",
file.label,
file.label === file.key ? "" : file.key,
);
// 낱값 마디는 표들 밑에 — 누르면 여느 표와 같은 길로 그림(2026-09-15 브레인).
const ordered = [...file.tables].sort(
(a, b) => Number(a.id === VALUES_TABLE_ID) - Number(b.id === VALUES_TABLE_ID),
);
for (const table of ordered) {
const button = el("button", {
className: "z01-master__table",
attrs: { type: "button", title: table.key },
children: [
el("span", { text: table.label }),
el("span", {
className: "z01-master__key",
text: table.row_count.toLocaleString("ko-KR"),
}),
],
});
button.addEventListener("click", () => {
activate(button);
view.show({
title: `${file.label} › ${table.label}`,
key: `${file.key} / ${table.key}`,
load: (query) => fetchMasterRows(file.id, table.id, query),
});
});
fileBox.append(button);
}
groupBox.append(fileBox);
}
tree.append(groupBox);
}
panel.append(section(baseTitle, baseGrid), section(L("Z01_MasterData_Tree"), tree));
attachCollapsible(panel);
return panel;
}
const STATE_LABEL = {
source_changed: "Z01_MasterData_List_SourceChanged",
orphan: "Z01_MasterData_List_Orphan",
edited: "Z01_MasterData_List_Edited",
} as const;
/**
* 고친 것 목록을 표 한 벌로 — 서버 정렬(원본 바뀜 → 주인 없음 → 고쳐짐)을 그대로 두고 쪽만 자름.
* 쪽을 서버에서 나눠 받으면 맨 위가 그 쪽 안에서만 맨 위가 됨(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 column = (key: string, label: Parameters<typeof L>[0]) => ({ key, label: L(label) });
return {
columns: [
column("state", "Z01_MasterData_List_State"),
column("kind", "Z01_MasterData_BasePrices"),
column("row_label", "Z01_MasterData_List_Row"),
column("column_label", "Z01_MasterData_List_Column"),
column("value", "Z01_MasterData_List_Value"),
column("original", "Z01_MasterData_List_Original"),
column("current", "Z01_MasterData_List_Current"),
],
rows: items.slice(start, start + query.size).map((item) => ({
...item,
// state 가 안 오면 때우지 않고 드러냄(서버 버그).
state: L(STATE_LABEL[item.state] ?? "Z01_MasterData_State_Missing"),
kind: L(`Z01_MasterData_Base_${item.kind}` as const),
})),
total: items.length,
};
}
function section(title: string, body: HTMLElement): HTMLElement {
return el("section", {
className: "z01-master__section ui-collapsible ui-sidebar-section",
children: [
el("p", { className: "z01-master__section-title ui-collapsible__title", text: title }),
body,
],
});
}
/** 트리 접는 칸 — 브라우저 기본 <details> 로 둠. */
function folder(className: string, label: string, aside: string): HTMLDetailsElement {
const summary = el("summary", {
children: [
el("span", { text: label }),
el("span", { className: "z01-master__key", text: aside }),
],
});
return el("details", { className, children: [summary] });
}
+138 -36
View File
@@ -1,24 +1,66 @@
/* Z01 마스터 데이터 — 좌 트리 / 우 표 */
/* Z01 마스터 데이터 — 좌측 도킹 사이드바(기초단가 · 마스터 트리) / 우측 표 */
.z01-master [hidden] {
display: none !important;
}
.z01-master__content {
/* ── 좌측 패널: B08 과 같은 상자 틀(겉선·접기는 공용 ui-sidebar-section · ui-collapsible) ── */
.z01-master__side {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding-top: var(--spacing-8);
}
.z01-master__section {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
margin: 0;
padding: 10px 12px;
border-radius: var(--radius-cards);
background-color: var(--color-surface-raised);
}
.z01-master__section-title {
margin: 0 0 2px;
color: var(--color-text-secondary);
font-size: 12px;
font-weight: 600;
}
.z01-master__base-grid {
display: grid;
grid-template-columns: minmax(220px, 300px) minmax(0, 1fr);
gap: var(--spacing-24);
align-items: start;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--spacing-4);
}
.z01-master__base {
padding: var(--spacing-4) var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-buttons);
background: var(--color-surface);
color: var(--color-text-body);
font: inherit;
font-size: var(--text-body-sm);
cursor: pointer;
}
.z01-master__base--wide {
grid-column: 1 / -1;
}
.z01-master__base:hover,
.z01-master__table:hover {
background: var(--color-paper);
}
.z01-master__base.is-active,
.z01-master__table.is-active {
background: var(--color-mist-violet);
color: var(--color-accent);
}
.z01-master__tree {
position: sticky;
top: var(--spacing-16);
max-height: calc(100vh - 160px);
overflow-y: auto;
padding: var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
background: var(--color-surface);
font-size: var(--text-body-sm);
}
@@ -26,7 +68,7 @@
display: flex;
justify-content: space-between;
gap: var(--spacing-8);
padding: var(--spacing-4) var(--spacing-8);
padding: var(--spacing-4) var(--spacing-4);
cursor: pointer;
color: var(--color-text-body);
}
@@ -68,20 +110,13 @@
cursor: pointer;
}
.z01-master__table:hover {
background: var(--color-surface-raised);
}
.z01-master__table.is-active {
background: var(--color-mist-violet);
color: var(--color-accent);
}
/* ── 우측 표 ── */
.z01-master__panel {
display: flex;
flex-direction: column;
gap: var(--spacing-16);
gap: var(--spacing-12);
min-width: 0;
padding: var(--spacing-16) var(--spacing-24);
}
.z01-master__title {
@@ -90,6 +125,16 @@
font-size: var(--text-body);
}
/* 요율 알림 — 막지 않고 알리기만 */
.z01-master__notice {
margin: 0;
padding: var(--spacing-8) var(--spacing-12);
border-left: 4px solid var(--color-warning);
background: var(--color-paper);
color: var(--color-text-body);
font-size: var(--text-body-sm);
}
.z01-master__toolbar {
display: flex;
flex-wrap: wrap;
@@ -105,8 +150,30 @@
font-size: var(--text-body-sm);
}
/* 칸 설명 줄 — 고칠 수 있나 · 까닭 · 원래 값 · 산출근거 */
.z01-master__detail {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-4) var(--spacing-12);
padding: var(--spacing-8) var(--spacing-12);
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
background: var(--color-surface);
font-size: var(--text-body-sm);
}
.z01-master__detail-line {
color: var(--color-text-body);
}
.z01-master__detail-line.is-warning {
color: var(--color-danger);
font-weight: var(--font-weight-bold);
}
.z01-master__grid-wrap {
max-height: calc(100vh - 300px);
max-height: calc(100vh - 320px);
overflow: auto;
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
@@ -146,6 +213,52 @@
margin-left: 0;
}
.z01-master__mark {
margin-left: var(--spacing-4);
cursor: help;
}
.z01-master__grid td.is-editable {
cursor: text;
text-decoration: underline dotted var(--color-text-muted);
}
.z01-master__grid td.is-selected {
outline: 2px solid var(--color-accent);
outline-offset: -2px;
}
/* 고쳐짐 — 덮개 값을 쓰는 칸 */
.z01-master__grid td.is-overridden {
background: var(--color-mist-violet);
color: var(--color-accent);
font-weight: var(--font-weight-medium);
}
/* 원본 바뀜 · 주인 없음 · 판정 없음 — 줄째 또렷이(흐리면 옛 값이 조용히 쓰임) */
.z01-master__grid tr.has-warning td {
background: color-mix(in srgb, var(--color-danger) 8%, var(--color-canvas));
}
.z01-master__grid tr.has-warning td:first-child {
box-shadow: inset 4px 0 0 var(--color-danger);
}
/* tr 을 넣어 줄 배경(tr.has-warning td)보다 무겁게 — 칸이 줄보다 더 진해야 함 */
.z01-master__grid tr td.is-source-changed,
.z01-master__grid tr td.is-orphan,
.z01-master__grid tr td.is-missing {
background: color-mix(in srgb, var(--color-danger) 22%, var(--color-canvas));
color: var(--color-danger);
font-weight: var(--font-weight-bold);
}
.z01-master__cell-input {
width: 100%;
min-width: 6em;
padding: 2px var(--spacing-4);
}
.z01-master__pager {
display: flex;
align-items: center;
@@ -159,14 +272,3 @@
width: 6em;
text-align: right;
}
@media (max-width: 860px) {
.z01-master__content {
display: block;
}
.z01-master__tree {
position: static;
margin-bottom: var(--spacing-24);
}
}
@@ -2,7 +2,7 @@
"schema_version": "1.0",
"dataset_id": "data_master_labels",
"effective_date": "2026-01-01",
"generated_at": "2026-09-15T19:33:14+09:00",
"generated_at": "2026-09-15T21:46:51+09:00",
"note": "마스터 자료의 **사람이 읽을 이름표**. 값이 아니라 이름만 담는다 — 여기를 고쳐도 계산은 안 바뀐다. Z01 마스터 관리 화면이 표·열 이름을 여기서 읽는다.",
"policy": {
"labels_only": "값·수식·단가를 담지 않는다. 마스터 파일은 손대지 않는다.",
@@ -12,6 +12,7 @@
"brain_kind_table": "갈래는 2026-09-15 브레인 갈래 나눔표를 그대로 따른다(로직 17 · 기초값 9 · 부산물 4 · 씨앗 1).",
"out_of_31": "폴더 지문(_manifest.json) 셋은 브레인 31 밖이다. 부산물이 아니라 **지문** 갈래로 둔다 — 계산이 남긴 기록이 아니라 어느 판으로 셈했는지 못박는 표라 「정본 아님」 딱지가 안 맞는다(2026-09-15 브레인 ②).",
"excluded": "강우 IDF 캐시(resources/data_rainfall_idf_cache, 96 파일)는 배수용이라 뺀다.",
"merged_tables_rule": "`merged_tables` 는 **기초단가 다섯**(노임·자재·유가·제비율·기계)을 화면이 한 표씩으로 보이려고 여러 원본 표를 모은 자리다. ⚠ **값을 옮기지 않는다** — 열 이름·단위·숨김과 `editable`(고칠 수 있나)만 담는다. `editable: false` 는 다른 값에서 나온 계산값이라 사람이 못 고친다. 기계는 한 표로 안 모이므로 `machine_cost_chain` 이 사슬을 적는다 (2026-09-15 사용자 지시 · 브레인).",
"row_key_column": "사전 모양(shape=map) 표는 **줄 이름(키) 자체가 한 열**이다. API 가 `@key` 로 내보내므로 열 목록 맨 앞에 `@key`(`is_row_key: true`)를 두고 표마다 제 이름을 붙인다 — 뜻이 표마다 달라 한 이름으로 뭉치지 않는다(2026-09-15 브레인 ①).",
"value_kind_rule": "값 묶음마다 `kind` 를 낱말로 적는다. `value`(값) = 원가·수량에 그대로 드는 것(한 값짜리 요율·시세·계수·원단위표·잇는 값) — 낱값 마디(`@values`)에 **보인다**. `doc`(설명) = 읽으라고 적어 둔 글(방침·출처·비고·읽기 기록·집계·표 머리의 줄 키·담은 꼴) — 낱값 마디에 **안 보인다**. 참·거짓이 아니라 낱말이라 수식 같은 갈래가 늘면 낱말만 더하면 된다. 가름은 「고치면 금액이 움직이나」로 한다. 움직이면 값, 안 움직이면 설명. 곁값(값 단위·기준일·걸치는 범위)은 **값 쪽**이다 — 없으면 값의 뜻이 안 선다. `undecided: true` 는 값도 설명도 아닌 미결이라 판정을 기다린다(2026-09-15 브레인 ②)."
},
@@ -49,6 +50,823 @@
"summary": "앞으로 늘려 갈 첫 벌. 모양을 잡아 두려고 먼저 놓은 것이다."
}
},
"merged_tables": [
{
"key": "labor",
"name_ko": "노임",
"summary": "직종마다 하루 노임 한 표. 건설업·제조업 두 공표를 한 자리에 모은다.",
"sources": [
"labor_const::variables/labor_rate/records",
"labor_mfg::variables/labor_mfg/records"
],
"rows_note": "건설업 132 + 제조업 129 = 261 줄",
"columns": [
{
"key": "source",
"name_ko": "출처",
"unit": "",
"visible": true,
"editable": true,
"from": "새 칸",
"note": "건설업(대한건설협회) · 제조업(중소기업중앙회). ⚠ 이 칸이 없으면 두 코드 공간이 섞인다."
},
{
"key": "occupation_code",
"name_ko": "직종코드",
"unit": "",
"visible": true,
"editable": true,
"from": "둘 다"
},
{
"key": "occupation_name",
"name_ko": "직종명",
"unit": "",
"visible": true,
"editable": true,
"from": "둘 다"
},
{
"key": "industry",
"name_ko": "업종",
"unit": "",
"visible": true,
"editable": true,
"from": "제조업만"
},
{
"key": "unit",
"name_ko": "단위",
"unit": "",
"visible": true,
"editable": true,
"from": "둘 다",
"note": "둘 다 KRW/day — 어긋남 없음."
},
{
"key": "hours_per_day",
"name_ko": "1일 근로시간",
"unit": "시간",
"visible": true,
"editable": true,
"from": "건설업만"
},
{
"key": "daily_wage_krw",
"name_ko": "일 노임",
"unit": "원",
"visible": true,
"editable": true,
"from": "둘 다"
},
{
"key": "status",
"name_ko": "공표 상태",
"unit": "",
"visible": true,
"editable": true,
"from": "둘 다"
},
{
"key": "reliability",
"name_ko": "신뢰도 기호",
"unit": "",
"visible": true,
"editable": true,
"from": "건설업만",
"note": "* 조사현장 5개 미만 · ** 미조사. 뜻풀이가 원본에 있다."
},
{
"key": "source_flag",
"name_ko": "원문 표시",
"unit": "",
"visible": true,
"editable": true,
"from": "제조업만",
"note": "`*` 16 줄. ⚠ 뜻풀이가 원본에 없어 신뢰도 기호와 한 칸으로 못 묶는다."
}
]
},
{
"key": "material",
"name_ko": "자재 단가",
"summary": "물품마다 규격·단위·단가 한 표. 원본이 하나라 합칠 것이 없다.",
"sources": [
"mat_price_public::variables/mat_price/records"
],
"rows_note": "6,999 줄 — 다섯 가운데 가장 크다",
"columns": [
{
"key": "item_code",
"name_ko": "물품코드",
"unit": "",
"visible": true,
"editable": true
},
{
"key": "classification_code",
"name_ko": "물품분류번호",
"unit": "",
"visible": true,
"editable": true
},
{
"key": "classification_name",
"name_ko": "물품분류명",
"unit": "",
"visible": true,
"editable": true
},
{
"key": "specification",
"name_ko": "규격",
"unit": "",
"visible": true,
"editable": true
},
{
"key": "unit",
"name_ko": "단위",
"unit": "",
"visible": true,
"editable": true
},
{
"key": "price_krw",
"name_ko": "단가",
"unit": "원",
"visible": true,
"editable": true
},
{
"key": "vat_basis",
"name_ko": "부가세 기준",
"unit": "",
"visible": true,
"editable": true
},
{
"key": "price_type",
"name_ko": "가격 종류",
"unit": "",
"visible": true,
"editable": true
},
{
"key": "delivery_condition",
"name_ko": "납품 조건",
"unit": "",
"visible": true,
"editable": true
},
{
"key": "business_division_name",
"name_ko": "사업구분",
"unit": "",
"visible": true,
"editable": true
},
{
"key": "field",
"name_ko": "분야",
"unit": "",
"visible": true,
"editable": true
},
{
"key": "notice_datetime",
"name_ko": "공고 일시",
"unit": "",
"visible": true,
"editable": true
},
{
"key": "notice_number",
"name_ko": "공고번호",
"unit": "",
"visible": false,
"editable": true
},
{
"key": "business_division_code",
"name_ko": "사업구분 코드",
"unit": "",
"visible": false,
"editable": true
}
]
},
{
"key": "oil",
"name_ko": "유가",
"summary": "유종 × 지역 한 표. 전국평균 판과 시도별 판을 한 자리에 모은다.",
"sources": [
"oil::variables",
"oil_regional::variables/oil_gasoline/records",
"oil_regional::variables/oil_diesel/records"
],
"rows_note": "전국평균 2 + 시도별 34 = 36 줄",
"columns": [
{
"key": "fuel",
"name_ko": "유종",
"unit": "",
"visible": true,
"editable": true,
"from": "원본 키",
"note": "휘발유 · 경유."
},
{
"key": "source",
"name_ko": "출처",
"unit": "",
"visible": true,
"editable": true,
"from": "새 칸",
"note": "전국평균 판 · 시도별 판. 기준일이 판마다 다르다."
},
{
"key": "region_code",
"name_ko": "시도코드",
"unit": "",
"visible": true,
"editable": true,
"from": "시도판",
"note": "전국평균 판은 `00` 으로 맞춘다."
},
{
"key": "region_name",
"name_ko": "시도명",
"unit": "",
"visible": true,
"editable": true,
"from": "시도판"
},
{
"key": "price_krw_per_l",
"name_ko": "단가",
"unit": "원/L",
"visible": true,
"editable": true,
"from": "둘 다"
},
{
"key": "date",
"name_ko": "기준일",
"unit": "",
"visible": true,
"editable": true,
"from": "둘 다",
"note": "⚠ 판마다 다르다 — 같은 표에 두 날짜가 선다."
},
{
"key": "source_product_name",
"name_ko": "원천 제품명",
"unit": "",
"visible": false,
"editable": true,
"from": "둘 다"
},
{
"key": "source_product_code",
"name_ko": "원천 제품코드",
"unit": "",
"visible": false,
"editable": true,
"from": "둘 다"
}
]
},
{
"key": "rates",
"name_ko": "제비율",
"summary": "법정경비·일반관리비·이윤·부가세 요율 한 표. 원본 19 벌을 한 줄씩 펴서 모은다.",
"sources": [
"rates::variables/* (19 벌)"
],
"rows_note": "한 값짜리 8 + 구간표 11 벌을 펴서 약 190 줄",
"columns": [
{
"key": "item_key",
"name_ko": "항목 키",
"unit": "",
"visible": false,
"editable": true,
"from": "원본 키"
},
{
"key": "item_name",
"name_ko": "항목",
"unit": "",
"visible": true,
"editable": true,
"from": "이름표"
},
{
"key": "variant",
"name_ko": "벌",
"unit": "",
"visible": true,
"editable": true,
"from": "새 칸",
"note": "한 항목이 표를 둘 가진 경우 — 일반관리비(토목/전문) · 기계 지급보증(종합/전문) · 환경보전비(임도 후보/전 공종)."
},
{
"key": "work_type",
"name_ko": "공사 종류",
"unit": "",
"visible": true,
"editable": true,
"from": "6 벌"
},
{
"key": "amount_bracket",
"name_ko": "금액 구간",
"unit": "",
"visible": true,
"editable": true,
"from": "9 벌",
"note": "⚠ **축이 넷인데 한 칸으로 접힌다** — 추정금액(고용) · 추정가격(하도급·일반관리비·이윤) · 직접공사비(간접노무·기타경비·이행보증) · 대상액(안전관리비). 어느 금액인지는 곁 칸이 말한다."
},
{
"key": "bracket_basis",
"name_ko": "구간이 무슨 금액인가",
"unit": "",
"visible": true,
"editable": true,
"from": "새 칸",
"note": "위 칸을 접으면 사라지는 것 — 반드시 함께 둔다."
},
{
"key": "duration_bracket",
"name_ko": "공사기간 구간",
"unit": "",
"visible": true,
"editable": true,
"from": "2 벌"
},
{
"key": "grade",
"name_ko": "등급",
"unit": "",
"visible": true,
"editable": true,
"from": "고용보험만"
},
{
"key": "year",
"name_ko": "연도",
"unit": "",
"visible": true,
"editable": true,
"from": "국민연금만"
},
{
"key": "rate_percent",
"name_ko": "요율",
"unit": "%",
"visible": true,
"editable": true,
"from": "18 벌",
"note": "이행보증은 요율이 아니라 산식이라 이 칸이 빈다."
},
{
"key": "base_amount_krw",
"name_ko": "기초액",
"unit": "원",
"visible": true,
"editable": true,
"from": "안전관리비만"
},
{
"key": "formula",
"name_ko": "산식",
"unit": "",
"visible": true,
"editable": true,
"from": "이행보증만"
},
{
"key": "base",
"name_ko": "밑수",
"unit": "",
"visible": true,
"editable": true,
"from": "19 벌 다",
"note": "이 요율을 어디에 곱하는가."
},
{
"key": "minimum_krw",
"name_ko": "적용 하한",
"unit": "원",
"visible": true,
"editable": true,
"from": "3 벌",
"note": "이 금액에 못 미치면 줄이 안 선다 — 안전관리비 2천만 · 환경보전비 1억 · 퇴직공제 1억."
},
{
"key": "note",
"name_ko": "비고",
"unit": "",
"visible": true,
"editable": true,
"from": "원본 곁글"
}
]
},
{
"key": "machine",
"name_ko": "기계경비",
"summary": "기종 한 줄 + 시간당 단가. ⚠ **한 표로 안 모인다** — 시간당 단가가 다섯 표를 물어 나오는 계산값이다.",
"sources": [
"mach_base::variables/mach_price/records",
"mach_base::variables/mach_loss_coef/records",
"mach_base::variables/mach_fuel_rate/parsed_records",
"mach_base::variables/mach_operator_map/explicit_mappings",
"mach_base::variables/mach_rock_adj/rules",
"machine_operating::records"
],
"rows_note": "취득가 613 줄이 뼈대 · 손료계수 387 · 연료 214 · 조종원 121 이 붙는다(안 붙는 줄은 빈 칸)",
"columns": [
{
"key": "machine_code",
"name_ko": "기계코드",
"unit": "",
"visible": true,
"editable": true,
"from": "취득가"
},
{
"key": "machine_name",
"name_ko": "기계명",
"unit": "",
"visible": true,
"editable": true,
"from": "취득가"
},
{
"key": "specification",
"name_ko": "규격",
"unit": "",
"visible": true,
"editable": true,
"from": "취득가"
},
{
"key": "price_thousand_krw",
"name_ko": "취득가",
"unit": "천원",
"visible": true,
"editable": true,
"from": "취득가",
"note": "⭐ 밑값."
},
{
"key": "loss_coefficient_per_hour",
"name_ko": "시간당 손료계수",
"unit": "",
"visible": true,
"editable": true,
"from": "손료계수",
"note": "⭐ 밑값."
},
{
"key": "fuel_type",
"name_ko": "연료 종류",
"unit": "",
"visible": true,
"editable": true,
"from": "연료"
},
{
"key": "fuel_rate_l_per_hour",
"name_ko": "시간당 연료량",
"unit": "L",
"visible": true,
"editable": true,
"from": "연료",
"note": "⭐ 밑값."
},
{
"key": "misc_material_percent",
"name_ko": "잡재료비",
"unit": "연료비의 %",
"visible": true,
"editable": true,
"from": "연료",
"note": "⭐ 밑값."
},
{
"key": "operator_occupation_code",
"name_ko": "조종원 직종",
"unit": "",
"visible": true,
"editable": true,
"from": "조종원",
"note": "⭐ 밑값 — 노임 표를 가리킨다."
},
{
"key": "operator_person_per_day",
"name_ko": "조종원",
"unit": "인/일",
"visible": true,
"editable": true,
"from": "조종원",
"note": "⭐ 밑값."
},
{
"key": "rock_adjust_pct",
"name_ko": "암 작업 할증",
"unit": "%",
"visible": true,
"editable": true,
"from": "암석보정",
"note": "⭐ 밑값 — 기계 묶음별."
},
{
"key": "hourly_loss_krw",
"name_ko": "시간당 손료",
"unit": "원",
"visible": true,
"editable": false,
"from": "계산",
"note": "취득가 × 1,000 × 손료계수."
},
{
"key": "hourly_fuel_krw",
"name_ko": "시간당 연료·잡재료",
"unit": "원",
"visible": true,
"editable": false,
"from": "계산",
"note": "연료량 × 유가 × (1 + 잡재료비율). 유가는 **유가 표**에서 온다."
},
{
"key": "hourly_operator_krw",
"name_ko": "시간당 조종원",
"unit": "원",
"visible": true,
"editable": false,
"from": "계산",
"note": "일 노임 ÷ 8 × 제수당 계수. 일 노임은 **노임 표**에서 온다."
},
{
"key": "hourly_total_krw",
"name_ko": "시간당 기계경비",
"unit": "원",
"visible": true,
"editable": false,
"from": "계산",
"note": "손료 + 운전경비. 수송비는 여기 안 든다."
}
]
}
],
"machine_cost_chain": {
"note": "기계경비 = 기계손료 + 운전경비 + 수송비(품셈 8-1-6). ⭐ **밑값만 고칠 수 있다** — 계산값은 밑값이 바뀌면 따라 움직인다.",
"steps": [
{
"step": "① 손료",
"formula": "취득가(천원) × 1,000 × 시간당 손료계수",
"inputs": [
"취득가",
"시간당 손료계수"
],
"note": "손료계수는 상각·정비·관리 계수의 합이다. 그 셋과 내용시간·연간표준가동시간도 밑값이라 함께 보인다."
},
{
"step": "② 운전경비 — 연료",
"formula": "시간당 연료량(L) × 유가(원/L) × (1 + 잡재료비율)",
"inputs": [
"시간당 연료량",
"잡재료비",
"유가 표"
],
"note": "유가는 이 표에 없다 — **유가 표**에서 온다. 그래서 유가를 고치면 모든 기종이 움직인다."
},
{
"step": "③ 운전경비 — 조종원",
"formula": "일 노임 ÷ 8시간 × 제수당 계수 × 조종원(인/일)",
"inputs": [
"조종원 직종",
"조종원 인/일",
"노임 표"
],
"note": "일 노임은 이 표에 없다 — **노임 표**에서 온다. 8시간은 품셈 8-1-6 단서다."
},
{
"step": "④ 암 작업 할증",
"formula": "위 값 × (1 + 암 할증률)",
"inputs": [
"암 작업 할증"
],
"note": "기계 묶음별로 붙는다(불도저·굴착기·덤프)."
},
{
"step": "⑤ 시간당 기계경비",
"formula": "① + ② + ③ (④ 를 걸친 뒤)",
"inputs": [],
"note": "⚠ **수송비는 안 든다** — 「회당」으로 서는 별개 공종(산림품셈 10-4)이다."
}
],
"editable_rule": "밑값 = 취득가 · 손료계수(와 그 속 계수들) · 연료량 · 잡재료비율 · 조종원 직종과 인수 · 암 할증률. 계산값 = 시간당 손료 · 시간당 연료 · 시간당 조종원 · 시간당 기계경비. ⚠ 계산값을 손으로 고치면 밑값과 어긋나 되짚을 수 없게 된다 — 화면에서 막는다.",
"cross_table": "기계 표는 **유가 표와 노임 표를 문다**. 그 둘이 갱신되면 기계 시간당 단가가 다 바뀐다 — 갱신 차례는 노임·유가가 먼저, 기계가 나중이다."
},
"merge_findings": {
"renamed_same_column": [
{
"where": "노임",
"columns": [
"labor_const.reliability",
"labor_mfg.source_flag"
],
"verdict": "합치지 않음",
"why": "둘 다 원문 별표지만 뜻이 다르다 — 건설업은 「조사현장 5개 미만(*)·미조사(**)」로 뜻풀이가 있고, 제조업 `*`(16 줄)는 원본에 뜻풀이가 없다. 한 칸으로 묶으면 뜻이 섞인다."
},
{
"where": "유가",
"columns": [
"oil.value",
"oil_regional.value"
],
"verdict": "합침",
"why": "둘 다 원/L 단가다. 이름만 `value` 로 같고 뜻도 같다 — 「단가」 한 칸으로 묶는다."
}
],
"one_side_only": [
{
"where": "노임",
"only_in": "건설업",
"columns": [
"hours_per_day",
"reliability"
]
},
{
"where": "노임",
"only_in": "제조업",
"columns": [
"industry",
"variation_coefficient",
"source_flag"
]
},
{
"where": "유가",
"only_in": "시도판",
"columns": [
"sido_code",
"sido_name"
],
"note": "전국평균 판은 시도코드 `00`·시도명 「전국」으로 맞춘다."
},
{
"where": "제비율",
"only_in": "일부 벌",
"columns": [
"grade",
"year",
"base_amount_krw",
"formula",
"duration_bracket"
],
"note": "19 벌이 축을 제각각 쓴다 — 안 쓰는 벌에서는 빈 칸이다."
}
],
"unit_or_axis_mismatch": [
{
"where": "제비율",
"what": "금액 구간 축이 넷",
"detail": "추정금액(고용보험) · 추정가격(하도급보증·일반관리비·이윤) · 직접공사비(간접노무비·기타경비·이행보증) · 대상액(안전관리비). 한 칸으로 접으면 「어느 금액인가」가 사라지므로 `bracket_basis` 칸을 함께 둔다."
},
{
"where": "제비율",
"what": "이행보증만 요율이 아니라 산식",
"detail": "`(직접공사비 × 0.0108%) × 공사연수` 꼴이라 `rate_percent` 가 빈다. `formula` 칸으로 받는다."
},
{
"where": "기계",
"what": "취득가는 천원, 나머지 금액은 원",
"detail": "원본이 천원 단위다. 한 표에 섞이면 1,000배 틀린다 — 열 이름에 단위를 못박는다."
},
{
"where": "노임",
"what": "단위는 어긋나지 않음",
"detail": "둘 다 `KRW/day` 다(건설 132 · 제조 129 전수 확인)."
}
],
"collision": [
{
"where": "노임",
"what": "직종코드 공간이 다르다",
"detail": "건설업은 네 자리(1001~), 제조업은 한두 자리(1·2·3…). **지금은 겹치는 코드가 0** 이지만 같은 칸에 넣으면 나중에 부딪힌다 — `source` 칸을 반드시 함께 둔다."
},
{
"where": "유가",
"what": "「전국」이 두 줄 선다",
"detail": "전국평균 판(휘발유 1,863.25 · 경유 1,846.39 · 2026-08-14)과 시도판 코드 `00`(1,858.93 · 1,843.79 · 2026-09-09)이 **값도 기준일도 다르다**. ⚠ 어느 쪽을 쓸지는 판정이 필요하다 — 이름표는 두 줄을 그대로 보이고 `source` 로 가른다."
}
]
},
"row_keys": [
{
"table": "labor",
"name_ko": "노임",
"verdict": "있음(묶어야 함)",
"key": [
"source",
"occupation_code"
],
"checked": "건설업 132 줄·제조업 129 줄 각각 직종코드 겹침 0(전수). 다만 코드 공간이 달라(건설 네 자리 1001~ · 제조 한두 자리 1·2·3) **출처 칸과 묶어야** 한 표에서 안 부딪힌다."
},
{
"table": "material",
"name_ko": "자재 단가",
"verdict": "있음",
"key": [
"item_code"
],
"checked": "6,999 줄 물품코드 겹침 0(전수). 그대로 열쇠로 쓸 수 있다."
},
{
"table": "oil",
"name_ko": "유가",
"verdict": "없음 — 묶어야 함",
"key": [
"fuel",
"source",
"region_code"
],
"checked": "원본이 유종마다 따로 선 표라 줄에는 시도코드뿐이다(유종별 17 줄 겹침 0). 유종은 표 이름에, 출처는 판에 있어 **줄 안에 없다** — 셋을 묶어야 줄이 하나로 가려진다."
},
{
"table": "rates",
"name_ko": "제비율",
"verdict": "없음 — 조건으로만 가려짐",
"key": [
"item_key",
"variant",
"(조건 열들)"
],
"checked": "19 벌에 id 칸이 아예 없다. 벌마다 조건 묶음으로 줄이 가려지는 것은 확인했다(14 개 구간표 전수 겹침 0). 다만 **조건 축이 벌마다 다르다** — 등급·연도·공사 종류·공사기간·금액 구간 넷. 한 끈으로 쓰려면 항목키+벌+조건을 이어야 한다."
},
{
"table": "machine",
"name_ko": "기계경비",
"verdict": "있음",
"key": [
"machine_code"
],
"checked": "취득가 613 · 손료계수 387 · 연료 214 · 운전경비 92 **네 표 모두 기계코드 겹침 0**(전수). 취득가가 가장 넓어 뼈대가 되고 나머지가 붙는다."
}
],
"null_is_real": {
"columns": [
{
"table": "labor",
"column": "daily_wage_krw",
"rows": 30,
"shape": "칸이 아예 없음",
"why": "⭐ **가장 위험한 자리** — 건설업 14 줄·제조업 16 줄에 일 노임 칸이 없다. 건설업 쪽은 `reliability` 가 `**`(미조사)인 줄이라 **값이 없는 것이 제값**이다. 되돌리기 null 과 구별이 안 된다."
},
{
"table": "labor",
"column": "reliability · source_flag · variation_coefficient",
"rows": 221,
"shape": "칸이 아예 없음",
"why": "기호가 없는 줄 = 정상 공표라는 뜻이다. 빈 칸 자체가 값이다."
},
{
"table": "machine",
"column": "fuel_liters_per_hour · misc_material_percent · operator_person_days",
"rows": 12,
"shape": "**명시 null**",
"why": "운전경비 파생본이 원문에서 못 읽은 칸을 `null` 로 적어 두었다 — 0 으로 안 때운 자리다. 다섯 가운데 **진짜 `null` 이 든 유일한 자료**다."
},
{
"table": "machine",
"column": "economic_life_hours 외 손료계수 8 열",
"rows": 22,
"shape": "칸이 아예 없음",
"why": "손료계수는 있는데 그 속을 이루는 계수·내용시간이 원문에 없는 줄이다."
},
{
"table": "machine",
"column": "specification",
"rows": 255,
"shape": "칸이 아예 없음",
"why": "규격이 없는 기종(취득가 230 · 연료 25). 규격 없음이 제값이라 빈 칸으로 둔다."
},
{
"table": "material",
"column": "specification",
"rows": 2,
"shape": "빈 문자열",
"why": "null 이 아니라 `\"\"` 다. 되돌리기 null 과는 안 겹치나 **빈 칸 판정이 두 가지**가 된다."
}
],
"rule": "⚠ 되돌리기를 `null` 로 보내면 위 자리에서 뜻이 겹친다. 겹침을 푸는 길은 둘 — ㉠ 되돌리기를 값이 아니라 **짓**으로 보낸다(덮개에서 그 열을 지움) · ㉡ 되돌리기 낱말을 따로 둔다(`\"@revert\"` 같은). 어느 쪽이든 **원본의 `null`·빈 칸은 그대로 살려야** 한다 — 「미조사」가 「값 지움」으로 바뀌면 안 된다. ⚠ 자재 `specification` 은 `null` 이 아니라 빈 문자열이라 빈 칸 판정이 두 가지다."
},
"value_kinds": {
"value": {
"name_ko": "값",
@@ -93,6 +911,8 @@
}
},
"counts": {
"merged_tables": 5,
"merged_columns": 62,
"files": 34,
"tables": 91,
"columns": 455,
@@ -447,3 +447,94 @@ def test_미결은_미판정으로_표시된다(labels):
column = labels["synthetic_tables"]["@values"]["columns"][3]
assert column["key"] == "@undecided"
assert column["name_ko"] == "미판정"
MERGED_KEYS = ("labor", "material", "oil", "rates", "machine")
def test_기초단가_다섯이_다_있다(labels):
merged = {m["key"]: m for m in labels["merged_tables"]}
assert tuple(merged) == MERGED_KEYS
for entry in merged.values():
assert entry["name_ko"].strip()
assert entry["summary"].strip()
assert entry["columns"], entry["key"]
assert entry["sources"], entry["key"]
def test_합친_표의_열마다_이름과_고칠수있나가_있다(labels):
bad = []
for entry in labels["merged_tables"]:
for column in entry["columns"]:
if not column["name_ko"].strip():
bad.append(f"{entry['key']}/{column['key']} — 이름 없음")
if not isinstance(column.get("editable"), bool):
bad.append(f"{entry['key']}/{column['key']} — editable 없음")
assert not bad, bad
def test_계산값은_기계에만_있고_못_고친다(labels):
"""⭐ 밑값만 고칠 수 있다 — 계산값을 손으로 고치면 밑값과 어긋나 되짚을 수 없다."""
derived = {
entry["key"]: [c["key"] for c in entry["columns"] if not c["editable"]]
for entry in labels["merged_tables"]
}
assert derived["machine"] == [
"hourly_loss_krw",
"hourly_fuel_krw",
"hourly_operator_krw",
"hourly_total_krw",
]
for key in ("labor", "material", "oil", "rates"):
assert derived[key] == [], f"{key} 에 계산값이 생겼다"
def test_기계_계산_사슬이_다섯_걸음으로_적혀_있다(labels):
chain = labels["machine_cost_chain"]
assert len(chain["steps"]) == 5
for step in chain["steps"]:
assert step["step"].strip() and step["formula"].strip()
assert "밑값" in chain["editable_rule"] and "계산값" in chain["editable_rule"]
# 기계는 유가·노임 표를 문다 — 갱신 차례가 여기서 갈린다.
assert "유가" in chain["cross_table"] and "노임" in chain["cross_table"]
def test_열_맞대기_어긋남이_네_갈래로_적혀_있다(labels):
findings = labels["merge_findings"]
assert set(findings) == {
"renamed_same_column",
"one_side_only",
"unit_or_axis_mismatch",
"collision",
}
assert all(findings[k] for k in findings)
def test_줄_열쇠가_다섯_다_판정돼_있다(labels):
"""덮개가 원본 줄을 붙드는 끈 — 없는 자료는 「없음」으로 내고 지어내지 않는다."""
keys = {r["table"]: r for r in labels["row_keys"]}
assert set(keys) == set(MERGED_KEYS)
for entry in keys.values():
assert entry["verdict"].strip() and entry["key"] and entry["checked"].strip()
assert keys["material"]["key"] == ["item_code"]
assert keys["machine"]["key"] == ["machine_code"]
# 한 칸으로는 안 되는 둘 — 묶어야 한다.
assert len(keys["labor"]["key"]) > 1
assert keys["oil"]["verdict"].startswith("없음")
assert keys["rates"]["verdict"].startswith("없음")
def test_null_이_제값인_열이_적혀_있다(labels):
"""되돌리기 null 과 원본의 「값 없음」이 겹치는 자리 — 겹치면 미조사가 값 지움으로 바뀐다."""
block = labels["null_is_real"]
assert block["columns"]
for row in block["columns"]:
assert row["table"] in MERGED_KEYS
assert row["column"].strip() and row["why"].strip()
assert isinstance(row["rows"], int)
# 가장 위험한 자리 — 노임 일 노임이 없는 줄(미조사)
wage = [
r for r in block["columns"] if r["table"] == "labor" and "daily_wage_krw" in r["column"]
]
assert wage and wage[0]["rows"] == 30
assert "되돌리기" in block["rule"]
+65 -1
View File
@@ -24,9 +24,34 @@ SOURCE = PROJECT_ROOT / "Z01_MasterData" / "Z01_MasterData_UI_Cells.ts"
_RUNNER = """
import { writeFileSync } from "node:fs";
import {
cellText, clampPage, columnTitle, latestOnly, pageCount, rowCells, shownColumns,
cellInfo, cellText, clampPage, columnTitle, latestOnly, pageCount, parseCellInput, rowCells,
shownColumns,
} from "./Z01_MasterData_UI_Cells.js";
// 기초단가 — 표 수준 editable·locked·formula · 줄 수준 @id·@overrides·@formula (2026-09-15 브레인)
const meta = {
editable: ["daily_wage_krw"],
locked: { hourly_krw: "계산으로 나옴" },
formula: { hourly_krw: "기계경비 = 손료 + 연료 + 노무" },
};
const machineRow = {
"@id": "M-001",
daily_wage_krw: 300000,
hourly_krw: 12340,
"@overrides": { daily_wage_krw: { original: 250000, value: 300000 } },
"@formula": { hourly_krw: "12,340 = 8,200 + 2,140 + 2,000" },
};
// 판정은 서버 state 만 — edited · source_changed · orphan (화면이 original≠current 로 따로 안 셈)
const withState = (state, current) => ({
"@id": "M-002",
daily_wage_krw: 300000,
"@overrides": { daily_wage_krw: { original: 250000, value: 300000, current, state } },
});
// 값은 같아도 서버가 바뀜이라 하면 바뀜 · 값이 달라도 서버가 edited 라 하면 edited
const changedRow = withState("source_changed", 250000);
const sameRow = withState("edited", 270000);
const orphanRow = withState("orphan", undefined);
const wait = (ms, value, fail) =>
new Promise((ok, no) => setTimeout(() => (fail ? no(new Error(value)) : ok(value)), ms));
const fetchLatest = latestOnly(wait);
@@ -62,6 +87,21 @@ writeFileSync(process.argv[2], JSON.stringify({
race: race.map((v) => v ?? null),
raceError: raceError.map((v) => v ?? null),
latestError,
info: [
cellInfo(meta, machineRow, "daily_wage_krw"),
cellInfo(meta, machineRow, "hourly_krw"),
cellInfo({}, { name: "x" }, "name"),
cellInfo(meta, changedRow, "daily_wage_krw"),
cellInfo(meta, sameRow, "daily_wage_krw"),
cellInfo(meta, orphanRow, "daily_wage_krw"),
],
parsed: [
parseCellInput("12,340", 100),
parseCellInput("abc", 100),
parseCellInput("", 100),
parseCellInput("보통인부2", "보통인부"),
parseCellInput("3.5", null),
].map((v) => (v === undefined ? "invalid" : v)),
}));
"""
@@ -109,3 +149,27 @@ def test_칸_글자와_숨김_열(tmp_path: Path) -> None:
assert got["race"] == [None, "new"]
assert got["raceError"] == [None, "new"]
assert got["latestError"] == "thrown boom"
# 고칠 수 있는 칸 · 고쳐짐이면 원래 값 · 못 고치는 칸은 까닭과 식 · 줄마다 값 넣은 식
wage, hourly, plain, changed, same, orphan = got["info"]
# state 가 안 온 고친 칸은 화면이 때우지 않고 「missing」으로 드러냄(서버 버그)
assert wage == {
"editable": True,
"reason": None,
"overridden": True,
"original": 250000,
"state": "missing",
"rule": None,
"filled": None,
}
# 원본이 바뀐 고친 칸 — 고친 값은 그대로 쓰되 또렷이 알림 · 지금 원본 값을 곁들임(브레인)
assert changed["state"] == "source_changed" and changed["current"] == 250000
assert same["state"] == "edited" and "current" not in same
assert orphan["state"] == "orphan"
assert hourly["state"] is None and plain["state"] is None
assert hourly["editable"] is False and hourly["reason"] == "계산으로 나옴"
assert hourly["overridden"] is False and "original" not in hourly
assert hourly["rule"] == "기계경비 = 손료 + 연료 + 노무"
assert hourly["filled"] == "12,340 = 8,200 + 2,140 + 2,000"
assert plain["editable"] is False and plain["reason"] is None and plain["rule"] is None
# 수 칸은 쉼표를 떼고 수로 · 못 읽으면 저장 안 함 · 글자 칸은 글자 그대로 · 빈 칸에 수를 적으면 수
assert got["parsed"] == [12340, "invalid", "invalid", "보통인부2", 3.5]
+47 -3
View File
@@ -140,10 +140,54 @@ export const ui_locales_b3 = {
B01_Dashboard_SystemSettings: ["시스템 설정", "System Settings"],
B01_Dashboard_MasterData: ["마스터 데이터", "Master Data"],
Z01_MasterData_Title: ["마스터 데이터", "Master Data"],
Z01_MasterData_Subtitle: [
"로직·기초값을 표로 봄 — 읽기 전용",
"Browse logic and base values — read only",
Z01_MasterData_Tree: ["마스터 트리", "Master tree"],
Z01_MasterData_BasePrices: ["기초단가", "Base prices"],
Z01_MasterData_Base_labor: ["노임단가", "Labor"],
Z01_MasterData_Base_machine: ["기계단가", "Machine"],
Z01_MasterData_Base_material: ["자재단가", "Material"],
Z01_MasterData_Base_oil: ["유가", "Fuel"],
Z01_MasterData_Base_rate: ["요율", "Rates"],
Z01_MasterData_Overrides: ["고친 것 모아 보기", "Edited values"],
Z01_MasterData_Rate_Notice: [
"법이 정한 값 — 고치면 갱신 때 주인을 잃기 쉬움",
"Values set by law — edits may lose their row when the law is updated",
],
Z01_MasterData_Edit_Can: [
"고칠 수 있는 칸 — 눌러 고치고 Enter 로 저장 (Esc 취소)",
"Editable — click, type, Enter to save (Esc cancels)",
],
Z01_MasterData_Edit_Locked: ["못 고치는 칸", "Not editable"],
Z01_MasterData_Edit_NotListed: ["고칠 수 있는 칸 목록에 없음", "Not in the editable list"],
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_Saved: ["저장했음 — 덮개 파일에 쌓임", "Saved to the override file"],
Z01_MasterData_Edit_Reverted: ["덮개를 뺐음 — 원본 값을 씀", "Override removed — using source"],
Z01_MasterData_Edit_Failed: ["저장 못 함", "Save failed"],
Z01_MasterData_Formula: ["산출근거", "Basis"],
Z01_MasterData_State_Edited: ["고쳐짐 — 원래 값 {value}", "Edited — original {value}"],
Z01_MasterData_State_SourceChanged: [
"⚠ 고친 뒤 원본이 바뀜 — 고친 값을 그대로 씀 · 새 원본 값 {value}",
"⚠ Source changed after edit — edited value still used · new source {value}",
],
Z01_MasterData_State_Orphan: [
"⚠ 주인 없음 — 갱신으로 그 줄이 사라짐",
"⚠ Orphan — the row disappeared on update",
],
Z01_MasterData_State_Missing: [
"⚠ 서버가 판정(state)을 안 줌 — 버그",
"⚠ Server sent no state — bug",
],
Z01_MasterData_List_State: ["상태", "State"],
Z01_MasterData_List_Row: ["줄", "Row"],
Z01_MasterData_List_Column: ["칸", "Column"],
Z01_MasterData_List_Value: ["고친 값", "Edited value"],
Z01_MasterData_List_Original: ["고칠 때 원본", "Source when edited"],
Z01_MasterData_List_Current: ["지금 원본", "Current source"],
Z01_MasterData_List_SourceChanged: ["⚠ 원본 바뀜", "⚠ Source changed"],
Z01_MasterData_List_Orphan: ["⚠ 주인 없음", "⚠ Orphan"],
Z01_MasterData_List_Edited: ["고쳐짐", "Edited"],
Z01_MasterData_AdminOnly: ["시스템 관리자만 볼 수 있음", "System administrators only"],
Z01_MasterData_LoadFailed: ["마스터 데이터를 못 읽음", "Failed to load master data"],
Z01_MasterData_PickTable: ["왼쪽에서 표를 고를 것", "Pick a table on the left"],
+19 -17
View File
@@ -13,9 +13,12 @@ const PROGRESS_OVERLAY_POSITION_KEY = "frd_workflow_progress_overlay_pos";
export interface WorkflowOverlayOptions {
title: string;
progressContent: HTMLElement;
/** 없으면 진행단계 패널을 안 세움 — 워크플로 밖 화면(Z01 마스터 데이터)용. */
progressContent?: HTMLElement;
optionsContent?: HTMLElement;
showTitlePanel?: boolean;
/** false 면 제목 줄 오른쪽 프로젝트 이름을 안 붙임 — 프로젝트와 무관한 화면용. */
showProjectName?: boolean;
onOptionsOpenChange?: (isOpen: boolean) => void;
}
@@ -239,15 +242,6 @@ export function createWorkflowOverlays(options: WorkflowOverlayOptions): Workflo
const root = document.createElement("div");
root.className = "ui-workflow-overlay";
const progressBody = document.createElement("div");
progressBody.append(options.progressContent);
const progressPanel = createPanel(
"progress",
t("Workflow_Progress_Title"),
progressBody,
PROGRESS_OVERLAY_STATE_KEY,
);
let setTitleOpen = (_isOpen: boolean): void => {};
if (options.showTitlePanel !== false) {
const titleBody = document.createElement("div");
@@ -258,16 +252,24 @@ export function createWorkflowOverlays(options: WorkflowOverlayOptions): Workflo
titleBody,
TITLE_OVERLAY_STATE_KEY,
options.onOptionsOpenChange,
createProjectNameTag(),
options.showProjectName === false ? undefined : createProjectNameTag(),
);
root.append(titlePanel.root);
setTitleOpen = titlePanel.setOpen;
}
root.append(progressPanel.root);
let setProgressOpen = (_isOpen: boolean): void => {};
if (options.progressContent) {
const progressBody = document.createElement("div");
progressBody.append(options.progressContent);
const progressPanel = createPanel(
"progress",
t("Workflow_Progress_Title"),
progressBody,
PROGRESS_OVERLAY_STATE_KEY,
);
root.append(progressPanel.root);
setProgressOpen = progressPanel.setOpen;
}
return {
root,
setTitleOpen,
setProgressOpen: progressPanel.setOpen,
};
return { root, setTitleOpen, setProgressOpen };
}