Merge remote-tracking branch 'origin/dev' into main_laptop_1
This commit is contained in:
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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] });
|
||||
}
|
||||
@@ -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"]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user