- 쪽 있는 표(요소 · 표 목록 · 로직 목록 · 재료 고르기 모달 · M02 서고 · 새 항목 목록): 줄 수를 표 칸 위치 ~ 화면 아래로 셈(watchFit) · 창 크기 · 글꼴 늦게 실림에도 다시 셈 · 쪽 크기 바뀌어도 첫 줄 유지 - 쪽 단추 = 표 바로 아래 · 줄이 모자라면 표와 함께 위로 붙음 - 쪽 없는 한 장 표(소요량 · 계수): 높이를 화면 아래로 묶고 안에서 스크롤(watchHeight · 표 아래 주 높이 뺌) - UI_Style.css 의 max-height calc(100dvh - 300px) 제거 · m01-master__pager 규칙 제거(부품 ui-pager 로) - 낡은 점검 51-10(쪽 단추가 거름 줄 안에 있다고 봄) = 표 바로 아래 ui-pager 로 고침
256 lines
9.4 KiB
TypeScript
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) };
|
|
}
|