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

This commit is contained in:
2026-09-15 19:03:29 +09:00
14 changed files with 7356 additions and 3 deletions
@@ -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}`);
}
+38
View File
@@ -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));
}
+266
View File
@@ -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 };
}
+167
View File
@@ -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);
}
}