Updated financial report and sms outside of Ethiopia

This commit is contained in:
Roba Boru
2026-08-13 22:47:22 +03:00
parent f5a19a705a
commit 376c23a0ad
12 changed files with 509 additions and 308 deletions

View File

@@ -312,7 +312,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, useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Download, Eye, Trash2, AlertCircle, Send, CheckCircle, XCircle, RotateCcw } from 'lucide-react';
import { 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,19 @@ const SectionHeader = ({ title }: { title: string }) => (
</h3>
);
export default function PaymentsPage() {
function PaymentsPageContent() {
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 initialBookingStatus = searchParams.get('bookingStatus') ?? '';
const [pageTab, setPageTab] = useState<PageTab>('payments');
const [filters, setFilters] = useState({ search: '', status: '', method: '' });
const [filters, setFilters] = useState({
search: '',
status: searchParams.get('status') ?? '',
method: '',
bookingStatus: initialBookingStatus,
});
const [selectedPayment, setSelectedPayment] = useState<any>(null);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [paymentToDelete, setPaymentToDelete] = useState<any>(null);
@@ -108,6 +131,7 @@ export default function PaymentsPage() {
search: filters.search || undefined,
status: filters.status || undefined,
method: filters.method || undefined,
bookingStatus: filters.bookingStatus || undefined,
}),
});
@@ -168,7 +192,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 +311,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: '' })}
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 +518,11 @@ export default function PaymentsPage() {
</div>
);
}
export default function PaymentsPage() {
return (
<Suspense fallback={null}>
<PaymentsPageContent />
</Suspense>
);
}

View File

@@ -2,7 +2,7 @@
import { useMemo, useRef, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Banknote, BookOpen, FileSpreadsheet, Receipt } from 'lucide-react';
import { Banknote, BookOpen, FileSpreadsheet } from 'lucide-react';
import {
Bar, BarChart, CartesianGrid, Line, LineChart,
ResponsiveContainer, Tooltip as RechartsTooltip, XAxis, YAxis,
@@ -114,9 +114,12 @@ export default function FinanceReportPage() {
const [method, setMethod] = useState('');
const [exporting, setExporting] = useState(false);
const trendCardRef = useRef<HTMLDivElement>(null);
const segmentCardRef = useRef<HTMLDivElement>(null);
const methodCardRef = useRef<HTMLDivElement>(null);
// 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();
@@ -192,11 +195,17 @@ export default function FinanceReportPage() {
if (!data) return;
setExporting(true);
try {
const [trend, segment, method_] = await Promise.all([
captureCard(trendCardRef.current),
captureCard(segmentCardRef.current),
captureCard(methodCardRef.current),
]);
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';
@@ -212,7 +221,7 @@ export default function FinanceReportPage() {
},
methodLabel,
periodLabel,
images: { trend, segment, method: method_ },
imagesByCurrency,
});
const url = URL.createObjectURL(blob);
@@ -226,43 +235,53 @@ export default function FinanceReportPage() {
}
};
const trendData = useMemo(
() =>
(data?.byPeriod ?? [])
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.key, data!.granularity),
revenue: p.revenueEtbMinor / 100,
})),
[data],
);
.map((p) => ({ label: periodLabel(p.label, data.granularity), revenue: p.revenueMinor / 100 }));
const segmentData = useMemo(
() =>
(data?.bySegment ?? [])
const segmentData = data.bySegment
.filter((r) => r.currency === t.currency)
.slice()
.sort((a, b) => b.revenueEtbMinor - a.revenueEtbMinor)
.map((r) => ({ label: r.label, revenue: r.revenueEtbMinor })),
[data],
);
.sort((a, b) => b.revenueMinor - a.revenueMinor)
.map((r) => ({ label: r.label, revenue: r.revenueMinor }));
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 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,
}));
const totals = data?.totals;
const hasData = (totals?.bookingCount ?? 0) > 0;
const avgPerBookingMinor = hasData ? Math.round(totals!.revenueEtbMinor / totals!.bookingCount) : 0;
return {
currency: t.currency,
revenueMinor: t.revenueMinor,
bookingCount: t.bookingCount,
trendData,
segmentData,
methodBreakdown,
};
});
}, [data, totals, palette]);
return (
<div className="space-y-6">
@@ -357,18 +376,25 @@ export default function FinanceReportPage() {
</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>
{/* 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>
<p className="text-2xl font-bold text-emerald-600 dark:text-emerald-400 tabular-nums mt-1">
{formatCurrency(totals!.revenueEtbMinor, 'ETB')}
</p>
{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">
@@ -377,104 +403,116 @@ export default function FinanceReportPage() {
<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>
<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 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>
{/* 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>
<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) => (
{/* 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
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')}`}
/>
))}
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>
<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">
@@ -487,7 +525,7 @@ export default function FinanceReportPage() {
<table className="w-full text-sm">
<thead className="bg-gray-50 dark:bg-gray-800">
<tr>
{['Period', 'Segment', 'Method', 'Bookings', 'Revenue'].map((h) => (
{['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>
@@ -500,13 +538,14 @@ export default function FinanceReportPage() {
<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.revenueEtbMinor, 'ETB')}</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={5} className="py-8 text-center text-sm text-muted-foreground">No rows on this page</td>
<td colSpan={6} className="py-8 text-center text-sm text-muted-foreground">No rows on this page</td>
</tr>
)}
</tbody>

View File

@@ -17,28 +17,26 @@ export interface FinanceBucketRow {
destinationStationId: string;
segmentLabel: string;
method: string;
currency: string;
bookingCount: number;
revenueEtbMinor: 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;
revenueEtbMinor: number;
currency: string;
revenueMinor: number;
bookingCount: number;
}
export interface FinanceTotals {
bookingCount: number;
revenueEtbMinor: number;
}
export interface FinanceSummaryReport {
granularity: FinanceGranularity;
dateFrom: string;
dateTo: string;
currency: string;
totals: FinanceTotals;
/** Grand totals, one entry per currency present — never summed across currencies. */
totals: FinanceRollupRow[];
byPeriod: FinanceRollupRow[];
bySegment: FinanceRollupRow[];
byMethod: FinanceRollupRow[];

View File

@@ -12,7 +12,6 @@ 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 } },
@@ -20,6 +19,11 @@ const THIN_BORDER: Partial<ExcelJS.Borders> = {
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;
@@ -31,7 +35,8 @@ export interface FinanceWorkbookInput {
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 };
/** 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) {
@@ -110,6 +115,18 @@ function kpiCard(ws: ExcelJS.Worksheet, startRow: number, startCol: number, span
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;
@@ -140,10 +157,13 @@ function addImage(wb: ExcelJS.Workbook, ws: ExcelJS.Worksheet, image: ChartImage
}
export async function buildFinanceWorkbook(input: FinanceWorkbookInput): Promise<Blob> {
const { report, filters, images } = input;
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();
@@ -159,86 +179,105 @@ export async function buildFinanceWorkbook(input: FinanceWorkbookInput): Promise
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);
// 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,
]);
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');
// 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: 14 }, { width: 20 }];
addTableHeader(byPeriod, 1, ['Period', 'Bookings', 'Revenue (ETB)'], new Set([1, 2]));
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.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(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' };
bandRow(byPeriod, i + 2, 3, i % 2 === 1);
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: 3 } };
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: 14 }, { width: 20 }];
addTableHeader(bySegment, 1, ['Origin → Destination', 'Bookings', 'Revenue (ETB)'], new Set([1, 2]));
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.bookingCount;
r.getCell(2).alignment = { horizontal: 'right' };
r.getCell(3).value = s.revenueEtbMinor / 100;
r.getCell(3).numFmt = CURRENCY_FMT;
r.getCell(2).value = s.currency;
r.getCell(3).value = s.bookingCount;
r.getCell(3).alignment = { horizontal: 'right' };
bandRow(bySegment, i + 2, 3, i % 2 === 1);
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: 3 } };
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: 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);
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);
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;
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 = methodTotal > 0 ? m.revenueEtbMinor / methodTotal : 0;
r.getCell(4).numFmt = '0.0%';
r.getCell(4).value = m.revenueMinor / 100;
r.getCell(4).numFmt = currencyFmt(m.currency);
r.getCell(4).alignment = { horizontal: 'right' };
bandRow(byMethod, i + 2, 4, i % 2 === 1);
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: 4 } };
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: 14 }, { width: 20 }];
addTableHeader(detail, 1, ['Period', 'Origin → Destination', 'Payment Method', 'Bookings', 'Revenue (ETB)'], new Set([2, 3]));
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.bookingCount;
r.getCell(4).alignment = { horizontal: 'right' };
r.getCell(5).value = row.revenueEtbMinor / 100;
r.getCell(5).numFmt = CURRENCY_FMT;
r.getCell(4).value = row.currency;
r.getCell(5).value = row.bookingCount;
r.getCell(5).alignment = { horizontal: 'right' };
bandRow(detail, i + 2, 5, i % 2 === 1);
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: 5 } };
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

@@ -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) => {
@@ -931,6 +952,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
@@ -953,8 +978,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);
@@ -996,7 +1022,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;
@@ -1047,7 +1073,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
@@ -1145,14 +1171,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];
@@ -1249,7 +1279,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>
)}