373 lines
14 KiB
TypeScript
373 lines
14 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 {
|
|
ArrowRight,
|
|
Clock,
|
|
Package,
|
|
PackageCheck,
|
|
PackageX,
|
|
Plus,
|
|
TrendingUp,
|
|
Weight,
|
|
} from 'lucide-react';
|
|
import {
|
|
Bar,
|
|
BarChart,
|
|
CartesianGrid,
|
|
Cell,
|
|
Pie,
|
|
PieChart,
|
|
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_COLORS, CHART_UI, 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]);
|
|
|
|
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 numberFormat = new Intl.NumberFormat(locale === 'fr' ? 'fr-FR' : 'en-US');
|
|
|
|
const totalBookings =
|
|
(csvKpis?.totalAccepted ?? 0) + (csvKpis?.totalRejected ?? 0) + (csvKpis?.totalPending ?? 0);
|
|
|
|
const statusDistribution = csvKpis
|
|
? [
|
|
{
|
|
name: t('charts.distribution.accepted'),
|
|
value: csvKpis.totalAccepted,
|
|
color: CHART_COLORS.success,
|
|
},
|
|
{
|
|
name: t('charts.distribution.rejected'),
|
|
value: csvKpis.totalRejected,
|
|
color: CHART_COLORS.danger,
|
|
},
|
|
{
|
|
name: t('charts.distribution.pending'),
|
|
value: csvKpis.totalPending,
|
|
color: CHART_COLORS.warning,
|
|
},
|
|
]
|
|
: [];
|
|
|
|
const weightDataKey = t('charts.weightByCarrier.weight');
|
|
|
|
const carrierWeightData = topCarriers
|
|
? topCarriers.slice(0, 5).map(c => ({
|
|
name: c.carrierName.length > 15 ? c.carrierName.slice(0, 15) + '…' : c.carrierName,
|
|
[weightDataKey]: Math.round(c.totalWeightKG),
|
|
}))
|
|
: [];
|
|
|
|
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(locale === 'fr' ? 'fr-FR' : 'en-US') || '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">
|
|
{/* Indicateurs primaires : les quatre chiffres qui repondent a « ou en
|
|
sommes-nous ». Alignes a gauche pour se comparer en colonne. */}
|
|
<section className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
|
<StatCard
|
|
label={t('performance.totalBookings')}
|
|
value={numberFormat.format(totalBookings)}
|
|
icon={Package}
|
|
loading={csvKpisLoading}
|
|
/>
|
|
<StatCard
|
|
label={t('kpi.accepted')}
|
|
value={numberFormat.format(csvKpis?.totalAccepted ?? 0)}
|
|
hint={t('kpi.thisMonth', { count: csvKpis?.acceptedThisMonth ?? 0 })}
|
|
icon={PackageCheck}
|
|
loading={csvKpisLoading}
|
|
/>
|
|
<StatCard
|
|
label={t('kpi.rejected')}
|
|
value={numberFormat.format(csvKpis?.totalRejected ?? 0)}
|
|
hint={t('kpi.thisMonth', { count: csvKpis?.rejectedThisMonth ?? 0 })}
|
|
icon={PackageX}
|
|
loading={csvKpisLoading}
|
|
/>
|
|
<StatCard
|
|
label={t('kpi.pending')}
|
|
value={numberFormat.format(csvKpis?.totalPending ?? 0)}
|
|
hint={t('kpi.acceptanceRate', { rate: (csvKpis?.acceptanceRate ?? 0).toFixed(1) })}
|
|
icon={Clock}
|
|
loading={csvKpisLoading}
|
|
/>
|
|
</section>
|
|
|
|
{/* Indicateurs secondaires : volumetrie et performance, en bandeau
|
|
compact pour ne pas concurrencer la rangee primaire. */}
|
|
<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-3">
|
|
<CardTitle>{t('charts.distribution.title')}</CardTitle>
|
|
<CardDescription>{t('charts.distribution.description')}</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{csvKpisLoading ? (
|
|
<Skeleton className="h-52 w-full" />
|
|
) : totalBookings === 0 ? (
|
|
<EmptyState size="sm" icon={Package} title={t('topCarriers.empty.title')} />
|
|
) : (
|
|
<ResponsiveContainer width="100%" height={208}>
|
|
<PieChart>
|
|
<Pie
|
|
data={statusDistribution}
|
|
cx="50%"
|
|
cy="50%"
|
|
labelLine={false}
|
|
label={({ name, percent }: any) =>
|
|
`${name} ${((percent || 0) * 100).toFixed(0)}%`
|
|
}
|
|
outerRadius={72}
|
|
innerRadius={44}
|
|
paddingAngle={2}
|
|
dataKey="value"
|
|
>
|
|
{statusDistribution.map(entry => (
|
|
<Cell key={entry.name} fill={entry.color} stroke={CHART_UI.surface} />
|
|
))}
|
|
</Pie>
|
|
<Tooltip {...chartTooltipStyle} />
|
|
</PieChart>
|
|
</ResponsiveContainer>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<CardTitle>{t('charts.weightByCarrier.title')}</CardTitle>
|
|
<CardDescription>{t('charts.weightByCarrier.description')}</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{carriersLoading ? (
|
|
<Skeleton className="h-52 w-full" />
|
|
) : carrierWeightData.length === 0 ? (
|
|
<EmptyState size="sm" icon={Weight} title={t('topCarriers.empty.title')} />
|
|
) : (
|
|
<ResponsiveContainer width="100%" height={208}>
|
|
<BarChart data={carrierWeightData} margin={{ top: 4, right: 4, bottom: 0, left: 0 }}>
|
|
<CartesianGrid strokeDasharray="3 3" stroke={CHART_UI.grid} vertical={false} />
|
|
<XAxis dataKey="name" interval={0} height={44} {...chartAxisProps} />
|
|
<YAxis width={48} {...chartAxisProps} />
|
|
<Tooltip {...chartTooltipStyle} cursor={{ fill: CHART_UI.grid }} />
|
|
<Bar dataKey={weightDataKey} fill={CHART_COLORS.primary} radius={[4, 4, 0, 0]} />
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</section>
|
|
|
|
<Card>
|
|
<CardHeader className="flex-row items-start justify-between gap-3 pb-3">
|
|
<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>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
/** 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>
|
|
);
|
|
}
|