mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-02 17:23:38 +00:00
288 lines
11 KiB
TypeScript
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'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>
|
|
);
|
|
}
|