594 lines
23 KiB
TypeScript
594 lines
23 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 trendOf = (value: number | undefined, change: number | undefined, positiveIsGood = true) =>
|
|
kpis && ((value ?? 0) !== 0 || (change ?? 0) !== 0)
|
|
? { value: change ?? 0, label: t('kpi.vsLastMonth'), positiveIsGood }
|
|
: 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={t('kpi.vsLastMonth')}
|
|
icon={Package}
|
|
loading={kpisLoading}
|
|
/>
|
|
<StatCard
|
|
label={t('kpi.volume')}
|
|
value={`${cbmFormat.format(kpis?.volumeCBM ?? 0)} CBM`}
|
|
trend={trendOf(kpis?.volumeCBM, kpis?.volumeCBMChange)}
|
|
hint={t('kpi.vsLastMonth')}
|
|
icon={ContainerIcon}
|
|
loading={kpisLoading}
|
|
/>
|
|
<StatCard
|
|
label={t('kpi.revenue')}
|
|
value={currencyFormat.format(kpis?.estimatedRevenue ?? 0)}
|
|
trend={trendOf(kpis?.estimatedRevenue, kpis?.estimatedRevenueChange)}
|
|
hint={t('kpi.vsLastMonth')}
|
|
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={t('kpi.vsLastMonth')}
|
|
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)} %
|
|
</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>
|
|
</>
|
|
);
|
|
}
|
|
|
|
/** Bandeau des points a traiter, en tete de tableau de bord. */
|
|
function AlertsStrip({
|
|
alerts,
|
|
}: {
|
|
alerts: Array<{
|
|
id: string;
|
|
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={`/dashboard/bookings/${alert.bookingId}`}>
|
|
{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>
|
|
);
|
|
}
|