/* ============================================================================= * M01_MasterData_UI_Side.ts * 왼쪽 패널 — 공용 접기 컨테이너(`ui-collapsible ui-sidebar-section`) 여덟 개 * 인력 · 재료 · 기계 · 소요량 · 계수 · 환율 · 요율 · 일위대가 로직 * 펼치면 하위 목록(거름 · 파일) · 환율/요율은 누르면 바로 표 · 로직은 안에 로직 목록 * ========================================================================== */ 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 { 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"; /** 컨테이너마다 기억하는 화면 상태 — 찾기 글 · 쪽 · 소요량·계수 표의 용도 대상 거름 (거름은 `m01.filter.*`) */ export interface View { q: string; page: number; usage?: string; } const viewKey = (group: string): string => `m01.view.${group}`; export function loadView(group: string): View { try { return { q: "", page: 1, ...JSON.parse(sessionStorage.getItem(viewKey(group)) ?? "{}") }; } catch { return { q: "", page: 1 }; } } export function saveView(group: string, patch: Partial): void { try { sessionStorage.setItem(viewKey(group), JSON.stringify({ ...loadView(group), ...patch })); } catch { /* 기억 못 해도 화면은 됨 */ } } const PENDING_LOGIC_KEY = "m01.logic.pending"; /** 소요량·계수 표의 「쓰는 로직」 키를 눌러 로직 탭으로 넘어갈 때 들고 갈 키 — 로직 목록 화면이 직접 열지는 못함(로직 부분은 sub1). */ export function setPendingLogicKey(key: string): void { try { sessionStorage.setItem(PENDING_LOGIC_KEY, key); } catch { /* 기억 못 해도 로직 탭 전환은 됨 */ } } export function takePendingLogicKey(): string | null { try { const key = sessionStorage.getItem(PENDING_LOGIC_KEY); sessionStorage.removeItem(PENDING_LOGIC_KEY); return key; } catch { return null; } } /** 요소 화면에서 열 것 — 파일 · 하위 거름(구분·세부분류) · 상세구분 · 제목 */ export interface Pick { group: string; file: FileInfo; sub: string; detail: string; /** 인력 상태 거름 · 비면 전체 */ state?: string; label: string; /** 다시 펼칠 때만 — 닫기 전 찾기 글 · 쪽 */ view?: View; } export interface SideHandle { root: HTMLElement; /** 일위대가 로직 컨테이너 안(로직 목록이 들어갈 자리) */ logicHost: HTMLElement; /** 「로직 쉽게 보기·만들기」 컨테이너 안(같은 로직 목록이 들어갈 자리) */ easyHost: HTMLElement; /** 그룹의 파일 판본을 다시 받음(저장 뒤) — 돌려받는 것 = 새 목록 */ refresh: (group: string) => Promise; setActive: (id: string | null) => void; /** 「전체」 + 드롭다운 거름 한 벌 — 로직 컨테이너도 씀 */ filter: (args: FilterArgs) => HTMLElement; } export interface FilterArgs { /** 「전체」 줄 눌림 표시용 */ id: string; /** 고른 것을 기억할 sessionStorage 칸 */ store: string; subs: SubInfo[]; total: number; subLabel: string; /** 「전체」 줄 글 · 없으면 「전체」 */ title?: string; /** 없으면 한 단 */ detailLabel?: string; /** 있으면 「상태」 드롭다운 한 단 더 — 목록은 서버가 줌 */ states?: string[]; stateLabel?: string; onPick: (sub: string, detail: string, label: string, state: string) => void; /** 만들자마자 고른 거름으로 한 번 부름 */ restore?: boolean; } const MATERIAL_NAMES: Record = { 유가전력: "유가·전력" }; /** 재료 컨테이너 차례 — 여기 없는 파일은 뒤에 */ const FUEL = "유가전력"; const MATERIAL = "자재품목"; const MATERIAL_ORDER = ["자재품목", "유가전력", "품셈재료"]; /** 파일 이름 → 목록 글자 — 「소요량_건설품셈_10장_창호…」 → 「건설품셈_10장_창호…」 */ export const fileLabel = (file: string): string => file.replace(/^[^_]+_/, "").replace(/\.json$/, ""); const GROUP_TITLE = { 인력: "M01_GroupLabor", 재료: "M01_GroupMaterial", 기계: "M01_GroupMachine", 소요량: "M01_GroupQuantity", 계수: "M01_GroupFactor", 환율: "M01_GroupExchange", 요율: "M01_GroupRate", } as const; type Group = keyof typeof GROUP_TITLE; const GROUPS = Object.keys(GROUP_TITLE) as Group[]; const LEAF: Group[] = ["환율", "요율"]; /** 거름이 있는 컨테이너 — 펼치면 「전체」(또는 닫기 전 거름) 이 바로 열림 */ const FILTERED: Group[] = ["인력", "기계", "소요량", "계수"]; const LOGIC_ID = "로직|"; const EASY_ID = "쉬움|"; export function buildSide( onOpen: (pick: Pick) => void, onLogic: () => void, onEasy: () => void, ): SideHandle { const lists = new Map(); const bodies = new Map(); const buttons = new Map(); // 하위 거름은 서버가 준 목록(파일 머리에 등록된 구분·세부분류) — 새 조사가 늘어도 화면은 그대로 const kinds = new Map(); /** 재료 그룹 안 파일별 구분 목록(자재품목·유가전력 각자) — file 이름이 열쇠 */ const materialKinds = new Map(); const states = new Map(); const runs = new Map void>(); let restoring = false; const setActive = (id: string | null): void => { for (const [key, button] of buttons) button.classList.toggle("is-active", key === id); }; /** 트리 줄 — 부모는 접기/펴기 · 누를 일이 있는 줄만 밝게 */ const make: MakeRow = (node, caret, depth, onClick) => { const button = el("button", { className: "m01-side__item", attrs: { type: "button" }, children: [ el("span", { children: [ el("span", { className: "m01-tree__caret", text: caret }), el("span", { text: node.label }), ], }), ...(node.count === null ? [] : [ el("span", { className: "m01-side__muted", text: node.count.toLocaleString("ko-KR"), }), ]), ], }); button.style.paddingLeft = `calc(var(--spacing-8) + ${depth} * var(--spacing-16))`; if (node.run) buttons.set(node.id, button); button.addEventListener("click", () => { if (node.run) setActive(node.id); onClick(); }); return button; }; /** 「전체」 한 줄 + 구분 → 상세구분 드롭다운 거름(목록은 서버가 준 것 · 고른 것은 기억) — * detailLabel 없으면 한 단 */ const filter = (a: FilterArgs): HTMLElement => { const saved = (() => { try { return JSON.parse(sessionStorage.getItem(a.store) ?? "[]") as string[]; } catch { return []; } })(); const pickSub = a.subs.find((k) => k.name === saved[0]); const opts = (values: string[]): { value: string; text: string }[] => [L("M01_All"), ...values].map((v, i) => ({ value: i ? v : "", text: v })); const sub = createSelectField({ options: opts(a.subs.map((k) => k.name)), value: pickSub?.name ?? "", compact: true, label: a.subLabel, }); const detail = a.detailLabel ? createSelectField({ options: opts(pickSub?.details ?? []), value: saved[1] ?? "", compact: true, label: a.detailLabel, disabled: !pickSub?.details.length, }) : null; const state = a.states?.length ? createSelectField({ options: opts(a.states), value: a.states.includes(saved[2]) ? saved[2] : "", compact: true, label: a.stateLabel ?? "", }) : null; const run = (): void => { const d = detail?.select.value ?? ""; const s = state?.select.value ?? ""; try { sessionStorage.setItem(a.store, JSON.stringify([sub.select.value, d, s])); } catch { /* 기억 못 해도 거름은 됨 */ } setActive(a.id); a.onPick( sub.select.value, d, [sub.select.value, d, s].filter(Boolean).join(" · ") || L("M01_All"), s, ); }; runs.set(a.id, run); sub.select.addEventListener("change", () => { if (detail) { const details = a.subs.find((k) => k.name === sub.select.value)?.details ?? []; detail.setOptions(opts(details), ""); detail.select.disabled = details.length === 0; } run(); }); detail?.select.addEventListener("change", run); state?.select.addEventListener("change", run); const all = make( { id: a.id, label: a.title ?? L("M01_All"), count: a.total, run }, "", 0, () => { if (state) state.select.value = ""; sub.select.value = ""; sub.select.dispatchEvent(new Event("change")); }, ); if (a.restore) run(); return el("div", { children: [ all, el("div", { className: "m01-side__filter", children: [sub.root, ...(detail ? [detail.root] : []), ...(state ? [state.root] : [])], }), ], }); }; const order = (file: string): number => { const i = MATERIAL_ORDER.indexOf(fileLabel(file)); return i < 0 ? MATERIAL_ORDER.length : i; }; const drawGroup = (group: Group): void => { const files = lists.get(group) ?? []; const body = bodies.get(group); if (!body) return; const open = (file: FileInfo, sub: string, label: string, detail = "", state = ""): void => onOpen({ group, file, sub, detail, state, label, view: restoring ? loadView(group) : undefined, }); const one = files[0]; const dropped: Partial> = { 인력: { label: L("M01_LaborSub"), detail: L("M01_LaborDetail") }, 기계: { label: L("M01_MachineSub"), detail: L("M01_MachineDetail") }, 소요량: { label: L("M01_LaborSub"), detail: L("M01_LaborDetail") }, 계수: { label: L("M01_LaborSub"), detail: L("M01_LaborDetail") }, }; const drop = dropped[group]; if (drop && one) { body.replaceChildren( filter({ id: `${group}|`, store: `m01.filter.${group}`, subs: kinds.get(group) ?? [], total: files.reduce((n, f) => n + f.rows, 0), subLabel: drop.label, detailLabel: drop.detail, states: states.get(group), stateLabel: L("M01_LaborState"), onPick: (sub, detail, label, state) => open(one, sub, label, detail, state), }), ); return; } body.replaceChildren( ...[...files] .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)) return [ filter({ id: `${group}\n${f.file}`, store: `m01.filter.${group}.${name}`, 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"), onPick: (sub, detail, tail) => open(f, sub, sub || detail ? `${label} · ${tail}` : label, detail), }), ]; return renderTree( [ { id: `${group}\n${f.file}`, label, count: f.rows, run: () => open(f, "", label), }, ], make, ); }), ); }; const refresh = async (group: string): Promise => { const list = await fetchFiles(group); if (list[0] && (group === "인력" || group === "기계")) { const got = await fetchSubsAll({ file: list[0].file }); kinds.set(group, got.subs); states.set(group as Group, got.states ?? []); } else if (group === "재료") { const fuel = list.find((f) => fileLabel(f.file) === FUEL); if (fuel) materialKinds.set(fuel.file, (await fetchSubsAll({ file: fuel.file })).subs); const material = list.find((f) => fileLabel(f.file) === MATERIAL); if (material) materialKinds.set(material.file, (await fetchSubsAll({ file: material.file })).subs); } else if (group === "소요량" || group === "계수") { kinds.set(group, (await fetchSubsAll({ group })).subs); } lists.set(group as Group, list); drawGroup(group as Group); return list; }; const section = (title: string, body: HTMLElement | null): HTMLElement => el("section", { className: "m01-side__group ui-collapsible ui-sidebar-section is-collapsed", children: [ el("h3", { className: "ui-collapsible__title", text: title }), ...(body ? [body] : []), ], }); const groups = GROUPS.map((group) => { const body = LEAF.includes(group) ? null : el("div", { className: "m01-side__items" }); if (body) bodies.set(group, body); const root = section(L(GROUP_TITLE[group]), body); if (!body) { root.classList.add("m01-side__leaf"); buttons.set(`${group}\n`, root); root.querySelector(".ui-collapsible__title")?.addEventListener("click", () => { const one = lists.get(group)?.[0]; if (!one) return; setActive(`${group}\n`); onOpen({ group, file: one, sub: "", detail: "", label: L(GROUP_TITLE[group]) }); }); } if (FILTERED.includes(group)) { root.querySelector(".ui-collapsible__title")?.addEventListener("click", () => { if (!root.classList.contains("is-collapsed")) return; // 닫을 때는 그대로 둠 restoring = true; try { runs.get(`${group}|`)?.(); } finally { restoring = false; } }); } return root; }); const logicHost = el("div", { className: "m01-side__items" }); const logic = section(L("M01_GroupLogic"), logicHost); logic.querySelector(".ui-collapsible__title")?.addEventListener("click", () => { if (!logic.classList.contains("is-collapsed")) return; // 닫을 때는 그대로 둠 setActive(buttons.has(LOGIC_ID) ? LOGIC_ID : null); onLogic(); }); const easyHost = el("div", { className: "m01-side__items" }); const easy = section(tx("Easy_Title"), easyHost); easy.querySelector(".ui-collapsible__title")?.addEventListener("click", () => { if (!easy.classList.contains("is-collapsed")) return; setActive(buttons.has(EASY_ID) ? EASY_ID : null); onEasy(); }); const root = el("div", { className: "m01-side", children: [...groups, logic, easy] }); attachCollapsible(root); void Promise.all(GROUPS.map(refresh)).catch((error) => showToast(error instanceof Error ? error.message : L("M01_LoadFailed"), "error"), ); return { root, logicHost, easyHost, refresh, setActive, filter }; }