From 25776269f1d28a27f52ff79bcf99bfb2f0f3aabf Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 07:48:00 +0000 Subject: [PATCH] Add gray fill and grid borders to invoice Excel export Give the title, subheader and header rows a light gray background and draw thin black borders around the subheader, header, data and totals rows, so the exported workbook has the same table styling as the reference invoice recipients report. The title row's banner fill spans the widest table's full column count rather than being sized to the title text, since Excel's column-width units don't reliably predict how far the title's larger, bold font actually overflows. --- src/util/InvoicesExcelReport.spec.ts | 73 ++++++++++++++++++++++++++++ src/util/InvoicesExcelReport.ts | 33 ++++++++++++- 2 files changed, 105 insertions(+), 1 deletion(-) diff --git a/src/util/InvoicesExcelReport.spec.ts b/src/util/InvoicesExcelReport.spec.ts index 5b84cd38..18c102a2 100644 --- a/src/util/InvoicesExcelReport.spec.ts +++ b/src/util/InvoicesExcelReport.spec.ts @@ -316,6 +316,79 @@ describe('util', () => { }); }); + describe('styling', () => { + const GRAY_FILL = {type: 'pattern', pattern: 'solid', fgColor: {argb: 'FFD9D9D9'}}; + const NO_FILL = {type: 'pattern', pattern: 'none'}; + const THIN_BLACK = {style: 'thin', color: {argb: 'FF000000'}}; + + it('fills the title row across the widest table\'s full width, regardless of title length', async () => { + // A short title still needs the full 12-column span, since the + // landing fees table (the widest) may follow it. + const report = makeReport(2023, 6); + const workbook = await render(report, [''], {'': [arrival()]}); + const sheet = workbook.getWorksheet('Rechnungsempfänger'); + for (let column = 1; column <= 12; column++) { + expect(sheet.getRow(1).getCell(column).fill).toEqual(GRAY_FILL); + } + expect(sheet.getRow(1).getCell(13).fill).toBeUndefined(); + }); + + it('fills the subheader row and closes off its top edge with corner ticks', async () => { + const report = makeReport(); + const workbook = await render(report, ['Company A'], {'Company A': [arrival()]}); + const sheet = workbook.getWorksheet('Company A'); + const subHeaderRow = sheet.getRow(3); + + expect(subHeaderRow.getCell(1).border).toEqual({top: THIN_BLACK, left: THIN_BLACK}); + for (let column = 2; column <= 11; column++) { + expect(subHeaderRow.getCell(column).border).toEqual({top: THIN_BLACK}); + } + expect(subHeaderRow.getCell(12).border).toEqual({top: THIN_BLACK, right: THIN_BLACK}); + for (let column = 1; column <= 12; column++) { + expect(subHeaderRow.getCell(column).fill).toEqual(GRAY_FILL); + } + }); + + it('fills and borders the header row across every column', async () => { + const report = makeReport(); + const workbook = await render(report, ['Company A'], {'Company A': [arrival()]}); + const sheet = workbook.getWorksheet('Company A'); + const headerRow = sheet.getRow(4); + for (let column = 1; column <= 12; column++) { + expect(headerRow.getCell(column).fill).toEqual(GRAY_FILL); + expect(headerRow.getCell(column).border).toEqual({ + top: THIN_BLACK, left: THIN_BLACK, bottom: THIN_BLACK, right: THIN_BLACK, + }); + } + }); + + it('borders every data row cell without filling it', async () => { + const report = makeReport(); + const workbook = await render(report, ['Company A'], {'Company A': [arrival()]}); + const sheet = workbook.getWorksheet('Company A'); + const dataRow = sheet.getRow(5); + for (let column = 1; column <= 12; column++) { + expect(dataRow.getCell(column).border).toEqual({ + top: THIN_BLACK, left: THIN_BLACK, bottom: THIN_BLACK, right: THIN_BLACK, + }); + expect(dataRow.getCell(column).fill).toEqual(NO_FILL); + } + }); + + it('fills and borders the totals row across every column, even non-total ones', async () => { + const report = makeReport(); + const workbook = await render(report, ['Company A'], {'Company A': [arrival()]}); + const sheet = workbook.getWorksheet('Company A'); + const totalsRow = sheet.getRow(6); + for (let column = 1; column <= 12; column++) { + expect(totalsRow.getCell(column).fill).toEqual(GRAY_FILL); + expect(totalsRow.getCell(column).border).toEqual({ + top: THIN_BLACK, left: THIN_BLACK, bottom: THIN_BLACK, right: THIN_BLACK, + }); + } + }); + }); + describe('landing fees table', () => { it('writes amounts as numbers, not formatted strings', async () => { const report = makeReport(); diff --git a/src/util/InvoicesExcelReport.ts b/src/util/InvoicesExcelReport.ts index 18efeb7a..cd6ec7c9 100644 --- a/src/util/InvoicesExcelReport.ts +++ b/src/util/InvoicesExcelReport.ts @@ -12,6 +12,17 @@ export const XLSX_MIME_TYPE = const MONEY_FORMAT = '#,##0.00'; const DATE_FORMAT = 'dd.mm.yyyy'; +const HEADER_FILL = {type: 'pattern', pattern: 'solid', fgColor: {argb: 'FFD9D9D9'}} as const; +const THIN_BLACK = {style: 'thin', color: {argb: 'FF000000'}} as const; +const GRID_BORDER = {top: THIN_BLACK, left: THIN_BLACK, bottom: THIN_BLACK, right: THIN_BLACK}; +// The line above the subheader row: a plain top edge, with a left/right tick +// at the row's own edges closing off the table's outline from above. +const subHeaderBorder = (index: number, lastIndex: number) => ({ + top: THIN_BLACK, + ...(index === 0 ? {left: THIN_BLACK} : {}), + ...(index === lastIndex ? {right: THIN_BLACK} : {}), +}); + // Excel rejects these characters in worksheet names and caps them at 31 chars. const FORBIDDEN_SHEET_NAME_CHARS = /[[\]:*?/\\]/g; const MAX_SHEET_NAME_LENGTH = 31; @@ -158,6 +169,8 @@ class InvoicesExcelReport extends InvoicesReportData { const monthLabel = this.getMonthLabel(); const usedSheetNames: string[] = []; + const titleFillColumns = + Math.max(this.landingFeeColumns().length, this.customsFeeColumns().length); recipientNames.forEach(recipientName => { const sheetName = sanitizeSheetName(recipientName, usedSheetNames); @@ -167,6 +180,12 @@ class InvoicesExcelReport extends InvoicesReportData { const titleRow = sheet.addRow([`${recipientName} (${monthLabel})`]); titleRow.getCell(1).font = {bold: true, size: 14}; + // The title text overflows past its own cell, so fill the same span the + // widest table uses — otherwise the overflow sits on a plain white + // background instead of the banner. + for (let column = 1; column <= titleFillColumns; column++) { + titleRow.getCell(column).fill = HEADER_FILL; + } this.addLandingFeesTable(sheet, arrivalRecipients[recipientName]); this.addCustomsFeesTable(sheet, customsRecipients[recipientName], false); @@ -312,9 +331,17 @@ class InvoicesExcelReport extends InvoicesReportData { const subHeaderRow = sheet.addRow([subHeader]); subHeaderRow.getCell(1).font = {bold: true, size: 12}; + columns.forEach((_, index) => { + subHeaderRow.getCell(index + 1).fill = HEADER_FILL; + subHeaderRow.getCell(index + 1).border = subHeaderBorder(index, columns.length - 1); + }); const headerRow = sheet.addRow(columns.map(column => column.header)); headerRow.font = {bold: true}; + columns.forEach((_, index) => { + headerRow.getCell(index + 1).fill = HEADER_FILL; + headerRow.getCell(index + 1).border = GRID_BORDER; + }); const firstDataRowNumber = headerRow.number + 1; @@ -324,6 +351,7 @@ class InvoicesExcelReport extends InvoicesReportData { if (column.numFmt) { row.getCell(index + 1).numFmt = column.numFmt; } + row.getCell(index + 1).border = GRID_BORDER; }); }); @@ -332,11 +360,14 @@ class InvoicesExcelReport extends InvoicesReportData { const totalsRow = sheet.addRow([]); let totalIndex = 0; columns.forEach((column, index) => { + const cell = totalsRow.getCell(index + 1); + cell.fill = HEADER_FILL; + cell.border = GRID_BORDER; + if (!column.total) { return; } const letter = columnLetter(index + 1); - const cell = totalsRow.getCell(index + 1); // The cached result keeps the total readable by tools that do not // evaluate formulas; without it they see an empty cell. cell.value = {