From 33d70a16dd7dde60530179fb693806a493b8c9e1 Mon Sep 17 00:00:00 2001 From: David Date: Mon, 7 Sep 2026 21:40:55 +0200 Subject: [PATCH 1/4] feat(domain): deriver la destination d une notification de son type Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018BAUeCFpDkRD6tU5wGsc1C --- .../services/notification-target.spec.ts | 95 +++++++++++++++++++ .../domain/services/notification-target.ts | 59 ++++++++++++ 2 files changed, 154 insertions(+) create mode 100644 apps/backend/src/domain/services/notification-target.spec.ts create mode 100644 apps/backend/src/domain/services/notification-target.ts diff --git a/apps/backend/src/domain/services/notification-target.spec.ts b/apps/backend/src/domain/services/notification-target.spec.ts new file mode 100644 index 0000000..ab7c688 --- /dev/null +++ b/apps/backend/src/domain/services/notification-target.spec.ts @@ -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/` 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); + } + }); +}); diff --git a/apps/backend/src/domain/services/notification-target.ts b/apps/backend/src/domain/services/notification-target.ts new file mode 100644 index 0000000..7a2f9c6 --- /dev/null +++ b/apps/backend/src/domain/services/notification-target.ts @@ -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 | 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; +} From f67de177e87c096013962e5627911e3a5e916a1f Mon Sep 17 00:00:00 2001 From: David Date: Mon, 7 Sep 2026 21:40:56 +0200 Subject: [PATCH 2/4] feat(api): servir la destination et le compteur de non-lues Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018BAUeCFpDkRD6tU5wGsc1C --- .../application/controllers/notifications.controller.ts | 8 +++++++- .../application/controllers/organizations.controller.ts | 1 - .../src/application/gateways/notifications.gateway.ts | 6 +++++- .../src/application/services/notification.service.ts | 4 ---- 4 files changed, 12 insertions(+), 7 deletions(-) diff --git a/apps/backend/src/application/controllers/notifications.controller.ts b/apps/backend/src/application/controllers/notifications.controller.ts index e66a090..1a3b06f 100644 --- a/apps/backend/src/application/controllers/notifications.controller.ts +++ b/apps/backend/src/application/controllers/notifications.controller.ts @@ -22,6 +22,7 @@ import { NotificationService } from '../services/notification.service'; import { JwtAuthGuard } from '../guards/jwt-auth.guard'; import { CurrentUser, UserPayload } from '../decorators/current-user.decorator'; import { Notification } from '@domain/entities/notification.entity'; +import { notificationTarget } from '@domain/services/notification-target'; class NotificationResponseDto { id: string; @@ -200,7 +201,12 @@ export class NotificationsController { metadata: notification.metadata, read: notification.read, 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(), }; } diff --git a/apps/backend/src/application/controllers/organizations.controller.ts b/apps/backend/src/application/controllers/organizations.controller.ts index bb272d4..d19b8ae 100644 --- a/apps/backend/src/application/controllers/organizations.controller.ts +++ b/apps/backend/src/application/controllers/organizations.controller.ts @@ -356,7 +356,6 @@ export class OrganizationsController { siren: organization.siren, requestedBy: user.email, }, - actionUrl: `/dashboard/admin/organizations`, }) ) ); diff --git a/apps/backend/src/application/gateways/notifications.gateway.ts b/apps/backend/src/application/gateways/notifications.gateway.ts index 0e52814..739aace 100644 --- a/apps/backend/src/application/gateways/notifications.gateway.ts +++ b/apps/backend/src/application/gateways/notifications.gateway.ts @@ -18,6 +18,7 @@ import { Logger, UseGuards } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { NotificationService } from '../services/notification.service'; import { Notification } from '@domain/entities/notification.entity'; +import { notificationTarget } from '@domain/services/notification-target'; /** * WebSocket authentication guard @@ -236,7 +237,10 @@ export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisco metadata: notification.metadata, read: notification.read, readAt: notification.readAt?.toISOString(), - actionUrl: notification.actionUrl, + actionUrl: + notification.actionUrl ?? + notificationTarget(notification.type, notification.metadata) ?? + undefined, createdAt: notification.createdAt.toISOString(), }; } diff --git a/apps/backend/src/application/services/notification.service.ts b/apps/backend/src/application/services/notification.service.ts index 9ee4c23..2deac6a 100644 --- a/apps/backend/src/application/services/notification.service.ts +++ b/apps/backend/src/application/services/notification.service.ts @@ -157,7 +157,6 @@ export class NotificationService { title: 'Booking Created', message: `Your booking ${bookingNumber} has been created successfully.`, metadata: { bookingId, bookingNumber }, - actionUrl: `/bookings/${bookingId}`, }); } @@ -176,7 +175,6 @@ export class NotificationService { title: 'Booking Updated', message: `Booking ${bookingNumber} status changed to ${status}.`, metadata: { bookingId, bookingNumber, status }, - actionUrl: `/bookings/${bookingId}`, }); } @@ -194,7 +192,6 @@ export class NotificationService { title: 'Booking Confirmed', message: `Your booking ${bookingNumber} has been confirmed by the carrier.`, metadata: { bookingId, bookingNumber }, - actionUrl: `/bookings/${bookingId}`, }); } @@ -212,7 +209,6 @@ export class NotificationService { title: 'Document Uploaded', message: `Document "${documentName}" has been uploaded for your booking.`, metadata: { documentName, bookingId }, - actionUrl: `/bookings/${bookingId}`, }); } } From abe3bfe9ae92922e0833495a52a7caa2d7cfa0e4 Mon Sep 17 00:00:00 2001 From: David Date: Mon, 7 Sep 2026 21:40:56 +0200 Subject: [PATCH 3/4] feat(ui): refonte du menu et de la page de notifications Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018BAUeCFpDkRD6tU5wGsc1C --- .../[locale]/dashboard/notifications/page.tsx | 549 +++++------------- apps/frontend/messages/en.json | 161 ++--- apps/frontend/messages/fr.json | 161 ++--- .../src/components/NotificationDropdown.tsx | 231 -------- .../src/components/NotificationPanel.tsx | 356 ------------ .../notifications/notification-kind.ts | 57 ++ .../notifications/notification-menu.tsx | 138 +++++ .../notifications/notification-row.tsx | 127 ++++ .../notifications/use-notification-labels.tsx | 69 +++ apps/frontend/src/components/shell/topbar.tsx | 4 +- apps/frontend/src/hooks/useNotifications.ts | 119 ++-- apps/frontend/src/lib/api/index.ts | 1 + apps/frontend/src/lib/api/notifications.ts | 8 + 13 files changed, 693 insertions(+), 1288 deletions(-) delete mode 100644 apps/frontend/src/components/NotificationDropdown.tsx delete mode 100644 apps/frontend/src/components/NotificationPanel.tsx create mode 100644 apps/frontend/src/components/notifications/notification-kind.ts create mode 100644 apps/frontend/src/components/notifications/notification-menu.tsx create mode 100644 apps/frontend/src/components/notifications/notification-row.tsx create mode 100644 apps/frontend/src/components/notifications/use-notification-labels.tsx diff --git a/apps/frontend/app/[locale]/dashboard/notifications/page.tsx b/apps/frontend/app/[locale]/dashboard/notifications/page.tsx index 94a7242..e999de2 100644 --- a/apps/frontend/app/[locale]/dashboard/notifications/page.tsx +++ b/apps/frontend/app/[locale]/dashboard/notifications/page.tsx @@ -1,425 +1,178 @@ 'use client'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { useState } from 'react'; -import { useRouter } from 'next/navigation'; -import { useTranslations, useLocale } from 'next-intl'; +import { useMemo, useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { Bell, CheckCheck } from 'lucide-react'; +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 { - listNotifications, - markNotificationAsRead, - markAllNotificationsAsRead, - deleteNotification, -} from '@/lib/api'; + notificationKind, + type NotificationKind, +} from '@/components/notifications/notification-kind'; +import { useNotifications } from '@/hooks/useNotifications'; +import { cn } from '@/lib/utils'; 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() { - const t = useTranslations('dashboard.notificationsPage'); - 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 tCommon = useTranslations('common'); - const [notificationToDelete, setNotificationToDelete] = useState(null); + const t = useTranslations('components.notifications'); + const [filter, setFilter] = useState('all'); - const { data, isLoading } = useQuery({ - queryKey: ['notifications', 'page', selectedFilter, currentPage], - queryFn: () => - listNotifications({ - page: currentPage, - limit: 20, - isRead: selectedFilter === 'all' ? undefined : selectedFilter === 'read', - }), - }); + const { + notifications, + unreadCount, + isLoading, + isError, + refetch, + markAsRead, + markAllAsRead, + isMarkingAll, + } = useNotifications({ limit: PAGE_SIZE }); - const notifications = data?.notifications || []; - const total = data?.total || 0; - const totalPages = Math.ceil(total / 20); - const unreadCount = notifications.filter((n: NotificationResponse) => !n.read).length; + const labels = useNotificationLabels(); - const markAsReadMutation = useMutation({ - mutationFn: markNotificationAsRead, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['notifications'] }); - }, - }); + const visible = useMemo(() => { + if (filter === 'all') return notifications; + if (filter === 'unread') return notifications.filter(n => !n.read); + return notifications.filter(n => matchesKind(n, filter)); + }, [notifications, filter]); - 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) { - 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 = { - 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 = { - booking_created: , - booking_updated: , - booking_cancelled: , - booking_confirmed: , - csv_booking_accepted: , - csv_booking_rejected: , - csv_booking_request_sent: , - rate_quote_expiring: , - document_uploaded: , - system_announcement: , - user_invited: , - organization_update: , - }; - return icons[type.toLowerCase()] || ; - }; - - 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', - }); + const countFor = (value: Filter) => { + if (value === 'all') return notifications.length; + if (value === 'unread') return unreadCount; + return notifications.filter(n => matchesKind(n, value)).length; }; return ( -
-
-
-
-
-
- -
-
-

{t('title')}

-

- {t('totalLabel', { count: total })} - {unreadCount > 0 && t('unreadSuffix', { count: unreadCount })} -

-
-
- {unreadCount > 0 && ( - - )} -
-
-
- -
-
-
- - {t('filter.label')} -
- {(['all', 'unread', 'read'] as const).map(filter => ( + <> + 0 && ( + + ) + } + tabs={ +
+ {FILTERS.map(value => { + const active = filter === value; + const count = countFor(value); + return ( - ))} -
+ ); + })}
-
+ } + /> -
- {isLoading ? ( -
-
-
-

{t('loading')}

-
-
- ) : notifications.length === 0 ? ( -
-
-
- +
+ {isLoading ? ( + - ) : ( -
- {notifications.map((notification: NotificationResponse) => ( -
handleNotificationClick(notification)} - className={`p-6 transition-all cursor-pointer group ${ - !notification.read ? 'bg-brand-blue/5/50' : '' - } ${getPriorityColor(notification.priority || 'low')}`} - > -
-
- {getNotificationIcon(notification.type)} -
- -
-
-
-

- {notification.title} -

- {!notification.read && ( - - - {t('new')} - - )} -
- -
- -

- {notification.message} -

- -
-
- - - - - - {formatTime(notification.createdAt)} - - - - {notification.type.replace(/_/g, ' ').toUpperCase()} - - {notification.priority && ( - - {getPriorityLabel(notification.priority)} - - )} -
- {notification.actionUrl && ( - - {t('viewDetails')} - - - - - )} -
-
-
-
- ))} -
- )} -
- - {totalPages > 1 && ( -
-
-
- {t.rich('pagination.info', { - current: currentPage, - total: totalPages, - items: total, - b: chunks => {chunks}, - })} -
-
- -
- {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 ( - - ); - })} -
- -
-
+ ))}
+ ) : isError ? ( + refetch()} + /> + ) : visible.length === 0 ? ( + setFilter('all')}> + {t('filters.all')} + + ) + } + /> + ) : ( +
    + {visible.map(notification => ( +
  • + !n.read && markAsRead(n.id)} + /> +
  • + ))} +
)}
- - !open && setNotificationToDelete(null)} - title={t('deleteConfirm')} - confirmLabel={tCommon('delete')} - cancelLabel={tCommon('cancel')} - variant="destructive" - onConfirm={() => { - if (notificationToDelete) deleteNotificationMutation.mutate(notificationToDelete); - setNotificationToDelete(null); - }} - /> -
+ ); } + +/** `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, NotificationKind[]> = { + booking: ['booking', 'document', 'rate'], + account: ['account'], + system: ['system'], + }; + return family[filter as Exclude].includes(kind); +} diff --git a/apps/frontend/messages/en.json b/apps/frontend/messages/en.json index 5472d55..2e2abe6 100644 --- a/apps/frontend/messages/en.json +++ b/apps/frontend/messages/en.json @@ -717,54 +717,27 @@ } }, "bookingDetail": { - "back": "← Back to bookings", + "back": "Back to bookings", "notFound": "Booking not found", - "timeline": { - "title": "Timeline", - "created": "Booking Created" - }, - "createdOn": "Created on {date}", - "downloadPdf": "Download PDF", - "pdfNotImplemented": "PDF download functionality is not yet implemented", - "status": { - "draft": "Draft", - "pending": "Pending", - "confirmed": "Confirmed", - "in_transit": "In transit", - "delivered": "Delivered", - "cancelled": "Cancelled" - }, - "cargo": { - "title": "Cargo Details", - "description": "Description", - "specialInstructions": "Special Instructions" - }, - "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" - } + "notFoundBody": "It may have been deleted, or it does not belong to you.", + "errorTitle": "Booking unavailable", + "errorBody": "The booking could not be loaded. Try again in a moment.", + "requestedOn": "Requested on {date}", + "carrier": "Carrier: {carrier}", + "transitDays": "{days} d transit", + "volume": "Volume", + "weight": "Weight", + "documents": "{count, plural, =0 {Documents} one {# document} other {# documents}}", + "noDocuments": "No document attached", + "freight": "Freight", + "destinationCharges": "Destination charges", + "commission": "Xpeditis commission", + "timeline": "Timeline", + "requested": "Request sent", + "responded": "Carrier reply", + "reference": "Internal reference", + "rejected": "Request rejected", + "packing": "Packing" }, "profile": { "loading": "Loading profile...", @@ -3181,45 +3154,6 @@ "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 {current} of {total} • {items} {items, plural, one {notification} other {notifications}} total", - "previous": "Previous", - "next": "Next" - } } }, "components": { @@ -3383,42 +3317,43 @@ "bulkUpdate": "Bulk update", "bulkUpdateSoon": "Bulk update is coming soon!" }, - "notificationDropdown": { + "notifications": { "ariaLabel": "Notifications", + "ariaLabelWithCount": "{count, plural, one {# unread notification} other {# unread notifications}}", "header": "Notifications", "markAllRead": "Mark all as read", - "loading": "Loading notifications…", - "empty": "No new notifications", - "viewAll": "View all notifications", + "viewAll": "See all notifications", + "unread": "Unread", + "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": { "now": "Just now", "minutes": "{minutes} min ago", "hours": "{hours} h 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": { diff --git a/apps/frontend/messages/fr.json b/apps/frontend/messages/fr.json index 3ebdf3c..309f44c 100644 --- a/apps/frontend/messages/fr.json +++ b/apps/frontend/messages/fr.json @@ -717,54 +717,27 @@ } }, "bookingDetail": { - "back": "← Retour aux réservations", + "back": "Retour aux réservations", "notFound": "Réservation introuvable", - "timeline": { - "title": "Chronologie", - "created": "Réservation créée" - }, - "createdOn": "Créée le {date}", - "downloadPdf": "Télécharger le PDF", - "pdfNotImplemented": "Le téléchargement PDF n'est pas encore disponible", - "status": { - "draft": "Brouillon", - "pending": "En attente", - "confirmed": "Confirmée", - "in_transit": "En transit", - "delivered": "Livrée", - "cancelled": "Annulée" - }, - "cargo": { - "title": "Détails de la cargaison", - "description": "Description", - "specialInstructions": "Instructions spéciales" - }, - "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" - } + "notFoundBody": "Elle a peut-être été supprimée, ou elle ne vous appartient pas.", + "errorTitle": "Réservation indisponible", + "errorBody": "Le dossier n’a pas pu être chargé. Réessayez dans un instant.", + "requestedOn": "Demandée le {date}", + "carrier": "Transporteur : {carrier}", + "transitDays": "{days} j de transit", + "volume": "Volume", + "weight": "Poids", + "documents": "{count, plural, =0 {Documents} one {# document} other {# documents}}", + "noDocuments": "Aucun document joint", + "freight": "Fret", + "destinationCharges": "Frais à destination", + "commission": "Commission Xpeditis", + "timeline": "Suivi", + "requested": "Demande envoyée", + "responded": "Réponse du transporteur", + "reference": "Référence interne", + "rejected": "Demande refusée", + "packing": "Colisage" }, "profile": { "loading": "Chargement du profil...", @@ -3181,45 +3154,6 @@ "É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 {current} sur {total} • {items} {items, plural, one {notification} other {notifications}} au total", - "previous": "Précédent", - "next": "Suivant" - } } }, "components": { @@ -3383,41 +3317,42 @@ "bulkUpdate": "Mise à jour groupée", "bulkUpdateSoon": "La mise à jour groupée arrive bientôt !" }, - "notificationDropdown": { + "notifications": { "ariaLabel": "Notifications", + "ariaLabelWithCount": "{count, plural, one {# notification non lue} other {# notifications non lues}}", "header": "Notifications", "markAllRead": "Tout marquer comme lu", - "loading": "Chargement des notifications…", - "empty": "Aucune nouvelle notification", "viewAll": "Voir toutes les notifications", - "time": { - "now": "À l'instant", - "minutes": "Il y a {minutes} min", - "hours": "Il y a {hours} h", - "days": "Il y a {days} j" - } - }, - "notificationPanel": { - "title": "Notifications", - "totalCount": "{count, plural, one {# notification au total} other {# notifications au total}}", - "closeAria": "Fermer le panneau", + "unread": "Non lue", + "emptyTitle": "Vous êtes à jour", + "emptyBody": "Les réponses de vos transporteurs et les changements sur vos dossiers apparaîtront ici.", + "emptyFilterTitle": "Rien dans ce filtre", + "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.", + "retry": "Réessayer", + "pageTitle": "Notifications", + "pageDescription": "Les réponses de vos transporteurs et les changements sur vos dossiers.", + "filterLabel": "Filtrer les notifications", "filters": { "all": "Toutes", "unread": "Non lues", - "read": "Lues" + "booking": "Réservations", + "account": "Compte", + "system": "Système" }, - "markAllRead": "Tout marquer comme lu", - "loading": "Chargement des notifications…", - "emptyTitle": "Aucune notification", - "emptyUnread": "Vous êtes à jour !", - "emptyAll": "Aucune notification à afficher", - "deleteConfirm": "Voulez-vous vraiment supprimer cette notification ?", - "deleteTitle": "Supprimer la notification", - "viewDetails": "Voir les détails →", - "pagination": { - "page": "Page {current} sur {total}", - "previous": "Précédent", - "next": "Suivant" + "destination": { + "booking": "Réservation {reference}", + "bookings": "Vos réservations", + "rates": "Recherche de tarifs", + "account": "Votre organisation", + "bookingUnnamed": "La réservation concernée" + }, + "time": { + "now": "À l’instant", + "minutes": "Il y a {minutes} min", + "hours": "Il y a {hours} h", + "days": "Il y a {days} j" } } }, diff --git a/apps/frontend/src/components/NotificationDropdown.tsx b/apps/frontend/src/components/NotificationDropdown.tsx deleted file mode 100644 index 7b8a214..0000000 --- a/apps/frontend/src/components/NotificationDropdown.tsx +++ /dev/null @@ -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(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 = { - 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 ( -
- {/* Notification Bell Button */} - - - {/* Dropdown Panel */} - {isOpen && ( -
- {/* Header */} -
-

{t('header')}

- {unreadCount > 0 && ( - - )} -
- - {/* Notifications List */} -
- {isLoading ? ( -
{t('loading')}
- ) : notifications.length === 0 ? ( -
- -

{t('empty')}

-
- ) : ( -
- {notifications.map((notification: NotificationResponse) => { - return ( -
handleNotificationClick(notification)} - className={`block px-4 py-3 hover:bg-neutral-50 transition-colors cursor-pointer ${ - !notification.read ? 'bg-brand-blue/5' : '' - }`} - > -
-
- {(() => { - const Icon = getNotificationIcon(notification.type); - return ; - })()} -
-
-
-

- {notification.title} -

- {!notification.read && ( - - )} -
-

- {notification.message} -

-
- - {formatTime(notification.createdAt)} - -
-
-
-
- ); - })} -
- )} -
- - {/* Footer */} -
- -
-
- )} - - {/* Notification Panel (Sidebar) */} - setIsPanelOpen(false)} /> -
- ); -} diff --git a/apps/frontend/src/components/NotificationPanel.tsx b/apps/frontend/src/components/NotificationPanel.tsx deleted file mode 100644 index 81df55d..0000000 --- a/apps/frontend/src/components/NotificationPanel.tsx +++ /dev/null @@ -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 = { - 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 */} -
- - {/* Panel */} -
- {/* Header */} -
-
-

{t('title')}

-

{t('totalCount', { count: total })}

-
- -
- - {/* Filter Bar */} -
-
- -
- {(['all', 'unread', 'read'] as const).map(filter => ( - - ))} -
-
- {selectedFilter === 'unread' && notifications.length > 0 && ( - - )} -
- - {/* Notifications List */} -
- {isLoading ? ( -
-
-
-

{t('loading')}

-
-
- ) : notifications.length === 0 ? ( -
-
- -

{t('emptyTitle')}

-

- {selectedFilter === 'unread' ? t('emptyUnread') : t('emptyAll')} -

-
-
- ) : ( -
- {notifications.map((notification: NotificationResponse) => ( -
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')}`} - > -
- {/* Icon */} -
- {(() => { - const Icon = getNotificationIconComponent(notification.type); - return ; - })()} -
- - {/* Content */} -
-
-
-

- {notification.title} -

- {!notification.read && ( - - )} -
- -
- -

- {notification.message} -

- - {/* Metadata */} -
-
- {formatTime(notification.createdAt)} - - {notification.type.replace(/_/g, ' ').toUpperCase()} - - {notification.priority && ( - - {notification.priority.toUpperCase()} - - )} -
- {notification.actionUrl && ( - - {t('viewDetails')} - - )} -
-
-
-
- ))} -
- )} -
- - {/* Pagination */} - {totalPages > 1 && ( -
-
- {t('pagination.page', { current: currentPage, total: totalPages })} -
-
- - -
-
- )} -
- - - - ); -} diff --git a/apps/frontend/src/components/notifications/notification-kind.ts b/apps/frontend/src/components/notifications/notification-kind.ts new file mode 100644 index 0000000..e8d40b3 --- /dev/null +++ b/apps/frontend/src/components/notifications/notification-kind.ts @@ -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 = { + 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 = { + 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; diff --git a/apps/frontend/src/components/notifications/notification-menu.tsx b/apps/frontend/src/components/notifications/notification-menu.tsx new file mode 100644 index 0000000..1b08162 --- /dev/null +++ b/apps/frontend/src/components/notifications/notification-menu.tsx @@ -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, + * `` 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 ( + + + + )} + + + + +
+

+ {t('header')} +

+ {unreadCount > 0 && ( + + )} +
+ +
+ {isLoading ? ( + + ) : notifications.length === 0 ? ( +
+
+ ) : ( +
    + {notifications.map(notification => ( +
  • + +
  • + ))} +
+ )} +
+ +
+ +
+
+ + ); +} + +/** Conserve pour les imports existants du chrome applicatif. */ +export default NotificationMenu; diff --git a/apps/frontend/src/components/notifications/notification-row.tsx b/apps/frontend/src/components/notifications/notification-row.tsx new file mode 100644 index 0000000..49e5244 --- /dev/null +++ b/apps/frontend/src/components/notifications/notification-row.tsx @@ -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 = ( + <> + + + + + + {notification.title} + + + {labels.time(notification.createdAt)} + + + + + {notification.message} + + + {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. + + {destination} + + )} + + + ); + + 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 ( +
+ {body} + {unread && {labels.unread}} +
+ ); + } + + return ( + 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 && {labels.unread}} + + ); +} diff --git a/apps/frontend/src/components/notifications/use-notification-labels.tsx b/apps/frontend/src/components/notifications/use-notification-labels.tsx new file mode 100644 index 0000000..1a30398 --- /dev/null +++ b/apps/frontend/src/components/notifications/use-notification-labels.tsx @@ -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]); +} diff --git a/apps/frontend/src/components/shell/topbar.tsx b/apps/frontend/src/components/shell/topbar.tsx index 9c97075..c3b8f51 100644 --- a/apps/frontend/src/components/shell/topbar.tsx +++ b/apps/frontend/src/components/shell/topbar.tsx @@ -5,7 +5,7 @@ import { Menu, Search } from 'lucide-react'; import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import LanguageSwitcher from '@/components/LanguageSwitcher'; -import NotificationDropdown from '@/components/NotificationDropdown'; +import { NotificationMenu } from '@/components/notifications/notification-menu'; import { Breadcrumb } from './breadcrumb'; import type { NavGroup, ShellVariant } from './nav-config'; @@ -90,7 +90,7 @@ export function Topbar({ - {showNotifications && } + {showNotifications && }
); diff --git a/apps/frontend/src/hooks/useNotifications.ts b/apps/frontend/src/hooks/useNotifications.ts index 0b59e9c..dafb908 100644 --- a/apps/frontend/src/hooks/useNotifications.ts +++ b/apps/frontend/src/hooks/useNotifications.ts @@ -1,108 +1,77 @@ /** - * useNotifications Hook + * useNotifications * - * Custom hook for managing notifications with automatic polling - * Polls the API every 30 seconds for new notifications + * Source unique des notifications : la cloche, la page et tout futur écran + * lisent le même état. */ 'use client'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { listNotifications, markNotificationAsRead, markAllNotificationsAsRead } from '@/lib/api'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { + getUnreadNotificationCount, + listNotifications, + markAllNotificationsAsRead, + markNotificationAsRead, +} from '@/lib/api'; import type { NotificationResponse } from '@/types/api'; interface UseNotificationsOptions { - /** - * Whether to fetch only unread notifications - * @default true - */ + /** Ne remonter que les non-lues. Par défaut, tout, du plus récent au plus ancien. */ unreadOnly?: boolean; - /** - * Maximum number of notifications to fetch - * @default 10 - */ + /** Nombre maximum de notifications chargées. */ limit?: number; - /** - * Polling interval in milliseconds - * @default 30000 (30 seconds) - */ + /** Intervalle de rafraîchissement, en millisecondes. */ refetchInterval?: number; } export function useNotifications(options: UseNotificationsOptions = {}) { - const { unreadOnly = true, limit = 10, refetchInterval = 30000 } = options; - + const { unreadOnly = false, limit = 10, refetchInterval = 30000 } = options; const queryClient = useQueryClient(); - // Fetch notifications with automatic polling - const { data, isLoading, refetch, error } = useQuery({ - queryKey: ['notifications', { isRead: !unreadOnly, limit }], - queryFn: () => listNotifications({ isRead: !unreadOnly, limit }), - refetchInterval, // Poll every 30 seconds by default - refetchOnWindowFocus: true, // Refetch when window regains focus + const list = useQuery({ + queryKey: ['notifications', { unreadOnly, limit }], + queryFn: () => listNotifications(unreadOnly ? { isRead: false, limit } : { limit }), + refetchInterval, + refetchOnWindowFocus: true, }); - 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({ mutationFn: markNotificationAsRead, - onSuccess: () => { - // Invalidate all notification queries to refetch - queryClient.invalidateQueries({ queryKey: ['notifications'] }); - }, + onSuccess: invalidate, }); - // Mark all notifications as read const markAllAsReadMutation = useMutation({ mutationFn: markAllNotificationsAsRead, - onSuccess: () => { - // Invalidate all notification queries to refetch - queryClient.invalidateQueries({ queryKey: ['notifications'] }); - }, + onSuccess: invalidate, }); - /** - * 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(); - }; + const notifications: NotificationResponse[] = list.data?.notifications ?? []; return { - /** Array of notifications */ notifications, - /** Count of unread notifications */ - unreadCount, - /** Mark a single notification as read */ - markAsRead, - /** Mark all notifications as read */ - markAllAsRead, - /** Manually refresh notifications */ - refresh, - /** 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, + total: list.data?.total ?? notifications.length, + unreadCount: count.data?.count ?? 0, + isLoading: list.isPending, + isError: list.isError, + refetch: list.refetch, + markAsRead: markAsReadMutation.mutate, + markAllAsRead: markAllAsReadMutation.mutate, + isMarkingAll: markAllAsReadMutation.isPending, }; } diff --git a/apps/frontend/src/lib/api/index.ts b/apps/frontend/src/lib/api/index.ts index 3ff3ab0..4443100 100644 --- a/apps/frontend/src/lib/api/index.ts +++ b/apps/frontend/src/lib/api/index.ts @@ -84,6 +84,7 @@ export { // Notifications (7 endpoints) export { listNotifications, + getUnreadNotificationCount, getNotification, createNotification, markNotificationAsRead, diff --git a/apps/frontend/src/lib/api/notifications.ts b/apps/frontend/src/lib/api/notifications.ts index c168392..287cacc 100644 --- a/apps/frontend/src/lib/api/notifications.ts +++ b/apps/frontend/src/lib/api/notifications.ts @@ -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 /api/v1/notifications/:id From d89104f49ef747103f045dbfaaa0519c14a4c8e0 Mon Sep 17 00:00:00 2001 From: David Date: Mon, 7 Sep 2026 21:40:56 +0200 Subject: [PATCH 4/4] fix(ui): la page de detail lit csv-bookings, la table bookings n existe pas Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018BAUeCFpDkRD6tU5wGsc1C --- .../[locale]/dashboard/bookings/[id]/page.tsx | 367 ++++++++++-------- 1 file changed, 198 insertions(+), 169 deletions(-) diff --git a/apps/frontend/app/[locale]/dashboard/bookings/[id]/page.tsx b/apps/frontend/app/[locale]/dashboard/bookings/[id]/page.tsx index 8b94282..717f55f 100644 --- a/apps/frontend/app/[locale]/dashboard/bookings/[id]/page.tsx +++ b/apps/frontend/app/[locale]/dashboard/bookings/[id]/page.tsx @@ -3,61 +3,68 @@ import { useQuery } from '@tanstack/react-query'; import { useParams } from 'next/navigation'; 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 { Badge, type BadgeProps } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; +import { Callout } from '@/components/ui/callout'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { PageHeader } from '@/components/ui/PageHeader'; -import { EmptyState, PageSpinner } from '@/components/ui/states'; -import { useToast } from '@/components/ui/toast'; -import { DescriptionList } from '@/components/data/description-list'; - -const STATUS_VARIANT: Record> = { - draft: 'neutral', - pending: 'warning', - confirmed: 'info', - in_transit: 'info', - delivered: 'success', - cancelled: 'danger', -}; +import { EmptyState, ErrorState, PageSpinner } from '@/components/ui/states'; +import { BookingStatusBadge } from '@/components/bookings/BookingStatusBadge'; +/** + * 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() { const t = useTranslations('dashboard.bookingDetail'); + const tList = useTranslations('dashboard.bookingsList'); const tCommon = useTranslations('common'); const locale = useLocale(); const dateLocale = locale === 'fr' ? 'fr-FR' : 'en-US'; const params = useParams(); const bookingId = params.id as string; - const { toast } = useToast(); - const { data: booking, isLoading } = useQuery({ - queryKey: ['booking', bookingId], - queryFn: () => getBooking(bookingId), - enabled: !!bookingId, + const { + data: booking, + isPending, + 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) => { - try { - return t(`status.${status}` as any); - } catch { - return status; - } - }; + const dateTime = (value?: string) => + value + ? new Date(value).toLocaleString(dateLocale, { dateStyle: 'long', timeStyle: 'short' }) + : '—'; - // Le telechargement PDF n'est pas encore branche cote back : on le signale - // par une notification plutot que par une alerte bloquante du navigateur. - const downloadPDF = () => toast.info(t('pdfNotImplemented')); + if (isPending) return ; - if (isLoading) return ; - - if (!booking) { - return ( + if (isError) { + const missing = (error as { statusCode?: number })?.statusCode === 404; + return missing ? ( @@ -67,173 +74,195 @@ export default function BookingDetailPage() { } /> + ) : ( + refetch()} + /> ); } + const unpaid = booking.status === 'PENDING_PAYMENT'; + const price = booking.priceEUR + ? `${booking.priceEUR} €` + : booking.priceUSD + ? `${booking.priceUSD} $` + : null; + return ( <> - - {statusLabel(booking.status)} - - + + {unpaid && ( + + )} } /> + {unpaid && ( + + {tList('detail.pendingPaymentNotice')} + + )} + + {booking.status === 'REJECTED' && booking.rejectionReason && ( + + {booking.rejectionReason} + + )} +
+ {/* La route est ce qu'un transitaire regarde en premier : elle occupe + la tete de page, pas une ligne de tableau parmi d'autres. */} - - {t('cargo.title')} - - - - - - - - - - {t('containers.title', { count: booking.containers?.length || 0 })} - - - - {booking.containers && booking.containers.length > 0 ? ( -
    - {booking.containers.map((container, index) => ( -
  • - -
  • - ))} -
- ) : ( - + +
+ {booking.origin} + + + {booking.destination} +
+ {booking.carrierName && ( +

+ {t('carrier', { carrier: booking.carrierName })} +

)}
-
- - - {t('shipper.title')} - - - - - + + + {tList('detail.cargo')} + + +
+ + {booking.palletCount + ? tList('units.palletsCount', { count: booking.palletCount }) + : '—'} + + + {booking.volumeCBM ? tList('units.cbm', { value: booking.volumeCBM }) : '—'} + + + {booking.weightKG ? tList('units.kg', { value: booking.weightKG }) : '—'} + +
+ {booking.notes && ( +

+ {booking.notes} +

+ )} +
+
- - - {t('consignee.title')} - - - - - -
+ + + {t('documents', { count: booking.documents?.length ?? 0 })} + + + {booking.documents?.length ? ( +
    + {booking.documents.map((document, index) => ( +
  • +
  • + ))} +
+ ) : ( + + )} +
+
- - - {t('timeline.title')} - - -
    -
  1. - -
    -

    - {t('timeline.created')} -

    -

    - {new Date(booking.createdAt).toLocaleString(dateLocale)} -

    -
    -
  2. -
-
-
+ {price && ( + + + {tList('detail.price')} + + +

{price}

+
+ {booking.freightTotal ? ( + + {booking.freightTotal} {booking.freightCurrency} + + ) : null} + {booking.fobTotal ? ( + + {booking.fobTotal} {booking.fobCurrency} + + ) : null} + {booking.commissionAmountEur ? ( + {booking.commissionAmountEur} € + ) : null} +
+
+
+ )} - {t('info.title')} + {t('timeline')} - +
+ {dateTime(booking.requestedAt)} + {booking.respondedAt && ( + {dateTime(booking.respondedAt)} + )} + {booking.id} +
+ +
); } + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+
{label}
+
{children}
+
+ ); +}