feat(z01): 좌측 패널을 B03~B09 도킹 사이드바 양식으로(상자 둘 — 기초단가 · 마스터 트리) · 기초단가 다섯(노임·기계·자재·유가·요율) 표 + 칸 고치기(editable 만 · 못 고치는 칸은 까닭·산출근거) · 덮개 판정은 서버 state 만(원본 바뀜·주인 없음은 줄째 또렷이 · 단추 글자에 값) · 요율 알림 · 고친 것 모아 보기(서버 정렬) · 공용 오버레이에 진행단계·프로젝트 이름 끄기 선택(브레인)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user