Added financial report

This commit is contained in:
Roba Boru
2026-08-12 11:24:12 +03:00
parent e2e1685b62
commit bdcdb4f047
12 changed files with 1170 additions and 232 deletions

View File

@@ -0,0 +1,245 @@
import ExcelJS from 'exceljs';
import type { FinanceGranularity, FinanceSummaryReport } from '@/lib/api/finance';
// Brand palette — rgb(20,113,76), the same green used by ActionButton's primary variant,
// so the exported file reads as the same product as the on-screen report.
const BRAND = 'FF14714C';
const BRAND_DARK = 'FF0E5A3D';
const BRAND_TINT = 'FFEAF5EF';
const INK = 'FF1F2937';
const MUTED = 'FF6B7280';
const ROW_ALT = 'FFF7F8F7';
const BORDER = 'FFE2E5E1';
const WHITE = 'FFFFFFFF';
const CURRENCY_FMT = '"ETB" #,##0.00';
const THIN_BORDER: Partial<ExcelJS.Borders> = {
top: { style: 'thin', color: { argb: BORDER } },
left: { style: 'thin', color: { argb: BORDER } },
bottom: { style: 'thin', color: { argb: BORDER } },
right: { style: 'thin', color: { argb: BORDER } },
};
export interface ChartImage {
dataUrl: string;
width: number;
height: number;
}
export interface FinanceWorkbookInput {
report: FinanceSummaryReport;
filters: { dateFrom: string; dateTo: string; granularity: FinanceGranularity; originLabel: string; destinationLabel: string; methodLabel: string };
methodLabel: (method: string) => string;
periodLabel: (period: string, granularity: FinanceGranularity) => string;
images: { trend?: ChartImage; segment?: ChartImage; method?: ChartImage };
}
function styleHeaderCell(cell: ExcelJS.Cell) {
cell.font = { bold: true, color: { argb: WHITE }, size: 11 };
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: BRAND } };
cell.alignment = { vertical: 'middle', horizontal: 'left' };
cell.border = THIN_BORDER;
}
function addTableHeader(ws: ExcelJS.Worksheet, rowIndex: number, headers: string[], alignRight: Set<number> = new Set()) {
const row = ws.getRow(rowIndex);
headers.forEach((h, i) => {
const cell = row.getCell(i + 1);
cell.value = h;
styleHeaderCell(cell);
if (alignRight.has(i)) cell.alignment = { vertical: 'middle', horizontal: 'right' };
});
row.height = 20;
row.commit();
}
function bandRow(ws: ExcelJS.Worksheet, rowIndex: number, colCount: number, isAlt: boolean) {
const row = ws.getRow(rowIndex);
for (let c = 1; c <= colCount; c++) {
const cell = row.getCell(c);
cell.border = THIN_BORDER;
if (isAlt) cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: ROW_ALT } };
}
}
function titleBanner(ws: ExcelJS.Worksheet, title: string, subtitle: string, colSpan: number) {
ws.mergeCells(1, 1, 1, colSpan);
const titleCell = ws.getCell(1, 1);
titleCell.value = title;
titleCell.font = { bold: true, size: 18, color: { argb: WHITE } };
titleCell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: BRAND } };
titleCell.alignment = { vertical: 'middle', horizontal: 'left', indent: 1 };
ws.getRow(1).height = 34;
for (let c = 1; c <= colSpan; c++) ws.getCell(1, c).fill = titleCell.fill;
ws.mergeCells(2, 1, 2, colSpan);
const subCell = ws.getCell(2, 1);
subCell.value = subtitle;
subCell.font = { italic: true, size: 10, color: { argb: MUTED } };
subCell.alignment = { vertical: 'middle', horizontal: 'left', indent: 1 };
ws.getRow(2).height = 18;
}
function kpiCard(ws: ExcelJS.Worksheet, startRow: number, startCol: number, span: number, label: string, value: string, accent: string) {
ws.mergeCells(startRow, startCol, startRow, startCol + span - 1);
ws.mergeCells(startRow + 1, startCol, startRow + 1, startCol + span - 1);
const labelCell = ws.getCell(startRow, startCol);
labelCell.value = label.toUpperCase();
labelCell.font = { bold: true, size: 9, color: { argb: MUTED } };
labelCell.alignment = { vertical: 'middle', horizontal: 'left', indent: 1 };
const valueCell = ws.getCell(startRow + 1, startCol);
valueCell.value = value;
valueCell.font = { bold: true, size: 16, color: { argb: accent } };
valueCell.alignment = { vertical: 'middle', horizontal: 'left', indent: 1 };
for (let r = startRow; r <= startRow + 1; r++) {
for (let c = startCol; c < startCol + span; c++) {
const cell = ws.getCell(r, c);
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: BRAND_TINT } };
cell.border = {
top: r === startRow ? { style: 'thin', color: { argb: BORDER } } : undefined,
bottom: r === startRow + 1 ? { style: 'thin', color: { argb: BORDER } } : undefined,
left: c === startCol ? { style: 'thin', color: { argb: BORDER } } : undefined,
right: c === startCol + span - 1 ? { style: 'thin', color: { argb: BORDER } } : undefined,
};
}
}
ws.getRow(startRow).height = 16;
ws.getRow(startRow + 1).height = 26;
}
function addImage(wb: ExcelJS.Workbook, ws: ExcelJS.Worksheet, image: ChartImage | undefined, anchorRow: number, heading: string) {
const headingCell = ws.getCell(anchorRow, 1);
headingCell.value = heading;
headingCell.font = { bold: true, size: 12, color: { argb: INK } };
ws.getRow(anchorRow).height = 20;
if (!image) {
const emptyCell = ws.getCell(anchorRow + 1, 1);
emptyCell.value = 'No chart available for the current filters.';
emptyCell.font = { italic: true, size: 10, color: { argb: MUTED } };
return anchorRow + 3;
}
const maxWidth = 640;
const scale = image.width > maxWidth ? maxWidth / image.width : 1;
const width = Math.round(image.width * scale);
const height = Math.round(image.height * scale);
const imageId = wb.addImage({ base64: image.dataUrl, extension: 'png' });
ws.addImage(imageId, {
tl: { col: 0.15, row: anchorRow + 0.15 },
ext: { width, height },
});
// Advance past the image height (≈20px per row) plus a spacer row.
const rowsUsed = Math.ceil(height / 20) + 2;
return anchorRow + rowsUsed;
}
export async function buildFinanceWorkbook(input: FinanceWorkbookInput): Promise<Blob> {
const { report, filters, images } = input;
const methodLabel = input.methodLabel;
const periodLabel = input.periodLabel;
const wb = new ExcelJS.Workbook();
wb.creator = 'EDR Passenger Backoffice';
wb.created = new Date();
// ── Summary sheet ─────────────────────────────────────────────────────────
const summary = wb.addWorksheet('Summary', { views: [{ showGridLines: false }] });
summary.columns = [{ width: 16 }, { width: 16 }, { width: 16 }, { width: 16 }, { width: 16 }, { width: 16 }];
titleBanner(
summary,
'EDR Passenger — Finance Summary',
`${filters.dateFrom} to ${filters.dateTo} · ${filters.granularity} · Origin: ${filters.originLabel} · Destination: ${filters.destinationLabel} · Method: ${filters.methodLabel} · Generated ${new Date().toLocaleString('en-US')}`,
6,
);
const avgPerBooking = report.totals.bookingCount > 0 ? report.totals.revenueEtbMinor / report.totals.bookingCount : 0;
kpiCard(summary, 4, 1, 2, 'Total Revenue', `ETB ${(report.totals.revenueEtbMinor / 100).toLocaleString('en-US', { minimumFractionDigits: 2 })}`, BRAND_DARK);
kpiCard(summary, 4, 3, 2, 'Bookings', report.totals.bookingCount.toLocaleString('en-US'), INK);
kpiCard(summary, 4, 5, 2, 'Avg. per Booking', `ETB ${(avgPerBooking / 100).toLocaleString('en-US', { minimumFractionDigits: 2 })}`, INK);
let cursor = 7;
cursor = addImage(wb, summary, images.trend, cursor, 'Revenue Trend') + 1;
cursor = addImage(wb, summary, images.segment, cursor, 'Revenue by Segment') + 1;
addImage(wb, summary, images.method, cursor, 'Revenue by Payment Method');
// ── By Period sheet ──────────────────────────────────────────────────────
const byPeriod = wb.addWorksheet('By Period', { views: [{ state: 'frozen', ySplit: 1 }] });
byPeriod.columns = [{ width: 18 }, { width: 14 }, { width: 20 }];
addTableHeader(byPeriod, 1, ['Period', 'Bookings', 'Revenue (ETB)'], new Set([1, 2]));
const periodRows = [...report.byPeriod].sort((a, b) => a.key.localeCompare(b.key));
periodRows.forEach((p, i) => {
const r = byPeriod.getRow(i + 2);
r.getCell(1).value = periodLabel(p.key, report.granularity);
r.getCell(2).value = p.bookingCount;
r.getCell(2).alignment = { horizontal: 'right' };
r.getCell(3).value = p.revenueEtbMinor / 100;
r.getCell(3).numFmt = CURRENCY_FMT;
r.getCell(3).alignment = { horizontal: 'right' };
bandRow(byPeriod, i + 2, 3, i % 2 === 1);
});
byPeriod.autoFilter = { from: { row: 1, column: 1 }, to: { row: 1, column: 3 } };
// ── By Segment sheet ─────────────────────────────────────────────────────
const bySegment = wb.addWorksheet('By Segment', { views: [{ state: 'frozen', ySplit: 1 }] });
bySegment.columns = [{ width: 34 }, { width: 14 }, { width: 20 }];
addTableHeader(bySegment, 1, ['Origin → Destination', 'Bookings', 'Revenue (ETB)'], new Set([1, 2]));
report.bySegment.forEach((s, i) => {
const r = bySegment.getRow(i + 2);
r.getCell(1).value = s.label;
r.getCell(2).value = s.bookingCount;
r.getCell(2).alignment = { horizontal: 'right' };
r.getCell(3).value = s.revenueEtbMinor / 100;
r.getCell(3).numFmt = CURRENCY_FMT;
r.getCell(3).alignment = { horizontal: 'right' };
bandRow(bySegment, i + 2, 3, i % 2 === 1);
});
bySegment.autoFilter = { from: { row: 1, column: 1 }, to: { row: 1, column: 3 } };
// ── By Method sheet ──────────────────────────────────────────────────────
const byMethod = wb.addWorksheet('By Method', { views: [{ state: 'frozen', ySplit: 1 }] });
byMethod.columns = [{ width: 20 }, { width: 14 }, { width: 20 }, { width: 12 }];
addTableHeader(byMethod, 1, ['Payment Method', 'Bookings', 'Revenue (ETB)', 'Share'], new Set([1, 2, 3]));
const methodTotal = report.byMethod.reduce((sum, m) => sum + m.revenueEtbMinor, 0);
report.byMethod.forEach((m, i) => {
const r = byMethod.getRow(i + 2);
r.getCell(1).value = methodLabel(m.key);
r.getCell(2).value = m.bookingCount;
r.getCell(2).alignment = { horizontal: 'right' };
r.getCell(3).value = m.revenueEtbMinor / 100;
r.getCell(3).numFmt = CURRENCY_FMT;
r.getCell(3).alignment = { horizontal: 'right' };
r.getCell(4).value = methodTotal > 0 ? m.revenueEtbMinor / methodTotal : 0;
r.getCell(4).numFmt = '0.0%';
r.getCell(4).alignment = { horizontal: 'right' };
bandRow(byMethod, i + 2, 4, i % 2 === 1);
});
byMethod.autoFilter = { from: { row: 1, column: 1 }, to: { row: 1, column: 4 } };
// ── Detail sheet — every row, unpaginated ───────────────────────────────
const detail = wb.addWorksheet('Detail', { views: [{ state: 'frozen', ySplit: 1 }] });
detail.columns = [{ width: 18 }, { width: 34 }, { width: 18 }, { width: 14 }, { width: 20 }];
addTableHeader(detail, 1, ['Period', 'Origin → Destination', 'Payment Method', 'Bookings', 'Revenue (ETB)'], new Set([2, 3]));
report.rows.forEach((row, i) => {
const r = detail.getRow(i + 2);
r.getCell(1).value = periodLabel(row.period, report.granularity);
r.getCell(2).value = row.segmentLabel;
r.getCell(3).value = methodLabel(row.method);
r.getCell(4).value = row.bookingCount;
r.getCell(4).alignment = { horizontal: 'right' };
r.getCell(5).value = row.revenueEtbMinor / 100;
r.getCell(5).numFmt = CURRENCY_FMT;
r.getCell(5).alignment = { horizontal: 'right' };
bandRow(detail, i + 2, 5, i % 2 === 1);
});
detail.autoFilter = { from: { row: 1, column: 1 }, to: { row: 1, column: 5 } };
const buffer = await wb.xlsx.writeBuffer();
return new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
}