Merge remote-tracking branches 'origin/sub_desktop_1' and 'origin/sub_laptop_1' into main_laptop_1
This commit is contained in:
@@ -6,7 +6,13 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import type { MasterColumn, OverrideState, SortOrder, TableMeta } from "./Z01_MasterData_UI_Cells";
|
||||
import type {
|
||||
MasterColumn,
|
||||
OverrideState,
|
||||
ServedKind,
|
||||
SortOrder,
|
||||
TableMeta,
|
||||
} from "./Z01_MasterData_UI_Cells";
|
||||
|
||||
export interface MasterTable {
|
||||
id: string;
|
||||
@@ -48,26 +54,14 @@ export interface RowsQuery {
|
||||
}
|
||||
|
||||
/**
|
||||
* ① 기초단가 다섯 — 얼마인가. 시장이 정하고 주간~반기로 바뀜.
|
||||
* 고친 값은 원본이 아니라 덮개 파일에 쌓임(품셈이 갱신돼도 안 날아감).
|
||||
* 표 한 벌의 이름 — 화면은 목록을 안 가짐. 갈래·kind·이름·줄 수를 서버에서 받아 세움
|
||||
* (2026-09-16 브레인 — kind 가 늘 때마다 화면이 빠지던 자리).
|
||||
*/
|
||||
export const BASE_PRICE_KINDS = ["labor", "machine", "material", "oil", "rate"] as const;
|
||||
export type BasePriceKind = string;
|
||||
|
||||
/** ② 품셈 기준 아홉 — 얼마나 드나. 품셈이 정하고 해마다 한 번 개정(2026-09-16 사용자 ①②). */
|
||||
export const SPEC_KINDS = [
|
||||
"coef",
|
||||
"material_surcharge",
|
||||
"formwork_reuse",
|
||||
"rebar_complexity",
|
||||
"timber_structure_class",
|
||||
"masonry_slope",
|
||||
"masonry_back_length",
|
||||
"stone_kind",
|
||||
"machine_productivity",
|
||||
"masonry_class",
|
||||
] as const;
|
||||
|
||||
export type BasePriceKind = (typeof BASE_PRICE_KINDS)[number] | (typeof SPEC_KINDS)[number];
|
||||
export function fetchBaseKinds(): Promise<ServedKind[]> {
|
||||
return request<{ kinds: ServedKind[] }>("/master-data/base-prices").then((data) => data.kinds);
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
|
||||
@@ -85,6 +85,25 @@ export function outdatedBanner(sources?: TableSource[]): string[] {
|
||||
});
|
||||
}
|
||||
|
||||
/** 서버가 주는 표 한 벌 — 갈래·kind·한글 이름·줄 수. 화면은 제 목록을 안 가짐(늘면 저절로 섬). */
|
||||
export interface ServedKind {
|
||||
group: string;
|
||||
kind: string;
|
||||
label: string;
|
||||
rows: number;
|
||||
}
|
||||
|
||||
/** 받은 차례 그대로 갈래별로 묶음 — 갈래 차례도 서버가 낸 순서를 따름. */
|
||||
export function groupKinds(kinds: ServedKind[]): { group: string; items: ServedKind[] }[] {
|
||||
const boxes: { group: string; items: ServedKind[] }[] = [];
|
||||
for (const kind of kinds) {
|
||||
const box = boxes.find((each) => each.group === kind.group);
|
||||
if (box) box.items.push(kind);
|
||||
else boxes.push({ group: kind.group, items: [kind] });
|
||||
}
|
||||
return boxes;
|
||||
}
|
||||
|
||||
/** 열 제목을 눌러 세운 차례 — null 이면 원본 차례(서버가 sort 없이 주는 그대로). */
|
||||
export interface SortOrder {
|
||||
key: string;
|
||||
|
||||
@@ -13,7 +13,8 @@ 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 { fetchMasterTree, type MasterGroup } from "./Z01_MasterData_Api_Fetch";
|
||||
import { fetchBaseKinds, fetchMasterTree, type MasterGroup } from "./Z01_MasterData_Api_Fetch";
|
||||
import type { ServedKind } from "./Z01_MasterData_UI_Cells";
|
||||
import { buildRowsView } from "./Z01_MasterData_UI_Rows";
|
||||
import { buildSidePanel } from "./Z01_MasterData_UI_Side";
|
||||
import "./Z01_MasterData_UI_Style.css";
|
||||
@@ -27,24 +28,25 @@ export async function renderZ01MasterData(root: HTMLElement): Promise<void> {
|
||||
}
|
||||
showLoadingOverlay();
|
||||
let groups: MasterGroup[] = [];
|
||||
let kinds: ServedKind[] = [];
|
||||
try {
|
||||
groups = await fetchMasterTree();
|
||||
[groups, kinds] = await Promise.all([fetchMasterTree(), fetchBaseKinds()]);
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : L("Z01_MasterData_LoadFailed"), "error");
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
root.innerHTML = "";
|
||||
root.append(buildPage(groups));
|
||||
root.append(buildPage(groups, kinds));
|
||||
}
|
||||
|
||||
function buildPage(groups: MasterGroup[]): HTMLElement {
|
||||
function buildPage(groups: MasterGroup[], kinds: ServedKind[]): HTMLElement {
|
||||
const view = buildRowsView();
|
||||
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"),
|
||||
optionsContent: buildSidePanel(groups, view),
|
||||
optionsContent: buildSidePanel(groups, kinds, view),
|
||||
showProjectName: false,
|
||||
onOptionsOpenChange: (isOpen) => layout.classList.toggle("is-options-open", isOpen),
|
||||
});
|
||||
|
||||
@@ -10,55 +10,62 @@ 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 BasePriceKind,
|
||||
type MasterRows,
|
||||
type RowsQuery,
|
||||
saveBasePrice,
|
||||
SPEC_KINDS,
|
||||
VALUES_TABLE_ID,
|
||||
} from "./Z01_MasterData_Api_Fetch";
|
||||
import { valueWithUnit, type OverrideState } from "./Z01_MasterData_UI_Cells";
|
||||
import {
|
||||
groupKinds,
|
||||
valueWithUnit,
|
||||
type OverrideState,
|
||||
type ServedKind,
|
||||
} from "./Z01_MasterData_UI_Cells";
|
||||
import type { RowsView } from "./Z01_MasterData_UI_Rows";
|
||||
|
||||
export function buildSidePanel(groups: MasterGroup[], view: RowsView): HTMLElement {
|
||||
export function buildSidePanel(
|
||||
groups: MasterGroup[],
|
||||
kinds: ServedKind[],
|
||||
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 specTitle = L("Z01_MasterData_SpecValues");
|
||||
/** 한 상자 몫 단추들 — ①얼마인가 ②얼마나 드나 로 갈라 세움(성격도 갱신 주기도 다름). */
|
||||
const kindGrid = (boxTitle: string, kinds: readonly BasePriceKind[]): HTMLElement => {
|
||||
// 갈래·kind·이름·줄 수 모두 서버 것 — 화면은 목록도 이름도 안 가짐(kind 가 늘면 저절로 섬).
|
||||
const boxes = groupKinds(kinds).map((box) => {
|
||||
const grid = el("div", { className: "z01-master__base-grid" });
|
||||
for (const kind of kinds) {
|
||||
const label = L(`Z01_MasterData_Base_${kind}` as const);
|
||||
for (const served of box.items) {
|
||||
const button = el("button", {
|
||||
className: "z01-master__base",
|
||||
attrs: { type: "button" },
|
||||
text: label,
|
||||
children: [
|
||||
el("span", { text: served.label }),
|
||||
el("span", {
|
||||
className: "z01-master__key",
|
||||
text: served.rows.toLocaleString("ko-KR"),
|
||||
}),
|
||||
],
|
||||
});
|
||||
button.addEventListener("click", () => {
|
||||
activate(button);
|
||||
view.show({
|
||||
title: `${boxTitle} › ${label}`,
|
||||
key: `base-prices/${kind}`,
|
||||
load: (query) => fetchBasePrices(kind, query),
|
||||
save: (rowId, values) => saveBasePrice(kind, rowId, values),
|
||||
title: `${box.group} › ${served.label}`,
|
||||
key: `base-prices/${served.kind}`,
|
||||
load: (query) => fetchBasePrices(served.kind, query),
|
||||
save: (rowId, values) => saveBasePrice(served.kind, rowId, values),
|
||||
});
|
||||
});
|
||||
grid.append(button);
|
||||
}
|
||||
return grid;
|
||||
};
|
||||
const baseGrid = kindGrid(baseTitle, BASE_PRICE_KINDS);
|
||||
const specGrid = kindGrid(specTitle, SPEC_KINDS);
|
||||
return { group: box.group, grid };
|
||||
});
|
||||
const overridesButton = el("button", {
|
||||
className: "z01-master__base z01-master__base--wide",
|
||||
attrs: { type: "button" },
|
||||
@@ -67,13 +74,14 @@ export function buildSidePanel(groups: MasterGroup[], view: RowsView): HTMLEleme
|
||||
overridesButton.addEventListener("click", () => {
|
||||
activate(overridesButton);
|
||||
view.show({
|
||||
title: `${baseTitle} › ${L("Z01_MasterData_Overrides")}`,
|
||||
title: L("Z01_MasterData_Overrides"),
|
||||
key: "overrides",
|
||||
searchable: false,
|
||||
load: loadOverrides,
|
||||
// 갈래 이름도 서버 것 — 목록 줄의 kind 를 표 이름으로 바꿔 보임.
|
||||
load: (query) => loadOverrides(query, new Map(kinds.map((k) => [k.kind, k.label]))),
|
||||
});
|
||||
});
|
||||
baseGrid.append(overridesButton);
|
||||
boxes[0]?.grid.append(overridesButton);
|
||||
|
||||
const tree = el("nav", { className: "z01-master__tree" });
|
||||
for (const group of groups) {
|
||||
@@ -123,8 +131,7 @@ export function buildSidePanel(groups: MasterGroup[], view: RowsView): HTMLEleme
|
||||
}
|
||||
|
||||
panel.append(
|
||||
section(baseTitle, baseGrid),
|
||||
section(specTitle, specGrid),
|
||||
...boxes.map((box) => section(box.group, box.grid)),
|
||||
section(L("Z01_MasterData_Tree"), tree),
|
||||
);
|
||||
attachCollapsible(panel);
|
||||
@@ -142,13 +149,16 @@ const STATE_LABEL: Record<OverrideState, Parameters<typeof L>[0]> = {
|
||||
* 고친 것 목록을 표 한 벌로 — 정렬(원본 바뀜 → 주인 없음 → 고쳐짐)과 쪽 나눔은 서버가 함.
|
||||
* 화면이 다시 줄 세우면 맨 위가 그 쪽 안에서만 맨 위가 됨(2026-09-15 브레인 ②). 서버에 검색이 없어 검색 칸은 숨김.
|
||||
*/
|
||||
async function loadOverrides(query: RowsQuery): Promise<MasterRows> {
|
||||
async function loadOverrides(
|
||||
query: RowsQuery,
|
||||
kindLabels: Map<string, string>,
|
||||
): Promise<MasterRows> {
|
||||
const { items, total } = await fetchOverrides(query);
|
||||
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("kind", "Z01_MasterData_List_Kind"),
|
||||
column("row_label", "Z01_MasterData_List_Row"),
|
||||
column("column_label", "Z01_MasterData_List_Column"),
|
||||
column("value", "Z01_MasterData_List_Value"),
|
||||
@@ -164,7 +174,7 @@ async function loadOverrides(query: RowsQuery): Promise<MasterRows> {
|
||||
: {}),
|
||||
// state 가 안 오면 때우지 않고 드러냄(서버 버그).
|
||||
state: L(STATE_LABEL[item.state] ?? "Z01_MasterData_State_Missing"),
|
||||
kind: L(`Z01_MasterData_Base_${item.kind}` as const),
|
||||
kind: kindLabels.get(item.kind) ?? item.kind,
|
||||
})),
|
||||
total,
|
||||
};
|
||||
|
||||
@@ -25,9 +25,20 @@ _RUNNER = """
|
||||
import { writeFileSync } from "node:fs";
|
||||
import {
|
||||
cellInfo, cellText, clampPage, columnTitle, latestOnly, pageCount, parseCellInput, rowCells,
|
||||
boldSegments, nextSort, noticeLines, outdatedBanner, shownColumns, valueWithUnit,
|
||||
boldSegments, groupKinds, nextSort, noticeLines, outdatedBanner, shownColumns, valueWithUnit,
|
||||
} from "./Z01_MasterData_UI_Cells.js";
|
||||
|
||||
// kind 목록은 서버가 줌 — 화면이 제 목록을 들면 kind 가 늘 때마다 빠짐(2026-09-16 브레인)
|
||||
const served = [
|
||||
{ group: "base_price", kind: "labor", label: "노임", rows: 261 },
|
||||
{ group: "base_price", kind: "oil", label: "유가", rows: 36 },
|
||||
{ group: "pumsem_basis", kind: "coef", label: "토량환산계수", rows: 32 },
|
||||
// 서버가 새 kind·새 갈래를 더해도 화면이 그대로 세움
|
||||
{ group: "pumsem_basis", kind: "machine_productivity", label: "기계 작업량", rows: 47 },
|
||||
{ group: "later_group", kind: "brand_new", label: "새 갈래 것", rows: 3 },
|
||||
];
|
||||
const grouped = groupKinds(served).map((g) => [g.group, g.items.map((i) => i.kind)]);
|
||||
|
||||
// 열 제목 누르기 — 없음 → 오름 → 내림 → 없음(원본 차례) · 다른 열을 누르면 그 열 오름부터
|
||||
const sorts = [
|
||||
nextSort(null, "price_krw"),
|
||||
@@ -193,6 +204,7 @@ writeFileSync(process.argv[2], JSON.stringify({
|
||||
notices,
|
||||
bolds,
|
||||
sorts,
|
||||
grouped,
|
||||
}));
|
||||
"""
|
||||
|
||||
@@ -311,3 +323,9 @@ def test_칸_글자와_숨김_열(tmp_path: Path) -> None:
|
||||
None,
|
||||
{"key": "item_code", "desc": False},
|
||||
]
|
||||
# 서버 차례 그대로 갈래별로 묶임 · 새 kind·새 갈래도 저절로 섬(화면 목록이 없음)
|
||||
assert got["grouped"] == [
|
||||
["base_price", ["labor", "oil"]],
|
||||
["pumsem_basis", ["coef", "machine_productivity"]],
|
||||
["later_group", ["brand_new"]],
|
||||
]
|
||||
|
||||
@@ -141,24 +141,8 @@ export const ui_locales_b3 = {
|
||||
B01_Dashboard_MasterData: ["마스터 데이터", "Master Data"],
|
||||
Z01_MasterData_Title: ["마스터 데이터", "Master Data"],
|
||||
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_SpecValues: ["품셈 기준", "Standard rates"],
|
||||
Z01_MasterData_Base_coef: ["토량환산", "Soil conversion"],
|
||||
Z01_MasterData_Base_material_surcharge: ["자재할증", "Material surcharge"],
|
||||
Z01_MasterData_Base_formwork_reuse: ["거푸집", "Formwork reuse"],
|
||||
Z01_MasterData_Base_rebar_complexity: ["철근", "Rebar"],
|
||||
Z01_MasterData_Base_timber_structure_class: ["목구조", "Timber structure"],
|
||||
Z01_MasterData_Base_masonry_slope: ["돌쌓기 경사", "Masonry slope"],
|
||||
Z01_MasterData_Base_masonry_back_length: ["뒷길이", "Back length"],
|
||||
Z01_MasterData_Base_stone_kind: ["돌종류", "Stone kind"],
|
||||
Z01_MasterData_Base_machine_productivity: ["기계 작업량", "Machine productivity"],
|
||||
Z01_MasterData_Base_masonry_class: ["갈래", "Masonry class"],
|
||||
/* ⚠ 갈래·표 이름은 사전에 두지 않음 — 서버(이름표)가 줌. 화면이 들면 kind 가 늘 때 빠짐 */
|
||||
Z01_MasterData_List_Kind: ["표", "Table"],
|
||||
Z01_MasterData_Overrides: ["고친 것 모아 보기", "Edited values"],
|
||||
Z01_MasterData_Edit_Can: [
|
||||
"고칠 수 있는 칸 — 눌러 고치고 Enter 로 저장 (Esc 취소)",
|
||||
|
||||
Reference in New Issue
Block a user