mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 15:30:56 +00:00
Merge branch 'alpha' of github.com:Tria-plc/edr-platform into alpha
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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" />;
|
||||
}
|
||||
63
apps/edr-passenger-web/backoffice/src/lib/api/finance.ts
Normal file
63
apps/edr-passenger-web/backoffice/src/lib/api/finance.ts
Normal 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) }),
|
||||
};
|
||||
@@ -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]));
|
||||
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user