Merge remote-tracking branch 'origin/dev' into main_laptop_1
This commit is contained in:
@@ -26,6 +26,7 @@ const FOOTERLESS_ROUTES: readonly RoutePath[] = [
|
||||
ROUTES.B09_ESTIMATION,
|
||||
ROUTES.B10_PAYMENT,
|
||||
ROUTES.B11_STATUS,
|
||||
ROUTES.Z01_MASTER_DATA,
|
||||
];
|
||||
|
||||
function bootstrap(): void {
|
||||
|
||||
@@ -59,6 +59,8 @@ const routeTable: Partial<Record<RoutePath, () => Promise<PageRenderer>>> = {
|
||||
(await import("../B11_Status/B11_Status_UI_Page")).renderB11Status,
|
||||
[ROUTES.B11_LOADING]: async () =>
|
||||
(await import("../B11_Status/B11_Status_UI_Loading")).renderB11Loading,
|
||||
[ROUTES.Z01_MASTER_DATA]: async () =>
|
||||
(await import("../Z01_MasterData/Z01_MasterData_UI_Page")).renderZ01MasterData,
|
||||
};
|
||||
|
||||
/** 로그인 여부 (토큰 존재 확인) */
|
||||
|
||||
@@ -130,6 +130,19 @@ async function loadRoleData(state: DashboardState): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
function buildSystemSettingsPanel(): HTMLElement {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "b01-dashboard__actions";
|
||||
wrap.append(
|
||||
createButton({
|
||||
label: L("B01_Dashboard_MasterData"),
|
||||
variant: "ghost",
|
||||
onClick: () => navigateTo(ROUTES.Z01_MASTER_DATA),
|
||||
}),
|
||||
);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function buildPage(state: DashboardState): HTMLElement {
|
||||
// 제목·여백은 공용 템플릿을 따른다 (2026-09-06 사용자 지시) — B02 등 다른 화면과 같은 모양.
|
||||
const page = document.createElement("div");
|
||||
@@ -138,8 +151,15 @@ function buildPage(state: DashboardState): HTMLElement {
|
||||
grid.className = "b01-dashboard__grid";
|
||||
|
||||
if (state.user.role === "SYSTEM_ADMIN") {
|
||||
// 리소스 현황 오른쪽에 시스템 설정 — 앞으로 다른 설정 단추도 이 칸에 듦 (2026-09-15 브레인 Z01).
|
||||
const topRow = document.createElement("div");
|
||||
topRow.className = "b01-dashboard__top-row";
|
||||
topRow.append(
|
||||
section(L("B01_Dashboard_Resources"), buildResourcePanel(state.resources)),
|
||||
section(L("B01_Dashboard_SystemSettings"), buildSystemSettingsPanel()),
|
||||
);
|
||||
grid.append(
|
||||
section(L("B01_Dashboard_Resources"), buildResourcePanel(state.resources), true),
|
||||
topRow,
|
||||
section(L("B01_Dashboard_Projects"), projectTable(state.allProjects, state.user), true, [
|
||||
createButton({ label: "+", onClick: () => navigateTo(ROUTES.B02_PROJ_REGISTER) }),
|
||||
]),
|
||||
|
||||
@@ -31,6 +31,14 @@
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
/* 리소스 현황 | 시스템 설정 — 설정 칸은 단추만 들어 좁게. */
|
||||
.b01-dashboard__top-row {
|
||||
grid-column: 1 / -1;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 3fr) minmax(0, 1fr);
|
||||
gap: var(--spacing-24);
|
||||
}
|
||||
|
||||
.b01-dashboard__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -280,7 +288,8 @@
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.b01-dashboard__header,
|
||||
.b01-dashboard__grid {
|
||||
.b01-dashboard__grid,
|
||||
.b01-dashboard__top-row {
|
||||
display: block;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/* =============================================================================
|
||||
* Z01_MasterData_Api_Fetch.ts
|
||||
* 마스터 데이터 읽기 — 서버 `Z01_MasterData_Router.py`(랩탑 메인) · 시스템 관리자만
|
||||
*
|
||||
* 응답 모양은 2026-09-15 브레인이 못박은 약속 그대로. 읽기 전용 — 고치기는 다음 차례.
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import type { MasterColumn } from "./Z01_MasterData_UI_Cells";
|
||||
|
||||
export interface MasterTable {
|
||||
id: string;
|
||||
label: string;
|
||||
key: string;
|
||||
row_count: number;
|
||||
}
|
||||
|
||||
export interface MasterFile {
|
||||
id: string;
|
||||
label: string;
|
||||
key: string;
|
||||
tables: MasterTable[];
|
||||
}
|
||||
|
||||
export interface MasterGroup {
|
||||
key: "logic" | "base" | "byproduct" | "seed";
|
||||
label: string;
|
||||
files: MasterFile[];
|
||||
}
|
||||
|
||||
export interface MasterRows {
|
||||
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" });
|
||||
const data = (await response.json()) as { detail?: string } & T;
|
||||
if (!response.ok) throw new Error(data.detail ?? "Request failed");
|
||||
return data;
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
return request<MasterRows>(`/master-data/rows?${params}`);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/* =============================================================================
|
||||
* Z01_MasterData_UI_Cells.ts
|
||||
* 마스터 표 칸 글자 — 숨김 열 · 열 제목 · 칸 값 · 쪽 수 (import 없음 · 시험이 그대로 돌림)
|
||||
*
|
||||
* 한글 이름은 API 가 `label` 로 줌(이름표 파일 `resources/data_master_labels/`) — 화면은
|
||||
* 사전을 두지 않고 받은 대로 보임. 두 벌이 되면 어긋나기 때문(2026-09-15 브레인 Z01 ①).
|
||||
* ========================================================================== */
|
||||
|
||||
export interface MasterColumn {
|
||||
key: string;
|
||||
label: string;
|
||||
unit?: string | null;
|
||||
/** 내부 id · sha · 생성시각 — 기본으로 안 보임 */
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
export function shownColumns(columns: MasterColumn[], showHidden: boolean): MasterColumn[] {
|
||||
return showHidden ? columns : columns.filter((column) => !column.hidden);
|
||||
}
|
||||
|
||||
export function columnTitle(column: MasterColumn): string {
|
||||
return column.unit ? `${column.label} (${column.unit})` : column.label;
|
||||
}
|
||||
|
||||
/** 단위가 붙은 수만 쉼표 — 연도·코드 같은 수(단위 없음)에 쉼표가 끼면 틀린 값처럼 보임. */
|
||||
export function cellText(value: unknown, unit?: string | null): string {
|
||||
if (value === null || value === undefined) return "";
|
||||
if (typeof value === "boolean") return value ? "예" : "아니오";
|
||||
if (typeof value === "number" && unit) {
|
||||
return value.toLocaleString("ko-KR", { maximumFractionDigits: 10 });
|
||||
}
|
||||
if (typeof value === "object") return JSON.stringify(value);
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function pageCount(total: number, size: number): number {
|
||||
return Math.max(1, Math.ceil(total / size));
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
/* =============================================================================
|
||||
* Z01_MasterData_UI_Page.ts
|
||||
* 마스터 데이터 — 좌 트리(갈래 → 파일 → 표) / 우 고른 표의 실제 줄(쪽 나누기 · 검색)
|
||||
*
|
||||
* 시스템 관리자만(서버도 require_system_admin 으로 다시 막음) · 읽기 전용부터
|
||||
* (2026-09-15 브레인 Z01 — 마스터를 사람이 보고 고칠 수 있게 먼저 만들고 그 위에 일위대가 재조립).
|
||||
* ========================================================================== */
|
||||
|
||||
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 { t as L } from "@ui/ui_template_locale";
|
||||
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,
|
||||
} from "./Z01_MasterData_Api_Fetch";
|
||||
import { cellText, columnTitle, pageCount, shownColumns } from "./Z01_MasterData_UI_Cells";
|
||||
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") {
|
||||
showToast(L("Z01_MasterData_AdminOnly"), "error");
|
||||
navigateTo(ROUTES.B01_ACCOUNT);
|
||||
return;
|
||||
}
|
||||
showLoadingOverlay();
|
||||
let groups: MasterGroup[] = [];
|
||||
try {
|
||||
groups = await fetchMasterTree();
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : L("Z01_MasterData_LoadFailed"), "error");
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
root.innerHTML = "";
|
||||
root.append(buildPage(groups));
|
||||
}
|
||||
|
||||
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",
|
||||
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,
|
||||
);
|
||||
for (const table of file.tables) {
|
||||
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 requestSeq = 0;
|
||||
let searchTimer = 0;
|
||||
|
||||
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 pageInfo = el("span", { className: "z01-master__page-info" });
|
||||
const prev = createButton({
|
||||
label: L("Z01_MasterData_Prev"),
|
||||
variant: "ghost",
|
||||
onClick: () => turn(-1),
|
||||
});
|
||||
const next = createButton({
|
||||
label: L("Z01_MasterData_Next"),
|
||||
variant: "ghost",
|
||||
onClick: () => turn(1),
|
||||
});
|
||||
const toolbar = el("div", {
|
||||
className: "z01-master__toolbar",
|
||||
children: [search.root, hiddenLabel],
|
||||
});
|
||||
const pager = el("div", { className: "z01-master__pager", children: [prev, pageInfo, next] });
|
||||
const root = el("section", {
|
||||
className: "z01-master__panel",
|
||||
children: [el("div", { children: [title, titleKey] }), toolbar, grid, pager],
|
||||
});
|
||||
toolbar.hidden = true;
|
||||
pager.hidden = true;
|
||||
|
||||
function turn(step: number): void {
|
||||
state.page += step;
|
||||
void load();
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
if (!state.file || !state.table) return;
|
||||
const mine = ++requestSeq;
|
||||
grid.classList.add("is-loading");
|
||||
try {
|
||||
const data = await fetchMasterRows({
|
||||
file: state.file.id,
|
||||
table: state.table.id,
|
||||
page: state.page,
|
||||
size: PAGE_SIZE,
|
||||
q: state.q,
|
||||
});
|
||||
if (mine !== requestSeq) return;
|
||||
last = data;
|
||||
draw();
|
||||
} catch (error) {
|
||||
if (mine === requestSeq) {
|
||||
showToast(error instanceof Error ? error.message : L("Z01_MasterData_LoadFailed"), "error");
|
||||
}
|
||||
} finally {
|
||||
if (mine === requestSeq) grid.classList.remove("is-loading");
|
||||
}
|
||||
}
|
||||
|
||||
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 column of columns) {
|
||||
const text = cellText(row[column.key], column.unit);
|
||||
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);
|
||||
pageInfo.textContent = `${state.page} / ${pages} ${L("Z01_MasterData_Page")} · ${last.total.toLocaleString("ko-KR")} ${L("Z01_MasterData_Rows")}`;
|
||||
prev.disabled = state.page <= 1;
|
||||
next.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 };
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
/* Z01 마스터 데이터 — 좌 트리 / 우 표 */
|
||||
.z01-master [hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.z01-master__content {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 300px) minmax(0, 1fr);
|
||||
gap: var(--spacing-24);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.z01-master__tree summary {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
cursor: pointer;
|
||||
color: var(--color-text-body);
|
||||
}
|
||||
|
||||
.z01-master__group > summary {
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.z01-master__file {
|
||||
margin-left: var(--spacing-12);
|
||||
}
|
||||
|
||||
.z01-master__key {
|
||||
margin-left: var(--spacing-4);
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-caption);
|
||||
font-weight: var(--font-weight-regular);
|
||||
}
|
||||
|
||||
.z01-master__note {
|
||||
margin: 0 var(--spacing-8) var(--spacing-4);
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.z01-master__table {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-8);
|
||||
width: 100%;
|
||||
padding: var(--spacing-4) var(--spacing-8) var(--spacing-4) var(--spacing-24);
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--color-text-body);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
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);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.z01-master__title {
|
||||
display: inline;
|
||||
color: var(--color-text);
|
||||
font-size: var(--text-body);
|
||||
}
|
||||
|
||||
.z01-master__toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-16);
|
||||
}
|
||||
|
||||
.z01-master__check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.z01-master__grid-wrap {
|
||||
max-height: calc(100vh - 300px);
|
||||
overflow: auto;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
}
|
||||
|
||||
.z01-master__grid-wrap.is-loading {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.z01-master__grid {
|
||||
border-collapse: collapse;
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.z01-master__grid th,
|
||||
.z01-master__grid td {
|
||||
max-width: 320px;
|
||||
padding: var(--spacing-8) var(--spacing-12);
|
||||
overflow: hidden;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
text-align: left;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.z01-master__grid th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.z01-master__grid th .z01-master__key {
|
||||
display: block;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.z01-master__pager {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-12);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.z01-master__content {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.z01-master__tree {
|
||||
position: static;
|
||||
margin-bottom: var(--spacing-24);
|
||||
}
|
||||
}
|
||||
@@ -116,6 +116,8 @@ export const ROUTES = {
|
||||
B11_STATUS: "b11-status",
|
||||
// 대시보드에서 B그룹으로 처음 들어갈 때 3D·등고선을 미리 받아 두는 준비 화면.
|
||||
B11_LOADING: "b11-loading",
|
||||
// 시스템 관리자 전용 — 로직·기초값 마스터 보기 (2026-09-15 브레인 Z01).
|
||||
Z01_MASTER_DATA: "z01-master-data",
|
||||
} as const;
|
||||
|
||||
export type RouteKey = keyof typeof ROUTES;
|
||||
@@ -138,6 +140,7 @@ export const PROTECTED_ROUTES: readonly RoutePath[] = [
|
||||
ROUTES.B10_PAYMENT,
|
||||
ROUTES.B11_STATUS,
|
||||
ROUTES.B11_LOADING,
|
||||
ROUTES.Z01_MASTER_DATA,
|
||||
];
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,273 @@
|
||||
"""마스터 이름표(`resources/data_master_labels/labels_2026-01-01.json`) 덮임 시험.
|
||||
|
||||
이름표는 **값을 안 담고 이름만 담는다** — 그래서 마스터가 늘거나 열이 바뀌면
|
||||
이름표가 조용히 뒤처진다. 이 시험이 그 어긋남을 잡는다.
|
||||
|
||||
- 마스터에 있는데 이름표에 없는 표·열 → 빨강
|
||||
- 이름표에 있는데 마스터에 없는 표·열(묵은 이름표) → 빨강
|
||||
- 갈래 나눔은 2026-09-15 브레인 갈래표(로직 17 · 기초값 9 · 부산물 4 · 씨앗 1)와 대조
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
LABELS_PATH = ROOT / "resources" / "data_master_labels" / "labels_2026-01-01.json"
|
||||
|
||||
#: 브레인 갈래 나눔표(2026-09-15). 폴더 장부(_manifest)는 그 31 밖이라 따로 센다.
|
||||
BRAIN_KIND_COUNTS = {"logic": 17, "base_value": 9, "byproduct": 4, "seed": 1}
|
||||
MANIFEST_PREFIX = "manifest_"
|
||||
|
||||
META_KEYS = {
|
||||
"schema_version",
|
||||
"dataset_id",
|
||||
"effective_date",
|
||||
"generated_at",
|
||||
"publication_date",
|
||||
"survey_month",
|
||||
"pum_edition",
|
||||
"dataset_version",
|
||||
"source_master_file",
|
||||
"source_dataset_version",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def labels() -> dict:
|
||||
return json.loads(LABELS_PATH.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _is_table(node) -> str | None:
|
||||
"""이름표 생성기와 **같은 잣대** — 화면이 표로 그릴 마디인가."""
|
||||
if isinstance(node, list) and node and all(isinstance(r, dict) for r in node):
|
||||
return "list"
|
||||
if isinstance(node, dict):
|
||||
values = list(node.values())
|
||||
dicts = [v for v in values if isinstance(v, dict)]
|
||||
if (
|
||||
len(values) >= 2
|
||||
and len(dicts) == len(values)
|
||||
and not any(
|
||||
isinstance(v.get("records"), list) or isinstance(v.get("rows"), list) for v in dicts
|
||||
)
|
||||
):
|
||||
keysets = [frozenset(v.keys()) for v in dicts]
|
||||
if len(set(keysets)) <= max(2, len(keysets) // 3 + 1):
|
||||
return "map"
|
||||
return None
|
||||
|
||||
|
||||
def _has_table(node, depth: int = 0) -> bool:
|
||||
if _is_table(node):
|
||||
return True
|
||||
if depth > 5 or not isinstance(node, dict):
|
||||
return False
|
||||
return any(_has_table(v, depth + 1) for v in node.values())
|
||||
|
||||
|
||||
def _collect(node, path, tables, values, depth: int = 0) -> None:
|
||||
shape = _is_table(node)
|
||||
if shape:
|
||||
tables.append(("/".join(path), shape, node))
|
||||
return
|
||||
if depth <= 5 and isinstance(node, dict) and any(_has_table(v) for v in node.values()):
|
||||
for key, value in node.items():
|
||||
_collect(value, path + [key], tables, values, depth + 1)
|
||||
return
|
||||
values.append("/".join(path))
|
||||
|
||||
|
||||
def _scan(path: Path) -> tuple[list, list]:
|
||||
doc = json.loads(path.read_text(encoding="utf-8"))
|
||||
tables, values = [], []
|
||||
for key, value in doc.items():
|
||||
if key in META_KEYS:
|
||||
continue
|
||||
if key == "variables" and isinstance(value, dict) and not _is_table(value):
|
||||
for sub_key, sub in value.items():
|
||||
_collect(sub, ["variables", sub_key], tables, values)
|
||||
else:
|
||||
_collect(value, [key], tables, values)
|
||||
return tables, values
|
||||
|
||||
|
||||
def _columns_of(node, shape: str) -> list[str]:
|
||||
records = node if shape == "list" else list(node.values())
|
||||
seen: list[str] = []
|
||||
for record in records:
|
||||
if not isinstance(record, dict):
|
||||
continue
|
||||
for key in record:
|
||||
if key not in seen:
|
||||
seen.append(key)
|
||||
return seen
|
||||
|
||||
|
||||
def test_이름표_파일이_읽힌다(labels):
|
||||
assert labels["dataset_id"] == "data_master_labels"
|
||||
assert labels["files"], "이름표에 파일이 하나도 없다"
|
||||
|
||||
|
||||
def test_이름표가_가리키는_파일이_다_있다(labels):
|
||||
missing = [f["path"] for f in labels["files"] if not (ROOT / f["path"]).is_file()]
|
||||
assert not missing, f"이름표가 없는 파일을 가리킨다: {missing}"
|
||||
|
||||
|
||||
def test_갈래_나눔이_브레인_표와_같다(labels):
|
||||
counted: dict[str, int] = {}
|
||||
for entry in labels["files"]:
|
||||
if entry["file_id"].startswith(MANIFEST_PREFIX):
|
||||
continue
|
||||
counted[entry["kind"]] = counted.get(entry["kind"], 0) + 1
|
||||
assert counted == BRAIN_KIND_COUNTS
|
||||
assert sum(counted.values()) == 31
|
||||
|
||||
|
||||
def test_갈래_이름이_다_풀려_있다(labels):
|
||||
known = set(labels["kinds"])
|
||||
used = {f["kind"] for f in labels["files"]}
|
||||
assert used <= known, f"뜻을 안 적은 갈래: {sorted(used - known)}"
|
||||
|
||||
|
||||
def test_마스터의_표가_이름표에_다_있다(labels):
|
||||
missing = []
|
||||
for entry in labels["files"]:
|
||||
tables, _ = _scan(ROOT / entry["path"])
|
||||
labelled = {t["key"] for t in entry["tables"]}
|
||||
for key, _shape, _node in tables:
|
||||
if key not in labelled:
|
||||
missing.append(f"{entry['file_id']}::{key}")
|
||||
assert not missing, f"이름표에 없는 표: {missing}"
|
||||
|
||||
|
||||
def test_이름표의_표가_마스터에_다_있다(labels):
|
||||
stale = []
|
||||
for entry in labels["files"]:
|
||||
tables, _ = _scan(ROOT / entry["path"])
|
||||
actual = {key for key, _s, _n in tables}
|
||||
for table in entry["tables"]:
|
||||
if table["key"] not in actual:
|
||||
stale.append(f"{entry['file_id']}::{table['key']}")
|
||||
assert not stale, f"마스터에 없는 묵은 이름표: {stale}"
|
||||
|
||||
|
||||
def test_마스터의_값_묶음이_이름표에_다_있다(labels):
|
||||
missing = []
|
||||
for entry in labels["files"]:
|
||||
_tables, values = _scan(ROOT / entry["path"])
|
||||
labelled = {v["key"] for v in entry["value_groups"]}
|
||||
for key in values:
|
||||
if key not in labelled:
|
||||
missing.append(f"{entry['file_id']}::{key}")
|
||||
assert not missing, f"이름표에 없는 값 묶음: {missing}"
|
||||
|
||||
|
||||
def test_표_이름이_비지_않았다(labels):
|
||||
blank = [
|
||||
f"{entry['file_id']}::{table['key']}"
|
||||
for entry in labels["files"]
|
||||
for table in entry["tables"]
|
||||
if not table["name_ko"].strip()
|
||||
]
|
||||
assert not blank, f"한글 이름이 빈 표: {blank}"
|
||||
|
||||
|
||||
def test_값_묶음_이름이_비지_않았다(labels):
|
||||
blank = [
|
||||
f"{entry['file_id']}::{group['key']}"
|
||||
for entry in labels["files"]
|
||||
for group in entry["value_groups"]
|
||||
if not group["name_ko"].strip()
|
||||
]
|
||||
assert not blank, f"한글 이름이 빈 값 묶음: {blank}"
|
||||
|
||||
|
||||
def test_마스터의_열이_이름표에_다_있다(labels):
|
||||
missing = []
|
||||
for entry in labels["files"]:
|
||||
tables, _ = _scan(ROOT / entry["path"])
|
||||
by_key = {t["key"]: t for t in entry["tables"]}
|
||||
for key, shape, node in tables:
|
||||
table = by_key.get(key)
|
||||
if table is None:
|
||||
continue
|
||||
labelled = {c["key"] for c in table["columns"]}
|
||||
for column in _columns_of(node, shape):
|
||||
if column not in labelled:
|
||||
missing.append(f"{entry['file_id']}::{key}/{column}")
|
||||
assert not missing, f"이름표에 없는 열: {missing}"
|
||||
|
||||
|
||||
def test_이름표의_열이_마스터에_다_있다(labels):
|
||||
stale = []
|
||||
for entry in labels["files"]:
|
||||
tables, _ = _scan(ROOT / entry["path"])
|
||||
actual = {key: _columns_of(node, shape) for key, shape, node in tables}
|
||||
for table in entry["tables"]:
|
||||
known = set(actual.get(table["key"], ()))
|
||||
for column in table["columns"]:
|
||||
if column["key"] not in known:
|
||||
stale.append(f"{entry['file_id']}::{table['key']}/{column['key']}")
|
||||
assert not stale, f"마스터에 없는 묵은 열 이름표: {stale}"
|
||||
|
||||
|
||||
def test_열_이름은_붙었거나_사유가_있다(labels):
|
||||
bad = []
|
||||
for entry in labels["files"]:
|
||||
for table in entry["tables"]:
|
||||
for column in table["columns"]:
|
||||
if column["name_ko"].strip():
|
||||
continue
|
||||
if not column.get("unknown_reason", "").strip():
|
||||
bad.append(f"{entry['file_id']}::{table['key']}/{column['key']}")
|
||||
assert not bad, f"이름도 사유도 없는 열: {bad}"
|
||||
|
||||
|
||||
def test_이름_없는_열은_unknown_에도_적혀_있다(labels):
|
||||
listed = {u["where"] for u in labels["unknown"]}
|
||||
for entry in labels["files"]:
|
||||
for table in entry["tables"]:
|
||||
for column in table["columns"]:
|
||||
if column["name_ko"].strip():
|
||||
continue
|
||||
where = f"{entry['file_id']}::{table['key']}/{column['key']}"
|
||||
assert where in listed, f"unknown 에 안 적힌 빈 열: {where}"
|
||||
|
||||
|
||||
def test_찾는_차례가_적혀_있다(labels):
|
||||
order = labels["lookup_order"]["column"]
|
||||
assert order[0].startswith("column_overrides")
|
||||
assert order[1].startswith("columns")
|
||||
|
||||
|
||||
def test_덮어쓰기_이름표가_다_쓰인다(labels):
|
||||
"""`column_overrides` 는 「파일id/열key」 꼴이고 그 파일에 실제로 그 열이 있어야 한다."""
|
||||
by_file = {}
|
||||
for entry in labels["files"]:
|
||||
cols = set()
|
||||
for table in entry["tables"]:
|
||||
cols |= {c["key"] for c in table["columns"]}
|
||||
by_file[entry["file_id"]] = cols
|
||||
dangling = []
|
||||
for key in labels["column_overrides"]:
|
||||
file_id, _, column = key.partition("/")
|
||||
if column not in by_file.get(file_id, set()):
|
||||
dangling.append(key)
|
||||
assert not dangling, f"쓰이지 않는 열 덮어쓰기: {dangling}"
|
||||
|
||||
|
||||
def test_세어_둔_수가_실제와_같다(labels):
|
||||
counts = labels["counts"]
|
||||
assert counts["files"] == len(labels["files"])
|
||||
assert counts["tables"] == sum(len(f["tables"]) for f in labels["files"])
|
||||
assert counts["columns"] == sum(len(t["columns"]) for f in labels["files"] for t in f["tables"])
|
||||
assert counts["value_groups"] == sum(len(f["value_groups"]) for f in labels["files"])
|
||||
assert counts["unknown"] == len(labels["unknown"])
|
||||
|
||||
|
||||
def test_강우_IDF_캐시는_안_담았다(labels):
|
||||
assert not [f for f in labels["files"] if "rainfall" in f["path"]]
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Z01 마스터 데이터 표 칸 — 숨김 열 · 열 제목(한글 label + 단위) · 칸 글자 · 쪽 수 (2026-09-15 브레인 Z01).
|
||||
|
||||
① 한글 이름은 API 가 `label` 로 줌 — 화면은 사전을 안 가짐(받은 대로 보임)
|
||||
② `hidden` 열(내부 id · sha · 생성시각)은 기본 숨김 · 「숨긴 열 보기」면 보임
|
||||
③ 단위가 있는 열의 수는 천 단위 쉼표(소수 자리는 자르지 않음) · 단위 없는 수(연도 등)는 그대로
|
||||
④ 쪽 수는 줄이 없어도 1
|
||||
TS 를 실제로 돌린다(`test_b06_berm_review_info` 와 같은 방식).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
TSC = PROJECT_ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
|
||||
SOURCE = PROJECT_ROOT / "Z01_MasterData" / "Z01_MasterData_UI_Cells.ts"
|
||||
|
||||
_RUNNER = """
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { cellText, columnTitle, pageCount, shownColumns } from "./Z01_MasterData_UI_Cells.js";
|
||||
|
||||
const columns = [
|
||||
{ key: "id", label: "id", unit: null, hidden: true },
|
||||
{ key: "occupation_name", label: "직종명", unit: null, hidden: false },
|
||||
{ key: "daily_wage_krw", label: "일 노임", unit: "원", hidden: false },
|
||||
];
|
||||
writeFileSync(process.argv[2], JSON.stringify({
|
||||
shown: shownColumns(columns, false).map((c) => c.key),
|
||||
shownAll: shownColumns(columns, true).map((c) => c.key),
|
||||
titles: columns.map(columnTitle),
|
||||
cells: [
|
||||
cellText(null, null),
|
||||
cellText(250000, "원"),
|
||||
cellText(1.23456, "㎥"),
|
||||
cellText(2026, null),
|
||||
cellText({ a: 1 }, null),
|
||||
cellText(true, null),
|
||||
cellText("보통인부", null),
|
||||
],
|
||||
pages: [pageCount(0, 50), pageCount(6999, 50), pageCount(50, 50)],
|
||||
}));
|
||||
"""
|
||||
|
||||
|
||||
def test_칸_글자와_숨김_열(tmp_path: Path) -> None:
|
||||
out = tmp_path / "js"
|
||||
subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
[
|
||||
"node",
|
||||
str(TSC),
|
||||
str(SOURCE),
|
||||
"--outDir",
|
||||
str(out),
|
||||
"--module",
|
||||
"esnext",
|
||||
"--target",
|
||||
"es2022",
|
||||
"--ignoreConfig",
|
||||
"--noCheck",
|
||||
"--noResolve",
|
||||
],
|
||||
cwd=str(PROJECT_ROOT),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
(out / "runner.mjs").write_text(_RUNNER, encoding="utf-8")
|
||||
result = tmp_path / "output.json"
|
||||
subprocess.run( # noqa: S603
|
||||
["node", str(out / "runner.mjs"), str(result)],
|
||||
cwd=str(PROJECT_ROOT),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
got = json.loads(result.read_text(encoding="utf-8"))
|
||||
|
||||
assert got["shown"] == ["occupation_name", "daily_wage_krw"]
|
||||
assert got["shownAll"] == ["id", "occupation_name", "daily_wage_krw"]
|
||||
assert got["titles"] == ["id", "직종명", "일 노임 (원)"]
|
||||
assert got["cells"] == ["", "250,000", "1.23456", "2026", '{"a":1}', "예", "보통인부"]
|
||||
assert got["pages"] == [1, 140, 1]
|
||||
+1
-1
@@ -32,6 +32,6 @@
|
||||
"maplibre-gl": ["config/node_modules/maplibre-gl"],
|
||||
}
|
||||
},
|
||||
"include": ["A00_Common", "A0*", "B0*", "B1*", "ui_template", "config/**/*", "common_util"],
|
||||
"include": ["A00_Common", "A0*", "B0*", "B1*", "Z0*", "ui_template", "config/**/*", "common_util"],
|
||||
"exclude": ["0_old", "node_modules", "venv", "dist"]
|
||||
}
|
||||
|
||||
@@ -136,4 +136,29 @@ export const ui_locales_b3 = {
|
||||
"법령 별표2 Ⅰ.2.바.(2) 가 요구하는 구간만 자동 — 종단 8% 초과 사질·점토(우리 프리셋은 토사 하나라 토사 전부) · 8% 이하 연약·습윤은 칸(판정 근거 없음). 교본 3-2 는 「콘크리트 포장 구간 이외」 전부라 고르면 넓힘. 두께 제안 0.10(교본) · C·L 제안 소광 관측(원문 표에 혼합석 줄 없음 · 역 C 와 방향이 반대)",
|
||||
"Only the stations required by the forest act table 2 are automatic — grade > 8% sandy/clay soil (our preset has one soil class) and ≤ 8% soft/wet ranges you enter. Manual 3-2 covers all non-concrete stations. Suggested thickness 0.10 (manual) · C·L from practice (no gravel row in the standard table)",
|
||||
],
|
||||
/* --- B01 시스템 설정 · Z01 마스터 데이터 (2026-09-15 브레인 Z01) --- */
|
||||
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_AdminOnly: ["시스템 관리자만 볼 수 있음", "System administrators only"],
|
||||
Z01_MasterData_LoadFailed: ["마스터 데이터를 못 읽음", "Failed to load master data"],
|
||||
Z01_MasterData_PickTable: ["왼쪽에서 표를 고를 것", "Pick a table on the left"],
|
||||
Z01_MasterData_Search: ["검색", "Search"],
|
||||
Z01_MasterData_ShowHidden: [
|
||||
"숨긴 열 보기(내부 id · sha · 생성시각)",
|
||||
"Show hidden columns (id · sha · created)",
|
||||
],
|
||||
Z01_MasterData_Byproduct: [
|
||||
"정본 아님 — 계산이 남긴 기록",
|
||||
"Not source data — calculation records",
|
||||
],
|
||||
Z01_MasterData_NoRows: ["줄 없음", "No rows"],
|
||||
Z01_MasterData_Prev: ["이전", "Prev"],
|
||||
Z01_MasterData_Next: ["다음", "Next"],
|
||||
Z01_MasterData_Page: ["쪽", "pages"],
|
||||
Z01_MasterData_Rows: ["줄", "rows"],
|
||||
} as const;
|
||||
|
||||
Reference in New Issue
Block a user