- 일위대가 격자(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>
278 lines
9.9 KiB
TypeScript
278 lines
9.9 KiB
TypeScript
/* =============================================================================
|
||
* 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 };
|
||
}
|