'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 ( <> 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', }, ]} /> } />
{/* Ce qui demande une action passe avant ce qui informe. */} {openAlerts.length > 0 && } {/* Indicateurs de tete : chacun porte sa variation mensuelle. */}
{/* Evolution a gauche (la question « ou va-t-on »), etat des demandes a droite (la question « ou en est-on »). */}
{t('charts.volume.title')} {t('charts.volume.description')} {volumeLoading ? ( ) : !hasVolume ? ( ) : ( {/* Seules les horizontales aident a lire une hauteur. */} )} {t('charts.status.title')} {t('charts.status.description')} {csvKpisLoading ? ( ) : totalRequests === 0 ? ( ) : (

{t('charts.status.rateLabel')}

{(csvKpis?.acceptanceRate ?? 0).toFixed(1)} %

{/* Barre empilee. L'ecart de 2 px entre segments evite que deux etats se touchent et paraissent continus. */}
{statusParts .filter(p => p.value > 0) .map(part => (
))}
{/* Legende chiffree : la couleur ne porte jamais seule l'information. */}
    {statusParts.map(part => (
  • ))}
)}
{/* Volumetrie, en bandeau compact : utile mais sans tendance, donc sans le poids visuel d'une tuile. */}
{t('charts.lanes.title')} {t('charts.lanes.description')} {lanesLoading ? ( ) : laneData.length === 0 ? ( ) : ( // Barres horizontales : les libelles de route sont longs et se // lisent a plat, sans rotation ni troncature. )}
{t('topCarriers.title')} {t('topCarriers.description')}
{carriersLoading ? (
{Array.from({ length: 3 }).map((_, i) => (
))}
) : topCarriers && topCarriers.length > 0 ? (
    {topCarriers.slice(0, 5).map((carrier, index) => (
  • {index + 1}

    {carrier.carrierName}

    {t('topCarriers.bookingsCount', { count: carrier.totalBookings })} {' · '} {numberFormat.format(carrier.totalWeightKG)} kg

    {carrier.acceptedBookings} {carrier.rejectedBookings > 0 && ( {carrier.rejectedBookings} )}

    {carrier.acceptanceRate.toFixed(0)} %

    ${carrier.avgPriceUSD.toFixed(0)}

  • ))}
) : ( {t('topCarriers.empty.cta')} } /> )}
); } /** * 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; 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 = { critical: 'text-destructive', high: 'text-destructive', medium: 'text-amber-600', low: 'text-neutral-500', }; return (
    {sorted.map(alert => (
  • {/* Severite : jamais la couleur seule — un libelle l'accompagne. */} {t(`severity.${alert.severity}` as any)}

    {t(`items.${alert.titleKey}` as any)}

    {t(`items.${alert.messageKey}` as any, alert.messageParams ?? {})}

    {alert.bookingId && ( )}
  • ))}
); } /** 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 (
); }