- 새 공용 부품 ui_template_hopyo_grid(.ts · .css) — 호표 머리 줄(이름 · 규격 · 단위 · 1 단위 합계) · 구성 줄(품명 · 규격 · 수량 · 단위 · 합계 · 노무비 · 재료비 · 경비 단가 / 금액 · 비고 · 구분 꼬리표) · 합계 줄 빈 줄 붉게 · 부품 모음 견본 칸 - M02 Labor_Grid — 문서 + 서버 calc 답 그대로(단가 = 기준 ÷ 배 · 금액 = 반영 · 합계 = 반영합 · 수량 없음 줄 = 빈 줄) · Labor_Detail 맨 위 격자(카드 · 상세 같이) · 읽기만 카드 = 격자만 - B09 일위대가표(호표보기 · 박스보기 바탕 줄) = 같은 부품 · 줄 값 · 이동 · 줄 꼴 그대로 + 구분 칸 · 머리 1 단위 합계 - 시험: test_704_3 새로 · B09 표 시험 다섯(155 · 157-4 · 201-1 · 202-1 · 304)은 격자 파일을 이어 붙이게 고침(줄 글이 공용 부품으로 옮김) · hopyo_screen 상세 줄 수 693 → 700 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
224 lines
7.5 KiB
TypeScript
224 lines
7.5 KiB
TypeScript
/* =============================================================================
|
||
* ui_template_hopyo_grid.ts
|
||
* 일위대가 엑셀 꼴 격자(PLAN 704-3) — 호표마다 머리 줄 + 구성 줄 + 합계 줄 · 공용 데이터표(`ui_template_data_table`) 위.
|
||
* M02 일위대가 카드 · 상세 · B09 일위대가표(호표보기)가 같은 열 · 같은 줄 꼴을 씀.
|
||
*
|
||
* 머리 줄 = 「제 N 호표 이름」(번호 없으면 이름만) · 규격 · 단위 · 1 단위 합계(합계 금액 칸) · 비고 = 호표 꼴
|
||
* 품셈 인용 줄(있으면) = 「ㄴ 조항」 · 비고 「품셈 인용」
|
||
* 구성 줄 = 「ㄴ 품명」 · 규격 · 품/수량 · 단위 · 합계 · 노무비 · 재료비 · 경비(단가 / 금액 쌍) · 비고 · 구분(줄 꼬리표)
|
||
* 합계 줄 = 비목 금액 합 · 빈 줄(금액 모름 · 수량 없음) 있으면 비고 「빈 줄 n」 붉게 — 합계는 아는 줄만
|
||
* 줄 꼬리표 = 「품셈」(우리 로직이 품을 셈) · 「고정」 갈래는 705 몫(자리만 · 같은 칸)
|
||
* 값은 부르는 쪽이 준 것 그대로(서버 계산 값) — 여기서 셈하지 않음
|
||
*
|
||
* const g = createHopyoGrid([block]); host.append(g.root); g.set([block2]);
|
||
* B09 처럼 탭 표가 따로 그리면 `hopyoGrid(blocks)` 의 열 · 줄 · 제목 줄만 씀
|
||
* ========================================================================== */
|
||
|
||
import {
|
||
createDataTable,
|
||
type DataColumn,
|
||
type DataRow,
|
||
type DataTableOptions,
|
||
} from "./ui_template_data_table";
|
||
import "./ui_template_hopyo_grid.css";
|
||
|
||
export interface HopyoMoney {
|
||
sum?: number | null;
|
||
labor?: number | null;
|
||
material?: number | null;
|
||
expense?: number | null;
|
||
}
|
||
|
||
export interface HopyoLine {
|
||
name: string;
|
||
spec?: string | null;
|
||
/** 규격 원문(풍선 · 부르는 쪽 몫) */
|
||
spec_raw?: unknown;
|
||
/** 품 · 수량(1 단위 당) — null = 수량 없음 */
|
||
qty: number | null;
|
||
unit?: string | null;
|
||
/** 1 단위(위 단위) 당 단가 */
|
||
prices: HopyoMoney;
|
||
/** 단가 × 품/수량 — sum null = 금액 모름(빈 줄) */
|
||
amounts: HopyoMoney;
|
||
note?: string | null;
|
||
/** 줄 꼬리표 — 「품셈」 · 「고정」(705) · 빈 값 = 없음 */
|
||
tag?: string | null;
|
||
}
|
||
|
||
export interface HopyoBlock {
|
||
/** 줄 키 앞머리(머리 = key · 줄 = key|n · 합계 = key|합계) */
|
||
key: string;
|
||
no?: string | number | null;
|
||
name: string;
|
||
spec?: string | null;
|
||
spec_raw?: unknown;
|
||
unit?: string | null;
|
||
/** 호표 꼴(머리 줄 비고) */
|
||
kind?: string | null;
|
||
/** 품셈 인용(조항) */
|
||
cite?: string | null;
|
||
total: HopyoMoney;
|
||
lines: HopyoLine[];
|
||
/** 빈 줄 수 · 이름 — 없으면 금액 모르는 줄에서 셈 */
|
||
blank?: number;
|
||
blank_names?: string[];
|
||
}
|
||
|
||
/** 줄 꼬리표 낱말 */
|
||
export const HOPYO_TAG = { pum: "품셈", fix: "고정" } as const;
|
||
|
||
/** 하위 줄 앞 — 공용 표가 앞 빈칸을 접지 않음(들여쓰기 = level) */
|
||
export const HOPYO_CHILD = "ㄴ ";
|
||
|
||
const HOPYO_COSTS = [
|
||
["sum", "합계"],
|
||
["labor", "노무비"],
|
||
["material", "재료비"],
|
||
["expense", "경비"],
|
||
] as const;
|
||
|
||
const hopyoText = (key: string, label: string, width?: number): DataColumn => ({
|
||
key,
|
||
label,
|
||
width,
|
||
});
|
||
const hopyoMoney = (key: string, label: string, group: string): DataColumn => ({
|
||
key,
|
||
label,
|
||
group: [group],
|
||
kind: "number",
|
||
decimals: 1,
|
||
width: 96,
|
||
});
|
||
|
||
/** 열 — 명칭 · 규격 · 수량 · 단위 · 비목 넷(단가 / 금액) · 비고 · 구분 */
|
||
export const HOPYO_COLUMNS: DataColumn[] = [
|
||
hopyoText("name", "명칭", 240),
|
||
hopyoText("spec", "규격", 160),
|
||
{ key: "qty", label: "수량", kind: "number", decimals: 4, trim: true, width: 76 },
|
||
hopyoText("unit", "단위", 56),
|
||
...HOPYO_COSTS.flatMap(([key, label]) => [
|
||
hopyoMoney(`${key}_price`, "단가", label),
|
||
hopyoMoney(key, "금액", label),
|
||
]),
|
||
hopyoText("note", "비고", 120),
|
||
hopyoText("tag", "구분", 56),
|
||
];
|
||
|
||
export interface HopyoGrid {
|
||
columns: DataColumn[];
|
||
rows: DataRow[];
|
||
/** 머리 · 합계 줄 번호(굵게 · 레벨 1) */
|
||
titles: Set<number>;
|
||
/** 줄 번호 → [호표 차례, 구성 줄 차례] · 구성 줄만 */
|
||
at: Map<number, [number, number]>;
|
||
}
|
||
|
||
const hopyoBlank = (b: HopyoBlock): { n: number; names: string[] } => {
|
||
if (b.blank !== undefined) return { n: b.blank, names: b.blank_names ?? [] };
|
||
const names = b.lines.filter((l) => l.amounts.sum == null).map((l) => l.name);
|
||
return { n: names.length, names };
|
||
};
|
||
|
||
/** 호표 → 표 줄(값 그대로) */
|
||
export function hopyoGrid(blocks: HopyoBlock[]): HopyoGrid {
|
||
const rows: DataRow[] = [];
|
||
const titles = new Set<number>();
|
||
const at = new Map<number, [number, number]>();
|
||
blocks.forEach((b, bi) => {
|
||
titles.add(rows.length);
|
||
rows.push({
|
||
key: b.key,
|
||
name: b.no != null && b.no !== "" ? `제 ${b.no} 호표 ${b.name}` : b.name,
|
||
no: b.no,
|
||
title: b.name,
|
||
spec: b.spec ?? "",
|
||
spec_raw: b.spec_raw,
|
||
unit: b.unit ?? "",
|
||
sum: b.total.sum ?? null, // 1 단위 합계
|
||
note: b.kind ?? "",
|
||
});
|
||
if (b.cite)
|
||
rows.push({ key: `${b.key}|cite`, name: `${HOPYO_CHILD}${b.cite}`, note: "품셈 인용" });
|
||
b.lines.forEach((ln, n) => {
|
||
at.set(rows.length, [bi, n]);
|
||
const row: DataRow = {
|
||
key: `${b.key}|${n}`,
|
||
name: `${HOPYO_CHILD}${ln.name}`,
|
||
spec: ln.spec ?? "",
|
||
spec_raw: ln.spec_raw,
|
||
qty: ln.qty,
|
||
unit: ln.unit ?? "",
|
||
note: ln.note ?? "",
|
||
tag: ln.tag ?? "",
|
||
};
|
||
for (const [key] of HOPYO_COSTS) {
|
||
row[`${key}_price`] = ln.prices[key] ?? null;
|
||
row[key] = ln.amounts[key] ?? null;
|
||
}
|
||
rows.push(row);
|
||
});
|
||
const blank = hopyoBlank(b);
|
||
titles.add(rows.length);
|
||
rows.push({
|
||
key: `${b.key}|합계`,
|
||
name: "합 계",
|
||
sum: b.total.sum ?? null,
|
||
labor: b.total.labor ?? null,
|
||
material: b.total.material ?? null,
|
||
expense: b.total.expense ?? null,
|
||
note: blank.n ? `빈 줄 ${blank.n}` : "",
|
||
blank: blank.n,
|
||
blank_names: blank.names,
|
||
});
|
||
});
|
||
return { columns: HOPYO_COLUMNS, rows, titles, at };
|
||
}
|
||
|
||
/** 칸 꼴 — 꼬리표 · 빈 줄 알림 */
|
||
export function hopyoCellClass(rows: DataRow[], i: number, key: string): string | null {
|
||
const r = rows[i];
|
||
if (key === "tag" && r?.tag)
|
||
return `ui-hopyo-grid__tag${r.tag === HOPYO_TAG.fix ? " is-fix" : " is-pum"}`;
|
||
if (key === "note" && Number(r?.blank) > 0) return "ui-hopyo-grid__blank";
|
||
return null;
|
||
}
|
||
|
||
/** 공용 표 옵션(격자 꼴 그대로) — `extra` 로 누름 · 풍선 자리를 더함 */
|
||
export function hopyoTableOptions(
|
||
blocks: HopyoBlock[],
|
||
extra: Partial<DataTableOptions> = {},
|
||
): DataTableOptions {
|
||
const g = hopyoGrid(blocks);
|
||
return {
|
||
columns: g.columns,
|
||
rows: g.rows,
|
||
frozen: 1,
|
||
empty: "",
|
||
rowClass: (i) => (g.titles.has(i) ? "is-title" : null),
|
||
cellClass: (i, key) => hopyoCellClass(g.rows, i, key),
|
||
level: { key: "name", of: (i) => (g.titles.has(i) ? 1 : 2) },
|
||
...extra,
|
||
};
|
||
}
|
||
|
||
export interface HopyoGridHandle {
|
||
root: HTMLElement;
|
||
set: (blocks: HopyoBlock[]) => void;
|
||
}
|
||
|
||
/** 격자 하나(읽기 전용) — 빈 호표 목록 = 빈 표 */
|
||
export function createHopyoGrid(
|
||
blocks: HopyoBlock[],
|
||
extra: Partial<DataTableOptions> = {},
|
||
): HopyoGridHandle {
|
||
const root = document.createElement("div");
|
||
root.className = "ui-hopyo-grid";
|
||
const set = (next: HopyoBlock[]): void => {
|
||
root.replaceChildren(createDataTable(hopyoTableOptions(next, extra)).root);
|
||
};
|
||
set(blocks);
|
||
return { root, set };
|
||
}
|