diff --git a/apps/backend/src/application/services/analytics.service.ts b/apps/backend/src/application/services/analytics.service.ts index 2d75127..a863d53 100644 --- a/apps/backend/src/application/services/analytics.service.ts +++ b/apps/backend/src/application/services/analytics.service.ts @@ -4,27 +4,26 @@ * Calculates KPIs and analytics data for dashboard */ -import { Injectable, Inject } from '@nestjs/common'; -import { BOOKING_REPOSITORY } from '@domain/ports/out/booking.repository'; -import { BookingRepository } from '@domain/ports/out/booking.repository'; -import { RATE_QUOTE_REPOSITORY } from '@domain/ports/out/rate-quote.repository'; -import { RateQuoteRepository } from '@domain/ports/out/rate-quote.repository'; +import { Injectable } from '@nestjs/common'; import { TypeOrmCsvBookingRepository } from '../../infrastructure/persistence/typeorm/repositories/csv-booking.repository'; import { CsvBooking, CsvBookingStatus } from '@domain/entities/csv-booking.entity'; export interface DashboardKPIs { bookingsThisMonth: number; - totalTEUs: number; + /** Volume LCL du mois, en CBM. */ + volumeCBM: number; + /** Commission Xpeditis encaissee sur les reservations acceptees, en EUR. */ estimatedRevenue: number; pendingConfirmations: number; bookingsThisMonthChange: number; // % change from last month - totalTEUsChange: number; + volumeCBMChange: number; estimatedRevenueChange: number; pendingConfirmationsChange: number; } export interface BookingsChartData { - labels: string[]; // Month names + /** Cles de mois ISO `YYYY-MM` ; la mise en forme revient au client. */ + labels: string[]; data: number[]; // Booking counts } @@ -33,7 +32,7 @@ export interface TopTradeLane { originPort: string; destinationPort: string; bookingCount: number; - totalTEUs: number; + totalVolumeCBM: number; avgPrice: number; } @@ -41,8 +40,10 @@ export interface DashboardAlert { id: string; type: 'delay' | 'confirmation' | 'document' | 'payment' | 'info'; severity: 'low' | 'medium' | 'high' | 'critical'; - title: string; - message: string; + /** Cle de traduction, resolue par le client. */ + titleKey: string; + messageKey: string; + messageParams?: Record; bookingId?: string; bookingNumber?: string; createdAt: Date; @@ -73,125 +74,94 @@ export interface TopCarrier { @Injectable() export class AnalyticsService { - constructor( - @Inject(BOOKING_REPOSITORY) - private readonly bookingRepository: BookingRepository, - @Inject(RATE_QUOTE_REPOSITORY) - private readonly rateQuoteRepository: RateQuoteRepository, - private readonly csvBookingRepository: TypeOrmCsvBookingRepository - ) {} + constructor(private readonly csvBookingRepository: TypeOrmCsvBookingRepository) {} /** * Calculate dashboard KPIs - * Cached for 1 hour + * + * Source : `csv_bookings`. Les quatre methodes de ce bloc lisaient + * auparavant `bookingRepository.findByOrganization()`, c'est-a-dire la table + * `bookings` — absente du schema (aucune migration ne la cree). Les quatre + * endpoints repondaient donc 500 en permanence. Les reservations reelles du + * produit sont des reservations LCL portees par `csv_bookings`. */ async calculateKPIs(organizationId: string): Promise { + const bookings = await this.csvBookingRepository.findByOrganizationId(organizationId); + const now = new Date(); const thisMonthStart = new Date(now.getFullYear(), now.getMonth(), 1); const lastMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1); - const lastMonthEnd = new Date(now.getFullYear(), now.getMonth(), 0, 23, 59, 59); - // Get all bookings for organization - const allBookings = await this.bookingRepository.findByOrganization(organizationId); - - // This month bookings - const thisMonthBookings = allBookings.filter(b => b.createdAt >= thisMonthStart); - - // Last month bookings - const lastMonthBookings = allBookings.filter( - b => b.createdAt >= lastMonthStart && b.createdAt <= lastMonthEnd + const thisMonth = bookings.filter((b: CsvBooking) => b.requestedAt >= thisMonthStart); + const lastMonth = bookings.filter( + (b: CsvBooking) => b.requestedAt >= lastMonthStart && b.requestedAt < thisMonthStart ); - // Calculate total TEUs (20' = 1 TEU, 40' = 2 TEU) - // Each container is an individual entity, so we count them - const calculateTEUs = (bookings: typeof allBookings): number => { - return bookings.reduce((total, booking) => { - return ( - total + - booking.containers.reduce((containerTotal, container) => { - const teu = container.type.startsWith('20') ? 1 : 2; - return containerTotal + teu; // Each container counts as 1 or 2 TEU - }, 0) - ); - }, 0); - }; + // LCL : le volume se mesure en CBM. Le TEU, unite du conteneur complet, ne + // veut rien dire ici — c'est ce que l'ancienne version calculait. + const volumeOf = (list: CsvBooking[]): number => + list.reduce((sum, b) => sum + (Number(b.volumeCBM) || 0), 0); - const totalTEUsThisMonth = calculateTEUs(thisMonthBookings); - const totalTEUsLastMonth = calculateTEUs(lastMonthBookings); + // Le chiffre d'affaires est celui d'Xpeditis : la commission percue, pas le + // montant du fret qui revient au transporteur. Seules les reservations + // acceptees comptent. + const revenueOf = (list: CsvBooking[]): number => + list + .filter(b => b.status === CsvBookingStatus.ACCEPTED) + .reduce((sum, b) => sum + (Number(b.commissionAmountEur) || 0), 0); - // Calculate estimated revenue (from rate quotes) - const calculateRevenue = async (bookings: typeof allBookings): Promise => { - let total = 0; - for (const booking of bookings) { - try { - const rateQuote = await this.rateQuoteRepository.findById(booking.rateQuoteId); - if (rateQuote) { - total += rateQuote.pricing.totalAmount; - } - } catch (error) { - // Skip if rate quote not found - continue; - } - } - return total; - }; + const pendingOf = (list: CsvBooking[]): number => + list.filter(b => b.status === CsvBookingStatus.PENDING).length; - const estimatedRevenueThisMonth = await calculateRevenue(thisMonthBookings); - const estimatedRevenueLastMonth = await calculateRevenue(lastMonthBookings); + const volumeThisMonth = volumeOf(thisMonth); + const volumeLastMonth = volumeOf(lastMonth); + const revenueThisMonth = revenueOf(thisMonth); + const revenueLastMonth = revenueOf(lastMonth); + const pendingThisMonth = pendingOf(thisMonth); + const pendingLastMonth = pendingOf(lastMonth); - // Pending confirmations (status = pending_confirmation) - const pendingThisMonth = thisMonthBookings.filter( - b => b.status.value === 'pending_confirmation' - ).length; - const pendingLastMonth = lastMonthBookings.filter( - b => b.status.value === 'pending_confirmation' - ).length; - - // Calculate percentage changes - const calculateChange = (current: number, previous: number): number => { + const change = (current: number, previous: number): number => { if (previous === 0) return current > 0 ? 100 : 0; return ((current - previous) / previous) * 100; }; return { - bookingsThisMonth: thisMonthBookings.length, - totalTEUs: totalTEUsThisMonth, - estimatedRevenue: estimatedRevenueThisMonth, + bookingsThisMonth: thisMonth.length, + volumeCBM: volumeThisMonth, + estimatedRevenue: revenueThisMonth, pendingConfirmations: pendingThisMonth, - bookingsThisMonthChange: calculateChange(thisMonthBookings.length, lastMonthBookings.length), - totalTEUsChange: calculateChange(totalTEUsThisMonth, totalTEUsLastMonth), - estimatedRevenueChange: calculateChange(estimatedRevenueThisMonth, estimatedRevenueLastMonth), - pendingConfirmationsChange: calculateChange(pendingThisMonth, pendingLastMonth), + bookingsThisMonthChange: change(thisMonth.length, lastMonth.length), + volumeCBMChange: change(volumeThisMonth, volumeLastMonth), + estimatedRevenueChange: change(revenueThisMonth, revenueLastMonth), + pendingConfirmationsChange: change(pendingThisMonth, pendingLastMonth), }; } /** * Get bookings chart data for last 6 months + * + * Les etiquettes sont des cles ISO `YYYY-MM` : le service ignore la langue de + * l'utilisateur, la mise en forme du mois revient au client. */ async getBookingsChartData(organizationId: string): Promise { + const bookings = await this.csvBookingRepository.findByOrganizationId(organizationId); + const now = new Date(); const labels: string[] = []; const data: number[] = []; - // Get bookings for last 6 months - const allBookings = await this.bookingRepository.findByOrganization(organizationId); - for (let i = 5; i >= 0; i--) { - const monthDate = new Date(now.getFullYear(), now.getMonth() - i, 1); - const monthEnd = new Date(now.getFullYear(), now.getMonth() - i + 1, 0, 23, 59, 59); + const monthStart = new Date(now.getFullYear(), now.getMonth() - i, 1); + const nextMonthStart = new Date(now.getFullYear(), now.getMonth() - i + 1, 1); - // Month label (e.g., "Jan 2025") - const monthLabel = monthDate.toLocaleDateString('en-US', { - month: 'short', - year: 'numeric', - }); - labels.push(monthLabel); - - // Count bookings in this month - const count = allBookings.filter( - b => b.createdAt >= monthDate && b.createdAt <= monthEnd - ).length; - data.push(count); + labels.push( + `${monthStart.getFullYear()}-${String(monthStart.getMonth() + 1).padStart(2, '0')}` + ); + data.push( + bookings.filter( + (b: CsvBooking) => b.requestedAt >= monthStart && b.requestedAt < nextMonthStart + ).length + ); } return { labels, data }; @@ -201,136 +171,133 @@ export class AnalyticsService { * Get top 5 trade lanes */ async getTopTradeLanes(organizationId: string): Promise { - const allBookings = await this.bookingRepository.findByOrganization(organizationId); + const bookings = await this.csvBookingRepository.findByOrganizationId(organizationId); - // Group by route (origin-destination) const routeMap = new Map< string, { originPort: string; destinationPort: string; bookingCount: number; - totalTEUs: number; + totalVolumeCBM: number; totalPrice: number; } >(); - for (const booking of allBookings) { - try { - const rateQuote = await this.rateQuoteRepository.findById(booking.rateQuoteId); - if (!rateQuote) continue; + for (const booking of bookings) { + const originPort = booking.origin.getValue(); + const destinationPort = booking.destination.getValue(); + const routeKey = `${originPort} → ${destinationPort}`; - // Get first and last ports from route - const originPort = rateQuote.route[0]?.portCode || 'UNKNOWN'; - const destinationPort = rateQuote.route[rateQuote.route.length - 1]?.portCode || 'UNKNOWN'; - const routeKey = `${originPort}-${destinationPort}`; + const entry = routeMap.get(routeKey) ?? { + originPort, + destinationPort, + bookingCount: 0, + totalVolumeCBM: 0, + totalPrice: 0, + }; - if (!routeMap.has(routeKey)) { - routeMap.set(routeKey, { - originPort, - destinationPort, - bookingCount: 0, - totalTEUs: 0, - totalPrice: 0, - }); - } - - const route = routeMap.get(routeKey)!; - route.bookingCount++; - route.totalPrice += rateQuote.pricing.totalAmount; - - // Calculate TEUs - const teus = booking.containers.reduce((total, container) => { - const teu = container.type.startsWith('20') ? 1 : 2; - return total + teu; - }, 0); - route.totalTEUs += teus; - } catch (error) { - continue; - } + entry.bookingCount++; + entry.totalVolumeCBM += Number(booking.volumeCBM) || 0; + entry.totalPrice += Number(booking.priceEUR) || 0; + routeMap.set(routeKey, entry); } - // Convert to array and sort by booking count - const tradeLanes: TopTradeLane[] = Array.from(routeMap.entries()).map(([route, data]) => ({ - route, - originPort: data.originPort, - destinationPort: data.destinationPort, - bookingCount: data.bookingCount, - totalTEUs: data.totalTEUs, - avgPrice: data.totalPrice / data.bookingCount, - })); - - // Sort by booking count and return top 5 - return tradeLanes.sort((a, b) => b.bookingCount - a.bookingCount).slice(0, 5); + return Array.from(routeMap.entries()) + .map(([route, entry]) => ({ + route, + originPort: entry.originPort, + destinationPort: entry.destinationPort, + bookingCount: entry.bookingCount, + totalVolumeCBM: entry.totalVolumeCBM, + avgPrice: entry.bookingCount > 0 ? entry.totalPrice / entry.bookingCount : 0, + })) + .sort((a, b) => b.bookingCount - a.bookingCount) + .slice(0, 5); } /** * Get dashboard alerts + * + * Uniquement ce sur quoi l'utilisateur peut agir. Les libelles sont des cles + * de traduction resolues cote client : le service ne connait pas la langue. */ async getAlerts(organizationId: string): Promise { + const bookings = await this.csvBookingRepository.findByOrganizationId(organizationId); const alerts: DashboardAlert[] = []; - const allBookings = await this.bookingRepository.findByOrganization(organizationId); - // Check for pending confirmations (older than 24h) - const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000); - const oldPendingBookings = allBookings.filter( - b => b.status.value === 'pending_confirmation' && b.createdAt < oneDayAgo - ); + const now = Date.now(); + const oneDay = 24 * 60 * 60 * 1000; - for (const booking of oldPendingBookings) { - alerts.push({ - id: `pending-${booking.id}`, - type: 'confirmation', - severity: 'medium', - title: 'Pending Confirmation', - message: `Booking ${booking.bookingNumber.value} is awaiting carrier confirmation for over 24 hours`, - bookingId: booking.id, - bookingNumber: booking.bookingNumber.value, - createdAt: booking.createdAt, - isRead: false, - }); - } + for (const booking of bookings) { + const waitedMs = now - booking.requestedAt.getTime(); + const waitedDays = Math.floor(waitedMs / oneDay); - // Check for bookings departing soon (within 7 days) with pending status - const sevenDaysFromNow = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); - for (const booking of allBookings) { - try { - const rateQuote = await this.rateQuoteRepository.findById(booking.rateQuoteId); - if (rateQuote && rateQuote.route.length > 0) { - const etd = rateQuote.route[0].departure; - if (etd) { - const etdDate = new Date(etd); - if ( - etdDate <= sevenDaysFromNow && - etdDate >= new Date() && - booking.status.value === 'pending_confirmation' - ) { - alerts.push({ - id: `departure-${booking.id}`, - type: 'delay', - severity: 'high', - title: 'Departure Soon - Not Confirmed', - message: `Booking ${booking.bookingNumber.value} departs in ${Math.ceil( - (etdDate.getTime() - Date.now()) / (24 * 60 * 60 * 1000) - )} days but is not confirmed yet`, - bookingId: booking.id, - bookingNumber: booking.bookingNumber.value, - createdAt: booking.createdAt, - isRead: false, - }); - } - } - } - } catch (error) { + // Sans reponse du transporteur au-dela de 48 h, la demande decroche. + if (booking.status === CsvBookingStatus.PENDING && waitedMs > 2 * oneDay) { + alerts.push({ + id: `pending-${booking.id}`, + type: 'confirmation', + severity: waitedDays >= 5 ? 'high' : 'medium', + titleKey: 'awaitingCarrier', + messageKey: 'awaitingCarrierSince', + messageParams: { days: waitedDays, carrier: booking.carrierName }, + bookingId: booking.id, + bookingNumber: booking.bookingNumber, + createdAt: booking.requestedAt, + isRead: false, + }); continue; } + + // Reservation bloquee avant paiement : rien ne part tant qu'elle y reste. + if ( + (booking.status === CsvBookingStatus.PENDING_PAYMENT || + booking.status === CsvBookingStatus.PENDING_BANK_TRANSFER) && + waitedMs > oneDay + ) { + alerts.push({ + id: `payment-${booking.id}`, + type: 'payment', + severity: 'critical', + titleKey: 'awaitingPayment', + messageKey: 'awaitingPaymentSince', + messageParams: { days: waitedDays }, + bookingId: booking.id, + bookingNumber: booking.bookingNumber, + createdAt: booking.requestedAt, + isRead: false, + }); + continue; + } + + // Refus recent : il faut replacer la marchandise. + if ( + booking.status === CsvBookingStatus.REJECTED && + booking.respondedAt && + now - booking.respondedAt.getTime() < 7 * oneDay + ) { + alerts.push({ + id: `rejected-${booking.id}`, + type: 'info', + severity: 'medium', + titleKey: 'rejected', + messageKey: 'rejectedBy', + messageParams: { carrier: booking.carrierName }, + bookingId: booking.id, + bookingNumber: booking.bookingNumber, + createdAt: booking.respondedAt, + isRead: false, + }); + } } - // Sort by severity (critical > high > medium > low) const severityOrder = { critical: 0, high: 1, medium: 2, low: 3 }; - alerts.sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity]); - - return alerts; + return alerts.sort( + (a, b) => + severityOrder[a.severity] - severityOrder[b.severity] || + b.createdAt.getTime() - a.createdAt.getTime() + ); } /** diff --git a/apps/frontend/app/[locale]/admin/blog/page.tsx b/apps/frontend/app/[locale]/admin/blog/page.tsx index 912eeb0..eece09e 100644 --- a/apps/frontend/app/[locale]/admin/blog/page.tsx +++ b/apps/frontend/app/[locale]/admin/blog/page.tsx @@ -68,7 +68,7 @@ const STATUS_LABELS: Record = { const STATUS_COLORS: Record = { draft: 'bg-yellow-100 text-yellow-800', - scheduled: 'bg-brand-turquoise/10 text-brand-navy', + scheduled: 'bg-brand-blue/10 text-brand-navy', published: 'bg-green-100 text-green-800', archived: 'bg-neutral-100 text-neutral-600', }; @@ -526,7 +526,7 @@ export default function AdminBlogPage() { actions={ diff --git a/apps/frontend/app/[locale]/admin/organizations/page.tsx b/apps/frontend/app/[locale]/admin/organizations/page.tsx index f8a9a5c..b5251b2 100644 --- a/apps/frontend/app/[locale]/admin/organizations/page.tsx +++ b/apps/frontend/app/[locale]/admin/organizations/page.tsx @@ -318,14 +318,14 @@ export default function AdminOrganizationsPage() { `mt-1 block w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none text-sm ${ hasError ? 'border-red-400 focus:border-red-500 focus:ring-red-500' - : 'border-neutral-300 focus:border-brand-turquoise focus:ring-ring' + : 'border-neutral-300 focus:border-brand-blue focus:ring-ring' }`; if (loading) { return (
-
+

{t('loading')}

@@ -340,7 +340,7 @@ export default function AdminOrganizationsPage() { actions={ @@ -363,7 +363,7 @@ export default function AdminOrganizationsPage() { @@ -523,7 +523,7 @@ export default function AdminOrganizationsPage() { {submitting ? t('modal.saving') diff --git a/apps/frontend/app/[locale]/admin/users/page.tsx b/apps/frontend/app/[locale]/admin/users/page.tsx index 3167022..2451c4e 100644 --- a/apps/frontend/app/[locale]/admin/users/page.tsx +++ b/apps/frontend/app/[locale]/admin/users/page.tsx @@ -167,7 +167,7 @@ export default function AdminUsersPage() { return (
-
+

{t('loading')}

@@ -182,7 +182,7 @@ export default function AdminUsersPage() { actions={ @@ -238,7 +238,7 @@ export default function AdminUsersPage() { user.role === 'ADMIN' ? 'bg-purple-100 text-purple-800' : user.role === 'MANAGER' - ? 'bg-brand-turquoise/10 text-brand-navy' + ? 'bg-brand-blue/10 text-brand-navy' : 'bg-neutral-100 text-neutral-800' }`} > @@ -260,7 +260,7 @@ export default function AdminUsersPage() { @@ -292,7 +292,7 @@ export default function AdminUsersPage() { required value={formData.email} onChange={e => setFormData({ ...formData, email: e.target.value })} - className="mt-1 block w-full px-3 py-2 border border-neutral-300 rounded-md shadow-sm focus:border-brand-turquoise focus:ring-ring focus:outline-none" + className="mt-1 block w-full px-3 py-2 border border-neutral-300 rounded-md shadow-sm focus:border-brand-blue focus:ring-ring focus:outline-none" />
@@ -304,7 +304,7 @@ export default function AdminUsersPage() { required value={formData.firstName} onChange={e => setFormData({ ...formData, firstName: e.target.value })} - className="mt-1 block w-full px-3 py-2 border border-neutral-300 rounded-md shadow-sm focus:border-brand-turquoise focus:ring-ring focus:outline-none" + className="mt-1 block w-full px-3 py-2 border border-neutral-300 rounded-md shadow-sm focus:border-brand-blue focus:ring-ring focus:outline-none" />
@@ -316,7 +316,7 @@ export default function AdminUsersPage() { required value={formData.lastName} onChange={e => setFormData({ ...formData, lastName: e.target.value })} - className="mt-1 block w-full px-3 py-2 border border-neutral-300 rounded-md shadow-sm focus:border-brand-turquoise focus:ring-ring focus:outline-none" + className="mt-1 block w-full px-3 py-2 border border-neutral-300 rounded-md shadow-sm focus:border-brand-blue focus:ring-ring focus:outline-none" />
@@ -324,7 +324,7 @@ export default function AdminUsersPage() { setFormData({ ...formData, role: e.target.value as UserRole })} - className="mt-1 block w-full px-3 py-2 border border-neutral-300 rounded-md shadow-sm focus:border-brand-turquoise focus:ring-ring focus:outline-none" + className="mt-1 block w-full px-3 py-2 border border-neutral-300 rounded-md shadow-sm focus:border-brand-blue focus:ring-ring focus:outline-none" > @@ -463,7 +463,7 @@ export default function AdminUsersPage() { diff --git a/apps/frontend/app/[locale]/booking/confirm/[token]/page.tsx b/apps/frontend/app/[locale]/booking/confirm/[token]/page.tsx index 13af220..658a075 100644 --- a/apps/frontend/app/[locale]/booking/confirm/[token]/page.tsx +++ b/apps/frontend/app/[locale]/booking/confirm/[token]/page.tsx @@ -54,7 +54,7 @@ export default function BookingConfirmPage() { return (
-
+

{t('loading')}

@@ -205,7 +205,7 @@ export default function BookingConfirmPage() {
{/* Next Steps */} -
+

{t('labels.download')} diff --git a/apps/frontend/app/[locale]/booking/reject/[token]/page.tsx b/apps/frontend/app/[locale]/booking/reject/[token]/page.tsx index 00dcd19..6292b0c 100644 --- a/apps/frontend/app/[locale]/booking/reject/[token]/page.tsx +++ b/apps/frontend/app/[locale]/booking/reject/[token]/page.tsx @@ -184,7 +184,7 @@ export default function BookingRejectPage() { )}

-
+

{error}

diff --git a/apps/frontend/app/[locale]/carrier/documents/[token]/page.tsx b/apps/frontend/app/[locale]/carrier/documents/[token]/page.tsx index 416bab2..3077acf 100644 --- a/apps/frontend/app/[locale]/carrier/documents/[token]/page.tsx +++ b/apps/frontend/app/[locale]/carrier/documents/[token]/page.tsx @@ -327,7 +327,7 @@ export default function CarrierDocumentsPage() { return (
- +

{t('loading')}

{t('loadingHint')}

@@ -360,8 +360,8 @@ export default function CarrierDocumentsPage() {
-
- +
+

{t('password.title')}

{t('password.intro')}

@@ -385,7 +385,7 @@ export default function CarrierDocumentsPage() { value={password} onChange={e => setPassword(e.target.value.toUpperCase())} placeholder={t('password.passwordPlaceholder')} - className="w-full px-4 py-3 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-brand-turquoise focus:border-brand-turquoise text-center text-xl tracking-widest font-mono uppercase" + className="w-full px-4 py-3 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-brand-blue focus:border-brand-blue text-center text-xl tracking-widest font-mono uppercase" autoComplete="off" autoFocus /> @@ -456,7 +456,7 @@ export default function CarrierDocumentsPage() {
@@ -524,7 +524,7 @@ export default function CarrierDocumentsPage() {

- + {t('list.heading', { count: documents.length })}

@@ -547,7 +547,7 @@ export default function CarrierDocumentsPage() {

{doc.fileName}

- + {getDocumentTypeLabel(doc.type)} {formatFileSize(doc.size)} diff --git a/apps/frontend/app/[locale]/carrier/reject/[token]/page.tsx b/apps/frontend/app/[locale]/carrier/reject/[token]/page.tsx index fe8d2f6..0171dab 100644 --- a/apps/frontend/app/[locale]/carrier/reject/[token]/page.tsx +++ b/apps/frontend/app/[locale]/carrier/reject/[token]/page.tsx @@ -110,7 +110,7 @@ export default function CarrierRejectPage() {

{error}

diff --git a/apps/frontend/app/[locale]/dashboard/booking/[id]/pay/page.tsx b/apps/frontend/app/[locale]/dashboard/booking/[id]/pay/page.tsx index 863ad55..baf6fc6 100644 --- a/apps/frontend/app/[locale]/dashboard/booking/[id]/pay/page.tsx +++ b/apps/frontend/app/[locale]/dashboard/booking/[id]/pay/page.tsx @@ -164,7 +164,7 @@ export default function PayCommissionPage() { if (loading) { return ( -
+
Chargement... @@ -175,13 +175,13 @@ export default function PayCommissionPage() { if (error && !booking) { return ( -
+

{error}

@@ -210,13 +210,13 @@ export default function PayCommissionPage() { : (booking.priceUSD || 0) + (booking.priceEUR || 0); return ( -
+
{/* Back button — returns to the previous context (list or booking detail) instead of always jumping to the global list. */}
{/* Booking fee box */} -
+

Forfait par booking — à régler maintenant

{formatPrice(bookingFeeAmount, 'EUR')}

diff --git a/apps/frontend/app/[locale]/dashboard/booking/[id]/payment-success/page.tsx b/apps/frontend/app/[locale]/dashboard/booking/[id]/payment-success/page.tsx index 9ac39ff..9f51c30 100644 --- a/apps/frontend/app/[locale]/dashboard/booking/[id]/payment-success/page.tsx +++ b/apps/frontend/app/[locale]/dashboard/booking/[id]/payment-success/page.tsx @@ -42,14 +42,14 @@ export default function PaymentSuccessPage() { if (!sessionId) { return ( -

+

Session invalide

Aucune session de paiement trouvee.

@@ -59,7 +59,7 @@ export default function PaymentSuccessPage() { } return ( -
+
{status === 'confirming' && ( <> @@ -85,7 +85,7 @@ export default function PaymentSuccessPage() { avec votre demande de booking.

-
+
Email envoye au transporteur @@ -97,7 +97,7 @@ export default function PaymentSuccessPage() { diff --git a/apps/frontend/app/[locale]/dashboard/booking/new/page.tsx b/apps/frontend/app/[locale]/dashboard/booking/new/page.tsx index b5362e1..7c3aec3 100644 --- a/apps/frontend/app/[locale]/dashboard/booking/new/page.tsx +++ b/apps/frontend/app/[locale]/dashboard/booking/new/page.tsx @@ -234,13 +234,13 @@ function NewBookingPageContent() { }; return ( -
+
{/* Header */}
@@ -263,7 +263,7 @@ function NewBookingPageContent() {
= step ? 'bg-brand-turquoise text-brand-navy' : 'bg-neutral-200 text-neutral-600' + currentStep >= step ? 'bg-brand-navy text-white' : 'bg-neutral-200 text-neutral-600' }`} > {step} @@ -283,7 +283,7 @@ function NewBookingPageContent() { {step < 3 && (
step ? 'bg-brand-turquoise' : 'bg-neutral-200' + currentStep > step ? 'bg-brand-blue' : 'bg-neutral-200' }`} /> )} @@ -314,7 +314,7 @@ function NewBookingPageContent() {
{/* Carrier Info */} -
+

Transporteur

@@ -409,7 +409,7 @@ function NewBookingPageContent() { @@ -440,8 +440,8 @@ function NewBookingPageContent() {
{ e.preventDefault(); @@ -470,7 +470,7 @@ function NewBookingPageContent() { />
setFormData(prev => ({ ...prev, notes: e.target.value }))} rows={4} - className="w-full px-4 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-ring focus:border-brand-turquoise" + className="w-full px-4 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-ring focus:border-brand-blue" placeholder="Ajoutez des instructions spéciales pour le transporteur..." />
@@ -571,7 +571,7 @@ function NewBookingPageContent() { @@ -586,7 +586,7 @@ function NewBookingPageContent() {
{/* Summary */} -
+

Récapitulatif

diff --git a/apps/frontend/app/[locale]/dashboard/bookings/[id]/page.tsx b/apps/frontend/app/[locale]/dashboard/bookings/[id]/page.tsx index 6055b94..8b94282 100644 --- a/apps/frontend/app/[locale]/dashboard/bookings/[id]/page.tsx +++ b/apps/frontend/app/[locale]/dashboard/bookings/[id]/page.tsx @@ -198,9 +198,9 @@ export default function BookingDetailPage() {
  • diff --git a/apps/frontend/app/[locale]/dashboard/notifications/page.tsx b/apps/frontend/app/[locale]/dashboard/notifications/page.tsx index 596caa7..94a7242 100644 --- a/apps/frontend/app/[locale]/dashboard/notifications/page.tsx +++ b/apps/frontend/app/[locale]/dashboard/notifications/page.tsx @@ -100,7 +100,7 @@ export default function NotificationsPage() { 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-turquoise bg-brand-turquoise/5 hover:bg-brand-turquoise/10', + 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' @@ -126,7 +126,7 @@ export default function NotificationsPage() { booking_confirmed: , csv_booking_accepted: , csv_booking_rejected: , - csv_booking_request_sent: , + csv_booking_request_sent: , rate_quote_expiring: , document_uploaded: , system_announcement: , @@ -163,7 +163,7 @@ export default function NotificationsPage() {

    -
    +
    @@ -178,7 +178,7 @@ export default function NotificationsPage() {
  • {!notification.read && ( - + {t('new')} @@ -312,7 +312,7 @@ export default function NotificationsPage() { ? 'bg-orange-100 text-orange-700' : notification.priority === 'medium' ? 'bg-yellow-100 text-yellow-700' - : 'bg-brand-turquoise/10 text-brand-navy' + : 'bg-brand-blue/10 text-brand-navy' }`} > {getPriorityLabel(notification.priority)} @@ -361,7 +361,7 @@ export default function NotificationsPage() { - + + {carriersLoading ? ( - - ) : carrierWeightData.length === 0 ? ( - +
    + {Array.from({ length: 3 }).map((_, i) => ( +
    + +
    + ))} +
    + ) : topCarriers && topCarriers.length > 0 ? ( +
      + {topCarriers.slice(0, 5).map((carrier, index) => ( +
    • + + {index + 1} + + +
      +

      + {carrier.carrierName} +

      +

      + {t('topCarriers.bookingsCount', { count: carrier.totalBookings })} + {' · '} + {numberFormat.format(carrier.totalWeightKG)} kg +

      +
      + +
      + {carrier.acceptedBookings} + {carrier.rejectedBookings > 0 && ( + {carrier.rejectedBookings} + )} +
      + +
      +

      + {carrier.acceptanceRate.toFixed(0)} % +

      +

      + ${carrier.avgPriceUSD.toFixed(0)} +

      +
      +
    • + ))} +
    ) : ( - - - - - - - - - + + + + {t('topCarriers.empty.cta')} + + + } + /> )}
    - - - -
    - {t('topCarriers.title')} - {t('topCarriers.description')} -
    - -
    - - - {carriersLoading ? ( -
    - {Array.from({ length: 3 }).map((_, i) => ( -
    - -
    - ))} -
    - ) : topCarriers && topCarriers.length > 0 ? ( -
      - {topCarriers.slice(0, 5).map((carrier, index) => ( -
    • - - {index + 1} - - -
      -

      - {carrier.carrierName} -

      -

      - {t('topCarriers.bookingsCount', { count: carrier.totalBookings })} - {' · '} - {numberFormat.format(carrier.totalWeightKG)} kg -

      -
      - -
      - {carrier.acceptedBookings} - {carrier.rejectedBookings > 0 && ( - {carrier.rejectedBookings} - )} -
      - -
      -

      - {carrier.acceptanceRate.toFixed(0)} % -

      -

      - ${carrier.avgPriceUSD.toFixed(0)} -

      -
      -
    • - ))} -
    - ) : ( - - - - {t('topCarriers.empty.cta')} - - - } - /> - )} -
    -
    ); } +/** Bandeau des points a traiter, en tete de tableau de bord. */ +function AlertsStrip({ + alerts, +}: { + alerts: Array<{ + id: string; + severity: 'critical' | 'high' | 'medium' | 'low'; + titleKey: string; + messageKey: string; + messageParams?: Record; + bookingId?: string; + bookingNumber?: string; + }>; +}) { + const t = useTranslations('dashboard.home.alerts'); + + // Le plus urgent d'abord : trois alertes suffisent en tete de page, le reste + // se consulte dans les listes concernees. + const order = { critical: 0, high: 1, medium: 2, low: 3 } as const; + const sorted = [...alerts].sort((a, b) => order[a.severity] - order[b.severity]).slice(0, 3); + + const tone: Record = { + critical: 'text-destructive', + high: 'text-destructive', + medium: 'text-amber-600', + low: 'text-neutral-500', + }; + + return ( +
    +
    +
    +
      + {sorted.map(alert => ( +
    • + {/* Severite : jamais la couleur seule — un libelle l'accompagne. */} + + {t(`severity.${alert.severity}` as any)} + +
      +

      + {t(`items.${alert.titleKey}` as any)} +

      +

      + {t(`items.${alert.messageKey}` as any, alert.messageParams ?? {})} +

      +
      + {alert.bookingId && ( + + )} +
    • + ))} +
    +
    + ); +} + /** Indicateur secondaire, en bandeau, sans le poids visuel d'une carte. */ function SecondaryStat({ label, diff --git a/apps/frontend/app/[locale]/dashboard/profile/page.tsx b/apps/frontend/app/[locale]/dashboard/profile/page.tsx index 60dc319..22b4536 100644 --- a/apps/frontend/app/[locale]/dashboard/profile/page.tsx +++ b/apps/frontend/app/[locale]/dashboard/profile/page.tsx @@ -142,7 +142,7 @@ export default function ProfilePage() { return (
    -
    +

    {t('loading')}

    @@ -156,7 +156,7 @@ export default function ProfilePage() {

    {t('loadError')}

    @@ -204,7 +204,7 @@ export default function ProfilePage() {
    -
    +
    {user?.firstName?.[0]} {user?.lastName?.[0]}
    @@ -214,7 +214,7 @@ export default function ProfilePage() {

    {user?.email}

    - + {user?.role} @@ -232,7 +232,7 @@ export default function ProfilePage() { onClick={() => setActiveTab('profile')} className={`py-4 px-1 border-b-2 font-medium text-sm ${ activeTab === 'profile' - ? 'border-brand-turquoise text-brand-navy' + ? 'border-brand-blue text-brand-navy' : 'border-transparent text-neutral-500 hover:text-neutral-700 hover:border-neutral-300' }`} > @@ -242,7 +242,7 @@ export default function ProfilePage() { onClick={() => setActiveTab('password')} className={`py-4 px-1 border-b-2 font-medium text-sm ${ activeTab === 'password' - ? 'border-brand-turquoise text-brand-navy' + ? 'border-brand-blue text-brand-navy' : 'border-transparent text-neutral-500 hover:text-neutral-700 hover:border-neutral-300' }`} > @@ -314,7 +314,7 @@ export default function ProfilePage() { @@ -493,7 +493,7 @@ export default function AdvancedSearchPage() { onFocus={() => setShowOriginDropdown(true)} onBlur={() => setTimeout(() => setShowOriginDropdown(false), 200)} placeholder={t('step1.originPlaceholder')} - className={`w-full px-3 py-2 border rounded-md focus:ring-ring focus:border-brand-turquoise ${ + className={`w-full px-3 py-2 border rounded-md focus:ring-ring focus:border-brand-blue ${ searchForm.origin ? 'border-green-500 bg-green-50' : 'border-neutral-300' }`} /> @@ -517,7 +517,7 @@ export default function AdvancedSearchPage() { setDestinationSearch(''); setShowOriginDropdown(false); }} - className="w-full text-left px-4 py-3 hover:bg-brand-turquoise/5 border-b border-border last:border-b-0" + className="w-full text-left px-4 py-3 hover:bg-brand-blue/5 border-b border-border last:border-b-0" >
    {port.name}
    @@ -574,7 +574,7 @@ export default function AdvancedSearchPage() { ? t('step1.destinationPlaceholder') : t('step1.destinationDisabled') } - className={`w-full px-3 py-2 border rounded-md focus:ring-ring focus:border-brand-turquoise ${ + className={`w-full px-3 py-2 border rounded-md focus:ring-ring focus:border-brand-blue ${ searchForm.destination ? 'border-green-500 bg-green-50' : 'border-neutral-300' } ${!searchForm.origin ? 'bg-neutral-100 cursor-not-allowed' : ''}`} /> @@ -604,7 +604,7 @@ export default function AdvancedSearchPage() { setSelectedDestinationPort(port); setShowDestinationDropdown(false); }} - className="w-full text-left px-4 py-3 hover:bg-brand-turquoise/5 border-b border-border last:border-b-0" + className="w-full text-left px-4 py-3 hover:bg-brand-blue/5 border-b border-border last:border-b-0" >
    {port.name}
    @@ -674,7 +674,7 @@ export default function AdvancedSearchPage() { @@ -794,7 +794,7 @@ export default function AdvancedSearchPage() {
    ))} -
    +

    {t('step2.summary.title')}

    {t('step2.summary.volume', { value: totals.totalVolumeCBM.toFixed(2) })}
    @@ -1007,14 +1007,14 @@ export default function AdvancedSearchPage() {
    = step ? 'bg-brand-turquoise text-brand-navy' : 'bg-neutral-200 text-neutral-600' + currentStep >= step ? 'bg-brand-navy text-white' : 'bg-neutral-200 text-neutral-600' }`} > {index + 1}
    {index < wizardSteps.length - 1 && (
    step ? 'bg-brand-turquoise' : 'bg-neutral-200'}`} + className={`w-20 h-1 mx-2 ${currentStep > step ? 'bg-brand-blue' : 'bg-neutral-200'}`} /> )}
    @@ -1046,7 +1046,7 @@ export default function AdvancedSearchPage() { ? !direction : !searchForm.origin || !searchForm.destination } - className="px-6 py-2 text-sm font-medium text-white bg-brand-turquoise rounded-md hover:bg-brand-turquoise/90 disabled:opacity-50 disabled:cursor-not-allowed" + className="px-6 py-2 text-sm font-medium text-white bg-brand-navy rounded-md hover:bg-brand-navy/90 disabled:opacity-50 disabled:cursor-not-allowed" > {t('navigation.next')} diff --git a/apps/frontend/app/[locale]/dashboard/search-advanced/results/page.tsx b/apps/frontend/app/[locale]/dashboard/search-advanced/results/page.tsx index 9c53de7..642f119 100644 --- a/apps/frontend/app/[locale]/dashboard/search-advanced/results/page.tsx +++ b/apps/frontend/app/[locale]/dashboard/search-advanced/results/page.tsx @@ -178,10 +178,10 @@ export default function SearchResultsPage() { if (isLoading) { return ( -
    +
    -
    +

    {t('loadingTitle')}

    {origin} → {destination} @@ -194,7 +194,7 @@ export default function SearchResultsPage() { if (error) { return ( -

    +
    @@ -220,11 +220,11 @@ export default function SearchResultsPage() { if (results.length === 0) { return ( -
    +
    @@ -247,7 +247,7 @@ export default function SearchResultsPage() {
    @@ -274,10 +274,10 @@ export default function SearchResultsPage() { type: t('options.standard'), option: bestOptions?.standard, colors: { - border: 'border-brand-turquoise/30', - bg: 'bg-brand-turquoise/5', + border: 'border-brand-blue/30', + bg: 'bg-brand-blue/5', text: 'text-brand-navy', - button: 'bg-brand-turquoise hover:bg-brand-turquoise/90', + button: 'bg-brand-navy hover:bg-brand-navy/90', }, icon: , badge: t('options.badgeBalanced'), @@ -297,13 +297,13 @@ export default function SearchResultsPage() { ]; return ( -
    +
    {/* Header */}
    @@ -333,7 +333,7 @@ export default function SearchResultsPage() {
    {editBookingId && ( -
    +
    {t('editMode')}
    )} @@ -501,7 +501,7 @@ export default function SearchResultsPage() { @@ -240,7 +240,7 @@ function RevokeConfirmModal({
    @@ -371,14 +371,14 @@ export default function ApiKeysPage() { } /> -
    +

    {t('infoTitle')}

    {t.rich('infoBody', { code: () => ( - + X-API-Key: xped_live_... ), diff --git a/apps/frontend/app/[locale]/dashboard/settings/organization/page.tsx b/apps/frontend/app/[locale]/dashboard/settings/organization/page.tsx index f0c7509..bbdadff 100644 --- a/apps/frontend/app/[locale]/dashboard/settings/organization/page.tsx +++ b/apps/frontend/app/[locale]/dashboard/settings/organization/page.tsx @@ -149,7 +149,7 @@ export default function OrganizationSettingsPage() { return (

    -
    +

    {t('loading')}

    @@ -288,7 +288,7 @@ export default function OrganizationSettingsPage() { )} {!canEdit && (activeTab === 'information' || activeTab === 'address') && ( -
    +
    setActiveTab(tab.id)} className={`flex-shrink-0 px-6 py-4 text-sm font-medium border-b-2 transition-colors ${ activeTab === tab.id - ? 'border-brand-turquoise text-brand-navy' + ? 'border-brand-blue text-brand-navy' : 'border-transparent text-neutral-500 hover:text-neutral-700 hover:border-neutral-300' }`} > @@ -498,7 +498,7 @@ export default function OrganizationSettingsPage() { type="button" onClick={handleCancel} disabled={isSaving} - className="px-6 py-2 text-sm font-medium text-neutral-700 bg-white border border-neutral-300 rounded-lg hover:bg-neutral-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors" + className="px-6 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" > {t('actions.cancel')} @@ -506,7 +506,7 @@ export default function OrganizationSettingsPage() { type="button" onClick={handleSave} disabled={isSaving || !formData.name || !formData.address_street} - className="px-6 py-2 text-sm font-medium text-white bg-brand-turquoise rounded-lg hover:bg-brand-turquoise/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center" + className="px-6 py-2 text-sm font-medium text-white bg-brand-navy rounded-lg hover:bg-brand-navy/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center" > {isSaving ? ( <> diff --git a/apps/frontend/app/[locale]/dashboard/settings/subscription/page.tsx b/apps/frontend/app/[locale]/dashboard/settings/subscription/page.tsx index df4604c..82eb33f 100644 --- a/apps/frontend/app/[locale]/dashboard/settings/subscription/page.tsx +++ b/apps/frontend/app/[locale]/dashboard/settings/subscription/page.tsx @@ -19,7 +19,7 @@ export default function SubscriptionPage() { return (
    -
    +

    {t('loading')}

    diff --git a/apps/frontend/app/[locale]/dashboard/settings/users/page.tsx b/apps/frontend/app/[locale]/dashboard/settings/users/page.tsx index ee88418..eba1048 100644 --- a/apps/frontend/app/[locale]/dashboard/settings/users/page.tsx +++ b/apps/frontend/app/[locale]/dashboard/settings/users/page.tsx @@ -50,7 +50,7 @@ function Pagination({ onClick={() => onPage(p)} className={`px-3 py-1 rounded text-sm border ${ p === page - ? 'bg-brand-turquoise text-brand-navy border-brand-turquoise' + ? 'bg-brand-navy text-white border-brand-blue' : 'border-neutral-300 hover:bg-neutral-50' }`} > @@ -199,7 +199,7 @@ export default function UsersManagementPage() { if (!currentUser || (currentUser.role !== 'ADMIN' && currentUser.role !== 'MANAGER')) { return (
    -
    +
    ); } @@ -249,7 +249,7 @@ export default function UsersManagementPage() { const getRoleBadgeColor = (role: string) => { const colors: Record = { ADMIN: 'bg-red-100 text-red-800', - MANAGER: 'bg-brand-turquoise/10 text-brand-navy', + MANAGER: 'bg-brand-blue/10 text-brand-navy', USER: 'bg-green-100 text-green-800', VIEWER: 'bg-neutral-100 text-neutral-800', }; @@ -304,10 +304,10 @@ export default function UsersManagementPage() { licenseStatus.canInvite && licenseStatus.availableLicenses <= 2 && licenseStatus.maxLicenses !== -1 && ( -
    +
    - + {t('license.manageLink')} @@ -365,7 +365,7 @@ export default function UsersManagementPage() { {licenseStatus?.canInvite ? ( + {t('actions.invite')} @@ -408,7 +408,7 @@ export default function UsersManagementPage() {
    {isLoading ? (
    -
    +
    {t('loading')}
    ) : pagedUsers.length > 0 ? ( @@ -442,7 +442,7 @@ export default function UsersManagementPage() {
    -
    +
    {user.firstName[0]} {user.lastName[0]}
    @@ -544,7 +544,7 @@ export default function UsersManagementPage() { {licenseStatus?.canInvite ? (
    @@ -814,7 +814,7 @@ export default function UsersManagementPage() { required value={inviteForm.lastName} onChange={e => setInviteForm({ ...inviteForm, lastName: e.target.value })} - className="mt-1 block w-full rounded-md border-neutral-300 shadow-sm focus:border-brand-turquoise focus:ring-ring sm:text-sm px-3 py-2 border" + className="mt-1 block w-full rounded-md border-neutral-300 shadow-sm focus:border-brand-blue focus:ring-ring sm:text-sm px-3 py-2 border" />
    @@ -827,7 +827,7 @@ export default function UsersManagementPage() { required value={inviteForm.email} onChange={e => setInviteForm({ ...inviteForm, email: e.target.value })} - className="mt-1 block w-full rounded-md border-neutral-300 shadow-sm focus:border-brand-turquoise focus:ring-ring sm:text-sm px-3 py-2 border" + className="mt-1 block w-full rounded-md border-neutral-300 shadow-sm focus:border-brand-blue focus:ring-ring sm:text-sm px-3 py-2 border" />
    @@ -837,7 +837,7 @@ export default function UsersManagementPage() {