Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1

# Conflicts:
#	B09_Estimation/B09_Estimation_UI_Tabs.ts
This commit is contained in:
2026-10-10 14:32:01 +09:00
18 changed files with 329 additions and 174 deletions
@@ -40,6 +40,7 @@ export function renderEarthworkSummary(host: HTMLElement, table: EarthworkTable)
note: row.note || " ",
})),
frozen: 1,
mergeSame: ["group", "spec"], // 201-1 같은 구분 · 규격 이어지면 세로 병합(빈칸은 안 합침)
caption: "토공집계표",
fillViewport: true,
});
+9 -4
View File
@@ -109,6 +109,8 @@ interface Table {
editable: (index: number, key: string) => boolean;
/** 고친 칸 → 사용자 값 자리 [표, 줄 키, 칸 키] */
target: (index: number, key: string) => [Record<string, Cells> | Cells, string, string | null];
/** 같은 값 세로 병합 열(201-1 · 공용 표 mergeSame) */
mergeSame?: string[];
}
function quantityTable(s: MaterialSheets, o: MaterialOverrides): Table {
@@ -218,10 +220,10 @@ function concreteWhy(s: MaterialSheets, source: SummarySource): string {
function concreteTable(s: MaterialSheets, o: MaterialOverrides): Table {
const { rows, total } = s.concrete;
const keep = rows.map((_row, i) => i); // 5줄 늘 보임(163-5) — 수량 없으면 빈칸 · 배합은 늘 고침
const rowsOut: DataRow[] = keep.map((at, i) => {
const rowsOut: DataRow[] = keep.map((at) => {
const row = rows[at];
const out: DataRow = {
name: i > 0 && rows[keep[i - 1]].name === row.name ? "" : row.name,
name: row.name, // 위 줄과 같으면 세로 병합(201-1)
spec: row.spec,
qty: row.qty,
};
@@ -256,6 +258,7 @@ function concreteTable(s: MaterialSheets, o: MaterialOverrides): Table {
rows: rowsOut,
total: totalRow,
frozen: 2,
mergeSame: ["name"],
editable: (_i, key) => key.startsWith("per:"),
target: (i, key) => [o.mix, rows[keep[i]].key, key.slice(4)],
};
@@ -275,10 +278,10 @@ function totalTable(s: MaterialSheets, o: MaterialOverrides): Table {
numberColumn({ key: "amount", label: "합계", width: 96 }),
{ key: "note", label: "비고", width: 96 },
],
rows: keep.map((at, i) => {
rows: keep.map((at) => {
const row = rows[at];
return {
group: i > 0 && rows[keep[i - 1]].group === row.group ? "" : row.group,
group: row.group, // 위 줄과 같으면 세로 병합(201-1)
spec: row.spec,
unit: row.unit,
qty: row.qty,
@@ -295,6 +298,7 @@ function totalTable(s: MaterialSheets, o: MaterialOverrides): Table {
}),
total: null,
frozen: 2,
mergeSame: ["group"],
editable: (i, key) =>
key === "surcharge" && !rows[keep[i]].panel && rows[keep[i]].key !== DEDUCT_KEY,
target: (i) => [o.surcharge, rows[keep[i]].key, null],
@@ -364,6 +368,7 @@ export async function renderMaterialSheet(
rows: built.rows,
total: built.total,
frozen: built.frozen,
mergeSame: built.mergeSame,
empty: "",
caption: BASIS,
fillViewport: true,
+2 -83
View File
@@ -66,85 +66,17 @@
font-size: var(--text-caption);
}
/* 182 설계내역서 레벨 — 글자 크기(줄 높이 28px 고정 → 16px 까지) · 첫 칸 들여쓰기 1em × (레벨 − 1) */
.b09-estimation .ui-data-table__table tbody tr.is-lv1 td {
font-size: var(--text-body);
}
.b09-estimation .ui-data-table__table tbody tr:is(.is-lv2, .is-lv3) td {
font-size: var(--text-body-sm);
}
.b09-estimation .ui-data-table__table tbody tr:is(.is-lv4, .is-lv5, .is-lv6) td {
font-size: 13px;
}
/* 182 설계내역서 묶음 줄 굵게 · 레벨 들여쓰기 = 공용 표 level 옵션(201-1) */
.b09-estimation .ui-data-table__table tbody tr.is-group td {
font-weight: var(--font-weight-bold);
}
.b09-estimation .ui-data-table__table tbody tr.is-lv2 td:first-child {
padding-left: calc(var(--spacing-8) + 1em);
}
.b09-estimation .ui-data-table__table tbody tr.is-lv3 td:first-child {
padding-left: calc(var(--spacing-8) + 2em);
}
.b09-estimation .ui-data-table__table tbody tr.is-lv4 td:first-child {
padding-left: calc(var(--spacing-8) + 3em);
}
.b09-estimation .ui-data-table__table tbody tr:is(.is-lv5, .is-lv6) td:first-child {
padding-left: calc(var(--spacing-8) + 4em);
}
/* 166-2 설계내역서 손 줄 — 사용자가 더한 줄 표시(왼쪽 띠) */
.b09-estimation .ui-data-table__table tbody tr.is-hand td:first-child {
background-image: linear-gradient(to right, var(--color-primary) 3px, transparent 3px);
}
/* 200-2 설계내역서 레벨 = 공종 · 명칭 칸 왼쪽 들여쓰기(레벨마다 한 칸 1em) · 글자 크기 한 벌(굵기 = 묶음 · 제목) · 글 칸 왼쪽 맞춤 */
.b09-estimation [data-b09-table="bill"] tbody tr {
--b09-lv: 0;
}
.b09-estimation [data-b09-table="bill"] tbody tr.is-lv2 {
--b09-lv: 1;
}
.b09-estimation [data-b09-table="bill"] tbody tr.is-lv3 {
--b09-lv: 2;
}
.b09-estimation [data-b09-table="bill"] tbody tr.is-lv4 {
--b09-lv: 3;
}
.b09-estimation [data-b09-table="bill"] tbody tr.is-lv5 {
--b09-lv: 4;
}
.b09-estimation [data-b09-table="bill"] tbody tr.is-lv6 {
--b09-lv: 5;
}
.b09-estimation [data-b09-table="bill"] tbody tr[class*="is-lv"] td {
font-size: inherit;
}
.b09-estimation [data-b09-table="bill"] tbody tr[class*="is-lv"] td:first-child {
padding-left: calc(var(--spacing-8) + var(--b09-lv) * 1em);
}
.b09-estimation [data-b09-table="bill"] tbody tr[class*="is-lv"] td.is-edit:first-child {
padding-left: 0;
}
.b09-estimation [data-b09-table="bill"] tbody td:first-child .ui-data-table__input {
padding-left: calc(var(--spacing-8) + var(--b09-lv) * 1em);
}
/* 200-2 설계내역서 글 칸 왼쪽 맞춤(수량만 오른쪽) */
.b09-estimation [data-b09-table="bill"] .ui-data-table__input:not([data-k="qty"]) {
text-align: left;
}
@@ -192,19 +124,6 @@
font-weight: var(--font-weight-bold);
}
/* 185-1 총괄 · 원가계산서 레벨 — 글자 크기 = 위 is-lvN · 들여쓰기 = 명칭 칸(is-indent) · 앞 칸(is-flat)은 그대로 */
.b09-estimation .ui-data-table__table tbody tr[class*="is-lv"] td.is-flat:first-child {
padding-left: var(--spacing-8);
}
.b09-estimation .ui-data-table__table tbody tr.is-lv2 td.is-indent {
padding-left: calc(var(--spacing-8) + 1em);
}
.b09-estimation .ui-data-table__table tbody tr.is-lv3 td.is-indent {
padding-left: calc(var(--spacing-8) + 2em);
}
/* 184-3 설계내역서 고친 칸 — 자동 값과 다른 칸(풍선 = 자동 값) */
.b09-estimation .ui-data-table__table tbody td.is-edited {
background: color-mix(in srgb, var(--color-warning) 22%, var(--color-canvas));
+5 -3
View File
@@ -20,7 +20,7 @@ import {
showToast,
} from "@ui/ui_template_elements";
import { attachCollapsible } from "@ui/ui_template_collapsible";
import { createDataTable, type DataTable } from "@ui/ui_template_data_table";
import { cellColumn, createDataTable, type DataTable } from "@ui/ui_template_data_table";
import { attachHoverInfo } from "@ui/ui_template_hover_info";
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
import { clearState, readState, writeState } from "../A00_Common/b_page_state";
@@ -209,6 +209,8 @@ export function renderB09Estimation(root: HTMLElement): void {
? (i) => [spec.rowClass?.(i), extra(i)].filter(Boolean).join(" ") || null
: spec.rowClass,
cellClass: spec.cellClass,
level: spec.level,
mergeSame: spec.mergeSame,
onCellClick: (i, key) => {
if (spec.onCell) spec.onCell(i, key);
if (spec.link && !spec.editable?.(i, key)) goto(spec.link(i, key));
@@ -222,7 +224,7 @@ export function renderB09Estimation(root: HTMLElement): void {
if (spec.choices)
made.root.addEventListener("click", (event) => {
const cell = (event.target as Element).closest<HTMLTableCellElement>("tr[data-i] td");
const column = cell ? spec.columns[cell.cellIndex] : undefined;
const column = cell ? spec.columns[cellColumn(cell)] : undefined;
const items =
cell && column && spec.choices?.(Number(cell.parentElement?.dataset.i), column.key);
if (!cell || !items?.length) return;
@@ -234,7 +236,7 @@ export function renderB09Estimation(root: HTMLElement): void {
attachHoverInfo(
made.root,
(cell) => {
const column = spec.columns[(cell as HTMLTableCellElement).cellIndex];
const column = spec.columns[cellColumn(cell as HTMLTableCellElement)];
return column && spec.tip?.(Number(cell.parentElement?.dataset.i), column.key);
},
"tr[data-i] td",
+11 -1
View File
@@ -9,7 +9,7 @@
* ========================================================================== */
import type { MapContextMenuItem } from "@ui/ui_template_context_menu";
import type { DataColumn, DataRow } from "@ui/ui_template_data_table";
import type { DataColumn, DataRow, DataTableOptions } from "@ui/ui_template_data_table";
import type { Row } from "./B09_Estimation_Calc_Base";
export type TabId =
@@ -110,10 +110,20 @@ export interface TabTable {
cellClass?: (index: number, key: string) => string | null | undefined;
/** 칸 누름 고르기 메뉴(183-3 · 칸 아래 공용 메뉴 · 빈 것 = 안 엶) */
choices?: (index: number, key: string) => MapContextMenuItem[] | null;
/** 레벨(201-1 · 공용 표 level · 보기 전용) — 그 열 들여쓰기 */
level?: DataTableOptions["level"];
/** 같은 값 세로 병합할 열(201-1 · 공용 표 mergeSame) */
mergeSame?: string[];
}
export type TabBuild = (ctx: TabContext) => TabTable | string;
/** 201-1 호표 묶음 레벨(보기 전용) — 머리 · 합계(제목 줄) = 1 · 그 밖 = 2 · key 열을 들여씀 */
export const blockLevel = (key: string, top: (index: number) => boolean): TabTable["level"] => ({
key,
of: (i) => (top(i) ? 1 : 2),
});
/* ── 열 꼴 ────────────────────────────────────────────────────────────── */
export const text = (key: string, label: string, width?: number, group?: string[]): DataColumn => ({
@@ -19,6 +19,7 @@ import type { DataRow } from "@ui/ui_template_data_table";
import { SOURCES, type Row } from "./B09_Estimation_Calc_Base";
import { openQuotes } from "./B09_Estimation_UI_Tabs_Base_Quote";
import {
blockLevel,
money,
parseNumber,
qty,
@@ -102,6 +103,7 @@ export const buildBasis: TabBuild = ({ sheets }): TabTable => {
],
rows,
frozen: 2,
mergeSame: ["part"],
hint: "운전사 시간 노임 = ROUNDDOWN(일당 × 1/8 × 16/12 × 25/20, 0)(M01 CC000336 · 건협기술-2230) · 환율 = M01 한국은행 · 인건비 줄 누름 = 노무비목록표",
link: (i) => {
const row = rows[i];
@@ -344,7 +346,7 @@ export const buildMachineBlocks: TabBuild = ({ sheets }): TabTable => {
const amt = (part: string): number | null => (ln.kind === part ? ln.amount : null);
rows.push({
key: `${b.key}|${ln.name}`,
name: ` ${ln.name}`,
name: ln.name,
spec: ln.spec,
qty: ln.qty,
unit: ln.unit,
@@ -361,7 +363,7 @@ export const buildMachineBlocks: TabBuild = ({ sheets }): TabTable => {
}
rows.push({
key: `${b.key}|합계`,
name: " 합 계",
name: "합 계",
sum: b.sum,
labor: b.labor,
material: b.material,
@@ -388,5 +390,6 @@ export const buildMachineBlocks: TabBuild = ({ sheets }): TabTable => {
frozen: 1,
hint: "손료 = 기준가(천원) × 손료계수 ÷ 10,000 · 운전사 = 인 × 시간 노임 · 연료 = ℓ/hr × 적용 단가 · 잡품 = 연료 금액 × % · 줄 ROUNDDOWN 1 · 비목 합 ROUNDDOWN 0 · 줄 누름 = 단가를 낸 탭",
link: (i) => links[i] ?? null,
level: blockLevel("name", (i) => !String(rows[i]?.key).includes("|")),
};
};
@@ -7,7 +7,7 @@
* 고칠 칸 = 항목 줄 명칭 · 규격 · 단위 · 수량 · 묶음 줄 이름 · 조달수수료 율(%) — 자동 줄은 덮개
* 고친 칸 = 칸 강조(셈 줄 `auto_{칸}` 있음) · 풍선 = 자동 값
* 줄 누름 = 단가를 낸 탭 줄(자재 → 재료비목록표 · 구조물 → 일위대가목록표)
* 레벨 = 줄 `level`(셈이 냄) → class is-lvN · 글자 크기 · 첫 칸 들여쓰기(CSS)
* 레벨 = 줄 `level`(셈이 냄) → 공용 표 level 옵션(공종 · 명칭 칸 들여쓰기 · 201-1) · 다루기 = `…_Bill_Level`
* 「연결 ▾」 칸 = 항목 줄(자재 · 조달수수료 빼고) · 누름 = 우클릭 [연결] 과 같은 메뉴
* ========================================================================== */
@@ -93,8 +93,9 @@ export const buildBill: TabBuild = (ctx): BillTable => {
const r = rows[i];
if (!r) return null;
const title = r.kind === "title" ? "is-title" : r.kind === "group" ? "is-group" : "";
return [`is-lv${r.level ?? 1}`, title, r.auto ? "" : "is-hand"].filter((c) => c).join(" ");
return [title, r.auto ? "" : "is-hand"].filter((c) => c).join(" ") || null;
},
level: { key: "name", of: (i) => rows[i]?.level as number | undefined },
menu: (i) => billMenu(ctx, raw, i),
onCell: (i, key) => {
if (key === "connect") openLinkMenu(ctx, raw, i);
@@ -12,6 +12,7 @@
import type { DataRow } from "@ui/ui_template_data_table";
import type { Row } from "./B09_Estimation_Calc_Base";
import {
blockLevel,
money,
qty,
text,
@@ -25,7 +26,7 @@ import { raw } from "./B09_Estimation_UI_Tabs_Sangeun";
const EMPTY =
"연결 없음 — 연결된 구조물 조합 사본(일위대가)이 없음 · B05 · B06 에서 조합을 고르고 [저장]";
/** 하위 줄 앞 — 들여쓰기 + 「ㄴ」(공용 표가 앞 빈칸을 접지 않음) */
const CHILD = " ㄴ ";
const CHILD = "ㄴ "; // 들여쓰기 = 공용 표 level(201-1)
const ROUNDS = "줄 금액 ROUNDDOWN(단가 × 수량, 1) · % 줄 = 밑수 × % / 100 · 합계 ROUNDDOWN 0";
const BLANK = "빈 줄 n = 금액 모르는 줄 · 합계 = 아는 줄 합 · 내역 단가엔 안 넘김";
/** 「빈 줄 n」 작은 글 · 풍선 = 빈 줄 이름 */
@@ -122,7 +123,7 @@ export const buildDaegaBlocks: TabBuild = ({ sheets }): TabTable | string => {
titles.add(rows.length);
rows.push({
key: `${b.id}|합계`,
name: " 합 계",
name: "합 계",
sum: b.sum,
labor: b.labor,
material: b.material,
@@ -154,6 +155,7 @@ export const buildDaegaBlocks: TabBuild = ({ sheets }): TabTable | string => {
hint: `단가 = B09 프로젝트 한 벌 굳힌 단가 · ${ROUNDS} · ${BLANK} · 줄 누름 = 단가를 낸 탭(대가 줄 = 그 블록)`,
link: (i: number) => links[i] ?? null,
rowClass: (i: number) => (titles.has(i) ? "is-title" : null),
level: blockLevel("name", (i) => titles.has(i)),
tip: (i: number, key: string) =>
key === "spec"
? raw(rows[i]?.spec, rows[i]?.spec_raw)
@@ -13,6 +13,7 @@
import type { DataRow } from "@ui/ui_template_data_table";
import type { Row } from "./B09_Estimation_Calc_Base";
import {
blockLevel,
money,
qty,
text,
@@ -99,7 +100,7 @@ export const buildSg: TabBuild = ({ sheets }): TabTable | string => {
null,
true,
);
head.slice(1).forEach((line, n) => push({ key: `${b.key}|머리${n}`, text: ` ${line}` }));
head.slice(1).forEach((line, n) => push({ key: `${b.key}|머리${n}`, text: line }));
(b.lines as Row[]).forEach((ln, n) => {
const link = ln.ref?.key ? { tab: LINE_TAB[ln.ref.tab] ?? "compare", key: ln.ref.key } : null;
const kind =
@@ -115,8 +116,8 @@ export const buildSg: TabBuild = ({ sheets }): TabTable | string => {
push(
{
key: `${b.key}|${n}${part ? `|${part}` : ""}`,
kind: k === 0 ? kind : "",
text: ` ${texts[k] ?? fallback}`,
kind, // 같은 공종 줄 = 세로 병합(201-1)
text: texts[k] ?? fallback,
sum: PARTS.some((p) => at(p) === null)
? null
: PARTS.reduce((s, p) => s + (at(p) as number), 0),
@@ -162,6 +163,8 @@ export const buildSg: TabBuild = ({ sheets }): TabTable | string => {
notes(sheets),
link: (i: number) => links[i] ?? null,
rowClass: (i: number) => (titles.has(i) ? "is-title" : null),
level: blockLevel("text", (i) => titles.has(i)),
mergeSame: ["kind"],
tip: (i: number, key: string) =>
key === "note" ? raw(rows[i]?.note, rows[i]?.note_raw) : null,
};
+12 -11
View File
@@ -8,8 +8,8 @@
* 고칠 칸 = 요율 · 손입력(기초액 · 이윤 보정 · 부가세 조정 · 조달수수료율) ·
* 엑셀에 없는 현행 비목 = 줄만(빈칸)
* 원가계산서 = 총괄 값 거울 + 구성비 · 고칠 칸 = 간접재료비 · 작업설 · 줄 누름 = 총괄 그 줄로
* 레벨(185-1) = 줄 `level`(화면이 정함 · 줄 짜임이 이 파일에 있음 · 셈 짝은 값만) → 182 is-lvN 글자 크기 ·
* 들여쓰기 = 명칭 칸(is-indent) · 앞 칸(is-flat)은 그대로 · 1단 = 굵게(is-title)
* 레벨(185-1 · 201-1) = 줄 `level`(화면이 정함 · 줄 짜임이 이 파일에 있음 · 셈 짝은 값만) →
* 공용 표 level 옵션(명칭 칸 들여쓰기 · 보기 전용) · 1단 = 굵게(is-title)
* 총괄 = 가.~바. 소계 1 · 그 밖 2 · 원가계산서 = 구분만 1 · 비목 소계 2 · 세목 3
* ========================================================================== */
@@ -77,13 +77,12 @@ function clickEstimate(ctx: TabContext, rowKey: string, column: string, from: un
ctx.setUser({ ...ctx.user, estimate });
}
/** 185-1 레벨 꼴 — 줄 class · 칸 class(들여쓰기 = 명칭 칸 · 앞 칸 그대로) */
const levelClass = (r: DataRow | undefined): string | null =>
r ? `is-lv${r.level}${r.level === 1 ? " is-title" : ""}` : null;
const indentClass =
(first: string, name: string) =>
(_i: number, key: string): string | null =>
key === name ? "is-indent" : key === first ? "is-flat" : null;
/** 185-1 레벨 꼴 — 1단 = 굵게 · 들여쓰기 = 공용 표 level(명칭 칸) */
const levelClass = (r: DataRow | undefined): string | null => (r?.level === 1 ? "is-title" : null);
const nameLevel = (rows: DataRow[]) => ({
key: "name",
of: (i: number) => rows[i]?.level as number | undefined,
});
function rateText(formula: string, rate: Num): string {
return rate === null ? `${formula}(요율 빈칸)` : formula.replace("율", `${rate}%`);
@@ -210,7 +209,8 @@ export const buildTotal: TabBuild = (ctx): TabTable => {
rows,
frozen: 2,
rowClass: (i) => levelClass(rows[i]),
cellClass: indentClass("part", "name"),
level: nameLevel(rows),
mergeSame: ["part"],
hint:
"요율 = M01 조달청 제비율에서 자동(맨 위 고르는 값 · 공종 토목) — 칸에 적으면 사용자 값이 앞섬 · 지우거나 출처 칸 누르면 자동 · " +
"2024 엑셀 요율과 연도 차로 다를 수 있음 · 자동도 없으면 그 줄 · 그 줄을 쓰는 소계 빈칸 · 끝수 = 엑셀 그대로(줄마다 원 버림 · 관급 천원 올림) · " +
@@ -290,7 +290,8 @@ export const buildCost: TabBuild = (ctx): TabTable => {
rows,
frozen: 3,
rowClass: (i) => levelClass(rows[i]),
cellClass: indentClass("group", "name"),
level: nameLevel(rows),
mergeSame: ["group", "part"],
hint: "총괄설계내역서 값 거울 · 구성비 = 총공사비 대비 · 고칠 칸 = 간접재료비 · 작업설(직접재료비가 그만큼 바뀌고 재료비 소계는 그대로 — 엑셀 꼴) · 줄 누름 = 총괄 그 줄",
editable: (i, key) => key === "amount" && String(rows[i]?.key) in HAND,
onEdit: (i, key, value) => editEstimate(ctx, String(rows[i]?.key), key, value),
+14 -12
View File
@@ -52,16 +52,18 @@ def test_155_2_일위대가표_제목_굵게_하위_ㄴ():
out = _node(
f"\nconst t = buildDaegaBlocks({{ sheets: {{ daega_blocks: [{json.dumps(BLOCK)}] }} }});"
"\nconsole.log(JSON.stringify({ names: t.rows.map((r) => r.name),"
" cls: t.rows.map((_, i) => t.rowClass(i) ?? null) }));"
" cls: t.rows.map((_, i) => t.rowClass(i) ?? null),"
" lv: [t.level.key, ...t.rows.map((_, i) => t.level.of(i))] }));"
)
assert out["names"] == [
"제 1 호표 찰쌓기",
" ㄴ 건설표준품셈 7-1-2",
" ㄴ 석공",
" ㄴ 뒷채움",
" 합 계",
]
"ㄴ 건설표준품셈 7-1-2",
"ㄴ 석공",
"ㄴ 뒷채움",
"합 계",
] # 201-1 들여쓰기 = 공용 표 level(앞 빈칸 없앰)
assert out["cls"] == ["is-title", None, None, None, "is-title"]
assert out["lv"] == ["name", 1, 2, 2, 2, 1]
SG = {
@@ -92,12 +94,12 @@ def test_155_5_단가산출근거_엑셀_꼴_칸():
rows = out["rows"]
assert [(r.get("kind"), r["text"]) for r in rows] == [
("제 1 호표", "뒷채움 / m3"),
(None, " 산림품셈 13-3-2 적용"),
(None, " 규격 : 150 mm"),
("노무", " 보통인부 : 165,545 * 0.018 = 2,979.8"),
("중기", " 굴착기 노무비 : 55,700 * 0.07 = 3,899.0"),
("", " 굴착기 재료비 : 7,695 * 0.07 = 538.6"),
("", " 굴착기 경비 : 13,041 * 0.07 = 912.8"),
(None, "산림품셈 13-3-2 적용"),
(None, "규격 : 150 mm"),
("노무", "보통인부 : 165,545 * 0.018 = 2,979.8"),
("중기", "굴착기 노무비 : 55,700 * 0.07 = 3,899.0"),
("중기", "굴착기 재료비 : 7,695 * 0.07 = 538.6"), # 201-1 같은 공종 = 세로 병합
("중기", "굴착기 경비 : 13,041 * 0.07 = 912.8"),
("총 계", "계 : 노무비 6,878 + 재료비 538 + 경비 912 = 8,328"),
]
assert [r.get("sum") for r in rows[3:7]] == [2979.8, 3899.0, 538.6, 912.8]
+7 -1
View File
@@ -281,6 +281,7 @@ const out = {
connect: ["hand:a", "hand:c", "hand:g1", "earth:4"].map((k) => table.rows[at(k)].connect),
link: [table.link(at("hand:a"), "connect"), table.link(at("hand:a"), "name")],
cls: ["sec:1", "hand:g1", "hand:a", "earth:4"].map((k) => table.rowClass(at(k))),
lv: [table.level.key, ...["sec:1", "hand:g1", "hand:a", "earth:4"].map((k) => table.level.of(at(k)))],
edit: [table.editable(at("hand:g1"), "name"), table.editable(at("hand:g1"), "qty")],
};
table.onCell(at("hand:a"), "connect");
@@ -309,7 +310,12 @@ def test_연결_단추_칸_메뉴_고르기_창_TS():
# 184-3 모든 항목 줄 · 새 줄 연결됨 = 호표 번호 · 자동 줄 연결됨 = 「연결됨」 · 묶음 = 없음
assert out["link"] == [None, {"tab": "dg", "key": "DG:a"}] # 단추 칸 = 이동 안 함
assert out["cls"] == [
"is-lv1 is-title", "is-lv3 is-group is-hand", "is-lv4 is-hand", "is-lv3"] # fmt: skip
"is-title",
"is-group is-hand",
"is-hand",
None,
] # fmt: skip 201-1 레벨 = 공용 표 level
assert out["lv"] == ["name", 1, 3, 4, 3]
assert out["edit"] == [True, False] # 묶음 줄 = 이름만
picks = ["일위대가 호표", "단가산출 호표", "단가산출 로직(M01 · 새 산근)"]
# 누른 칸 아래(left · bottom) · 묶음 줄 = 안 엶 · 연결된 줄(자동 줄도) = 연결 풀기 더함
+16 -20
View File
@@ -1,9 +1,9 @@
# -*- coding: utf-8 -*-
"""185-1 B09 총괄설계내역서 · 공사원가계산서 줄 레벨 — 줄 `level` · 줄 class(182 is-lvN) · 칸 class · CSS.
"""185-1 B09 총괄설계내역서 · 공사원가계산서 줄 레벨 — 줄 `level` · 1단 굵게 · 공용 표 level 옵션(201-1).
레벨 = 화면이 정함(줄 짜임이 `B09_Estimation_UI_Tabs_Total.ts` 에 있음 · 셈 짝은 값만 냄 → 거울 없음).
총괄 = 가.~바. 소계 1(굵게) · 그 밖 2 · 원가계산서 = 구분만 1 · 비목 소계 2 · 세목 3.
들여쓰기 = 명칭 칸(is-indent) · 앞 칸(is-flat)은 그대로.
들여쓰기 = 공용 표 level { key: 명칭 칸 · of = 줄 level }(201-1 · 옛 is-lvN · is-indent · is-flat 갈음).
"""
import json
@@ -20,7 +20,7 @@ SUMS = {"material": 194234417, "labor": 346800554, "expense": 169324165, "supply
BODY = f"""
const user = {{ estimate: {{}} }};
const ctx = {{ sheets: {{ total: computeTotal({json.dumps(SUMS)}, user.estimate) }}, payload: {{}}, user, setUser: () => {{}} }};
const pack = (t) => t.rows.map((r, i) => [r.key, r.level, t.rowClass(i), t.cellClass(i, "name"), t.cellClass(i, t.columns[0].key), t.cellClass(i, "amount")]);
const pack = (t) => t.rows.map((r, i) => [r.key, r.level, t.rowClass(i), t.level.key, t.level.of(i), t.cellClass ?? null]);
console.log(JSON.stringify({{ total: pack(buildTotal(ctx)), cost: pack(buildCost(ctx)) }}));
"""
@@ -42,39 +42,35 @@ def _run() -> dict:
def test_총괄_레벨():
rows = {k: (lv, cls) for k, lv, cls, *_ in _run()["total"]}
top = ["direct", "net_cost", "da", "supply_price", "contract", "grand"]
assert all(rows[k] == (1, "is-lv1 is-title") for k in top)
assert all(rows[k] == (1, "is-title") for k in top)
for k in ["in_price", "in_env", "indirect_labor", "machine", "retirement", "overhead", "vat",
"supply"]: # fmt: skip
assert rows[k] == (2, "is-lv2"), k
assert rows[k] == (2, None), k
assert {lv for lv, _ in rows.values()} == {1, 2}
def test_원가계산서_레벨():
rows = {k: (lv, cls) for k, lv, cls, *_ in _run()["cost"]}
for k in ["overhead", "profit", "supply_price", "vat", "contract", "supply", "grand"]:
assert rows[k] == (1, "is-lv1 is-title"), k
assert rows[k] == (1, "is-title"), k
for k in ["material", "labor", "expense_sum"]:
assert rows[k] == (2, "is-lv2"), k
assert rows[k] == (2, None), k
for k in ["direct_material", "scrap", "direct_labor", "indirect_labor", "expense", "safety"]:
assert rows[k] == (3, "is-lv3"), k
assert rows[k] == (3, None), k
def test_칸_class_명칭만_들여씀():
def test_명칭_칸만_들여씀_공용_level():
out = _run()
for tab in ("total", "cost"):
for _k, _lv, _cls, name, first, amount in out[tab]:
assert (name, first, amount) == ("is-indent", "is-flat", None)
for _k, lv, _cls, key, of, cell in out[tab]:
assert (key, of, cell) == ("name", lv, None) # 칸 class(is-indent · is-flat) 없음
def test_CSS_레벨_규칙():
def test_CSS_옛_레벨_규칙_없음_공용_표로():
css = (B09 / "B09_Estimation_UI_Page.css").read_text(encoding="utf-8")
assert "is-indent" not in css and "is-flat" not in css and "is-lv" not in css
common = (ROOT / "ui_template" / "ui_template_data_table.css").read_text(encoding="utf-8")
assert re.search(
r'tr\[class\*="is-lv"\] td\.is-flat:first-child \{\s*padding-left: var\(--spacing-8\);', css
r"td\.ui-data-table__lv \{\s*padding-left: calc\(var\(--spacing-8\) \+ var\(--dt-lv, 0\) \* 1em\);",
common,
)
for lv, em in ((2, 1), (3, 2)):
assert re.search(
rf"tr\.is-lv{lv} td\.is-indent \{{\s*padding-left: calc\(var\(--spacing-8\) \+ {em}em\);",
css,
)
# 글자 크기 = 182 규칙 재사용(줄 높이 28px → 16px 까지)
assert re.search(r"tr\.is-lv1 td \{\s*font-size: var\(--text-body\);", css)
@@ -174,34 +174,15 @@ def test_키_셋_편집_중엔_안_먹음():
def test_왼쪽_맞춤_레벨_들여쓰기_글자_크기_한_벌():
css = (B09 / "B09_Estimation_UI_Page.css").read_text("utf-8")
bill = '.b09-estimation [data-b09-table="bill"]'
# 레벨마다 한 칸(1em) — 변수로 공종 · 명칭 칸(글 칸이면 글 상자) 왼쪽 padding
for lv in range(2, 7):
assert f"{bill} tbody tr.is-lv{lv} {{\n --b09-lv: {lv - 1};" in css.replace("\r", "")
pad = "padding-left: calc(var(--spacing-8) + var(--b09-lv) * 1em);"
assert re.search(
re.escape(f'{bill} tbody tr[class*="is-lv"] td:first-child {{') + r"\s*" + re.escape(pad),
css,
)
assert re.search(
re.escape(f"{bill} tbody td:first-child .ui-data-table__input {{")
+ r"\s*"
+ re.escape(pad),
css,
)
assert re.search(
re.escape(f'{bill} tbody tr[class*="is-lv"] td.is-edit:first-child {{')
+ r"\s*padding-left: 0;",
css,
)
# 글 칸 왼쪽 맞춤(수량만 오른쪽) · 글자 크기 = 표 한 벌(182 16/14/13px 덮음 · 굵기만)
# 201-1 레벨 들여쓰기 = 공용 표 level 옵션(test_201_1) · 레벨별 글자 크기 규칙 없음(한 벌)
assert "is-lv" not in css and "font-size: 13px" not in css
src = (B09 / "B09_Estimation_UI_Tabs_Bill.ts").read_text("utf-8")
assert 'level: { key: "name", of: (i) => rows[i]?.level' in src
# 글 칸 왼쪽 맞춤(수량만 오른쪽)
assert re.search(
re.escape(f'{bill} .ui-data-table__input:not([data-k="qty"]) {{') + r"\s*text-align: left;",
css,
)
assert re.search(
re.escape(f'{bill} tbody tr[class*="is-lv"] td {{') + r"\s*font-size: inherit;", css
)
assert css.index("200-2 설계내역서 레벨") > css.index("182 설계내역서 레벨") # 뒤에 와서 덮음
assert re.search(r"\.b09-estimation__level \{\s*display: flex;", css)
@@ -0,0 +1,135 @@
"""201-1 공용 표 level(보기 전용) · mergeSame(같은 값 세로 병합) 옵션 + B08 · B09 적용 표.
- level { key, of } = 그 열 칸 class ui-data-table__lv · 줄 style --dt-lv(레벨 − 1) · 글 상자 칸도 · 다루기 단추 없음
- mergeSame [열] = 위아래 같은 글이 이어지면 rowspan · 앞 병합 열이 갈리면 갈림 · 빈 값 · 빈칸 글 · 고칠 칸 안 합침 ·
합계 줄 안 합침 · 그린 구간(win) 안에서만 · 병합 표 칸 = data-c(열 번호)
- 옵션 없으면 무변(rowspan · data-c · --dt-lv 없음)
"""
import json
import re
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
JS = r"""
const fs = require("fs");
const path = require("path");
const ts = require(path.join(process.argv[1], "config", "node_modules", "typescript"));
const file = path.join(process.argv[1], "ui_template", "ui_template_data_table.ts");
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
new Function("exports", "module", "require", js)(box.exports, box, (p) => (p.endsWith(".css") ? {} : require(p)));
const m = box.exports;
const body = (html) => html.split("<tbody>")[1].split("</tbody>")[0].split("</tr>").filter(Boolean);
const columns = [
{ key: "g", label: "구분" }, { key: "s", label: "규격" }, { key: "n", label: "명칭" },
{ key: "v", label: "값", kind: "number" },
];
const rows = [
{ g: "흙깍기", s: "가", n: "a", v: 1 },
{ g: "흙깍기", s: "가", n: "b", v: 1 },
{ g: "흙깍기", s: "나", n: "c", v: 1 },
{ g: "운반", s: "나", n: "d", v: 2 },
{ g: "운반", s: " ", n: "e", v: 2 },
{ g: "", s: " ", n: "f", v: 2 },
{ g: "", s: "다", n: "g", v: 3 },
];
const total = { g: "계", v: 12 };
const out = {};
out.plain = m.renderDataTableHtml({ columns, rows, total });
const merged = { columns, rows, total, mergeSame: ["g", "s"] };
out.merged = body(m.renderDataTableHtml(merged));
out.mergedFoot = m.renderDataTableHtml(merged).split("<tfoot>")[1];
out.spans = m.mergeSpans(merged, 0, rows.length);
out.win = m.mergeSpans(merged, 1, 4);
out.edit = m.mergeSpans({ ...merged, editable: (i, k) => i === 1 && k === "g" }, 0, 3);
out.level = body(m.renderDataTableHtml({
columns, rows: rows.slice(0, 3), level: { key: "n", of: (i) => [1, 2, null][i] },
editable: (i, k) => i === 1 && k === "n",
}));
out.cell = [m.cellColumn({ dataset: { c: "3" }, cellIndex: 1 }), m.cellColumn({ dataset: {}, cellIndex: 1 })];
console.log(JSON.stringify(out));
"""
def _run() -> dict:
result = subprocess.run(
["node", "-e", JS, str(ROOT)], capture_output=True, text=True, encoding="utf-8", timeout=60
)
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
def test_옵션_없으면_무변():
html = _run()["plain"]
assert "rowspan" not in html.split("<tbody>")[1]
assert "data-c=" not in html and "--dt-lv" not in html and "ui-data-table__lv" not in html
def test_mergeSame_같은_값만_rowspan():
out = _run()
# 구분: 흙깍기 3 · 운반 2 · 빈 값 2(안 합침) / 규격: 가 2(흙깍기 안) · 나 = 구분이 갈려 따로 · 빈칸 글 안 합침
assert [s[0] for s in out["spans"]] == [3, 0, 0, 2, 0, 1, 1]
assert [s[1] for s in out["spans"]] == [2, 0, 1, 1, 1, 1, 1]
assert all(s[2] == 1 and s[3] == 1 for s in out["spans"]) # 지정 안 한 열 = 그대로
rows = out["merged"]
assert '<td rowspan="3" data-c="0">흙깍기</td>' in rows[0].replace(' class="is-merged"', "")
assert 'class="is-merged"' in rows[0]
assert (
rows[1].count("<td") == 2 and '<td data-c="2">b</td>' in rows[1]
) # 합쳐진 칸 빠짐 · 열 번호
assert '<td data-num="" data-c="3">2</td>' in rows[4] # 숫자 값 그대로
assert "rowspan" not in out["mergedFoot"] # 합계 줄 안 합침
# 그린 구간(1~3줄) 안에서만 · 고칠 칸 안 합침
assert out["win"] == [[2, 1, 1, 1], [0, 1, 1, 1], [1, 1, 1, 1]]
assert [s[0] for s in out["edit"]] == [1, 1, 1]
assert out["cell"] == [3, 1]
def test_level_보기_전용_들여쓰기():
rows = _run()["level"]
assert [re.search(r'style="--dt-lv:(\d)"', r).group(1) for r in rows] == ["0", "1", "0"]
assert '<td class="ui-data-table__lv">a</td>' in rows[0]
assert rows[0].count("ui-data-table__lv") == 1 # 지정 열만
assert '<td class="is-edit ui-data-table__lv" data-num="">' in rows[1] # 글 상자 칸도
assert "button" not in "".join(rows) # 다루기 단추 없음(설계내역서만)
css = (ROOT / "ui_template" / "ui_template_data_table.css").read_text("utf-8")
pad = r"padding-left: calc\(var\(--spacing-8\) \+ var\(--dt-lv, 0\) \* 1em\);"
assert re.search(r"td\.ui-data-table__lv \{\s*" + pad + r"\s*text-align: left;", css)
assert re.search(r"td\.ui-data-table__lv \.ui-data-table__input \{\s*" + pad, css)
assert re.search(r"td\.is-merged \{\s*vertical-align: top;", css)
#: 적용 표 — [파일, 들어 있어야 할 글]
APPLIED = [
("B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts", 'mergeSame: ["group", "spec"]'),
("B08_Quantity/B08_Quantity_UI_Material.ts", 'mergeSame: ["name"]'),
("B08_Quantity/B08_Quantity_UI_Material.ts", 'mergeSame: ["group"]'),
("B08_Quantity/B08_Quantity_UI_Material.ts", "mergeSame: built.mergeSame"),
("B09_Estimation/B09_Estimation_UI_Tabs_Base.ts", 'mergeSame: ["part"]'),
("B09_Estimation/B09_Estimation_UI_Tabs_Base.ts", 'level: blockLevel("name"'),
("B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts", 'level: blockLevel("name"'),
("B09_Estimation/B09_Estimation_UI_Tabs_Sangeun.ts", 'level: blockLevel("text"'),
("B09_Estimation/B09_Estimation_UI_Tabs_Sangeun.ts", 'mergeSame: ["kind"]'),
("B09_Estimation/B09_Estimation_UI_Tabs_Total.ts", 'mergeSame: ["group", "part"]'),
("B09_Estimation/B09_Estimation_UI_Tabs_Total.ts", 'mergeSame: ["part"]'),
("B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts", 'level: { key: "name"'),
("B09_Estimation/B09_Estimation_UI_Shell.ts", "mergeSame: spec.mergeSame"),
]
def test_B08_B09_적용():
for path, text in APPLIED:
assert text in (ROOT / path).read_text("utf-8"), (path, text)
# 옛 글 빈칸 지우기 · 앞 빈칸 들여쓰기는 없앰(병합 · level 로 갈음)
mat = (ROOT / "B08_Quantity/B08_Quantity_UI_Material.ts").read_text("utf-8")
assert '=== row.name ? ""' not in mat and '=== row.group ? ""' not in mat
labor = (ROOT / "B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts").read_text("utf-8")
assert 'const CHILD = "ㄴ "' in labor and '" 합 계"' not in labor
# 셸 칸 → 열 = cellColumn(병합 표에서 cellIndex 가 밀림)
shell = (ROOT / "B09_Estimation/B09_Estimation_UI_Shell.ts").read_text("utf-8")
assert ".cellIndex" not in shell and shell.count("cellColumn(") == 2
@@ -104,7 +104,10 @@ def test_묶음_탭_단추가_보조_줄에_안내는_아래에():
def test_잇기_쉘_안내_줄_아래_도구_비움():
shell = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8")
assert "panel.replaceChildren(table.root, ...(hint ? [hint] : []))" in shell
assert (
"panel.replaceChildren(...(level ? [level] : []), table.root, ...(hint ? [hint] : []))"
in shell
) # 200-2 레벨 줄 · 203-1 연결 점검 탭 없앰과 합침
assert "...(hint ? [hint] : []), table.root" not in shell # 위 자리 없음
assert re.search(r"bars\.draw\(tab\);\s*bars\.setTools\(\[\]\);", shell)
assert "setTools: bars.setTools" in shell
+21
View File
@@ -172,3 +172,24 @@
outline: 2px solid var(--color-focus-ring);
outline-offset: -2px;
}
/* 레벨(PLAN 201-1 · level 옵션 · 보기 전용) — 레벨 열 왼쪽 맞춤 · 레벨마다 1em 들여쓰기(글 상자도) */
.ui-data-table__table td.ui-data-table__lv {
padding-left: calc(var(--spacing-8) + var(--dt-lv, 0) * 1em);
text-align: left;
}
.ui-data-table__table td.ui-data-table__lv.is-edit {
padding-left: 0;
}
.ui-data-table__table td.ui-data-table__lv .ui-data-table__input {
padding-left: calc(var(--spacing-8) + var(--dt-lv, 0) * 1em);
text-align: left;
}
/* 같은 값 세로 병합(PLAN 201-1 · mergeSame) — 한 칸 글은 위에 */
.ui-data-table__table td.is-merged {
vertical-align: top;
padding-top: calc((var(--dt-row-h) - 1.3em) / 2);
}
+69 -5
View File
@@ -11,6 +11,9 @@
* 칸 누름(PLAN 144) = onCellClick(줄, 열) · 줄로 이동 = t.focusRow(줄)(스크롤 + 강조)
* 줄 꼴(PLAN 155-2) = rowClass(줄) → 줄 class(예 is-title 굵게) · 안 주면 글 그대로
* 창 끝까지(PLAN 199) = fillViewport → 표 칸 최대 높이를 창 아래 끝까지(줄이 늘어도 페이지 스크롤 0)
* 레벨(PLAN 201-1 · 보기 전용) = level { key, of(줄) } → 그 열 왼쪽 맞춤 + 레벨마다 1em 들여쓰기(글 상자도)
* 같은 값 세로 병합(PLAN 201-1) = mergeSame [열] → 위아래로 같은 값이 이어지면 한 칸(rowSpan) · 본문만
* 앞 병합 열이 갈리면 뒤 병합 열도 갈림 · 빈 값 · 고칠 칸은 안 합침 · 칸 → 열 = `cellColumn`(병합 표는 data-c)
* ========================================================================== */
import "./ui_template_data_table.css";
@@ -65,6 +68,10 @@ export interface DataTableOptions {
cellClass?: (index: number, key: string) => string | null | undefined;
/** 참이면 표 칸 최대 높이 = 창 아래 끝까지(창 크기 바뀌면 다시 셈) — 안 주면 CSS 고정값 */
fillViewport?: boolean;
/** 레벨(보기 전용) — key 열을 레벨마다 1em 들여씀 · of = 줄 레벨(1 = 맨 위 · 빈 값 = 1) */
level?: { key: string; of: (index: number) => number | null | undefined };
/** 같은 값 세로 병합할 열 키(표마다 지정) — 위아래로 같은 글이 이어지면 rowSpan 한 칸 */
mergeSame?: string[];
}
export interface DataTable {
@@ -145,15 +152,21 @@ function rowHtml(
focus = false,
extra: string | null | undefined = null,
cellClass?: (key: string) => string | null | undefined,
more: RowMore = {},
): string {
let tr = "";
const { lv, spans } = more;
for (let j = 0; j < cols.length; j++) {
const key = cols[j].key;
const span = spans?.[j] ?? 1;
if (!span) continue; // 위 칸에 합쳐짐
const lvc = lv?.key === key ? " ui-data-table__lv" : "";
const c = spans ? ` data-c="${j}"` : "";
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>`;
tr += `<td class="is-edit${lvc}" data-num=""${c}><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);
@@ -163,24 +176,68 @@ function rowHtml(
frz && left[j + 1] == null && "is-edge",
empty && "is-empty",
cellClass?.(key),
lvc.trim(),
span > 1 && "is-merged",
]
.filter(Boolean)
.join(" ");
const attrs =
(cls ? ` class="${cls}"` : "") +
(frz ? ` style="left:${left[j]}px"` : "") +
(fmts[j] ? ' data-num=""' : "");
(fmts[j] ? ' data-num=""' : "") +
(span > 1 ? ` rowspan="${span}"` : "") +
c;
tr += `<td${attrs}>${esc(txt)}</td>`;
}
const cls = [alt && "is-alt", index !== null && "is-clickable", focus && "is-focus", extra]
.filter(Boolean)
.join(" ");
const data = index !== null ? ` data-i="${index}"` : "";
const data =
(index !== null ? ` data-i="${index}"` : "") + (lv ? ` style="--dt-lv:${lv.n}"` : "");
return cls || data
? `<tr${cls ? ` class="${esc(cls)}"` : ""}${data}>${tr}</tr>`
: `<tr>${tr}</tr>`;
}
/** 줄 곁값 — lv = 레벨 열 · 들여쓸 칸 수 · spans = 열마다 rowSpan(0 = 위 칸에 합쳐짐) */
interface RowMore {
lv?: { key: string; n: number };
spans?: number[];
}
/** 칸 → 열 번호 — 병합 표는 칸이 빠져 cellIndex 가 밀리므로 data-c */
export function cellColumn(cell: HTMLTableCellElement): number {
return cell.dataset.c !== undefined ? Number(cell.dataset.c) : cell.cellIndex;
}
/** 병합 구간(from ~ to 줄) — [줄][열] rowSpan · 0 = 위 칸에 합쳐짐 · 병합 안 하는 열 = 1 */
export function mergeSpans(o: DataTableOptions, from: number, to: number): number[][] {
const cols = o.columns;
const keys = new Set(o.mergeSame ?? []);
const out = Array.from({ length: to - from }, () => cols.map(() => 1));
// 이음 = 이 열까지 앞 병합 열이 모두 위 줄과 같은 글(빈 값 · 고칠 칸 빼고)
const shown = (i: number, j: number): string | null => {
const key = cols[j].key;
if (o.editable?.(i, key)) return null;
const [txt] = formatCell(o.rows[i][key], null, "");
return txt.trim() ? txt : null; // 빈 값 · 빈칸 글(" ")은 안 합침
};
for (let i = from + 1; i < to; i++) {
let joined = true;
for (let j = 0; j < cols.length; j++) {
if (!keys.has(cols[j].key)) continue;
const v = shown(i, j);
joined = joined && v !== null && v === shown(i - 1, j);
if (!joined) continue;
let top = i - 1;
while (!out[top - from][j]) top--;
out[top - from][j]++;
out[i - from][j] = 0;
}
}
return out;
}
/** 그릴 줄 구간 — 밖의 줄은 빈 줄 높이(rowH px)로만 채움 */
export interface RowWindow {
from: number;
@@ -216,7 +273,9 @@ export function renderDataTableHtml(o: DataTableOptions, win?: RowWindow): strin
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++)
const spans = o.mergeSame?.length ? mergeSpans(o, from, to) : null;
for (let i = from; i < to; i++) {
const lvN = o.level ? Math.max(0, (o.level.of(i) ?? 1) - 1) : 0;
body += rowHtml(
cols,
fmts,
@@ -230,7 +289,12 @@ export function renderDataTableHtml(o: DataTableOptions, win?: RowWindow): strin
o.focus === i,
o.rowClass?.(i),
o.cellClass ? (key) => o.cellClass?.(i, key) : undefined,
{
...(o.level ? { lv: { key: o.level.key, n: lvN } } : {}),
...(spans ? { spans: spans[i - from] } : {}),
},
);
}
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>` : "";
@@ -342,7 +406,7 @@ export function createDataTable(opts: DataTableOptions): DataTable {
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;
const column = cell ? state.columns[cellColumn(cell)] : undefined;
if (column) state.onCellClick?.(Number(row.dataset.i), column.key);
state.onRowClick?.(Number(row.dataset.i));
});