/* ============================================================================= * 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; /** 줄 번호 → [호표 차례, 구성 줄 차례] · 구성 줄만 */ at: Map; } 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(); const at = new Map(); 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 { 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 = {}, 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 }; }