Merge remote-tracking branch 'origin/dev' into sub_desktop_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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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