mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Updated financial report and sms outside of Ethiopia
This commit is contained in:
@@ -85,14 +85,20 @@ export class PaymentsController {
|
||||
@ApiBearerAuth("IAM-auth")
|
||||
@ApiOperation({ summary: "Get all payments with filters (staff/admin only)" })
|
||||
@ApiQuery({ name: "search", required: false })
|
||||
@ApiQuery({ name: "status", required: false })
|
||||
@ApiQuery({ name: "status", required: false, description: "PaymentIntentStatus value, e.g. SUCCEEDED" })
|
||||
@ApiQuery({ name: "method", required: false })
|
||||
@ApiQuery({
|
||||
name: "bookingStatus",
|
||||
required: false,
|
||||
description: "Comma-separated Booking.status values, e.g. CONFIRMED,BOARDED — restricts to payments backing bookings in those states.",
|
||||
})
|
||||
@ApiQuery({ name: "page", required: false })
|
||||
@ApiQuery({ name: "pageSize", required: false })
|
||||
async getAll(
|
||||
@Query("search") search?: string,
|
||||
@Query("status") status?: string,
|
||||
@Query("method") method?: string,
|
||||
@Query("bookingStatus") bookingStatus?: string,
|
||||
@Query("page") page?: string,
|
||||
@Query("pageSize") pageSize?: string,
|
||||
) {
|
||||
@@ -100,6 +106,7 @@ export class PaymentsController {
|
||||
search,
|
||||
status,
|
||||
method,
|
||||
bookingStatus,
|
||||
page: page ? parseInt(page) : 1,
|
||||
pageSize: pageSize ? parseInt(pageSize) : 10,
|
||||
});
|
||||
|
||||
@@ -99,10 +99,13 @@ export class PaymentsService {
|
||||
search?: string;
|
||||
status?: string;
|
||||
method?: string;
|
||||
/** Comma-separated Booking.status values, e.g. "CONFIRMED,BOARDED" — lets a caller ask
|
||||
* for exactly the payments that back confirmed revenue, not every payment attempt. */
|
||||
bookingStatus?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}) {
|
||||
const { search, status, method, page = 1, pageSize = 10 } = filters;
|
||||
const { search, status, method, bookingStatus, page = 1, pageSize = 10 } = filters;
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
const where: any = {};
|
||||
@@ -118,6 +121,12 @@ export class PaymentsService {
|
||||
if (method) {
|
||||
where.method = method;
|
||||
}
|
||||
if (bookingStatus) {
|
||||
const statuses = bookingStatus.split(",").map((s) => s.trim()).filter(Boolean);
|
||||
if (statuses.length > 0) {
|
||||
where.booking = { status: { in: statuses } };
|
||||
}
|
||||
}
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.paymentIntent.findMany({
|
||||
@@ -133,6 +142,7 @@ export class PaymentsService {
|
||||
childCount: true,
|
||||
totalMinor: true,
|
||||
currency: true,
|
||||
status: true,
|
||||
priceTier: { select: { priceMinor: true } },
|
||||
},
|
||||
},
|
||||
@@ -172,6 +182,7 @@ export class PaymentsService {
|
||||
bookingRef: b?.bookingRef,
|
||||
totalMinor: b?.totalMinor,
|
||||
currency: b?.currency,
|
||||
status: b?.status,
|
||||
},
|
||||
amountMinor,
|
||||
currency: item.currency,
|
||||
|
||||
@@ -122,12 +122,16 @@ export class ReportsController {
|
||||
|
||||
@Get("finance")
|
||||
@ApiOperation({
|
||||
summary: "Finance summary — revenue by period, origin/destination segment, and payment method",
|
||||
summary: "Finance summary — revenue by period, origin/destination segment, payment method, and currency",
|
||||
description:
|
||||
"Revenue collected in the window (PaymentIntent.paidAt), grouped by day/week/month, origin → " +
|
||||
"destination station pair, and payment method. Filter by originStationId and/or destinationStationId " +
|
||||
"independently to query any station-pair segment (A→B, A→D, B→C), not just a whole predefined route. " +
|
||||
"Returns per-bucket rows plus roll-ups by period, segment, and method for charting.",
|
||||
"destination station pair, payment method, and currency. Amounts are never converted to ETB — a " +
|
||||
"Waafi payment is reported in whatever currency Waafi actually charged, and with no method filter " +
|
||||
"every currency present is listed separately rather than summed. Filter by originStationId and/or " +
|
||||
"destinationStationId independently to query any station-pair segment (A→B, A→D, B→C), not just a " +
|
||||
"whole predefined route. Only counts CONFIRMED/BOARDED bookings with a SUCCEEDED payment — the same " +
|
||||
"revenue definition as the dashboard and /payments confirmed-revenue filter. Returns per-bucket rows " +
|
||||
"plus roll-ups by period, segment, and method for charting.",
|
||||
})
|
||||
getFinanceSummary(@Query() query: FinanceSummaryQueryDto) {
|
||||
return this.service.getFinanceSummary(query);
|
||||
|
||||
@@ -152,8 +152,17 @@ export interface FinanceBucket {
|
||||
destinationStationId: string;
|
||||
segmentLabel: string;
|
||||
method: string;
|
||||
currency: string;
|
||||
bookingCount: number;
|
||||
revenueMinor: number;
|
||||
}
|
||||
|
||||
export interface FinanceRollupRow {
|
||||
key: string;
|
||||
label: string;
|
||||
currency: string;
|
||||
revenueMinor: number;
|
||||
bookingCount: number;
|
||||
revenueEtbMinor: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -1724,8 +1733,20 @@ export class ReportsService {
|
||||
|
||||
/**
|
||||
* Revenue collected in the window, grouped by reporting period (day/week/month), origin →
|
||||
* destination station pair, and payment method — the shape finance reconciles against
|
||||
* provider settlement statements.
|
||||
* destination station pair, payment method, and currency — the shape finance reconciles
|
||||
* against provider settlement statements.
|
||||
*
|
||||
* Amounts are never converted to ETB. A Waafi payment settles in whatever currency Waafi
|
||||
* actually charged (DJF/USD), not an exchange-rate estimate of its ETB equivalent — so
|
||||
* filtering to one method shows exactly what that method collected, in its own currency,
|
||||
* and leaving every method selected lists each currency's total separately rather than
|
||||
* summing unlike currencies into one converted figure.
|
||||
*
|
||||
* The "actual" amount/currency is `displayTotalMinor`/`displayCurrency` when set, falling
|
||||
* back to `totalMinor`/`currency` — the same resolution `getPaymentDiscrepancyReport` and
|
||||
* `getPaymentsReport` use, because `Booking.currency` is often just the internal ETB
|
||||
* charge basis (many booking-creation paths hardcode it to ETB); the currency the
|
||||
* passenger was actually shown and charged in lives in the display fields.
|
||||
*
|
||||
* Grouped by the booking's own origin/destination, not the parent Route — a route like
|
||||
* "Sebeta - Dire Dawa" has intermediate stops, and a passenger may have booked any
|
||||
@@ -1734,29 +1755,26 @@ export class ReportsService {
|
||||
*
|
||||
* Bucketed on `PaymentIntent.paidAt` (cash actually received), not `Booking.createdAt`,
|
||||
* so a booking made in one period but paid in another lands in the period it was paid.
|
||||
*
|
||||
* Same revenue definition as `getBackofficeStats` and the `/payments` "confirmed revenue"
|
||||
* filter: `Booking.status` must still be CONFIRMED/BOARDED (a booking that was paid and
|
||||
* later cancelled is not revenue) and `PaymentIntent.status` must be SUCCEEDED, not just
|
||||
* carry a stale `paidAt` from before a cancellation.
|
||||
*/
|
||||
async getFinanceSummary(query: FinanceSummaryQueryDto) {
|
||||
const dateFrom = new Date(query.dateFrom + "T00:00:00.000Z");
|
||||
const dateTo = new Date(query.dateTo + "T23:59:59.999Z");
|
||||
const granularity = query.granularity ?? FinanceGranularity.DAILY;
|
||||
|
||||
const rateRows = await this.prisma.currencyExchangeRate.findMany({
|
||||
where: { toCurrency: "ETB" as any },
|
||||
orderBy: { effectiveDate: "desc" },
|
||||
});
|
||||
const rateToEtb = new Map<string, number>();
|
||||
for (const r of rateRows) {
|
||||
if (!rateToEtb.has(r.fromCurrency)) rateToEtb.set(r.fromCurrency, Number(r.rate));
|
||||
}
|
||||
const toEtbMinor = (minor: number, currency: string): number => {
|
||||
if (currency === "ETB") return minor;
|
||||
const rate = rateToEtb.get(currency);
|
||||
return rate ? Math.round(minor * rate) : minor;
|
||||
};
|
||||
|
||||
const bookings = await this.prisma.booking.findMany({
|
||||
where: {
|
||||
// Same revenue definition as the dashboard's backoffice-stats and the /payments
|
||||
// "confirmed revenue" filter: the booking must still be CONFIRMED/BOARDED (a booking
|
||||
// that was paid and later cancelled is not revenue) and the payment itself must have
|
||||
// actually succeeded, not just carry a stale paidAt.
|
||||
status: { in: ["CONFIRMED", "BOARDED"] },
|
||||
paymentIntent: {
|
||||
status: "SUCCEEDED",
|
||||
paidAt: { gte: dateFrom, lte: dateTo },
|
||||
...(query.method ? { method: query.method } : {}),
|
||||
},
|
||||
@@ -1766,6 +1784,8 @@ export class ReportsService {
|
||||
select: {
|
||||
totalMinor: true,
|
||||
currency: true,
|
||||
displayTotalMinor: true,
|
||||
displayCurrency: true,
|
||||
originStationId: true,
|
||||
destinationStationId: true,
|
||||
schedule: { select: { originStationId: true, destinationStationId: true } },
|
||||
@@ -1795,11 +1815,12 @@ export class ReportsService {
|
||||
destinationStationId: string,
|
||||
segmentLabel: string,
|
||||
method: string,
|
||||
currency: string,
|
||||
): FinanceBucket => {
|
||||
const key = `${period}|${originStationId}|${destinationStationId}|${method}`;
|
||||
const key = `${period}|${originStationId}|${destinationStationId}|${method}|${currency}`;
|
||||
let bucket = buckets.get(key);
|
||||
if (!bucket) {
|
||||
bucket = { period, originStationId, destinationStationId, segmentLabel, method, bookingCount: 0, revenueEtbMinor: 0 };
|
||||
bucket = { period, originStationId, destinationStationId, segmentLabel, method, currency, bookingCount: 0, revenueMinor: 0 };
|
||||
buckets.set(key, bucket);
|
||||
}
|
||||
return bucket;
|
||||
@@ -1811,65 +1832,62 @@ export class ReportsService {
|
||||
const originStationId = b.originStationId ?? b.schedule.originStationId ?? "UNKNOWN";
|
||||
const destinationStationId = b.destinationStationId ?? b.schedule.destinationStationId ?? "UNKNOWN";
|
||||
const segmentLabel = `${stationName.get(originStationId) ?? "Unknown"} → ${stationName.get(destinationStationId) ?? "Unknown"}`;
|
||||
const bucket = bucketFor(period, originStationId, destinationStationId, segmentLabel, pi.method);
|
||||
const currency = (b.displayCurrency as string | null) ?? b.currency;
|
||||
const amountMinor = b.displayTotalMinor ?? b.totalMinor;
|
||||
const bucket = bucketFor(period, originStationId, destinationStationId, segmentLabel, pi.method, currency);
|
||||
bucket.bookingCount += 1;
|
||||
bucket.revenueEtbMinor += toEtbMinor(b.totalMinor, b.currency);
|
||||
bucket.revenueMinor += amountMinor;
|
||||
}
|
||||
|
||||
const rows = [...buckets.values()].sort((a, b) =>
|
||||
a.period === b.period
|
||||
? a.segmentLabel.localeCompare(b.segmentLabel) || a.method.localeCompare(b.method)
|
||||
? a.segmentLabel.localeCompare(b.segmentLabel) || a.method.localeCompare(b.method) || a.currency.localeCompare(b.currency)
|
||||
: a.period.localeCompare(b.period),
|
||||
);
|
||||
|
||||
const totals = rows.reduce(
|
||||
(acc, r) => {
|
||||
acc.bookingCount += r.bookingCount;
|
||||
acc.revenueEtbMinor += r.revenueEtbMinor;
|
||||
return acc;
|
||||
},
|
||||
{ bookingCount: 0, revenueEtbMinor: 0 },
|
||||
);
|
||||
|
||||
const rollUp = (keyOf: (r: FinanceBucket) => string, labelOf: (r: FinanceBucket) => string) => {
|
||||
const map = new Map<string, { key: string; label: string; revenueEtbMinor: number; bookingCount: number }>();
|
||||
const rollUp = (keyOf: (r: FinanceBucket) => string, labelOf: (r: FinanceBucket) => string): FinanceRollupRow[] => {
|
||||
const map = new Map<string, FinanceRollupRow>();
|
||||
for (const r of rows) {
|
||||
const key = keyOf(r);
|
||||
let entry = map.get(key);
|
||||
if (!entry) {
|
||||
entry = { key, label: labelOf(r), revenueEtbMinor: 0, bookingCount: 0 };
|
||||
entry = { key, label: labelOf(r), currency: r.currency, revenueMinor: 0, bookingCount: 0 };
|
||||
map.set(key, entry);
|
||||
}
|
||||
entry.revenueEtbMinor += r.revenueEtbMinor;
|
||||
entry.revenueMinor += r.revenueMinor;
|
||||
entry.bookingCount += r.bookingCount;
|
||||
}
|
||||
return [...map.values()].sort((a, b) => b.revenueEtbMinor - a.revenueEtbMinor);
|
||||
return [...map.values()].sort((a, b) => b.revenueMinor - a.revenueMinor);
|
||||
};
|
||||
|
||||
// Currency is folded into every rollup key so amounts in different currencies are never
|
||||
// summed together — see class-level note on why this endpoint doesn't convert to ETB.
|
||||
const totals = rollUp((r) => r.currency, (r) => r.currency);
|
||||
|
||||
return {
|
||||
granularity,
|
||||
dateFrom: query.dateFrom,
|
||||
dateTo: query.dateTo,
|
||||
currency: "ETB",
|
||||
totals,
|
||||
byPeriod: rollUp((r) => r.period, (r) => r.period),
|
||||
bySegment: rollUp((r) => `${r.originStationId}|${r.destinationStationId}`, (r) => r.segmentLabel),
|
||||
byMethod: rollUp((r) => r.method, (r) => r.method),
|
||||
byPeriod: rollUp((r) => `${r.period}|${r.currency}`, (r) => r.period),
|
||||
bySegment: rollUp((r) => `${r.originStationId}|${r.destinationStationId}|${r.currency}`, (r) => r.segmentLabel),
|
||||
byMethod: rollUp((r) => `${r.method}|${r.currency}`, (r) => r.method),
|
||||
rows,
|
||||
};
|
||||
}
|
||||
|
||||
/** CSV of the finance summary, one row per period + origin/destination segment + payment method. */
|
||||
/** CSV of the finance summary, one row per period + origin/destination segment + payment method + currency. */
|
||||
async exportFinanceSummaryCsv(query: FinanceSummaryQueryDto): Promise<string> {
|
||||
const report = await this.getFinanceSummary(query);
|
||||
|
||||
const headers = ["Period", "Origin → Destination", "Payment Method", "Bookings", "Revenue (ETB)"];
|
||||
const headers = ["Period", "Origin → Destination", "Payment Method", "Currency", "Bookings", "Revenue"];
|
||||
const rows = report.rows.map((r) => [
|
||||
r.period,
|
||||
r.segmentLabel,
|
||||
r.method,
|
||||
r.currency,
|
||||
r.bookingCount,
|
||||
(r.revenueEtbMinor / 100).toFixed(2),
|
||||
(r.revenueMinor / 100).toFixed(2),
|
||||
]);
|
||||
|
||||
return [headers, ...rows].map((row) => row.map(toCsvCell).join(",")).join("\n");
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
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.key as any) - PAYMENT_METHOD_ORDER.indexOf(b.key as any))
|
||||
.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.key as any)),
|
||||
sharePercent: total > 0 ? (r.revenueEtbMinor / total) * 100 : 0,
|
||||
color: categoricalColor(palette, PAYMENT_METHOD_ORDER.indexOf(r.label as any)),
|
||||
sharePercent: methodTotal > 0 ? (r.revenueMinor / methodTotal) * 100 : 0,
|
||||
}));
|
||||
}, [data, palette]);
|
||||
|
||||
const totals = data?.totals;
|
||||
const hasData = (totals?.bookingCount ?? 0) > 0;
|
||||
const avgPerBookingMinor = hasData ? Math.round(totals!.revenueEtbMinor / totals!.bookingCount) : 0;
|
||||
return {
|
||||
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,75 +403,85 @@ 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>
|
||||
</div>
|
||||
<div className="card flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Avg. per Booking</p>
|
||||
<div className="rounded-lg bg-amber-100 dark:bg-amber-900/30 p-1.5">
|
||||
<Receipt className="h-4 w-4 text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-2xl font-bold tabular-nums mt-1">{formatCurrency(avgPerBookingMinor, 'ETB')}</p>
|
||||
<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>
|
||||
|
||||
{/* Trend + route charts */}
|
||||
{/* 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={trendCardRef}>
|
||||
<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">(ETB, {granularity})</span>
|
||||
Revenue Trend <span className="text-xs font-normal text-muted-foreground">({section.currency}, {granularity})</span>
|
||||
</h3>
|
||||
{trendData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<LineChart data={trendData}>
|
||||
{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) => `ETB ${Math.round(value).toLocaleString()}`} />
|
||||
<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-[280px] flex items-center justify-center text-muted-foreground text-sm">No data for selected range</div>
|
||||
<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={segmentCardRef}>
|
||||
<h3 className="text-base font-semibold mb-4">Revenue by Segment</h3>
|
||||
{segmentData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<BarChart data={segmentData}>
|
||||
<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) => `ETB ${Math.round(value).toLocaleString()}`} />
|
||||
<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-[280px] flex items-center justify-center text-muted-foreground text-sm">No data for selected range</div>
|
||||
<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={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="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={`Revenue by payment method: ${methodBreakdown
|
||||
.map((m) => `${methodLabel(m.key)} ${m.sharePercent.toFixed(0)}%`)
|
||||
aria-label={`${section.currency} revenue by payment method: ${section.methodBreakdown
|
||||
.map((m) => `${methodLabel(m.label)} ${m.sharePercent.toFixed(0)}%`)
|
||||
.join(', ')}`}
|
||||
>
|
||||
{methodBreakdown.map((m, i) => (
|
||||
{section.methodBreakdown.map((m, i) => (
|
||||
<div
|
||||
key={m.key}
|
||||
className="h-full"
|
||||
style={{ width: `${m.sharePercent}%`, background: m.color, marginRight: i < methodBreakdown.length - 1 ? 2 : 0 }}
|
||||
title={`${methodLabel(m.key)} — ${formatCurrency(m.revenueEtbMinor, 'ETB')}`}
|
||||
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>
|
||||
@@ -459,22 +495,24 @@ export default function FinanceReportPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{methodBreakdown.map((m) => (
|
||||
{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.key)}</span>
|
||||
<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.revenueEtbMinor, 'ETB')}</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">
|
||||
@@ -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>
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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,12 +645,45 @@ function createFormSchema(adultCount: number) {
|
||||
}
|
||||
|
||||
const age = calculateAge(p.dateOfBirth);
|
||||
if (age === null) return;
|
||||
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">
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user