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

This commit is contained in:
2026-10-02 09:45:23 +09:00
2 changed files with 93 additions and 18 deletions
+49 -18
View File
@@ -7,7 +7,7 @@
import { createSelectField, el, showToast } from "@ui/ui_template_elements";
import { attachCollapsible } from "@ui/ui_template_collapsible";
import { t as L } from "@ui/ui_template_locale";
import { currentLanguageIndex, t as L } from "@ui/ui_template_locale";
import { fetchFiles, fetchSubsAll, type FileInfo, type SubInfo } from "./M01_MasterData_Api_Fetch";
import { tx } from "./M01_MasterData_UI_Logic_Text";
import { renderTree, type MakeRow } from "./M01_MasterData_UI_Tree";
@@ -125,11 +125,13 @@ export interface FilterArgs {
restore?: boolean;
}
const MATERIAL_NAMES: Record<string, string> = { 유가전력: "유가·전력" };
/** 재료 컨테이너 차례 — 여기 없는 파일은 뒤에 */
/** 유가 · 전력 — 서버로는 재료 그룹의 한 파일(재료_유가전력.json) · 왼쪽 패널에서는 재료 다음 따로 한 컨테이너 */
const FUEL = "유가전력";
const FUEL_GROUP = "유가 · 전력";
const FUEL_TITLE = ["유가 · 전력", "Fuel · power"] as const;
const MATERIAL = "자재품목";
const MATERIAL_ORDER = ["자재품목", "유가전력", "품셈재료"];
/** 재료 컨테이너 차례 — 여기 없는 파일은 뒤에 */
const MATERIAL_ORDER = ["자재품목", "품셈재료"];
/** 파일 이름 → 목록 글자 — 「소요량_건설품셈_10장_창호…」 → 「건설품셈_10장_창호…」 */
export const fileLabel = (file: string): string =>
@@ -145,8 +147,11 @@ const GROUP_TITLE = {
요율: "M01_GroupRate",
} as const;
type Group = keyof typeof GROUP_TITLE;
const GROUPS = Object.keys(GROUP_TITLE) as Group[];
type Group = keyof typeof GROUP_TITLE | typeof FUEL_GROUP;
const GROUPS: Group[] = ["인력", "재료", FUEL_GROUP, "기계", "소요량", "계수", "환율", "요율"];
const titleOf = (group: Group): string =>
group === FUEL_GROUP ? FUEL_TITLE[currentLanguageIndex === 1 ? 1 : 0] : L(GROUP_TITLE[group]);
const isFuel = (file: FileInfo): boolean => fileLabel(file.file) === FUEL;
const LEAF: Group[] = ["환율", "요율"];
/** 거름이 있는 컨테이너 — 펼치면 「전체」(또는 닫기 전 거름) 이 바로 열림 */
const FILTERED: Group[] = ["인력", "기계", "소요량", "계수"];
@@ -158,7 +163,7 @@ export function buildSide(onOpen: (pick: Pick) => void, onLogic: () => void): Si
const buttons = new Map<string, HTMLElement>();
// 하위 거름은 서버가 준 목록(파일 머리에 등록된 구분·세부분류) — 새 조사가 늘어도 화면은 그대로
const kinds = new Map<Group, SubInfo[]>();
/** 재료 그룹 안 파일별 구분 목록(자재품목·유가전력 각자) — file 이름이 열쇠 */
/** 재료 그룹 안 파일별 구분 목록(자재품목 · 유가전력 각자) — file 이름이 열쇠 */
const materialKinds = new Map<string, SubInfo[]>();
const states = new Map<Group, string[]>();
const runs = new Map<string, () => void>();
@@ -304,8 +309,15 @@ export function buildSide(onOpen: (pick: Pick) => void, onLogic: () => void): Si
return i < 0 ? MATERIAL_ORDER.length : i;
};
/** 컨테이너에 보일 파일 — 재료는 유가 · 전력 파일을 빼고 · 유가 · 전력은 그 파일 하나(둘 다 서버 재료 그룹) */
const shown = (group: Group): FileInfo[] => {
if (group === FUEL_GROUP) return (lists.get("재료") ?? []).filter(isFuel);
const list = lists.get(group) ?? [];
return group === "재료" ? list.filter((f) => !isFuel(f)) : list;
};
const drawGroup = (group: Group): void => {
const files = lists.get(group) ?? [];
const files = shown(group);
const body = bodies.get(group);
if (!body) return;
const open = (file: FileInfo, sub: string, label: string, detail = "", state = ""): void =>
@@ -325,6 +337,20 @@ export function buildSide(onOpen: (pick: Pick) => void, onLogic: () => void): Si
소요량: { label: L("M01_LaborSub"), detail: L("M01_LaborDetail") },
계수: { label: L("M01_LaborSub"), detail: L("M01_LaborDetail") },
};
if (group === FUEL_GROUP && one) {
body.replaceChildren(
filter({
id: `${group}|`,
store: `m01.filter.재료.${FUEL}`,
subs: materialKinds.get(one.file) ?? [],
total: one.rows,
subLabel: L("M01_FuelSub"),
detailLabel: L("M01_FuelDetail"),
onPick: (sub, detail, label) => open(one, sub, label, detail),
}),
);
return;
}
const drop = dropped[group];
if (drop && one) {
body.replaceChildren(
@@ -347,8 +373,8 @@ export function buildSide(onOpen: (pick: Pick) => void, onLogic: () => void): Si
.sort((a, b) => order(a.file) - order(b.file))
.flatMap((f) => {
const name = fileLabel(f.file);
const label = group === "재료" ? (MATERIAL_NAMES[name] ?? name) : name;
if (group === "재료" && (name === FUEL || name === MATERIAL))
const label = name;
if (group === "재료" && name === MATERIAL)
return [
filter({
id: `${group}\n${f.file}`,
@@ -356,8 +382,8 @@ export function buildSide(onOpen: (pick: Pick) => void, onLogic: () => void): Si
subs: materialKinds.get(f.file) ?? [],
total: f.rows,
title: label,
subLabel: name === FUEL ? L("M01_FuelSub") : L("M01_LaborSub"),
detailLabel: name === FUEL ? L("M01_FuelDetail") : L("M01_LaborDetail"),
subLabel: L("M01_LaborSub"),
detailLabel: L("M01_LaborDetail"),
onPick: (sub, detail, tail) =>
open(f, sub, sub || detail ? `${label} · ${tail}` : label, detail),
}),
@@ -380,7 +406,7 @@ export function buildSide(onOpen: (pick: Pick) => void, onLogic: () => void): Si
/** 처음 펼칠 때 열 줄 — 재료는 차례 첫 파일(자재품목) */
const firstRun = (group: Group): string => {
if (group !== "재료") return `${group}|`;
const first = [...(lists.get(group) ?? [])].sort((a, b) => order(a.file) - order(b.file))[0];
const first = [...shown(group)].sort((a, b) => order(a.file) - order(b.file))[0];
return first
? `${group}
${first.file}`
@@ -388,6 +414,8 @@ ${first.file}`
};
const refresh = async (group: string): Promise<FileInfo[]> => {
// 유가 · 전력 컨테이너는 서버 재료 그룹을 받아 그 파일만
if (group === FUEL_GROUP) return (await refresh("재료")).filter(isFuel);
const list = await fetchFiles(group);
if (list[0] && (group === "인력" || group === "기계")) {
const got = await fetchSubsAll({ file: list[0].file });
@@ -404,6 +432,7 @@ ${first.file}`
}
lists.set(group as Group, list);
drawGroup(group as Group);
if (group === "재료") drawGroup(FUEL_GROUP);
return list;
};
@@ -421,7 +450,7 @@ ${first.file}`
? null
: el("div", { className: "m01-side__items ui-sidebar-list" });
if (body) bodies.set(group, body);
const root = section(L(GROUP_TITLE[group]), body);
const root = section(titleOf(group), body);
if (!body) {
root.classList.add("m01-side__leaf");
buttons.set(`${group}\n`, root);
@@ -429,10 +458,10 @@ ${first.file}`
const one = lists.get(group)?.[0];
if (!one) return;
setActive(`${group}\n`);
onOpen({ group, file: one, sub: "", detail: "", label: L(GROUP_TITLE[group]) });
onOpen({ group, file: one, sub: "", detail: "", label: titleOf(group) });
});
}
if (FILTERED.includes(group) || group === "재료") {
if (FILTERED.includes(group) || group === "재료" || group === FUEL_GROUP) {
root.querySelector(".ui-collapsible__title")?.addEventListener("click", () => {
if (!root.classList.contains("is-collapsed")) return; // 닫을 때는 그대로 둠
restoring = true;
@@ -459,12 +488,14 @@ ${first.file}`
children: [...groups, logic],
});
attachCollapsible(root);
void Promise.all(GROUPS.map(refresh)).catch((error) =>
void Promise.all(GROUPS.filter((g) => g !== FUEL_GROUP).map(refresh)).catch((error) =>
showToast(error instanceof Error ? error.message : L("M01_LoadFailed"), "error"),
);
const setOnPicked = (fn: () => void): void => {
onPicked = fn;
};
const files = (group: string): FileInfo[] => lists.get(group as Group) ?? [];
// 재료는 유가 · 전력 파일까지 다(알약 → 마스터 화면 길이 파일로 찾음) · 유가 · 전력은 그 파일만
const files = (group: string): FileInfo[] =>
group === FUEL_GROUP ? shown(FUEL_GROUP) : (lists.get(group as Group) ?? []);
return { root, logicHost, refresh, files, setActive, setOnPicked, filter };
}
+44
View File
@@ -0,0 +1,44 @@
"""38-2 왼쪽 패널 「유가 · 전력」 컨테이너 — 재료 다음 따로 · 재료 컨테이너에서는 뺌 · 서버 재료 그룹 그대로."""
import json
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
SIDE = (ROOT / "M01_MasterData/M01_MasterData_UI_Side.ts").read_text(encoding="utf-8")
def _array(name: str) -> list[str]:
body = re.search(rf"const {name}(?:: \w+\[\])? = \[(.*?)\]", SIDE, re.S).group(1)
return [x.strip().strip('"') for x in body.split(",") if x.strip()]
def test_fuel_container_right_after_material():
groups = _array("GROUPS")
assert groups[groups.index("재료") + 1] == "FUEL_GROUP"
assert 'const FUEL_GROUP = "유가 · 전력";' in SIDE
def test_material_container_has_no_fuel():
assert _array("MATERIAL_ORDER") == ["자재품목", "품셈재료"]
assert "list.filter((f) => !isFuel(f))" in SIDE
def test_fuel_container_reads_server_material_group():
# 서버로는 재료 그룹을 받아 유가전력 파일만 · 첫 받기에서 따로 부르지 않음
assert 'if (group === FUEL_GROUP) return (await refresh("재료")).filter(isFuel);' in SIDE
assert "GROUPS.filter((g) => g !== FUEL_GROUP).map(refresh)" in SIDE
# 거름 저장 열쇠 그대로 — 전에 고른 구분이 이어짐
assert "store: `m01.filter.재료.${FUEL}`" in SIDE
def test_fuel_file_still_in_material_group():
data = json.loads(
(ROOT / "resources/master_data/재료_유가전력.json").read_text(encoding="utf-8")
)
assert data
# 흐름도 넣을 값 판 · 단가산출 로직 요소 고르기는 서버 재료 그룹을 그대로 씀
panel = (ROOT / "M02_MasterTemplete/DataFlow/M02_DF_InputPanel_Build.ts").read_text(
encoding="utf-8"
)
assert '유가전력: { group: "재료", part: "유가전력" }' in panel