xpeditis2.0/apps/frontend/app/[locale]/dashboard/page.tsx
2026-08-25 16:33:08 +02:00

632 lines
24 KiB
TypeScript

'use client';
import { useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { dashboardApi } from '@/lib/api';
import { Link, useRouter } from '@/i18n/navigation';
import { useTranslations, useLocale } from 'next-intl';
import {
AlertTriangle,
ArrowRight,
Clock,
Container as ContainerIcon,
Euro,
Package,
Plus,
Ship,
TrendingUp,
Weight,
} from 'lucide-react';
import {
Area,
AreaChart,
Bar,
BarChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { PageHeader } from '@/components/ui/PageHeader';
import { EmptyState, Skeleton } from '@/components/ui/states';
import { StatCard } from '@/components/data/stat-card';
import { useSubscription } from '@/lib/context/subscription-context';
import ExportButton from '@/components/ExportButton';
import {
CHART_UI,
MARK,
SERIES,
STATUS,
chartAxisProps,
chartTooltipStyle,
} from '@/lib/chart-colors';
export default function DashboardPage() {
const router = useRouter();
const { hasFeature, loading: subLoading } = useSubscription();
const t = useTranslations('dashboard.home');
const locale = useLocale();
useEffect(() => {
if (!subLoading && !hasFeature('dashboard')) {
router.replace('/dashboard/bookings');
}
}, [subLoading, hasFeature, router]);
// Indicateurs de tete : ils portent une variation mensuelle, donc une
// tendance. Cet endpoint existait deja cote backend sans etre consomme.
const { data: kpis, isLoading: kpisLoading } = useQuery({
queryKey: ['dashboard', 'kpis'],
queryFn: () => dashboardApi.getKPIs(),
});
const { data: volume, isLoading: volumeLoading } = useQuery({
queryKey: ['dashboard', 'bookings-chart'],
queryFn: () => dashboardApi.getBookingsChart(),
});
const { data: lanes, isLoading: lanesLoading } = useQuery({
queryKey: ['dashboard', 'top-trade-lanes'],
queryFn: () => dashboardApi.getTopTradeLanes(),
});
const { data: alerts } = useQuery({
queryKey: ['dashboard', 'alerts'],
queryFn: () => dashboardApi.getAlerts(),
});
const { data: csvKpis, isLoading: csvKpisLoading } = useQuery({
queryKey: ['dashboard', 'csv-booking-kpis'],
queryFn: () => dashboardApi.getCsvBookingKPIs(),
});
const { data: topCarriers, isLoading: carriersLoading } = useQuery({
queryKey: ['dashboard', 'top-carriers'],
queryFn: () => dashboardApi.getTopCarriers(),
});
const intl = locale === 'fr' ? 'fr-FR' : 'en-US';
const numberFormat = new Intl.NumberFormat(intl);
const currencyFormat = new Intl.NumberFormat(intl, {
style: 'currency',
currency: 'EUR',
maximumFractionDigits: 0,
});
const cbmFormat = new Intl.NumberFormat(intl, { maximumFractionDigits: 1 });
/**
* N'affiche une variation que lorsqu'elle compare quelque chose : deux
* periodes vides donnent 0 %, un badge vert qui ne veut rien dire.
*/
const hasTrend = (value: number | undefined, change: number | undefined) =>
Boolean(kpis) && ((value ?? 0) !== 0 || (change ?? 0) !== 0);
const trendOf = (value: number | undefined, change: number | undefined, positiveIsGood = true) =>
hasTrend(value, change)
? { value: change ?? 0, label: t('kpi.vsLastMonth'), positiveIsGood }
: undefined;
/**
* « vs mois dernier » accompagne le pourcentage ; seul, il ne dit rien.
* Il disparait donc avec lui.
*/
const hintOf = (value: number | undefined, change: number | undefined) =>
hasTrend(value, change) ? t('kpi.vsLastMonth') : undefined;
// Le backend renvoie des cles ISO `YYYY-MM` : le nom du mois se compose ici,
// dans la langue de l'utilisateur.
const monthFormat = new Intl.DateTimeFormat(intl, { month: 'short' });
const formatMonth = (iso: string) => {
const [year, month] = iso.split('-').map(Number);
if (!year || !month) return iso;
return monthFormat.format(new Date(year, month - 1, 1));
};
const volumeSeries = t('charts.volume.series');
const volumeData = (volume?.labels ?? []).map((label, i) => ({
label: formatMonth(label),
[volumeSeries]: volume?.data?.[i] ?? 0,
}));
const hasVolume = volumeData.some(d => (d[volumeSeries] as number) > 0);
const laneSeries = t('charts.lanes.series');
const laneData = (lanes ?? []).slice(0, 5).map(lane => ({
route: lane.route,
[laneSeries]: lane.bookingCount,
}));
// Etats des demandes : trois parts d'un tout, avec le taux d'acceptation en
// chiffre de tete. Un anneau obligeait a comparer des angles ; une barre
// empilee legendee se lit d'un coup.
const accepted = csvKpis?.totalAccepted ?? 0;
const rejected = csvKpis?.totalRejected ?? 0;
const pending = csvKpis?.totalPending ?? 0;
const totalRequests = accepted + rejected + pending;
const statusParts = [
{ key: 'accepted', label: t('charts.distribution.accepted'), value: accepted, color: STATUS.good },
{ key: 'pending', label: t('charts.distribution.pending'), value: pending, color: STATUS.warning },
{ key: 'rejected', label: t('charts.distribution.rejected'), value: rejected, color: STATUS.critical },
];
const openAlerts = alerts ?? [];
return (
<>
<PageHeader
title={t('title')}
description={t('subtitle')}
actions={
<>
<ExportButton
data={topCarriers || []}
filename={t('exportFilename')}
columns={[
{ key: 'carrierName', label: t('export.carrier') },
{ key: 'totalBookings', label: t('export.totalBookings') },
{ key: 'acceptedBookings', label: t('export.accepted') },
{ key: 'rejectedBookings', label: t('export.rejected') },
{
key: 'totalWeightKG',
label: t('export.totalWeight'),
format: v => v?.toLocaleString(intl) || '0',
},
{
key: 'totalVolumeCBM',
label: t('export.totalVolume'),
format: v => v?.toFixed(2) || '0',
},
{
key: 'acceptanceRate',
label: t('export.acceptanceRate'),
format: v => v?.toFixed(1) || '0',
},
{
key: 'avgPriceUSD',
label: t('export.avgPrice'),
format: v => v?.toFixed(2) || '0',
},
]}
/>
<Button asChild>
<Link href="/dashboard/search-advanced">
<Plus />
{t('newBooking')}
</Link>
</Button>
</>
}
/>
<div className="flex flex-col gap-6">
{/* Ce qui demande une action passe avant ce qui informe. */}
{openAlerts.length > 0 && <AlertsStrip alerts={openAlerts} />}
{/* Indicateurs de tete : chacun porte sa variation mensuelle. */}
<section className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard
label={t('kpi.bookingsThisMonth')}
value={numberFormat.format(kpis?.bookingsThisMonth ?? 0)}
trend={trendOf(kpis?.bookingsThisMonth, kpis?.bookingsThisMonthChange)}
hint={hintOf(kpis?.bookingsThisMonth, kpis?.bookingsThisMonthChange)}
icon={Package}
loading={kpisLoading}
/>
<StatCard
label={t('kpi.volume')}
value={`${cbmFormat.format(kpis?.volumeCBM ?? 0)} CBM`}
trend={trendOf(kpis?.volumeCBM, kpis?.volumeCBMChange)}
hint={hintOf(kpis?.volumeCBM, kpis?.volumeCBMChange)}
icon={ContainerIcon}
loading={kpisLoading}
/>
<StatCard
label={t('kpi.revenue')}
value={currencyFormat.format(kpis?.estimatedRevenue ?? 0)}
trend={trendOf(kpis?.estimatedRevenue, kpis?.estimatedRevenueChange)}
hint={hintOf(kpis?.estimatedRevenue, kpis?.estimatedRevenueChange)}
icon={Euro}
loading={kpisLoading}
/>
<StatCard
label={t('kpi.pendingConfirmations')}
value={numberFormat.format(kpis?.pendingConfirmations ?? 0)}
// Une hausse des dossiers en attente n'est pas une bonne nouvelle :
// la couleur de la variation doit s'inverser.
trend={trendOf(kpis?.pendingConfirmations, kpis?.pendingConfirmationsChange, false)}
hint={hintOf(kpis?.pendingConfirmations, kpis?.pendingConfirmationsChange)}
icon={Clock}
loading={kpisLoading}
/>
</section>
{/* Evolution a gauche (la question « ou va-t-on »), etat des demandes a
droite (la question « ou en est-on »). */}
<section className="grid gap-4 lg:grid-cols-3">
<Card className="lg:col-span-2">
<CardHeader className="pb-2">
<CardTitle>{t('charts.volume.title')}</CardTitle>
<CardDescription>{t('charts.volume.description')}</CardDescription>
</CardHeader>
<CardContent>
{volumeLoading ? (
<Skeleton className="h-56 w-full" />
) : !hasVolume ? (
<EmptyState size="sm" icon={TrendingUp} title={t('charts.volume.empty')} />
) : (
<ResponsiveContainer width="100%" height={224}>
<AreaChart data={volumeData} margin={{ top: 8, right: 8, bottom: 0, left: 0 }}>
<defs>
<linearGradient id="volumeFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={SERIES.primary} stopOpacity={0.18} />
<stop offset="100%" stopColor={SERIES.primary} stopOpacity={0} />
</linearGradient>
</defs>
{/* Seules les horizontales aident a lire une hauteur. */}
<CartesianGrid strokeDasharray="3 3" stroke={CHART_UI.grid} vertical={false} />
<XAxis dataKey="label" {...chartAxisProps} />
<YAxis width={40} allowDecimals={false} {...chartAxisProps} />
<Tooltip
{...chartTooltipStyle}
cursor={{ stroke: CHART_UI.border, strokeWidth: 1 }}
/>
<Area
type="monotone"
dataKey={volumeSeries}
stroke={SERIES.primary}
strokeWidth={MARK.strokeWidth}
fill="url(#volumeFill)"
dot={false}
activeDot={{
r: MARK.activeDotRadius,
fill: SERIES.primary,
stroke: CHART_UI.surface,
strokeWidth: 2,
}}
/>
</AreaChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle>{t('charts.status.title')}</CardTitle>
<CardDescription>{t('charts.status.description')}</CardDescription>
</CardHeader>
<CardContent>
{csvKpisLoading ? (
<Skeleton className="h-56 w-full" />
) : totalRequests === 0 ? (
<EmptyState size="sm" icon={Package} title={t('topCarriers.empty.title')} />
) : (
<div className="flex h-56 flex-col justify-center">
<p className="text-label uppercase text-neutral-500">
{t('charts.status.rateLabel')}
</p>
<p className="mt-1 font-heading text-4xl font-extrabold tabular-nums text-brand-navy">
{(csvKpis?.acceptanceRate ?? 0).toFixed(1)}&nbsp;%
</p>
{/* Barre empilee. L'ecart de 2 px entre segments evite que
deux etats se touchent et paraissent continus. */}
<div className="mt-5 flex h-2.5 gap-0.5 overflow-hidden rounded-full">
{statusParts
.filter(p => p.value > 0)
.map(part => (
<div
key={part.key}
className="h-full first:rounded-l-full last:rounded-r-full"
style={{
width: `${(part.value / totalRequests) * 100}%`,
backgroundColor: part.color,
}}
/>
))}
</div>
{/* Legende chiffree : la couleur ne porte jamais seule
l'information. */}
<ul className="mt-5 space-y-2.5">
{statusParts.map(part => (
<li key={part.key} className="flex items-center gap-2.5">
<span
aria-hidden="true"
className="size-2.5 shrink-0 rounded-sm"
style={{ backgroundColor: part.color }}
/>
<span className="flex-1 text-body-sm text-neutral-600">{part.label}</span>
<span className="font-heading text-body-sm font-semibold tabular-nums text-brand-navy">
{numberFormat.format(part.value)}
</span>
</li>
))}
</ul>
</div>
)}
</CardContent>
</Card>
</section>
{/* Volumetrie, en bandeau compact : utile mais sans tendance, donc sans
le poids visuel d'une tuile. */}
<section className="grid gap-px overflow-hidden rounded-lg border border-border bg-border sm:grid-cols-3">
<SecondaryStat
label={t('performance.acceptanceRate')}
value={csvKpisLoading ? null : `${(csvKpis?.acceptanceRate ?? 0).toFixed(1)} %`}
icon={TrendingUp}
/>
<SecondaryStat
label={t('kpi.totalWeight')}
value={
csvKpisLoading
? null
: `${numberFormat.format(csvKpis?.totalWeightAcceptedKG ?? 0)} kg`
}
icon={Weight}
/>
<SecondaryStat
label={t('performance.totalVolume')}
value={
csvKpisLoading ? null : `${(csvKpis?.totalVolumeAcceptedCBM ?? 0).toFixed(1)} CBM`
}
icon={Package}
/>
</section>
<section className="grid gap-4 lg:grid-cols-2">
<Card>
<CardHeader className="pb-2">
<CardTitle>{t('charts.lanes.title')}</CardTitle>
<CardDescription>{t('charts.lanes.description')}</CardDescription>
</CardHeader>
<CardContent>
{lanesLoading ? (
<Skeleton className="h-56 w-full" />
) : laneData.length === 0 ? (
<EmptyState size="sm" icon={Ship} title={t('charts.lanes.empty')} />
) : (
// Barres horizontales : les libelles de route sont longs et se
// lisent a plat, sans rotation ni troncature.
<ResponsiveContainer width="100%" height={224}>
<BarChart
data={laneData}
layout="vertical"
margin={{ top: 4, right: 16, bottom: 0, left: 0 }}
barCategoryGap="28%"
>
<CartesianGrid strokeDasharray="3 3" stroke={CHART_UI.grid} horizontal={false} />
<XAxis type="number" allowDecimals={false} {...chartAxisProps} />
<YAxis
type="category"
dataKey="route"
width={110}
{...chartAxisProps}
tick={{ fontSize: 11, fill: CHART_UI.axis }}
/>
<Tooltip {...chartTooltipStyle} cursor={{ fill: CHART_UI.grid }} />
<Bar
dataKey={laneSeries}
fill={SERIES.primary}
radius={[0, MARK.barRadius, MARK.barRadius, 0]}
maxBarSize={18}
/>
</BarChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex-row items-start justify-between gap-3 pb-2">
<div className="min-w-0">
<CardTitle>{t('topCarriers.title')}</CardTitle>
<CardDescription>{t('topCarriers.description')}</CardDescription>
</div>
<Button asChild variant="ghost" size="sm" className="shrink-0">
<Link href="/dashboard/bookings">
{t('topCarriers.viewAll')}
<ArrowRight />
</Link>
</Button>
</CardHeader>
<CardContent className="p-0">
{carriersLoading ? (
<div className="divide-y divide-border">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="px-5 py-3">
<Skeleton className="h-10 w-full" />
</div>
))}
</div>
) : topCarriers && topCarriers.length > 0 ? (
<ul className="divide-y divide-border">
{topCarriers.slice(0, 5).map((carrier, index) => (
<li
key={carrier.carrierName}
className="flex items-center gap-3 px-5 py-3 transition-colors hover:bg-neutral-50"
>
<span className="flex size-6 shrink-0 items-center justify-center rounded-md bg-neutral-100 text-body-xs font-semibold text-neutral-600">
{index + 1}
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-body-sm font-medium text-brand-navy">
{carrier.carrierName}
</p>
<p className="mt-0.5 truncate text-body-xs text-neutral-500">
{t('topCarriers.bookingsCount', { count: carrier.totalBookings })}
{' · '}
{numberFormat.format(carrier.totalWeightKG)} kg
</p>
</div>
<div className="flex shrink-0 items-center gap-1.5">
<Badge variant="success">{carrier.acceptedBookings}</Badge>
{carrier.rejectedBookings > 0 && (
<Badge variant="danger">{carrier.rejectedBookings}</Badge>
)}
</div>
<div className="hidden w-24 shrink-0 text-right sm:block">
<p className="text-body-sm font-medium text-brand-navy">
{carrier.acceptanceRate.toFixed(0)} %
</p>
<p className="text-body-xs text-neutral-500">
${carrier.avgPriceUSD.toFixed(0)}
</p>
</div>
</li>
))}
</ul>
) : (
<EmptyState
icon={Package}
title={t('topCarriers.empty.title')}
description={t('topCarriers.empty.description')}
action={
<Button asChild size="sm">
<Link href="/dashboard/search-advanced">
<Plus />
{t('topCarriers.empty.cta')}
</Link>
</Button>
}
/>
)}
</CardContent>
</Card>
</section>
</div>
</>
);
}
/**
* Destination d'une alerte.
*
* `/dashboard/bookings/{id}` n'existe pas vraiment : la page interroge
* `/api/v1/bookings/:id`, donc la table `bookings`, absente du schema. Elle
* repondait systematiquement en erreur, et ce bandeau etait le seul endroit de
* l'application a y renvoyer.
*
* Les deux destinations ci-dessous existent et fonctionnent :
* - une alerte de paiement mene la ou l'utilisateur peut agir, l'ecran de
* reglement de la commission ;
* - les autres mene a la liste des reservations filtree sur cette seule ligne
* (le filtre `quote` compare aussi l'identifiant), faute de vue de detail :
* la liste ouvre ses reservations dans un panneau, pas dans une route.
*/
function alertHref(alert: {
type?: string;
bookingId?: string;
bookingNumber?: string;
}): string {
if (!alert.bookingId) return '/dashboard/bookings';
if (alert.type === 'payment') return `/dashboard/booking/${alert.bookingId}/pay`;
// On filtre sur le numero plutot que sur l'identifiant : c'est ce que le
// bouton affiche, donc ce que l'utilisateur retrouve dans la barre de
// recherche en arrivant.
const term = alert.bookingNumber || alert.bookingId;
return `/dashboard/bookings?by=quote&q=${encodeURIComponent(term)}`;
}
/** Bandeau des points a traiter, en tete de tableau de bord. */
function AlertsStrip({
alerts,
}: {
alerts: Array<{
id: string;
type: 'delay' | 'confirmation' | 'document' | 'payment' | 'info';
severity: 'critical' | 'high' | 'medium' | 'low';
titleKey: string;
messageKey: string;
messageParams?: Record<string, string | number>;
bookingId?: string;
bookingNumber?: string;
}>;
}) {
const t = useTranslations('dashboard.home.alerts');
// Le plus urgent d'abord : trois alertes suffisent en tete de page, le reste
// se consulte dans les listes concernees.
const order = { critical: 0, high: 1, medium: 2, low: 3 } as const;
const sorted = [...alerts].sort((a, b) => order[a.severity] - order[b.severity]).slice(0, 3);
const tone: Record<string, string> = {
critical: 'text-destructive',
high: 'text-destructive',
medium: 'text-amber-600',
low: 'text-neutral-500',
};
return (
<section className="overflow-hidden rounded-lg border border-border bg-white">
<div className="flex items-center gap-2 border-b border-border px-5 py-3">
<AlertTriangle className="size-4 shrink-0 text-amber-500" aria-hidden="true" />
<p className="text-body-sm font-semibold text-brand-navy">{t('title')}</p>
<p className="truncate text-body-xs text-neutral-500">
{t('description', { count: alerts.length })}
</p>
</div>
<ul className="divide-y divide-border">
{sorted.map(alert => (
<li key={alert.id} className="flex items-center gap-3 px-5 py-3">
{/* Severite : jamais la couleur seule — un libelle l'accompagne. */}
<span
className={`w-16 shrink-0 text-label uppercase ${tone[alert.severity] ?? 'text-neutral-500'}`}
>
{t(`severity.${alert.severity}` as any)}
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-body-sm font-medium text-brand-navy">
{t(`items.${alert.titleKey}` as any)}
</p>
<p className="truncate text-body-xs text-neutral-500">
{t(`items.${alert.messageKey}` as any, alert.messageParams ?? {})}
</p>
</div>
{alert.bookingId && (
<Button asChild variant="outline" size="sm" className="shrink-0">
<Link href={alertHref(alert)}>{alert.bookingNumber ?? <ArrowRight />}</Link>
</Button>
)}
</li>
))}
</ul>
</section>
);
}
/** Indicateur secondaire, en bandeau, sans le poids visuel d'une carte. */
function SecondaryStat({
label,
value,
icon: Icon,
}: {
label: string;
value: string | null;
icon: React.ComponentType<{ className?: string }>;
}) {
return (
<div className="flex items-center gap-3 bg-white px-5 py-4">
<Icon className="size-4 shrink-0 text-neutral-300" aria-hidden="true" />
<div className="min-w-0">
<p className="text-label uppercase text-neutral-500">{label}</p>
{value === null ? (
<Skeleton className="mt-1 h-5 w-20" />
) : (
<p className="mt-0.5 font-heading text-h5 text-brand-navy">{value}</p>
)}
</div>
</div>
);
}