mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #1282 from Tria-plc/alpha
feat: ( dashboard ) add booking charts
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { Controller, Get, Param, SetMetadata, UseGuards } from '@nestjs/common';
|
import { Controller, Get, Param, Query, SetMetadata, UseGuards } from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { DashboardService } from './dashboard.service';
|
import { DashboardService } from './dashboard.service';
|
||||||
import { JwtGuard } from '../../common/jwt.guard';
|
import { JwtGuard } from '../../common/jwt.guard';
|
||||||
@@ -16,6 +16,27 @@ export class DashboardController {
|
|||||||
@ApiOperation({ summary: 'Backoffice summary: totals and revenue by currency' })
|
@ApiOperation({ summary: 'Backoffice summary: totals and revenue by currency' })
|
||||||
getBackofficeStats() { return this.service.getBackofficeStats(); }
|
getBackofficeStats() { return this.service.getBackofficeStats(); }
|
||||||
|
|
||||||
|
// Two segments, so the single-segment `@Get(':passengerId')` below cannot swallow it
|
||||||
|
// however the routes are ordered. Staff-guarded like backoffice-stats, not JwtGuard.
|
||||||
|
@Get('analytics/bookings')
|
||||||
|
@PassengerStaff([PASSENGER_PERMS.dashboard.view, PASSENGER_PERMS.admin])
|
||||||
|
@ApiBearerAuth('IAM-auth')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Booking analytics for the dashboard charts',
|
||||||
|
description:
|
||||||
|
'Revenue trend, daily confirmed bookings, booking status distribution and payment-method split over the ' +
|
||||||
|
'last `days` days (default 30), bucketed by booking creation date.\n\n' +
|
||||||
|
'Revenue and the daily count cover CONFIRMED and BOARDED bookings; the status and payment-method ' +
|
||||||
|
'breakdowns cover every booking in range — the same asymmetry the /reports/overall page applies, kept so ' +
|
||||||
|
'the two agree.\n\n' +
|
||||||
|
'Revenue is returned per currency and unconverted; the caller applies its own exchange rates. These ' +
|
||||||
|
'figures answer "what was booked" and will not match the Revenue Breakdown card, which requires a ' +
|
||||||
|
'SUCCEEDED payment intent and answers "what was collected".',
|
||||||
|
})
|
||||||
|
getBookingAnalytics(@Query('days') days?: string) {
|
||||||
|
return this.service.getBookingAnalytics(days ? Number(days) : undefined);
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':passengerId')
|
@Get(':passengerId')
|
||||||
@UseGuards(JwtGuard)
|
@UseGuards(JwtGuard)
|
||||||
@ApiBearerAuth('JWT-auth')
|
@ApiBearerAuth('JWT-auth')
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ import { InjectDataSource } from '@nestjs/typeorm';
|
|||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { PrismaService } from '../../common/prisma.service';
|
import { PrismaService } from '../../common/prisma.service';
|
||||||
|
|
||||||
|
// ── Booking analytics (backoffice dashboard charts) ──────────────────────────
|
||||||
|
const MS_PER_DAY_ANALYTICS = 24 * 60 * 60 * 1000;
|
||||||
|
const ANALYTICS_DEFAULT_DAYS = 30;
|
||||||
|
const ANALYTICS_MAX_DAYS = 365;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DashboardService {
|
export class DashboardService {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -63,6 +68,109 @@ export class DashboardService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Booking analytics for the backoffice dashboard charts — revenue trend, daily
|
||||||
|
* confirmed bookings, status distribution and payment-method split.
|
||||||
|
*
|
||||||
|
* Ported from the client-side computation on `/reports/overall`, which pulled up to
|
||||||
|
* 5000 bookings into the browser and grouped them there. The dashboard is the landing
|
||||||
|
* page and refetches on an interval, so the grouping happens here instead.
|
||||||
|
*
|
||||||
|
* Two asymmetries are inherited from that report on purpose, so the dashboard and the
|
||||||
|
* report show the same figures:
|
||||||
|
* - Revenue and the daily count use CONFIRMED and BOARDED only; the status and
|
||||||
|
* payment-method breakdowns use every booking in range.
|
||||||
|
* - Everything buckets on `createdAt` — when the booking was made, not when the
|
||||||
|
* train departs.
|
||||||
|
*
|
||||||
|
* Revenue here will NOT equal the dashboard's Revenue Breakdown card, which
|
||||||
|
* additionally requires a SUCCEEDED PaymentIntent and prefers the display amounts
|
||||||
|
* (see getBackofficeStats). Different question, deliberately not reconciled: this is
|
||||||
|
* "what was booked", that is "what was collected".
|
||||||
|
*/
|
||||||
|
async getBookingAnalytics(daysRaw?: number) {
|
||||||
|
const days = Math.min(
|
||||||
|
Math.max(Math.trunc(daysRaw || ANALYTICS_DEFAULT_DAYS), 1),
|
||||||
|
ANALYTICS_MAX_DAYS,
|
||||||
|
);
|
||||||
|
const to = new Date();
|
||||||
|
const from = new Date(to.getTime() - days * MS_PER_DAY_ANALYTICS);
|
||||||
|
|
||||||
|
const bookings = await this.prisma.booking.findMany({
|
||||||
|
where: { createdAt: { gte: from, lte: to } },
|
||||||
|
select: {
|
||||||
|
createdAt: true,
|
||||||
|
status: true,
|
||||||
|
totalMinor: true,
|
||||||
|
currency: true,
|
||||||
|
paymentIntent: { select: { method: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const isConfirmed = (status: string) => status === 'CONFIRMED' || status === 'BOARDED';
|
||||||
|
|
||||||
|
// Day buckets keyed on the UTC calendar date, so the axis and the bars derive from
|
||||||
|
// one value and cannot disagree.
|
||||||
|
const byDayMap = new Map<
|
||||||
|
string,
|
||||||
|
{ date: string; bookings: number; revenueByCurrency: Map<string, number> }
|
||||||
|
>();
|
||||||
|
const statusCounts = new Map<string, number>();
|
||||||
|
const methodCounts = new Map<string, number>();
|
||||||
|
|
||||||
|
for (const booking of bookings) {
|
||||||
|
// Status and payment method count every booking in range.
|
||||||
|
const status = booking.status ?? 'UNKNOWN';
|
||||||
|
statusCounts.set(status, (statusCounts.get(status) ?? 0) + 1);
|
||||||
|
|
||||||
|
const method = booking.paymentIntent?.method ?? 'UNKNOWN';
|
||||||
|
methodCounts.set(method, (methodCounts.get(method) ?? 0) + 1);
|
||||||
|
|
||||||
|
// Revenue and the daily count are confirmed travel only.
|
||||||
|
if (!isConfirmed(booking.status)) continue;
|
||||||
|
|
||||||
|
const date = booking.createdAt.toISOString().slice(0, 10);
|
||||||
|
const bucket =
|
||||||
|
byDayMap.get(date) ?? { date, bookings: 0, revenueByCurrency: new Map<string, number>() };
|
||||||
|
bucket.bookings += 1;
|
||||||
|
|
||||||
|
const currency = booking.currency ?? 'ETB';
|
||||||
|
bucket.revenueByCurrency.set(
|
||||||
|
currency,
|
||||||
|
(bucket.revenueByCurrency.get(currency) ?? 0) + (booking.totalMinor ?? 0),
|
||||||
|
);
|
||||||
|
byDayMap.set(date, bucket);
|
||||||
|
}
|
||||||
|
|
||||||
|
const byDay = [...byDayMap.values()]
|
||||||
|
.sort((a, b) => a.date.localeCompare(b.date))
|
||||||
|
.map((bucket) => ({
|
||||||
|
date: bucket.date,
|
||||||
|
bookings: bucket.bookings,
|
||||||
|
revenueByCurrency: [...bucket.revenueByCurrency.entries()].map(
|
||||||
|
([currency, totalMinor]) => ({ currency, totalMinor }),
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const rank = <T extends { count: number }>(rows: T[]) =>
|
||||||
|
rows.sort((a, b) => b.count - a.count);
|
||||||
|
|
||||||
|
return {
|
||||||
|
window: { from, to, days },
|
||||||
|
totals: {
|
||||||
|
bookings: bookings.length,
|
||||||
|
confirmedBookings: bookings.filter((b) => isConfirmed(b.status)).length,
|
||||||
|
},
|
||||||
|
byDay,
|
||||||
|
statusDistribution: rank(
|
||||||
|
[...statusCounts.entries()].map(([status, count]) => ({ status, count })),
|
||||||
|
),
|
||||||
|
paymentMethods: rank(
|
||||||
|
[...methodCounts.entries()].map(([method, count]) => ({ method, count })),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async getHomeDashboard(passengerId: string) {
|
async getHomeDashboard(passengerId: string) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const [passenger, upcomingBooking, wallet, promos, weatherAlerts, stationSignals, savedRoutes] = await Promise.all([
|
const [passenger, upcomingBooking, wallet, promos, weatherAlerts, stationSignals, savedRoutes] = await Promise.all([
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
ScanLine,
|
ScanLine,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { dashboardApi } from "@/lib/api/dashboard";
|
import { dashboardApi } from "@/lib/api/dashboard";
|
||||||
|
import DashboardBookingCharts from "@/components/dashboard/DashboardBookingCharts";
|
||||||
import { apiClient } from "@/lib/api-client";
|
import { apiClient } from "@/lib/api-client";
|
||||||
import { formatCurrency } from "@/lib/utils";
|
import { formatCurrency } from "@/lib/utils";
|
||||||
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from "recharts";
|
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from "recharts";
|
||||||
@@ -322,6 +323,9 @@ function DashboardPageContent() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Booking charts — self-contained; degrades to a single line if its endpoint fails. */}
|
||||||
|
<DashboardBookingCharts />
|
||||||
|
|
||||||
{/* Revenue breakdown */}
|
{/* Revenue breakdown */}
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h2 className="text-sm font-semibold uppercase tracking-widest text-muted-foreground mb-4">
|
<h2 className="text-sm font-semibold uppercase tracking-widest text-muted-foreground mb-4">
|
||||||
|
|||||||
@@ -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