merge: integrer feat/notifications

This commit is contained in:
David 2026-09-07 21:42:36 +02:00
commit a491bdb79d
20 changed files with 1057 additions and 1464 deletions

View File

@ -22,6 +22,7 @@ import { NotificationService } from '../services/notification.service';
import { JwtAuthGuard } from '../guards/jwt-auth.guard'; import { JwtAuthGuard } from '../guards/jwt-auth.guard';
import { CurrentUser, UserPayload } from '../decorators/current-user.decorator'; import { CurrentUser, UserPayload } from '../decorators/current-user.decorator';
import { Notification } from '@domain/entities/notification.entity'; import { Notification } from '@domain/entities/notification.entity';
import { notificationTarget } from '@domain/services/notification-target';
class NotificationResponseDto { class NotificationResponseDto {
id: string; id: string;
@ -200,7 +201,12 @@ export class NotificationsController {
metadata: notification.metadata, metadata: notification.metadata,
read: notification.read, read: notification.read,
readAt: notification.readAt?.toISOString(), readAt: notification.readAt?.toISOString(),
actionUrl: notification.actionUrl, // La destination est derivee du type et des metadonnees : les liens
// ecrits a la main visaient des routes inexistantes.
actionUrl:
notification.actionUrl ??
notificationTarget(notification.type, notification.metadata) ??
undefined,
createdAt: notification.createdAt.toISOString(), createdAt: notification.createdAt.toISOString(),
}; };
} }

View File

@ -356,7 +356,6 @@ export class OrganizationsController {
siren: organization.siren, siren: organization.siren,
requestedBy: user.email, requestedBy: user.email,
}, },
actionUrl: `/dashboard/admin/organizations`,
}) })
) )
); );

View File

@ -18,6 +18,7 @@ import { Logger, UseGuards } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { NotificationService } from '../services/notification.service'; import { NotificationService } from '../services/notification.service';
import { Notification } from '@domain/entities/notification.entity'; import { Notification } from '@domain/entities/notification.entity';
import { notificationTarget } from '@domain/services/notification-target';
/** /**
* WebSocket authentication guard * WebSocket authentication guard
@ -236,7 +237,10 @@ export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisco
metadata: notification.metadata, metadata: notification.metadata,
read: notification.read, read: notification.read,
readAt: notification.readAt?.toISOString(), readAt: notification.readAt?.toISOString(),
actionUrl: notification.actionUrl, actionUrl:
notification.actionUrl ??
notificationTarget(notification.type, notification.metadata) ??
undefined,
createdAt: notification.createdAt.toISOString(), createdAt: notification.createdAt.toISOString(),
}; };
} }

View File

@ -157,7 +157,6 @@ export class NotificationService {
title: 'Booking Created', title: 'Booking Created',
message: `Your booking ${bookingNumber} has been created successfully.`, message: `Your booking ${bookingNumber} has been created successfully.`,
metadata: { bookingId, bookingNumber }, metadata: { bookingId, bookingNumber },
actionUrl: `/bookings/${bookingId}`,
}); });
} }
@ -176,7 +175,6 @@ export class NotificationService {
title: 'Booking Updated', title: 'Booking Updated',
message: `Booking ${bookingNumber} status changed to ${status}.`, message: `Booking ${bookingNumber} status changed to ${status}.`,
metadata: { bookingId, bookingNumber, status }, metadata: { bookingId, bookingNumber, status },
actionUrl: `/bookings/${bookingId}`,
}); });
} }
@ -194,7 +192,6 @@ export class NotificationService {
title: 'Booking Confirmed', title: 'Booking Confirmed',
message: `Your booking ${bookingNumber} has been confirmed by the carrier.`, message: `Your booking ${bookingNumber} has been confirmed by the carrier.`,
metadata: { bookingId, bookingNumber }, metadata: { bookingId, bookingNumber },
actionUrl: `/bookings/${bookingId}`,
}); });
} }
@ -212,7 +209,6 @@ export class NotificationService {
title: 'Document Uploaded', title: 'Document Uploaded',
message: `Document "${documentName}" has been uploaded for your booking.`, message: `Document "${documentName}" has been uploaded for your booking.`,
metadata: { documentName, bookingId }, metadata: { documentName, bookingId },
actionUrl: `/bookings/${bookingId}`,
}); });
} }
} }

View File

@ -0,0 +1,95 @@
import { existsSync } from 'fs';
import { join } from 'path';
import { NotificationType } from '../entities/notification.entity';
import { notificationTarget } from './notification-target';
const bookingId = 'b1e20067-db15-4028-a2c0-d8ef7f54e91b';
describe('notificationTarget', () => {
it('sends every booking notification to the booking itself', () => {
const bookingTypes = [
NotificationType.BOOKING_CREATED,
NotificationType.BOOKING_UPDATED,
NotificationType.BOOKING_CONFIRMED,
NotificationType.BOOKING_CANCELLED,
NotificationType.CSV_BOOKING_ACCEPTED,
NotificationType.CSV_BOOKING_REJECTED,
NotificationType.CSV_BOOKING_REQUEST_SENT,
NotificationType.DOCUMENT_UPLOADED,
];
for (const type of bookingTypes) {
expect(notificationTarget(type, { bookingId })).toBe(`/dashboard/bookings/${bookingId}`);
}
});
it('falls back to the list when the booking is unknown', () => {
// Mieux vaut la liste que rien : la personne retrouve son dossier.
expect(notificationTarget(NotificationType.CSV_BOOKING_ACCEPTED, {})).toBe(
'/dashboard/bookings'
);
expect(notificationTarget(NotificationType.CSV_BOOKING_ACCEPTED, undefined)).toBe(
'/dashboard/bookings'
);
});
it('leaves an announcement without a destination', () => {
// Une ligne sans cible ne doit pas se presenter comme cliquable.
expect(notificationTarget(NotificationType.SYSTEM_ANNOUNCEMENT, {})).toBeNull();
});
it.each([
[NotificationType.RATE_QUOTE_EXPIRING, '/dashboard/search-advanced'],
[NotificationType.USER_INVITED, '/dashboard/settings/users'],
[NotificationType.ORGANIZATION_UPDATE, '/dashboard/settings/organization'],
])('routes %s to %s', (type, expected) => {
expect(notificationTarget(type, {})).toBe(expected);
});
it.each([
['../../../admin/users', 'une remontee de chemin'],
['b1/../../etc', 'un segment compose'],
['id?next=/admin', 'une chaine de requete'],
['', 'une chaine vide'],
[42, 'un nombre'],
[{ id: 'x' }, 'un objet'],
])('refuses %p as a booking id (%s)', (value, _why) => {
// Les metadonnees sont du JSON libre : un identifiant douteux renvoie vers
// la liste, jamais vers une URL fabriquee.
expect(notificationTarget(NotificationType.CSV_BOOKING_ACCEPTED, { bookingId: value })).toBe(
'/dashboard/bookings'
);
});
/**
* Le garde-fou qui compte : chaque destination doit correspondre a une page
* qui existe. Les liens precedents — `/bookings/{id}` et
* `/dashboard/admin/organizations` — visaient des routes disparues, et rien ne
* le signalait.
*/
it('points every destination at a page that exists', () => {
const appDir = join(__dirname, '../../../../frontend/app/[locale]');
if (!existsSync(appDir)) {
// Depuis l'image backend seule, le frontend n'est pas la : on ne peut pas
// verifier, mais on ne fait pas echouer pour autant.
return;
}
const destinations = Object.values(NotificationType)
.map(type => notificationTarget(type, { bookingId }))
.filter((target): target is string => target !== null);
expect(destinations.length).toBeGreaterThan(0);
for (const destination of new Set(destinations)) {
// `/dashboard/bookings/<uuid>` correspond au segment dynamique `[id]`.
const segments = destination
.replace(/^\//, '')
.split('/')
.map(segment => (segment === bookingId ? '[id]' : segment));
const page = join(appDir, ...segments, 'page.tsx');
expect(existsSync(page)).toBe(true);
}
});
});

View File

@ -0,0 +1,59 @@
import { NotificationType } from '../entities/notification.entity';
/**
* Ou mene une notification.
*
* Une notification n'est pas un message : c'est un pointeur vers quelque chose
* qui a change. Le lien est donc derive du type et des metadonnees, ici et nulle
* part ailleurs — l'interface se contente de suivre.
*
* Les liens etaient jusqu'ici ecrits a la main a chaque appel, et deux d'entre
* eux visaient des routes qui n'existent pas : `/bookings/{id}` (la vraie route
* est `/dashboard/bookings/{id}`) et `/dashboard/admin/organizations` (l'espace
* d'administration a depuis son propre segment `/admin`). Les regrouper permet
* de les eprouver contre les routes reelles, en une seule fois.
*
* Les liens sont **relatifs et sans prefixe de langue** : le frontend est
* localise (`/fr`, `/en`) et ajoute le sien.
*/
export function notificationTarget(
type: NotificationType,
metadata: Record<string, unknown> | undefined
): string | null {
const bookingId = asId(metadata?.bookingId);
switch (type) {
// Toutes les notifications de reservation menent au dossier concerne.
case NotificationType.BOOKING_CREATED:
case NotificationType.BOOKING_UPDATED:
case NotificationType.BOOKING_CONFIRMED:
case NotificationType.BOOKING_CANCELLED:
case NotificationType.CSV_BOOKING_ACCEPTED:
case NotificationType.CSV_BOOKING_REJECTED:
case NotificationType.CSV_BOOKING_REQUEST_SENT:
case NotificationType.DOCUMENT_UPLOADED:
return bookingId ? `/dashboard/bookings/${bookingId}` : '/dashboard/bookings';
case NotificationType.RATE_QUOTE_EXPIRING:
return '/dashboard/search-advanced';
case NotificationType.USER_INVITED:
return '/dashboard/settings/users';
case NotificationType.ORGANIZATION_UPDATE:
return '/dashboard/settings/organization';
// Une annonce ne pointe vers rien : la ligne ne doit pas se presenter comme
// cliquable pour n'aboutir nulle part.
case NotificationType.SYSTEM_ANNOUNCEMENT:
return null;
}
}
/** Un identifiant utilisable dans une URL, ou rien. */
function asId(value: unknown): string | null {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
// Les metadonnees sont du JSON libre : refuser ce qui sortirait du segment.
return trimmed && /^[A-Za-z0-9_-]{1,64}$/.test(trimmed) ? trimmed : null;
}

View File

@ -3,61 +3,68 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useParams } from 'next/navigation'; import { useParams } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl'; import { useLocale, useTranslations } from 'next-intl';
import { ArrowLeft, Download, FileText, Package } from 'lucide-react'; import { ArrowLeft, ArrowRight, CreditCard, FileText, Package, Pencil } from 'lucide-react';
import { getBooking } from '@/lib/api'; import { getCsvBooking } from '@/lib/api';
import { Link } from '@/i18n/navigation'; import { Link } from '@/i18n/navigation';
import { Badge, type BadgeProps } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Callout } from '@/components/ui/callout';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { PageHeader } from '@/components/ui/PageHeader'; import { PageHeader } from '@/components/ui/PageHeader';
import { EmptyState, PageSpinner } from '@/components/ui/states'; import { EmptyState, ErrorState, PageSpinner } from '@/components/ui/states';
import { useToast } from '@/components/ui/toast'; import { BookingStatusBadge } from '@/components/bookings/BookingStatusBadge';
import { DescriptionList } from '@/components/data/description-list';
const STATUS_VARIANT: Record<string, NonNullable<BadgeProps['variant']>> = {
draft: 'neutral',
pending: 'warning',
confirmed: 'info',
in_transit: 'info',
delivered: 'success',
cancelled: 'danger',
};
/**
* Detail d'une reservation.
*
* La page interrogeait `GET /bookings/:id` et affichait un modele — expediteur,
* destinataire, conteneurs — qui n'existe pas dans ce produit : la table
* `bookings` n'a jamais ete creee, les reservations vivent dans `csv_bookings`.
* Elle repondait donc 500 et tournait indefiniment. C'est ce qui donnait
* l'impression qu'un clic sur une notification « ne faisait rien » : la
* navigation avait bien lieu, la destination ne s'affichait jamais.
*
* Elle lit desormais `GET /csv-bookings/:id`, la meme source que la liste.
*/
export default function BookingDetailPage() { export default function BookingDetailPage() {
const t = useTranslations('dashboard.bookingDetail'); const t = useTranslations('dashboard.bookingDetail');
const tList = useTranslations('dashboard.bookingsList');
const tCommon = useTranslations('common'); const tCommon = useTranslations('common');
const locale = useLocale(); const locale = useLocale();
const dateLocale = locale === 'fr' ? 'fr-FR' : 'en-US'; const dateLocale = locale === 'fr' ? 'fr-FR' : 'en-US';
const params = useParams(); const params = useParams();
const bookingId = params.id as string; const bookingId = params.id as string;
const { toast } = useToast();
const { data: booking, isLoading } = useQuery({ const {
queryKey: ['booking', bookingId], data: booking,
queryFn: () => getBooking(bookingId), isPending,
enabled: !!bookingId, isError,
error,
refetch,
} = useQuery({
queryKey: ['csv-booking', bookingId],
queryFn: () => getCsvBooking(bookingId),
enabled: Boolean(bookingId),
// Une reservation introuvable ne le deviendra pas en reessayant : trois
// tentatives ne faisaient que prolonger le tourniquet.
retry: (failureCount, err) =>
(err as { statusCode?: number })?.statusCode === 404 ? false : failureCount < 1,
}); });
const statusLabel = (status: string) => { const dateTime = (value?: string) =>
try { value
return t(`status.${status}` as any); ? new Date(value).toLocaleString(dateLocale, { dateStyle: 'long', timeStyle: 'short' })
} catch { : '—';
return status;
}
};
// Le telechargement PDF n'est pas encore branche cote back : on le signale if (isPending) return <PageSpinner label={tCommon('loading')} />;
// par une notification plutot que par une alerte bloquante du navigateur.
const downloadPDF = () => toast.info(t('pdfNotImplemented'));
if (isLoading) return <PageSpinner label={tCommon('loading')} />; if (isError) {
const missing = (error as { statusCode?: number })?.statusCode === 404;
if (!booking) { return missing ? (
return (
<EmptyState <EmptyState
icon={Package} icon={Package}
title={t('notFound')} title={t('notFound')}
description={t('notFoundBody')}
action={ action={
<Button asChild variant="outline"> <Button asChild variant="outline">
<Link href="/dashboard/bookings"> <Link href="/dashboard/bookings">
@ -67,173 +74,195 @@ export default function BookingDetailPage() {
</Button> </Button>
} }
/> />
) : (
<ErrorState
title={t('errorTitle')}
description={t('errorBody')}
retryLabel={tCommon('retry')}
onRetry={() => refetch()}
/>
); );
} }
const unpaid = booking.status === 'PENDING_PAYMENT';
const price = booking.priceEUR
? `${booking.priceEUR} €`
: booking.priceUSD
? `${booking.priceUSD} $`
: null;
return ( return (
<> <>
<PageHeader <PageHeader
title={booking.bookingNumber} title={booking.bookingNumber ?? `#${booking.id.slice(0, 8).toUpperCase()}`}
description={t('createdOn', { description={t('requestedOn', { date: dateTime(booking.requestedAt) })}
date: new Date(booking.createdAt).toLocaleDateString(dateLocale),
})}
actions={ actions={
<> <>
<Badge variant={STATUS_VARIANT[booking.status] ?? 'neutral'}> <BookingStatusBadge status={booking.status} />
{statusLabel(booking.status)} {unpaid && (
</Badge> <Button asChild>
<Button variant="outline" onClick={downloadPDF}> <Link href={`/dashboard/booking/${booking.id}/pay`}>
<Download /> <CreditCard />
{t('downloadPdf')} {tList('detail.payNow')}
</Link>
</Button> </Button>
)}
</> </>
} }
/> />
{unpaid && (
<Callout variant="warning" className="mb-4">
{tList('detail.pendingPaymentNotice')}
</Callout>
)}
{booking.status === 'REJECTED' && booking.rejectionReason && (
<Callout variant="danger" className="mb-4" title={t('rejected')}>
{booking.rejectionReason}
</Callout>
)}
<div className="grid gap-4 lg:grid-cols-3"> <div className="grid gap-4 lg:grid-cols-3">
<div className="flex flex-col gap-4 lg:col-span-2"> <div className="flex flex-col gap-4 lg:col-span-2">
{/* La route est ce qu'un transitaire regarde en premier : elle occupe
la tete de page, pas une ligne de tableau parmi d'autres. */}
<Card> <Card>
<CardHeader className="pb-3"> <CardContent className="p-5">
<CardTitle>{t('cargo.title')}</CardTitle> <div className="flex flex-wrap items-center gap-x-4 gap-y-2">
</CardHeader> <span className="font-heading text-h4 text-brand-navy">{booking.origin}</span>
<CardContent> <span className="flex items-center gap-2 text-neutral-400">
<DescriptionList <ArrowRight className="size-4" aria-hidden="true" />
columns={1} {booking.transitDays ? (
items={[ <span className="text-body-xs">
{ label: t('cargo.description'), value: booking.cargoDescription }, {t('transitDays', { days: booking.transitDays })}
...(booking.specialInstructions </span>
? [ ) : null}
{ </span>
label: t('cargo.specialInstructions'), <span className="font-heading text-h4 text-brand-navy">{booking.destination}</span>
value: booking.specialInstructions, </div>
}, {booking.carrierName && (
] <p className="mt-2 text-body-sm text-neutral-500">
: []), {t('carrier', { carrier: booking.carrierName })}
]} </p>
/>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle>
{t('containers.title', { count: booking.containers?.length || 0 })}
</CardTitle>
</CardHeader>
<CardContent>
{booking.containers && booking.containers.length > 0 ? (
<ul className="flex flex-col gap-3">
{booking.containers.map((container, index) => (
<li
key={container.id || index}
className="rounded-md border border-border p-4"
>
<DescriptionList
items={[
{ label: t('containers.type'), value: container.type },
...(container.containerNumber
? [{ label: t('containers.number'), value: container.containerNumber }]
: []),
...(container.sealNumber
? [{ label: t('containers.seal'), value: container.sealNumber }]
: []),
...(container.vgm
? [{ label: t('containers.vgm'), value: container.vgm }]
: []),
]}
/>
</li>
))}
</ul>
) : (
<EmptyState size="sm" icon={Package} title={t('containers.title', { count: 0 })} />
)} )}
</CardContent> </CardContent>
</Card> </Card>
<div className="grid gap-4 md:grid-cols-2">
<Card> <Card>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle>{t('shipper.title')}</CardTitle> <CardTitle>{tList('detail.cargo')}</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<DescriptionList <dl className="grid gap-4 sm:grid-cols-3">
columns={1} <Field label={t('packing')}>
items={[ {booking.palletCount
{ label: t('shipper.name'), value: booking.shipper.name, emphasis: true }, ? tList('units.palletsCount', { count: booking.palletCount })
{ label: t('shipper.contact'), value: booking.shipper.contactName }, : '—'}
{ label: t('shipper.email'), value: booking.shipper.contactEmail }, </Field>
{ label: t('shipper.phone'), value: booking.shipper.contactPhone }, <Field label={t('volume')}>
]} {booking.volumeCBM ? tList('units.cbm', { value: booking.volumeCBM }) : '—'}
/> </Field>
<Field label={t('weight')}>
{booking.weightKG ? tList('units.kg', { value: booking.weightKG }) : '—'}
</Field>
</dl>
{booking.notes && (
<p className="mt-4 border-t border-border pt-4 text-body-sm leading-6 text-neutral-600">
{booking.notes}
</p>
)}
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle>{t('consignee.title')}</CardTitle> <CardTitle>{t('documents', { count: booking.documents?.length ?? 0 })}</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<DescriptionList {booking.documents?.length ? (
columns={1} <ul className="flex flex-col gap-2">
items={[ {booking.documents.map((document, index) => (
{ label: t('consignee.name'), value: booking.consignee.name, emphasis: true }, <li
{ label: t('consignee.contact'), value: booking.consignee.contactName }, key={index}
{ label: t('consignee.email'), value: booking.consignee.contactEmail }, className="flex items-center gap-3 rounded-md border border-border px-4 py-3"
{ label: t('consignee.phone'), value: booking.consignee.contactPhone }, >
]} <FileText className="size-4 shrink-0 text-neutral-400" aria-hidden="true" />
/> <span className="min-w-0 flex-1 truncate text-body-sm text-neutral-700">
{document.fileName}
</span>
<span className="shrink-0 text-body-xs text-neutral-400">
{document.type}
</span>
</li>
))}
</ul>
) : (
<EmptyState size="sm" icon={FileText} title={t('noDocuments')} />
)}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
</div>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{price && (
<Card> <Card>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle>{t('timeline.title')}</CardTitle> <CardTitle>{tList('detail.price')}</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<ol className="relative flex flex-col gap-4"> <p className="font-heading text-h3 text-brand-navy">{price}</p>
<li className="flex gap-3"> <dl className="mt-4 flex flex-col gap-3 border-t border-border pt-4">
<span {booking.freightTotal ? (
aria-hidden="true" <Field label={t('freight')}>
className="mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full bg-brand-blue/10" {booking.freightTotal} {booking.freightCurrency}
> </Field>
<FileText className="size-3 text-brand-blue" /> ) : null}
</span> {booking.fobTotal ? (
<div className="min-w-0"> <Field label={t('destinationCharges')}>
<p className="text-body-sm font-medium text-brand-navy"> {booking.fobTotal} {booking.fobCurrency}
{t('timeline.created')} </Field>
</p> ) : null}
<p className="mt-0.5 text-body-xs text-neutral-500"> {booking.commissionAmountEur ? (
{new Date(booking.createdAt).toLocaleString(dateLocale)} <Field label={t('commission')}>{booking.commissionAmountEur} €</Field>
</p> ) : null}
</div> </dl>
</li>
</ol>
</CardContent> </CardContent>
</Card> </Card>
)}
<Card> <Card>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle>{t('info.title')}</CardTitle> <CardTitle>{t('timeline')}</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<DescriptionList <dl className="flex flex-col gap-3">
columns={1} <Field label={t('requested')}>{dateTime(booking.requestedAt)}</Field>
items={[ {booking.respondedAt && (
{ label: t('info.bookingId'), value: booking.id }, <Field label={t('responded')}>{dateTime(booking.respondedAt)}</Field>
{ )}
label: t('info.lastUpdated'), <Field label={t('reference')}>{booking.id}</Field>
value: new Date(booking.updatedAt).toLocaleString(dateLocale), </dl>
},
]}
/>
</CardContent> </CardContent>
</Card> </Card>
<Button asChild variant="outline">
<Link href="/dashboard/bookings">
<ArrowLeft />
{t('back')}
</Link>
</Button>
</div> </div>
</div> </div>
</> </>
); );
} }
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="min-w-0">
<dt className="text-label uppercase text-neutral-400">{label}</dt>
<dd className="mt-1 break-words text-body-sm text-neutral-700">{children}</dd>
</div>
);
}

View File

@ -1,425 +1,178 @@
'use client'; 'use client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useMemo, useState } from 'react';
import { useState } from 'react'; import { useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation'; import { Bell, CheckCheck } from 'lucide-react';
import { useTranslations, useLocale } from 'next-intl'; import { Button } from '@/components/ui/button';
import { PageHeader } from '@/components/ui/PageHeader';
import { EmptyState, ErrorState, Skeleton } from '@/components/ui/states';
import { NotificationRow } from '@/components/notifications/notification-row';
import { useNotificationLabels } from '@/components/notifications/use-notification-labels';
import { import {
listNotifications, notificationKind,
markNotificationAsRead, type NotificationKind,
markAllNotificationsAsRead, } from '@/components/notifications/notification-kind';
deleteNotification, import { useNotifications } from '@/hooks/useNotifications';
} from '@/lib/api'; import { cn } from '@/lib/utils';
import type { NotificationResponse } from '@/types/api'; import type { NotificationResponse } from '@/types/api';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import {
Trash2,
CheckCheck,
Filter,
Bell,
ChevronLeft,
ChevronRight,
Package,
RefreshCw,
XCircle,
CheckCircle,
Mail,
Clock,
FileText,
Megaphone,
User,
Building2,
} from 'lucide-react';
import type { ReactNode } from 'react';
/** Familles proposées au filtre, dans l'ordre où elles comptent au quotidien. */
const FILTERS = ['all', 'unread', 'booking', 'account', 'system'] as const;
type Filter = (typeof FILTERS)[number];
const PAGE_SIZE = 50;
/**
* Toutes les notifications.
*
* Cette page existait déjà mais n'était liée de nulle part : « Voir tout »
* ouvrait un panneau latéral qui rejouait le menu. Elle est désormais la seule
* destination, et partage ses lignes avec la cloche — une notification se
* présente pareil où qu'on la lise.
*/
export default function NotificationsPage() { export default function NotificationsPage() {
const t = useTranslations('dashboard.notificationsPage'); const t = useTranslations('components.notifications');
const locale = useLocale(); const [filter, setFilter] = useState<Filter>('all');
const dateLocale = locale === 'fr' ? 'fr-FR' : 'en-US';
const [selectedFilter, setSelectedFilter] = useState<'all' | 'unread' | 'read'>('all');
const [currentPage, setCurrentPage] = useState(1);
const queryClient = useQueryClient();
const router = useRouter();
const tCommon = useTranslations('common');
const [notificationToDelete, setNotificationToDelete] = useState<string | null>(null);
const { data, isLoading } = useQuery({ const {
queryKey: ['notifications', 'page', selectedFilter, currentPage], notifications,
queryFn: () => unreadCount,
listNotifications({ isLoading,
page: currentPage, isError,
limit: 20, refetch,
isRead: selectedFilter === 'all' ? undefined : selectedFilter === 'read', markAsRead,
}), markAllAsRead,
}); isMarkingAll,
} = useNotifications({ limit: PAGE_SIZE });
const notifications = data?.notifications || []; const labels = useNotificationLabels();
const total = data?.total || 0;
const totalPages = Math.ceil(total / 20);
const unreadCount = notifications.filter((n: NotificationResponse) => !n.read).length;
const markAsReadMutation = useMutation({ const visible = useMemo(() => {
mutationFn: markNotificationAsRead, if (filter === 'all') return notifications;
onSuccess: () => { if (filter === 'unread') return notifications.filter(n => !n.read);
queryClient.invalidateQueries({ queryKey: ['notifications'] }); return notifications.filter(n => matchesKind(n, filter));
}, }, [notifications, filter]);
});
const markAllAsReadMutation = useMutation({ const countFor = (value: Filter) => {
mutationFn: markAllNotificationsAsRead, if (value === 'all') return notifications.length;
onSuccess: () => { if (value === 'unread') return unreadCount;
queryClient.invalidateQueries({ queryKey: ['notifications'] }); return notifications.filter(n => matchesKind(n, value)).length;
},
});
const deleteNotificationMutation = useMutation({
mutationFn: deleteNotification,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notifications'] });
},
});
const handleNotificationClick = (notification: NotificationResponse) => {
if (!notification.read) {
markAsReadMutation.mutate(notification.id);
}
if (notification.actionUrl) {
router.push(notification.actionUrl);
}
};
// Remplace le dialogue natif : non bloquant et traduit dans son entierete.
const handleDelete = (e: React.MouseEvent, notificationId: string) => {
e.stopPropagation();
setNotificationToDelete(notificationId);
};
const getPriorityColor = (priority: string) => {
const colors = {
urgent: 'border-l-4 border-red-500 bg-red-50 hover:bg-red-100',
high: 'border-l-4 border-orange-500 bg-orange-50 hover:bg-orange-100',
medium: 'border-l-4 border-yellow-500 bg-yellow-50 hover:bg-yellow-100',
low: 'border-l-4 border-brand-blue bg-brand-blue/5 hover:bg-brand-blue/10',
};
return (
colors[priority as keyof typeof colors] || 'border-l-4 border-neutral-300 hover:bg-neutral-100'
);
};
const getPriorityLabel = (priority: string) => {
const map: Record<string, string> = {
urgent: t('priority.urgent'),
high: t('priority.high'),
medium: t('priority.medium'),
low: t('priority.low'),
};
return map[priority] || priority.toUpperCase();
};
const getNotificationIcon = (type: string): ReactNode => {
const iconClass = 'h-8 w-8';
const icons: Record<string, ReactNode> = {
booking_created: <Package className={`${iconClass} text-brand-navy`} />,
booking_updated: <RefreshCw className={`${iconClass} text-orange-500`} />,
booking_cancelled: <XCircle className={`${iconClass} text-red-500`} />,
booking_confirmed: <CheckCircle className={`${iconClass} text-green-500`} />,
csv_booking_accepted: <CheckCircle className={`${iconClass} text-green-500`} />,
csv_booking_rejected: <XCircle className={`${iconClass} text-red-500`} />,
csv_booking_request_sent: <Mail className={`${iconClass} text-brand-blue`} />,
rate_quote_expiring: <Clock className={`${iconClass} text-yellow-500`} />,
document_uploaded: <FileText className={`${iconClass} text-neutral-600`} />,
system_announcement: <Megaphone className={`${iconClass} text-purple-500`} />,
user_invited: <User className={`${iconClass} text-teal-500`} />,
organization_update: <Building2 className={`${iconClass} text-indigo-500`} />,
};
return icons[type.toLowerCase()] || <Bell className={`${iconClass} text-neutral-500`} />;
};
const formatTime = (dateString: string) => {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return t('time.now');
if (diffMins < 60) return t('time.minutes', { count: diffMins });
if (diffHours < 24) return t('time.hours', { count: diffHours });
if (diffDays < 7) return t('time.days', { count: diffDays });
return date.toLocaleDateString(dateLocale, {
month: 'long',
day: 'numeric',
year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined,
hour: '2-digit',
minute: '2-digit',
});
}; };
return ( return (
<div className="min-h-screen bg-neutral-50"> <>
<div className="bg-white border-b shadow-sm"> <PageHeader
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6"> title={t('pageTitle')}
<div className="flex items-center justify-between"> description={t('pageDescription')}
<div className="flex items-center space-x-4"> actions={
<div className="p-3 bg-brand-blue/10 rounded-lg"> unreadCount > 0 && (
<Bell className="w-8 h-8 text-brand-navy" /> <Button
</div> variant="outline"
<div> size="sm"
<h1 className="text-3xl font-bold text-brand-navy">{t('title')}</h1> onClick={() => markAllAsRead()}
<p className="text-sm text-neutral-600 mt-1"> loading={isMarkingAll}
{t('totalLabel', { count: total })} >
{unreadCount > 0 && t('unreadSuffix', { count: unreadCount })} <CheckCheck aria-hidden="true" />
</p> {t('markAllRead')}
</div> </Button>
</div> )
{unreadCount > 0 && ( }
tabs={
<div className="flex flex-wrap gap-2" role="tablist" aria-label={t('filterLabel')}>
{FILTERS.map(value => {
const active = filter === value;
const count = countFor(value);
return (
<button <button
onClick={() => markAllAsReadMutation.mutate()} key={value}
disabled={markAllAsReadMutation.isPending} type="button"
className="flex items-center space-x-2 px-4 py-2 bg-brand-navy text-white rounded-lg hover:bg-brand-navy/90 disabled:opacity-50 transition-colors" role="tab"
> aria-selected={active}
<CheckCheck className="w-5 h-5" /> onClick={() => setFilter(value)}
<span>{t('markAllRead')}</span> className={cn(
</button> 'inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-body-sm transition-colors duration-150 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
active
? 'border-brand-navy bg-brand-navy text-white'
: 'border-border bg-white text-neutral-700 hover:bg-neutral-50'
)} )}
</div>
</div>
</div>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="bg-white rounded-lg shadow-sm border p-4 mb-6">
<div className="flex items-center space-x-3">
<Filter className="w-5 h-5 text-neutral-500" />
<span className="text-sm font-medium text-neutral-700">{t('filter.label')}</span>
<div className="flex space-x-2">
{(['all', 'unread', 'read'] as const).map(filter => (
<button
key={filter}
onClick={() => {
setSelectedFilter(filter);
setCurrentPage(1);
}}
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
selectedFilter === filter
? 'bg-brand-navy text-white shadow-sm'
: 'bg-neutral-100 text-neutral-700 hover:bg-neutral-200'
}`}
> >
{t(`filter.${filter}` as any)} {t(`filters.${value}`)}
{filter === 'unread' && unreadCount > 0 && ( {count > 0 && (
<span className="ml-2 px-2 py-0.5 bg-white/20 rounded-full text-xs">
{unreadCount}
</span>
)}
</button>
))}
</div>
</div>
</div>
<div className="bg-white rounded-lg shadow-sm border">
{isLoading ? (
<div className="flex items-center justify-center py-20">
<div className="text-center">
<div className="animate-spin rounded-full h-16 w-16 border-b-2 border-brand-blue mx-auto mb-4" />
<p className="text-neutral-500">{t('loading')}</p>
</div>
</div>
) : notifications.length === 0 ? (
<div className="flex items-center justify-center py-20">
<div className="text-center">
<div className="mb-4 flex justify-center">
<Bell className="h-16 w-16 text-neutral-300" />
</div>
<h3 className="text-xl font-semibold text-brand-navy mb-2">{t('empty.title')}</h3>
<p className="text-neutral-500">
{selectedFilter === 'unread' ? t('empty.upToDate') : t('empty.none')}
</p>
</div>
</div>
) : (
<div className="divide-y">
{notifications.map((notification: NotificationResponse) => (
<div
key={notification.id}
onClick={() => handleNotificationClick(notification)}
className={`p-6 transition-all cursor-pointer group ${
!notification.read ? 'bg-brand-blue/5/50' : ''
} ${getPriorityColor(notification.priority || 'low')}`}
>
<div className="flex items-start space-x-4">
<div className="flex-shrink-0 flex items-center justify-center w-12 h-12">
{getNotificationIcon(notification.type)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between mb-2">
<div className="flex items-center space-x-3">
<h3 className="text-lg font-semibold text-brand-navy">
{notification.title}
</h3>
{!notification.read && (
<span className="flex items-center space-x-1 px-2 py-1 bg-brand-navy text-white text-xs font-medium rounded-full">
<span className="w-2 h-2 bg-white rounded-full animate-pulse" />
<span>{t('new')}</span>
</span>
)}
</div>
<button
onClick={e => handleDelete(e, notification.id)}
className="opacity-0 group-hover:opacity-100 transition-opacity p-2 hover:bg-red-100 rounded-lg"
title={t('deleteTitle')}
>
<Trash2 className="w-5 h-5 text-red-600" />
</button>
</div>
<p className="text-base text-neutral-700 mb-4 leading-relaxed">
{notification.message}
</p>
<div className="flex items-center justify-between">
<div className="flex items-center flex-wrap gap-3 text-xs">
<span className="flex items-center space-x-1 text-neutral-600">
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span className="font-medium">
{formatTime(notification.createdAt)}
</span>
</span>
<span className="px-3 py-1 bg-neutral-100 rounded-full text-neutral-700 font-medium">
{notification.type.replace(/_/g, ' ').toUpperCase()}
</span>
{notification.priority && (
<span <span
className={`px-3 py-1 rounded-full font-medium ${ className={cn(
notification.priority === 'urgent' 'tabular-nums text-body-xs',
? 'bg-red-100 text-red-700' active ? 'text-white/70' : 'text-neutral-400'
: notification.priority === 'high' )}
? 'bg-orange-100 text-orange-700'
: notification.priority === 'medium'
? 'bg-yellow-100 text-yellow-700'
: 'bg-brand-blue/10 text-brand-navy'
}`}
> >
{getPriorityLabel(notification.priority)} {count}
</span> </span>
)} )}
</div>
{notification.actionUrl && (
<span className="text-sm text-brand-navy font-medium group-hover:underline flex items-center space-x-1">
<span>{t('viewDetails')}</span>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
</span>
)}
</div>
</div>
</div>
</div>
))}
</div>
)}
</div>
{totalPages > 1 && (
<div className="mt-6 bg-white rounded-lg shadow-sm border p-4">
<div className="flex items-center justify-between">
<div className="text-sm text-neutral-600">
{t.rich('pagination.info', {
current: currentPage,
total: totalPages,
items: total,
b: chunks => <span className="font-semibold">{chunks}</span>,
})}
</div>
<div className="flex items-center space-x-2">
<button
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="flex items-center space-x-1 px-4 py-2 text-sm font-medium text-brand-navy bg-white border border-border rounded-lg hover:bg-neutral-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<ChevronLeft className="w-4 h-4" />
<span>{t('pagination.previous')}</span>
</button>
<div className="flex items-center space-x-1">
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
let pageNum;
if (totalPages <= 5) {
pageNum = i + 1;
} else if (currentPage <= 3) {
pageNum = i + 1;
} else if (currentPage >= totalPages - 2) {
pageNum = totalPages - 4 + i;
} else {
pageNum = currentPage - 2 + i;
}
return (
<button
key={pageNum}
onClick={() => setCurrentPage(pageNum)}
className={`w-10 h-10 text-sm font-medium rounded-lg transition-colors ${
currentPage === pageNum
? 'bg-brand-navy text-white'
: 'bg-white text-neutral-700 hover:bg-neutral-100 border border-neutral-300'
}`}
>
{pageNum}
</button> </button>
); );
})} })}
</div> </div>
<button }
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))} />
disabled={currentPage === totalPages}
className="flex items-center space-x-1 px-4 py-2 text-sm font-medium text-brand-navy bg-white border border-border rounded-lg hover:bg-neutral-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors" <div className="overflow-hidden rounded-lg border border-border bg-white">
> {isLoading ? (
<span>{t('pagination.next')}</span> <div className="space-y-4 p-5" aria-hidden="true">
<ChevronRight className="w-4 h-4" /> {Array.from({ length: 5 }).map((_, index) => (
</button> <div key={index} className="flex gap-3">
<Skeleton className="size-9 shrink-0 rounded-md" />
<div className="flex-1 space-y-2">
<Skeleton className="h-3 w-1/3" />
<Skeleton className="h-3 w-3/4" />
</div> </div>
</div> </div>
))}
</div> </div>
) : isError ? (
<ErrorState
title={t('errorTitle')}
description={t('errorBody')}
retryLabel={t('retry')}
onRetry={() => refetch()}
/>
) : visible.length === 0 ? (
<EmptyState
icon={Bell}
title={filter === 'all' ? t('emptyTitle') : t('emptyFilterTitle')}
description={filter === 'all' ? t('emptyBody') : t('emptyFilterBody')}
action={
filter !== 'all' && (
<Button variant="outline" size="sm" onClick={() => setFilter('all')}>
{t('filters.all')}
</Button>
)
}
/>
) : (
<ul className="divide-y divide-border">
{visible.map(notification => (
<li key={notification.id}>
<NotificationRow
notification={notification}
labels={labels}
density="comfortable"
onOpen={n => !n.read && markAsRead(n.id)}
/>
</li>
))}
</ul>
)} )}
</div> </div>
</>
<ConfirmDialog
open={Boolean(notificationToDelete)}
onOpenChange={open => !open && setNotificationToDelete(null)}
title={t('deleteConfirm')}
confirmLabel={tCommon('delete')}
cancelLabel={tCommon('cancel')}
variant="destructive"
onConfirm={() => {
if (notificationToDelete) deleteNotificationMutation.mutate(notificationToDelete);
setNotificationToDelete(null);
}}
/>
</div>
); );
} }
/** `document` et `rate` se rangent avec les réservations : c'est le même dossier. */
function matchesKind(notification: NotificationResponse, filter: Filter): boolean {
const kind = notificationKind(notification.type);
const family: Record<Exclude<Filter, 'all' | 'unread'>, NotificationKind[]> = {
booking: ['booking', 'document', 'rate'],
account: ['account'],
system: ['system'],
};
return family[filter as Exclude<Filter, 'all' | 'unread'>].includes(kind);
}

View File

@ -717,54 +717,27 @@
} }
}, },
"bookingDetail": { "bookingDetail": {
"back": "← Back to bookings", "back": "Back to bookings",
"notFound": "Booking not found", "notFound": "Booking not found",
"timeline": { "notFoundBody": "It may have been deleted, or it does not belong to you.",
"title": "Timeline", "errorTitle": "Booking unavailable",
"created": "Booking Created" "errorBody": "The booking could not be loaded. Try again in a moment.",
}, "requestedOn": "Requested on {date}",
"createdOn": "Created on {date}", "carrier": "Carrier: {carrier}",
"downloadPdf": "Download PDF", "transitDays": "{days} d transit",
"pdfNotImplemented": "PDF download functionality is not yet implemented", "volume": "Volume",
"status": { "weight": "Weight",
"draft": "Draft", "documents": "{count, plural, =0 {Documents} one {# document} other {# documents}}",
"pending": "Pending", "noDocuments": "No document attached",
"confirmed": "Confirmed", "freight": "Freight",
"in_transit": "In transit", "destinationCharges": "Destination charges",
"delivered": "Delivered", "commission": "Xpeditis commission",
"cancelled": "Cancelled" "timeline": "Timeline",
}, "requested": "Request sent",
"cargo": { "responded": "Carrier reply",
"title": "Cargo Details", "reference": "Internal reference",
"description": "Description", "rejected": "Request rejected",
"specialInstructions": "Special Instructions" "packing": "Packing"
},
"containers": {
"title": "Containers ({count})",
"type": "Type",
"number": "Container Number",
"seal": "Seal Number",
"vgm": "VGM (kg)"
},
"shipper": {
"title": "Shipper",
"name": "Name",
"contact": "Contact",
"email": "Email",
"phone": "Phone"
},
"consignee": {
"title": "Consignee",
"name": "Name",
"contact": "Contact",
"email": "Email",
"phone": "Phone"
},
"info": {
"title": "Information",
"bookingId": "Booking ID",
"lastUpdated": "Last Updated"
}
}, },
"profile": { "profile": {
"loading": "Loading profile...", "loading": "Loading profile...",
@ -3181,45 +3154,6 @@
"Avoid critical shipments during high-risk periods" "Avoid critical shipments during high-risk periods"
] ]
} }
},
"notificationsPage": {
"title": "Notifications",
"totalLabel": "{count, plural, one {# notification total} other {# notifications total}}",
"unreadSuffix": " • {count, plural, one {# unread} other {# unread}}",
"markAllRead": "Mark all as read",
"filter": {
"label": "Filter:",
"all": "All",
"unread": "Unread",
"read": "Read"
},
"loading": "Loading notifications...",
"empty": {
"title": "No notifications",
"upToDate": "You're all caught up!",
"none": "No notifications to display"
},
"new": "NEW",
"deleteTitle": "Delete notification",
"deleteConfirm": "Are you sure you want to delete this notification?",
"viewDetails": "View details",
"priority": {
"urgent": "URGENT",
"high": "HIGH",
"medium": "MEDIUM",
"low": "LOW"
},
"time": {
"now": "Just now",
"minutes": "{count}m ago",
"hours": "{count}h ago",
"days": "{count}d ago"
},
"pagination": {
"info": "Page <b>{current}</b> of <b>{total}</b> • <b>{items}</b> {items, plural, one {notification} other {notifications}} total",
"previous": "Previous",
"next": "Next"
}
} }
}, },
"components": { "components": {
@ -3383,42 +3317,43 @@
"bulkUpdate": "Bulk update", "bulkUpdate": "Bulk update",
"bulkUpdateSoon": "Bulk update is coming soon!" "bulkUpdateSoon": "Bulk update is coming soon!"
}, },
"notificationDropdown": { "notifications": {
"ariaLabel": "Notifications", "ariaLabel": "Notifications",
"ariaLabelWithCount": "{count, plural, one {# unread notification} other {# unread notifications}}",
"header": "Notifications", "header": "Notifications",
"markAllRead": "Mark all as read", "markAllRead": "Mark all as read",
"loading": "Loading notifications…", "viewAll": "See all notifications",
"empty": "No new notifications", "unread": "Unread",
"viewAll": "View all notifications", "emptyTitle": "You are up to date",
"emptyBody": "Carrier replies and changes to your bookings will show up here.",
"emptyFilterTitle": "Nothing in this filter",
"emptyFilterBody": "No notification matches this family right now.",
"errorTitle": "Notifications unavailable",
"errorBody": "Your notifications could not be loaded. Try again in a moment.",
"retry": "Retry",
"pageTitle": "Notifications",
"pageDescription": "Carrier replies and changes to your bookings.",
"filterLabel": "Filter notifications",
"filters": {
"all": "All",
"unread": "Unread",
"booking": "Bookings",
"account": "Account",
"system": "System"
},
"destination": {
"booking": "Booking {reference}",
"bookings": "Your bookings",
"rates": "Rate search",
"account": "Your organisation",
"bookingUnnamed": "The related booking"
},
"time": { "time": {
"now": "Just now", "now": "Just now",
"minutes": "{minutes} min ago", "minutes": "{minutes} min ago",
"hours": "{hours} h ago", "hours": "{hours} h ago",
"days": "{days} d ago" "days": "{days} d ago"
} }
},
"notificationPanel": {
"title": "Notifications",
"totalCount": "{count, plural, one {# notification total} other {# notifications total}}",
"closeAria": "Close panel",
"filters": {
"all": "All",
"unread": "Unread",
"read": "Read"
},
"markAllRead": "Mark all as read",
"loading": "Loading notifications…",
"emptyTitle": "No notifications",
"emptyUnread": "You're all caught up!",
"emptyAll": "Nothing to show",
"deleteConfirm": "Are you sure you want to delete this notification?",
"deleteTitle": "Delete notification",
"viewDetails": "View details →",
"pagination": {
"page": "Page {current} of {total}",
"previous": "Previous",
"next": "Next"
}
} }
}, },
"carrierPortal": { "carrierPortal": {

View File

@ -717,54 +717,27 @@
} }
}, },
"bookingDetail": { "bookingDetail": {
"back": "← Retour aux réservations", "back": "Retour aux réservations",
"notFound": "Réservation introuvable", "notFound": "Réservation introuvable",
"timeline": { "notFoundBody": "Elle a peut-être été supprimée, ou elle ne vous appartient pas.",
"title": "Chronologie", "errorTitle": "Réservation indisponible",
"created": "Réservation créée" "errorBody": "Le dossier n’a pas pu être chargé. Réessayez dans un instant.",
}, "requestedOn": "Demandée le {date}",
"createdOn": "Créée le {date}", "carrier": "Transporteur : {carrier}",
"downloadPdf": "Télécharger le PDF", "transitDays": "{days} j de transit",
"pdfNotImplemented": "Le téléchargement PDF n'est pas encore disponible", "volume": "Volume",
"status": { "weight": "Poids",
"draft": "Brouillon", "documents": "{count, plural, =0 {Documents} one {# document} other {# documents}}",
"pending": "En attente", "noDocuments": "Aucun document joint",
"confirmed": "Confirmée", "freight": "Fret",
"in_transit": "En transit", "destinationCharges": "Frais à destination",
"delivered": "Livrée", "commission": "Commission Xpeditis",
"cancelled": "Annulée" "timeline": "Suivi",
}, "requested": "Demande envoyée",
"cargo": { "responded": "Réponse du transporteur",
"title": "Détails de la cargaison", "reference": "Référence interne",
"description": "Description", "rejected": "Demande refusée",
"specialInstructions": "Instructions spéciales" "packing": "Colisage"
},
"containers": {
"title": "Conteneurs ({count})",
"type": "Type",
"number": "Numéro du conteneur",
"seal": "Numéro de plomb",
"vgm": "VGM (kg)"
},
"shipper": {
"title": "Expéditeur",
"name": "Nom",
"contact": "Contact",
"email": "Email",
"phone": "Téléphone"
},
"consignee": {
"title": "Destinataire",
"name": "Nom",
"contact": "Contact",
"email": "Email",
"phone": "Téléphone"
},
"info": {
"title": "Informations",
"bookingId": "ID de réservation",
"lastUpdated": "Dernière mise à jour"
}
}, },
"profile": { "profile": {
"loading": "Chargement du profil...", "loading": "Chargement du profil...",
@ -3181,45 +3154,6 @@
"Éviter les expéditions critiques pendant les périodes à risque" "Éviter les expéditions critiques pendant les périodes à risque"
] ]
} }
},
"notificationsPage": {
"title": "Notifications",
"totalLabel": "{count, plural, one {# notification au total} other {# notifications au total}}",
"unreadSuffix": " • {count, plural, one {# non lue} other {# non lues}}",
"markAllRead": "Tout marquer comme lu",
"filter": {
"label": "Filtrer :",
"all": "Toutes",
"unread": "Non lues",
"read": "Lues"
},
"loading": "Chargement des notifications...",
"empty": {
"title": "Aucune notification",
"upToDate": "Vous êtes à jour !",
"none": "Aucune notification à afficher"
},
"new": "NOUVEAU",
"deleteTitle": "Supprimer la notification",
"deleteConfirm": "Êtes-vous sûr de vouloir supprimer cette notification ?",
"viewDetails": "Voir les détails",
"priority": {
"urgent": "URGENT",
"high": "ÉLEVÉE",
"medium": "MOYENNE",
"low": "FAIBLE"
},
"time": {
"now": "À l'instant",
"minutes": "Il y a {count}min",
"hours": "Il y a {count}h",
"days": "Il y a {count}j"
},
"pagination": {
"info": "Page <b>{current}</b> sur <b>{total}</b> • <b>{items}</b> {items, plural, one {notification} other {notifications}} au total",
"previous": "Précédent",
"next": "Suivant"
}
} }
}, },
"components": { "components": {
@ -3383,41 +3317,42 @@
"bulkUpdate": "Mise à jour groupée", "bulkUpdate": "Mise à jour groupée",
"bulkUpdateSoon": "La mise à jour groupée arrive bientôt !" "bulkUpdateSoon": "La mise à jour groupée arrive bientôt !"
}, },
"notificationDropdown": { "notifications": {
"ariaLabel": "Notifications", "ariaLabel": "Notifications",
"ariaLabelWithCount": "{count, plural, one {# notification non lue} other {# notifications non lues}}",
"header": "Notifications", "header": "Notifications",
"markAllRead": "Tout marquer comme lu", "markAllRead": "Tout marquer comme lu",
"loading": "Chargement des notifications…",
"empty": "Aucune nouvelle notification",
"viewAll": "Voir toutes les notifications", "viewAll": "Voir toutes les notifications",
"time": { "unread": "Non lue",
"now": "À l'instant", "emptyTitle": "Vous êtes à jour",
"minutes": "Il y a {minutes} min", "emptyBody": "Les réponses de vos transporteurs et les changements sur vos dossiers apparaîtront ici.",
"hours": "Il y a {hours} h", "emptyFilterTitle": "Rien dans ce filtre",
"days": "Il y a {days} j" "emptyFilterBody": "Aucune notification ne correspond à cette famille pour le moment.",
} "errorTitle": "Notifications indisponibles",
}, "errorBody": "Vos notifications n’ont pas pu être chargées. Réessayez dans un instant.",
"notificationPanel": { "retry": "Réessayer",
"title": "Notifications", "pageTitle": "Notifications",
"totalCount": "{count, plural, one {# notification au total} other {# notifications au total}}", "pageDescription": "Les réponses de vos transporteurs et les changements sur vos dossiers.",
"closeAria": "Fermer le panneau", "filterLabel": "Filtrer les notifications",
"filters": { "filters": {
"all": "Toutes", "all": "Toutes",
"unread": "Non lues", "unread": "Non lues",
"read": "Lues" "booking": "Réservations",
"account": "Compte",
"system": "Système"
}, },
"markAllRead": "Tout marquer comme lu", "destination": {
"loading": "Chargement des notifications…", "booking": "Réservation {reference}",
"emptyTitle": "Aucune notification", "bookings": "Vos réservations",
"emptyUnread": "Vous êtes à jour !", "rates": "Recherche de tarifs",
"emptyAll": "Aucune notification à afficher", "account": "Votre organisation",
"deleteConfirm": "Voulez-vous vraiment supprimer cette notification ?", "bookingUnnamed": "La réservation concernée"
"deleteTitle": "Supprimer la notification", },
"viewDetails": "Voir les détails →", "time": {
"pagination": { "now": "À l’instant",
"page": "Page {current} sur {total}", "minutes": "Il y a {minutes} min",
"previous": "Précédent", "hours": "Il y a {hours} h",
"next": "Suivant" "days": "Il y a {days} j"
} }
} }
}, },

View File

@ -1,231 +0,0 @@
/**
* Notification Dropdown Component
*
* Displays real-time notifications with mark as read functionality
*/
'use client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useState, useRef, useEffect } from 'react';
import { useTranslations, useLocale } from 'next-intl';
import { listNotifications, markNotificationAsRead, markAllNotificationsAsRead } from '@/lib/api';
import type { NotificationResponse } from '@/types/api';
import NotificationPanel from './NotificationPanel';
import {
CheckCircle,
RefreshCw,
XCircle,
DollarSign,
Ship,
Settings,
AlertTriangle,
Bell,
Megaphone,
type LucideIcon,
} from 'lucide-react';
export default function NotificationDropdown() {
const t = useTranslations('components.notificationDropdown');
const locale = useLocale();
const dateLocale = locale === 'fr' ? 'fr-FR' : 'en-US';
const [isOpen, setIsOpen] = useState(false);
const [isPanelOpen, setIsPanelOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const queryClient = useQueryClient();
// Fetch unread notifications
const { data: notificationsData, isLoading } = useQuery({
queryKey: ['notifications', 'unread'],
queryFn: () => listNotifications({ isRead: false, limit: 10 }),
refetchInterval: 30000, // Refetch every 30 seconds
});
const notifications = notificationsData?.notifications || [];
const unreadCount = notifications.filter((n: NotificationResponse) => !n.read).length;
// Mark single notification as read
const markAsReadMutation = useMutation({
mutationFn: markNotificationAsRead,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notifications'] });
},
});
// Mark all as read
const markAllAsReadMutation = useMutation({
mutationFn: markAllNotificationsAsRead,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notifications'] });
},
});
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);
const handleNotificationClick = (notification: NotificationResponse) => {
if (!notification.read) {
markAsReadMutation.mutate(notification.id);
}
setIsOpen(false);
if (notification.actionUrl) {
window.location.href = notification.actionUrl;
}
};
const getNotificationIcon = (type: string): LucideIcon => {
const icons: Record<string, LucideIcon> = {
BOOKING_CONFIRMED: CheckCircle,
BOOKING_UPDATED: RefreshCw,
BOOKING_CANCELLED: XCircle,
RATE_ALERT: DollarSign,
CARRIER_UPDATE: Ship,
SYSTEM: Settings,
WARNING: AlertTriangle,
};
return icons[type] || Megaphone;
};
const formatTime = (dateString: string) => {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return t('time.now');
if (diffMins < 60) return t('time.minutes', { minutes: diffMins });
if (diffHours < 24) return t('time.hours', { hours: diffHours });
if (diffDays < 7) return t('time.days', { days: diffDays });
return date.toLocaleDateString(dateLocale);
};
return (
<div className="relative" ref={dropdownRef}>
{/* Notification Bell Button */}
<button
onClick={() => setIsOpen(!isOpen)}
className="relative p-2 text-neutral-600 hover:text-brand-navy hover:bg-neutral-100 rounded-lg transition-colors"
aria-label={t('ariaLabel')}
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
/>
</svg>
{unreadCount > 0 && (
<span className="absolute top-0 right-0 flex items-center justify-center w-5 h-5 text-xs font-bold text-white bg-red-500 rounded-full">
{unreadCount > 9 ? '9+' : unreadCount}
</span>
)}
</button>
{/* Dropdown Panel */}
{isOpen && (
<div className="absolute right-0 mt-2 w-96 bg-white rounded-lg shadow-lg border border-border z-50">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b">
<h3 className="text-sm font-semibold text-brand-navy">{t('header')}</h3>
{unreadCount > 0 && (
<button
onClick={() => markAllAsReadMutation.mutate()}
disabled={markAllAsReadMutation.isPending}
className="text-xs text-brand-navy hover:text-brand-blue font-medium disabled:opacity-50"
>
{t('markAllRead')}
</button>
)}
</div>
{/* Notifications List */}
<div className="max-h-96 overflow-y-auto">
{isLoading ? (
<div className="p-4 text-center text-sm text-neutral-500">{t('loading')}</div>
) : notifications.length === 0 ? (
<div className="p-8 text-center">
<Bell className="h-10 w-10 text-neutral-300 mx-auto mb-2" />
<p className="text-sm text-neutral-500">{t('empty')}</p>
</div>
) : (
<div className="divide-y">
{notifications.map((notification: NotificationResponse) => {
return (
<div
key={notification.id}
onClick={() => handleNotificationClick(notification)}
className={`block px-4 py-3 hover:bg-neutral-50 transition-colors cursor-pointer ${
!notification.read ? 'bg-brand-blue/5' : ''
}`}
>
<div className="flex items-start space-x-3">
<div className="flex-shrink-0">
{(() => {
const Icon = getNotificationIcon(notification.type);
return <Icon className="h-5 w-5 text-neutral-500" />;
})()}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<p className="text-sm font-medium text-brand-navy truncate">
{notification.title}
</p>
{!notification.read && (
<span className="w-2 h-2 bg-brand-blue rounded-full"></span>
)}
</div>
<p className="text-sm text-neutral-600 line-clamp-2">
{notification.message}
</p>
<div className="flex items-center justify-between mt-2">
<span className="text-xs text-neutral-500">
{formatTime(notification.createdAt)}
</span>
</div>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
{/* Footer */}
<div className="px-4 py-3 border-t bg-neutral-50">
<button
onClick={() => {
setIsOpen(false);
setIsPanelOpen(true);
}}
className="w-full text-center text-sm text-brand-navy hover:text-brand-blue font-medium py-2 hover:bg-brand-blue/5 rounded transition-colors"
>
{t('viewAll')}
</button>
</div>
</div>
)}
{/* Notification Panel (Sidebar) */}
<NotificationPanel isOpen={isPanelOpen} onClose={() => setIsPanelOpen(false)} />
</div>
);
}

View File

@ -1,356 +0,0 @@
/**
* Notification Panel Component
*
* Sidebar panel that displays all notifications with detailed view
*/
'use client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { useTranslations, useLocale } from 'next-intl';
import { useRouter } from 'next/navigation';
import {
listNotifications,
markNotificationAsRead,
markAllNotificationsAsRead,
deleteNotification,
} from '@/lib/api';
import type { NotificationResponse } from '@/types/api';
import {
X,
Trash2,
CheckCheck,
Filter,
Bell,
Package,
RefreshCw,
XCircle,
CheckCircle,
Mail,
Timer,
FileText,
Megaphone,
User,
Building2,
} from 'lucide-react';
import { useConfirm } from '@/components/ui/use-confirm';
interface NotificationPanelProps {
isOpen: boolean;
onClose: () => void;
}
export default function NotificationPanel({ isOpen, onClose }: NotificationPanelProps) {
const confirm = useConfirm();
const t = useTranslations('components.notificationPanel');
const tTime = useTranslations('components.notificationDropdown.time');
const locale = useLocale();
const dateLocale = locale === 'fr' ? 'fr-FR' : 'en-US';
const [selectedFilter, setSelectedFilter] = useState<'all' | 'unread' | 'read'>('all');
const [currentPage, setCurrentPage] = useState(1);
const queryClient = useQueryClient();
const router = useRouter();
const { data, isLoading } = useQuery({
queryKey: ['notifications', 'panel', selectedFilter, currentPage],
queryFn: () =>
listNotifications({
page: currentPage,
limit: 20,
isRead: selectedFilter === 'all' ? undefined : selectedFilter === 'read',
}),
enabled: isOpen,
});
const notifications = data?.notifications || [];
const total = data?.total || 0;
const totalPages = Math.ceil(total / 20);
const markAsReadMutation = useMutation({
mutationFn: markNotificationAsRead,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notifications'] });
},
});
const markAllAsReadMutation = useMutation({
mutationFn: markAllNotificationsAsRead,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notifications'] });
},
});
const deleteNotificationMutation = useMutation({
mutationFn: deleteNotification,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notifications'] });
},
});
const handleNotificationClick = (notification: NotificationResponse) => {
if (!notification.read) {
markAsReadMutation.mutate(notification.id);
}
if (notification.actionUrl) {
onClose();
router.push(notification.actionUrl);
}
};
const handleDelete = async (e: React.MouseEvent, notificationId: string) => {
e.stopPropagation();
if (await confirm({ title: t('deleteConfirm'), destructive: true })) {
deleteNotificationMutation.mutate(notificationId);
}
};
const getPriorityColor = (priority: string) => {
const colors = {
urgent: 'border-l-4 border-red-500 bg-red-50',
high: 'border-l-4 border-orange-500 bg-orange-50',
medium: 'border-l-4 border-yellow-500 bg-yellow-50',
low: 'border-l-4 border-brand-blue bg-brand-blue/5',
};
return colors[priority as keyof typeof colors] || 'border-l-4 border-neutral-300';
};
const getNotificationIconComponent = (type: string) => {
const icons: Record<string, typeof Bell> = {
booking_created: Package,
booking_updated: RefreshCw,
booking_cancelled: XCircle,
booking_confirmed: CheckCircle,
csv_booking_accepted: CheckCircle,
csv_booking_rejected: XCircle,
csv_booking_request_sent: Mail,
rate_quote_expiring: Timer,
document_uploaded: FileText,
system_announcement: Megaphone,
user_invited: User,
organization_update: Building2,
};
return icons[type.toLowerCase()] || Bell;
};
const formatTime = (dateString: string) => {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return tTime('now');
if (diffMins < 60) return tTime('minutes', { minutes: diffMins });
if (diffHours < 24) return tTime('hours', { hours: diffHours });
if (diffDays < 7) return tTime('days', { days: diffDays });
return date.toLocaleDateString(dateLocale, {
day: 'numeric',
month: 'short',
year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined,
});
};
if (!isOpen) return null;
return (
<>
{/* Backdrop */}
<div className="fixed inset-0 bg-black/30 z-40 transition-opacity" onClick={onClose} />
{/* Panel */}
<div className="fixed right-0 top-0 bottom-0 w-full max-w-2xl bg-white shadow-2xl z-50 flex flex-col animate-slide-in-right">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b bg-white">
<div>
<h2 className="text-xl font-bold text-brand-navy">{t('title')}</h2>
<p className="text-sm text-neutral-600 mt-1">{t('totalCount', { count: total })}</p>
</div>
<button
onClick={onClose}
className="p-2 hover:bg-neutral-100 rounded-lg transition-colors text-neutral-600"
aria-label={t('closeAria')}
>
<X className="w-6 h-6" />
</button>
</div>
{/* Filter Bar */}
<div className="flex items-center justify-between px-6 py-3 border-b bg-neutral-50">
<div className="flex items-center space-x-2">
<Filter className="w-4 h-4 text-neutral-500" />
<div className="flex space-x-1">
{(['all', 'unread', 'read'] as const).map(filter => (
<button
key={filter}
onClick={() => {
setSelectedFilter(filter);
setCurrentPage(1);
}}
className={`px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
selectedFilter === filter
? 'bg-brand-navy text-white'
: 'bg-white text-neutral-700 hover:bg-neutral-100 border border-neutral-300'
}`}
>
{t(`filters.${filter}` as any)}
</button>
))}
</div>
</div>
{selectedFilter === 'unread' && notifications.length > 0 && (
<button
onClick={() => markAllAsReadMutation.mutate()}
disabled={markAllAsReadMutation.isPending}
className="flex items-center space-x-1 text-sm text-brand-navy hover:text-brand-blue font-medium disabled:opacity-50"
>
<CheckCheck className="w-4 h-4" />
<span>{t('markAllRead')}</span>
</button>
)}
</div>
{/* Notifications List */}
<div className="flex-1 overflow-y-auto">
{isLoading ? (
<div className="flex items-center justify-center h-64">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-brand-blue mx-auto mb-4" />
<p className="text-sm text-neutral-500">{t('loading')}</p>
</div>
</div>
) : notifications.length === 0 ? (
<div className="flex items-center justify-center h-64">
<div className="text-center">
<Bell className="h-16 w-16 text-neutral-300 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-brand-navy mb-2">{t('emptyTitle')}</h3>
<p className="text-sm text-neutral-500">
{selectedFilter === 'unread' ? t('emptyUnread') : t('emptyAll')}
</p>
</div>
</div>
) : (
<div className="divide-y">
{notifications.map((notification: NotificationResponse) => (
<div
key={notification.id}
onClick={() => handleNotificationClick(notification)}
className={`p-6 hover:bg-neutral-50 transition-all cursor-pointer group ${
!notification.read ? 'bg-brand-blue/5/50' : ''
} ${getPriorityColor(notification.priority || 'low')}`}
>
<div className="flex items-start space-x-4">
{/* Icon */}
<div className="flex-shrink-0">
{(() => {
const Icon = getNotificationIconComponent(notification.type);
return <Icon className="h-6 w-6 text-neutral-500" />;
})()}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between mb-2">
<div className="flex items-center space-x-2">
<h3 className="text-base font-semibold text-brand-navy">
{notification.title}
</h3>
{!notification.read && (
<span className="w-2.5 h-2.5 bg-brand-blue rounded-full" />
)}
</div>
<button
onClick={e => handleDelete(e, notification.id)}
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 hover:bg-red-100 rounded"
title={t('deleteTitle')}
>
<Trash2 className="w-4 h-4 text-red-600" />
</button>
</div>
<p className="text-sm text-neutral-700 mb-3 leading-relaxed">
{notification.message}
</p>
{/* Metadata */}
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4 text-xs text-neutral-500">
<span className="font-medium">{formatTime(notification.createdAt)}</span>
<span className="px-2 py-0.5 bg-neutral-200 rounded-full text-neutral-700 font-medium">
{notification.type.replace(/_/g, ' ').toUpperCase()}
</span>
{notification.priority && (
<span
className={`px-2 py-0.5 rounded-full font-medium ${
notification.priority === 'urgent'
? 'bg-red-100 text-red-700'
: notification.priority === 'high'
? 'bg-orange-100 text-orange-700'
: notification.priority === 'medium'
? 'bg-yellow-100 text-yellow-700'
: 'bg-brand-blue/10 text-brand-navy'
}`}
>
{notification.priority.toUpperCase()}
</span>
)}
</div>
{notification.actionUrl && (
<span className="text-xs text-brand-navy font-medium group-hover:underline">
{t('viewDetails')}
</span>
)}
</div>
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between px-6 py-4 border-t bg-neutral-50">
<div className="text-sm text-neutral-600">
{t('pagination.page', { current: currentPage, total: totalPages })}
</div>
<div className="flex space-x-2">
<button
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="px-3 py-1.5 text-sm font-medium bg-white border border-neutral-300 rounded-md hover:bg-neutral-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
{t('pagination.previous')}
</button>
<button
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
className="px-3 py-1.5 text-sm font-medium bg-white border border-neutral-300 rounded-md hover:bg-neutral-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
{t('pagination.next')}
</button>
</div>
</div>
)}
</div>
<style jsx>{`
@keyframes slide-in-right {
from {
transform: translateX(100%);
}
to {
transform: translateX(0);
}
}
.animate-slide-in-right {
animation: slide-in-right 0.3s ease-out;
}
`}</style>
</>
);
}

View File

@ -0,0 +1,57 @@
import {
Building2,
FileText,
Megaphone,
Package,
Tag,
UserPlus,
type LucideIcon,
} from 'lucide-react';
/**
* Famille d'une notification : ce qu'elle concerne.
*
* L'icone n'est pas une decoration : c'est le premier repere qui permet de
* balayer une liste sans lire. Elle est donc indexee sur les **valeurs reelles**
* de l'enumeration du backend, en minuscules. La table precedente etait ecrite
* en MAJUSCULES et la moitie de ses cles n'existaient pas : toutes les
* notifications, sans exception, tombaient sur l'icone par defaut.
*/
export type NotificationKind = 'booking' | 'document' | 'rate' | 'account' | 'system';
const KIND_BY_TYPE: Record<string, NotificationKind> = {
booking_created: 'booking',
booking_updated: 'booking',
booking_cancelled: 'booking',
booking_confirmed: 'booking',
csv_booking_accepted: 'booking',
csv_booking_rejected: 'booking',
csv_booking_request_sent: 'booking',
document_uploaded: 'document',
rate_quote_expiring: 'rate',
user_invited: 'account',
organization_update: 'account',
system_announcement: 'system',
};
export function notificationKind(type: string): NotificationKind {
return KIND_BY_TYPE[type?.toLowerCase()] ?? 'system';
}
/**
* Une teinte par famille, prises dans la charte du produit.
*
* Le refus d'un transporteur n'est pas peint en rouge : le statut appartient au
* dossier, pas au canal qui l'annonce. Colorer la ligne rendrait une liste de
* dix notifications illisible.
*/
export const KIND_STYLE: Record<NotificationKind, { icon: LucideIcon; tile: string }> = {
booking: { icon: Package, tile: 'bg-brand-blue-soft text-brand-blue' },
document: { icon: FileText, tile: 'bg-neutral-100 text-neutral-600' },
rate: { icon: Tag, tile: 'bg-neutral-100 text-neutral-600' },
account: { icon: UserPlus, tile: 'bg-neutral-100 text-brand-navy' },
system: { icon: Megaphone, tile: 'bg-amber-50 text-amber-700' },
};
/** Famille des mises a jour d'organisation, distinguee d'une invitation. */
export const ORGANISATION_ICON: LucideIcon = Building2;

View File

@ -0,0 +1,138 @@
'use client';
import * as React from 'react';
import { Bell, CheckCheck } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { Link } from '@/i18n/navigation';
import { Button } from '@/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Skeleton } from '@/components/ui/states';
import { useNotifications } from '@/hooks/useNotifications';
import type { NotificationResponse } from '@/types/api';
import { NotificationRow } from './notification-row';
import { useNotificationLabels } from './use-notification-labels';
/** Au-dela, le menu devient une liste : c'est le role de la page. */
const PREVIEW_COUNT = 6;
/**
* Cloche et apercu des notifications.
*
* Remplace un menu ecrit a la main — etat local, ecouteur de clic exterieur,
* `<svg>` de cloche recopie, `window.location.href` — par les primitives du
* produit : `Popover` gere la fermeture, le focus et l'echappement, et `Link`
* navigue sans recharger l'application ni perdre le prefixe de langue.
*
* Le panneau lateral qui doublait cet apercu a ete supprime : « Voir tout »
* mene desormais a la page `/dashboard/notifications`, qui existait deja mais
* n'etait liee de nulle part.
*/
export function NotificationMenu() {
const t = useTranslations('components.notifications');
const [open, setOpen] = React.useState(false);
const { notifications, unreadCount, isLoading, markAsRead, markAllAsRead, isMarkingAll } =
useNotifications({ unreadOnly: false, limit: PREVIEW_COUNT });
const labels = useNotificationLabels();
const openNotification = (notification: NotificationResponse) => {
if (!notification.read) markAsRead(notification.id);
setOpen(false);
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className="relative"
aria-label={
unreadCount > 0 ? t('ariaLabelWithCount', { count: unreadCount }) : t('ariaLabel')
}
>
<Bell />
{unreadCount > 0 && (
// Pastille de comptage : le chiffre exact vient du serveur, la
// version precedente comptait les non-lues de la page chargee et
// plafonnait donc a dix.
<span
aria-hidden="true"
className="absolute -right-0.5 -top-0.5 flex min-w-[1.125rem] items-center justify-center rounded-full bg-brand-blue px-1 text-[0.625rem] font-semibold leading-4 text-white"
>
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-[22rem] p-0 sm:w-[26rem]">
<div className="flex items-center justify-between border-b border-border px-4 py-3">
<h2 className="truncate font-heading text-body-sm font-semibold text-brand-navy">
{t('header')}
</h2>
{unreadCount > 0 && (
<Button
variant="ghost"
size="sm"
onClick={() => markAllAsRead()}
loading={isMarkingAll}
className="shrink-0 text-neutral-500 hover:text-brand-navy"
>
<CheckCheck aria-hidden="true" />
{t('markAllRead')}
</Button>
)}
</div>
<div className="max-h-[24rem] overflow-y-auto" role="log" aria-live="polite">
{isLoading ? (
<div className="space-y-3 p-4" aria-hidden="true">
{Array.from({ length: 3 }).map((_, index) => (
<div key={index} className="flex gap-3">
<Skeleton className="size-8 shrink-0 rounded-md" />
<div className="flex-1 space-y-2">
<Skeleton className="h-3 w-1/2" />
<Skeleton className="h-3 w-full" />
</div>
</div>
))}
</div>
) : notifications.length === 0 ? (
<div className="px-6 py-10 text-center">
<Bell className="mx-auto mb-3 size-8 text-neutral-300" aria-hidden="true" />
<p className="font-heading text-body-sm font-medium text-brand-navy">
{t('emptyTitle')}
</p>
<p className="mt-1 text-body-xs leading-5 text-neutral-500">{t('emptyBody')}</p>
</div>
) : (
<ul className="divide-y divide-border">
{notifications.map(notification => (
<li key={notification.id}>
<NotificationRow
notification={notification}
labels={labels}
onOpen={openNotification}
/>
</li>
))}
</ul>
)}
</div>
<div className="border-t border-border p-2">
<Button asChild variant="ghost" size="sm" className="w-full">
<Link href="/dashboard/notifications" onClick={() => setOpen(false)}>
{t('viewAll')}
</Link>
</Button>
</div>
</PopoverContent>
</Popover>
);
}
/** Conserve pour les imports existants du chrome applicatif. */
export default NotificationMenu;

View File

@ -0,0 +1,127 @@
'use client';
import * as React from 'react';
import { ArrowRight } from 'lucide-react';
import { Link } from '@/i18n/navigation';
import { cn } from '@/lib/utils';
import type { NotificationResponse } from '@/types/api';
import { KIND_STYLE, notificationKind } from './notification-kind';
/**
* Une notification dans une liste.
*
* Le parti pris : **une notification est un pointeur, pas un message.** La ligne
* entiere est donc la cible du clic, et elle annonce ou elle mene avant qu'on
* clique — « Réservation XPD-2026-… » plutot qu'une fleche muette. Une
* notification sans destination n'est pas rendue cliquable : mieux vaut un
* texte inerte qu'un clic qui ne fait rien, ce qui etait le cas de toutes les
* notifications du produit.
*
* Le non-lu est porte par la structure — un filet vertical et la graisse du
* titre — et non par un aplat de couleur : dix lignes teintees ne se lisent pas.
*/
export interface NotificationRowLabels {
destination: (notification: NotificationResponse) => string | null;
time: (iso: string) => string;
unread: string;
}
export interface NotificationRowProps {
notification: NotificationResponse;
labels: NotificationRowLabels;
/** Marque comme lue ; declenche aussi la navigation quand il y a une cible. */
onOpen: (notification: NotificationResponse) => void;
/** Densite : `compact` dans le menu, `comfortable` sur la page. */
density?: 'compact' | 'comfortable';
}
export function NotificationRow({
notification,
labels,
onOpen,
density = 'compact',
}: NotificationRowProps) {
const kind = notificationKind(notification.type);
const { icon: Icon, tile } = KIND_STYLE[kind];
const destination = labels.destination(notification);
const unread = !notification.read;
const body = (
<>
<span
className={cn(
'flex shrink-0 items-center justify-center rounded-md',
density === 'compact' ? 'size-8' : 'size-9',
tile
)}
aria-hidden="true"
>
<Icon className={density === 'compact' ? 'size-4' : 'size-[18px]'} />
</span>
<span className="min-w-0 flex-1">
<span className="flex items-baseline justify-between gap-3">
<span
className={cn(
'truncate font-heading text-body-sm text-brand-navy',
unread ? 'font-semibold' : 'font-medium'
)}
>
{notification.title}
</span>
<span className="shrink-0 whitespace-nowrap text-body-xs text-neutral-400">
{labels.time(notification.createdAt)}
</span>
</span>
<span
className={cn(
'mt-0.5 block text-body-sm leading-6 text-neutral-600',
density === 'compact' && 'line-clamp-2'
)}
>
{notification.message}
</span>
{destination && (
// Annoncer la destination rend le clic previsible : on sait ou l'on va
// avant d'y aller, ce qui compte quand la liste melange les sujets.
<span className="mt-2 inline-flex items-center gap-1 text-body-xs font-medium text-brand-blue">
{destination}
<ArrowRight className="size-3" aria-hidden="true" />
</span>
)}
</span>
</>
);
const shell = cn(
'flex w-full gap-3 border-l-2 px-4 text-left transition-colors duration-150 ease-out',
density === 'compact' ? 'py-3' : 'py-4',
unread ? 'border-brand-blue bg-brand-blue-soft/40' : 'border-transparent'
);
if (!notification.actionUrl) {
return (
<div className={shell}>
{body}
{unread && <span className="sr-only">{labels.unread}</span>}
</div>
);
}
return (
<Link
href={notification.actionUrl}
onClick={() => onOpen(notification)}
className={cn(
shell,
'hover:bg-neutral-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring',
unread && 'hover:bg-brand-blue-soft/70'
)}
>
{body}
{unread && <span className="sr-only">{labels.unread}</span>}
</Link>
);
}

View File

@ -0,0 +1,69 @@
'use client';
import { useCallback, useMemo } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import type { NotificationResponse } from '@/types/api';
import type { NotificationRowLabels } from './notification-row';
import { notificationKind } from './notification-kind';
/**
* Libelles partages par le menu et la page.
*
* La destination affichee est deduite du **type** et des metadonnees, pas de
* l'URL : « Réservation XPD-2026-R9KE8U » se lit, `/dashboard/bookings/b1e2…`
* non. Quand la reference du dossier est connue, elle est nommee ; sinon la
* destination reste generique plutot que d'inventer un intitule.
*/
export function useNotificationLabels(): NotificationRowLabels {
const t = useTranslations('components.notifications');
const locale = useLocale();
const dateLocale = locale === 'fr' ? 'fr-FR' : 'en-US';
const destination = useCallback(
(notification: NotificationResponse): string | null => {
if (!notification.actionUrl) return null;
switch (notificationKind(notification.type)) {
case 'booking':
case 'document': {
const reference = notification.metadata?.bookingNumber;
if (typeof reference === 'string' && reference) {
return t('destination.booking', { reference });
}
// Le lien vise un dossier precis mais son numero n'a pas ete
// enregistre : le nommer au singulier reste honnete, la ou « vos
// reservations » laisserait croire a la liste.
return notification.metadata?.bookingId
? t('destination.bookingUnnamed')
: t('destination.bookings');
}
case 'rate':
return t('destination.rates');
case 'account':
return t('destination.account');
case 'system':
return null;
}
},
[t]
);
const time = useCallback(
(iso: string): string => {
const minutes = Math.floor((Date.now() - new Date(iso).getTime()) / 60000);
if (minutes < 1) return t('time.now');
if (minutes < 60) return t('time.minutes', { minutes });
const hours = Math.floor(minutes / 60);
if (hours < 24) return t('time.hours', { hours });
const days = Math.floor(hours / 24);
if (days < 7) return t('time.days', { days });
return new Date(iso).toLocaleDateString(dateLocale, { day: 'numeric', month: 'short' });
},
[t, dateLocale]
);
return useMemo(() => ({ destination, time, unread: t('unread') }), [destination, time, t]);
}

View File

@ -5,7 +5,7 @@ import { Menu, Search } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import LanguageSwitcher from '@/components/LanguageSwitcher'; import LanguageSwitcher from '@/components/LanguageSwitcher';
import NotificationDropdown from '@/components/NotificationDropdown'; import { NotificationMenu } from '@/components/notifications/notification-menu';
import { Breadcrumb } from './breadcrumb'; import { Breadcrumb } from './breadcrumb';
import type { NavGroup, ShellVariant } from './nav-config'; import type { NavGroup, ShellVariant } from './nav-config';
@ -90,7 +90,7 @@ export function Topbar({
</Button> </Button>
<LanguageSwitcher variant="light" /> <LanguageSwitcher variant="light" />
{showNotifications && <NotificationDropdown />} {showNotifications && <NotificationMenu />}
</div> </div>
</header> </header>
); );

View File

@ -1,108 +1,77 @@
/** /**
* useNotifications Hook * useNotifications
* *
* Custom hook for managing notifications with automatic polling * Source unique des notifications : la cloche, la page et tout futur écran
* Polls the API every 30 seconds for new notifications * lisent le même état.
*/ */
'use client'; 'use client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { listNotifications, markNotificationAsRead, markAllNotificationsAsRead } from '@/lib/api'; import {
getUnreadNotificationCount,
listNotifications,
markAllNotificationsAsRead,
markNotificationAsRead,
} from '@/lib/api';
import type { NotificationResponse } from '@/types/api'; import type { NotificationResponse } from '@/types/api';
interface UseNotificationsOptions { interface UseNotificationsOptions {
/** /** Ne remonter que les non-lues. Par défaut, tout, du plus récent au plus ancien. */
* Whether to fetch only unread notifications
* @default true
*/
unreadOnly?: boolean; unreadOnly?: boolean;
/** /** Nombre maximum de notifications chargées. */
* Maximum number of notifications to fetch
* @default 10
*/
limit?: number; limit?: number;
/** /** Intervalle de rafraîchissement, en millisecondes. */
* Polling interval in milliseconds
* @default 30000 (30 seconds)
*/
refetchInterval?: number; refetchInterval?: number;
} }
export function useNotifications(options: UseNotificationsOptions = {}) { export function useNotifications(options: UseNotificationsOptions = {}) {
const { unreadOnly = true, limit = 10, refetchInterval = 30000 } = options; const { unreadOnly = false, limit = 10, refetchInterval = 30000 } = options;
const queryClient = useQueryClient(); const queryClient = useQueryClient();
// Fetch notifications with automatic polling const list = useQuery({
const { data, isLoading, refetch, error } = useQuery({ queryKey: ['notifications', { unreadOnly, limit }],
queryKey: ['notifications', { isRead: !unreadOnly, limit }], queryFn: () => listNotifications(unreadOnly ? { isRead: false, limit } : { limit }),
queryFn: () => listNotifications({ isRead: !unreadOnly, limit }), refetchInterval,
refetchInterval, // Poll every 30 seconds by default refetchOnWindowFocus: true,
refetchOnWindowFocus: true, // Refetch when window regains focus
}); });
const notifications = data?.notifications || []; /**
const unreadCount = notifications.filter((n: NotificationResponse) => !n.read).length; * Le compteur vient du serveur.
*
* Il etait auparavant calcule sur la page chargee — dix elements — et ne
* pouvait donc jamais depasser dix, quel que soit le nombre reel de non-lues.
*/
const count = useQuery({
queryKey: ['notifications', 'unread-count'],
queryFn: getUnreadNotificationCount,
refetchInterval,
refetchOnWindowFocus: true,
});
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['notifications'] });
// Mark single notification as read
const markAsReadMutation = useMutation({ const markAsReadMutation = useMutation({
mutationFn: markNotificationAsRead, mutationFn: markNotificationAsRead,
onSuccess: () => { onSuccess: invalidate,
// Invalidate all notification queries to refetch
queryClient.invalidateQueries({ queryKey: ['notifications'] });
},
}); });
// Mark all notifications as read
const markAllAsReadMutation = useMutation({ const markAllAsReadMutation = useMutation({
mutationFn: markAllNotificationsAsRead, mutationFn: markAllNotificationsAsRead,
onSuccess: () => { onSuccess: invalidate,
// Invalidate all notification queries to refetch
queryClient.invalidateQueries({ queryKey: ['notifications'] });
},
}); });
/** const notifications: NotificationResponse[] = list.data?.notifications ?? [];
* Mark a single notification as read
* @param id - Notification ID
*/
const markAsRead = async (id: string) => {
return markAsReadMutation.mutateAsync(id);
};
/**
* Mark all notifications as read
*/
const markAllAsRead = async () => {
return markAllAsReadMutation.mutateAsync();
};
/**
* Manually refetch notifications
*/
const refresh = () => {
return refetch();
};
return { return {
/** Array of notifications */
notifications, notifications,
/** Count of unread notifications */ total: list.data?.total ?? notifications.length,
unreadCount, unreadCount: count.data?.count ?? 0,
/** Mark a single notification as read */ isLoading: list.isPending,
markAsRead, isError: list.isError,
/** Mark all notifications as read */ refetch: list.refetch,
markAllAsRead, markAsRead: markAsReadMutation.mutate,
/** Manually refresh notifications */ markAllAsRead: markAllAsReadMutation.mutate,
refresh, isMarkingAll: markAllAsReadMutation.isPending,
/** Loading state */
isLoading,
/** Error state */
error,
/** Whether marking as read is in progress */
isMarkingAsRead: markAsReadMutation.isPending,
/** Whether marking all as read is in progress */
isMarkingAllAsRead: markAllAsReadMutation.isPending,
}; };
} }

View File

@ -84,6 +84,7 @@ export {
// Notifications (7 endpoints) // Notifications (7 endpoints)
export { export {
listNotifications, listNotifications,
getUnreadNotificationCount,
getNotification, getNotification,
createNotification, createNotification,
markNotificationAsRead, markNotificationAsRead,

View File

@ -53,6 +53,14 @@ export async function listNotifications(params?: {
); );
} }
/**
* Nombre exact de notifications non lues.
* GET /api/v1/notifications/unread/count
*/
export async function getUnreadNotificationCount(): Promise<{ count: number }> {
return get<{ count: number }>('/api/v1/notifications/unread/count');
}
/** /**
* Get notification by ID * Get notification by ID
* GET /api/v1/notifications/:id * GET /api/v1/notifications/:id