Files
Aislo/ui_template/ui_template_data_table.ts
T
eomsangdonandClaude Opus 5.5 e1d83398d4 feat(B09): 원가 바탕 단가 — 뼈대 · 노무 · 경비 · 환율 · 자재 대비 · 중기 탭(144-1~4)
- 탭 = 기번3 공통 엑셀 보이는 탭 차례(목차 · 안내 = 연결 점검) · 탭 하나 = 공용 표 하나 · 호표 칸 누름 = 그 탭 그 줄로 이동
- 저장 = 프로젝트 B09_Estimation/v1(사용자 값 · 굳힌 단가 한 벌) · 결과는 GET 때 서버 셈 · [저장] · [단가 갱신] 길
- 줄 = 수요 따라(연결된 사본 원천 자리 · 기계 → 운전사 · 연료 · B08 총괄 자재) · 연결 없으면 빈칸 + 연결 점검 목록
- 셈 = 파이썬 · TS 짝 + 거울(운전사 시간 노임 · 자재 최저 자동 + 사용자 고침 · 관급 · 별도 · 중기 손료 + 운전사 + 연료 + 잡품 · 엑셀 끝수)
- 공용 표 = 칸 누름 · 줄로 이동(강조) 더함
- 시험 = test_144_b09_skeleton · test_144_b09_base_calc(엑셀 중기 126,000 · 76,436 · 96,829 · 40,659 · 운전사 55,700 · 47,231 · 33,571)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015KYZ2HWWdUwZWYnn3tQz7X
2026-10-09 15:01:27 +09:00

285 lines
12 KiB
TypeScript

/* =============================================================================
* ui_template_data_table.ts
* 많은 자료용 읽기 표(B08 · B09 공용) — 묶음 머리 여러 줄 · 머리 · 앞 열 고정 스크롤 ·
* 숫자 오른쪽 맞춤 · 천 단위 쉼표 · 열마다 소수 자리 · 빈 값 「-」 · 합계 줄.
* 줄 1,000개도 빠르게 — HTML 글로 통째로 넣고(칸마다 요소 만들지 않음) 보이는 줄 근처만 그림.
*
* const t = createDataTable({ columns, rows, frozen: 1, total });
* host.append(t.root); t.setRows(rows2, total2);
* 열 = { key: "cut_soil_area", label: "단면적", group: ["절토", "토사"], kind: "number", decimals: 2 }
* 고칠 칸(PLAN 121) = editable(줄, 열) 참인 칸만 글 상자 · 바꾸면 onEdit(줄, 열, 글) — 다시 그려도 커서 자리 지킴
* 칸 누름(PLAN 144) = onCellClick(줄, 열) · 줄로 이동 = t.focusRow(줄)(스크롤 + 강조)
* ========================================================================== */
import "./ui_template_data_table.css";
export interface DataColumn {
/** 줄 객체에서 값을 꺼낼 키 */
key: string;
/** 맨 아래 머리 글 */
label: string;
/** 묶음 머리(위 → 아래) — 이웃 열과 앞부분이 같으면 한 칸으로 합침 */
group?: string[];
/** 기본 글 · 숫자면 오른쪽 맞춤 · 천 단위 쉼표 */
kind?: "number" | "text";
/** 숫자 소수 자리(기본 0) */
decimals?: number;
/** 끝 0 지움(decimals 는 최대 자리) — 사용자가 넣는 값처럼 자리가 줄마다 다른 열 */
trim?: boolean;
/** 열 폭 px(기본 숫자 84 · 글 96) */
width?: number;
/** 맨 아래 머리 칸 풍선 글(기본 label) */
title?: string;
/** 맨 아래 머리 칸에 더할 class(예 빠진 항목 빨강) */
className?: string;
}
export type DataRow = Record<string, unknown>;
export interface DataTableOptions {
columns: DataColumn[];
rows: DataRow[];
/** 앞에서부터 고정할 열 수(기본 0) */
frozen?: number;
/** 합계 줄(맨 아래 고정) — 없으면 안 그림 */
total?: DataRow | null;
/** 표 이름(화면 읽기용) */
caption?: string;
/** 빈 값 글(기본 「-」 · 사용자가 채울 칸은 "") */
empty?: string;
/** 줄 누름(본문 줄만 · 합계 제외) — 주면 줄에 `data-i`(줄 번호)·`is-clickable` 이 붙음 */
onRowClick?: (index: number) => void;
/** 칸 누름(본문 줄만) — 주면 줄에 `data-i`·`is-clickable` 이 붙음 · 줄 누름보다 먼저 부름 */
onCellClick?: (index: number, key: string) => void;
/** 강조할 줄(`focusRow` 가 정함) */
focus?: number | null;
/** 고칠 칸인지(본문 줄만 · 합계 제외) — 참이면 칸이 글 상자 */
editable?: (index: number, key: string) => boolean;
/** 고친 칸 글(바꾼 뒤 · 커서가 옮겨 간 다음에 부름) */
onEdit?: (index: number, key: string, text: string) => void;
}
export interface DataTable {
root: HTMLElement;
setRows: (rows: DataRow[], total?: DataRow | null) => void;
/** 그 줄로 스크롤하고 강조 */
focusRow: (index: number) => void;
}
const EMPTY = "-";
const ESC: Record<string, string> = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" };
const esc = (s: string): string => s.replace(/[&<>"]/g, (c) => ESC[c]);
function colWidth(c: DataColumn): number {
return c.width ?? (c.kind === "number" ? 84 : 96);
}
/** 값 한 칸 → [보일 글, 빈 값인지] */
export function formatCell(
v: unknown,
fmt: Intl.NumberFormat | null,
empty = EMPTY,
): [string, boolean] {
if (v === null || v === undefined || v === "") return [empty, true];
if (fmt && typeof v === "number")
return Number.isFinite(v) ? [fmt.format(v), false] : [empty, true];
return [String(v), false];
}
function headHtml(cols: DataColumn[], left: (number | null)[]): string {
const depth = Math.max(0, ...cols.map((c) => c.group?.length ?? 0));
const same = (a: DataColumn, b: DataColumn, n: number) =>
(b.group?.length ?? 0) > n &&
a.group!.slice(0, n + 1).join("\u0000") === b.group!.slice(0, n + 1).join("\u0000");
const stick = (from: number, to: number, extra = "") => {
const frz = left[to] !== null;
const cls = [frz && "is-frozen", frz && left[to + 1] == null && "is-edge", extra]
.filter(Boolean)
.join(" ");
return (cls ? ` class="${esc(cls)}"` : "") + (frz ? ` style="left:${left[from]}px"` : "");
};
const rows: string[] = [];
for (let lv = 0; lv <= depth; lv++) {
let tr = "";
for (let j = 0; j < cols.length; j++) {
const c = cols[j];
const g = c.group?.length ?? 0;
if (g > lv) {
let k = j;
while (k + 1 < cols.length && same(c, cols[k + 1], lv)) k++;
tr += `<th scope="colgroup" colspan="${k - j + 1}"${stick(j, k)}>${esc(c.group![lv])}</th>`;
j = k;
} else if (g === lv) {
const num = c.kind === "number" ? ' data-num=""' : "";
const span = depth - lv + 1;
const tip = esc(c.title ?? c.label);
tr += `<th scope="col" rowspan="${span}"${num}${stick(j, j, c.className)} title="${tip}">${esc(c.label)}</th>`;
}
}
rows.push(`<tr>${tr}</tr>`);
}
return rows.join("");
}
function rowHtml(
cols: DataColumn[],
fmts: (Intl.NumberFormat | null)[],
left: (number | null)[],
r: DataRow,
alt = false,
blank = EMPTY,
index: number | null = null,
edit: number | null = null,
editable?: (index: number, key: string) => boolean,
focus = false,
): string {
let tr = "";
for (let j = 0; j < cols.length; j++) {
const key = cols[j].key;
if (edit !== null && editable?.(edit, key)) {
const v = r[key];
const raw = v === null || v === undefined ? "" : String(v);
const tip = esc(cols[j].title ?? cols[j].label);
tr += `<td class="is-edit" data-num=""><input class="ui-data-table__input" data-i="${edit}" data-k="${esc(key)}" value="${esc(raw)}" inputmode="decimal" aria-label="${tip}"></td>`;
continue;
}
const [txt, empty] = formatCell(r[key], fmts[j], blank);
const frz = left[j] !== null;
const cls = [frz && "is-frozen", frz && left[j + 1] == null && "is-edge", empty && "is-empty"]
.filter(Boolean)
.join(" ");
const attrs =
(cls ? ` class="${cls}"` : "") +
(frz ? ` style="left:${left[j]}px"` : "") +
(fmts[j] ? ' data-num=""' : "");
tr += `<td${attrs}>${esc(txt)}</td>`;
}
const cls = [alt && "is-alt", index !== null && "is-clickable", focus && "is-focus"]
.filter(Boolean)
.join(" ");
const data = index !== null ? ` data-i="${index}"` : "";
return cls || data ? `<tr${cls ? ` class="${cls}"` : ""}${data}>${tr}</tr>` : `<tr>${tr}</tr>`;
}
/** 그릴 줄 구간 — 밖의 줄은 빈 줄 높이(rowH px)로만 채움 */
export interface RowWindow {
from: number;
to: number;
rowH: number;
}
const pad = (h: number, n: number) =>
h > 0 ? `<tr class="ui-data-table__pad" style="height:${h}px"><td colspan="${n}"></td></tr>` : "";
/** 표 전체 HTML 글 — DOM 없이 만듦(시험 · 서버 그리기도 같은 글) · win 없으면 모든 줄 */
export function renderDataTableHtml(o: DataTableOptions, win?: RowWindow): string {
const cols = o.columns;
const frozen = Math.min(o.frozen ?? 0, cols.length);
const left: (number | null)[] = [];
let x = 0;
cols.forEach((c, i) => {
left.push(i < frozen ? x : null);
x += colWidth(c);
});
const fmts = cols.map((c) =>
c.kind === "number"
? new Intl.NumberFormat("ko-KR", {
minimumFractionDigits: c.trim ? 0 : (c.decimals ?? 0),
maximumFractionDigits: c.decimals ?? 0,
})
: null,
);
const colgroup = cols.map((c) => `<col style="width:${colWidth(c)}px">`).join("");
const n = o.rows.length;
const from = win ? Math.max(0, Math.min(win.from, n)) : 0;
const to = win ? Math.max(from, Math.min(win.to, n)) : n;
let body = win ? pad(from * win.rowH, cols.length) : "";
const blank = o.empty ?? EMPTY;
const pick = o.onRowClick !== undefined || o.onCellClick !== undefined;
for (let i = from; i < to; i++)
body += rowHtml(
cols,
fmts,
left,
o.rows[i],
i % 2 === 1,
blank,
pick ? i : null,
i,
o.editable,
o.focus === i,
);
if (win) body += pad((n - to) * win.rowH, cols.length);
const foot = o.total ? `<tfoot>${rowHtml(cols, fmts, left, o.total, false, blank)}</tfoot>` : "";
const cap = o.caption ? `<caption>${esc(o.caption)}</caption>` : "";
return (
`<table class="ui-data-table__table" style="width:${x}px">${cap}<colgroup>${colgroup}</colgroup>` +
`<thead>${headHtml(cols, left)}</thead><tbody>${body}</tbody>${foot}</table>`
);
}
/** 화면 위아래로 더 그려 둘 줄 수 · 구간은 이 단위로 끊어 스크롤마다 다시 그리지 않음 */
const OVER = 40;
export function createDataTable(opts: DataTableOptions): DataTable {
const root = document.createElement("div");
root.className = "ui-data-table";
root.tabIndex = 0; // 키보드로 표 안 스크롤
const state = { ...opts };
let win: RowWindow = { from: -1, to: -1, rowH: 24 }; // --dt-row-h · 그린 뒤 재서 고침
// 보이는 줄 근처만 그림 — 줄 1,000개를 다 배치하면 수백 ms
const draw = (force: boolean) => {
const n = state.rows.length;
const view = Math.ceil((root.clientHeight || 800) / win.rowH);
const from = Math.max(0, Math.floor(root.scrollTop / win.rowH / OVER) * OVER - OVER);
const to = Math.min(n, from + view + 3 * OVER);
if (!force && from === win.from && to === win.to) return;
win = { ...win, from, to };
// 고치던 칸 — 다시 그린 뒤 같은 칸에 커서를 돌려 놓음(Tab 으로 옮겨 간 칸 포함)
const active = document.activeElement;
const focus =
active instanceof HTMLInputElement && root.contains(active)
? `input[data-i="${active.dataset.i}"][data-k="${CSS.escape(active.dataset.k ?? "")}"]`
: null;
root.innerHTML = renderDataTableHtml(state, win);
if (focus) root.querySelector<HTMLInputElement>(focus)?.focus();
// 줄 높이는 그린 줄을 재서 맞춤(글꼴 · 토큰이 바뀌어도)
const tr = root.querySelector<HTMLElement>("tbody tr:not(.ui-data-table__pad)");
if (tr && tr.offsetHeight && tr.offsetHeight !== win.rowH) {
win = { ...win, rowH: tr.offsetHeight };
draw(true);
}
};
root.addEventListener("scroll", () => draw(false), { passive: true });
// 바꾼 칸 — 커서가 다음 칸으로 옮겨 간 뒤 알림(그 사이 다시 그리면 Tab 이 갈 곳을 잃음)
root.addEventListener("change", (event) => {
const input = event.target as HTMLInputElement;
if (!input.dataset?.k) return;
const index = Number(input.dataset.i);
const key = input.dataset.k;
setTimeout(() => state.onEdit?.(index, key, input.value));
});
root.addEventListener("click", (event) => {
const row = (event.target as Element).closest<HTMLElement>("tr[data-i]");
if (!row || !root.contains(row)) return;
const cell = (event.target as Element).closest<HTMLTableCellElement>("td");
const column = cell ? state.columns[cell.cellIndex] : undefined;
if (column) state.onCellClick?.(Number(row.dataset.i), column.key);
state.onRowClick?.(Number(row.dataset.i));
});
draw(true);
return {
root,
setRows(rows, total) {
state.rows = rows;
if (total !== undefined) state.total = total;
draw(true);
},
focusRow(index) {
state.focus = index;
root.scrollTop = Math.max(0, index * win.rowH - root.clientHeight / 3);
draw(true);
},
};
}