feat(B08): 총괄자재 · 콘크리트소요자재 · 자재수량 집계표 세 표(121-1)

- 실무 엑셀(4.2 수량산출(2공구)) 꼴 · 구조물 탭 아래 공용 표 · 구조물 수량 C = 구조물 집계표 계
- 단위수량 · ㎥당 배합 · 할증 % 고칠 칸 · 조작 중 TS 짝 계산 · [저장] 때 서버 정본 · 설정 quantity › material_sheets
- 공용 표 고칠 칸(editable · onEdit) · 끝 0 지움(trim)
- 시험 test_121(엑셀 66행 · 11행 · 총괄 G열 · 거울 · 저장)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
This commit is contained in:
2026-10-08 18:35:33 +09:00
co-authored by Claude Opus 5.5
parent 5f64633df3
commit 449a8cfe9b
12 changed files with 1272 additions and 7 deletions
+45 -3
View File
@@ -7,6 +7,7 @@
* 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(줄, 열, 글) — 다시 그려도 커서 자리 지킴
* ========================================================================== */
import "./ui_template_data_table.css";
@@ -22,6 +23,8 @@ export interface DataColumn {
kind?: "number" | "text";
/** 숫자 소수 자리(기본 0) */
decimals?: number;
/** 끝 0 지움(decimals 는 최대 자리) — 사용자가 넣는 값처럼 자리가 줄마다 다른 열 */
trim?: boolean;
/** 열 폭 px(기본 숫자 84 · 글 96) */
width?: number;
/** 맨 아래 머리 칸 풍선 글(기본 label) */
@@ -45,6 +48,10 @@ export interface DataTableOptions {
empty?: string;
/** 줄 누름(본문 줄만 · 합계 제외) — 주면 줄에 `data-i`(줄 번호)·`is-clickable` 이 붙음 */
onRowClick?: (index: number) => void;
/** 고칠 칸인지(본문 줄만 · 합계 제외) — 참이면 칸이 글 상자 */
editable?: (index: number, key: string) => boolean;
/** 고친 칸 글(바꾼 뒤 · 커서가 옮겨 간 다음에 부름) */
onEdit?: (index: number, key: string, text: string) => void;
}
export interface DataTable {
@@ -115,10 +122,20 @@ function rowHtml(
alt = false,
blank = EMPTY,
index: number | null = null,
edit: number | null = null,
editable?: (index: number, key: string) => boolean,
): string {
let tr = "";
for (let j = 0; j < cols.length; j++) {
const [txt, empty] = formatCell(r[cols[j].key], fmts[j], blank);
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)
@@ -157,7 +174,7 @@ export function renderDataTableHtml(o: DataTableOptions, win?: RowWindow): strin
const fmts = cols.map((c) =>
c.kind === "number"
? new Intl.NumberFormat("ko-KR", {
minimumFractionDigits: c.decimals ?? 0,
minimumFractionDigits: c.trim ? 0 : (c.decimals ?? 0),
maximumFractionDigits: c.decimals ?? 0,
})
: null,
@@ -170,7 +187,17 @@ export function renderDataTableHtml(o: DataTableOptions, win?: RowWindow): strin
const blank = o.empty ?? EMPTY;
const pick = o.onRowClick !== undefined;
for (let i = from; i < to; i++)
body += rowHtml(cols, fmts, left, o.rows[i], i % 2 === 1, blank, pick ? i : null);
body += rowHtml(
cols,
fmts,
left,
o.rows[i],
i % 2 === 1,
blank,
pick ? i : null,
i,
o.editable,
);
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>` : "";
@@ -197,7 +224,14 @@ export function createDataTable(opts: DataTableOptions): DataTable {
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) {
@@ -206,6 +240,14 @@ export function createDataTable(opts: DataTableOptions): DataTable {
}
};
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)) state.onRowClick?.(Number(row.dataset.i));