Merge branch 'alpha' of github.com:Tria-plc/edr-platform into alpha

This commit is contained in:
Stephanos A
2026-08-21 18:26:54 +03:00
843 changed files with 76899 additions and 16046 deletions

View File

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

View File

@@ -313,7 +313,7 @@ function DashboardPageContent() {
</div>
</div>
<Link
href="/payments"
href="/payments?status=SUCCEEDED&bookingStatus=CONFIRMED,BOARDED"
className="flex items-center gap-1 text-xs text-primary hover:underline mt-auto pt-1"
>
View payments <ArrowRight className="h-3 w-3" />

View File

@@ -1,8 +1,9 @@
'use client';
import { useState } from 'react';
import { Suspense, useEffect, useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Download, Eye, Trash2, AlertCircle, Send, CheckCircle, XCircle, RotateCcw } from 'lucide-react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Download, Eye, Trash2, AlertCircle, Send, CheckCircle, XCircle, RotateCcw, X } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton';
@@ -22,6 +23,18 @@ import {
type PageTab = 'payments' | 'supplementary';
// PaymentIntentStatus values, as actually defined on the backend — the dropdown used to
// offer PENDING/COMPLETED/FAILED, none of which are real values, so selecting them just
// returned nothing.
const PAYMENT_STATUS_OPTIONS = [
{ value: 'SUCCEEDED', label: 'Succeeded' },
{ value: 'PROCESSING', label: 'Processing' },
{ value: 'REQUIRES_ACTION', label: 'Requires Action' },
{ value: 'FAILED', label: 'Failed' },
{ value: 'CANCELLED', label: 'Cancelled' },
{ value: 'REFUNDED', label: 'Refunded' },
];
const STATUS_COLORS: Record<string, string> = {
PENDING: 'warning',
PAID: 'success',
@@ -42,9 +55,29 @@ const SectionHeader = ({ title }: { title: string }) => (
</h3>
);
export default function PaymentsPage() {
function PaymentsPageContent() {
const router = useRouter();
const searchParams = useSearchParams();
// A link can pre-filter this page — the dashboard's Revenue card links here with
// status=SUCCEEDED&bookingStatus=CONFIRMED,BOARDED so "view payments" shows exactly the
// payments that make up that revenue figure, not every payment attempt.
const [pageTab, setPageTab] = useState<PageTab>('payments');
const [filters, setFilters] = useState({ search: '', status: '', method: '' });
const [filters, setFilters] = useState({
search: '',
status: searchParams.get('status') ?? '',
method: '',
bookingStatus: searchParams.get('bookingStatus') ?? '',
});
// useState's initializer only runs on first mount — if this page was already mounted from
// an earlier visit (e.g. the sidebar link), Next's client-side navigation to a new
// ?status=...&bookingStatus=... URL does NOT remount the component, so the filters above
// would silently keep whatever was set before. Re-sync whenever the URL itself changes.
useEffect(() => {
const status = searchParams.get('status') ?? '';
const bookingStatus = searchParams.get('bookingStatus') ?? '';
setFilters((f) => (f.status === status && f.bookingStatus === bookingStatus ? f : { ...f, status, bookingStatus }));
}, [searchParams]);
const [selectedPayment, setSelectedPayment] = useState<any>(null);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [paymentToDelete, setPaymentToDelete] = useState<any>(null);
@@ -108,6 +141,7 @@ export default function PaymentsPage() {
search: filters.search || undefined,
status: filters.status || undefined,
method: filters.method || undefined,
bookingStatus: filters.bookingStatus || undefined,
}),
});
@@ -168,7 +202,8 @@ export default function PaymentsPage() {
{ key: 'booking', label: 'Booking', render: (payment: any) => payment.booking?.bookingRef || 'N/A' },
{ key: 'amount', label: 'Amount', render: (payment: any) => formatCurrency(payment.booking?.totalMinor ?? payment.amountMinor, 'ETB') },
{ key: 'method', label: 'Method', render: (payment: any) => <Badge>{payment.method}</Badge> },
{ key: 'status', label: 'Status', render: (payment: any) => <Badge variant="status" status={payment.status}>{payment.status}</Badge> },
{ key: 'status', label: 'Payment Status', render: (payment: any) => <Badge variant="status" status={payment.status}>{payment.status}</Badge> },
{ key: 'bookingStatus', label: 'Booking Status', render: (payment: any) => payment.booking?.status ? <Badge variant="status" status={payment.booking.status}>{payment.booking.status}</Badge> : '—' },
{ key: 'createdAt', label: 'Created', render: (payment: any) => formatDateTime(payment.createdAt) },
];
@@ -286,18 +321,33 @@ export default function PaymentsPage() {
{successMessage && (
<div className="mb-4 rounded-lg bg-green-50 dark:bg-green-900/20 p-4 text-sm text-green-800 dark:text-green-200"> {successMessage}</div>
)}
{filters.bookingStatus && (
<div className="mb-4 flex items-center justify-between rounded-lg bg-emerald-50 dark:bg-emerald-900/20 p-3 text-sm text-emerald-800 dark:text-emerald-200">
<span>
Showing payments backing <strong>{filters.bookingStatus.split(',').join(' / ')}</strong> bookings only
the same confirmed revenue the dashboard total is built from.
</span>
<button
type="button"
onClick={() => { setFilters({ ...filters, status: '', bookingStatus: '' }); router.replace('/payments'); }}
className="flex items-center gap-1 font-medium hover:underline shrink-0 ml-3"
>
<X className="w-3.5 h-3.5" /> Clear
</button>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="label">Search</label>
<input type="text" placeholder="Search..." className="input" value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value })} />
</div>
<div>
<label className="label">Status</label>
<label className="label">Payment Status</label>
<select className="input" value={filters.status} onChange={(e) => setFilters({ ...filters, status: e.target.value })}>
<option value="">All Status</option>
<option value="PENDING">Pending</option>
<option value="COMPLETED">Completed</option>
<option value="FAILED">Failed</option>
{PAYMENT_STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
<div>
@@ -478,3 +528,11 @@ export default function PaymentsPage() {
</div>
);
}
export default function PaymentsPage() {
return (
<Suspense fallback={null}>
<PaymentsPageContent />
</Suspense>
);
}

View File

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

View File

@@ -0,0 +1,560 @@
'use client';
import { useMemo, useRef, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Banknote, BookOpen, FileSpreadsheet } 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);
// Chart cards are captured for the Excel export. There's one Trend/Segment/Method set per
// currency (see currencySections below), so refs are keyed by `${currency}-${chart}`.
const chartRefs = useRef<Record<string, HTMLDivElement | null>>({});
const setChartRef = (key: string) => (el: HTMLDivElement | null) => {
chartRefs.current[key] = el;
};
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 imagesByCurrency: Record<string, { trend?: ChartImage; segment?: ChartImage; method?: ChartImage }> = {};
await Promise.all(
currencySections.map(async (section) => {
const [trend, segment, methodImg] = await Promise.all([
captureCard(chartRefs.current[`${section.currency}-trend`]),
captureCard(chartRefs.current[`${section.currency}-segment`]),
captureCard(chartRefs.current[`${section.currency}-method`]),
]);
imagesByCurrency[section.currency] = { trend, segment, method: methodImg };
}),
);
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,
imagesByCurrency,
});
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 totals = useMemo(
() => (data?.totals ?? []).slice().sort((a, b) => b.revenueMinor - a.revenueMinor),
[data],
);
const totalBookings = totals.reduce((sum, t) => sum + t.bookingCount, 0);
const hasData = totalBookings > 0;
// Money is never comparable across currencies, so rather than scoping every chart to
// whichever currency happens to be biggest overall (which would silently drop a
// currency-specific method like Waafi/DJF from the payment-method breakdown whenever ETB
// dominates the total), each currency present gets its own full Trend/Segment/Method set.
const currencySections = useMemo(() => {
if (!data) return [];
return totals.map((t) => {
const trendData = data.byPeriod
.filter((p) => p.currency === t.currency)
.slice()
.sort((a, b) => a.key.localeCompare(b.key))
.map((p) => ({ label: periodLabel(p.label, data.granularity), revenue: p.revenueMinor / 100 }));
const segmentData = data.bySegment
.filter((r) => r.currency === t.currency)
.slice()
.sort((a, b) => b.revenueMinor - a.revenueMinor)
.map((r) => ({ label: r.label, revenue: r.revenueMinor }));
const methodRows = data.byMethod.filter((r) => r.currency === t.currency);
const methodTotal = methodRows.reduce((sum, r) => sum + r.revenueMinor, 0);
const methodBreakdown = methodRows
.slice()
.sort((a, b) => PAYMENT_METHOD_ORDER.indexOf(a.label as any) - PAYMENT_METHOD_ORDER.indexOf(b.label as any))
.map((r) => ({
...r,
color: categoricalColor(palette, PAYMENT_METHOD_ORDER.indexOf(r.label as any)),
sharePercent: methodTotal > 0 ? (r.revenueMinor / methodTotal) * 100 : 0,
}));
return {
currency: t.currency,
revenueMinor: t.revenueMinor,
bookingCount: t.bookingCount,
trendData,
segmentData,
methodBreakdown,
};
});
}, [data, totals, palette]);
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'}>
{/* Revenue by currency — never summed across currencies, so a Waafi/DJF total and an
ETB total each get their own row instead of one converted figure. */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<div className="card lg:col-span-2 flex flex-col gap-2">
<div className="flex items-center justify-between mb-1">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Revenue by Currency</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>
{totals.map((t) => (
<div key={t.currency} className="flex items-center justify-between rounded-md bg-muted/20 px-3 py-2">
<span className="text-sm font-medium">{t.currency}</span>
<span className="text-right">
<span className="text-sm font-semibold tabular-nums">{formatCurrency(t.revenueMinor, t.currency)}</span>
<span className="ml-2 text-xs text-muted-foreground tabular-nums">{t.bookingCount.toLocaleString()} bookings</span>
</span>
</div>
))}
</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">{totalBookings.toLocaleString()}</p>
<p className="text-xs text-muted-foreground mt-auto pt-2 border-t border-border">
Across {totals.length} currenc{totals.length === 1 ? 'y' : 'ies'}
</p>
</div>
</div>
{/* One full Trend + Segment + Method set per currency — never scoped to a single
"dominant" currency, so a currency-specific method like Waafi/DJF always shows
its own numbers instead of being dropped in favor of whichever currency is
biggest overall. */}
{currencySections.map((section) => (
<div key={section.currency} className="space-y-6">
<div className="flex items-center gap-3">
<h2 className="text-lg font-semibold text-foreground">{section.currency}</h2>
<span className="text-xs text-muted-foreground">
{formatCurrency(section.revenueMinor, section.currency)} · {section.bookingCount.toLocaleString()} bookings
</span>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="card" ref={setChartRef(`${section.currency}-trend`)}>
<h3 className="text-base font-semibold mb-4">
Revenue Trend <span className="text-xs font-normal text-muted-foreground">({section.currency}, {granularity})</span>
</h3>
{section.trendData.length > 0 ? (
<ResponsiveContainer width="100%" height={260}>
<LineChart data={section.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) => `${section.currency} ${Math.round(value).toLocaleString()}`} />
<Line type="monotone" dataKey="revenue" name="Revenue" stroke={palette.sequential} dot={{ r: 3 }} strokeWidth={2} />
</LineChart>
</ResponsiveContainer>
) : (
<div className="h-[260px] flex items-center justify-center text-muted-foreground text-sm">No data for selected range</div>
)}
</div>
<div className="card" ref={setChartRef(`${section.currency}-segment`)}>
<h3 className="text-base font-semibold mb-4">
Revenue by Segment <span className="text-xs font-normal text-muted-foreground">({section.currency})</span>
</h3>
{section.segmentData.length > 0 ? (
<ResponsiveContainer width="100%" height={260}>
<BarChart data={section.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) => `${section.currency} ${Math.round(value).toLocaleString()}`} />
<Bar dataKey="revenue" fill={palette.sequential} radius={[3, 3, 0, 0]} />
</BarChart>
</ResponsiveContainer>
) : (
<div className="h-[260px] 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={setChartRef(`${section.currency}-method`)}>
<h3 className="text-base font-semibold text-foreground">
Revenue by Payment Method <span className="text-xs font-normal text-muted-foreground">({section.currency})</span>
</h3>
<p className="text-xs text-muted-foreground mt-1 mb-4">Share of {section.currency} revenue by method</p>
<div
className="flex w-full h-7 rounded-md overflow-hidden"
role="img"
aria-label={`${section.currency} revenue by payment method: ${section.methodBreakdown
.map((m) => `${methodLabel(m.label)} ${m.sharePercent.toFixed(0)}%`)
.join(', ')}`}
>
{section.methodBreakdown.map((m, i) => (
<div
key={m.key}
className="h-full"
style={{ width: `${m.sharePercent}%`, background: m.color, marginRight: i < section.methodBreakdown.length - 1 ? 2 : 0 }}
title={`${methodLabel(m.label)}${formatCurrency(m.revenueMinor, m.currency)}`}
/>
))}
</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">
{section.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.label)}</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.revenueMinor, m.currency)}</td>
</tr>
))}
</tbody>
</table>
</div>
</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', 'Currency', '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 whitespace-nowrap text-muted-foreground">{r.currency}</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.revenueMinor, r.currency)}</td>
</tr>
))}
{pg.paged.length === 0 && (
<tr>
<td colSpan={6} 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',
items: [
{ 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: 'Blocked Seats', href: '/reports/blocked-seats', icon: Ban, 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,63 @@
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;
currency: string;
bookingCount: number;
revenueMinor: number;
}
/** One roll-up entry. Amounts are never mixed across currencies — `currency` names which one this row is in. */
export interface FinanceRollupRow {
key: string;
label: string;
currency: string;
revenueMinor: number;
bookingCount: number;
}
export interface FinanceSummaryReport {
granularity: FinanceGranularity;
dateFrom: string;
dateTo: string;
/** Grand totals, one entry per currency present — never summed across currencies. */
totals: FinanceRollupRow[];
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

@@ -6,6 +6,33 @@ import axios from 'axios';
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
/**
* Every permission key a single IAM position grants.
*
* A position's own `permissions[]` used to be the whole story. IAM now also
* hangs grants off *position types* — the legacy singular `positionType` plus
* the newer `positionTypes[]` array, whose entries expose
* `positionTypePermissions[].permission.key` (note the extra `permission`
* wrapper). Union all three rather than trust IAM to have merged them back into
* `permissions[]`. Mirrors collectPermissionKeys() on the API.
*/
export function positionPermissionKeys(pos: any): string[] {
const keys: string[] = (pos?.permissions ?? [])
.map((p: any) => p?.key)
.filter(Boolean)
.map(String);
const positionTypes: any[] = [pos?.positionType, ...(pos?.positionTypes ?? [])];
for (const pt of positionTypes) {
for (const ptp of pt?.positionTypePermissions ?? []) {
const key = ptp?.permission?.key;
if (key) keys.push(String(key));
}
}
return keys;
}
function mapIamRole(roles: { key?: string }[]): 'ADMIN' | 'AGENT' | 'SUPERVISOR' {
const keys = roles.map((r) => r.key ?? '');
if (keys.some((k) => k.includes('admin') || k === 'super_admin' || k === 'organization_admin')) return 'ADMIN';
@@ -70,12 +97,11 @@ export const useAuthStore = create<AuthState>((set, get) => ({
// Role permissions — flat array in data.permissions
const rolePerms = (iamUser.permissions ?? []).map((p: any) => String(p.key));
// Position permissions — employee[] is an array here; positions[].permissions[] merged by IAM
// Position permissions — employee[] is an array here; each position contributes
// its own permissions[] plus everything its position type(s) grant.
const employeeArr: any[] = Array.isArray(iamUser.employee) ? iamUser.employee : [];
const positionPerms = employeeArr.flatMap((emp: any) =>
(emp.positions ?? []).flatMap((pos: any) =>
(pos.permissions ?? []).map((p: any) => String(p.key))
)
(emp.positions ?? []).flatMap((pos: any) => positionPermissionKeys(pos))
);
const permissions = Array.from(new Set([...rolePerms, ...positionPerms]));

View File

@@ -0,0 +1,284 @@
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 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 } },
};
/** Amounts are never converted between currencies, so every number format names its own currency. */
function currencyFmt(currency: string): string {
return `"${currency}" #,##0.00`;
}
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;
/** One Trend/Segment/Method image set per currency present — mirrors the on-screen per-currency sections. */
imagesByCurrency: Record<string, { 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;
}
/** Lays out KPI cards three to a row (each spanning 2 of 6 columns). Returns the next free row. */
function kpiRow(ws: ExcelJS.Worksheet, startRow: number, cards: { label: string; value: string; accent: string }[]): number {
const perRow = 3;
let row = startRow;
for (let i = 0; i < cards.length; i += perRow) {
const rowCards = cards.slice(i, i + perRow);
rowCards.forEach((c, idx) => kpiCard(ws, row, 1 + idx * 2, 2, c.label, c.value, c.accent));
row += 3;
}
return row;
}
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, imagesByCurrency } = input;
const methodLabel = input.methodLabel;
const periodLabel = input.periodLabel;
const totals = [...report.totals].sort((a, b) => b.revenueMinor - a.revenueMinor);
const totalBookings = totals.reduce((sum, t) => sum + t.bookingCount, 0);
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,
);
// Amounts are never converted between currencies — each currency present gets its own
// card, exactly like the on-screen "Revenue by Currency" breakdown.
const revenueCards = totals.map((t) => ({
label: `Revenue (${t.currency})`,
value: `${t.currency} ${(t.revenueMinor / 100).toLocaleString('en-US', { minimumFractionDigits: 2 })}`,
accent: BRAND_DARK,
}));
const cursorAfterKpis = kpiRow(summary, 4, [
{ label: 'Bookings', value: totalBookings.toLocaleString('en-US'), accent: INK },
...revenueCards,
]);
// One Trend/Segment/Method chart set per currency, largest currency first — mirrors the
// on-screen layout so no currency's payment-method breakdown gets dropped from the file.
let cursor = cursorAfterKpis + 1;
for (const t of totals) {
const images = imagesByCurrency[t.currency] ?? {};
cursor = addImage(wb, summary, images.trend, cursor, `Revenue Trend (${t.currency})`) + 1;
cursor = addImage(wb, summary, images.segment, cursor, `Revenue by Segment (${t.currency})`) + 1;
cursor = addImage(wb, summary, images.method, cursor, `Revenue by Payment Method (${t.currency})`) + 1;
}
// ── By Period sheet ──────────────────────────────────────────────────────
const byPeriod = wb.addWorksheet('By Period', { views: [{ state: 'frozen', ySplit: 1 }] });
byPeriod.columns = [{ width: 18 }, { width: 12 }, { width: 14 }, { width: 20 }];
addTableHeader(byPeriod, 1, ['Period', 'Currency', 'Bookings', 'Revenue'], 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.label, report.granularity);
r.getCell(2).value = p.currency;
r.getCell(3).value = p.bookingCount;
r.getCell(3).alignment = { horizontal: 'right' };
r.getCell(4).value = p.revenueMinor / 100;
r.getCell(4).numFmt = currencyFmt(p.currency);
r.getCell(4).alignment = { horizontal: 'right' };
bandRow(byPeriod, i + 2, 4, i % 2 === 1);
});
byPeriod.autoFilter = { from: { row: 1, column: 1 }, to: { row: 1, column: 4 } };
// ── By Segment sheet ─────────────────────────────────────────────────────
const bySegment = wb.addWorksheet('By Segment', { views: [{ state: 'frozen', ySplit: 1 }] });
bySegment.columns = [{ width: 34 }, { width: 12 }, { width: 14 }, { width: 20 }];
addTableHeader(bySegment, 1, ['Origin → Destination', 'Currency', 'Bookings', 'Revenue'], 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.currency;
r.getCell(3).value = s.bookingCount;
r.getCell(3).alignment = { horizontal: 'right' };
r.getCell(4).value = s.revenueMinor / 100;
r.getCell(4).numFmt = currencyFmt(s.currency);
r.getCell(4).alignment = { horizontal: 'right' };
bandRow(bySegment, i + 2, 4, i % 2 === 1);
});
bySegment.autoFilter = { from: { row: 1, column: 1 }, to: { row: 1, column: 4 } };
// ── By Method sheet ──────────────────────────────────────────────────────
// Share is computed against the grand total for that same currency (`totals`), never
// against a sum spanning multiple currencies.
const byMethod = wb.addWorksheet('By Method', { views: [{ state: 'frozen', ySplit: 1 }] });
byMethod.columns = [{ width: 20 }, { width: 12 }, { width: 14 }, { width: 20 }, { width: 12 }];
addTableHeader(byMethod, 1, ['Payment Method', 'Currency', 'Bookings', 'Revenue', 'Share'], new Set([1, 2, 3]));
const totalByCurrency = new Map(totals.map((t) => [t.currency, t.revenueMinor]));
report.byMethod.forEach((m, i) => {
const r = byMethod.getRow(i + 2);
const currencyTotal = totalByCurrency.get(m.currency) ?? 0;
r.getCell(1).value = methodLabel(m.label);
r.getCell(2).value = m.currency;
r.getCell(3).value = m.bookingCount;
r.getCell(3).alignment = { horizontal: 'right' };
r.getCell(4).value = m.revenueMinor / 100;
r.getCell(4).numFmt = currencyFmt(m.currency);
r.getCell(4).alignment = { horizontal: 'right' };
r.getCell(5).value = currencyTotal > 0 ? m.revenueMinor / currencyTotal : 0;
r.getCell(5).numFmt = '0.0%';
r.getCell(5).alignment = { horizontal: 'right' };
bandRow(byMethod, i + 2, 5, i % 2 === 1);
});
byMethod.autoFilter = { from: { row: 1, column: 1 }, to: { row: 1, column: 5 } };
// ── Detail sheet — every row, unpaginated ───────────────────────────────
const detail = wb.addWorksheet('Detail', { views: [{ state: 'frozen', ySplit: 1 }] });
detail.columns = [{ width: 18 }, { width: 34 }, { width: 18 }, { width: 12 }, { width: 14 }, { width: 20 }];
addTableHeader(detail, 1, ['Period', 'Origin → Destination', 'Payment Method', 'Currency', 'Bookings', 'Revenue'], 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.currency;
r.getCell(5).value = row.bookingCount;
r.getCell(5).alignment = { horizontal: 'right' };
r.getCell(6).value = row.revenueMinor / 100;
r.getCell(6).numFmt = currencyFmt(row.currency);
r.getCell(6).alignment = { horizontal: 'right' };
bandRow(detail, i + 2, 6, i % 2 === 1);
});
detail.autoFilter = { from: { row: 1, column: 1 }, to: { row: 1, column: 6 } };
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 {
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 {

View File

@@ -3,6 +3,7 @@
import { useForm, useFieldArray } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useQuery } from '@tanstack/react-query';
import { useRouter } from 'next/navigation';
import { useBookingStore } from '@/lib/booking-store';
import { useAuthStore } from '@/lib/auth-store';
@@ -614,35 +615,9 @@ const passengerSchema = z.object({
if (data.gender !== 'Male' && data.gender !== 'Female') {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Gender is required', path: ['gender'] });
}
const isNonEthiopian = data.nationality !== 'ETHIOPIAN' && data.nationality !== 'Ethiopian';
if (isNonEthiopian) {
const passportNum = data.passportNumber?.trim() ?? '';
if (!passportNum) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Passport number is required', path: ['passportNumber'] });
} else if (/[^A-Za-z0-9]/.test(passportNum)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Passport number must not contain special characters', path: ['passportNumber'] });
} else if (passportNum.length < 6 || passportNum.length > 12) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Passport number must be between 6 and 12 characters', path: ['passportNumber'] });
}
if (!data.passportCountry || data.passportCountry.trim().length === 0) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Issuing country is required', path: ['passportCountry'] });
}
if (data.passportIssueDate) {
const issue = new Date(data.passportIssueDate);
if (!isNaN(issue.getTime()) && issue > new Date()) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Passport issue date cannot be in the future', path: ['passportIssueDate'] });
}
}
if (data.passportExpiryDate) {
const expiry = new Date(data.passportExpiryDate);
if (!isNaN(expiry.getTime()) && expiry <= new Date()) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Passport expiry date must be in the future', path: ['passportExpiryDate'] });
}
}
}
});
function createFormSchema(adultCount: number) {
function createFormSchema(adultCount: number, isOriginOutsideEthiopia: boolean) {
return z.object({
passengers: z.array(passengerSchema),
createAccount: z.boolean(),
@@ -660,6 +635,9 @@ function createFormSchema(adultCount: number) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Invalid email format', path: ['passengers', i, 'email'] });
}
}
// Phone format stays scoped to the passenger's actual nationality regardless of the
// passport-flow override below — an Ethiopian is still validated against Ethiopian
// number ranges even when their origin station forces the foreigner document flow.
const phoneError = validatePhone(p.phone, p.nationality);
if (phoneError) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: phoneError, path: ['passengers', i, 'phone'] });
@@ -667,11 +645,44 @@ function createFormSchema(adultCount: number) {
}
const age = calculateAge(p.dateOfBirth);
if (age === null) return;
if (isAdult && age <= 5) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Adult passengers must be older than 5 years', path: ['passengers', i, 'dateOfBirth'] });
} else if (!isAdult && age > 5) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Child passengers must be 5 years old or younger', path: ['passengers', i, 'dateOfBirth'] });
if (age !== null) {
if (isAdult && age <= 5) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Adult passengers must be older than 5 years', path: ['passengers', i, 'dateOfBirth'] });
} else if (!isAdult && age > 5) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Child passengers must be 5 years old or younger', path: ['passengers', i, 'dateOfBirth'] });
}
}
// Fayda's SMS-based OTP only reaches Ethiopian phone numbers inside Ethiopia, so a
// passenger boarding from a station outside Ethiopia can't complete it even when their
// nationality is Ethiopian — they (like any genuinely non-Ethiopian national) fall back
// to the same passport document requirements as a foreigner. Nationality and phone
// validation are unaffected by this — only the identity-document requirement changes.
const isNonEthiopianNationality = p.nationality !== 'ETHIOPIAN' && p.nationality !== 'Ethiopian';
if (isNonEthiopianNationality || isOriginOutsideEthiopia) {
const passportNum = p.passportNumber?.trim() ?? '';
if (!passportNum) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Passport number is required', path: ['passengers', i, 'passportNumber'] });
} else if (/[^A-Za-z0-9]/.test(passportNum)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Passport number must not contain special characters', path: ['passengers', i, 'passportNumber'] });
} else if (passportNum.length < 6 || passportNum.length > 12) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Passport number must be between 6 and 12 characters', path: ['passengers', i, 'passportNumber'] });
}
if (!p.passportCountry || p.passportCountry.trim().length === 0) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Issuing country is required', path: ['passengers', i, 'passportCountry'] });
}
if (p.passportIssueDate) {
const issue = new Date(p.passportIssueDate);
if (!isNaN(issue.getTime()) && issue > new Date()) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Passport issue date cannot be in the future', path: ['passengers', i, 'passportIssueDate'] });
}
}
if (p.passportExpiryDate) {
const expiry = new Date(p.passportExpiryDate);
if (!isNaN(expiry.getTime()) && expiry <= new Date()) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Passport expiry date must be in the future', path: ['passengers', i, 'passportExpiryDate'] });
}
}
}
});
});
@@ -684,6 +695,16 @@ function PassengersForm() {
const { searchCriteria, passengers: storedPassengers, setPassengers, setCreateAccount, packageId } = useBookingStore();
const { user, isAuthenticated, updateUser } = useAuthStore();
const isInitialized = useAuthStore((s) => s.isInitialized);
// Fayda's SMS-based OTP is Ethiopia-only — a passenger boarding from a station outside
// Ethiopia can't receive it, so they need the passport flow below even if their nationality
// is Ethiopian. Looked up by ID rather than trusting a `country`/`countryCode` field carried
// on searchCriteria, since the origin station isn't otherwise threaded through this store.
const { data: originStation, isLoading: isOriginStationLoading } = useQuery({
queryKey: ['station', searchCriteria?.originStationId],
queryFn: () => apiClient.get<{ id: string; countryCode?: string }>(`/stations/${searchCriteria!.originStationId}`),
enabled: !!searchCriteria?.originStationId,
});
const isOriginOutsideEthiopia = !!originStation?.countryCode && originStation.countryCode !== 'ET';
const [faydaEnabled, setFaydaEnabled] = useState(true);
// "Skip for now" (bypasses Fayda verification) is only offered on local dev and the
// staging/test domain — never on an unrecognized host, which would include production.
@@ -715,7 +736,7 @@ function PassengersForm() {
const adultCount = searchCriteria?.adultCount || 1;
const { register, control, handleSubmit, setValue, watch, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(createFormSchema(adultCount) as any),
resolver: zodResolver(createFormSchema(adultCount, isOriginOutsideEthiopia) as any),
mode: 'onChange',
defaultValues: {
passengers: Array.from({ length: totalPassengers }, (_, i) => {
@@ -944,6 +965,10 @@ function PassengersForm() {
setFormInitialized(true);
return;
}
// Whether the Fayda gate applies below depends on the origin station's country — wait for
// that lookup to settle instead of gating on a stale "inside Ethiopia" default, which
// would flash the wrong screen for an Ethiopian departing from outside Ethiopia.
if (isOriginStationLoading) return;
try {
// Fetch passenger profile from backend. This may be null (e.g. no Passenger row linked
@@ -966,8 +991,9 @@ function PassengersForm() {
// A logged-in but NOT Fayda-verified Ethiopian must pass the Fayda gate exactly like a
// guest. Prefilling their identity and expanding the form would let them submit the
// booking without ever verifying — only a verified passenger may pass. When Fayda is
// globally disabled there is no gate, so the restriction doesn't apply.
const mustVerifyFayda = isEthiopian && !isVerified && faydaEnabled;
// globally disabled, or the origin station is outside Ethiopia (SMS OTP won't reach
// them), there is no gate, so the restriction doesn't apply.
const mustVerifyFayda = isEthiopian && !isVerified && faydaEnabled && !isOriginOutsideEthiopia;
// Nationality + contact aren't identity-verifying, so they're safe to prefill either way.
setValue('passengers.0.nationality', nationality);
@@ -1016,7 +1042,7 @@ function PassengersForm() {
};
populateForm();
}, [isInitialized, isAuthenticated, user, searchCriteria, setValue]);
}, [isInitialized, isAuthenticated, user, searchCriteria, setValue, isOriginStationLoading, isOriginOutsideEthiopia]);
const openFaydaVerification = async (index: number) => {
if (typeof window === 'undefined') return;
@@ -1067,7 +1093,7 @@ function PassengersForm() {
const isEthiopian = p?.nationality === 'ETHIOPIAN';
const isChildPassenger = i >= adultCount;
const isLoggedInAndVerified = i === 0 && isAuthenticated && user?.faydaVerified;
return isEthiopian && faydaEnabled && !p?.formExpanded && !isLoggedInAndVerified && !isChildPassenger;
return isEthiopian && faydaEnabled && !isOriginOutsideEthiopia && !p?.formExpanded && !isLoggedInAndVerified && !isChildPassenger;
});
setSubmitError(
needsFaydaVerification
@@ -1172,14 +1198,18 @@ function PassengersForm() {
<form onSubmit={handleSubmit(onSubmit, onInvalid)} className="space-y-6">
{fields.map((field, index) => {
const isEthiopian = passengers[index]?.nationality === 'ETHIOPIAN';
// Fayda only applies to an Ethiopian national whose origin station is inside
// Ethiopia — outside it, the SMS OTP never arrives, so they go through the same
// passport flow as a foreigner (nationality/phone stay Ethiopian regardless).
const eligibleForFayda = isEthiopian && !isOriginOutsideEthiopia;
const isFormExpanded = passengers[index]?.formExpanded;
const status = verificationStatus[index];
const isPrimaryPassenger = index === 0;
const isChildPassenger = index >= adultCount;
const isLoggedInAndVerified = isPrimaryPassenger && isAuthenticated && user?.faydaVerified;
const isLoggedInNotVerified = isPrimaryPassenger && isAuthenticated && !user?.faydaVerified;
const showVerifyButton = isEthiopian && faydaEnabled && !isFormExpanded && !isLoggedInAndVerified && !isChildPassenger;
const showManualEntryLink = isEthiopian && !faydaEnabled && !isFormExpanded && !isChildPassenger;
const showVerifyButton = eligibleForFayda && faydaEnabled && !isFormExpanded && !isLoggedInAndVerified && !isChildPassenger;
const showManualEntryLink = eligibleForFayda && !faydaEnabled && !isFormExpanded && !isChildPassenger;
const isVerifyingThis = verifyingIndex === index;
const isVerifyingOther = verifyingIndex !== null && verifyingIndex !== index;
const faydaError = faydaErrors[index];
@@ -1276,7 +1306,7 @@ function PassengersForm() {
</div>
) : (
<div className="space-y-4">
{isEthiopian ? (
{eligibleForFayda ? (
<>
{status === 'success' && (
<div className="p-3 bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-800 rounded-lg mb-4">

View File

@@ -612,8 +612,15 @@ export default function SearchPage() {
queryKey: ["stations"],
// Bounded so a stalled request surfaces the "Unable to load stations"
// error below instead of leaving the widget stuck loading indefinitely.
queryFn: async () =>
(await apiClient.get("/stations", { timeout: 8000 })) as Station[],
queryFn: async () => {
const res = await apiClient.get<Station[]>("/stations", {
timeout: 8000,
});
// apiClient unwraps `{ success, data }` envelopes, but guard against an
// unexpected non-array payload so downstream .find()/.filter() calls
// (here and in every StationSelector this list is passed to) never throw.
return Array.isArray(res) ? res : [];
},
});
const {

View File

@@ -209,7 +209,7 @@ function FeaturedCard({ pkg }: { pkg: HolidayPackage }) {
{/* Name */}
<h3 className="text-xl md:text-2xl font-extrabold text-gray-900 dark:text-white leading-tight mb-1.5">
{pkg.name.trim()}
{pkg.name?.trim()}
</h3>
{/* Route */}
@@ -217,11 +217,11 @@ function FeaturedCard({ pkg }: { pkg: HolidayPackage }) {
<div className="flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400 mb-5">
<Train className="w-3.5 h-3.5 text-primary flex-shrink-0" />
<span className="font-medium text-gray-700 dark:text-gray-300">
{origin.name.trim()}
{origin.name?.trim()}
</span>
<ArrowRight className="w-3 h-3 flex-shrink-0" />
<span className="font-medium text-gray-700 dark:text-gray-300">
{dest.name.trim()}
{dest.name?.trim()}
</span>
{pkg.busTransferIncluded && (
<>
@@ -353,16 +353,16 @@ function PackageCard({ pkg }: { pkg: HolidayPackage }) {
{/* Content */}
<div className="flex flex-col flex-1 p-4">
<h3 className="font-bold text-gray-900 dark:text-white text-sm leading-snug mb-2.5 line-clamp-2">
{pkg.name.trim()}
{pkg.name?.trim()}
</h3>
{/* Route */}
{origin && dest && (
<div className="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400 mb-2">
<MapPin className="w-3 h-3 text-primary flex-shrink-0" />
<span className="truncate">{origin.name.trim()}</span>
<span className="truncate">{origin.name?.trim()}</span>
<ArrowRight className="w-3 h-3 flex-shrink-0 text-gray-300" />
<span className="truncate">{dest.name.trim()}</span>
<span className="truncate">{dest.name?.trim()}</span>
</div>
)}

View File

@@ -455,8 +455,11 @@ interface VoucherData {
// /bookings/:ref returns one row per passenger PER LEG for round trips (leg 1 =
// outbound, leg 2 = return), each with that leg's own seat — see bookings.service.ts's
// getByRef(). dateOfBirth is included purely to disambiguate same-name passengers when
// grouping leg rows back into one passenger below.
passengers: Array<{ fullName: string; dateOfBirth?: string; category: string; leg?: number; seat?: { number: string; coach: string; seatClass: string } }>;
// grouping leg rows back into one passenger below. fareMinor is that specific row's own
// fare (ETB minor units) — e.g. a free child's row is 0 even though other passengers on
// the same booking paid full fare — mirrored from the same field the booking detail
// page's "Fare breakdown" section already reads (booking/detail/page.tsx).
passengers: Array<{ fullName: string; dateOfBirth?: string; category: string; leg?: number; fareMinor?: number; seat?: { number: string; coach: string; seatClass: string } }>;
schedule: VoucherSchedule;
returnSchedule?: VoucherSchedule | null;
totalMinor: number;
@@ -475,32 +478,46 @@ interface VoucherData {
}
export const generateVoucherPDF = async (booking: VoucherData): Promise<void> => {
// The amount shown is always a single raw field straight from the API — the settled
// payment amount when available, otherwise the booking total — never a derived value
// (previously this fell back to Math.round(totalMinor / passengers.length), which
// doesn't correspond to any real field and could disagree with what was actually
// charged). Same value on every passenger's voucher; no /100, no per-passenger split.
// Currency always comes straight from the booking/payment data, never hardcoded — the
// settled payment currency when a payment has settled, otherwise the booking's own
// display currency (falling back to the internal ETB currency field).
const settledAmountMinor = booking.payment?.amountMinor;
const settledCurrency = booking.payment?.currency;
const useSettledAmount = settledAmountMinor != null && !!settledCurrency;
// Prefer displayCurrency (passenger's home currency) over the internal ETB currency field.
const voucherCurrency = useSettledAmount ? settledCurrency! : (booking.displayCurrency || booking.currency || 'ETB');
// Use displayTotalMinor when available so the voucher shows the passenger's currency amount.
const voucherFareMinor = useSettledAmount ? settledAmountMinor! : (booking.displayTotalMinor ?? booking.totalMinor);
// Basis total for the currency this voucher displays — the settled payment amount when
// available, otherwise the display-currency total (falling back to the raw ETB total).
const voucherBasisTotal = useSettledAmount ? settledAmountMinor! : (booking.displayTotalMinor ?? booking.totalMinor);
// Ratio that converts a passenger's own ETB fareMinor into the same currency/amount basis
// as voucherBasisTotal above — e.g. if the settled payment is 60% of the ETB total (a
// currency conversion), each passenger's own ETB fare is scaled by that same 60% ratio.
// This is NOT an equal split: two passengers with different fares still get different
// scaled amounts, in exact proportion to what each of them actually paid. Falls back to no
// scaling (ratio 1) only when the totals are missing or already equal, mirroring the same
// fallback the booking detail page's "Fare breakdown" section uses for this identical ratio.
const etbTotalMinor = booking.totalMinor;
const fareScaleFactor =
!etbTotalMinor || !voucherBasisTotal || etbTotalMinor === voucherBasisTotal
? 1
: voucherBasisTotal / etbTotalMinor;
const isRoundTrip = booking.bookingType === 'ROUND_TRIP' && !!booking.returnSchedule;
// Group leg rows back into one entry per real passenger — without this, a round trip
// produced two half-passenger vouchers (one per leg, each showing only its own leg's
// seat) instead of one voucher per passenger covering both legs.
// seat) instead of one voucher per passenger covering both legs. Each leg row's own
// fareMinor is summed here too, so a round trip's voucher reflects both legs' fares
// and a one-way voucher reflects just its single row's fare.
type SeatInfo = VoucherData['passengers'][number]['seat'];
const grouped = new Map<
string,
{ fullName: string; category: string; outboundSeat?: SeatInfo; returnSeat?: SeatInfo }
{ fullName: string; category: string; fareMinor: number; outboundSeat?: SeatInfo; returnSeat?: SeatInfo }
>();
booking.passengers.forEach((p) => {
const key = `${p.fullName}|${p.dateOfBirth}|${p.category}`;
const entry = grouped.get(key) || { fullName: p.fullName, category: p.category, outboundSeat: undefined, returnSeat: undefined };
const entry = grouped.get(key) || { fullName: p.fullName, category: p.category, fareMinor: 0, outboundSeat: undefined, returnSeat: undefined };
entry.fareMinor += p.fareMinor ?? 0;
if (p.leg === 2) entry.returnSeat = p.seat;
else entry.outboundSeat = p.seat;
grouped.set(key, entry);
@@ -522,6 +539,10 @@ export const generateVoucherPDF = async (booking: VoucherData): Promise<void> =>
// if it doesn't match what's actually on file.
const ticketNumber = matchedTicket?.barcodePayload || 'Not yet issued';
// This passenger's own fare, scaled onto the same currency/amount basis as the rest of
// the voucher — not the booking's overall total, and not an equal share of it.
const passengerFareMinor = Math.round(p.fareMinor * fareScaleFactor);
await generatePassengerVoucherPDF({
bookingRef: booking.bookingRef,
ticketNumber,
@@ -536,7 +557,7 @@ export const generateVoucherPDF = async (booking: VoucherData): Promise<void> =>
outboundCoachNumber: isRoundTrip ? p.outboundSeat?.coach : undefined,
inboundSeatNumber: isRoundTrip ? p.returnSeat?.number : undefined,
inboundCoachNumber: isRoundTrip ? p.returnSeat?.coach : undefined,
fareMinor: voucherFareMinor,
fareMinor: passengerFareMinor,
currency: voucherCurrency,
fareIsMajorUnits: useSettledAmount,
createdAt: booking.createdAt,