Files
Aislo/ui_template/ui_template_hopyo_grid.ts
T
eomsangdonandClaude Opus 5.5 32de488401 feat(M02 붙여넣기 화면 · 다시 잇기): 격자 Ctrl+V · 열 짝 한 번 묻고 기억 · 이 공사 단가로 다시 잇기 (PLAN 705-2)
- 일위대가 격자(ui_template_hopyo_grid onPaste) 누르고 Ctrl+V → 서버 /hopyo-paste · 열 짝 = 처음 한 번 묻고 이 브라우저에 기억 · 엑셀 머리 글이면 묻지 않음
- 호표 머리 없음 = 열린 문서에 고정 줄(알림 누름 = 다시 잇기) · 머리 있음 = 호표마다 사전조합 + 결과 창 [다음 — 이 공사 단가로 다시 잇기]
- 다시 잇기 창(EST 신 · 구 단가 비교 꼴) — 붙인 단가 · 최신 판 단가 · 후보(705-3 이음 짝) · 금액 차이 · 줄마다 · 모두 고름 · [바꿈] · [되돌리기]
- 서버 M02_Hopyo_Relink — 후보 표 · 이음 칸만 고쳐 씀(숫자 안 받음) · 전후 1 단위 값 · 되돌림
- 실무자료 [모두 고정으로 들여오기] 결과에도 다음 걸음 단추
- M02 상세가 고정 줄을 읽을 때 떨구던 흠 — normalizeLabor 가 고정 칸 지킴 · 로직 표 · 격자에 고정 줄 이름 · 규격 · 단위 · 「고정」 꼬리표 · 이음 / 손 단가

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 15:57:39 +09:00

278 lines
9.9 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,
});
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;
}
/** 격자 하나(읽기 전용) — 빈 호표 목록 = 빈 표 · `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 };
}