Files
Aislo/M02_MasterTemplete/M02_MasterTemplete_ItemList.ts
T
eomsangdon 1a931e2ea1 feat(709-2): M01 표 · M02 목록 줄 수 = 화면 높이 · 쪽 단추 표 아래 · 한 장 표 화면 안 스크롤
- 쪽 있는 표(요소 · 표 목록 · 로직 목록 · 재료 고르기 모달 · M02 서고 · 새 항목 목록): 줄 수를 표 칸 위치 ~ 화면 아래로 셈(watchFit) · 창 크기 · 글꼴 늦게 실림에도 다시 셈 · 쪽 크기 바뀌어도 첫 줄 유지
- 쪽 단추 = 표 바로 아래 · 줄이 모자라면 표와 함께 위로 붙음
- 쪽 없는 한 장 표(소요량 · 계수): 높이를 화면 아래로 묶고 안에서 스크롤(watchHeight · 표 아래 주 높이 뺌)
- UI_Style.css 의 max-height calc(100dvh - 300px) 제거 · m01-master__pager 규칙 제거(부품 ui-pager 로)
- 낡은 점검 51-10(쪽 단추가 거름 줄 안에 있다고 봄) = 표 바로 아래 ui-pager 로 고침
2026-11-01 15:19:35 +09:00

256 lines
9.4 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 { PAGER_ROOM, pageKeepFirst, watchFit } from "@ui/ui_template_pager";
import { dateText } from "../common_util/common_util_time";
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";
import { createEmpty } from "@ui/ui_template_empty";
/** 목록 종류 — 하위 컨테이너 셋 + 항목 통째(복사 생성) */
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 => dateText(iso, "");
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[];
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;
let size = SIZE; // 붙은 뒤 화면 높이로 다시 셈
const searchField = createInputField({ type: "search", placeholder: TEXT.search });
const search = searchField.input;
const grid = el("div", { className: "ui-table-wrap m01-master__grid-wrap" });
let pager = buildPager(0, SIZE, 1, () => {});
const note = (text: string): void => {
grid.replaceChildren(createEmpty({ text, small: true }));
};
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, size);
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: "ui-text ui-text--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: "ui-table 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]), grid, pager],
});
// 모달 안 목록 — 모달이 줄 수에 따라 움직이니 화면 위치 대신 높이 한도로 셈
watchFit(
root,
() => grid,
(rows) => {
if (rows === size) return;
page = pageKeepFirst(page, size, rows);
size = rows;
draw();
},
{
cap: () => Math.min(window.innerHeight * 0.6, window.innerHeight - 300) + PAGER_ROOM,
once: true,
},
);
// 처음 = 빈 표 자리(줄이 오면 `show`)
note(TEXT.loading);
return { root, show, message: (text = TEXT.loading) => note(text) };
}