Files
Aislo/ui_template/ui_template_hopyo_grid.ts
T
eomsangdonandClaude Sonnet 5.5 27f33a8c53 fix(751-1): 원화 꼴 흠 — B09 · 일위대가표 격자 「.0」 꼬리 걷음 · 단가 소수 셋 · 환율표 · 빈 값 · 금액 정수 꼴 공용 갈음
- 격자 단가 칸 소수 셋 · 금액 칸 소수 하나 끝 0 없음(275,790.0 → 275,790)
- B09 money() 소수 자리 있으면 끝 0 없음 · 환율표 59,020.00 → 59,020 · 단가 열 소수 셋(1,846.39 · 22.9)
- 엑셀 불러오기 대조 글 빈 값 = 빈칸(0 아님) · en-US 정수 → 공용 formatMoney · 재료 창고 금액 공용 꼴

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 22:20:43 +09:00

280 lines
10 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]);
* 붙여넣기(705-2) = `createHopyoGrid(blocks, {}, (text) => …)` — 격자 누른 뒤 Ctrl+V 글을 넘김
* 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,
});
/** 단가 = 소수 셋까지 · 금액 = 소수 하나까지 — 끝 0 없음(`275,790` · `109,326.3`) */
const hopyoMoney = (key: string, label: string, group: string, decimals: number): DataColumn => ({
key,
label,
group: [group],
kind: "number",
decimals,
trim: true,
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, 3),
hopyoMoney(key, "금액", label, 1),
]),
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;
}
/** 격자 하나(읽기 전용) — 빈 호표 목록 = 빈 표 · `onPaste`(705-2) = 격자를 누르고 Ctrl+V → 붙인 글 그대로
* (엑셀 칸 = 탭 · 줄 = 줄바꿈 · 해석은 부르는 쪽 · 서버) */
export function createHopyoGrid(
blocks: HopyoBlock[],
extra: Partial<DataTableOptions> = {},
onPaste?: (text: string) => void,
): HopyoGridHandle {
const root = document.createElement("div");
root.className = "ui-hopyo-grid";
if (onPaste) {
root.tabIndex = 0; // 눌러 고르면 붙여넣기를 받음
root.dataset.paste = "";
root.addEventListener("paste", (ev) => {
if ((ev.target as Element | null)?.closest("input, textarea")) return; // 칸 고침 중은 그 칸 몫
const text = ev.clipboardData?.getData("text/plain") ?? "";
if (!text.trim()) return;
ev.preventDefault();
onPaste(text);
});
}
const set = (next: HopyoBlock[]): void => {
root.replaceChildren(createDataTable(hopyoTableOptions(next, extra)).root);
};
set(blocks);
return { root, set };
}