diff --git a/M01_MasterData/M01_MasterData_UI_Logic_List.ts b/M01_MasterData/M01_MasterData_UI_Logic_List.ts index 0f681d0f..31dc2d58 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_List.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_List.ts @@ -5,6 +5,7 @@ * ========================================================================== */ import { el } from "@ui/ui_template_elements"; +import { renderTree, type MakeRow, type TreeNode } from "./M01_MasterData_UI_Tree"; import type { LogicSummary } from "./M01_MasterData_UI_Logic_Api"; import { tx } from "./M01_MasterData_UI_Logic_Text"; @@ -36,8 +37,9 @@ export function buildList(onOpen: (item: ListItem) => void): ListHandle { let extra: ListItem[] = []; let marks = new Map(); let active: string | null = null; - const book = el("select", { className: "m01-logic__input" }); - const chapter = el("select", { className: "m01-logic__input" }); + /** 트리에서 고른 범위 — 원문 · 부문 · 장(비면 전체) */ + const pick = { book: "", division: "", chapter: "" }; + const tree = el("div", { className: "m01-logic__tree" }); const search = el("input", { className: "m01-logic__input", attrs: { type: "search", placeholder: tx("List_Search") }, @@ -46,25 +48,89 @@ export function buildList(onOpen: (item: ListItem) => void): ListHandle { const count = el("span", { className: "m01-logic__muted" }); const list = el("ul", { className: "m01-logic__list" }); - const options = (select: HTMLSelectElement, all: string, values: string[]): void => { - const keep = select.value; - select.replaceChildren( - el("option", { text: all, attrs: { value: "" } }), - ...values.map((v) => el("option", { text: v, attrs: { value: v } })), - ); - select.value = values.includes(keep) ? keep : ""; + const divisionOf = (chapter: string): string => /^(\S+) \d+장/.exec(chapter)?.[1] ?? ""; + const inPick = (x: ListItem): boolean => + (!pick.book || x.book === pick.book) && + (!pick.division || divisionOf(x.chapter) === pick.division) && + (!pick.chapter || x.chapter === pick.chapter); + + const make: MakeRow = (node, caret, depth, onClick) => { + const button = el("button", { + className: `m01-side__item${node.id === pickId ? " is-active" : ""}`, + attrs: { type: "button" }, + children: [ + el("span", { + children: [ + el("span", { className: "m01-tree__caret", text: caret }), + el("span", { text: node.label }), + ], + }), + el("span", { className: "m01-side__muted", text: String(node.count ?? "") }), + ], + }); + button.style.paddingLeft = `calc(var(--spacing-8) + ${depth} * var(--spacing-16))`; + button.addEventListener("click", () => { + onClick(); + for (const b of tree.querySelectorAll(".is-active")) b.classList.remove("is-active"); + button.classList.add("is-active"); + }); + return button; + }; + let pickId = ""; + /** 원문 › 부문 › 장 — 눌러 범위를 고름 */ + const drawTree = (): void => { + const count = (f: (x: ListItem) => boolean): number => items.filter(f).length; + const books = [...new Set(items.map((x) => x.book))]; + const set = (id: string, book: string, division: string, chapter: string): void => { + pickId = id; + Object.assign(pick, { book, division, chapter }); + draw(); + }; + const nodes: TreeNode[] = [ + { + id: "all", + label: tx("List_AllBooks"), + count: items.length, + open: true, + run: () => set("all", "", "", ""), + children: books.map((b): TreeNode => { + const chapters = [...new Set(items.filter((x) => x.book === b).map((x) => x.chapter))]; + const leaf = (c: string, label: string): TreeNode => ({ + id: `${b}|${c}`, + label, + count: count((x) => x.book === b && x.chapter === c), + run: () => set(`${b}|${c}`, b, "", c), + }); + const divisions = [...new Set(chapters.map(divisionOf).filter(Boolean))]; + return { + id: b, + label: b, + count: count((x) => x.book === b), + run: () => set(b, b, "", ""), + children: [ + ...chapters.filter((c) => !divisionOf(c)).map((c) => leaf(c, c)), + ...divisions.map((d): TreeNode => ({ + id: `${b}|#${d}`, + label: d, + count: count((x) => x.book === b && divisionOf(x.chapter) === d), + run: () => set(`${b}|#${d}`, b, d, ""), + children: chapters + .filter((c) => divisionOf(c) === d) + .map((c) => leaf(c, c.slice(d.length + 1))), + })), + ], + }; + }), + }, + ]; + tree.replaceChildren(...renderTree(nodes, make)); }; - const chapters = (): void => - options(chapter, tx("List_AllChapters"), [ - ...new Set(items.filter((x) => !book.value || x.book === book.value).map((x) => x.chapter)), - ]); const draw = (): void => { const q = search.value.trim().toLowerCase(); const shown = [...extra, ...items].filter( (x) => - (!book.value || x.book === book.value) && - (!chapter.value || x.chapter === chapter.value) && + inPick(x) && (!blockedOnly.checked || x.blocked) && (!q || [x.key, x.number, x.name].some((v) => v.toLowerCase().includes(q))), ); @@ -103,11 +169,6 @@ export function buildList(onOpen: (item: ListItem) => void): ListHandle { return el("li", { children: [button] }); }; - book.addEventListener("change", () => { - chapters(); - draw(); - }); - chapter.addEventListener("change", draw); search.addEventListener("input", draw); blockedOnly.addEventListener("change", draw); @@ -117,8 +178,6 @@ export function buildList(onOpen: (item: ListItem) => void): ListHandle { el("div", { className: "m01-logic__filters", children: [ - book, - chapter, search, el("label", { className: "m01-logic__check", @@ -130,6 +189,7 @@ export function buildList(onOpen: (item: ListItem) => void): ListHandle { list, ], }); + root.prepend(tree); return { root, setItems: (logics) => { @@ -143,8 +203,7 @@ export function buildList(onOpen: (item: ListItem) => void): ListHandle { blocked: x.blocked, reasons: x.reasons, })); - options(book, tx("List_AllBooks"), [...new Set(items.map((x) => x.book))]); - chapters(); + drawTree(); draw(); }, setMarks: (next, more) => { diff --git a/M01_MasterData/M01_MasterData_UI_Rows.ts b/M01_MasterData/M01_MasterData_UI_Rows.ts index f76c25f1..bd945d26 100644 --- a/M01_MasterData/M01_MasterData_UI_Rows.ts +++ b/M01_MasterData/M01_MasterData_UI_Rows.ts @@ -34,6 +34,7 @@ const SIZE = 50; const MARKET = "재료_시중물가.json"; const LINKED = "재료_품셈재료.json"; const JOB = ["인력.json"]; +const NOTE = "비고"; const SUPPLY = ["값", "조달"]; const SOURCE = [...SUPPLY, "출처"]; const NARA = "나라장터:"; @@ -49,6 +50,14 @@ function blank(sample: Row): Row { return out; } +/** 비고 칸 고침 — 빈 글이면 칸을 지움 */ +function withNote(row: Row, text: string): Row { + const out = { ...row }; + if (text.trim()) out[NOTE] = text; + else delete out[NOTE]; + return out; +} + /** 요소별 전용 칸으로 그리는 칸 — 일반 칸에서 뺌. */ function hidden(file: string, col: string): boolean { if (file === MARKET) return col === `값${SEP}조달`; @@ -97,7 +106,7 @@ export function renderRows(host: HTMLElement, file: string, q: string, sub = "") const cols: string[] = []; for (const row of [...adds, ...d.rows]) { for (const k of Object.keys(flatten(row))) - if (!hidden(file, k) && !cols.includes(k)) cols.push(k); + if (!hidden(file, k) && k !== NOTE && !cols.includes(k)) cols.push(k); } const head = el("tr", { children: [ @@ -106,6 +115,7 @@ export function renderRows(host: HTMLElement, file: string, q: string, sub = "") ...(HEADS[file] ?? []).map((h) => el("th", { text: h.startsWith("M01_") ? L(h as "M01_PriceCol") : h }), ), + el("th", { text: NOTE }), // 비고 — 항상 끝 열(줄에 없으면 빈 칸 · 눌러 적음) ], }); const body = el("tbody"); @@ -122,6 +132,12 @@ export function renderRows(host: HTMLElement, file: string, q: string, sub = "") }), ); } + tr.append( + buildCell(show(row[NOTE]), { + changed: true, + onEdit: (text) => setAdd(file, i, withNote(row, text)), + }), + ); body.append(tr); }); @@ -156,6 +172,15 @@ export function renderRows(host: HTMLElement, file: string, q: string, sub = "") if (file === MARKET) tr.append(...marketCells(row, cur, put)); if (file === LINKED) tr.append(linkedCell(row, cur, put)); if (JOB.includes(file)) tr.append(wageCell(row, cur, put)); + tr.append( + buildCell(show(cur[NOTE]), { + changed: !same(cur[NOTE], row[NOTE]), + onEdit: + deleted || !isScalar(row[NOTE]) + ? undefined + : (text) => editRow(file, d.version, key, row, withNote(cur, text)), + }), + ); body.append(tr); } diff --git a/M01_MasterData/M01_MasterData_UI_Side.ts b/M01_MasterData/M01_MasterData_UI_Side.ts index 6b3afa43..7d432e6c 100644 --- a/M01_MasterData/M01_MasterData_UI_Side.ts +++ b/M01_MasterData/M01_MasterData_UI_Side.ts @@ -8,7 +8,8 @@ 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"; +import { fetchFiles, fetchRows, type FileInfo } from "./M01_MasterData_Api_Fetch"; +import { bookTree, renderTree, type MakeRow, type TreeNode } from "./M01_MasterData_UI_Tree"; /** 요소 화면에서 열 것 — 파일 · 하위 거름(조사·세부분류) · 제목 */ export interface Pick { @@ -62,26 +63,39 @@ export function buildSide(onOpen: (pick: Pick) => void, onLogic: () => void): Si const lists = new Map(); const bodies = new Map(); const buttons = new Map(); + const counts = 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 make: MakeRow = (node, caret, depth, onClick) => { const button = el("button", { className: "m01-side__item", attrs: { type: "button" }, children: [ - el("span", { text }), - ...(count === null + 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: count.toLocaleString("ko-KR") })]), + : [ + el("span", { + className: "m01-side__muted", + text: node.count.toLocaleString("ko-KR"), + }), + ]), ], }); - buttons.set(id, button); + button.style.paddingLeft = `calc(var(--spacing-8) + ${depth} * var(--spacing-16))`; + if (node.run) buttons.set(node.id, button); button.addEventListener("click", () => { - setActive(id); - run(); + if (node.run) setActive(node.id); + onClick(); }); return button; }; @@ -95,27 +109,67 @@ export function buildSide(onOpen: (pick: Pick) => void, onLogic: () => void): Si 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))), - ] - : []), + const all: TreeNode[] = one + ? [ + { + id: `${group}\n`, + label: L("M01_All"), + count: one.rows, + open: true, + run: () => open(one, "", L("M01_All")), + children: kinds.map((k) => ({ + id: `${group}\n${k}`, + label: k, + count: counts.get(`${group}\n${k}`) ?? null, + run: () => open(one, k, k), + })), + }, + ] + : []; + body.replaceChildren(...renderTree(all, make)); + return; + } + if (group === "소요량" || group === "계수") { + const tree = bookTree( + files, + (f, division): TreeNode => { + const label = fileLabel(f.file); + return { + id: `${group}\n${f.file}`, + label: division ? f.chapter.slice(division.length + 1) : f.chapter, + count: f.rows, + run: () => open(f, "", label), + }; + }, + (parts) => `${group}\n#${parts.join("\n")}`, + (list) => list.reduce((n, f) => n + f.rows, 0), ); + body.replaceChildren(...renderTree(tree, make)); 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)); - }), + ...renderTree( + files.map((f) => { + const name = fileLabel(f.file); + const label = group === "재료" ? (MATERIAL_NAMES[name] ?? name) : name; + return { 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); + const kinds = group === "인력" ? LABOR_SURVEYS : group === "기계" ? MACHINE_KINDS : []; + if (list[0] && kinds.length) { + const file = list[0].file; + await Promise.all( + kinds.map(async (k) => + counts.set(`${group}\n${k}`, (await fetchRows(file, 1, 1, "", false, k)).total), + ), + ); + } lists.set(group as Group, list); drawGroup(group as Group); return list; diff --git a/M01_MasterData/M01_MasterData_UI_Style.css b/M01_MasterData/M01_MasterData_UI_Style.css index 2499d41b..d9548f8f 100644 --- a/M01_MasterData/M01_MasterData_UI_Style.css +++ b/M01_MasterData/M01_MasterData_UI_Style.css @@ -67,6 +67,24 @@ color: var(--color-accent); } +.m01-tree__kids { + display: flex; + flex-direction: column; + gap: 2px; +} + +.m01-tree__branch { + display: flex; + flex-direction: column; + gap: 2px; +} + +.m01-tree__caret { + display: inline-block; + width: var(--spacing-12); + color: var(--color-text-muted); +} + .m01-side__muted, .m01-master__muted { color: var(--color-text-muted); diff --git a/M01_MasterData/M01_MasterData_UI_Tree.ts b/M01_MasterData/M01_MasterData_UI_Tree.ts new file mode 100644 index 00000000..b97bfad5 --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_Tree.ts @@ -0,0 +1,77 @@ +/* ============================================================================= + * M01_MasterData_UI_Tree.ts + * 왼쪽 패널 트리 — 부모(접기/펴기) · 자식(들여쓰기) · 줄 수 표시 + * ========================================================================== */ + +import { el } from "@ui/ui_template_elements"; + +export interface TreeNode { + id: string; + label: string; + /** 줄 수 — null 이면 안 보임 */ + count: number | null; + /** 눌렀을 때 — 없으면 접기/펴기만 */ + run?: () => void; + children?: TreeNode[]; + /** 처음부터 펴짐 */ + open?: boolean; +} + +/** 줄(버튼) 만들기 — 돌려받은 것 = 그 버튼 · click 은 부르는 쪽이 이음 */ +export type MakeRow = ( + node: TreeNode, + caret: string, + depth: number, + onClick: () => void, +) => HTMLElement; + +export function renderTree(nodes: TreeNode[], make: MakeRow, depth = 0): HTMLElement[] { + return nodes.map((node) => { + if (!node.children?.length) return make(node, "", depth, () => node.run?.()); + const kids = el("div", { + className: "m01-tree__kids", + children: renderTree(node.children, make, depth + 1), + }); + kids.hidden = !node.open; + const row = make(node, kids.hidden ? "▸" : "▾", depth, () => { + kids.hidden = !kids.hidden; + const caret = row.querySelector(".m01-tree__caret"); + if (caret) caret.textContent = kids.hidden ? "▸" : "▾"; + node.run?.(); + }); + return el("div", { className: "m01-tree__branch", children: [row, kids] }); + }); +} + +/** 파일 목록 → 원문 › 부문 › 장 — 장 이름이 「공통 03장 …」 이면 부문 「공통」 */ +export function bookTree( + items: T[], + leaf: (item: T, division: string) => TreeNode, + id: (parts: string[]) => string, + count: (list: T[]) => number, +): TreeNode[] { + const books = new Map>(); + for (const it of items) { + const division = /^(\S+) \d+장/.exec(it.chapter)?.[1] ?? ""; + const divs = books.get(it.book ?? "") ?? new Map(); + divs.set(division, [...(divs.get(division) ?? []), it]); + books.set(it.book ?? "", divs); + } + return [...books].map(([book, divs]) => ({ + id: id([book]), + label: book, + count: count([...divs.values()].flat()), + children: [...divs].flatMap(([division, list]) => + division + ? [ + { + id: id([book, division]), + label: division, + count: count(list), + children: list.map((it) => leaf(it, division)), + }, + ] + : list.map((it) => leaf(it, "")), + ), + })); +}