mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 13:05:44 +00:00
Added financial report
This commit is contained in:
@@ -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' });
|
||||
}
|
||||
Reference in New Issue
Block a user