/* ============================================================================= * M02_MasterTemplete_ItemList.ts * 구조물 항목 목록 하나(PLAN 39-5) — 「전체 항목」(도면 · 일위대가 · 면적산출) · 복사 생성 · 항목 하나 * 복사가 같이 씀. 꼴 = M01 마스터 화면 — 찾기 줄(`buildHeadFilters`) · 쪽(`buildPager`) · 표 * (`m01-master__grid`) 를 M01 파일에서 그대로 import(옮기지 않음). * 자료 = 층 나무 항목(`GET /layers/{층}/tree` — 서버 `items` 의 `수정일` · `labor` 칸) · 칸은 종류마다. * 찾기 = 보이는 칸 글 전부에서 띄어쓰기 AND(나무 찾기와 같은 `matchText`). * ========================================================================== */ import { createInputField, el } from "@ui/ui_template_elements"; import { buildPager } from "../M01_MasterData/M01_MasterData_UI_Cells"; import { buildHeadFilters } from "../M01_MasterData/M01_MasterData_UI_Head"; import "../M01_MasterData/M01_MasterData_UI_Style.css"; import { fetchTree, type Layer, type TreeItem } from "./M02_MasterTemplete_Api_Fetch"; import { folderOf, leafName, matchText } from "./M02_MasterTemplete_TreePanel_Model"; /** 목록 종류 — 하위 컨테이너 셋 + 항목 통째(복사 생성) */ export type ListKind = "structure" | "area" | "labor" | "item"; /** 나무 항목 줄 + 목록 칸(서버 `M02_MasterTemplete_Items._list_extras`) */ export interface ItemRow extends TreeItem { 도번?: string | null; 면적번?: string | null; 수정일?: Partial>; labor?: { 번호: string | null; 로직수: number; 합계: number | null } | null; } export interface Col { head: string; get: (row: R) => string; } const TEXT = { search: "찾기", none: "항목이 없음", noMatch: "맞는 항목이 없음", loading: "불러오는 중", failed: "목록을 못 받음", }; const SIZE = 30; const DASH = "—"; const day = (iso?: string): string => (iso ? iso.slice(0, 10) : ""); const money = (value: number | null | undefined): string => typeof value === "number" ? value.toLocaleString("ko-KR", { maximumFractionDigits: 0 }) : DASH; /** 가장 늦게 고친 날 — 항목 통째 */ const latest = (row: ItemRow): string => day( Object.values(row.수정일 ?? {}) .sort() .pop(), ); const NAME: Col = { head: "이름", get: (r) => leafName(r) }; const FOLDER: Col = { head: "폴더", get: (r) => folderOf(r).join(" / ") }; const UNIT: Col = { head: "단위", get: (r) => r.단위 ?? "" }; const changed = (part: "structure" | "area" | "labor"): Col => ({ head: "고친 날", get: (r) => day(r.수정일?.[part]), }); /** 종류마다 칸(39-5 · 정함 5장) — 규격 · 층 칸 없음(한 목록 = 한 층) */ export const ITEM_COLS: Record[]> = { structure: [ { head: "도번", get: (r) => r.도번 || DASH }, NAME, FOLDER, UNIT, changed("structure"), ], area: [{ head: "면적번", get: (r) => r.면적번 || DASH }, NAME, FOLDER, UNIT, changed("area")], labor: [ { head: "일위대가번", get: (r) => r.labor?.번호 || DASH }, NAME, FOLDER, UNIT, { head: "담은 로직 수", get: (r) => (r.labor ? String(r.labor.로직수) : DASH) }, { head: "합계", get: (r) => money(r.labor?.합계) }, changed("labor"), ], item: [ NAME, FOLDER, UNIT, { head: "도번", get: (r) => r.도번 || DASH }, { head: "일위대가번", get: (r) => r.labor?.번호 || DASH }, { head: "면적번", get: (r) => r.면적번 || DASH }, { head: "고친 날", get: latest }, ], }; /** 찾기 · 쪽 — 보일 줄 · 맞춘 쪽 · 맞는 줄 수(화면 밖 · 시험이 부름) */ export function pageOf( rows: R[], cols: Col[], query: string, page: number, size = SIZE, ): { shown: R[]; page: number; total: number } { const hits = rows.filter((r) => matchText( cols.map((c) => c.get(r)), query, ), ); const last = Math.max(1, Math.ceil(hits.length / size)); const at = Math.min(Math.max(1, page), last); return { shown: hits.slice((at - 1) * size, at * size), page: at, total: hits.length }; } /** 한 층 항목 — `owner` = 남의 층(없으면 자기 층) */ export const loadItems = async (layer: Layer, owner: string | null): Promise => (await fetchTree(layer, owner)).items as ItemRow[]; /** 「전체 항목」 화면(39-5) — 한 층 · 한 하위 목록 · 줄 누름 = 그 항목 상세(좌측 열 줄 누름과 같음) */ export function buildAllItems( kind: ListKind, layer: Layer, owner: string | null, onOpen: (row: ItemRow) => void, ): HTMLElement { const list = buildItemList(); loadItems(layer, owner).then( (rows) => list.show(rows, ITEM_COLS[kind], onOpen), (error: unknown) => list.message(`${TEXT.failed} — ${error instanceof Error ? error.message : String(error)}`), ); return list.root; } export interface ItemListHandle { root: HTMLElement; /** 줄 · 칸 · 누를 일을 바꿔 그림(찾는 글은 그대로 · 쪽은 1) */ show: (rows: R[], cols: Col[], onPick: (row: R) => void) => void; /** 표 자리에 글 한 줄(불러오는 중 · 못 받음) */ message: (text?: string) => void; } export function buildItemList(): ItemListHandle { let draw = (): void => {}; let page = 1; const searchField = createInputField({ type: "search", placeholder: TEXT.search }); const search = searchField.input; const grid = el("div", { className: "m01-master__grid-wrap" }); let pager = buildPager(0, SIZE, 1, () => {}); const note = (text: string): void => { grid.replaceChildren(el("p", { className: "m01-master__empty", text })); }; const show = (rows: R[], cols: Col[], onPick: (row: R) => void): void => { page = 1; draw = (): void => { const got = pageOf(rows, cols, search.value, page); page = got.page; const next = buildPager(got.total, SIZE, page, (p) => { page = p; draw(); }); pager.replaceWith(next); pager = next; if (!got.shown.length) return note(rows.length ? TEXT.noMatch : TEXT.none); const body = got.shown.map((row) => { const tr = el("tr", { className: "m01-master__table-row", children: cols.map((c) => { const text = c.get(row); return el("td", { text, attrs: { title: text } }); }), }); tr.addEventListener("click", () => onPick(row)); return tr; }); grid.replaceChildren( el("table", { className: "m01-master__grid", children: [ el("thead", { children: [el("tr", { children: cols.map((c) => el("th", { text: c.head })) })], }), el("tbody", { children: body }), ], }), ); }; draw(); }; search.addEventListener("input", () => { page = 1; draw(); }); const root = el("div", { className: "m02-itemlist", children: [buildHeadFilters([searchField.root, pager]), grid], }); // 처음 = 빈 표 자리(줄이 오면 `show`) note(TEXT.loading); return { root, show, message: (text = TEXT.loading) => note(text) }; }