All checks were successful
Dev CI / Backend — Lint (push) Successful in 10m23s
Dev CI / Backend — Unit Tests (push) Successful in 10m17s
Dev CI / Frontend — Lint & Type-check (push) Successful in 11m3s
Dev CI / Frontend — Unit Tests (push) Successful in 10m33s
Dev CI / Notify Failure (push) Has been skipped
435 lines
19 KiB
TypeScript
435 lines
19 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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import {
|
|
Package,
|
|
PackageCheck,
|
|
PackageX,
|
|
Clock,
|
|
Weight,
|
|
TrendingUp,
|
|
Plus,
|
|
ArrowRight,
|
|
} from 'lucide-react';
|
|
import { useSubscription } from '@/lib/context/subscription-context';
|
|
import ExportButton from '@/components/ExportButton';
|
|
import {
|
|
PieChart,
|
|
Pie,
|
|
Cell,
|
|
BarChart,
|
|
Bar,
|
|
XAxis,
|
|
YAxis,
|
|
CartesianGrid,
|
|
Tooltip,
|
|
ResponsiveContainer,
|
|
} from 'recharts';
|
|
|
|
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 statusDistribution = csvKpis
|
|
? [
|
|
{ name: t('charts.distribution.accepted'), value: csvKpis.totalAccepted, color: '#10b981' },
|
|
{ name: t('charts.distribution.rejected'), value: csvKpis.totalRejected, color: '#ef4444' },
|
|
{ name: t('charts.distribution.pending'), value: csvKpis.totalPending, color: '#f59e0b' },
|
|
]
|
|
: [];
|
|
|
|
const carrierWeightData = topCarriers
|
|
? topCarriers.slice(0, 5).map(c => ({
|
|
name: c.carrierName.length > 15 ? c.carrierName.substring(0, 15) + '...' : c.carrierName,
|
|
[t('charts.weightByCarrier.weight')]: Math.round(c.totalWeightKG),
|
|
}))
|
|
: [];
|
|
|
|
const weightDataKey = t('charts.weightByCarrier.weight');
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gray-50">
|
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-4 sm:py-8 space-y-4 sm:space-y-6">
|
|
<div className="flex items-center justify-between pb-3 sm:pb-4 border-b border-gray-200">
|
|
<div>
|
|
<h1 className="text-xl sm:text-3xl font-semibold text-gray-900">{t('title')}</h1>
|
|
<p className="text-gray-600 mt-0.5 sm:mt-1 text-xs sm:text-sm">
|
|
{t('subtitle')}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex items-center space-x-2 sm:space-x-3">
|
|
<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' },
|
|
]}
|
|
/>
|
|
<Link href="/dashboard/search-advanced">
|
|
<Button className="bg-blue-600 hover:bg-blue-700 text-white gap-2 shadow-lg font-semibold px-3 sm:px-6 py-2 sm:py-5 text-sm sm:text-base">
|
|
<Plus className="h-4 w-4 sm:h-5 sm:w-5 flex-shrink-0" />
|
|
<span className="hidden sm:inline">{t('newBooking')}</span>
|
|
</Button>
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
|
<Card className="border border-gray-200 shadow-sm hover:shadow-md transition-shadow bg-white">
|
|
<CardContent className="p-4">
|
|
<div className="flex flex-col items-center text-center">
|
|
<div className="h-10 w-10 rounded-lg bg-green-100 flex items-center justify-center mb-2">
|
|
<PackageCheck className="h-5 w-5 text-green-600" />
|
|
</div>
|
|
<p className="text-xs font-medium text-gray-600 mb-1">{t('kpi.accepted')}</p>
|
|
{csvKpisLoading ? (
|
|
<div className="h-8 w-16 bg-gray-100 animate-pulse rounded" />
|
|
) : (
|
|
<>
|
|
<p className="text-2xl font-bold text-gray-900">
|
|
{csvKpis?.totalAccepted || 0}
|
|
</p>
|
|
<p className="text-xs text-gray-500 mt-1">
|
|
{t('kpi.thisMonth', { count: csvKpis?.acceptedThisMonth || 0 })}
|
|
</p>
|
|
</>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="border border-gray-200 shadow-sm hover:shadow-md transition-shadow bg-white">
|
|
<CardContent className="p-4">
|
|
<div className="flex flex-col items-center text-center">
|
|
<div className="h-10 w-10 rounded-lg bg-red-100 flex items-center justify-center mb-2">
|
|
<PackageX className="h-5 w-5 text-red-600" />
|
|
</div>
|
|
<p className="text-xs font-medium text-gray-600 mb-1">{t('kpi.rejected')}</p>
|
|
{csvKpisLoading ? (
|
|
<div className="h-8 w-16 bg-gray-100 animate-pulse rounded" />
|
|
) : (
|
|
<>
|
|
<p className="text-2xl font-bold text-gray-900">
|
|
{csvKpis?.totalRejected || 0}
|
|
</p>
|
|
<p className="text-xs text-gray-500 mt-1">
|
|
{t('kpi.thisMonth', { count: csvKpis?.rejectedThisMonth || 0 })}
|
|
</p>
|
|
</>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="border border-gray-200 shadow-sm hover:shadow-md transition-shadow bg-white">
|
|
<CardContent className="p-4">
|
|
<div className="flex flex-col items-center text-center">
|
|
<div className="h-10 w-10 rounded-lg bg-amber-100 flex items-center justify-center mb-2">
|
|
<Clock className="h-5 w-5 text-amber-600" />
|
|
</div>
|
|
<p className="text-xs font-medium text-gray-600 mb-1">{t('kpi.pending')}</p>
|
|
{csvKpisLoading ? (
|
|
<div className="h-8 w-16 bg-gray-100 animate-pulse rounded" />
|
|
) : (
|
|
<>
|
|
<p className="text-2xl font-bold text-gray-900">
|
|
{csvKpis?.totalPending || 0}
|
|
</p>
|
|
<p className="text-xs text-gray-500 mt-1">
|
|
{t('kpi.acceptanceRate', { rate: (csvKpis?.acceptanceRate ?? 0).toFixed(1) })}
|
|
</p>
|
|
</>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="border border-gray-200 shadow-sm hover:shadow-md transition-shadow bg-white">
|
|
<CardContent className="p-4">
|
|
<div className="flex flex-col items-center text-center">
|
|
<div className="h-10 w-10 rounded-lg bg-blue-100 flex items-center justify-center mb-2">
|
|
<Weight className="h-5 w-5 text-blue-600" />
|
|
</div>
|
|
<p className="text-xs font-medium text-gray-600 mb-1">{t('kpi.totalWeight')}</p>
|
|
{csvKpisLoading ? (
|
|
<div className="h-8 w-16 bg-gray-100 animate-pulse rounded" />
|
|
) : (
|
|
<>
|
|
<p className="text-2xl font-bold text-gray-900">
|
|
{numberFormat.format(csvKpis?.totalWeightAcceptedKG || 0)}
|
|
</p>
|
|
<p className="text-xs text-gray-500 mt-1">
|
|
KG • {(csvKpis?.totalVolumeAcceptedCBM || 0).toFixed(1)} CBM
|
|
</p>
|
|
</>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<Card className="border border-gray-200 shadow-sm bg-white">
|
|
<CardHeader className="pb-4 border-b border-gray-100">
|
|
<CardTitle className="text-base font-semibold text-gray-900">
|
|
{t('charts.distribution.title')}
|
|
</CardTitle>
|
|
<CardDescription className="text-xs text-gray-600">
|
|
{t('charts.distribution.description')}
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="pt-4">
|
|
{csvKpisLoading ? (
|
|
<div className="h-48 bg-gray-50 animate-pulse rounded" />
|
|
) : (
|
|
<ResponsiveContainer width="100%" height={200}>
|
|
<PieChart>
|
|
<Pie
|
|
data={statusDistribution}
|
|
cx="50%"
|
|
cy="50%"
|
|
labelLine={false}
|
|
label={({ name, percent }: any) =>
|
|
`${name} ${((percent || 0) * 100).toFixed(0)}%`
|
|
}
|
|
outerRadius={70}
|
|
fill="#8884d8"
|
|
dataKey="value"
|
|
>
|
|
{statusDistribution.map((entry, index) => (
|
|
<Cell key={`cell-${index}`} fill={entry.color} />
|
|
))}
|
|
</Pie>
|
|
<Tooltip />
|
|
</PieChart>
|
|
</ResponsiveContainer>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="border border-gray-200 shadow-sm bg-white">
|
|
<CardHeader className="pb-4 border-b border-gray-100">
|
|
<CardTitle className="text-base font-semibold text-gray-900">
|
|
{t('charts.weightByCarrier.title')}
|
|
</CardTitle>
|
|
<CardDescription className="text-xs text-gray-600">
|
|
{t('charts.weightByCarrier.description')}
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="pt-4">
|
|
{carriersLoading ? (
|
|
<div className="h-48 bg-gray-50 animate-pulse rounded" />
|
|
) : (
|
|
<ResponsiveContainer width="100%" height={200}>
|
|
<BarChart data={carrierWeightData}>
|
|
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
|
|
<XAxis
|
|
dataKey="name"
|
|
tick={{ fontSize: 11 }}
|
|
angle={-15}
|
|
textAnchor="end"
|
|
height={50}
|
|
/>
|
|
<YAxis tick={{ fontSize: 11 }} />
|
|
<Tooltip />
|
|
<Bar dataKey={weightDataKey} fill="#3b82f6" radius={[4, 4, 0, 0]} />
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
<div className="grid gap-4 md:grid-cols-3">
|
|
<Card className="border border-gray-200 shadow-sm bg-white">
|
|
<CardContent className="p-4">
|
|
<div className="flex flex-col items-center text-center">
|
|
<div className="h-10 w-10 rounded-lg bg-green-100 flex items-center justify-center mb-2">
|
|
<TrendingUp className="h-5 w-5 text-green-600" />
|
|
</div>
|
|
<p className="text-xs font-medium text-gray-600 mb-1">{t('performance.acceptanceRate')}</p>
|
|
<p className="text-2xl font-bold text-gray-900">
|
|
{csvKpisLoading ? '--' : `${(csvKpis?.acceptanceRate ?? 0).toFixed(1)}%`}
|
|
</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="border border-gray-200 shadow-sm bg-white">
|
|
<CardContent className="p-4">
|
|
<div className="flex flex-col items-center text-center">
|
|
<div className="h-10 w-10 rounded-lg bg-blue-100 flex items-center justify-center mb-2">
|
|
<Package className="h-5 w-5 text-blue-600" />
|
|
</div>
|
|
<p className="text-xs font-medium text-gray-600 mb-1">{t('performance.totalBookings')}</p>
|
|
<p className="text-2xl font-bold text-gray-900">
|
|
{csvKpisLoading
|
|
? '--'
|
|
: (csvKpis?.totalAccepted || 0) +
|
|
(csvKpis?.totalRejected || 0) +
|
|
(csvKpis?.totalPending || 0)}
|
|
</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="border border-gray-200 shadow-sm bg-white">
|
|
<CardContent className="p-4">
|
|
<div className="flex flex-col items-center text-center">
|
|
<div className="h-10 w-10 rounded-lg bg-purple-100 flex items-center justify-center mb-2">
|
|
<Weight className="h-5 w-5 text-purple-600" />
|
|
</div>
|
|
<p className="text-xs font-medium text-gray-600 mb-1">{t('performance.totalVolume')}</p>
|
|
<p className="text-2xl font-bold text-gray-900">
|
|
{csvKpisLoading ? '--' : `${(csvKpis?.totalVolumeAcceptedCBM || 0).toFixed(1)}`}
|
|
<span className="text-sm font-normal text-gray-500 ml-1">CBM</span>
|
|
</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
<Card className="border border-gray-200 shadow-sm bg-white">
|
|
<CardHeader className="pb-3 border-b border-gray-100">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<CardTitle className="text-base font-semibold text-gray-900">
|
|
{t('topCarriers.title')}
|
|
</CardTitle>
|
|
<CardDescription className="text-xs text-gray-600 mt-1">
|
|
{t('topCarriers.description')}
|
|
</CardDescription>
|
|
</div>
|
|
<Link href="/dashboard/bookings">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="gap-2 text-gray-600 hover:text-gray-900 text-xs"
|
|
>
|
|
{t('topCarriers.viewAll')}
|
|
<ArrowRight className="h-3 w-3" />
|
|
</Button>
|
|
</Link>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="p-0">
|
|
{carriersLoading ? (
|
|
<div className="divide-y divide-gray-100">
|
|
{Array.from({ length: 3 }).map((_, i) => (
|
|
<div key={i} className="px-4 py-3">
|
|
<div className="h-12 bg-gray-50 animate-pulse rounded" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : topCarriers && topCarriers.length > 0 ? (
|
|
<div className="divide-y divide-gray-100">
|
|
{topCarriers.slice(0, 5).map((carrier, index) => (
|
|
<div
|
|
key={carrier.carrierName}
|
|
className="px-4 py-3 hover:bg-gray-50 transition-colors"
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3 flex-1">
|
|
<div className="flex items-center justify-center w-6 h-6 rounded-md bg-gray-100 text-gray-700 font-semibold text-xs">
|
|
{index + 1}
|
|
</div>
|
|
<div className="flex-1">
|
|
<h3 className="font-semibold text-gray-900 text-sm">
|
|
{carrier.carrierName}
|
|
</h3>
|
|
<div className="flex items-center gap-3 text-xs text-gray-500 mt-0.5">
|
|
<span>{t('topCarriers.bookingsCount', { count: carrier.totalBookings })}</span>
|
|
<span>•</span>
|
|
<span>{numberFormat.format(carrier.totalWeightKG)} KG</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-4">
|
|
<div className="text-right">
|
|
<div className="flex items-center gap-1.5 justify-end mb-0.5">
|
|
<Badge
|
|
variant="secondary"
|
|
className="bg-green-50 text-green-700 border-green-200 text-xs px-1.5 py-0"
|
|
>
|
|
{carrier.acceptedBookings} ✓
|
|
</Badge>
|
|
{carrier.rejectedBookings > 0 && (
|
|
<Badge
|
|
variant="secondary"
|
|
className="bg-red-50 text-red-700 border-red-200 text-xs px-1.5 py-0"
|
|
>
|
|
{carrier.rejectedBookings} ✗
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<p className="text-xs text-gray-500">
|
|
{carrier.acceptanceRate.toFixed(0)}% • ${carrier.avgPriceUSD.toFixed(0)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="text-center py-12 px-4">
|
|
<div className="mx-auto mb-3 h-12 w-12 rounded-full bg-gray-100 flex items-center justify-center">
|
|
<Package className="h-6 w-6 text-gray-400" />
|
|
</div>
|
|
<h3 className="text-sm font-semibold text-gray-900 mb-1">
|
|
{t('topCarriers.empty.title')}
|
|
</h3>
|
|
<p className="text-xs text-gray-500 mb-4 max-w-sm mx-auto">
|
|
{t('topCarriers.empty.description')}
|
|
</p>
|
|
<Link href="/dashboard/bookings">
|
|
<Button size="sm" className="bg-blue-600 hover:bg-blue-700">
|
|
<Plus className="mr-1.5 h-3 w-3" />
|
|
{t('topCarriers.empty.cta')}
|
|
</Button>
|
|
</Link>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|