Files
edr-platform/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx
2026-07-18 19:12:02 +03:00

288 lines
11 KiB
TypeScript

'use client';
import { useQuery } from '@tanstack/react-query';
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
import { Ticket, AlertCircle, BookOpen, Banknote, ArrowRight, ScanLine } from 'lucide-react';
import { dashboardApi } from '@/lib/api/dashboard';
import { apiClient } from '@/lib/api-client';
import { formatCurrency } from '@/lib/utils';
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from 'recharts';
import Link from 'next/link';
const COLORS = ['#2563eb', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6'];
function StatCard({
icon, iconBg, label, total, loading, rows, href,
}: {
icon: React.ReactNode;
iconBg: string;
label: string;
total: number;
loading: boolean;
rows: { label: string; value: number; icon?: React.ReactNode; href: string }[];
href: string;
}) {
return (
<div className="card flex flex-col gap-3">
<div className="flex items-center gap-2">
<div className={`rounded-lg ${iconBg} p-1.5`}>{icon}</div>
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">{label}</span>
</div>
<p className="text-3xl font-bold text-foreground tabular-nums">
{loading ? '—' : total.toLocaleString()}
</p>
<div className="flex flex-col gap-2 border-t border-border pt-3">
{rows.map((r) => (
<div key={r.label} className="flex items-center justify-between">
<span className="flex items-center gap-1 text-xs text-muted-foreground">{r.icon}{r.label}</span>
<Link href={r.href} className="text-sm font-semibold text-foreground tabular-nums hover:text-primary transition-colors">
{loading ? '—' : r.value.toLocaleString()}
</Link>
</div>
))}
</div>
<Link href={href} className="flex items-center gap-1 text-xs text-primary hover:underline mt-auto pt-1">
View all <ArrowRight className="h-3 w-3" />
</Link>
</div>
);
}
function RevenueSection({
label, bookingCount, rows, subtotal, loading, renderRow,
}: {
label: React.ReactNode;
bookingCount: number;
rows: { currency: string; totalMinor: number }[];
subtotal: number;
loading: boolean;
renderRow: (r: { currency: string; totalMinor: number }) => React.ReactNode;
}) {
return (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between mb-1">
<span className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{label}
</span>
<span className="text-xs text-muted-foreground tabular-nums">
{loading ? '—' : bookingCount.toLocaleString()} bookings
</span>
</div>
{rows.length === 0
? <p className="text-xs text-muted-foreground py-1">No revenue yet</p>
: rows.map(renderRow)}
{rows.length > 0 && (
<div className="flex items-center justify-between rounded-md bg-muted/40 px-3 py-2 mt-1">
<span className="text-xs font-semibold text-muted-foreground">Subtotal</span>
<span className="text-sm font-bold text-foreground tabular-nums">{formatCurrency(subtotal, 'ETB')}</span>
</div>
)}
</div>
);
}
function DashboardPageContent() {
const { data: exchangeRates = [] } = useQuery<any[]>({
queryKey: ['currencies'],
queryFn: () => apiClient.get('/currencies'),
select: (d: any) => (Array.isArray(d) ? d : d?.data ?? d?.items ?? []),
});
const toEtbRate = (currency: string): number | null => {
if (currency === 'ETB') return 1;
const r = exchangeRates.find((x: any) => x.fromCurrency === 'ETB' && x.toCurrency === currency);
return r ? 1 / r.rate : null;
};
const { data: stats, isLoading: statsLoading, error: statsError } = useQuery({
queryKey: ['backoffice-stats'],
queryFn: dashboardApi.getBackofficeStats,
retry: 1,
staleTime: 60000,
});
const { data: paymentMethods } = useQuery({
queryKey: ['payment-methods'],
queryFn: dashboardApi.getPaymentMethods,
retry: 1,
});
const calcGrand = (rows: { currency: string; totalMinor: number }[]) =>
rows.reduce((sum, { currency, totalMinor }) => {
const rate = toEtbRate(currency);
return rate !== null ? sum + Math.round(totalMinor * rate) : sum;
}, 0);
const normalRows = stats?.revenueByCurrency ?? [];
const packageRows = stats?.packageRevenueByCurrency ?? [];
const normalGrand = calcGrand(normalRows);
const packageGrand = calcGrand(packageRows);
const overallGrand = normalGrand + packageGrand;
const renderRevenueRow = ({ currency, totalMinor }: { currency: string; totalMinor: number }) => {
const rate = toEtbRate(currency);
const etbMinor = rate !== null ? Math.round(totalMinor * rate) : null;
return (
<div key={currency} className="flex items-center justify-between rounded-md bg-muted/20 px-3 py-2">
<div className="flex items-center gap-1.5">
<Banknote className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-sm font-medium text-foreground">{currency}</span>
</div>
<span className="text-sm font-semibold text-foreground tabular-nums">
{formatCurrency(totalMinor, currency)}
{currency !== 'ETB' && etbMinor !== null && (
<span className="ml-1.5 text-xs font-normal text-muted-foreground">
({formatCurrency(etbMinor, 'ETB')})
</span>
)}
</span>
</div>
);
};
return (
<div className="space-y-6 p-6">
<div className="flex items-start justify-between">
<div>
<h1 className="text-3xl font-bold text-foreground">Dashboard</h1>
<p className="text-muted-foreground mt-1">Welcome back! Here&apos;s your operational summary.</p>
</div>
<Link
href="/boarding"
className="flex items-center gap-2 rounded-lg bg-emerald-600 hover:bg-emerald-700 text-white px-4 py-2 text-sm font-medium transition-colors"
>
<ScanLine className="h-4 w-4" />
Boarding
</Link>
</div>
{statsError && (
<div className="rounded-lg border border-orange-200 bg-orange-50 dark:border-orange-800 dark:bg-orange-950/30 p-4">
<div className="flex items-center gap-2">
<AlertCircle className="h-5 w-5 text-orange-600 dark:text-orange-400" />
<div>
<h3 className="font-semibold text-orange-800 dark:text-orange-200">Some data may be outdated</h3>
<p className="text-sm text-orange-700 dark:text-orange-300">Unable to fetch live data. Showing cached or sample information.</p>
</div>
</div>
</div>
)}
{/* Stat cards */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<StatCard
icon={<BookOpen className="h-4 w-4 text-blue-600 dark:text-blue-400" />}
iconBg="bg-blue-100 dark:bg-blue-900/30"
label="Bookings"
total={stats?.totalBookings ?? 0}
loading={statsLoading}
href="/bookings"
rows={[
{ label: 'Regular', value: stats?.totalNormalBookings ?? 0, href: '/bookings' },
{ label: 'Package', value: stats?.totalPackageBookings ?? 0, href: '/package-bookings' },
]}
/>
<StatCard
icon={<Ticket className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />}
iconBg="bg-emerald-100 dark:bg-emerald-900/30"
label="Tickets"
total={stats?.totalTickets ?? 0}
loading={statsLoading}
href="/tickets"
rows={[
{ label: 'Regular', value: stats?.totalNormalTickets ?? 0, href: '/tickets' },
{ label: 'Package', value: stats?.totalPackageTickets ?? 0, href: '/tickets' },
]}
/>
{/* Revenue card */}
<div className="card flex flex-col gap-3">
<div className="flex items-center gap-2">
<div className="rounded-lg bg-amber-100 dark:bg-amber-900/30 p-1.5">
<Banknote className="h-4 w-4 text-amber-600 dark:text-amber-400" />
</div>
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Revenue</span>
</div>
{statsLoading ? (
<p className="text-muted-foreground text-sm">Loading</p>
) : (
<>
<p className="text-3xl font-bold text-emerald-600 dark:text-emerald-400 tabular-nums">
{formatCurrency(overallGrand, 'ETB')}
</p>
<div className="flex flex-col gap-2 border-t border-border pt-3">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">Regular</p>
<p className="text-sm font-semibold text-foreground tabular-nums">{formatCurrency(normalGrand, 'ETB')}</p>
</div>
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">Package</p>
<p className="text-sm font-semibold text-foreground tabular-nums">{formatCurrency(packageGrand, 'ETB')}</p>
</div>
</div>
<Link href="/payments" className="flex items-center gap-1 text-xs text-primary hover:underline mt-auto pt-1">
View payments <ArrowRight className="h-3 w-3" />
</Link>
</>
)}
</div>
</div>
{/* Revenue breakdown */}
<div className="card">
<h2 className="text-sm font-semibold uppercase tracking-widest text-muted-foreground mb-4">Revenue Breakdown</h2>
{statsLoading ? (
<p className="text-muted-foreground text-sm">Loading</p>
) : !normalRows.length && !packageRows.length ? (
<p className="text-muted-foreground text-sm">No revenue data yet.</p>
) : (
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
<RevenueSection
label="Regular"
bookingCount={stats?.totalNormalBookings ?? 0}
rows={normalRows}
subtotal={normalGrand}
loading={statsLoading}
renderRow={renderRevenueRow}
/>
<RevenueSection
label="Package"
bookingCount={stats?.totalPackageBookings ?? 0}
rows={packageRows}
subtotal={packageGrand}
loading={statsLoading}
renderRow={renderRevenueRow}
/>
</div>
)}
</div>
{/* Payment Methods Distribution */}
{paymentMethods && paymentMethods.length > 0 && (
<div className="card">
<h2 className="mb-4 text-lg font-semibold text-foreground">Payment Methods Distribution</h2>
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie data={paymentMethods} dataKey="count" nameKey="method" cx="50%" cy="50%" outerRadius={80} label>
{paymentMethods.map((_: any, index: number) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
</div>
)}
</div>
);
}
export default function DashboardPage() {
return (
<PermissionGuard permission={PERMS.dashboard}>
<DashboardPageContent />
</PermissionGuard>
);
}