Merge remote-tracking branch 'origin/sub_laptop_6' into main_laptop_1
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user