mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
469 lines
16 KiB
TypeScript
469 lines
16 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,
|
|
Ban,
|
|
} from "lucide-react";
|
|
import { SEAT_BLOCK_REASON_CATEGORY_LABELS } from "@edr/types";
|
|
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: 30000,
|
|
refetchInterval: 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 blockedLoss = stats?.blockedSeatRevenueLoss;
|
|
// Never summed across currencies — each is shown on its own line, largest first.
|
|
const blockedLossRows = blockedLoss?.lossByCurrency ?? [];
|
|
|
|
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-2 lg: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>
|
|
|
|
{/* Blocked-seat revenue loss — rides the same backoffice-stats payload, so the
|
|
dashboard makes no extra request for it. */}
|
|
<div className="card flex flex-col gap-3">
|
|
<div className="flex items-center gap-2">
|
|
<div className="rounded-lg bg-slate-100 dark:bg-slate-800 p-1.5">
|
|
<Ban className="h-4 w-4 text-slate-600 dark:text-slate-400" />
|
|
</div>
|
|
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
|
Blocked Seats
|
|
</span>
|
|
<span className="ml-auto text-[11px] text-muted-foreground">
|
|
Last {blockedLoss?.periodDays ?? 30}d
|
|
</span>
|
|
</div>
|
|
{statsLoading ? (
|
|
<p className="text-muted-foreground text-sm">Loading…</p>
|
|
) : (
|
|
<>
|
|
{blockedLossRows.length === 0 ? (
|
|
<p className="text-3xl font-bold text-foreground tabular-nums">
|
|
{formatCurrency(0, "ETB")}
|
|
</p>
|
|
) : (
|
|
blockedLossRows.map((row, i) => (
|
|
<p
|
|
key={row.currency}
|
|
className={
|
|
i === 0
|
|
? "text-3xl font-bold text-foreground tabular-nums"
|
|
: "text-lg font-semibold text-foreground tabular-nums"
|
|
}
|
|
>
|
|
{formatCurrency(row.estimatedLossMinor, row.currency)}
|
|
</p>
|
|
))
|
|
)}
|
|
<p className="text-xs text-muted-foreground -mt-1">
|
|
Estimated potential revenue never earned
|
|
</p>
|
|
<div className="flex flex-col gap-2 border-t border-border pt-3">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-xs text-muted-foreground">Seats blocked</span>
|
|
<span className="text-sm font-semibold text-foreground tabular-nums">
|
|
{(blockedLoss?.blockedSeatCount ?? 0).toLocaleString()} across{" "}
|
|
{(blockedLoss?.schedulesAffected ?? 0).toLocaleString()} schedules
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-xs text-muted-foreground">Top reason</span>
|
|
<span className="text-sm font-semibold text-foreground">
|
|
{blockedLoss?.topReasonCategory
|
|
? (SEAT_BLOCK_REASON_CATEGORY_LABELS[blockedLoss.topReasonCategory] ??
|
|
blockedLoss.topReasonCategory)
|
|
: "—"}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<Link
|
|
href="/reports/blocked-seats"
|
|
className="flex items-center gap-1 text-xs text-primary hover:underline mt-auto pt-1"
|
|
>
|
|
View full report <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>
|
|
);
|
|
}
|