- 한 줄 표(compact) 머리만 · 상세 표 머리는 그대로 - 글 줄만: 넣을 값 빈 글 · 전체 항목 · 복사 생성 「단가산출 로직 수」 · 면적산출 알약 없음 · 실무문서 locale · B05 고르기 모달 일위대가 카드 · 면적 알약 없음 · 일위대가 문서 검사 글(「단가산출 로직 n째」) - 시험 기대값: test_m02_item_list 머리 글 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7
253 lines
9.3 KiB
TypeScript
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) };
|
|
}
|