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:
2026-09-15 21:55:33 +09:00
co-authored by Claude Opus 5
parent 5ae246146c
commit 81a1c013d2
9 changed files with 929 additions and 325 deletions
+177
View File
@@ -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] });
}