diff --git a/B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts b/B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts index 420100cc8..e7acf989c 100644 --- a/B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts +++ b/B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts @@ -40,6 +40,7 @@ export function renderEarthworkSummary(host: HTMLElement, table: EarthworkTable) note: row.note || " ", })), frozen: 1, + mergeSame: ["group", "spec"], // 201-1 같은 구분 · 규격 이어지면 세로 병합(빈칸은 안 합침) caption: "토공집계표", fillViewport: true, }); diff --git a/B08_Quantity/B08_Quantity_UI_Material.ts b/B08_Quantity/B08_Quantity_UI_Material.ts index 0f148cd02..53d2223ed 100644 --- a/B08_Quantity/B08_Quantity_UI_Material.ts +++ b/B08_Quantity/B08_Quantity_UI_Material.ts @@ -109,6 +109,8 @@ interface Table { editable: (index: number, key: string) => boolean; /** 고친 칸 → 사용자 값 자리 [표, 줄 키, 칸 키] */ target: (index: number, key: string) => [Record | 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, diff --git a/B09_Estimation/B09_Estimation_UI_Page.css b/B09_Estimation/B09_Estimation_UI_Page.css index 6cb680503..311d446d0 100644 --- a/B09_Estimation/B09_Estimation_UI_Page.css +++ b/B09_Estimation/B09_Estimation_UI_Page.css @@ -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)); diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index 546565b2c..c3c67a6c6 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -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("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", diff --git a/B09_Estimation/B09_Estimation_UI_Tabs.ts b/B09_Estimation/B09_Estimation_UI_Tabs.ts index 5f6eca99d..1a3405e96 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs.ts @@ -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 => ({ diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts index f1a978755..fdd74a838 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts @@ -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("|")), }; }; diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts index c14b71c14..e29e0b762 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts @@ -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); diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts index e50dfece3..50b500e2f 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts @@ -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) diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Sangeun.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Sangeun.ts index f98ab1cfa..1776afdf3 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Sangeun.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Sangeun.ts @@ -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, }; diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts index 0614ea532..96a3691aa 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts @@ -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), diff --git a/resources/tester/test_155_b09_tabs.py b/resources/tester/test_155_b09_tabs.py index 4f58a2581..a59f43219 100644 --- a/resources/tester/test_155_b09_tabs.py +++ b/resources/tester/test_155_b09_tabs.py @@ -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] diff --git a/resources/tester/test_182_b09_bill_level.py b/resources/tester/test_182_b09_bill_level.py index f9f68a211..8b938d94d 100644 --- a/resources/tester/test_182_b09_bill_level.py +++ b/resources/tester/test_182_b09_bill_level.py @@ -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) · 묶음 줄 = 안 엶 · 연결된 줄(자동 줄도) = 연결 풀기 더함 diff --git a/resources/tester/test_185_1_b09_total_level.py b/resources/tester/test_185_1_b09_total_level.py index e6011ee2b..6608e4ae1 100644 --- a/resources/tester/test_185_1_b09_total_level.py +++ b/resources/tester/test_185_1_b09_total_level.py @@ -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) diff --git a/resources/tester/test_200_2_b09_bill_level_bar.py b/resources/tester/test_200_2_b09_bill_level_bar.py index b2e0936b5..142109187 100644 --- a/resources/tester/test_200_2_b09_bill_level_bar.py +++ b/resources/tester/test_200_2_b09_bill_level_bar.py @@ -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) diff --git a/resources/tester/test_201_1_table_level_merge.py b/resources/tester/test_201_1_table_level_merge.py new file mode 100644 index 000000000..dbcf7093d --- /dev/null +++ b/resources/tester/test_201_1_table_level_merge.py @@ -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("")[1].split("")[0].split("").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("")[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("")[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 '흙깍기' in rows[0].replace(' class="is-merged"', "") + assert 'class="is-merged"' in rows[0] + assert ( + rows[1].count("b' in rows[1] + ) # 합쳐진 칸 빠짐 · 열 번호 + assert '2' 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 'a' in rows[0] + assert rows[0].count("ui-data-table__lv") == 1 # 지정 열만 + assert '' 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 diff --git a/resources/tester/test_202_1_table_hint_bottom.py b/resources/tester/test_202_1_table_hint_bottom.py index 8847b62e6..a4db1829a 100644 --- a/resources/tester/test_202_1_table_hint_bottom.py +++ b/resources/tester/test_202_1_table_hint_bottom.py @@ -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 diff --git a/ui_template/ui_template_data_table.css b/ui_template/ui_template_data_table.css index af0a5e26d..787d9e90f 100644 --- a/ui_template/ui_template_data_table.css +++ b/ui_template/ui_template_data_table.css @@ -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); +} diff --git a/ui_template/ui_template_data_table.ts b/ui_template/ui_template_data_table.ts index 33656ca10..7f0b93d82 100644 --- a/ui_template/ui_template_data_table.ts +++ b/ui_template/ui_template_data_table.ts @@ -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 += ``; + tr += ``; 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 += `${esc(txt)}`; } 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}` : `${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 ? `${rowHtml(cols, fmts, left, o.total, false, blank)}` : ""; const cap = o.caption ? `${esc(o.caption)}` : ""; @@ -342,7 +406,7 @@ export function createDataTable(opts: DataTableOptions): DataTable { const row = (event.target as Element).closest("tr[data-i]"); if (!row || !root.contains(row)) return; const cell = (event.target as Element).closest("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)); });