Files
edr-platform/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx
2026-08-02 23:08:15 +03:00

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&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-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>
);
}