Files
Aislo/ui_template/ui_template_hopyo_grid.ts
T
eomsangdonandClaude Opus 5.5 90273507c5 fix(규격 표 화면): 열 합계 한 자리 · 규격 줄 칸 폭 · B08 · B09 금액 = 열 수량 × 단가 시험 (PLAN 704-4)
- 격자 합계 줄 규격 칸 = 금액 칸처럼 소수 한 자리
- 규격 이름 · 열 이름 칸 6em — 좁은 카드에서 한 줄
- 임시 storage 시험 — 사본 셈 반영합 = 층 문서 그 열 셈(B08) · 대가 줄 금액 = 열 수량 × 단가(B09)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 14:41:13 +09:00

264 lines
9.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* ui_template_hopyo_grid.ts
* 일위대가 엑셀 꼴 격자(PLAN 704-3) — 호표마다 머리 줄 + 구성 줄 + 합계 줄 · 공용 데이터표(`ui_template_data_table`) 위.
* M02 일위대가 카드 · 상세 · B09 일위대가표(호표보기)가 같은 열 · 같은 줄 꼴을 씀.
*
* 머리 줄 = 「제 N 호표 이름」(번호 없으면 이름만) · 규격 · 단위 · 1 단위 합계(합계 금액 칸) · 비고 = 호표 꼴
* 품셈 인용 줄(있으면) = 「ㄴ 조항」 · 비고 「품셈 인용」
* 구성 줄 = 「ㄴ 품명」 · 규격 · 품/수량 · 단위 · 합계 · 노무비 · 재료비 · 경비(단가 / 금액 쌍) · 비고 · 구분(줄 꼬리표)
* 합계 줄 = 비목 금액 합 · 빈 줄(금액 모름 · 수량 없음) 있으면 비고 「빈 줄 n」 붉게 — 합계는 아는 줄만
* 줄 꼬리표 = 「품셈」(우리 로직이 품을 셈) · 「고정」 갈래는 705 몫(자리만 · 같은 칸)
* 규격 열(704-4 · 첫 호표 `specs`) = 수량 칸이 열 수만큼(`qty_n` · 머리 「H 열」) · 합계 줄 그 칸 = 열마다 1 단위 합계 ·
* 금액 칸 = 기본 열 값
* 값은 부르는 쪽이 준 것 그대로(서버 계산 값) — 여기서 셈하지 않음
*
* 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;
/** 규격 열마다 품 · 수량(704-4 · 블록 `specs.cols` 차례) — 없으면 `qty` */
qtys?: (number | null)[];
}
/** 규격 열(704-4) — 이름(H) · 열 이름 · 기본 열 · 열마다 1 단위 합계 */
export interface HopyoSpecs {
name: string;
cols: string[];
pick?: string | null;
totals: (number | 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[];
/** 규격 열(704-4) — 첫 호표 것만 열로 폄 */
specs?: HopyoSpecs | null;
}
/** 줄 꼬리표 낱말 */
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),
];
/** 규격 열 수량 칸 열쇠(704-4) */
export const hopyoSpecKey = (n: number): string => `qty_${n}`;
/** 열 — 첫 호표에 규격 열이 있으면 수량 칸을 열마다 폄(기본 열 = 「(기본)」) */
export function hopyoColumns(blocks: HopyoBlock[]): DataColumn[] {
const specs = blocks[0]?.specs;
if (!specs?.cols.length) return HOPYO_COLUMNS;
const qty = HOPYO_COLUMNS.find((c) => c.key === "qty") as DataColumn;
const cols = specs.cols.map((col, n): DataColumn => ({
...qty,
key: hopyoSpecKey(n),
label: col === specs.pick ? `${col} (기본)` : col,
group: [`${specs.name} 열`],
}));
return HOPYO_COLUMNS.flatMap((c) => (c.key === "qty" ? cols : [c]));
}
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]>();
const specCount = blocks[0]?.specs?.cols.length ?? 0;
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 (let k = 0; k < specCount; k++) row[hopyoSpecKey(k)] = ln.qtys?.[k] ?? ln.qty;
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);
const sums = Object.fromEntries(
Array.from({ length: specCount }, (_, k) => {
const sum = b.specs?.totals[k];
return [hopyoSpecKey(k), sum == null ? null : Math.round(sum * 10) / 10]; // 금액 칸처럼 한 자리
}),
);
rows.push({
...sums,
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: hopyoColumns(blocks), 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 };
}