mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 18:20:57 +00:00
Merge pull request #1282 from Tria-plc/alpha
feat: ( dashboard ) add booking charts
This commit is contained in:
@@ -0,0 +1,403 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Booking charts for the backoffice dashboard — revenue trend, daily confirmed
|
||||
* bookings, status distribution and payment-method split over the last 30 days.
|
||||
*
|
||||
* Ported from `/reports/overall`, which computes the same four panels in the browser
|
||||
* from a 5000-row booking fetch. Here the grouping is done by
|
||||
* `GET /dashboard/analytics/bookings` so the landing page stays light.
|
||||
*
|
||||
* Revenue on this panel answers "what was booked" — CONFIRMED and BOARDED bookings by
|
||||
* creation date. The Revenue Breakdown card below answers "what was collected" (it also
|
||||
* requires a SUCCEEDED payment intent). The two will not match, which is why each says
|
||||
* what it measures in its own heading.
|
||||
*/
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
LabelList,
|
||||
Line,
|
||||
LineChart,
|
||||
Pie,
|
||||
PieChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { apiClient } from "@/lib/api-client";
|
||||
import { categoricalColor, getChartPalette } from "@/lib/chart-palette";
|
||||
import { useTheme } from "@/lib/theme-store";
|
||||
import { formatCurrency } from "@/lib/utils";
|
||||
|
||||
interface BookingAnalytics {
|
||||
window: { from: string; to: string; days: number };
|
||||
totals: { bookings: number; confirmedBookings: number };
|
||||
byDay: {
|
||||
date: string;
|
||||
bookings: number;
|
||||
revenueByCurrency: { currency: string; totalMinor: number }[];
|
||||
}[];
|
||||
statusDistribution: { status: string; count: number }[];
|
||||
paymentMethods: { method: string; count: number }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixed colour domain for booking status. Keyed by position in the enum rather than by
|
||||
* rank in the data, so a day with no cancellations does not repaint the other slices.
|
||||
*/
|
||||
const STATUS_ORDER = [
|
||||
"CONFIRMED",
|
||||
"BOARDED",
|
||||
"PENDING_PAYMENT",
|
||||
"CANCELLED",
|
||||
"REFUNDED",
|
||||
"NO_SHOW",
|
||||
] as const;
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
CONFIRMED: "Confirmed",
|
||||
BOARDED: "Boarded",
|
||||
PENDING_PAYMENT: "Pending payment",
|
||||
CANCELLED: "Cancelled",
|
||||
REFUNDED: "Refunded",
|
||||
NO_SHOW: "No show",
|
||||
DRAFT: "Draft",
|
||||
UNKNOWN: "Unknown",
|
||||
};
|
||||
|
||||
const MAX_METHOD_BARS = 6;
|
||||
|
||||
/** `YYYY-MM-DD` → `5 Mar`, parsed by parts so no timezone can shift the label. */
|
||||
function formatDayLabel(date: string): string {
|
||||
const [, month, day] = date.split("-");
|
||||
const monthName = [
|
||||
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
||||
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
|
||||
][Number(month) - 1];
|
||||
return `${Number(day)} ${monthName}`;
|
||||
}
|
||||
|
||||
function prettyMethod(method: string): string {
|
||||
return method
|
||||
.toLowerCase()
|
||||
.replace(/_/g, " ")
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
export default function DashboardBookingCharts() {
|
||||
const isDark = useTheme((s) => s.isDark);
|
||||
const palette = getChartPalette(isDark);
|
||||
|
||||
// Same query key the dashboard page already uses, so this shares its cache rather
|
||||
// than issuing a second request for the rates.
|
||||
const { data: exchangeRates = [] } = useQuery<any[]>({
|
||||
queryKey: ["currencies"],
|
||||
queryFn: () => apiClient.get("/currencies"),
|
||||
select: (d: any) => (Array.isArray(d) ? d : (d?.data ?? d?.items ?? [])),
|
||||
});
|
||||
|
||||
const { data, isLoading, isError } = useQuery<BookingAnalytics>({
|
||||
queryKey: ["dashboard-booking-analytics"],
|
||||
queryFn: () => apiClient.get("/dashboard/analytics/bookings"),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
// Matches the conversion the dashboard page applies to its revenue cards.
|
||||
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 dayRows = useMemo(
|
||||
() =>
|
||||
(data?.byDay ?? []).map((d) => ({
|
||||
label: formatDayLabel(d.date),
|
||||
bookings: d.bookings,
|
||||
// A currency with no rate on file is left out rather than counted at 1:1.
|
||||
revenueMinor: d.revenueByCurrency.reduce((sum, r) => {
|
||||
const rate = toEtbRate(r.currency);
|
||||
return rate !== null ? sum + Math.round(r.totalMinor * rate) : sum;
|
||||
}, 0),
|
||||
})),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[data, exchangeRates],
|
||||
);
|
||||
|
||||
const statusRows = useMemo(
|
||||
() =>
|
||||
(data?.statusDistribution ?? [])
|
||||
.filter((s) => s.count > 0)
|
||||
.map((s) => ({
|
||||
name: STATUS_LABELS[s.status] ?? s.status,
|
||||
value: s.count,
|
||||
color: categoricalColor(
|
||||
palette,
|
||||
STATUS_ORDER.indexOf(s.status as (typeof STATUS_ORDER)[number]) >= 0
|
||||
? STATUS_ORDER.indexOf(s.status as (typeof STATUS_ORDER)[number])
|
||||
: STATUS_ORDER.length,
|
||||
),
|
||||
})),
|
||||
[data, palette],
|
||||
);
|
||||
|
||||
const methodRows = useMemo(
|
||||
() =>
|
||||
(data?.paymentMethods ?? []).slice(0, MAX_METHOD_BARS).map((m) => ({
|
||||
label: prettyMethod(m.method),
|
||||
count: m.count,
|
||||
})),
|
||||
[data],
|
||||
);
|
||||
|
||||
const tooltipStyle = {
|
||||
background: palette.tooltipBg,
|
||||
border: `1px solid ${palette.tooltipBorder}`,
|
||||
borderRadius: 8,
|
||||
fontSize: 12,
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="card py-12 text-center text-muted-foreground">
|
||||
<p className="text-sm">Loading booking analytics…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// The dashboard's other cards stand on their own, so a failure here degrades to a
|
||||
// single quiet line rather than taking the page down.
|
||||
if (isError || !data) {
|
||||
return (
|
||||
<div className="card py-8 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Booking analytics are unavailable right now.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const hasDays = dayRows.length > 0;
|
||||
const rangeLabel = `Last ${data.window.days} days`;
|
||||
|
||||
const emptyPanel = (
|
||||
<div className="h-[260px] flex items-center justify-center text-muted-foreground text-sm">
|
||||
No bookings in this range
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Revenue Trend */}
|
||||
<div className="card">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
Revenue Trend
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground mt-1 mb-4">
|
||||
{rangeLabel} · value of confirmed bookings on the day they were made, in ETB.
|
||||
Not the same as collected revenue below.
|
||||
</p>
|
||||
{hasDays ? (
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<LineChart data={dayRows} margin={{ top: 4, right: 8, bottom: 4, left: 0 }}>
|
||||
<CartesianGrid stroke={palette.grid} vertical={false} />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fill: palette.textMuted, fontSize: 11 }}
|
||||
stroke={palette.axis}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fill: palette.textMuted, fontSize: 11 }}
|
||||
stroke={palette.axis}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(v: number) => Math.round(v / 100).toLocaleString()}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={tooltipStyle}
|
||||
labelStyle={{ color: palette.textMuted }}
|
||||
formatter={(value: number) => [formatCurrency(value, "ETB"), "Revenue"]}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="revenueMinor"
|
||||
name="Revenue"
|
||||
stroke={palette.sequential}
|
||||
strokeWidth={2}
|
||||
dot={{ r: 3 }}
|
||||
activeDot={{ r: 5 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
emptyPanel
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Daily Confirmed Bookings */}
|
||||
<div className="card">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
Daily Confirmed Bookings
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground mt-1 mb-4">
|
||||
{rangeLabel} · how many bookings were confirmed each day.
|
||||
</p>
|
||||
{hasDays ? (
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<BarChart data={dayRows} margin={{ top: 4, right: 8, bottom: 4, left: 0 }}>
|
||||
<CartesianGrid stroke={palette.grid} vertical={false} />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fill: palette.textMuted, fontSize: 11 }}
|
||||
stroke={palette.axis}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fill: palette.textMuted, fontSize: 11 }}
|
||||
stroke={palette.axis}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
allowDecimals={false}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: palette.grid, fillOpacity: 0.35 }}
|
||||
contentStyle={tooltipStyle}
|
||||
labelStyle={{ color: palette.textMuted }}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="bookings"
|
||||
name="Bookings"
|
||||
fill={palette.sequential}
|
||||
radius={[3, 3, 0, 0]}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
emptyPanel
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Booking Status Distribution */}
|
||||
<div className="card">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
Booking Status Distribution
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground mt-1 mb-4">
|
||||
{rangeLabel} · every booking made in the range, by current status.
|
||||
</p>
|
||||
{statusRows.length > 0 ? (
|
||||
<>
|
||||
{/* Legend with text labels and counts — the palette's light-mode contrast is
|
||||
validated only with that relief in place. */}
|
||||
<div className="flex items-center gap-x-4 gap-y-2 flex-wrap mb-3">
|
||||
{statusRows.map((s) => (
|
||||
<div key={s.name} className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="h-2.5 w-2.5 rounded-sm"
|
||||
style={{ backgroundColor: s.color }}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{s.name} · {s.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={statusRows}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={45}
|
||||
outerRadius={85}
|
||||
paddingAngle={2}
|
||||
stroke={palette.surface}
|
||||
strokeWidth={2}
|
||||
>
|
||||
{statusRows.map((s) => (
|
||||
<Cell key={s.name} fill={s.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip contentStyle={tooltipStyle} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</>
|
||||
) : (
|
||||
emptyPanel
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Payment Methods */}
|
||||
<div className="card">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
Payment Methods
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground mt-1 mb-4">
|
||||
{rangeLabel} · which method each booking used. “Unknown” means no
|
||||
payment was started.
|
||||
</p>
|
||||
{methodRows.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={38 * methodRows.length + 32}>
|
||||
<BarChart
|
||||
data={methodRows}
|
||||
layout="vertical"
|
||||
margin={{ top: 0, right: 44, bottom: 0, left: 8 }}
|
||||
>
|
||||
<CartesianGrid stroke={palette.grid} horizontal={false} />
|
||||
<XAxis
|
||||
type="number"
|
||||
tick={{ fill: palette.textMuted, fontSize: 11 }}
|
||||
stroke={palette.axis}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
allowDecimals={false}
|
||||
/>
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="label"
|
||||
width={120}
|
||||
tick={{ fill: palette.textMuted, fontSize: 11 }}
|
||||
stroke={palette.axis}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: palette.grid, fillOpacity: 0.35 }}
|
||||
contentStyle={tooltipStyle}
|
||||
labelStyle={{ color: palette.textMuted }}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="count"
|
||||
name="Bookings"
|
||||
fill={palette.sequential}
|
||||
radius={[0, 3, 3, 0]}
|
||||
>
|
||||
<LabelList
|
||||
dataKey="count"
|
||||
position="right"
|
||||
fill={palette.textMuted}
|
||||
fontSize={11}
|
||||
/>
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
emptyPanel
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user