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

@@ -8,7 +8,7 @@ import {
ApiProduces, ApiProduces,
} from "@nestjs/swagger"; } from "@nestjs/swagger";
import { ReportsService } from "./reports.service"; import { ReportsService } from "./reports.service";
import { BlockedSeatsRevenueLossQueryDto, GenerateReportDto } from "./reports.dto"; import { BlockedSeatsRevenueLossQueryDto, FinanceSummaryQueryDto, GenerateReportDto } from "./reports.dto";
import { PassengerStaff } from "../../common/passenger-guards"; import { PassengerStaff } from "../../common/passenger-guards";
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry"; import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
@@ -83,6 +83,35 @@ export class ReportsController {
return this.service.getPaymentDiscrepancyBySchedule(scheduleId, { search, seatClass, sort }); return this.service.getPaymentDiscrepancyBySchedule(scheduleId, { search, seatClass, sort });
} }
// ── Finance Summary ──────────────────────────────────────────────────────
@Get("finance")
@ApiOperation({
summary: "Finance summary — revenue by period, origin/destination segment, and payment method",
description:
"Revenue collected in the window (PaymentIntent.paidAt), grouped by day/week/month, origin → " +
"destination station pair, and payment method. Filter by originStationId and/or destinationStationId " +
"independently to query any station-pair segment (A→B, A→D, B→C), not just a whole predefined route. " +
"Returns per-bucket rows plus roll-ups by period, segment, and method for charting.",
})
getFinanceSummary(@Query() query: FinanceSummaryQueryDto) {
return this.service.getFinanceSummary(query);
}
@Get("finance/export")
@ApiOperation({ summary: "Finance summary as CSV — one row per period + route + payment method" })
@ApiProduces("text/csv")
@ApiOkResponse({ description: "CSV export", schema: { type: "string" } })
async exportFinanceSummary(@Query() query: FinanceSummaryQueryDto, @Res() res: Response): Promise<void> {
const csv = await this.service.exportFinanceSummaryCsv(query);
res.setHeader("Content-Type", "text/csv; charset=utf-8");
res.setHeader(
"Content-Disposition",
`attachment; filename="finance-summary-${new Date().toISOString().split("T")[0]}.csv"`,
);
res.send(csv);
}
// ── Blocked Seat Revenue Loss ────────────────────────────────────────────── // ── Blocked Seat Revenue Loss ──────────────────────────────────────────────
@Get("blocked-seats-revenue-loss") @Get("blocked-seats-revenue-loss")

View File

@@ -1,6 +1,7 @@
import { IsString, IsDateString, IsOptional, IsEnum, IsInt, Min, Max } from 'class-validator'; import { IsString, IsDateString, IsOptional, IsEnum, IsInt, Min, Max } from 'class-validator';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { PaymentMethodType } from '@prisma/client';
import { SeatBlockReasonCategory } from '../seats/seats.dto'; import { SeatBlockReasonCategory } from '../seats/seats.dto';
export enum ReportType { export enum ReportType {
@@ -103,3 +104,31 @@ export class BlockedSeatsRevenueLossQueryDto {
}) })
@IsOptional() @IsEnum(BlockedSeatsLossSortBy) sortBy?: BlockedSeatsLossSortBy; @IsOptional() @IsEnum(BlockedSeatsLossSortBy) sortBy?: BlockedSeatsLossSortBy;
} }
// ── Finance Summary ──────────────────────────────────────────────────────────
export enum FinanceGranularity {
DAILY = 'daily',
WEEKLY = 'weekly',
MONTHLY = 'monthly',
}
export class FinanceSummaryQueryDto {
@ApiProperty({ example: '2026-07-01', description: 'Start of the window, inclusive, matched on PaymentIntent.paidAt.' })
@IsDateString() dateFrom: string;
@ApiProperty({ example: '2026-07-31', description: 'End of the window, inclusive, matched on PaymentIntent.paidAt.' })
@IsDateString() dateTo: string;
@ApiPropertyOptional({ enum: FinanceGranularity, default: FinanceGranularity.DAILY })
@IsOptional() @IsEnum(FinanceGranularity) granularity?: FinanceGranularity;
@ApiPropertyOptional({ description: 'Restrict to bookings departing from this station.' })
@IsOptional() @IsString() originStationId?: string;
@ApiPropertyOptional({ description: 'Restrict to bookings arriving at this station.' })
@IsOptional() @IsString() destinationStationId?: string;
@ApiPropertyOptional({ enum: PaymentMethodType, description: 'Restrict to payments made with this method.' })
@IsOptional() @IsEnum(PaymentMethodType) method?: PaymentMethodType;
}

View File

@@ -10,6 +10,8 @@ import { FareEngineService } from "../fare-engine/fare-engine.service";
import { import {
BlockedSeatsLossSortBy, BlockedSeatsLossSortBy,
BlockedSeatsRevenueLossQueryDto, BlockedSeatsRevenueLossQueryDto,
FinanceGranularity,
FinanceSummaryQueryDto,
GenerateReportDto, GenerateReportDto,
ReportType, ReportType,
} from "./reports.dto"; } from "./reports.dto";
@@ -84,6 +86,33 @@ function toCsvCell(value: string | number): string {
return `"${String(value).replace(/"/g, '""')}"`; return `"${String(value).replace(/"/g, '""')}"`;
} }
/**
* Buckets a paid-at timestamp into the requested reporting period, keyed so buckets sort
* chronologically as plain strings. Weekly buckets are labelled by their Monday (UTC).
*/
function periodKeyFor(date: Date, granularity: FinanceGranularity): string {
if (granularity === FinanceGranularity.MONTHLY) {
return date.toISOString().slice(0, 7);
}
if (granularity === FinanceGranularity.WEEKLY) {
const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
const isoDay = d.getUTCDay() || 7; // Monday=1 .. Sunday=7
d.setUTCDate(d.getUTCDate() - (isoDay - 1));
return d.toISOString().split("T")[0];
}
return date.toISOString().split("T")[0];
}
export interface FinanceBucket {
period: string;
originStationId: string;
destinationStationId: string;
segmentLabel: string;
method: string;
bookingCount: number;
revenueEtbMinor: number;
}
@Injectable() @Injectable()
export class ReportsService { export class ReportsService {
private readonly logger = new Logger(ReportsService.name); private readonly logger = new Logger(ReportsService.name);
@@ -1051,6 +1080,161 @@ export class ReportsService {
return { totalActualEtbMinor, totalPaidEtbMinor, byMethod, rows }; return { totalActualEtbMinor, totalPaidEtbMinor, byMethod, rows };
} }
// ── Finance Summary ────────────────────────────────────────────────────────
/**
* Revenue collected in the window, grouped by reporting period (day/week/month), origin →
* destination station pair, and payment method — the shape finance reconciles against
* provider settlement statements.
*
* Grouped by the booking's own origin/destination, not the parent Route — a route like
* "Sebeta - Dire Dawa" has intermediate stops, and a passenger may have booked any
* sub-segment of it (e.g. Lebu → Adama). Filtering by station lets finance ask about any
* A→B pair, not just whole routes.
*
* Bucketed on `PaymentIntent.paidAt` (cash actually received), not `Booking.createdAt`,
* so a booking made in one period but paid in another lands in the period it was paid.
*/
async getFinanceSummary(query: FinanceSummaryQueryDto) {
const dateFrom = new Date(query.dateFrom + "T00:00:00.000Z");
const dateTo = new Date(query.dateTo + "T23:59:59.999Z");
const granularity = query.granularity ?? FinanceGranularity.DAILY;
const rateRows = await this.prisma.currencyExchangeRate.findMany({
where: { toCurrency: "ETB" as any },
orderBy: { effectiveDate: "desc" },
});
const rateToEtb = new Map<string, number>();
for (const r of rateRows) {
if (!rateToEtb.has(r.fromCurrency)) rateToEtb.set(r.fromCurrency, Number(r.rate));
}
const toEtbMinor = (minor: number, currency: string): number => {
if (currency === "ETB") return minor;
const rate = rateToEtb.get(currency);
return rate ? Math.round(minor * rate) : minor;
};
const bookings = await this.prisma.booking.findMany({
where: {
paymentIntent: {
paidAt: { gte: dateFrom, lte: dateTo },
...(query.method ? { method: query.method } : {}),
},
...(query.originStationId ? { originStationId: query.originStationId } : {}),
...(query.destinationStationId ? { destinationStationId: query.destinationStationId } : {}),
},
select: {
totalMinor: true,
currency: true,
originStationId: true,
destinationStationId: true,
schedule: { select: { originStationId: true, destinationStationId: true } },
paymentIntent: { select: { paidAt: true, method: true } },
},
});
// Booking.originStationId/destinationStationId are set on every create path (guest and
// authenticated booking both pass them from the DTO); the schedule's own endpoints are
// only a fallback for the rare legacy row that predates those columns.
const stationIds = new Set<string>();
for (const b of bookings) {
const origin = b.originStationId ?? b.schedule.originStationId;
const destination = b.destinationStationId ?? b.schedule.destinationStationId;
if (origin) stationIds.add(origin);
if (destination) stationIds.add(destination);
}
const stations = stationIds.size > 0
? await this.prisma.station.findMany({ where: { id: { in: [...stationIds] } }, select: { id: true, name: true } })
: [];
const stationName = new Map(stations.map((s) => [s.id, s.name]));
const buckets = new Map<string, FinanceBucket>();
const bucketFor = (
period: string,
originStationId: string,
destinationStationId: string,
segmentLabel: string,
method: string,
): FinanceBucket => {
const key = `${period}|${originStationId}|${destinationStationId}|${method}`;
let bucket = buckets.get(key);
if (!bucket) {
bucket = { period, originStationId, destinationStationId, segmentLabel, method, bookingCount: 0, revenueEtbMinor: 0 };
buckets.set(key, bucket);
}
return bucket;
};
for (const b of bookings) {
const pi = b.paymentIntent!;
const period = periodKeyFor(pi.paidAt!, granularity);
const originStationId = b.originStationId ?? b.schedule.originStationId ?? "UNKNOWN";
const destinationStationId = b.destinationStationId ?? b.schedule.destinationStationId ?? "UNKNOWN";
const segmentLabel = `${stationName.get(originStationId) ?? "Unknown"}${stationName.get(destinationStationId) ?? "Unknown"}`;
const bucket = bucketFor(period, originStationId, destinationStationId, segmentLabel, pi.method);
bucket.bookingCount += 1;
bucket.revenueEtbMinor += toEtbMinor(b.totalMinor, b.currency);
}
const rows = [...buckets.values()].sort((a, b) =>
a.period === b.period
? a.segmentLabel.localeCompare(b.segmentLabel) || a.method.localeCompare(b.method)
: a.period.localeCompare(b.period),
);
const totals = rows.reduce(
(acc, r) => {
acc.bookingCount += r.bookingCount;
acc.revenueEtbMinor += r.revenueEtbMinor;
return acc;
},
{ bookingCount: 0, revenueEtbMinor: 0 },
);
const rollUp = (keyOf: (r: FinanceBucket) => string, labelOf: (r: FinanceBucket) => string) => {
const map = new Map<string, { key: string; label: string; revenueEtbMinor: number; bookingCount: number }>();
for (const r of rows) {
const key = keyOf(r);
let entry = map.get(key);
if (!entry) {
entry = { key, label: labelOf(r), revenueEtbMinor: 0, bookingCount: 0 };
map.set(key, entry);
}
entry.revenueEtbMinor += r.revenueEtbMinor;
entry.bookingCount += r.bookingCount;
}
return [...map.values()].sort((a, b) => b.revenueEtbMinor - a.revenueEtbMinor);
};
return {
granularity,
dateFrom: query.dateFrom,
dateTo: query.dateTo,
currency: "ETB",
totals,
byPeriod: rollUp((r) => r.period, (r) => r.period),
bySegment: rollUp((r) => `${r.originStationId}|${r.destinationStationId}`, (r) => r.segmentLabel),
byMethod: rollUp((r) => r.method, (r) => r.method),
rows,
};
}
/** CSV of the finance summary, one row per period + origin/destination segment + payment method. */
async exportFinanceSummaryCsv(query: FinanceSummaryQueryDto): Promise<string> {
const report = await this.getFinanceSummary(query);
const headers = ["Period", "Origin → Destination", "Payment Method", "Bookings", "Revenue (ETB)"];
const rows = report.rows.map((r) => [
r.period,
r.segmentLabel,
r.method,
r.bookingCount,
(r.revenueEtbMinor / 100).toFixed(2),
]);
return [headers, ...rows].map((row) => row.map(toCsvCell).join(",")).join("\n");
}
async getPaymentDiscrepancyBySchedule(scheduleId: string, params: { async getPaymentDiscrepancyBySchedule(scheduleId: string, params: {
search?: string; search?: string;
seatClass?: string; seatClass?: string;

View File

@@ -16,6 +16,8 @@
"axios": "^1.7.7", "axios": "^1.7.7",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^3.0.0", "date-fns": "^3.0.0",
"exceljs": "^4.4.0",
"html-to-image": "^1.11.11",
"lucide-react": "^0.446.0", "lucide-react": "^0.446.0",
"next": "^14.2.0", "next": "^14.2.0",
"react": "^18.3.1", "react": "^18.3.1",

View File

@@ -0,0 +1,3 @@
export default function Layout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}

View File

@@ -0,0 +1,521 @@
'use client';
import { useMemo, useRef, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Banknote, BookOpen, FileSpreadsheet, Receipt } from 'lucide-react';
import {
Bar, BarChart, CartesianGrid, Line, LineChart,
ResponsiveContainer, Tooltip as RechartsTooltip, XAxis, YAxis,
} from 'recharts';
import { toPng } from 'html-to-image';
import { apiClient } from '@/lib/api-client';
import { financeApi, type FinanceGranularity, type FinanceSummaryFilters } from '@/lib/api/finance';
import { buildFinanceWorkbook, type ChartImage } from '@/lib/export/finance-workbook';
import ActionButton from '@/components/ui/ActionButton';
import Pagination from '@/components/ui/Pagination';
import Skeleton from '@/components/ui/Skeleton';
import { usePagination } from '@/lib/use-pagination';
import { formatCurrency } from '@/lib/utils';
import { categoricalColor, getChartPalette } from '@/lib/chart-palette';
import { useTheme } from '@/lib/theme-store';
interface StationOption {
id: string;
name: string;
code: string;
}
// Fixed order so a method keeps its colour/slot when the method filter narrows the set.
const PAYMENT_METHOD_ORDER = ['TELEBIRR', 'CBE_BIRR', 'EBIRR', 'WAAFI', 'CARD', 'WALLET', 'DMONEY', 'CAC_BANK', 'CBE_BILL'] as const;
const PAYMENT_METHOD_LABELS: Record<string, string> = {
TELEBIRR: 'Telebirr',
CBE_BIRR: 'CBE Birr',
EBIRR: 'eBirr',
WAAFI: 'Waafi',
CARD: 'Card',
WALLET: 'Wallet',
DMONEY: 'DMoney',
CAC_BANK: 'CAC Bank',
CBE_BILL: 'CBE Bill',
};
function methodLabel(method: string): string {
return PAYMENT_METHOD_LABELS[method] ?? method;
}
function periodLabel(period: string, granularity: FinanceGranularity): string {
if (granularity === 'monthly') {
return new Date(`${period}-01T00:00:00`).toLocaleDateString('en-US', { month: 'short', year: 'numeric' });
}
return new Date(`${period}T00:00:00`).toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
const TABLE_PAGE_SIZE = 25;
/** Mirrors the loaded layout's shape (KPI tiles, two charts, method breakdown, detail table) so nothing jumps when data arrives. */
function FinanceReportSkeleton() {
return (
<div className="space-y-6">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="card flex flex-col gap-3">
<div className="flex items-center justify-between">
<Skeleton className="h-3 w-24" />
<Skeleton className="h-7 w-7 rounded-lg" />
</div>
<Skeleton className="h-7 w-32" />
</div>
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{Array.from({ length: 2 }).map((_, i) => (
<div key={i} className="card">
<Skeleton className="h-4 w-40 mb-4" />
<Skeleton className="h-[280px] w-full" />
</div>
))}
</div>
<div className="card">
<Skeleton className="h-4 w-56 mb-2" />
<Skeleton className="h-3 w-32 mb-4" />
<Skeleton className="h-7 w-full mb-4" />
<div className="space-y-2">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-5 w-full" />
))}
</div>
</div>
<div className="card p-0">
<div className="px-4 pt-4 pb-3">
<Skeleton className="h-4 w-52" />
</div>
<div className="px-4 pb-4 space-y-2">
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={i} className="h-8 w-full" />
))}
</div>
</div>
</div>
);
}
export default function FinanceReportPage() {
const isDark = useTheme((s) => s.isDark);
const palette = getChartPalette(isDark);
const [dateRangePreset, setDateRangePreset] = useState('90');
const [customFrom, setCustomFrom] = useState('');
const [customTo, setCustomTo] = useState('');
const [granularity, setGranularity] = useState<FinanceGranularity>('daily');
const [originStationId, setOriginStationId] = useState('');
const [destinationStationId, setDestinationStationId] = useState('');
const [method, setMethod] = useState('');
const [exporting, setExporting] = useState(false);
const trendCardRef = useRef<HTMLDivElement>(null);
const segmentCardRef = useRef<HTMLDivElement>(null);
const methodCardRef = useRef<HTMLDivElement>(null);
const { dateFrom, dateTo } = useMemo(() => {
const end = new Date();
end.setHours(23, 59, 59, 999);
if (dateRangePreset === 'custom') {
if (customFrom && customTo) {
return customFrom <= customTo
? { dateFrom: customFrom, dateTo: customTo }
: { dateFrom: customTo, dateTo: customFrom };
}
const fallbackStart = new Date(end);
fallbackStart.setDate(end.getDate() - 90);
return {
dateFrom: fallbackStart.toISOString().split('T')[0],
dateTo: end.toISOString().split('T')[0],
};
}
const start = new Date(end);
start.setDate(end.getDate() - Number(dateRangePreset));
return {
dateFrom: start.toISOString().split('T')[0],
dateTo: end.toISOString().split('T')[0],
};
}, [dateRangePreset, customFrom, customTo]);
const filters: FinanceSummaryFilters = useMemo(
() => ({
dateFrom,
dateTo,
granularity,
originStationId: originStationId || undefined,
destinationStationId: destinationStationId || undefined,
method: method || undefined,
}),
[dateFrom, dateTo, granularity, originStationId, destinationStationId, method],
);
const { data: stations = [] } = useQuery<StationOption[]>({
queryKey: ['stations'],
queryFn: () => apiClient.get<StationOption[]>('/stations'),
});
const { data, isLoading, isFetching, isError } = useQuery({
queryKey: ['reports-finance', filters],
placeholderData: (previous) => previous,
queryFn: () => financeApi.getSummary(filters),
});
const rows = data?.rows ?? [];
const pg = usePagination(rows, TABLE_PAGE_SIZE);
const resetFilters = () => {
setDateRangePreset('90');
setCustomFrom('');
setCustomTo('');
setGranularity('daily');
setOriginStationId('');
setDestinationStationId('');
setMethod('');
};
/** Captures a chart card as a PNG data URL, sized to the card's actual on-screen pixels. */
const captureCard = async (node: HTMLDivElement | null): Promise<ChartImage | undefined> => {
if (!node) return undefined;
const rect = node.getBoundingClientRect();
const dataUrl = await toPng(node, { pixelRatio: 2, cacheBust: true, backgroundColor: palette.surface });
return { dataUrl, width: Math.round(rect.width), height: Math.round(rect.height) };
};
const doExport = async () => {
if (!data) return;
setExporting(true);
try {
const [trend, segment, method_] = await Promise.all([
captureCard(trendCardRef.current),
captureCard(segmentCardRef.current),
captureCard(methodCardRef.current),
]);
const stationLabel = (id: string) => stations.find((s) => s.id === id)?.name ?? 'Any';
const blob = await buildFinanceWorkbook({
report: data,
filters: {
dateFrom,
dateTo,
granularity,
originLabel: originStationId ? stationLabel(originStationId) : 'Any',
destinationLabel: destinationStationId ? stationLabel(destinationStationId) : 'Any',
methodLabel: method ? methodLabel(method) : 'All',
},
methodLabel,
periodLabel,
images: { trend, segment, method: method_ },
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `finance-summary-${dateFrom}-to-${dateTo}.xlsx`;
a.click();
URL.revokeObjectURL(url);
} finally {
setExporting(false);
}
};
const trendData = useMemo(
() =>
(data?.byPeriod ?? [])
.slice()
.sort((a, b) => a.key.localeCompare(b.key))
.map((p) => ({
label: periodLabel(p.key, data!.granularity),
revenue: p.revenueEtbMinor / 100,
})),
[data],
);
const segmentData = useMemo(
() =>
(data?.bySegment ?? [])
.slice()
.sort((a, b) => b.revenueEtbMinor - a.revenueEtbMinor)
.map((r) => ({ label: r.label, revenue: r.revenueEtbMinor })),
[data],
);
const methodBreakdown = useMemo(() => {
const rowsByMethod = data?.byMethod ?? [];
const total = rowsByMethod.reduce((sum, r) => sum + r.revenueEtbMinor, 0);
return rowsByMethod
.slice()
.sort((a, b) => PAYMENT_METHOD_ORDER.indexOf(a.key as any) - PAYMENT_METHOD_ORDER.indexOf(b.key as any))
.map((r) => ({
...r,
color: categoricalColor(palette, PAYMENT_METHOD_ORDER.indexOf(r.key as any)),
sharePercent: total > 0 ? (r.revenueEtbMinor / total) * 100 : 0,
}));
}, [data, palette]);
const totals = data?.totals;
const hasData = (totals?.bookingCount ?? 0) > 0;
const avgPerBookingMinor = hasData ? Math.round(totals!.revenueEtbMinor / totals!.bookingCount) : 0;
return (
<div className="space-y-6">
<div className="flex items-start justify-between gap-4 flex-wrap">
<div>
<h1 className="text-3xl font-bold text-foreground">Finance Summary</h1>
<p className="text-muted-foreground mt-1">
Revenue collected by period, origin/destination, and payment method for daily, weekly, or monthly finance reporting.
</p>
</div>
<ActionButton icon={FileSpreadsheet} variant="secondary" onClick={doExport} loading={exporting} disabled={!hasData}>
Export
</ActionButton>
</div>
{/* Filters */}
<div className="card">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-6">
<div>
<label className="label">Date Range</label>
<select className="input" value={dateRangePreset} onChange={(e) => setDateRangePreset(e.target.value)}>
<option value="7">Last 7 Days</option>
<option value="30">Last 30 Days</option>
<option value="90">Last 90 Days</option>
<option value="custom">Custom Range</option>
</select>
</div>
{dateRangePreset === 'custom' && (
<>
<div>
<label className="label">Start Date</label>
<input type="date" className="input" value={customFrom} onChange={(e) => setCustomFrom(e.target.value)} />
</div>
<div>
<label className="label">End Date</label>
<input type="date" className="input" value={customTo} onChange={(e) => setCustomTo(e.target.value)} />
</div>
</>
)}
<div>
<label className="label">Granularity</label>
<select className="input" value={granularity} onChange={(e) => setGranularity(e.target.value as FinanceGranularity)}>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
</select>
</div>
<div>
<label className="label">Origin</label>
<select className="input" value={originStationId} onChange={(e) => setOriginStationId(e.target.value)}>
<option value="">Any origin</option>
{stations.map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
</div>
<div>
<label className="label">Destination</label>
<select className="input" value={destinationStationId} onChange={(e) => setDestinationStationId(e.target.value)}>
<option value="">Any destination</option>
{stations.map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
</div>
<div>
<label className="label">Payment Method</label>
<select className="input" value={method} onChange={(e) => setMethod(e.target.value)}>
<option value="">All methods</option>
{PAYMENT_METHOD_ORDER.map((m) => (
<option key={m} value={m}>{methodLabel(m)}</option>
))}
</select>
</div>
</div>
<div className="mt-4 flex items-center justify-between">
<button type="button" onClick={resetFilters} className="text-xs text-primary hover:underline">
Reset filters
</button>
{isFetching && <span className="text-xs text-muted-foreground">Refreshing</span>}
</div>
{isError && <p className="text-xs text-red-500 mt-3">Failed to load the finance summary. Check the filters and try again.</p>}
</div>
{isLoading && !data ? (
<FinanceReportSkeleton />
) : !hasData ? (
<div className="card py-16 text-center text-muted-foreground">
<Banknote className="h-10 w-10 mx-auto mb-3 opacity-30" />
<p>No paid bookings in this window.</p>
<p className="text-xs mt-1">Widen the date range, or clear the origin/destination/method filters.</p>
</div>
) : (
<div className={isFetching ? 'opacity-60 transition-opacity space-y-6' : 'space-y-6'}>
{/* KPI tiles */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="card flex flex-col gap-1">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Revenue</p>
<div className="rounded-lg bg-emerald-100 dark:bg-emerald-900/30 p-1.5">
<Banknote className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
</div>
</div>
<p className="text-2xl font-bold text-emerald-600 dark:text-emerald-400 tabular-nums mt-1">
{formatCurrency(totals!.revenueEtbMinor, 'ETB')}
</p>
</div>
<div className="card flex flex-col gap-1">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Bookings</p>
<div className="rounded-lg bg-blue-100 dark:bg-blue-900/30 p-1.5">
<BookOpen className="h-4 w-4 text-blue-600 dark:text-blue-400" />
</div>
</div>
<p className="text-2xl font-bold tabular-nums mt-1">{totals!.bookingCount.toLocaleString()}</p>
</div>
<div className="card flex flex-col gap-1">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Avg. per Booking</p>
<div className="rounded-lg bg-amber-100 dark:bg-amber-900/30 p-1.5">
<Receipt className="h-4 w-4 text-amber-600 dark:text-amber-400" />
</div>
</div>
<p className="text-2xl font-bold tabular-nums mt-1">{formatCurrency(avgPerBookingMinor, 'ETB')}</p>
</div>
</div>
{/* Trend + route charts */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="card" ref={trendCardRef}>
<h3 className="text-base font-semibold mb-4">
Revenue Trend <span className="text-xs font-normal text-muted-foreground">(ETB, {granularity})</span>
</h3>
{trendData.length > 0 ? (
<ResponsiveContainer width="100%" height={280}>
<LineChart data={trendData}>
<CartesianGrid strokeDasharray="3 3" stroke={palette.grid} />
<XAxis dataKey="label" tick={{ fontSize: 11, fill: palette.textMuted }} />
<YAxis tick={{ fontSize: 11, fill: palette.textMuted }} />
<RechartsTooltip formatter={(value: number) => `ETB ${Math.round(value).toLocaleString()}`} />
<Line type="monotone" dataKey="revenue" name="Revenue" stroke={palette.sequential} dot={{ r: 3 }} strokeWidth={2} />
</LineChart>
</ResponsiveContainer>
) : (
<div className="h-[280px] flex items-center justify-center text-muted-foreground text-sm">No data for selected range</div>
)}
</div>
<div className="card" ref={segmentCardRef}>
<h3 className="text-base font-semibold mb-4">Revenue by Segment</h3>
{segmentData.length > 0 ? (
<ResponsiveContainer width="100%" height={280}>
<BarChart data={segmentData}>
<CartesianGrid strokeDasharray="3 3" stroke={palette.grid} />
<XAxis dataKey="label" tick={{ fontSize: 11, fill: palette.textMuted }} />
<YAxis tick={{ fontSize: 11, fill: palette.textMuted }} />
<RechartsTooltip formatter={(value: number) => `ETB ${Math.round(value).toLocaleString()}`} />
<Bar dataKey="revenue" fill={palette.sequential} radius={[3, 3, 0, 0]} />
</BarChart>
</ResponsiveContainer>
) : (
<div className="h-[280px] flex items-center justify-center text-muted-foreground text-sm">No data for selected range</div>
)}
</div>
</div>
{/* Payment method breakdown — part-to-whole stacked bar + legend table */}
<div className="card" ref={methodCardRef}>
<h3 className="text-base font-semibold text-foreground">Revenue by Payment Method</h3>
<p className="text-xs text-muted-foreground mt-1 mb-4">Share of revenue, in ETB</p>
<div
className="flex w-full h-7 rounded-md overflow-hidden"
role="img"
aria-label={`Revenue by payment method: ${methodBreakdown
.map((m) => `${methodLabel(m.key)} ${m.sharePercent.toFixed(0)}%`)
.join(', ')}`}
>
{methodBreakdown.map((m, i) => (
<div
key={m.key}
className="h-full"
style={{ width: `${m.sharePercent}%`, background: m.color, marginRight: i < methodBreakdown.length - 1 ? 2 : 0 }}
title={`${methodLabel(m.key)}${formatCurrency(m.revenueEtbMinor, 'ETB')}`}
/>
))}
</div>
<table className="w-full text-sm mt-4">
<thead>
<tr className="text-xs uppercase tracking-wider text-muted-foreground">
<th className="text-left font-medium py-2">Method</th>
<th className="text-right font-medium py-2">Bookings</th>
<th className="text-right font-medium py-2">Share</th>
<th className="text-right font-medium py-2">Revenue</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{methodBreakdown.map((m) => (
<tr key={m.key}>
<td className="py-2">
<span className="flex items-center gap-2">
<span className="h-2.5 w-2.5 rounded-sm shrink-0" style={{ background: m.color }} aria-hidden="true" />
<span className="text-foreground">{methodLabel(m.key)}</span>
</span>
</td>
<td className="py-2 text-right tabular-nums text-muted-foreground">{m.bookingCount.toLocaleString()}</td>
<td className="py-2 text-right tabular-nums text-muted-foreground">{m.sharePercent.toFixed(1)}%</td>
<td className="py-2 text-right tabular-nums text-foreground font-medium">{formatCurrency(m.revenueEtbMinor, 'ETB')}</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Detail table */}
<div className="card p-0">
<div className="px-4 pt-4 pb-3">
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Period × Segment × Method detail
</h3>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-gray-50 dark:bg-gray-800">
<tr>
{['Period', 'Segment', 'Method', 'Bookings', 'Revenue'].map((h) => (
<th key={h} className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400 whitespace-nowrap">
{h}
</th>
))}
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
{pg.paged.map((r, i) => (
<tr key={`${r.period}-${r.originStationId}-${r.destinationStationId}-${r.method}-${i}`} className="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors">
<td className="px-4 py-3 whitespace-nowrap text-foreground">{periodLabel(r.period, granularity)}</td>
<td className="px-4 py-3 whitespace-nowrap text-muted-foreground">{r.segmentLabel}</td>
<td className="px-4 py-3 whitespace-nowrap text-muted-foreground">{methodLabel(r.method)}</td>
<td className="px-4 py-3 tabular-nums whitespace-nowrap">{r.bookingCount.toLocaleString()}</td>
<td className="px-4 py-3 tabular-nums whitespace-nowrap font-medium">{formatCurrency(r.revenueEtbMinor, 'ETB')}</td>
</tr>
))}
{pg.paged.length === 0 && (
<tr>
<td colSpan={5} className="py-8 text-center text-sm text-muted-foreground">No rows on this page</td>
</tr>
)}
</tbody>
</table>
</div>
<Pagination currentPage={pg.page} totalPages={pg.totalPages} onPageChange={pg.setPage} />
</div>
</div>
)}
</div>
);
}

View File

@@ -123,6 +123,7 @@ const navigationSections: { title: string; items: NavItem[] }[] = [
title: 'Analytics & Reports', title: 'Analytics & Reports',
items: [ items: [
{ name: 'Overall', href: '/reports/overall', icon: BarChart3, permission: PERMS.reports.view }, { name: 'Overall', href: '/reports/overall', icon: BarChart3, permission: PERMS.reports.view },
{ name: 'Finance', href: '/reports/finance', icon: DollarSign, permission: PERMS.reports.view },
{ name: 'Seats', href: '/reports/seats', icon: Armchair, permission: PERMS.reports.view }, { name: 'Seats', href: '/reports/seats', icon: Armchair, permission: PERMS.reports.view },
{ name: 'Blocked Seats', href: '/reports/blocked-seats', icon: Ban, permission: PERMS.reports.view }, { name: 'Blocked Seats', href: '/reports/blocked-seats', icon: Ban, permission: PERMS.reports.view },
{ name: 'Passengers', href: '/reports/passengers', icon: Users, permission: PERMS.reports.view }, { name: 'Passengers', href: '/reports/passengers', icon: Users, permission: PERMS.reports.view },

View File

@@ -0,0 +1,10 @@
import { cn } from '@/lib/utils';
interface SkeletonProps {
className?: string;
}
/** A shimmering placeholder block. Give it the size/shape of the content it stands in for. */
export default function Skeleton({ className }: SkeletonProps) {
return <div className={cn('skeleton', className)} aria-hidden="true" />;
}

View File

@@ -0,0 +1,65 @@
import { apiClient } from '@/lib/api-client';
export type FinanceGranularity = 'daily' | 'weekly' | 'monthly';
export interface FinanceSummaryFilters {
dateFrom: string;
dateTo: string;
granularity?: FinanceGranularity;
originStationId?: string;
destinationStationId?: string;
method?: string;
}
export interface FinanceBucketRow {
period: string;
originStationId: string;
destinationStationId: string;
segmentLabel: string;
method: string;
bookingCount: number;
revenueEtbMinor: number;
}
export interface FinanceRollupRow {
key: string;
label: string;
revenueEtbMinor: number;
bookingCount: number;
}
export interface FinanceTotals {
bookingCount: number;
revenueEtbMinor: number;
}
export interface FinanceSummaryReport {
granularity: FinanceGranularity;
dateFrom: string;
dateTo: string;
currency: string;
totals: FinanceTotals;
byPeriod: FinanceRollupRow[];
bySegment: FinanceRollupRow[];
byMethod: FinanceRollupRow[];
rows: FinanceBucketRow[];
}
/** Drops blanks so the API applies its own defaults (granularity=daily, no station/method filter). */
function toParams(filters: FinanceSummaryFilters): Record<string, string> {
const params: Record<string, string> = { dateFrom: filters.dateFrom, dateTo: filters.dateTo };
if (filters.granularity) params.granularity = filters.granularity;
if (filters.originStationId) params.originStationId = filters.originStationId;
if (filters.destinationStationId) params.destinationStationId = filters.destinationStationId;
if (filters.method) params.method = filters.method;
return params;
}
export const financeApi = {
getSummary: (filters: FinanceSummaryFilters) =>
apiClient.get<FinanceSummaryReport>('/reports/finance', { params: toParams(filters) }),
/** CSV export. `getRaw` because the endpoint streams a bare CSV body, not the `{success,data}` envelope. */
exportCsv: (filters: FinanceSummaryFilters) =>
apiClient.getRaw<string>('/reports/finance/export', { params: toParams(filters) }),
};

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' });
}

View File

@@ -68,6 +68,24 @@
.animate-fade-up { .animate-fade-up {
animation: fade-up 0.25s cubic-bezier(0.22, 1, 0.36, 1) both; animation: fade-up 0.25s cubic-bezier(0.22, 1, 0.36, 1) both;
} }
@keyframes shimmer {
from { background-position: -300px 0; }
to { background-position: 300px 0; }
}
.skeleton {
border-radius: 0.5rem;
background-color: hsl(var(--muted));
background-image: linear-gradient(
90deg,
hsl(var(--muted)) 0%,
hsl(var(--muted-foreground) / 0.18) 50%,
hsl(var(--muted)) 100%
);
background-size: 600px 100%;
background-repeat: no-repeat;
animation: shimmer 1.5s ease-in-out infinite;
}
} }
@layer components { @layer components {

293
pnpm-lock.yaml generated
View File

@@ -604,7 +604,7 @@ importers:
version: 5.101.0(react@19.2.6) version: 5.101.0(react@19.2.6)
'@tria-plc/iamui': '@tria-plc/iamui':
specifier: file:../../../local-packages/tria-plc-iamui-0.1.1.tgz specifier: file:../../../local-packages/tria-plc-iamui-0.1.1.tgz
version: file:local-packages/tria-plc-iamui-0.1.1.tgz(a5d0bdee55164ae56064fb273b530e00) version: file:local-packages/tria-plc-iamui-0.1.1.tgz(0ce39b7e349029277dcd938d06eeb0f7)
'@vis.gl/react-google-maps': '@vis.gl/react-google-maps':
specifier: ^1.8.3 specifier: ^1.8.3
version: 1.8.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) version: 1.8.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
@@ -983,6 +983,12 @@ importers:
date-fns: date-fns:
specifier: ^3.0.0 specifier: ^3.0.0
version: 3.6.0 version: 3.6.0
exceljs:
specifier: ^4.4.0
version: 4.4.0
html-to-image:
specifier: ^1.11.11
version: 1.11.13
lucide-react: lucide-react:
specifier: ^0.446.0 specifier: ^0.446.0
version: 0.446.0(react@18.3.1) version: 0.446.0(react@18.3.1)
@@ -8338,6 +8344,9 @@ packages:
resolution: {integrity: sha512-XxzooSo6oBoxBEUazgjdXj7VwTn/iSTSZzTYKzYY6I916tkaYzypHxy+pbVU1h+0UQ9JlVf5XkNQyxOAiiQO1g==} resolution: {integrity: sha512-XxzooSo6oBoxBEUazgjdXj7VwTn/iSTSZzTYKzYY6I916tkaYzypHxy+pbVU1h+0UQ9JlVf5XkNQyxOAiiQO1g==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
html-to-image@1.11.13:
resolution: {integrity: sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==}
html-url-attributes@3.0.1: html-url-attributes@3.0.1:
resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
@@ -13063,11 +13072,11 @@ snapshots:
'@babel/helpers': 7.29.7 '@babel/helpers': 7.29.7
'@babel/parser': 7.29.7 '@babel/parser': 7.29.7
'@babel/template': 7.29.7 '@babel/template': 7.29.7
'@babel/traverse': 7.29.7 '@babel/traverse': 7.29.7(supports-color@5.5.0)
'@babel/types': 7.29.7 '@babel/types': 7.29.7
'@jridgewell/remapping': 2.3.5 '@jridgewell/remapping': 2.3.5
convert-source-map: 2.0.0 convert-source-map: 2.0.0
debug: 4.4.3(supports-color@8.1.1) debug: 4.4.3(supports-color@5.5.0)
gensync: 1.0.0-beta.2 gensync: 1.0.0-beta.2
json5: 2.2.3 json5: 2.2.3
semver: 6.3.1 semver: 6.3.1
@@ -13102,7 +13111,7 @@ snapshots:
'@babel/helper-optimise-call-expression': 7.29.7 '@babel/helper-optimise-call-expression': 7.29.7
'@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7) '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7)
'@babel/helper-skip-transparent-expression-wrappers': 7.29.7 '@babel/helper-skip-transparent-expression-wrappers': 7.29.7
'@babel/traverse': 7.29.7 '@babel/traverse': 7.29.7(supports-color@5.5.0)
semver: 6.3.1 semver: 6.3.1
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -13111,14 +13120,7 @@ snapshots:
'@babel/helper-member-expression-to-functions@7.29.7': '@babel/helper-member-expression-to-functions@7.29.7':
dependencies: dependencies:
'@babel/traverse': 7.29.7 '@babel/traverse': 7.29.7(supports-color@5.5.0)
'@babel/types': 7.29.7
transitivePeerDependencies:
- supports-color
'@babel/helper-module-imports@7.29.7':
dependencies:
'@babel/traverse': 7.29.7
'@babel/types': 7.29.7 '@babel/types': 7.29.7
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -13133,9 +13135,9 @@ snapshots:
'@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)':
dependencies: dependencies:
'@babel/core': 7.29.7 '@babel/core': 7.29.7
'@babel/helper-module-imports': 7.29.7 '@babel/helper-module-imports': 7.29.7(supports-color@5.5.0)
'@babel/helper-validator-identifier': 7.29.7 '@babel/helper-validator-identifier': 7.29.7
'@babel/traverse': 7.29.7 '@babel/traverse': 7.29.7(supports-color@5.5.0)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -13150,13 +13152,13 @@ snapshots:
'@babel/core': 7.29.7 '@babel/core': 7.29.7
'@babel/helper-member-expression-to-functions': 7.29.7 '@babel/helper-member-expression-to-functions': 7.29.7
'@babel/helper-optimise-call-expression': 7.29.7 '@babel/helper-optimise-call-expression': 7.29.7
'@babel/traverse': 7.29.7 '@babel/traverse': 7.29.7(supports-color@5.5.0)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@babel/helper-skip-transparent-expression-wrappers@7.29.7': '@babel/helper-skip-transparent-expression-wrappers@7.29.7':
dependencies: dependencies:
'@babel/traverse': 7.29.7 '@babel/traverse': 7.29.7(supports-color@5.5.0)
'@babel/types': 7.29.7 '@babel/types': 7.29.7
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -13309,18 +13311,6 @@ snapshots:
'@babel/parser': 7.29.7 '@babel/parser': 7.29.7
'@babel/types': 7.29.7 '@babel/types': 7.29.7
'@babel/traverse@7.29.7':
dependencies:
'@babel/code-frame': 7.29.7
'@babel/generator': 7.29.7
'@babel/helper-globals': 7.29.7
'@babel/parser': 7.29.7
'@babel/template': 7.29.7
'@babel/types': 7.29.7
debug: 4.4.3(supports-color@8.1.1)
transitivePeerDependencies:
- supports-color
'@babel/traverse@7.29.7(supports-color@5.5.0)': '@babel/traverse@7.29.7(supports-color@5.5.0)':
dependencies: dependencies:
'@babel/code-frame': 7.29.7 '@babel/code-frame': 7.29.7
@@ -13805,7 +13795,7 @@ snapshots:
'@emotion/babel-plugin@11.13.5': '@emotion/babel-plugin@11.13.5':
dependencies: dependencies:
'@babel/helper-module-imports': 7.29.7 '@babel/helper-module-imports': 7.29.7(supports-color@5.5.0)
'@babel/runtime': 7.29.7 '@babel/runtime': 7.29.7
'@emotion/hash': 0.9.2 '@emotion/hash': 0.9.2
'@emotion/memoize': 0.9.0 '@emotion/memoize': 0.9.0
@@ -13971,7 +13961,7 @@ snapshots:
'@eslint/eslintrc@2.1.4': '@eslint/eslintrc@2.1.4':
dependencies: dependencies:
ajv: 6.15.0 ajv: 6.15.0
debug: 4.4.3(supports-color@8.1.1) debug: 4.4.3(supports-color@5.5.0)
espree: 9.6.1 espree: 9.6.1
globals: 13.24.0 globals: 13.24.0
ignore: 5.3.2 ignore: 5.3.2
@@ -14131,7 +14121,7 @@ snapshots:
'@humanwhocodes/config-array@0.13.0': '@humanwhocodes/config-array@0.13.0':
dependencies: dependencies:
'@humanwhocodes/object-schema': 2.0.3 '@humanwhocodes/object-schema': 2.0.3
debug: 4.4.3(supports-color@8.1.1) debug: 4.4.3(supports-color@5.5.0)
minimatch: 3.1.5 minimatch: 3.1.5
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -15730,7 +15720,7 @@ snapshots:
'@puppeteer/browsers@2.13.2': '@puppeteer/browsers@2.13.2':
dependencies: dependencies:
debug: 4.4.3(supports-color@8.1.1) debug: 4.4.3(supports-color@5.5.0)
extract-zip: 2.0.1 extract-zip: 2.0.1
progress: 2.0.3 progress: 2.0.3
proxy-agent: 6.5.0 proxy-agent: 6.5.0
@@ -17804,7 +17794,7 @@ snapshots:
'@tokenizer/inflate@0.4.1': '@tokenizer/inflate@0.4.1':
dependencies: dependencies:
debug: 4.4.3(supports-color@8.1.1) debug: 4.4.3(supports-color@5.5.0)
token-types: 6.1.2 token-types: 6.1.2
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -18103,130 +18093,6 @@ snapshots:
- utf-8-validate - utf-8-validate
- vite - vite
'@tria-plc/iamui@file:local-packages/tria-plc-iamui-0.1.1.tgz(a5d0bdee55164ae56064fb273b530e00)':
dependencies:
'@emotion/react': 11.14.0(@types/react@18.3.31)(react@19.2.6)
'@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@18.3.31)(react@19.2.6))(@types/react@18.3.31)(react@19.2.6)
'@hookform/resolvers': 5.4.0(react-hook-form@7.77.0(react@19.2.6))
'@lottiefiles/react-lottie-player': 3.6.0(react@19.2.6)
'@mantine/charts': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(recharts@3.8.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)(redux@5.0.1))
'@mantine/core': 7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@mantine/dates': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@mantine/hooks': 7.17.8(react@19.2.6)
'@mantine/notifications': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-accordion': 1.2.13(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-alert-dialog': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-avatar': 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-checkbox': 1.3.4(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-collapsible': 1.1.13(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-context-menu': 2.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-dialog': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-dropdown-menu': 2.1.17(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-hover-card': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-label': 2.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-navigation-menu': 1.2.15(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-popover': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-progress': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-radio-group': 1.4.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-scroll-area': 1.2.11(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-select': 2.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-separator': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-slot': 1.2.5(@types/react@18.3.31)(react@19.2.6)
'@radix-ui/react-switch': 1.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-tabs': 1.1.14(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-toast': 1.2.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-tooltip': 1.2.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@react-pdf-viewer/core': 3.12.0(pdfjs-dist@5.4.296)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@react-pdf-viewer/default-layout': 3.12.0(pdfjs-dist@5.4.296)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@react-pdf/renderer': 4.5.1(react@19.2.6)
'@reduxjs/toolkit': 2.12.0(react-redux@9.3.0(@types/react@18.3.31)(react@19.2.6)(redux@5.0.1))(react@19.2.6)
'@tabler/icons-react': 3.44.0(react@19.2.6)
'@tailwindcss/vite': 4.3.0(vite@5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0))
'@tanstack/react-query': 5.101.0(react@19.2.6)
'@tanstack/react-query-devtools': 5.101.0(@tanstack/react-query@5.101.0(react@19.2.6))(react@19.2.6)
'@tanstack/react-table': 8.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@tinymce/tinymce-react': 6.3.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(tinymce@7.9.3)
'@types/dompurify': 3.2.0
'@types/node': 24.13.1
'@types/tinymce': 4.6.9
axios: 1.17.0
class-variance-authority: 0.7.1
clsx: 2.1.1
cmdk: 1.1.1(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
date-fns: 3.6.0
dayjs: 1.11.21
dompurify: 3.4.8
ethiopian-calendar-date-converter: 2.1.6
ethiopian-calendar-new: 1.1.0
file-type: 18.7.0
framer-motion: 12.40.0(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
html2canvas: 1.4.1
i18next: 25.10.10(typescript@5.9.3)
i18next-browser-languagedetector: 8.2.1
jquery: 3.7.1
js-cookie: 3.0.8
jspdf: 3.0.4
lodash: 4.18.1
lucide-react: 0.513.0(react@19.2.6)
mantine-react-table: 2.0.0-beta.9(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/dates@7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(@tabler/icons-react@3.44.0(react@19.2.6))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
mui-ethiopian-datepicker: 0.3.2(4b3af212eafdf0059f009b005d7e343d)
next-themes: 0.4.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
path: 0.12.7
pdf-lib: 1.17.1
qs: 6.15.2
react: 19.2.6
react-cookie: 8.1.2(@types/react@18.3.31)(react@19.2.6)
react-css-nocode-editor: 1.0.13(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)
react-day-picker: 8.10.2(date-fns@3.6.0)(react@19.2.6)
react-dom: 19.2.6(react@19.2.6)
react-dropzone: 14.4.1(react@19.2.6)
react-hook-form: 7.77.0(react@19.2.6)
react-i18next: 15.7.4(i18next@25.10.10(typescript@5.9.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
react-icons: 5.6.0(react@19.2.6)
react-image-crop: 11.0.10(react@19.2.6)
react-intersection-observer: 9.16.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react-pdf: 10.4.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react-pdf-html: 2.1.5(@react-pdf/renderer@4.5.1(react@19.2.6))(react@19.2.6)
react-redux: 9.3.0(@types/react@18.3.31)(react@19.2.6)(redux@5.0.1)
react-resizable-panels: 3.0.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react-router-dom: 7.17.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react-signature-canvas: 1.1.0-alpha.2(@types/prop-types@15.7.15)(@types/react@18.3.31)(prop-types@15.8.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
recharts: 3.8.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)(redux@5.0.1)
rollup-plugin-visualizer: 7.0.1(rollup@4.61.1)
socket.io-client: 4.8.3
sonner: 2.0.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
tailwind-merge: 3.6.0
tailwind-scrollbar-hide: 4.0.0(tailwindcss@4.3.0)
tailwindcss: 4.3.0
tailwindcss-animate: 1.0.7(tailwindcss@4.3.0)
tinymce: 7.9.3
url: 0.11.4
vaul: 1.1.2(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
xlsx: 0.18.5
zod: 3.25.76
transitivePeerDependencies:
- '@babel/core'
- '@emotion/is-prop-valid'
- '@mui/icons-material'
- '@mui/material'
- '@mui/x-date-pickers'
- '@types/prop-types'
- '@types/react'
- '@types/react-dom'
- bufferutil
- debug
- pdfjs-dist
- prop-types
- react-is
- react-native
- redux
- rolldown
- rollup
- supports-color
- typescript
- utf-8-validate
- vite
'@ts-morph/common@0.27.0': '@ts-morph/common@0.27.0':
dependencies: dependencies:
fast-glob: 3.3.3 fast-glob: 3.3.3
@@ -18624,7 +18490,7 @@ snapshots:
'@typescript-eslint/types': 8.60.1 '@typescript-eslint/types': 8.60.1
'@typescript-eslint/typescript-estree': 8.60.1(typescript@5.9.3) '@typescript-eslint/typescript-estree': 8.60.1(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.60.1 '@typescript-eslint/visitor-keys': 8.60.1
debug: 4.4.3(supports-color@8.1.1) debug: 4.4.3(supports-color@5.5.0)
eslint: 8.57.1 eslint: 8.57.1
typescript: 5.9.3 typescript: 5.9.3
transitivePeerDependencies: transitivePeerDependencies:
@@ -18634,7 +18500,7 @@ snapshots:
dependencies: dependencies:
'@typescript-eslint/tsconfig-utils': 8.60.1(typescript@5.9.3) '@typescript-eslint/tsconfig-utils': 8.60.1(typescript@5.9.3)
'@typescript-eslint/types': 8.60.1 '@typescript-eslint/types': 8.60.1
debug: 4.4.3(supports-color@8.1.1) debug: 4.4.3(supports-color@5.5.0)
typescript: 5.9.3 typescript: 5.9.3
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -18653,7 +18519,7 @@ snapshots:
'@typescript-eslint/types': 8.60.1 '@typescript-eslint/types': 8.60.1
'@typescript-eslint/typescript-estree': 8.60.1(typescript@5.9.3) '@typescript-eslint/typescript-estree': 8.60.1(typescript@5.9.3)
'@typescript-eslint/utils': 8.60.1(eslint@8.57.1)(typescript@5.9.3) '@typescript-eslint/utils': 8.60.1(eslint@8.57.1)(typescript@5.9.3)
debug: 4.4.3(supports-color@8.1.1) debug: 4.4.3(supports-color@5.5.0)
eslint: 8.57.1 eslint: 8.57.1
ts-api-utils: 2.5.0(typescript@5.9.3) ts-api-utils: 2.5.0(typescript@5.9.3)
typescript: 5.9.3 typescript: 5.9.3
@@ -18668,7 +18534,7 @@ snapshots:
'@typescript-eslint/tsconfig-utils': 8.60.1(typescript@5.9.3) '@typescript-eslint/tsconfig-utils': 8.60.1(typescript@5.9.3)
'@typescript-eslint/types': 8.60.1 '@typescript-eslint/types': 8.60.1
'@typescript-eslint/visitor-keys': 8.60.1 '@typescript-eslint/visitor-keys': 8.60.1
debug: 4.4.3(supports-color@8.1.1) debug: 4.4.3(supports-color@5.5.0)
minimatch: 10.2.5 minimatch: 10.2.5
semver: 7.8.2 semver: 7.8.2
tinyglobby: 0.2.17 tinyglobby: 0.2.17
@@ -18957,7 +18823,7 @@ snapshots:
agent-base@6.0.2: agent-base@6.0.2:
dependencies: dependencies:
debug: 4.4.3(supports-color@8.1.1) debug: 4.4.3(supports-color@5.5.0)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -19471,16 +19337,6 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
babel-plugin-styled-components@2.3.0(styled-components@5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6))(supports-color@5.5.0):
dependencies:
'@babel/helper-annotate-as-pure': 7.29.7
'@babel/helper-module-imports': 7.29.7(supports-color@5.5.0)
'@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7)
picomatch: 4.0.4
styled-components: 5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)
transitivePeerDependencies:
- supports-color
babel-polyfill@6.26.0: babel-polyfill@6.26.0:
dependencies: dependencies:
babel-runtime: 6.26.0 babel-runtime: 6.26.0
@@ -19636,7 +19492,7 @@ snapshots:
dependencies: dependencies:
bytes: 3.1.2 bytes: 3.1.2
content-type: 1.0.5 content-type: 1.0.5
debug: 4.4.3(supports-color@8.1.1) debug: 4.4.3(supports-color@5.5.0)
http-errors: 2.0.1 http-errors: 2.0.1
iconv-lite: 0.7.2 iconv-lite: 0.7.2
on-finished: 2.4.1 on-finished: 2.4.1
@@ -20685,7 +20541,7 @@ snapshots:
engine.io-client@6.6.5: engine.io-client@6.6.5:
dependencies: dependencies:
'@socket.io/component-emitter': 3.1.2 '@socket.io/component-emitter': 3.1.2
debug: 4.4.3(supports-color@8.1.1) debug: 4.4.3(supports-color@5.5.0)
engine.io-parser: 5.2.3 engine.io-parser: 5.2.3
ws: 8.20.1 ws: 8.20.1
xmlhttprequest-ssl: 2.1.2 xmlhttprequest-ssl: 2.1.2
@@ -20705,7 +20561,7 @@ snapshots:
base64id: 2.0.0 base64id: 2.0.0
cookie: 0.7.2 cookie: 0.7.2
cors: 2.8.6 cors: 2.8.6
debug: 4.4.3(supports-color@8.1.1) debug: 4.4.3(supports-color@5.5.0)
engine.io-parser: 5.2.3 engine.io-parser: 5.2.3
ws: 8.21.0 ws: 8.21.0
transitivePeerDependencies: transitivePeerDependencies:
@@ -20936,7 +20792,7 @@ snapshots:
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1): eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1):
dependencies: dependencies:
'@nolyfill/is-core-module': 1.0.39 '@nolyfill/is-core-module': 1.0.39
debug: 4.4.3(supports-color@8.1.1) debug: 4.4.3(supports-color@5.5.0)
eslint: 8.57.1 eslint: 8.57.1
get-tsconfig: 4.14.0 get-tsconfig: 4.14.0
is-bun-module: 2.0.0 is-bun-module: 2.0.0
@@ -21064,7 +20920,7 @@ snapshots:
ajv: 6.15.0 ajv: 6.15.0
chalk: 4.1.2 chalk: 4.1.2
cross-spawn: 7.0.6 cross-spawn: 7.0.6
debug: 4.4.3(supports-color@8.1.1) debug: 4.4.3(supports-color@5.5.0)
doctrine: 3.0.0 doctrine: 3.0.0
escape-string-regexp: 4.0.0 escape-string-regexp: 4.0.0
eslint-scope: 7.2.2 eslint-scope: 7.2.2
@@ -21301,7 +21157,7 @@ snapshots:
content-type: 1.0.5 content-type: 1.0.5
cookie: 0.7.2 cookie: 0.7.2
cookie-signature: 1.2.2 cookie-signature: 1.2.2
debug: 4.4.3(supports-color@8.1.1) debug: 4.4.3(supports-color@5.5.0)
depd: 2.0.0 depd: 2.0.0
encodeurl: 2.0.0 encodeurl: 2.0.0
escape-html: 1.0.3 escape-html: 1.0.3
@@ -21354,7 +21210,7 @@ snapshots:
extract-zip@2.0.1: extract-zip@2.0.1:
dependencies: dependencies:
debug: 4.4.3(supports-color@8.1.1) debug: 4.4.3(supports-color@5.5.0)
get-stream: 5.2.0 get-stream: 5.2.0
yauzl: 2.10.0 yauzl: 2.10.0
optionalDependencies: optionalDependencies:
@@ -21509,7 +21365,7 @@ snapshots:
finalhandler@2.1.1: finalhandler@2.1.1:
dependencies: dependencies:
debug: 4.4.3(supports-color@8.1.1) debug: 4.4.3(supports-color@5.5.0)
encodeurl: 2.0.0 encodeurl: 2.0.0
escape-html: 1.0.3 escape-html: 1.0.3
on-finished: 2.4.1 on-finished: 2.4.1
@@ -21757,7 +21613,7 @@ snapshots:
dependencies: dependencies:
basic-ftp: 5.3.1 basic-ftp: 5.3.1
data-uri-to-buffer: 6.0.2 data-uri-to-buffer: 6.0.2
debug: 4.4.3(supports-color@8.1.1) debug: 4.4.3(supports-color@5.5.0)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -22046,6 +21902,8 @@ snapshots:
is-self-closing: 1.0.1 is-self-closing: 1.0.1
kind-of: 6.0.3 kind-of: 6.0.3
html-to-image@1.11.13: {}
html-url-attributes@3.0.1: {} html-url-attributes@3.0.1: {}
html2canvas@1.4.1: html2canvas@1.4.1:
@@ -22064,7 +21922,7 @@ snapshots:
http-proxy-agent@7.0.2: http-proxy-agent@7.0.2:
dependencies: dependencies:
agent-base: 7.1.4 agent-base: 7.1.4
debug: 4.4.3(supports-color@8.1.1) debug: 4.4.3(supports-color@5.5.0)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -22077,14 +21935,14 @@ snapshots:
https-proxy-agent@5.0.1: https-proxy-agent@5.0.1:
dependencies: dependencies:
agent-base: 6.0.2 agent-base: 6.0.2
debug: 4.4.3(supports-color@8.1.1) debug: 4.4.3(supports-color@5.5.0)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
https-proxy-agent@7.0.6: https-proxy-agent@7.0.6:
dependencies: dependencies:
agent-base: 7.1.4 agent-base: 7.1.4
debug: 4.4.3(supports-color@8.1.1) debug: 4.4.3(supports-color@5.5.0)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -22524,7 +22382,7 @@ snapshots:
istanbul-lib-source-maps@4.0.1: istanbul-lib-source-maps@4.0.1:
dependencies: dependencies:
debug: 4.4.3(supports-color@8.1.1) debug: 4.4.3(supports-color@5.5.0)
istanbul-lib-coverage: 3.2.2 istanbul-lib-coverage: 3.2.2
source-map: 0.6.1 source-map: 0.6.1
transitivePeerDependencies: transitivePeerDependencies:
@@ -23184,7 +23042,7 @@ snapshots:
dependencies: dependencies:
chalk: 5.6.2 chalk: 5.6.2
commander: 13.1.0 commander: 13.1.0
debug: 4.4.3(supports-color@8.1.1) debug: 4.4.3(supports-color@5.5.0)
execa: 8.0.1 execa: 8.0.1
lilconfig: 3.1.3 lilconfig: 3.1.3
listr2: 8.3.3 listr2: 8.3.3
@@ -23871,7 +23729,7 @@ snapshots:
micromark@4.0.2: micromark@4.0.2:
dependencies: dependencies:
'@types/debug': 4.1.13 '@types/debug': 4.1.13
debug: 4.4.3(supports-color@8.1.1) debug: 4.4.3(supports-color@5.5.0)
decode-named-character-reference: 1.3.0 decode-named-character-reference: 1.3.0
devlop: 1.1.0 devlop: 1.1.0
micromark-core-commonmark: 2.0.3 micromark-core-commonmark: 2.0.3
@@ -24397,7 +24255,7 @@ snapshots:
dependencies: dependencies:
'@tootallnate/quickjs-emscripten': 0.23.0 '@tootallnate/quickjs-emscripten': 0.23.0
agent-base: 7.1.4 agent-base: 7.1.4
debug: 4.4.3(supports-color@8.1.1) debug: 4.4.3(supports-color@5.5.0)
get-uri: 6.0.5 get-uri: 6.0.5
http-proxy-agent: 7.0.2 http-proxy-agent: 7.0.2
https-proxy-agent: 7.0.6 https-proxy-agent: 7.0.6
@@ -24747,7 +24605,7 @@ snapshots:
proxy-agent@6.5.0: proxy-agent@6.5.0:
dependencies: dependencies:
agent-base: 7.1.4 agent-base: 7.1.4
debug: 4.4.3(supports-color@8.1.1) debug: 4.4.3(supports-color@5.5.0)
http-proxy-agent: 7.0.2 http-proxy-agent: 7.0.2
https-proxy-agent: 7.0.6 https-proxy-agent: 7.0.6
lru-cache: 7.18.3 lru-cache: 7.18.3
@@ -24776,7 +24634,7 @@ snapshots:
dependencies: dependencies:
'@puppeteer/browsers': 2.13.2 '@puppeteer/browsers': 2.13.2
chromium-bidi: 14.0.0(devtools-protocol@0.0.1608973) chromium-bidi: 14.0.0(devtools-protocol@0.0.1608973)
debug: 4.4.3(supports-color@8.1.1) debug: 4.4.3(supports-color@5.5.0)
devtools-protocol: 0.0.1608973 devtools-protocol: 0.0.1608973
typed-query-selector: 2.12.2 typed-query-selector: 2.12.2
webdriver-bidi-protocol: 0.4.1 webdriver-bidi-protocol: 0.4.1
@@ -25029,15 +24887,6 @@ snapshots:
- '@babel/core' - '@babel/core'
- react-is - react-is
react-css-nocode-editor@1.0.13(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6):
dependencies:
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
styled-components: 5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)
transitivePeerDependencies:
- '@babel/core'
- react-is
react-day-picker@8.10.2(date-fns@3.6.0)(react@19.2.6): react-day-picker@8.10.2(date-fns@3.6.0)(react@19.2.6):
dependencies: dependencies:
date-fns: 3.6.0 date-fns: 3.6.0
@@ -25648,7 +25497,7 @@ snapshots:
router@2.2.0: router@2.2.0:
dependencies: dependencies:
debug: 4.4.3(supports-color@8.1.1) debug: 4.4.3(supports-color@5.5.0)
depd: 2.0.0 depd: 2.0.0
is-promise: 4.0.0 is-promise: 4.0.0
parseurl: 1.3.3 parseurl: 1.3.3
@@ -25770,7 +25619,7 @@ snapshots:
send@1.2.1: send@1.2.1:
dependencies: dependencies:
debug: 4.4.3(supports-color@8.1.1) debug: 4.4.3(supports-color@5.5.0)
encodeurl: 2.0.0 encodeurl: 2.0.0
escape-html: 1.0.3 escape-html: 1.0.3
etag: 1.8.1 etag: 1.8.1
@@ -25986,7 +25835,7 @@ snapshots:
socket.io-adapter@2.5.8: socket.io-adapter@2.5.8:
dependencies: dependencies:
debug: 4.4.3(supports-color@8.1.1) debug: 4.4.3(supports-color@5.5.0)
ws: 8.21.0 ws: 8.21.0
transitivePeerDependencies: transitivePeerDependencies:
- bufferutil - bufferutil
@@ -25996,7 +25845,7 @@ snapshots:
socket.io-client@4.8.3: socket.io-client@4.8.3:
dependencies: dependencies:
'@socket.io/component-emitter': 3.1.2 '@socket.io/component-emitter': 3.1.2
debug: 4.4.3(supports-color@8.1.1) debug: 4.4.3(supports-color@5.5.0)
engine.io-client: 6.6.5 engine.io-client: 6.6.5
socket.io-parser: 4.2.6 socket.io-parser: 4.2.6
transitivePeerDependencies: transitivePeerDependencies:
@@ -26007,7 +25856,7 @@ snapshots:
socket.io-parser@4.2.6: socket.io-parser@4.2.6:
dependencies: dependencies:
'@socket.io/component-emitter': 3.1.2 '@socket.io/component-emitter': 3.1.2
debug: 4.4.3(supports-color@8.1.1) debug: 4.4.3(supports-color@5.5.0)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -26016,7 +25865,7 @@ snapshots:
accepts: 1.3.8 accepts: 1.3.8
base64id: 2.0.0 base64id: 2.0.0
cors: 2.8.6 cors: 2.8.6
debug: 4.4.3(supports-color@8.1.1) debug: 4.4.3(supports-color@5.5.0)
engine.io: 6.6.9 engine.io: 6.6.9
socket.io-adapter: 2.5.8 socket.io-adapter: 2.5.8
socket.io-parser: 4.2.6 socket.io-parser: 4.2.6
@@ -26028,7 +25877,7 @@ snapshots:
socks-proxy-agent@8.0.5: socks-proxy-agent@8.0.5:
dependencies: dependencies:
agent-base: 7.1.4 agent-base: 7.1.4
debug: 4.4.3(supports-color@8.1.1) debug: 4.4.3(supports-color@5.5.0)
socks: 2.8.9 socks: 2.8.9
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -26323,24 +26172,6 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- '@babel/core' - '@babel/core'
styled-components@5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6):
dependencies:
'@babel/helper-module-imports': 7.29.7(supports-color@5.5.0)
'@babel/traverse': 7.29.7(supports-color@5.5.0)
'@emotion/is-prop-valid': 1.4.0
'@emotion/stylis': 0.8.5
'@emotion/unitless': 0.7.5
babel-plugin-styled-components: 2.3.0(styled-components@5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6))(supports-color@5.5.0)
css-to-react-native: 3.2.0
hoist-non-react-statics: 3.3.2
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
react-is: 19.2.7
shallowequal: 1.1.0
supports-color: 5.5.0
transitivePeerDependencies:
- '@babel/core'
styled-jsx@5.1.1(babel-plugin-macros@3.1.0)(react@18.3.1): styled-jsx@5.1.1(babel-plugin-macros@3.1.0)(react@18.3.1):
dependencies: dependencies:
client-only: 0.0.1 client-only: 0.0.1
@@ -26366,7 +26197,7 @@ snapshots:
dependencies: dependencies:
component-emitter: 1.3.1 component-emitter: 1.3.1
cookiejar: 2.1.4 cookiejar: 2.1.4
debug: 4.4.3(supports-color@8.1.1) debug: 4.4.3(supports-color@5.5.0)
fast-safe-stringify: 2.1.1 fast-safe-stringify: 2.1.1
form-data: 4.0.5 form-data: 4.0.5
formidable: 3.5.4 formidable: 3.5.4
@@ -26879,7 +26710,7 @@ snapshots:
app-root-path: 3.1.0 app-root-path: 3.1.0
buffer: 6.0.3 buffer: 6.0.3
dayjs: 1.11.21 dayjs: 1.11.21
debug: 4.4.3(supports-color@8.1.1) debug: 4.4.3(supports-color@5.5.0)
dedent: 1.7.2(babel-plugin-macros@3.1.0) dedent: 1.7.2(babel-plugin-macros@3.1.0)
dotenv: 16.6.1 dotenv: 16.6.1
glob: 10.5.0 glob: 10.5.0
@@ -26903,7 +26734,7 @@ snapshots:
app-root-path: 3.1.0 app-root-path: 3.1.0
buffer: 6.0.3 buffer: 6.0.3
dayjs: 1.11.21 dayjs: 1.11.21
debug: 4.4.3(supports-color@8.1.1) debug: 4.4.3(supports-color@5.5.0)
dedent: 1.7.2(babel-plugin-macros@3.1.0) dedent: 1.7.2(babel-plugin-macros@3.1.0)
dotenv: 16.6.1 dotenv: 16.6.1
glob: 10.5.0 glob: 10.5.0
@@ -27264,7 +27095,7 @@ snapshots:
vite-node@2.1.9(@types/node@22.20.1)(lightningcss@1.32.0)(terser@5.48.0): vite-node@2.1.9(@types/node@22.20.1)(lightningcss@1.32.0)(terser@5.48.0):
dependencies: dependencies:
cac: 6.7.14 cac: 6.7.14
debug: 4.4.3(supports-color@8.1.1) debug: 4.4.3(supports-color@5.5.0)
es-module-lexer: 1.7.0 es-module-lexer: 1.7.0
pathe: 1.1.2 pathe: 1.1.2
vite: 5.4.21(@types/node@22.20.1)(lightningcss@1.32.0)(terser@5.48.0) vite: 5.4.21(@types/node@22.20.1)(lightningcss@1.32.0)(terser@5.48.0)
@@ -27282,7 +27113,7 @@ snapshots:
vite-node@2.1.9(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0): vite-node@2.1.9(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0):
dependencies: dependencies:
cac: 6.7.14 cac: 6.7.14
debug: 4.4.3(supports-color@8.1.1) debug: 4.4.3(supports-color@5.5.0)
es-module-lexer: 1.7.0 es-module-lexer: 1.7.0
pathe: 1.1.2 pathe: 1.1.2
vite: 5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0) vite: 5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0)
@@ -27329,7 +27160,7 @@ snapshots:
'@vitest/spy': 2.1.9 '@vitest/spy': 2.1.9
'@vitest/utils': 2.1.9 '@vitest/utils': 2.1.9
chai: 5.3.3 chai: 5.3.3
debug: 4.4.3(supports-color@8.1.1) debug: 4.4.3(supports-color@5.5.0)
expect-type: 1.3.0 expect-type: 1.3.0
magic-string: 0.30.21 magic-string: 0.30.21
pathe: 1.1.2 pathe: 1.1.2
@@ -27365,7 +27196,7 @@ snapshots:
'@vitest/spy': 2.1.9 '@vitest/spy': 2.1.9
'@vitest/utils': 2.1.9 '@vitest/utils': 2.1.9
chai: 5.3.3 chai: 5.3.3
debug: 4.4.3(supports-color@8.1.1) debug: 4.4.3(supports-color@5.5.0)
expect-type: 1.3.0 expect-type: 1.3.0
magic-string: 0.30.21 magic-string: 0.30.21
pathe: 1.1.2 pathe: 1.1.2