Files
Aislo/M02_MasterTemplete/M02_MasterTemplete_ItemList.ts
T

253 lines
9.3 KiB
TypeScript

/* =============================================================================
* 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, showToast } 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<Record<"structure" | "area" | "labor", string>>;
labor?: {
번호: string | null;
로직수: number;
/** 수량 반영 합 · 기준(단가) 합 · 일위대가 문서 단위 */
합계: number | null;
기준?: number | null;
단위?: string | null;
} | null;
}
export interface Col<R> {
head: string;
get: (row: R) => string;
/** 값 뒤 작은 글(예 「기준」) — 없으면 없음 */
note?: (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(),
);
/** 일위대가 합계(39-8 B2) — 수량 반영 합이 있으면 그것 · 없으면 기준(단가) 합 + 작은 글 「기준」 ·
* 담은 로직 0 · 계산값 없음 = 「—」 */
export function laborSum(row: ItemRow): { text: string; note: string } {
const l = row.labor;
if (!l?.로직수) return { text: DASH, note: "" };
if (l.합계) return { text: money(l.합계), note: "" };
return typeof l.기준 === "number"
? { text: money(l.기준), note: "기준" }
: { text: DASH, note: "" };
}
const NAME: Col<ItemRow> = { head: "이름", get: (r) => leafName(r) };
const FOLDER: Col<ItemRow> = { head: "폴더", get: (r) => folderOf(r).join(" / ") };
const UNIT: Col<ItemRow> = { head: "단위", get: (r) => r.단위 ?? "" };
const changed = (part: "structure" | "area" | "labor"): Col<ItemRow> => ({
head: "고친 날",
get: (r) => day(r.수정일?.[part]),
});
/** 종류마다 칸(39-5 · 정함 5장) — 규격 · 층 칸 없음(한 목록 = 한 층) */
export const ITEM_COLS: Record<ListKind, Col<ItemRow>[]> = {
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,
{ head: "단위", get: (r) => r.labor?.단위 ?? "" }, // 일위대가 문서 단위(39-8 B3)
{ head: "담은 로직 수", get: (r) => (r.labor ? String(r.labor.로직수) : DASH) },
{ head: "합계", get: (r) => laborSum(r).text, note: (r) => laborSum(r).note },
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 },
],
};
const PART_NAME = { structure: "도면", area: "면적산출", labor: "일위대가" } as const;
/** 그 부분 문서가 없는 줄의 까닭(항목 하나 복사 · 39-8 H3) — 옮기기 전 층의 일위대가처럼 파일이 없으면
* 가져올 것이 없음 · 항목 통째(복사 생성)는 없는 문서를 빈 문서로 세워 막지 않음 · 있으면 null */
export function missingPart(kind: ListKind, row: ItemRow): string | null {
if (kind === "item") return null;
const has = kind === "labor" ? !!row.labor : !!row.수정일?.[kind];
return has ? null : `이 항목에는 ${PART_NAME[kind]} 문서가 없음`;
}
/** 찾기 · 쪽 — 보일 줄 · 맞춘 쪽 · 맞는 줄 수(화면 밖 · 시험이 부름) */
export function pageOf<R>(
rows: R[],
cols: Col<R>[],
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<ItemRow[]> =>
(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: <R>(
rows: R[],
cols: Col<R>[],
onPick: (row: R) => void,
blocked?: (row: R) => string | null,
) => 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 = <R>(
rows: R[],
cols: Col<R>[],
onPick: (row: R) => void,
blocked?: (row: R) => string | null,
): 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);
const note = c.note?.(row) ?? "";
return el("td", {
text,
attrs: { title: note ? `${text} ${note}` : text },
children: note
? [el("small", { className: "m01-master__muted", text: ` ${note}` })]
: [],
});
}),
});
// 고를 수 없는 줄 — 흐리게 · 누르면 까닭 한 줄(고르기 안 함)
const why = blocked?.(row) ?? null;
if (why) {
tr.classList.add("is-blocked");
tr.title = why;
}
tr.addEventListener("click", () => (why ? showToast(why, "info") : 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) };
}