From c26f15718d9d8e819c89efbdbe3a06c84a33c1f7 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 20 Sep 2026 02:25:03 +0900 Subject: [PATCH] =?UTF-8?q?feat(M01):=20=EC=99=BC=EC=AA=BD=20=ED=8C=A8?= =?UTF-8?q?=EB=84=90=EC=9D=84=20=EA=B3=B5=EC=9A=A9=20=EC=A0=91=EA=B8=B0=20?= =?UTF-8?q?=EC=BB=A8=ED=85=8C=EC=9D=B4=EB=84=88=20=EC=97=AC=EB=8D=9F=20?= =?UTF-8?q?=EA=B0=9C=EB=A1=9C=20=C2=B7=20=ED=95=98=EC=9C=84=20=EA=B1=B0?= =?UTF-8?q?=EB=A6=84=20=C2=B7=20=EC=B0=B8=EC=A1=B0=20=EC=B9=B8=EC=97=90=20?= =?UTF-8?q?=ED=82=A4=20=EC=98=86=20=EC=9D=B4=EB=A6=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 인력 · 재료 · 기계 · 소요량 · 계수 · 환율 · 요율 · 일위대가 로직 컨테이너(환율·요율은 눌러 바로 표) - 인력 조사별 · 기계 세부분류별 거름 · 줄 더하기는 고른 조사·세부분류를 채움 - 준용 · 조달 연결 · 가격 연결 칸에 키와 이름을 같이 보임 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_016WRFJmmhPi4exAzHgPZHMT --- M01_MasterData/M01_MasterData_Api_Fetch.ts | 6 +- .../M01_MasterData_UI_Logic_Page.ts | 8 +- .../M01_MasterData_UI_Logic_Style.css | 8 +- M01_MasterData/M01_MasterData_UI_Page.ts | 164 ++++++------------ M01_MasterData/M01_MasterData_UI_Pick.ts | 4 +- M01_MasterData/M01_MasterData_UI_Rows.ts | 44 ++++- M01_MasterData/M01_MasterData_UI_Side.ts | 163 +++++++++++++++++ M01_MasterData/M01_MasterData_UI_Style.css | 56 +++--- M01_MasterData/M01_MasterData_UI_Tables.ts | 2 +- ui_template/ui_template_locale_m1.ts | 9 + 10 files changed, 313 insertions(+), 151 deletions(-) create mode 100644 M01_MasterData/M01_MasterData_UI_Side.ts diff --git a/M01_MasterData/M01_MasterData_Api_Fetch.ts b/M01_MasterData/M01_MasterData_Api_Fetch.ts index 11710dd9..8cbb70b0 100644 --- a/M01_MasterData/M01_MasterData_Api_Fetch.ts +++ b/M01_MasterData/M01_MasterData_Api_Fetch.ts @@ -30,6 +30,8 @@ export interface RowsPage { page: number; size: number; rows: Row[]; + /** 참조 칸 키 → 이름(키 옆에 같이 보임) */ + refs: Record; } export interface TableHead { @@ -90,7 +92,9 @@ export const fetchRows = ( size: number, q: string, unlinked = false, -): Promise => get("/rows", { file, page, size, q, unlinked: unlinked ? 1 : 0 }); + sub = "", +): Promise => + get("/rows", { file, page, size, q, unlinked: unlinked ? 1 : 0, sub }); export const fetchTables = ( file: string, diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Page.ts b/M01_MasterData/M01_MasterData_UI_Logic_Page.ts index e659906b..a84dd35f 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Page.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Page.ts @@ -1,7 +1,7 @@ /* ============================================================================= * M01_MasterData_UI_Logic_Page.ts - * 관리자 화면 — 로직 칸. 왼쪽 목록 / 가운데 일위대가표(호표) 고치기 / 오른쪽 시험 계산 - * 진입: 공용 진입 파일이 `mountM01Logic(host)` 한 줄로 붙임(요소 칸은 sub_laptop_3 몫) + * 관리자 화면 — 로직 칸. 가운데 일위대가표(호표) 고치기 / 오른쪽 시험 계산 + * 로직 목록(원문·장 고르기 · 찾기 · 막힘 거름)은 왼쪽 패널 「일위대가 로직」 컨테이너(`listHost`) 안 * * 데이터 흐름(CLAUDE.md 5장) — 고친 것은 캐시(sessionStorage)에 쌓고 [저장] 한 번에 `POST /save`. * 409 = 그 사이 파일이 바뀜 · 422 = 검사 걸림(아무것도 안 씀) — 까닭은 서버 글 그대로 보임. @@ -64,7 +64,7 @@ function loadDrafts(): Record { } } -export async function mountM01Logic(host: HTMLElement): Promise { +export async function mountM01Logic(host: HTMLElement, listHost: HTMLElement): Promise { let drafts = loadDrafts(); let files: LogicFile[] = []; let opened: Opened | null = null; @@ -350,12 +350,12 @@ export async function mountM01Logic(host: HTMLElement): Promise { el("div", { className: "m01-logic", children: [ - list.root, el("main", { className: "m01-logic__main", children: [bar, errors, editor] }), calc, ], }), ); + listHost.replaceChildren(list.root); show(null); showLoadingOverlay(); try { diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Style.css b/M01_MasterData/M01_MasterData_UI_Logic_Style.css index 0bbf5961..b4477c1d 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Style.css +++ b/M01_MasterData/M01_MasterData_UI_Logic_Style.css @@ -5,7 +5,7 @@ .m01-logic { display: grid; - grid-template-columns: 240px minmax(0, 1fr) 280px; + grid-template-columns: minmax(0, 1fr) 280px; gap: var(--spacing-12); height: 100%; min-height: 0; @@ -25,7 +25,6 @@ overflow: auto; } -.m01-logic__side, .m01-logic__calc { padding: var(--spacing-8); border-radius: var(--radius-cards); @@ -386,3 +385,8 @@ flex-wrap: wrap; gap: var(--spacing-4); } + +/* 왼쪽 패널 컨테이너 안 로직 목록 */ +.m01-side__items .m01-logic__list { + max-height: 40vh; +} diff --git a/M01_MasterData/M01_MasterData_UI_Page.ts b/M01_MasterData/M01_MasterData_UI_Page.ts index 2e7af22e..ebe9c970 100644 --- a/M01_MasterData/M01_MasterData_UI_Page.ts +++ b/M01_MasterData/M01_MasterData_UI_Page.ts @@ -1,38 +1,27 @@ /* ============================================================================= * M01_MasterData_UI_Page.ts - * 마스터 요소 화면 — 좌측 도킹 패널(그룹 탭 7 · 파일 고르기) / 우측 요소 표 · 표 목록 + * 마스터 요소 화면 — 좌측 도킹 패널(컨테이너 여덟: 인력·재료·기계·소요량·계수·환율·요율·일위대가 로직) + * / 우측 요소 표 · 표 목록 · 로직 편집 * * 시스템 관리자만. 고친 것은 초안(sessionStorage)에 쌓이고 [저장] 한 번에 서버로 — - * 자동저장 없음. 로직 그룹은 다음 화면. + * 자동저장 없음. * ========================================================================== */ import "@ui/ui_template_workflow_layout.css"; -import { ROUTES } from "@config/config_frontend"; -import { - createButton, - el, - hideLoadingOverlay, - showLoadingOverlay, - showToast, -} from "@ui/ui_template_elements"; +import { createButton, el, showToast } from "@ui/ui_template_elements"; import { t as L } from "@ui/ui_template_locale"; import { createWorkflowOverlays } from "@ui/ui_template_overlay"; +import { ROUTES } from "@config/config_frontend"; import { navigateTo } from "../A00_Common/router"; import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch"; -import { - fetchFiles, - fetchGroups, - saveFiles, - type FileInfo, - type GroupInfo, -} from "./M01_MasterData_Api_Fetch"; +import { saveFiles } from "./M01_MasterData_Api_Fetch"; import { discard, isStale, onDraftChange, payload, totalCount } from "./M01_MasterData_Draft"; import { renderRows } from "./M01_MasterData_UI_Rows"; +import { buildSide, fileLabel, type Pick } from "./M01_MasterData_UI_Side"; import { renderTables } from "./M01_MasterData_UI_Tables"; import "./M01_MasterData_UI_Style.css"; const TABLE_GROUPS = ["소요량", "계수"]; -const fileLabel = (file: string): string => file.replace(/^[^_]+_/, "").replace(/\.json$/, ""); export async function renderM01MasterData(root: HTMLElement): Promise { const user = await fetchSessionUser().catch(() => null); @@ -41,24 +30,15 @@ export async function renderM01MasterData(root: HTMLElement): Promise { navigateTo(ROUTES.B01_ACCOUNT); return; } - showLoadingOverlay(); - let groups: GroupInfo[] = []; - try { - groups = (await fetchGroups()).filter((g) => g.group !== "로직"); - } catch (error) { - showToast(error instanceof Error ? error.message : L("M01_LoadFailed"), "error"); - } finally { - hideLoadingOverlay(); - } root.innerHTML = ""; - root.append(buildPage(groups)); + root.append(buildPage()); } -function buildPage(groups: GroupInfo[]): HTMLElement { - let group = ""; - let current: FileInfo | null = null; +function buildPage(): HTMLElement { + let current: Pick | null = null; let query = ""; let dispose = (): void => {}; + let logicMounted = false; /* --- 우측: 머리(제목·찾기·저장) + 알림 + 본문 --- */ const title = el("h2", { className: "m01-master__title", text: L("M01_PickFile") }); @@ -75,6 +55,11 @@ function buildPage(groups: GroupInfo[]): HTMLElement { className: "m01-master__head", children: [title, search, summary, drop, save], }); + const logicHost = el("div", { className: "m01-master__logic", attrs: { hidden: "" } }); + const elementView = el("div", { + className: "m01-master__elements", + children: [head, notice, body], + }); const showNotice = (nodes: (HTMLElement | string)[]): void => { notice.replaceChildren(...nodes); @@ -88,13 +73,20 @@ function buildPage(groups: GroupInfo[]): HTMLElement { onDraftChange(refreshBar); refreshBar(); - const openFile = (file: FileInfo): void => { + const showMode = (logic: boolean): void => { + logicHost.hidden = !logic; + elementView.hidden = logic; + }; + + const openFile = (pick: Pick): void => { dispose(); - current = file; - title.textContent = `${group} · ${fileLabel(file.file)}`; - const view = TABLE_GROUPS.includes(group) ? renderTables : renderRows; - dispose = view(body, file.file, query); - showNotice(isStale(file.file, file.version) ? [L("M01_FileStale")] : []); + showMode(false); + current = pick; + const groupTitle = pick.group === pick.label ? [pick.group] : [pick.group, pick.label]; + title.textContent = groupTitle.join(" · "); + const view = TABLE_GROUPS.includes(pick.group) ? renderTables : renderRows; + dispose = view(body, pick.file.file, query, pick.sub); + showNotice(isStale(pick.file.file, pick.file.version) ? [L("M01_FileStale")] : []); }; let searchTimer: number | undefined; @@ -112,6 +104,18 @@ function buildPage(groups: GroupInfo[]): HTMLElement { showNotice([]); }); + /** 저장·되받기 뒤 — 지금 열린 그룹의 새 판본으로 다시 그림 */ + const reloadFiles = async (): Promise => { + if (!current) return; + const pick = current; + try { + const again = (await side.refresh(pick.group)).find((f) => f.file === pick.file.file); + if (again) openFile({ ...pick, file: again }); + } catch (error) { + showToast(error instanceof Error ? error.message : L("M01_LoadFailed"), "error"); + } + }; + save.addEventListener("click", async () => { save.disabled = true; try { @@ -150,89 +154,27 @@ function buildPage(groups: GroupInfo[]): HTMLElement { return [L("M01_Stale").replace("{value}", stale.map(fileLabel).join(", ")), again]; }; - /* --- 좌측: 그룹 탭 + 파일 --- */ - const tabs = el("div", { className: "m01-master__tabs" }); - const files = el("div", { className: "m01-master__files" }); - - const reloadFiles = async (): Promise => { - if (!group) return; - let list: FileInfo[] = []; - try { - list = await fetchFiles(group); - } catch (error) { - showToast(error instanceof Error ? error.message : L("M01_LoadFailed"), "error"); - } - files.replaceChildren( - ...list.map((f) => { - const button = el("button", { - className: "m01-master__file", - attrs: { type: "button" }, - children: [ - el("span", { text: fileLabel(f.file) }), - el("span", { className: "m01-master__muted", text: f.rows.toLocaleString("ko-KR") }), - ], - }); - button.classList.toggle("is-active", f.file === current?.file); - button.addEventListener("click", () => { - files.querySelector(".is-active")?.classList.remove("is-active"); - button.classList.add("is-active"); - openFile(f); - }); - return button; - }), - ); - const again = list.find((f) => f.file === current?.file); - if (again) openFile(again); - }; - - for (const g of groups) { - const tab = el("button", { - className: "m01-master__tab", - attrs: { type: "button" }, - children: [ - el("span", { text: g.group }), - el("span", { className: "m01-master__muted", text: String(g.files) }), - ], - }); - tab.addEventListener("click", () => { - tabs.querySelector(".is-active")?.classList.remove("is-active"); - tab.classList.add("is-active"); - group = g.group; - current = null; - dispose(); - body.replaceChildren(); - title.textContent = L("M01_PickFile"); - void reloadFiles(); - }); - tabs.append(tab); - } - - const logicTab = el("button", { - className: "m01-master__tab", - attrs: { type: "button" }, - text: L("M01_LogicTab"), - }); - logicTab.addEventListener("click", () => { - tabs.querySelector(".is-active")?.classList.remove("is-active"); - logicTab.classList.add("is-active"); - group = ""; - current = null; + /* --- 좌측: 컨테이너 여덟 --- */ + const side = buildSide(openFile, () => { dispose(); - files.replaceChildren(); - body.replaceChildren(); - title.textContent = L("M01_LogicTab"); - void import("./M01_MasterData_UI_Logic_Page").then((m) => m.mountM01Logic(body)); + dispose = (): void => {}; + current = null; + showMode(true); + if (logicMounted) return; + logicMounted = true; + void import("./M01_MasterData_UI_Logic_Page").then((m) => + m.mountM01Logic(logicHost, side.logicHost), + ); }); - tabs.append(logicTab); const layout = el("div", { className: "ui-workflow-layout m01-master" }); const main = el("main", { className: "ui-workflow-layout__main", - children: [el("div", { className: "m01-master__panel", children: [head, notice, body] })], + children: [el("div", { className: "m01-master__panel", children: [elementView, logicHost] })], }); const overlays = createWorkflowOverlays({ title: L("M01_Title"), - optionsContent: el("div", { className: "m01-master__side", children: [tabs, files] }), + optionsContent: el("div", { className: "m01-master__side", children: [side.root] }), showProjectName: false, onOptionsOpenChange: (isOpen) => layout.classList.toggle("is-options-open", isOpen), }); diff --git a/M01_MasterData/M01_MasterData_UI_Pick.ts b/M01_MasterData/M01_MasterData_UI_Pick.ts index e749af58..712f9264 100644 --- a/M01_MasterData/M01_MasterData_UI_Pick.ts +++ b/M01_MasterData/M01_MasterData_UI_Pick.ts @@ -23,7 +23,7 @@ export interface PickOptions { /** 처음 찾을 글 — 그 재료 이름·규격 (없는 결과면 첫 낱말만으로 다시). */ seed: string; /** 고르면 ref · 연결 끊기 = null. */ - onPick: (ref: string | null) => void; + onPick: (ref: string | null, name?: string) => void; /** 있으면 「후보 조건으로 연결」 칸이 뜸. */ cond?: { 이름: string; 규격: string; onCond: (c: Cond) => void }; } @@ -99,7 +99,7 @@ export function openPickModal(opt: PickOptions): void { ], }); b.addEventListener("click", () => { - opt.onPick(it.ref); + opt.onPick(it.ref, `${it.이름} ${it.규격}`.trim()); close(); }); return b; diff --git a/M01_MasterData/M01_MasterData_UI_Rows.ts b/M01_MasterData/M01_MasterData_UI_Rows.ts index 017adf03..f76c25f1 100644 --- a/M01_MasterData/M01_MasterData_UI_Rows.ts +++ b/M01_MasterData/M01_MasterData_UI_Rows.ts @@ -71,8 +71,20 @@ const linkText = (link: unknown): string => ? `${L("M01_PriceCondUsed")}: ${(link as Row)["이름"]} ${(link as Row)["규격"] ?? ""}`.trim() : String(link ?? ""); +/** 키 → 이름(서버가 준 것 + 방금 고른 것) — 참조 칸에 키 옆에 같이 보임 */ +const names = new Map(); +const KEY = /[A-Z]{2}\d{6}/; +const named = (text: string): string => { + const name = names.get(KEY.exec(text)?.[0] ?? ""); + return name ? `${text} · ${name}` : text; +}; +const remember = (ref: string | null, name?: string): void => { + const key = KEY.exec(ref ?? "")?.[0]; + if (key && name) names.set(key, name); +}; + /** 돌려받은 함수 = 이 화면을 걷을 때 부를 해제. */ -export function renderRows(host: HTMLElement, file: string, q: string): () => void { +export function renderRows(host: HTMLElement, file: string, q: string, sub = ""): () => void { let page = 1; let data: RowsPage | null = null; let unlinked = false; @@ -162,7 +174,11 @@ export function renderRows(host: HTMLElement, file: string, q: string): () => vo ] : []; const add = createButton({ label: L("M01_RowAdd"), variant: "ghost" }); - add.addEventListener("click", () => addRow(file, d.version, blank(d.rows[0] ?? FALLBACK))); + add.addEventListener("click", () => { + const fresh = blank(d.rows[0] ?? FALLBACK); + const col = ["조사", "세부분류"].find((c) => c in fresh); + addRow(file, d.version, sub && col ? { ...fresh, [col]: sub } : fresh); + }); const empty = d.rows.length || adds.length ? [] @@ -192,7 +208,8 @@ export function renderRows(host: HTMLElement, file: string, q: string): () => vo const load = async (): Promise => { try { - data = await fetchRows(file, page, SIZE, q, unlinked); + data = await fetchRows(file, page, SIZE, q, unlinked, sub); + for (const [key, name] of Object.entries(data.refs ?? {})) names.set(key, name); paint(); } catch (error) { showToast(error instanceof Error ? error.message : L("M01_LoadFailed"), "error"); @@ -222,7 +239,7 @@ const rowBtn = (text: string): HTMLButtonElement => /** 시중물가 — 조달 값 · 기준 · 조달 연결(「나라장터:<키>」 · 「가격정보」 줄은 원천이라 그대로). */ function marketCells(row: Row, cur: Row, put?: Put): HTMLTableCellElement[] { const link = show(deep(cur, SOURCE)); - const td = buildCell(link, { changed: link !== show(deep(row, SOURCE)) }); + const td = buildCell(named(link), { changed: link !== show(deep(row, SOURCE)) }); if (put && (!link || link.startsWith(NARA))) { const btn = rowBtn(L("M01_ProcureFind")); btn.addEventListener("click", () => @@ -231,7 +248,10 @@ function marketCells(row: Row, cur: Row, put?: Put): HTMLTableCellElement[] { title: L("M01_ProcureTitle"), current: link, seed: `${cur["이름"] ?? ""} ${cur["규격"] ?? ""}`.trim(), - onPick: (ref) => put(withDeep(cur, SOURCE, ref)), + onPick: (ref, name) => { + remember(ref, name); + put(withDeep(cur, SOURCE, ref)); + }, }), ); td.append(btn); @@ -246,7 +266,7 @@ function marketCells(row: Row, cur: Row, put?: Put): HTMLTableCellElement[] { /** 품셈재료 — 가격 연결(시중물가 키 · 후보 조건 · 없음) · 고르기 모달. */ function linkedCell(row: Row, cur: Row, put?: Put): HTMLTableCellElement { const link = cur["연결"]; - const td = buildCell(linkText(link), { + const td = buildCell(named(linkText(link)), { changed: JSON.stringify(link ?? null) !== JSON.stringify(row["연결"] ?? null), }); if (put) { @@ -260,7 +280,10 @@ function linkedCell(row: Row, cur: Row, put?: Put): HTMLTableCellElement { title: `${L("M01_PriceTitle")} · ${cur["이름"] ?? ""}`, current: linkText(link), seed: `${name} ${spec}`.trim(), - onPick: (ref) => put(withCell(cur, "연결", ref)), + onPick: (ref, name) => { + remember(ref, name); + put(withCell(cur, "연결", ref)); + }, cond: { 이름: name, 규격: spec, onCond: (c) => put(withCell(cur, "연결", c)) }, }), ); @@ -272,7 +295,7 @@ function linkedCell(row: Row, cur: Row, put?: Put): HTMLTableCellElement { /** 값 없는 인력 줄(건설업 미공표 · 준용대상)의 준용 = 공표 직종 키 고르기. */ function wageCell(row: Row, cur: Row, put?: Put): HTMLTableCellElement { const text = show(cur["준용"]); - const td = buildCell(text, { changed: text !== show(row["준용"]) }); + const td = buildCell(named(text), { changed: text !== show(row["준용"]) }); if (put && row["값"] === null) { const btn = rowBtn(L("M01_JobFind")); btn.addEventListener("click", () => @@ -281,7 +304,10 @@ function wageCell(row: Row, cur: Row, put?: Put): HTMLTableCellElement { title: `${L("M01_JobTitle")} · ${cur["이름"] ?? ""}`, current: text, seed: "", - onPick: (ref) => put(withCell(cur, "준용", ref)), + onPick: (ref, name) => { + remember(ref, name); + put(withCell(cur, "준용", ref)); + }, }), ); td.append(btn); diff --git a/M01_MasterData/M01_MasterData_UI_Side.ts b/M01_MasterData/M01_MasterData_UI_Side.ts new file mode 100644 index 00000000..6b3afa43 --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_Side.ts @@ -0,0 +1,163 @@ +/* ============================================================================= + * M01_MasterData_UI_Side.ts + * 왼쪽 패널 — 공용 접기 컨테이너(`ui-collapsible ui-sidebar-section`) 여덟 개 + * 인력 · 재료 · 기계 · 소요량 · 계수 · 환율 · 요율 · 일위대가 로직 + * 펼치면 하위 목록(거름 · 파일) · 환율/요율은 누르면 바로 표 · 로직은 안에 로직 목록 + * ========================================================================== */ + +import { 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, type FileInfo } from "./M01_MasterData_Api_Fetch"; + +/** 요소 화면에서 열 것 — 파일 · 하위 거름(조사·세부분류) · 제목 */ +export interface Pick { + group: string; + file: FileInfo; + sub: string; + label: string; +} + +export interface SideHandle { + root: HTMLElement; + /** 일위대가 로직 컨테이너 안(로직 목록이 들어갈 자리) */ + logicHost: HTMLElement; + /** 그룹의 파일 판본을 다시 받음(저장 뒤) — 돌려받는 것 = 새 목록 */ + refresh: (group: string) => Promise; + setActive: (id: string | null) => void; +} + +const LABOR_SURVEYS = [ + "건설업", + "제조업", + "엔지니어링", + "측량", + "건설사업관리", + "SW", + "산림", + "준용대상", +]; +const MACHINE_KINDS = ["건설품셈", "산림품셈"]; +const MATERIAL_NAMES: Record = { 나라장터자재: "나라장터", 오피넷유가: "유가" }; + +/** 파일 이름 → 목록 글자 — 「소요량_건설품셈_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[] = ["환율", "요율"]; + +export function buildSide(onOpen: (pick: Pick) => void, onLogic: () => void): SideHandle { + const lists = new Map(); + const bodies = new Map(); + const buttons = new Map(); + + const setActive = (id: string | null): void => { + for (const [key, button] of buttons) button.classList.toggle("is-active", key === id); + }; + + const item = (id: string, text: string, count: number | null, run: () => void): HTMLElement => { + const button = el("button", { + className: "m01-side__item", + attrs: { type: "button" }, + children: [ + el("span", { text }), + ...(count === null + ? [] + : [el("span", { className: "m01-side__muted", text: count.toLocaleString("ko-KR") })]), + ], + }); + buttons.set(id, button); + button.addEventListener("click", () => { + setActive(id); + run(); + }); + return button; + }; + + 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): void => + onOpen({ group, file, sub, label }); + const one = files[0]; + if (group === "인력" || group === "기계") { + const kinds = group === "인력" ? LABOR_SURVEYS : MACHINE_KINDS; + body.replaceChildren( + ...(one + ? [ + item(`${group}\n`, L("M01_All"), one.rows, () => open(one, "", L("M01_All"))), + ...kinds.map((k) => item(`${group}\n${k}`, k, null, () => open(one, k, k))), + ] + : []), + ); + return; + } + body.replaceChildren( + ...files.map((f) => { + const name = fileLabel(f.file); + const label = group === "재료" ? (MATERIAL_NAMES[name] ?? name) : name; + return item(`${group}\n${f.file}`, label, f.rows, () => open(f, "", label)); + }), + ); + }; + + const refresh = async (group: string): Promise => { + const list = await fetchFiles(group); + 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: "", label: L(GROUP_TITLE[group]) }); + }); + } + return root; + }); + + const logicHost = el("div", { className: "m01-side__items" }); + const logic = section(L("M01_GroupLogic"), logicHost); + logic.querySelector(".ui-collapsible__title")?.addEventListener("click", () => { + setActive(null); + onLogic(); + }); + + const root = el("div", { className: "m01-side", children: [...groups, logic] }); + attachCollapsible(root); + void Promise.all(GROUPS.map(refresh)).catch((error) => + showToast(error instanceof Error ? error.message : L("M01_LoadFailed"), "error"), + ); + return { root, logicHost, refresh, setActive }; +} diff --git a/M01_MasterData/M01_MasterData_UI_Style.css b/M01_MasterData/M01_MasterData_UI_Style.css index 35cc3d74..2499d41b 100644 --- a/M01_MasterData/M01_MasterData_UI_Style.css +++ b/M01_MasterData/M01_MasterData_UI_Style.css @@ -4,33 +4,53 @@ } .m01-master__side { - display: flex; - flex-direction: column; - gap: var(--spacing-8); padding-top: var(--spacing-8); } -.m01-master__tabs { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: var(--spacing-4); +/* 왼쪽 패널 — 공용 접기 컨테이너 여덟 */ +.m01-side { + display: flex; + flex-direction: column; + gap: var(--spacing-8); } -.m01-master__files { +.m01-side__group { + padding: calc(var(--spacing-8) + var(--spacing-4)); + border-radius: var(--radius-cards); + background: var(--color-surface-raised); +} + +.m01-side__group > h3 { + margin: 0; + color: var(--color-text); + font-size: var(--text-body-sm); +} + +.m01-side__leaf > h3::after { + display: none; +} + +.m01-side__leaf.is-active > h3 { + color: var(--color-accent); +} + +.m01-side__items { display: flex; flex-direction: column; gap: 2px; + max-height: 50vh; + margin-top: var(--spacing-8); + overflow: auto; } -.m01-master__tab, -.m01-master__file { +.m01-side__item { display: flex; justify-content: space-between; gap: var(--spacing-8); padding: var(--spacing-4) var(--spacing-8); - border: 1px solid var(--color-border); + border: 0; border-radius: var(--radius-buttons); - background: var(--color-surface); + background: transparent; color: var(--color-text-body); font: inherit; font-size: var(--text-body-sm); @@ -38,22 +58,16 @@ cursor: pointer; } -.m01-master__file { - border-color: transparent; - background: transparent; -} - -.m01-master__tab:hover, -.m01-master__file:hover { +.m01-side__item:hover { background: var(--color-paper); } -.m01-master__tab.is-active, -.m01-master__file.is-active { +.m01-side__item.is-active { background: var(--color-mist-violet); color: var(--color-accent); } +.m01-side__muted, .m01-master__muted { color: var(--color-text-muted); font-size: var(--text-caption); diff --git a/M01_MasterData/M01_MasterData_UI_Tables.ts b/M01_MasterData/M01_MasterData_UI_Tables.ts index f70b3920..482a8253 100644 --- a/M01_MasterData/M01_MasterData_UI_Tables.ts +++ b/M01_MasterData/M01_MasterData_UI_Tables.ts @@ -26,7 +26,7 @@ import { const LIST_SIZE = 30; /** 돌려받은 함수 = 이 화면을 걷을 때 부를 해제. */ -export function renderTables(host: HTMLElement, file: string, q: string): () => void { +export function renderTables(host: HTMLElement, file: string, q: string, _sub = ""): () => void { let stop = (): void => {}; const list = (page = 1): void => { stop(); diff --git a/ui_template/ui_template_locale_m1.ts b/ui_template/ui_template_locale_m1.ts index acaa66ea..40ec02f6 100644 --- a/ui_template/ui_template_locale_m1.ts +++ b/ui_template/ui_template_locale_m1.ts @@ -55,4 +55,13 @@ export const ui_locales_m1 = { M01_JobFind: ["직종 고르기", "Pick job"], M01_JobTitle: ["준용 직종 고르기", "Pick substitute job"], M01_JobCol: ["준용", "Substitute"], + M01_All: ["전체", "All"], + M01_GroupLabor: ["인력", "Labor"], + M01_GroupMaterial: ["재료", "Materials"], + M01_GroupMachine: ["기계", "Machines"], + M01_GroupQuantity: ["소요량", "Quantities"], + M01_GroupFactor: ["계수", "Factors"], + M01_GroupExchange: ["환율", "Exchange rates"], + M01_GroupRate: ["요율", "Rates"], + M01_GroupLogic: ["일위대가 로직", "Unit-cost logic"], } as const;