merge: integrer feat/notifications
This commit is contained in:
commit
a491bdb79d
@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
@ -356,7 +356,6 @@ export class OrganizationsController {
|
||||
siren: organization.siren,
|
||||
requestedBy: user.email,
|
||||
},
|
||||
actionUrl: `/dashboard/admin/organizations`,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
@ -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}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
95
apps/backend/src/domain/services/notification-target.spec.ts
Normal file
95
apps/backend/src/domain/services/notification-target.spec.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
59
apps/backend/src/domain/services/notification-target.ts
Normal file
59
apps/backend/src/domain/services/notification-target.ts
Normal 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;
|
||||
}
|
||||
@ -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<string, NonNullable<BadgeProps['variant']>> = {
|
||||
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 <PageSpinner label={tCommon('loading')} />;
|
||||
|
||||
if (isLoading) return <PageSpinner label={tCommon('loading')} />;
|
||||
|
||||
if (!booking) {
|
||||
return (
|
||||
if (isError) {
|
||||
const missing = (error as { statusCode?: number })?.statusCode === 404;
|
||||
return missing ? (
|
||||
<EmptyState
|
||||
icon={Package}
|
||||
title={t('notFound')}
|
||||
description={t('notFoundBody')}
|
||||
action={
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/dashboard/bookings">
|
||||
@ -67,173 +74,195 @@ export default function BookingDetailPage() {
|
||||
</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 (
|
||||
<>
|
||||
<PageHeader
|
||||
title={booking.bookingNumber}
|
||||
description={t('createdOn', {
|
||||
date: new Date(booking.createdAt).toLocaleDateString(dateLocale),
|
||||
})}
|
||||
title={booking.bookingNumber ?? `#${booking.id.slice(0, 8).toUpperCase()}`}
|
||||
description={t('requestedOn', { date: dateTime(booking.requestedAt) })}
|
||||
actions={
|
||||
<>
|
||||
<Badge variant={STATUS_VARIANT[booking.status] ?? 'neutral'}>
|
||||
{statusLabel(booking.status)}
|
||||
</Badge>
|
||||
<Button variant="outline" onClick={downloadPDF}>
|
||||
<Download />
|
||||
{t('downloadPdf')}
|
||||
</Button>
|
||||
<BookingStatusBadge status={booking.status} />
|
||||
{unpaid && (
|
||||
<Button asChild>
|
||||
<Link href={`/dashboard/booking/${booking.id}/pay`}>
|
||||
<CreditCard />
|
||||
{tList('detail.payNow')}
|
||||
</Link>
|
||||
</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="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>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle>{t('cargo.title')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DescriptionList
|
||||
columns={1}
|
||||
items={[
|
||||
{ label: t('cargo.description'), value: booking.cargoDescription },
|
||||
...(booking.specialInstructions
|
||||
? [
|
||||
{
|
||||
label: t('cargo.specialInstructions'),
|
||||
value: booking.specialInstructions,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
</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 className="p-5">
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||
<span className="font-heading text-h4 text-brand-navy">{booking.origin}</span>
|
||||
<span className="flex items-center gap-2 text-neutral-400">
|
||||
<ArrowRight className="size-4" aria-hidden="true" />
|
||||
{booking.transitDays ? (
|
||||
<span className="text-body-xs">
|
||||
{t('transitDays', { days: booking.transitDays })}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="font-heading text-h4 text-brand-navy">{booking.destination}</span>
|
||||
</div>
|
||||
{booking.carrierName && (
|
||||
<p className="mt-2 text-body-sm text-neutral-500">
|
||||
{t('carrier', { carrier: booking.carrierName })}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle>{t('shipper.title')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DescriptionList
|
||||
columns={1}
|
||||
items={[
|
||||
{ label: t('shipper.name'), value: booking.shipper.name, emphasis: true },
|
||||
{ label: t('shipper.contact'), value: booking.shipper.contactName },
|
||||
{ label: t('shipper.email'), value: booking.shipper.contactEmail },
|
||||
{ label: t('shipper.phone'), value: booking.shipper.contactPhone },
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle>{tList('detail.cargo')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="grid gap-4 sm:grid-cols-3">
|
||||
<Field label={t('packing')}>
|
||||
{booking.palletCount
|
||||
? tList('units.palletsCount', { count: booking.palletCount })
|
||||
: '—'}
|
||||
</Field>
|
||||
<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>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle>{t('consignee.title')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DescriptionList
|
||||
columns={1}
|
||||
items={[
|
||||
{ label: t('consignee.name'), value: booking.consignee.name, emphasis: true },
|
||||
{ label: t('consignee.contact'), value: booking.consignee.contactName },
|
||||
{ label: t('consignee.email'), value: booking.consignee.contactEmail },
|
||||
{ label: t('consignee.phone'), value: booking.consignee.contactPhone },
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle>{t('documents', { count: booking.documents?.length ?? 0 })}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{booking.documents?.length ? (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{booking.documents.map((document, index) => (
|
||||
<li
|
||||
key={index}
|
||||
className="flex items-center gap-3 rounded-md border border-border px-4 py-3"
|
||||
>
|
||||
<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>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle>{t('timeline.title')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ol className="relative flex flex-col gap-4">
|
||||
<li className="flex gap-3">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full bg-brand-blue/10"
|
||||
>
|
||||
<FileText className="size-3 text-brand-blue" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-body-sm font-medium text-brand-navy">
|
||||
{t('timeline.created')}
|
||||
</p>
|
||||
<p className="mt-0.5 text-body-xs text-neutral-500">
|
||||
{new Date(booking.createdAt).toLocaleString(dateLocale)}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{price && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle>{tList('detail.price')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="font-heading text-h3 text-brand-navy">{price}</p>
|
||||
<dl className="mt-4 flex flex-col gap-3 border-t border-border pt-4">
|
||||
{booking.freightTotal ? (
|
||||
<Field label={t('freight')}>
|
||||
{booking.freightTotal} {booking.freightCurrency}
|
||||
</Field>
|
||||
) : null}
|
||||
{booking.fobTotal ? (
|
||||
<Field label={t('destinationCharges')}>
|
||||
{booking.fobTotal} {booking.fobCurrency}
|
||||
</Field>
|
||||
) : null}
|
||||
{booking.commissionAmountEur ? (
|
||||
<Field label={t('commission')}>{booking.commissionAmountEur} €</Field>
|
||||
) : null}
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle>{t('info.title')}</CardTitle>
|
||||
<CardTitle>{t('timeline')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DescriptionList
|
||||
columns={1}
|
||||
items={[
|
||||
{ label: t('info.bookingId'), value: booking.id },
|
||||
{
|
||||
label: t('info.lastUpdated'),
|
||||
value: new Date(booking.updatedAt).toLocaleString(dateLocale),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<dl className="flex flex-col gap-3">
|
||||
<Field label={t('requested')}>{dateTime(booking.requestedAt)}</Field>
|
||||
{booking.respondedAt && (
|
||||
<Field label={t('responded')}>{dateTime(booking.respondedAt)}</Field>
|
||||
)}
|
||||
<Field label={t('reference')}>{booking.id}</Field>
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/dashboard/bookings">
|
||||
<ArrowLeft />
|
||||
{t('back')}
|
||||
</Link>
|
||||
</Button>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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<string | null>(null);
|
||||
const t = useTranslations('components.notifications');
|
||||
const [filter, setFilter] = useState<Filter>('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<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',
|
||||
});
|
||||
const countFor = (value: Filter) => {
|
||||
if (value === 'all') return notifications.length;
|
||||
if (value === 'unread') return unreadCount;
|
||||
return notifications.filter(n => matchesKind(n, value)).length;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-neutral-50">
|
||||
<div className="bg-white border-b shadow-sm">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="p-3 bg-brand-blue/10 rounded-lg">
|
||||
<Bell className="w-8 h-8 text-brand-navy" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-brand-navy">{t('title')}</h1>
|
||||
<p className="text-sm text-neutral-600 mt-1">
|
||||
{t('totalLabel', { count: total })}
|
||||
{unreadCount > 0 && t('unreadSuffix', { count: unreadCount })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{unreadCount > 0 && (
|
||||
<button
|
||||
onClick={() => markAllAsReadMutation.mutate()}
|
||||
disabled={markAllAsReadMutation.isPending}
|
||||
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"
|
||||
>
|
||||
<CheckCheck className="w-5 h-5" />
|
||||
<span>{t('markAllRead')}</span>
|
||||
</button>
|
||||
)}
|
||||
</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 => (
|
||||
<>
|
||||
<PageHeader
|
||||
title={t('pageTitle')}
|
||||
description={t('pageDescription')}
|
||||
actions={
|
||||
unreadCount > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => markAllAsRead()}
|
||||
loading={isMarkingAll}
|
||||
>
|
||||
<CheckCheck aria-hidden="true" />
|
||||
{t('markAllRead')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
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
|
||||
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'
|
||||
}`}
|
||||
key={value}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
onClick={() => setFilter(value)}
|
||||
className={cn(
|
||||
'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'
|
||||
)}
|
||||
>
|
||||
{t(`filter.${filter}` as any)}
|
||||
{filter === 'unread' && unreadCount > 0 && (
|
||||
<span className="ml-2 px-2 py-0.5 bg-white/20 rounded-full text-xs">
|
||||
{unreadCount}
|
||||
{t(`filters.${value}`)}
|
||||
{count > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
'tabular-nums text-body-xs',
|
||||
active ? 'text-white/70' : 'text-neutral-400'
|
||||
)}
|
||||
>
|
||||
{count}
|
||||
</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 className="overflow-hidden rounded-lg border border-border bg-white">
|
||||
{isLoading ? (
|
||||
<div className="space-y-4 p-5" aria-hidden="true">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<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>
|
||||
<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
|
||||
className={`px-3 py-1 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'
|
||||
}`}
|
||||
>
|
||||
{getPriorityLabel(notification.priority)}
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
</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"
|
||||
>
|
||||
<span>{t('pagination.next')}</span>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</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>
|
||||
|
||||
<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);
|
||||
}
|
||||
|
||||
@ -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 <b>{current}</b> of <b>{total}</b> • <b>{items}</b> {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": {
|
||||
|
||||
@ -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 <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": {
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
138
apps/frontend/src/components/notifications/notification-menu.tsx
Normal file
138
apps/frontend/src/components/notifications/notification-menu.tsx
Normal 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;
|
||||
127
apps/frontend/src/components/notifications/notification-row.tsx
Normal file
127
apps/frontend/src/components/notifications/notification-row.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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]);
|
||||
}
|
||||
@ -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({
|
||||
</Button>
|
||||
|
||||
<LanguageSwitcher variant="light" />
|
||||
{showNotifications && <NotificationDropdown />}
|
||||
{showNotifications && <NotificationMenu />}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -84,6 +84,7 @@ export {
|
||||
// Notifications (7 endpoints)
|
||||
export {
|
||||
listNotifications,
|
||||
getUnreadNotificationCount,
|
||||
getNotification,
|
||||
createNotification,
|
||||
markNotificationAsRead,
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user