fix design
This commit is contained in:
parent
84c34bdc21
commit
8b294d6ed1
@ -4,27 +4,26 @@
|
|||||||
* Calculates KPIs and analytics data for dashboard
|
* Calculates KPIs and analytics data for dashboard
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Injectable, Inject } from '@nestjs/common';
|
import { Injectable } 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 { TypeOrmCsvBookingRepository } from '../../infrastructure/persistence/typeorm/repositories/csv-booking.repository';
|
import { TypeOrmCsvBookingRepository } from '../../infrastructure/persistence/typeorm/repositories/csv-booking.repository';
|
||||||
import { CsvBooking, CsvBookingStatus } from '@domain/entities/csv-booking.entity';
|
import { CsvBooking, CsvBookingStatus } from '@domain/entities/csv-booking.entity';
|
||||||
|
|
||||||
export interface DashboardKPIs {
|
export interface DashboardKPIs {
|
||||||
bookingsThisMonth: number;
|
bookingsThisMonth: number;
|
||||||
totalTEUs: number;
|
/** Volume LCL du mois, en CBM. */
|
||||||
|
volumeCBM: number;
|
||||||
|
/** Commission Xpeditis encaissee sur les reservations acceptees, en EUR. */
|
||||||
estimatedRevenue: number;
|
estimatedRevenue: number;
|
||||||
pendingConfirmations: number;
|
pendingConfirmations: number;
|
||||||
bookingsThisMonthChange: number; // % change from last month
|
bookingsThisMonthChange: number; // % change from last month
|
||||||
totalTEUsChange: number;
|
volumeCBMChange: number;
|
||||||
estimatedRevenueChange: number;
|
estimatedRevenueChange: number;
|
||||||
pendingConfirmationsChange: number;
|
pendingConfirmationsChange: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BookingsChartData {
|
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
|
data: number[]; // Booking counts
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -33,7 +32,7 @@ export interface TopTradeLane {
|
|||||||
originPort: string;
|
originPort: string;
|
||||||
destinationPort: string;
|
destinationPort: string;
|
||||||
bookingCount: number;
|
bookingCount: number;
|
||||||
totalTEUs: number;
|
totalVolumeCBM: number;
|
||||||
avgPrice: number;
|
avgPrice: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -41,8 +40,10 @@ export interface DashboardAlert {
|
|||||||
id: string;
|
id: string;
|
||||||
type: 'delay' | 'confirmation' | 'document' | 'payment' | 'info';
|
type: 'delay' | 'confirmation' | 'document' | 'payment' | 'info';
|
||||||
severity: 'low' | 'medium' | 'high' | 'critical';
|
severity: 'low' | 'medium' | 'high' | 'critical';
|
||||||
title: string;
|
/** Cle de traduction, resolue par le client. */
|
||||||
message: string;
|
titleKey: string;
|
||||||
|
messageKey: string;
|
||||||
|
messageParams?: Record<string, string | number>;
|
||||||
bookingId?: string;
|
bookingId?: string;
|
||||||
bookingNumber?: string;
|
bookingNumber?: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
@ -73,125 +74,94 @@ export interface TopCarrier {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AnalyticsService {
|
export class AnalyticsService {
|
||||||
constructor(
|
constructor(private readonly csvBookingRepository: TypeOrmCsvBookingRepository) {}
|
||||||
@Inject(BOOKING_REPOSITORY)
|
|
||||||
private readonly bookingRepository: BookingRepository,
|
|
||||||
@Inject(RATE_QUOTE_REPOSITORY)
|
|
||||||
private readonly rateQuoteRepository: RateQuoteRepository,
|
|
||||||
private readonly csvBookingRepository: TypeOrmCsvBookingRepository
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate dashboard KPIs
|
* 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<DashboardKPIs> {
|
async calculateKPIs(organizationId: string): Promise<DashboardKPIs> {
|
||||||
|
const bookings = await this.csvBookingRepository.findByOrganizationId(organizationId);
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const thisMonthStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
const thisMonthStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
const lastMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 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 thisMonth = bookings.filter((b: CsvBooking) => b.requestedAt >= thisMonthStart);
|
||||||
const allBookings = await this.bookingRepository.findByOrganization(organizationId);
|
const lastMonth = bookings.filter(
|
||||||
|
(b: CsvBooking) => b.requestedAt >= lastMonthStart && b.requestedAt < thisMonthStart
|
||||||
// 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
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Calculate total TEUs (20' = 1 TEU, 40' = 2 TEU)
|
// LCL : le volume se mesure en CBM. Le TEU, unite du conteneur complet, ne
|
||||||
// Each container is an individual entity, so we count them
|
// veut rien dire ici — c'est ce que l'ancienne version calculait.
|
||||||
const calculateTEUs = (bookings: typeof allBookings): number => {
|
const volumeOf = (list: CsvBooking[]): number =>
|
||||||
return bookings.reduce((total, booking) => {
|
list.reduce((sum, b) => sum + (Number(b.volumeCBM) || 0), 0);
|
||||||
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);
|
|
||||||
};
|
|
||||||
|
|
||||||
const totalTEUsThisMonth = calculateTEUs(thisMonthBookings);
|
// Le chiffre d'affaires est celui d'Xpeditis : la commission percue, pas le
|
||||||
const totalTEUsLastMonth = calculateTEUs(lastMonthBookings);
|
// 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 pendingOf = (list: CsvBooking[]): number =>
|
||||||
const calculateRevenue = async (bookings: typeof allBookings): Promise<number> => {
|
list.filter(b => b.status === CsvBookingStatus.PENDING).length;
|
||||||
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 estimatedRevenueThisMonth = await calculateRevenue(thisMonthBookings);
|
const volumeThisMonth = volumeOf(thisMonth);
|
||||||
const estimatedRevenueLastMonth = await calculateRevenue(lastMonthBookings);
|
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 change = (current: number, previous: number): number => {
|
||||||
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 => {
|
|
||||||
if (previous === 0) return current > 0 ? 100 : 0;
|
if (previous === 0) return current > 0 ? 100 : 0;
|
||||||
return ((current - previous) / previous) * 100;
|
return ((current - previous) / previous) * 100;
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
bookingsThisMonth: thisMonthBookings.length,
|
bookingsThisMonth: thisMonth.length,
|
||||||
totalTEUs: totalTEUsThisMonth,
|
volumeCBM: volumeThisMonth,
|
||||||
estimatedRevenue: estimatedRevenueThisMonth,
|
estimatedRevenue: revenueThisMonth,
|
||||||
pendingConfirmations: pendingThisMonth,
|
pendingConfirmations: pendingThisMonth,
|
||||||
bookingsThisMonthChange: calculateChange(thisMonthBookings.length, lastMonthBookings.length),
|
bookingsThisMonthChange: change(thisMonth.length, lastMonth.length),
|
||||||
totalTEUsChange: calculateChange(totalTEUsThisMonth, totalTEUsLastMonth),
|
volumeCBMChange: change(volumeThisMonth, volumeLastMonth),
|
||||||
estimatedRevenueChange: calculateChange(estimatedRevenueThisMonth, estimatedRevenueLastMonth),
|
estimatedRevenueChange: change(revenueThisMonth, revenueLastMonth),
|
||||||
pendingConfirmationsChange: calculateChange(pendingThisMonth, pendingLastMonth),
|
pendingConfirmationsChange: change(pendingThisMonth, pendingLastMonth),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get bookings chart data for last 6 months
|
* 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<BookingsChartData> {
|
async getBookingsChartData(organizationId: string): Promise<BookingsChartData> {
|
||||||
|
const bookings = await this.csvBookingRepository.findByOrganizationId(organizationId);
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const labels: string[] = [];
|
const labels: string[] = [];
|
||||||
const data: number[] = [];
|
const data: number[] = [];
|
||||||
|
|
||||||
// Get bookings for last 6 months
|
|
||||||
const allBookings = await this.bookingRepository.findByOrganization(organizationId);
|
|
||||||
|
|
||||||
for (let i = 5; i >= 0; i--) {
|
for (let i = 5; i >= 0; i--) {
|
||||||
const monthDate = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
const monthStart = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||||
const monthEnd = new Date(now.getFullYear(), now.getMonth() - i + 1, 0, 23, 59, 59);
|
const nextMonthStart = new Date(now.getFullYear(), now.getMonth() - i + 1, 1);
|
||||||
|
|
||||||
// Month label (e.g., "Jan 2025")
|
labels.push(
|
||||||
const monthLabel = monthDate.toLocaleDateString('en-US', {
|
`${monthStart.getFullYear()}-${String(monthStart.getMonth() + 1).padStart(2, '0')}`
|
||||||
month: 'short',
|
);
|
||||||
year: 'numeric',
|
data.push(
|
||||||
});
|
bookings.filter(
|
||||||
labels.push(monthLabel);
|
(b: CsvBooking) => b.requestedAt >= monthStart && b.requestedAt < nextMonthStart
|
||||||
|
).length
|
||||||
// Count bookings in this month
|
);
|
||||||
const count = allBookings.filter(
|
|
||||||
b => b.createdAt >= monthDate && b.createdAt <= monthEnd
|
|
||||||
).length;
|
|
||||||
data.push(count);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { labels, data };
|
return { labels, data };
|
||||||
@ -201,136 +171,133 @@ export class AnalyticsService {
|
|||||||
* Get top 5 trade lanes
|
* Get top 5 trade lanes
|
||||||
*/
|
*/
|
||||||
async getTopTradeLanes(organizationId: string): Promise<TopTradeLane[]> {
|
async getTopTradeLanes(organizationId: string): Promise<TopTradeLane[]> {
|
||||||
const allBookings = await this.bookingRepository.findByOrganization(organizationId);
|
const bookings = await this.csvBookingRepository.findByOrganizationId(organizationId);
|
||||||
|
|
||||||
// Group by route (origin-destination)
|
|
||||||
const routeMap = new Map<
|
const routeMap = new Map<
|
||||||
string,
|
string,
|
||||||
{
|
{
|
||||||
originPort: string;
|
originPort: string;
|
||||||
destinationPort: string;
|
destinationPort: string;
|
||||||
bookingCount: number;
|
bookingCount: number;
|
||||||
totalTEUs: number;
|
totalVolumeCBM: number;
|
||||||
totalPrice: number;
|
totalPrice: number;
|
||||||
}
|
}
|
||||||
>();
|
>();
|
||||||
|
|
||||||
for (const booking of allBookings) {
|
for (const booking of bookings) {
|
||||||
try {
|
const originPort = booking.origin.getValue();
|
||||||
const rateQuote = await this.rateQuoteRepository.findById(booking.rateQuoteId);
|
const destinationPort = booking.destination.getValue();
|
||||||
if (!rateQuote) continue;
|
const routeKey = `${originPort} → ${destinationPort}`;
|
||||||
|
|
||||||
// Get first and last ports from route
|
const entry = routeMap.get(routeKey) ?? {
|
||||||
const originPort = rateQuote.route[0]?.portCode || 'UNKNOWN';
|
originPort,
|
||||||
const destinationPort = rateQuote.route[rateQuote.route.length - 1]?.portCode || 'UNKNOWN';
|
destinationPort,
|
||||||
const routeKey = `${originPort}-${destinationPort}`;
|
bookingCount: 0,
|
||||||
|
totalVolumeCBM: 0,
|
||||||
|
totalPrice: 0,
|
||||||
|
};
|
||||||
|
|
||||||
if (!routeMap.has(routeKey)) {
|
entry.bookingCount++;
|
||||||
routeMap.set(routeKey, {
|
entry.totalVolumeCBM += Number(booking.volumeCBM) || 0;
|
||||||
originPort,
|
entry.totalPrice += Number(booking.priceEUR) || 0;
|
||||||
destinationPort,
|
routeMap.set(routeKey, entry);
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert to array and sort by booking count
|
return Array.from(routeMap.entries())
|
||||||
const tradeLanes: TopTradeLane[] = Array.from(routeMap.entries()).map(([route, data]) => ({
|
.map(([route, entry]) => ({
|
||||||
route,
|
route,
|
||||||
originPort: data.originPort,
|
originPort: entry.originPort,
|
||||||
destinationPort: data.destinationPort,
|
destinationPort: entry.destinationPort,
|
||||||
bookingCount: data.bookingCount,
|
bookingCount: entry.bookingCount,
|
||||||
totalTEUs: data.totalTEUs,
|
totalVolumeCBM: entry.totalVolumeCBM,
|
||||||
avgPrice: data.totalPrice / data.bookingCount,
|
avgPrice: entry.bookingCount > 0 ? entry.totalPrice / entry.bookingCount : 0,
|
||||||
}));
|
}))
|
||||||
|
.sort((a, b) => b.bookingCount - a.bookingCount)
|
||||||
// Sort by booking count and return top 5
|
.slice(0, 5);
|
||||||
return tradeLanes.sort((a, b) => b.bookingCount - a.bookingCount).slice(0, 5);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get dashboard alerts
|
* 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<DashboardAlert[]> {
|
async getAlerts(organizationId: string): Promise<DashboardAlert[]> {
|
||||||
|
const bookings = await this.csvBookingRepository.findByOrganizationId(organizationId);
|
||||||
const alerts: DashboardAlert[] = [];
|
const alerts: DashboardAlert[] = [];
|
||||||
const allBookings = await this.bookingRepository.findByOrganization(organizationId);
|
|
||||||
|
|
||||||
// Check for pending confirmations (older than 24h)
|
const now = Date.now();
|
||||||
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
const oneDay = 24 * 60 * 60 * 1000;
|
||||||
const oldPendingBookings = allBookings.filter(
|
|
||||||
b => b.status.value === 'pending_confirmation' && b.createdAt < oneDayAgo
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const booking of oldPendingBookings) {
|
for (const booking of bookings) {
|
||||||
alerts.push({
|
const waitedMs = now - booking.requestedAt.getTime();
|
||||||
id: `pending-${booking.id}`,
|
const waitedDays = Math.floor(waitedMs / oneDay);
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for bookings departing soon (within 7 days) with pending status
|
// Sans reponse du transporteur au-dela de 48 h, la demande decroche.
|
||||||
const sevenDaysFromNow = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
|
if (booking.status === CsvBookingStatus.PENDING && waitedMs > 2 * oneDay) {
|
||||||
for (const booking of allBookings) {
|
alerts.push({
|
||||||
try {
|
id: `pending-${booking.id}`,
|
||||||
const rateQuote = await this.rateQuoteRepository.findById(booking.rateQuoteId);
|
type: 'confirmation',
|
||||||
if (rateQuote && rateQuote.route.length > 0) {
|
severity: waitedDays >= 5 ? 'high' : 'medium',
|
||||||
const etd = rateQuote.route[0].departure;
|
titleKey: 'awaitingCarrier',
|
||||||
if (etd) {
|
messageKey: 'awaitingCarrierSince',
|
||||||
const etdDate = new Date(etd);
|
messageParams: { days: waitedDays, carrier: booking.carrierName },
|
||||||
if (
|
bookingId: booking.id,
|
||||||
etdDate <= sevenDaysFromNow &&
|
bookingNumber: booking.bookingNumber,
|
||||||
etdDate >= new Date() &&
|
createdAt: booking.requestedAt,
|
||||||
booking.status.value === 'pending_confirmation'
|
isRead: false,
|
||||||
) {
|
});
|
||||||
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) {
|
|
||||||
continue;
|
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 };
|
const severityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
|
||||||
alerts.sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity]);
|
return alerts.sort(
|
||||||
|
(a, b) =>
|
||||||
return alerts;
|
severityOrder[a.severity] - severityOrder[b.severity] ||
|
||||||
|
b.createdAt.getTime() - a.createdAt.getTime()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -68,7 +68,7 @@ const STATUS_LABELS: Record<BlogPostStatus, string> = {
|
|||||||
|
|
||||||
const STATUS_COLORS: Record<BlogPostStatus, string> = {
|
const STATUS_COLORS: Record<BlogPostStatus, string> = {
|
||||||
draft: 'bg-yellow-100 text-yellow-800',
|
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',
|
published: 'bg-green-100 text-green-800',
|
||||||
archived: 'bg-neutral-100 text-neutral-600',
|
archived: 'bg-neutral-100 text-neutral-600',
|
||||||
};
|
};
|
||||||
@ -526,7 +526,7 @@ export default function AdminBlogPage() {
|
|||||||
actions={
|
actions={
|
||||||
<button
|
<button
|
||||||
onClick={openCreate}
|
onClick={openCreate}
|
||||||
className="flex items-center space-x-2 px-4 py-2 bg-brand-turquoise text-brand-navy rounded-lg hover:bg-brand-turquoise/90 transition-colors font-medium text-sm"
|
className="flex items-center space-x-2 px-4 py-2 bg-brand-navy text-white rounded-lg hover:bg-brand-navy/90 transition-colors font-medium text-sm"
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4" />
|
<Plus className="w-4 h-4" />
|
||||||
<span>Nouvel article</span>
|
<span>Nouvel article</span>
|
||||||
@ -556,7 +556,7 @@ export default function AdminBlogPage() {
|
|||||||
onClick={() => setViewFilter(tab.value)}
|
onClick={() => setViewFilter(tab.value)}
|
||||||
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
||||||
viewFilter === tab.value
|
viewFilter === tab.value
|
||||||
? 'bg-brand-turquoise text-brand-navy'
|
? 'bg-brand-navy text-white'
|
||||||
: 'bg-white text-neutral-600 border border-border hover:bg-neutral-50'
|
: 'bg-white text-neutral-600 border border-border hover:bg-neutral-50'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@ -814,7 +814,7 @@ export default function AdminBlogPage() {
|
|||||||
required
|
required
|
||||||
value={formData.title}
|
value={formData.title}
|
||||||
onChange={e => handleTitleChange(e.target.value)}
|
onChange={e => handleTitleChange(e.target.value)}
|
||||||
className="w-full text-3xl font-bold text-brand-navy border-0 border-b-2 border-border focus:border-brand-turquoise focus:outline-none pb-2 placeholder-neutral-300 bg-transparent"
|
className="w-full text-3xl font-bold text-brand-navy border-0 border-b-2 border-border focus:border-brand-blue focus:outline-none pb-2 placeholder-neutral-300 bg-transparent"
|
||||||
placeholder="Titre de l'article..."
|
placeholder="Titre de l'article..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -1011,7 +1011,7 @@ export default function AdminBlogPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={addFaqItem}
|
onClick={addFaqItem}
|
||||||
className="inline-flex items-center gap-1 text-xs font-medium text-brand-navy hover:text-brand-turquoise"
|
className="inline-flex items-center gap-1 text-xs font-medium text-brand-navy hover:text-brand-blue"
|
||||||
>
|
>
|
||||||
<Plus className="w-3.5 h-3.5" />
|
<Plus className="w-3.5 h-3.5" />
|
||||||
Ajouter
|
Ajouter
|
||||||
@ -1142,7 +1142,7 @@ export default function AdminBlogPage() {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
className="w-full py-2.5 bg-brand-turquoise text-brand-navy rounded-lg hover:bg-brand-turquoise/90 transition-colors font-medium disabled:opacity-50 flex items-center justify-center gap-2 text-sm"
|
className="w-full py-2.5 bg-brand-navy text-white rounded-lg hover:bg-brand-navy/90 transition-colors font-medium disabled:opacity-50 flex items-center justify-center gap-2 text-sm"
|
||||||
>
|
>
|
||||||
{saving && <Loader2 className="w-4 h-4 animate-spin" />}
|
{saving && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||||
{isScheduleMode ? 'Planifier' : 'Enregistrer brouillon'}
|
{isScheduleMode ? 'Planifier' : 'Enregistrer brouillon'}
|
||||||
@ -1152,7 +1152,7 @@ export default function AdminBlogPage() {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
className="w-full py-2.5 bg-brand-turquoise text-brand-navy rounded-lg hover:bg-brand-turquoise/90 transition-colors font-medium disabled:opacity-50 flex items-center justify-center gap-2 text-sm"
|
className="w-full py-2.5 bg-brand-navy text-white rounded-lg hover:bg-brand-navy/90 transition-colors font-medium disabled:opacity-50 flex items-center justify-center gap-2 text-sm"
|
||||||
>
|
>
|
||||||
{saving && <Loader2 className="w-4 h-4 animate-spin" />}
|
{saving && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||||
{saving ? 'Enregistrement...' : 'Enregistrer'}
|
{saving ? 'Enregistrement...' : 'Enregistrer'}
|
||||||
@ -1191,8 +1191,8 @@ export default function AdminBlogPage() {
|
|||||||
onDrop={handleCoverDrop}
|
onDrop={handleCoverDrop}
|
||||||
className={`w-full h-32 border-2 border-dashed rounded-lg flex flex-col items-center justify-center gap-2 transition-colors disabled:opacity-50 ${
|
className={`w-full h-32 border-2 border-dashed rounded-lg flex flex-col items-center justify-center gap-2 transition-colors disabled:opacity-50 ${
|
||||||
isDraggingCover
|
isDraggingCover
|
||||||
? 'border-brand-turquoise bg-brand-turquoise/5 text-brand-navy'
|
? 'border-brand-blue bg-brand-blue/5 text-brand-navy'
|
||||||
: 'border-neutral-300 text-neutral-400 hover:border-brand-turquoise hover:text-brand-navy'
|
: 'border-neutral-300 text-neutral-400 hover:border-brand-blue hover:text-brand-navy'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{uploadingCover ? (
|
{uploadingCover ? (
|
||||||
|
|||||||
@ -148,7 +148,7 @@ export default function AdminBookingsPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-96">
|
<div className="flex items-center justify-center h-96">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-brand-turquoise mx-auto"></div>
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-brand-blue mx-auto"></div>
|
||||||
<p className="mt-4 text-neutral-600">{t('loading')}</p>
|
<p className="mt-4 text-neutral-600">{t('loading')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -211,7 +211,7 @@ export default function AdminBookingsPage() {
|
|||||||
placeholder={t('search.placeholder')}
|
placeholder={t('search.placeholder')}
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={e => setSearchTerm(e.target.value)}
|
onChange={e => setSearchTerm(e.target.value)}
|
||||||
className="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 text-sm"
|
className="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 text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@ -221,7 +221,7 @@ export default function AdminBookingsPage() {
|
|||||||
<select
|
<select
|
||||||
value={filterStatus}
|
value={filterStatus}
|
||||||
onChange={e => setFilterStatus(e.target.value)}
|
onChange={e => setFilterStatus(e.target.value)}
|
||||||
className="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 text-sm"
|
className="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 text-sm"
|
||||||
>
|
>
|
||||||
<option value="all">{t('filter.all')}</option>
|
<option value="all">{t('filter.all')}</option>
|
||||||
<option value="pending_bank_transfer">{t('status.PENDING_BANK_TRANSFER')}</option>
|
<option value="pending_bank_transfer">{t('status.PENDING_BANK_TRANSFER')}</option>
|
||||||
|
|||||||
@ -30,7 +30,7 @@ import { useToast } from '@/components/ui/toast';
|
|||||||
import { useConfirm } from '@/components/ui/use-confirm';
|
import { useConfirm } from '@/components/ui/use-confirm';
|
||||||
|
|
||||||
const DIRECTION_COLORS: Record<'EXPORT' | 'IMPORT', string> = {
|
const DIRECTION_COLORS: Record<'EXPORT' | 'IMPORT', string> = {
|
||||||
EXPORT: 'bg-brand-turquoise/10 text-brand-navy',
|
EXPORT: 'bg-brand-blue/10 text-brand-navy',
|
||||||
IMPORT: 'bg-purple-100 text-purple-800',
|
IMPORT: 'bg-purple-100 text-purple-800',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -222,9 +222,9 @@ export default function AdminDocumentsPage() {
|
|||||||
png: <ImageIcon className={`${cls} text-green-500`} />,
|
png: <ImageIcon className={`${cls} text-green-500`} />,
|
||||||
gif: <ImageIcon className={`${cls} text-green-500`} />,
|
gif: <ImageIcon className={`${cls} text-green-500`} />,
|
||||||
image: <ImageIcon className={`${cls} text-green-500`} />,
|
image: <ImageIcon className={`${cls} text-green-500`} />,
|
||||||
word: <FileEdit className={`${cls} text-brand-turquoise`} />,
|
word: <FileEdit className={`${cls} text-brand-blue`} />,
|
||||||
doc: <FileEdit className={`${cls} text-brand-turquoise`} />,
|
doc: <FileEdit className={`${cls} text-brand-blue`} />,
|
||||||
docx: <FileEdit className={`${cls} text-brand-turquoise`} />,
|
docx: <FileEdit className={`${cls} text-brand-blue`} />,
|
||||||
excel: <FileSpreadsheet className={`${cls} text-green-600`} />,
|
excel: <FileSpreadsheet className={`${cls} text-green-600`} />,
|
||||||
xls: <FileSpreadsheet className={`${cls} text-green-600`} />,
|
xls: <FileSpreadsheet className={`${cls} text-green-600`} />,
|
||||||
xlsx: <FileSpreadsheet className={`${cls} text-green-600`} />,
|
xlsx: <FileSpreadsheet className={`${cls} text-green-600`} />,
|
||||||
@ -304,7 +304,7 @@ export default function AdminDocumentsPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-96">
|
<div className="flex items-center justify-center h-96">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-brand-turquoise mx-auto"></div>
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-brand-blue mx-auto"></div>
|
||||||
<p className="mt-4 text-neutral-600">{t('loading')}</p>
|
<p className="mt-4 text-neutral-600">{t('loading')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -345,7 +345,7 @@ export default function AdminDocumentsPage() {
|
|||||||
placeholder={t('filters.searchPlaceholder')}
|
placeholder={t('filters.searchPlaceholder')}
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={e => setSearchTerm(e.target.value)}
|
onChange={e => setSearchTerm(e.target.value)}
|
||||||
className="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="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"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@ -357,7 +357,7 @@ export default function AdminDocumentsPage() {
|
|||||||
placeholder={t('filters.quoteNumberPlaceholder')}
|
placeholder={t('filters.quoteNumberPlaceholder')}
|
||||||
value={filterQuoteNumber}
|
value={filterQuoteNumber}
|
||||||
onChange={e => setFilterQuoteNumber(e.target.value)}
|
onChange={e => setFilterQuoteNumber(e.target.value)}
|
||||||
className="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="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"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@ -367,7 +367,7 @@ export default function AdminDocumentsPage() {
|
|||||||
<select
|
<select
|
||||||
value={filterUserId}
|
value={filterUserId}
|
||||||
onChange={e => setFilterUserId(e.target.value)}
|
onChange={e => setFilterUserId(e.target.value)}
|
||||||
className="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="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"
|
||||||
>
|
>
|
||||||
<option value="all">{t('filters.allUsers')}</option>
|
<option value="all">{t('filters.allUsers')}</option>
|
||||||
{uniqueUsers.map(user => (
|
{uniqueUsers.map(user => (
|
||||||
@ -572,7 +572,7 @@ export default function AdminDocumentsPage() {
|
|||||||
onClick={() => setCurrentPage(pageNum)}
|
onClick={() => setCurrentPage(pageNum)}
|
||||||
className={`relative inline-flex items-center px-4 py-2 border text-sm font-medium ${
|
className={`relative inline-flex items-center px-4 py-2 border text-sm font-medium ${
|
||||||
currentPage === pageNum
|
currentPage === pageNum
|
||||||
? 'z-10 bg-brand-turquoise/5 border-brand-turquoise text-brand-navy'
|
? 'z-10 bg-brand-blue/5 border-brand-blue text-brand-navy'
|
||||||
: 'bg-white border-neutral-300 text-neutral-500 hover:bg-neutral-50'
|
: 'bg-white border-neutral-300 text-neutral-500 hover:bg-neutral-50'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -60,7 +60,7 @@ export default function AdminLoginPage() {
|
|||||||
priority
|
priority
|
||||||
className="h-auto mb-4"
|
className="h-auto mb-4"
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center gap-2 text-brand-turquoise">
|
<div className="flex items-center gap-2 text-brand-blue">
|
||||||
<ShieldCheck className="w-5 h-5" />
|
<ShieldCheck className="w-5 h-5" />
|
||||||
<span className="text-xs font-semibold uppercase tracking-wider">{t('badge')}</span>
|
<span className="text-xs font-semibold uppercase tracking-wider">{t('badge')}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -55,7 +55,7 @@ const LEVEL_STYLES: Record<string, string> = {
|
|||||||
error: 'bg-red-100 text-red-700 border border-red-200',
|
error: 'bg-red-100 text-red-700 border border-red-200',
|
||||||
fatal: 'bg-red-200 text-red-900 border border-red-300',
|
fatal: 'bg-red-200 text-red-900 border border-red-300',
|
||||||
warn: 'bg-yellow-100 text-yellow-700 border border-yellow-200',
|
warn: 'bg-yellow-100 text-yellow-700 border border-yellow-200',
|
||||||
info: 'bg-brand-turquoise/10 text-brand-navy border border-brand-turquoise/30',
|
info: 'bg-brand-blue/10 text-brand-navy border border-brand-blue/30',
|
||||||
debug: 'bg-neutral-100 text-neutral-600 border border-border',
|
debug: 'bg-neutral-100 text-neutral-600 border border-border',
|
||||||
trace: 'bg-purple-100 text-purple-700 border border-purple-200',
|
trace: 'bg-purple-100 text-purple-700 border border-purple-200',
|
||||||
};
|
};
|
||||||
@ -239,7 +239,7 @@ export default function AdminLogsPage() {
|
|||||||
label={t('stats.total')}
|
label={t('stats.total')}
|
||||||
value={total}
|
value={total}
|
||||||
icon={Activity}
|
icon={Activity}
|
||||||
color="bg-brand-turquoise/10 text-brand-navy"
|
color="bg-brand-blue/10 text-brand-navy"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label={t('stats.errors')}
|
label={t('stats.errors')}
|
||||||
@ -364,7 +364,7 @@ export default function AdminLogsPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={fetchLogs}
|
onClick={fetchLogs}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="px-3 py-2 text-sm font-medium text-white bg-brand-turquoise rounded-lg hover:bg-brand-turquoise/90 transition-colors disabled:opacity-50 whitespace-nowrap"
|
className="px-3 py-2 text-sm font-medium text-white bg-brand-navy rounded-lg hover:bg-brand-navy/90 transition-colors disabled:opacity-50 whitespace-nowrap"
|
||||||
>
|
>
|
||||||
{t('filters.apply')}
|
{t('filters.apply')}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -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 ${
|
`mt-1 block w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none text-sm ${
|
||||||
hasError
|
hasError
|
||||||
? 'border-red-400 focus:border-red-500 focus:ring-red-500'
|
? '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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-96">
|
<div className="flex items-center justify-center h-96">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-brand-turquoise mx-auto"></div>
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-brand-blue mx-auto"></div>
|
||||||
<p className="mt-4 text-neutral-600">{t('loading')}</p>
|
<p className="mt-4 text-neutral-600">{t('loading')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -340,7 +340,7 @@ export default function AdminOrganizationsPage() {
|
|||||||
actions={
|
actions={
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowCreateModal(true)}
|
onClick={() => setShowCreateModal(true)}
|
||||||
className="px-4 py-2 bg-brand-turquoise text-brand-navy text-sm font-medium rounded-lg hover:bg-brand-turquoise/90 transition-colors"
|
className="px-4 py-2 bg-brand-navy text-white text-sm font-medium rounded-lg hover:bg-brand-navy/90 transition-colors"
|
||||||
>
|
>
|
||||||
{t('create')}
|
{t('create')}
|
||||||
</button>
|
</button>
|
||||||
@ -363,7 +363,7 @@ export default function AdminOrganizationsPage() {
|
|||||||
<span
|
<span
|
||||||
className={`inline-block mt-2 px-2 py-1 text-xs font-semibold rounded-full ${
|
className={`inline-block mt-2 px-2 py-1 text-xs font-semibold rounded-full ${
|
||||||
org.type === 'FREIGHT_FORWARDER'
|
org.type === 'FREIGHT_FORWARDER'
|
||||||
? 'bg-brand-turquoise/10 text-brand-navy'
|
? 'bg-brand-blue/10 text-brand-navy'
|
||||||
: org.type === 'CARRIER'
|
: org.type === 'CARRIER'
|
||||||
? 'bg-green-100 text-green-800'
|
? 'bg-green-100 text-green-800'
|
||||||
: 'bg-purple-100 text-purple-800'
|
: 'bg-purple-100 text-purple-800'
|
||||||
@ -426,7 +426,7 @@ export default function AdminOrganizationsPage() {
|
|||||||
<div className="flex space-x-2">
|
<div className="flex space-x-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => openEditModal(org)}
|
onClick={() => openEditModal(org)}
|
||||||
className="flex-1 px-3 py-2 bg-brand-turquoise/5 text-brand-navy rounded-md hover:bg-brand-turquoise/10 transition-colors text-sm font-medium"
|
className="flex-1 px-3 py-2 bg-brand-blue/5 text-brand-navy rounded-md hover:bg-brand-blue/10 transition-colors text-sm font-medium"
|
||||||
>
|
>
|
||||||
{t('edit')}
|
{t('edit')}
|
||||||
</button>
|
</button>
|
||||||
@ -523,7 +523,7 @@ export default function AdminOrganizationsPage() {
|
|||||||
<span
|
<span
|
||||||
className={`px-2 py-1 text-xs font-semibold rounded-full ${
|
className={`px-2 py-1 text-xs font-semibold rounded-full ${
|
||||||
formData.type === 'FREIGHT_FORWARDER'
|
formData.type === 'FREIGHT_FORWARDER'
|
||||||
? 'bg-brand-turquoise/10 text-brand-navy'
|
? 'bg-brand-blue/10 text-brand-navy'
|
||||||
: formData.type === 'CARRIER'
|
: formData.type === 'CARRIER'
|
||||||
? 'bg-green-100 text-green-800'
|
? 'bg-green-100 text-green-800'
|
||||||
: 'bg-purple-100 text-purple-800'
|
: 'bg-purple-100 text-purple-800'
|
||||||
@ -818,7 +818,7 @@ export default function AdminOrganizationsPage() {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
className="px-4 py-2 bg-brand-turquoise text-brand-navy rounded-md text-sm font-medium hover:bg-brand-turquoise/90 disabled:opacity-60 disabled:cursor-not-allowed transition-colors"
|
className="px-4 py-2 bg-brand-navy text-white rounded-md text-sm font-medium hover:bg-brand-navy/90 disabled:opacity-60 disabled:cursor-not-allowed transition-colors"
|
||||||
>
|
>
|
||||||
{submitting
|
{submitting
|
||||||
? t('modal.saving')
|
? t('modal.saving')
|
||||||
|
|||||||
@ -167,7 +167,7 @@ export default function AdminUsersPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-96">
|
<div className="flex items-center justify-center h-96">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-brand-turquoise mx-auto"></div>
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-brand-blue mx-auto"></div>
|
||||||
<p className="mt-4 text-neutral-600">{t('loading')}</p>
|
<p className="mt-4 text-neutral-600">{t('loading')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -182,7 +182,7 @@ export default function AdminUsersPage() {
|
|||||||
actions={
|
actions={
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowCreateModal(true)}
|
onClick={() => setShowCreateModal(true)}
|
||||||
className="px-4 py-2 bg-brand-turquoise text-brand-navy text-sm font-medium rounded-lg hover:bg-brand-turquoise/90 transition-colors"
|
className="px-4 py-2 bg-brand-navy text-white text-sm font-medium rounded-lg hover:bg-brand-navy/90 transition-colors"
|
||||||
>
|
>
|
||||||
{t('create')}
|
{t('create')}
|
||||||
</button>
|
</button>
|
||||||
@ -238,7 +238,7 @@ export default function AdminUsersPage() {
|
|||||||
user.role === 'ADMIN'
|
user.role === 'ADMIN'
|
||||||
? 'bg-purple-100 text-purple-800'
|
? 'bg-purple-100 text-purple-800'
|
||||||
: user.role === 'MANAGER'
|
: user.role === 'MANAGER'
|
||||||
? 'bg-brand-turquoise/10 text-brand-navy'
|
? 'bg-brand-blue/10 text-brand-navy'
|
||||||
: 'bg-neutral-100 text-neutral-800'
|
: 'bg-neutral-100 text-neutral-800'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@ -260,7 +260,7 @@ export default function AdminUsersPage() {
|
|||||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2">
|
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => openEditModal(user)}
|
onClick={() => openEditModal(user)}
|
||||||
className="text-brand-navy hover:text-brand-turquoise"
|
className="text-brand-navy hover:text-brand-blue"
|
||||||
>
|
>
|
||||||
{t('edit')}
|
{t('edit')}
|
||||||
</button>
|
</button>
|
||||||
@ -292,7 +292,7 @@ export default function AdminUsersPage() {
|
|||||||
required
|
required
|
||||||
value={formData.email}
|
value={formData.email}
|
||||||
onChange={e => setFormData({ ...formData, email: e.target.value })}
|
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"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@ -304,7 +304,7 @@ export default function AdminUsersPage() {
|
|||||||
required
|
required
|
||||||
value={formData.firstName}
|
value={formData.firstName}
|
||||||
onChange={e => setFormData({ ...formData, firstName: e.target.value })}
|
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"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@ -316,7 +316,7 @@ export default function AdminUsersPage() {
|
|||||||
required
|
required
|
||||||
value={formData.lastName}
|
value={formData.lastName}
|
||||||
onChange={e => setFormData({ ...formData, lastName: e.target.value })}
|
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"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@ -324,7 +324,7 @@ export default function AdminUsersPage() {
|
|||||||
<select
|
<select
|
||||||
value={formData.role}
|
value={formData.role}
|
||||||
onChange={e => setFormData({ ...formData, role: e.target.value as UserRole })}
|
onChange={e => 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"
|
||||||
>
|
>
|
||||||
<option value="USER">{t('roles.USER')}</option>
|
<option value="USER">{t('roles.USER')}</option>
|
||||||
<option value="MANAGER">{t('roles.MANAGER')}</option>
|
<option value="MANAGER">{t('roles.MANAGER')}</option>
|
||||||
@ -340,7 +340,7 @@ export default function AdminUsersPage() {
|
|||||||
required
|
required
|
||||||
value={formData.organizationId}
|
value={formData.organizationId}
|
||||||
onChange={e => setFormData({ ...formData, organizationId: e.target.value })}
|
onChange={e => setFormData({ ...formData, organizationId: 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"
|
||||||
>
|
>
|
||||||
<option value="">{t('modal.selectOrganization')}</option>
|
<option value="">{t('modal.selectOrganization')}</option>
|
||||||
{organizations.map(org => (
|
{organizations.map(org => (
|
||||||
@ -359,7 +359,7 @@ export default function AdminUsersPage() {
|
|||||||
type={showPassword ? 'text' : 'password'}
|
type={showPassword ? 'text' : 'password'}
|
||||||
value={formData.password}
|
value={formData.password}
|
||||||
onChange={e => setFormData({ ...formData, password: e.target.value })}
|
onChange={e => setFormData({ ...formData, password: e.target.value })}
|
||||||
className="block w-full px-3 py-2 pr-10 border border-neutral-300 rounded-md shadow-sm focus:border-brand-turquoise focus:ring-ring focus:outline-none"
|
className="block w-full px-3 py-2 pr-10 border border-neutral-300 rounded-md shadow-sm focus:border-brand-blue focus:ring-ring focus:outline-none"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -385,7 +385,7 @@ export default function AdminUsersPage() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="px-4 py-2 bg-brand-turquoise text-brand-navy rounded-md hover:bg-brand-turquoise/90"
|
className="px-4 py-2 bg-brand-navy text-white rounded-md hover:bg-brand-navy/90"
|
||||||
>
|
>
|
||||||
{t('modal.create')}
|
{t('modal.create')}
|
||||||
</button>
|
</button>
|
||||||
@ -421,7 +421,7 @@ export default function AdminUsersPage() {
|
|||||||
required
|
required
|
||||||
value={formData.firstName}
|
value={formData.firstName}
|
||||||
onChange={e => setFormData({ ...formData, firstName: e.target.value })}
|
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"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@ -433,7 +433,7 @@ export default function AdminUsersPage() {
|
|||||||
required
|
required
|
||||||
value={formData.lastName}
|
value={formData.lastName}
|
||||||
onChange={e => setFormData({ ...formData, lastName: e.target.value })}
|
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"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@ -441,7 +441,7 @@ export default function AdminUsersPage() {
|
|||||||
<select
|
<select
|
||||||
value={formData.role}
|
value={formData.role}
|
||||||
onChange={e => setFormData({ ...formData, role: e.target.value as UserRole })}
|
onChange={e => 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"
|
||||||
>
|
>
|
||||||
<option value="USER">{t('roles.USER')}</option>
|
<option value="USER">{t('roles.USER')}</option>
|
||||||
<option value="MANAGER">{t('roles.MANAGER')}</option>
|
<option value="MANAGER">{t('roles.MANAGER')}</option>
|
||||||
@ -463,7 +463,7 @@ export default function AdminUsersPage() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="px-4 py-2 bg-brand-turquoise text-brand-navy rounded-md hover:bg-brand-turquoise/90"
|
className="px-4 py-2 bg-brand-navy text-white rounded-md hover:bg-brand-navy/90"
|
||||||
>
|
>
|
||||||
{t('modal.update')}
|
{t('modal.update')}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -54,7 +54,7 @@ export default function BookingConfirmPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-[60vh] flex items-center justify-center p-4">
|
<div className="min-h-[60vh] flex items-center justify-center p-4">
|
||||||
<div className="bg-white rounded-2xl shadow-xl p-8 max-w-md w-full text-center">
|
<div className="bg-white rounded-2xl shadow-xl p-8 max-w-md w-full text-center">
|
||||||
<div className="animate-spin rounded-full h-16 w-16 border-b-2 border-brand-turquoise mx-auto mb-4"></div>
|
<div className="animate-spin rounded-full h-16 w-16 border-b-2 border-brand-blue mx-auto mb-4"></div>
|
||||||
<p className="text-neutral-600">{t('loading')}</p>
|
<p className="text-neutral-600">{t('loading')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -205,7 +205,7 @@ export default function BookingConfirmPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Next Steps */}
|
{/* Next Steps */}
|
||||||
<div className="bg-brand-turquoise/5 border border-brand-turquoise/30 rounded-lg p-4 mb-6">
|
<div className="bg-brand-blue/5 border border-brand-blue/30 rounded-lg p-4 mb-6">
|
||||||
<h3 className="font-semibold text-brand-navy mb-2 flex items-center">
|
<h3 className="font-semibold text-brand-navy mb-2 flex items-center">
|
||||||
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path
|
<path
|
||||||
@ -257,7 +257,7 @@ export default function BookingConfirmPage() {
|
|||||||
href={doc.url}
|
href={doc.url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-brand-navy hover:text-brand-turquoise text-sm font-medium"
|
className="text-brand-navy hover:text-brand-blue text-sm font-medium"
|
||||||
>
|
>
|
||||||
{t('labels.download')}
|
{t('labels.download')}
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@ -184,7 +184,7 @@ export default function BookingRejectPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-brand-turquoise/5 border border-brand-turquoise/30 rounded-lg p-4 mb-6">
|
<div className="bg-brand-blue/5 border border-brand-blue/30 rounded-lg p-4 mb-6">
|
||||||
<h3 className="font-semibold text-brand-navy mb-2 flex items-center">
|
<h3 className="font-semibold text-brand-navy mb-2 flex items-center">
|
||||||
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path
|
<path
|
||||||
|
|||||||
@ -110,7 +110,7 @@ export default function CarrierAcceptPage() {
|
|||||||
<p className="text-neutral-600 mb-6">{error}</p>
|
<p className="text-neutral-600 mb-6">{error}</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push('/')}
|
onClick={() => router.push('/')}
|
||||||
className="w-full px-4 py-2 bg-brand-turquoise text-brand-navy rounded-lg hover:bg-brand-turquoise/90"
|
className="w-full px-4 py-2 bg-brand-navy text-white rounded-lg hover:bg-brand-navy/90"
|
||||||
>
|
>
|
||||||
{t('common.backHome')}
|
{t('common.backHome')}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -327,7 +327,7 @@ export default function CarrierDocumentsPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-[60vh] flex items-center justify-center bg-brand-gray">
|
<div className="min-h-[60vh] flex items-center justify-center bg-brand-gray">
|
||||||
<div className="bg-white p-8 rounded-xl shadow-lg max-w-md w-full text-center">
|
<div className="bg-white p-8 rounded-xl shadow-lg max-w-md w-full text-center">
|
||||||
<Loader2 className="w-16 h-16 text-brand-turquoise mx-auto mb-4 animate-spin" />
|
<Loader2 className="w-16 h-16 text-brand-blue mx-auto mb-4 animate-spin" />
|
||||||
<h1 className="text-2xl font-bold text-brand-navy mb-2">{t('loading')}</h1>
|
<h1 className="text-2xl font-bold text-brand-navy mb-2">{t('loading')}</h1>
|
||||||
<p className="text-neutral-600">{t('loadingHint')}</p>
|
<p className="text-neutral-600">{t('loadingHint')}</p>
|
||||||
</div>
|
</div>
|
||||||
@ -360,8 +360,8 @@ export default function CarrierDocumentsPage() {
|
|||||||
<div className="min-h-[60vh] flex items-center justify-center bg-brand-gray p-4">
|
<div className="min-h-[60vh] flex items-center justify-center bg-brand-gray p-4">
|
||||||
<div className="bg-white p-8 rounded-xl shadow-lg max-w-md w-full">
|
<div className="bg-white p-8 rounded-xl shadow-lg max-w-md w-full">
|
||||||
<div className="text-center mb-6">
|
<div className="text-center mb-6">
|
||||||
<div className="mx-auto w-16 h-16 bg-brand-turquoise/10 rounded-full flex items-center justify-center mb-4">
|
<div className="mx-auto w-16 h-16 bg-brand-blue/10 rounded-full flex items-center justify-center mb-4">
|
||||||
<Lock className="w-8 h-8 text-brand-turquoise" />
|
<Lock className="w-8 h-8 text-brand-blue" />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-bold text-brand-navy mb-2">{t('password.title')}</h1>
|
<h1 className="text-2xl font-bold text-brand-navy mb-2">{t('password.title')}</h1>
|
||||||
<p className="text-neutral-600">{t('password.intro')}</p>
|
<p className="text-neutral-600">{t('password.intro')}</p>
|
||||||
@ -385,7 +385,7 @@ export default function CarrierDocumentsPage() {
|
|||||||
value={password}
|
value={password}
|
||||||
onChange={e => setPassword(e.target.value.toUpperCase())}
|
onChange={e => setPassword(e.target.value.toUpperCase())}
|
||||||
placeholder={t('password.passwordPlaceholder')}
|
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"
|
autoComplete="off"
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
@ -456,7 +456,7 @@ export default function CarrierDocumentsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleRefresh}
|
onClick={handleRefresh}
|
||||||
className="text-sm text-brand-turquoise hover:text-brand-navy font-medium"
|
className="text-sm text-brand-blue hover:text-brand-navy font-medium"
|
||||||
>
|
>
|
||||||
{t('header.refresh')}
|
{t('header.refresh')}
|
||||||
</button>
|
</button>
|
||||||
@ -524,7 +524,7 @@ export default function CarrierDocumentsPage() {
|
|||||||
<div className="bg-white rounded-xl shadow-md overflow-hidden">
|
<div className="bg-white rounded-xl shadow-md overflow-hidden">
|
||||||
<div className="px-6 py-4 border-b border-border">
|
<div className="px-6 py-4 border-b border-border">
|
||||||
<h2 className="text-lg font-semibold text-brand-navy flex items-center gap-2">
|
<h2 className="text-lg font-semibold text-brand-navy flex items-center gap-2">
|
||||||
<FileText className="w-5 h-5 text-brand-turquoise" />
|
<FileText className="w-5 h-5 text-brand-blue" />
|
||||||
{t('list.heading', { count: documents.length })}
|
{t('list.heading', { count: documents.length })}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
@ -547,7 +547,7 @@ export default function CarrierDocumentsPage() {
|
|||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-brand-navy">{doc.fileName}</p>
|
<p className="font-medium text-brand-navy">{doc.fileName}</p>
|
||||||
<div className="flex items-center gap-2 mt-1">
|
<div className="flex items-center gap-2 mt-1">
|
||||||
<span className="text-xs px-2 py-0.5 bg-brand-turquoise/10 text-brand-navy rounded-full">
|
<span className="text-xs px-2 py-0.5 bg-brand-blue/10 text-brand-navy rounded-full">
|
||||||
{getDocumentTypeLabel(doc.type)}
|
{getDocumentTypeLabel(doc.type)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-neutral-500">{formatFileSize(doc.size)}</span>
|
<span className="text-xs text-neutral-500">{formatFileSize(doc.size)}</span>
|
||||||
|
|||||||
@ -110,7 +110,7 @@ export default function CarrierRejectPage() {
|
|||||||
<p className="text-neutral-600 mb-6">{error}</p>
|
<p className="text-neutral-600 mb-6">{error}</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push('/')}
|
onClick={() => router.push('/')}
|
||||||
className="w-full px-4 py-2 bg-brand-turquoise text-brand-navy rounded-lg hover:bg-brand-turquoise/90"
|
className="w-full px-4 py-2 bg-brand-navy text-white rounded-lg hover:bg-brand-navy/90"
|
||||||
>
|
>
|
||||||
{t('common.backHome')}
|
{t('common.backHome')}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -164,7 +164,7 @@ export default function PayCommissionPage() {
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-brand-turquoise/5">
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-brand-blue/5">
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<Loader2 className="h-6 w-6 animate-spin text-brand-navy" />
|
<Loader2 className="h-6 w-6 animate-spin text-brand-navy" />
|
||||||
<span className="text-neutral-600">Chargement...</span>
|
<span className="text-neutral-600">Chargement...</span>
|
||||||
@ -175,13 +175,13 @@ export default function PayCommissionPage() {
|
|||||||
|
|
||||||
if (error && !booking) {
|
if (error && !booking) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-brand-turquoise/5">
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-brand-blue/5">
|
||||||
<div className="bg-white rounded-xl shadow-md p-8 max-w-md">
|
<div className="bg-white rounded-xl shadow-md p-8 max-w-md">
|
||||||
<AlertTriangle className="h-12 w-12 text-red-500 mx-auto mb-4" />
|
<AlertTriangle className="h-12 w-12 text-red-500 mx-auto mb-4" />
|
||||||
<p className="text-center text-neutral-700">{error}</p>
|
<p className="text-center text-neutral-700">{error}</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push('/dashboard/bookings')}
|
onClick={() => router.push('/dashboard/bookings')}
|
||||||
className="mt-4 w-full px-4 py-2 bg-brand-turquoise text-brand-navy rounded-lg hover:bg-brand-turquoise/90"
|
className="mt-4 w-full px-4 py-2 bg-brand-navy text-white rounded-lg hover:bg-brand-navy/90"
|
||||||
>
|
>
|
||||||
Retour aux bookings
|
Retour aux bookings
|
||||||
</button>
|
</button>
|
||||||
@ -210,13 +210,13 @@ export default function PayCommissionPage() {
|
|||||||
: (booking.priceUSD || 0) + (booking.priceEUR || 0);
|
: (booking.priceUSD || 0) + (booking.priceEUR || 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-brand-turquoise/5 py-10 px-4">
|
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-brand-blue/5 py-10 px-4">
|
||||||
<div className="max-w-5xl mx-auto">
|
<div className="max-w-5xl mx-auto">
|
||||||
{/* Back button — returns to the previous context (list or booking detail)
|
{/* Back button — returns to the previous context (list or booking detail)
|
||||||
instead of always jumping to the global list. */}
|
instead of always jumping to the global list. */}
|
||||||
<button
|
<button
|
||||||
onClick={() => router.back()}
|
onClick={() => router.back()}
|
||||||
className="mb-6 flex items-center text-brand-navy hover:text-brand-turquoise font-medium"
|
className="mb-6 flex items-center text-brand-navy hover:text-brand-blue font-medium"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||||
Retour
|
Retour
|
||||||
@ -244,7 +244,7 @@ export default function PayCommissionPage() {
|
|||||||
if (enabledOptions) params.set('options', enabledOptions);
|
if (enabledOptions) params.set('options', enabledOptions);
|
||||||
router.push(`/dashboard/search-advanced?${params.toString()}`);
|
router.push(`/dashboard/search-advanced?${params.toString()}`);
|
||||||
}}
|
}}
|
||||||
className="mb-8 inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-brand-navy bg-brand-turquoise/5 hover:bg-brand-turquoise/10 rounded-lg transition-colors"
|
className="mb-8 inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-brand-navy bg-brand-blue/5 hover:bg-brand-blue/10 rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
Modifier la réservation avant de payer
|
Modifier la réservation avant de payer
|
||||||
@ -323,7 +323,7 @@ export default function PayCommissionPage() {
|
|||||||
onClick={() => setSelectedMethod('card')}
|
onClick={() => setSelectedMethod('card')}
|
||||||
className={`w-full text-left rounded-xl border-2 p-5 transition-all ${
|
className={`w-full text-left rounded-xl border-2 p-5 transition-all ${
|
||||||
selectedMethod === 'card'
|
selectedMethod === 'card'
|
||||||
? 'border-brand-turquoise bg-brand-turquoise/5'
|
? 'border-brand-blue bg-brand-blue/5'
|
||||||
: 'border-border bg-white hover:border-neutral-300'
|
: 'border-border bg-white hover:border-neutral-300'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@ -331,7 +331,7 @@ export default function PayCommissionPage() {
|
|||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<div
|
<div
|
||||||
className={`w-10 h-10 rounded-full flex items-center justify-center ${
|
className={`w-10 h-10 rounded-full flex items-center justify-center ${
|
||||||
selectedMethod === 'card' ? 'bg-brand-turquoise/10' : 'bg-neutral-100'
|
selectedMethod === 'card' ? 'bg-brand-blue/10' : 'bg-neutral-100'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<CreditCard
|
<CreditCard
|
||||||
@ -347,7 +347,7 @@ export default function PayCommissionPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${
|
className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${
|
||||||
selectedMethod === 'card' ? 'border-brand-turquoise bg-brand-turquoise' : 'border-neutral-300'
|
selectedMethod === 'card' ? 'border-brand-blue bg-brand-blue' : 'border-neutral-300'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{selectedMethod === 'card' && <div className="w-2 h-2 rounded-full bg-white" />}
|
{selectedMethod === 'card' && <div className="w-2 h-2 rounded-full bg-white" />}
|
||||||
@ -360,7 +360,7 @@ export default function PayCommissionPage() {
|
|||||||
onClick={() => setSelectedMethod('transfer')}
|
onClick={() => setSelectedMethod('transfer')}
|
||||||
className={`w-full text-left rounded-xl border-2 p-5 transition-all ${
|
className={`w-full text-left rounded-xl border-2 p-5 transition-all ${
|
||||||
selectedMethod === 'transfer'
|
selectedMethod === 'transfer'
|
||||||
? 'border-brand-turquoise bg-brand-turquoise/5'
|
? 'border-brand-blue bg-brand-blue/5'
|
||||||
: 'border-border bg-white hover:border-neutral-300'
|
: 'border-border bg-white hover:border-neutral-300'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@ -368,7 +368,7 @@ export default function PayCommissionPage() {
|
|||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<div
|
<div
|
||||||
className={`w-10 h-10 rounded-full flex items-center justify-center ${
|
className={`w-10 h-10 rounded-full flex items-center justify-center ${
|
||||||
selectedMethod === 'transfer' ? 'bg-brand-turquoise/10' : 'bg-neutral-100'
|
selectedMethod === 'transfer' ? 'bg-brand-blue/10' : 'bg-neutral-100'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Building2
|
<Building2
|
||||||
@ -385,7 +385,7 @@ export default function PayCommissionPage() {
|
|||||||
<div
|
<div
|
||||||
className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${
|
className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${
|
||||||
selectedMethod === 'transfer'
|
selectedMethod === 'transfer'
|
||||||
? 'border-brand-turquoise bg-brand-turquoise'
|
? 'border-brand-blue bg-brand-blue'
|
||||||
: 'border-neutral-300'
|
: 'border-neutral-300'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@ -405,7 +405,7 @@ export default function PayCommissionPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={handlePayByCard}
|
onClick={handlePayByCard}
|
||||||
disabled={paying}
|
disabled={paying}
|
||||||
className="w-full py-3 bg-brand-turquoise text-brand-navy rounded-lg font-semibold hover:bg-brand-turquoise/90 disabled:bg-neutral-300 disabled:cursor-not-allowed flex items-center justify-center space-x-2 transition-colors"
|
className="w-full py-3 bg-brand-navy text-white rounded-lg font-semibold hover:bg-brand-navy/90 disabled:bg-neutral-300 disabled:cursor-not-allowed flex items-center justify-center space-x-2 transition-colors"
|
||||||
>
|
>
|
||||||
{paying ? (
|
{paying ? (
|
||||||
<>
|
<>
|
||||||
@ -575,7 +575,7 @@ export default function PayCommissionPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Booking fee box */}
|
{/* Booking fee box */}
|
||||||
<div className="bg-brand-turquoise rounded-xl p-5 text-white">
|
<div className="bg-brand-navy rounded-xl p-5 text-white">
|
||||||
<p className="text-sm text-white/70 mb-1">Forfait par booking — à régler maintenant</p>
|
<p className="text-sm text-white/70 mb-1">Forfait par booking — à régler maintenant</p>
|
||||||
<p className="text-3xl font-bold">{formatPrice(bookingFeeAmount, 'EUR')}</p>
|
<p className="text-3xl font-bold">{formatPrice(bookingFeeAmount, 'EUR')}</p>
|
||||||
<p className="text-xs text-white/70 mt-1">
|
<p className="text-xs text-white/70 mt-1">
|
||||||
|
|||||||
@ -42,14 +42,14 @@ export default function PaymentSuccessPage() {
|
|||||||
|
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-brand-turquoise/5 to-indigo-100">
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-brand-blue/5 to-indigo-100">
|
||||||
<div className="bg-white rounded-lg shadow-md p-8 max-w-md text-center">
|
<div className="bg-white rounded-lg shadow-md p-8 max-w-md text-center">
|
||||||
<AlertTriangle className="h-12 w-12 text-red-500 mx-auto mb-4" />
|
<AlertTriangle className="h-12 w-12 text-red-500 mx-auto mb-4" />
|
||||||
<h2 className="text-xl font-bold text-brand-navy mb-2">Session invalide</h2>
|
<h2 className="text-xl font-bold text-brand-navy mb-2">Session invalide</h2>
|
||||||
<p className="text-neutral-600 mb-4">Aucune session de paiement trouvee.</p>
|
<p className="text-neutral-600 mb-4">Aucune session de paiement trouvee.</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push('/dashboard/bookings')}
|
onClick={() => router.push('/dashboard/bookings')}
|
||||||
className="px-6 py-2 bg-brand-turquoise text-brand-navy rounded-lg hover:bg-brand-turquoise/90"
|
className="px-6 py-2 bg-brand-navy text-white rounded-lg hover:bg-brand-navy/90"
|
||||||
>
|
>
|
||||||
Retour aux bookings
|
Retour aux bookings
|
||||||
</button>
|
</button>
|
||||||
@ -59,7 +59,7 @@ export default function PaymentSuccessPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-brand-turquoise/5 to-indigo-100 px-4">
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-brand-blue/5 to-indigo-100 px-4">
|
||||||
<div className="bg-white rounded-lg shadow-lg p-8 max-w-md w-full text-center">
|
<div className="bg-white rounded-lg shadow-lg p-8 max-w-md w-full text-center">
|
||||||
{status === 'confirming' && (
|
{status === 'confirming' && (
|
||||||
<>
|
<>
|
||||||
@ -85,7 +85,7 @@ export default function PaymentSuccessPage() {
|
|||||||
avec votre demande de booking.
|
avec votre demande de booking.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="bg-brand-turquoise/5 border border-brand-turquoise/30 rounded-lg p-4 mb-6">
|
<div className="bg-brand-blue/5 border border-brand-blue/30 rounded-lg p-4 mb-6">
|
||||||
<div className="flex items-center justify-center space-x-2 text-brand-navy">
|
<div className="flex items-center justify-center space-x-2 text-brand-navy">
|
||||||
<Mail className="h-5 w-5" />
|
<Mail className="h-5 w-5" />
|
||||||
<span className="text-sm font-medium">Email envoye au transporteur</span>
|
<span className="text-sm font-medium">Email envoye au transporteur</span>
|
||||||
@ -97,7 +97,7 @@ export default function PaymentSuccessPage() {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push('/dashboard/bookings')}
|
onClick={() => router.push('/dashboard/bookings')}
|
||||||
className="w-full px-6 py-3 bg-brand-turquoise text-brand-navy rounded-lg hover:bg-brand-turquoise/90 font-semibold flex items-center justify-center"
|
className="w-full px-6 py-3 bg-brand-navy text-white rounded-lg hover:bg-brand-navy/90 font-semibold flex items-center justify-center"
|
||||||
>
|
>
|
||||||
Voir mes bookings
|
Voir mes bookings
|
||||||
<ArrowRight className="h-4 w-4 ml-2" />
|
<ArrowRight className="h-4 w-4 ml-2" />
|
||||||
@ -127,7 +127,7 @@ export default function PaymentSuccessPage() {
|
|||||||
setStatus('error');
|
setStatus('error');
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
className="w-full px-6 py-2 bg-brand-turquoise text-brand-navy rounded-lg hover:bg-brand-turquoise/90"
|
className="w-full px-6 py-2 bg-brand-navy text-white rounded-lg hover:bg-brand-navy/90"
|
||||||
>
|
>
|
||||||
Reessayer
|
Reessayer
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -234,13 +234,13 @@ function NewBookingPageContent() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-brand-turquoise/5 to-indigo-100 py-12 px-4">
|
<div className="min-h-screen bg-gradient-to-br from-brand-blue/5 to-indigo-100 py-12 px-4">
|
||||||
<div className="max-w-4xl mx-auto">
|
<div className="max-w-4xl mx-auto">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<button
|
<button
|
||||||
onClick={() => router.back()}
|
onClick={() => router.back()}
|
||||||
className="mb-4 flex items-center text-brand-navy hover:text-brand-turquoise font-medium"
|
className="mb-4 flex items-center text-brand-navy hover:text-brand-blue font-medium"
|
||||||
>
|
>
|
||||||
← Retour aux résultats
|
← Retour aux résultats
|
||||||
</button>
|
</button>
|
||||||
@ -263,7 +263,7 @@ function NewBookingPageContent() {
|
|||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div
|
<div
|
||||||
className={`w-10 h-10 rounded-full flex items-center justify-center font-semibold ${
|
className={`w-10 h-10 rounded-full flex items-center justify-center font-semibold ${
|
||||||
currentStep >= 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}
|
{step}
|
||||||
@ -283,7 +283,7 @@ function NewBookingPageContent() {
|
|||||||
{step < 3 && (
|
{step < 3 && (
|
||||||
<div
|
<div
|
||||||
className={`flex-1 h-1 mx-4 ${
|
className={`flex-1 h-1 mx-4 ${
|
||||||
currentStep > step ? 'bg-brand-turquoise' : 'bg-neutral-200'
|
currentStep > step ? 'bg-brand-blue' : 'bg-neutral-200'
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@ -314,7 +314,7 @@ function NewBookingPageContent() {
|
|||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Carrier Info */}
|
{/* Carrier Info */}
|
||||||
<div className="bg-brand-turquoise/5 border-2 border-brand-turquoise/30 rounded-lg p-6">
|
<div className="bg-brand-blue/5 border-2 border-brand-blue/30 rounded-lg p-6">
|
||||||
<h3 className="text-lg font-semibold text-brand-navy mb-4">Transporteur</h3>
|
<h3 className="text-lg font-semibold text-brand-navy mb-4">Transporteur</h3>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
@ -409,7 +409,7 @@ function NewBookingPageContent() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setCurrentStep(2)}
|
onClick={() => setCurrentStep(2)}
|
||||||
disabled={!canProceedToStep2}
|
disabled={!canProceedToStep2}
|
||||||
className="px-8 py-3 bg-brand-turquoise text-brand-navy rounded-lg hover:bg-brand-turquoise/90 disabled:bg-neutral-300 disabled:cursor-not-allowed font-semibold"
|
className="px-8 py-3 bg-brand-navy text-white rounded-lg hover:bg-brand-navy/90 disabled:bg-neutral-300 disabled:cursor-not-allowed font-semibold"
|
||||||
>
|
>
|
||||||
Continuer →
|
Continuer →
|
||||||
</button>
|
</button>
|
||||||
@ -440,8 +440,8 @@ function NewBookingPageContent() {
|
|||||||
<div
|
<div
|
||||||
className={`border-2 border-dashed rounded-lg p-8 text-center transition-colors cursor-pointer ${
|
className={`border-2 border-dashed rounded-lg p-8 text-center transition-colors cursor-pointer ${
|
||||||
isDragging
|
isDragging
|
||||||
? 'border-brand-turquoise bg-brand-turquoise/5'
|
? 'border-brand-blue bg-brand-blue/5'
|
||||||
: 'border-neutral-300 hover:border-brand-turquoise hover:bg-neutral-50'
|
: 'border-neutral-300 hover:border-brand-blue hover:bg-neutral-50'
|
||||||
}`}
|
}`}
|
||||||
onDragOver={e => {
|
onDragOver={e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@ -470,7 +470,7 @@ function NewBookingPageContent() {
|
|||||||
/>
|
/>
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<svg
|
<svg
|
||||||
className={`w-16 h-16 mb-4 transition-colors ${isDragging ? 'text-brand-turquoise' : 'text-neutral-400'}`}
|
className={`w-16 h-16 mb-4 transition-colors ${isDragging ? 'text-brand-blue' : 'text-neutral-400'}`}
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@ -556,7 +556,7 @@ function NewBookingPageContent() {
|
|||||||
value={formData.notes || ''}
|
value={formData.notes || ''}
|
||||||
onChange={e => setFormData(prev => ({ ...prev, notes: e.target.value }))}
|
onChange={e => setFormData(prev => ({ ...prev, notes: e.target.value }))}
|
||||||
rows={4}
|
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..."
|
placeholder="Ajoutez des instructions spéciales pour le transporteur..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -571,7 +571,7 @@ function NewBookingPageContent() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setCurrentStep(3)}
|
onClick={() => setCurrentStep(3)}
|
||||||
disabled={!canProceedToStep3}
|
disabled={!canProceedToStep3}
|
||||||
className="px-8 py-3 bg-brand-turquoise text-brand-navy rounded-lg hover:bg-brand-turquoise/90 disabled:bg-neutral-300 disabled:cursor-not-allowed font-semibold"
|
className="px-8 py-3 bg-brand-navy text-white rounded-lg hover:bg-brand-navy/90 disabled:bg-neutral-300 disabled:cursor-not-allowed font-semibold"
|
||||||
>
|
>
|
||||||
Continuer →
|
Continuer →
|
||||||
</button>
|
</button>
|
||||||
@ -586,7 +586,7 @@ function NewBookingPageContent() {
|
|||||||
|
|
||||||
<div className="space-y-6 mb-8">
|
<div className="space-y-6 mb-8">
|
||||||
{/* Summary */}
|
{/* Summary */}
|
||||||
<div className="bg-brand-turquoise/5 border-2 border-brand-turquoise/30 rounded-lg p-6">
|
<div className="bg-brand-blue/5 border-2 border-brand-blue/30 rounded-lg p-6">
|
||||||
<h3 className="text-lg font-semibold text-brand-navy mb-4">Récapitulatif</h3>
|
<h3 className="text-lg font-semibold text-brand-navy mb-4">Récapitulatif</h3>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
|
|||||||
@ -198,9 +198,9 @@ export default function BookingDetailPage() {
|
|||||||
<li className="flex gap-3">
|
<li className="flex gap-3">
|
||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full bg-brand-turquoise/10"
|
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-turquoise" />
|
<FileText className="size-3 text-brand-blue" />
|
||||||
</span>
|
</span>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-body-sm font-medium text-brand-navy">
|
<p className="text-body-sm font-medium text-brand-navy">
|
||||||
|
|||||||
@ -100,7 +100,7 @@ export default function NotificationsPage() {
|
|||||||
urgent: 'border-l-4 border-red-500 bg-red-50 hover:bg-red-100',
|
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',
|
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',
|
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 (
|
return (
|
||||||
colors[priority as keyof typeof colors] || 'border-l-4 border-neutral-300 hover:bg-neutral-100'
|
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: <CheckCircle className={`${iconClass} text-green-500`} />,
|
booking_confirmed: <CheckCircle className={`${iconClass} text-green-500`} />,
|
||||||
csv_booking_accepted: <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_rejected: <XCircle className={`${iconClass} text-red-500`} />,
|
||||||
csv_booking_request_sent: <Mail className={`${iconClass} text-brand-turquoise`} />,
|
csv_booking_request_sent: <Mail className={`${iconClass} text-brand-blue`} />,
|
||||||
rate_quote_expiring: <Clock className={`${iconClass} text-yellow-500`} />,
|
rate_quote_expiring: <Clock className={`${iconClass} text-yellow-500`} />,
|
||||||
document_uploaded: <FileText className={`${iconClass} text-neutral-600`} />,
|
document_uploaded: <FileText className={`${iconClass} text-neutral-600`} />,
|
||||||
system_announcement: <Megaphone className={`${iconClass} text-purple-500`} />,
|
system_announcement: <Megaphone className={`${iconClass} text-purple-500`} />,
|
||||||
@ -163,7 +163,7 @@ export default function NotificationsPage() {
|
|||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
<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 justify-between">
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
<div className="p-3 bg-brand-turquoise/10 rounded-lg">
|
<div className="p-3 bg-brand-blue/10 rounded-lg">
|
||||||
<Bell className="w-8 h-8 text-brand-navy" />
|
<Bell className="w-8 h-8 text-brand-navy" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@ -178,7 +178,7 @@ export default function NotificationsPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => markAllAsReadMutation.mutate()}
|
onClick={() => markAllAsReadMutation.mutate()}
|
||||||
disabled={markAllAsReadMutation.isPending}
|
disabled={markAllAsReadMutation.isPending}
|
||||||
className="flex items-center space-x-2 px-4 py-2 bg-brand-turquoise text-brand-navy rounded-lg hover:bg-brand-turquoise/90 disabled:opacity-50 transition-colors"
|
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" />
|
<CheckCheck className="w-5 h-5" />
|
||||||
<span>{t('markAllRead')}</span>
|
<span>{t('markAllRead')}</span>
|
||||||
@ -203,7 +203,7 @@ export default function NotificationsPage() {
|
|||||||
}}
|
}}
|
||||||
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
|
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||||
selectedFilter === filter
|
selectedFilter === filter
|
||||||
? 'bg-brand-turquoise text-brand-navy shadow-sm'
|
? 'bg-brand-navy text-white shadow-sm'
|
||||||
: 'bg-neutral-100 text-neutral-700 hover:bg-neutral-200'
|
: 'bg-neutral-100 text-neutral-700 hover:bg-neutral-200'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@ -223,7 +223,7 @@ export default function NotificationsPage() {
|
|||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center justify-center py-20">
|
<div className="flex items-center justify-center py-20">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="animate-spin rounded-full h-16 w-16 border-b-2 border-brand-turquoise mx-auto mb-4" />
|
<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>
|
<p className="text-neutral-500">{t('loading')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -246,7 +246,7 @@ export default function NotificationsPage() {
|
|||||||
key={notification.id}
|
key={notification.id}
|
||||||
onClick={() => handleNotificationClick(notification)}
|
onClick={() => handleNotificationClick(notification)}
|
||||||
className={`p-6 transition-all cursor-pointer group ${
|
className={`p-6 transition-all cursor-pointer group ${
|
||||||
!notification.read ? 'bg-brand-turquoise/5/50' : ''
|
!notification.read ? 'bg-brand-blue/5/50' : ''
|
||||||
} ${getPriorityColor(notification.priority || 'low')}`}
|
} ${getPriorityColor(notification.priority || 'low')}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start space-x-4">
|
<div className="flex items-start space-x-4">
|
||||||
@ -261,7 +261,7 @@ export default function NotificationsPage() {
|
|||||||
{notification.title}
|
{notification.title}
|
||||||
</h3>
|
</h3>
|
||||||
{!notification.read && (
|
{!notification.read && (
|
||||||
<span className="flex items-center space-x-1 px-2 py-1 bg-brand-turquoise text-brand-navy text-xs font-medium rounded-full">
|
<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 className="w-2 h-2 bg-white rounded-full animate-pulse" />
|
||||||
<span>{t('new')}</span>
|
<span>{t('new')}</span>
|
||||||
</span>
|
</span>
|
||||||
@ -312,7 +312,7 @@ export default function NotificationsPage() {
|
|||||||
? 'bg-orange-100 text-orange-700'
|
? 'bg-orange-100 text-orange-700'
|
||||||
: notification.priority === 'medium'
|
: notification.priority === 'medium'
|
||||||
? 'bg-yellow-100 text-yellow-700'
|
? 'bg-yellow-100 text-yellow-700'
|
||||||
: 'bg-brand-turquoise/10 text-brand-navy'
|
: 'bg-brand-blue/10 text-brand-navy'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{getPriorityLabel(notification.priority)}
|
{getPriorityLabel(notification.priority)}
|
||||||
@ -361,7 +361,7 @@ export default function NotificationsPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
||||||
disabled={currentPage === 1}
|
disabled={currentPage === 1}
|
||||||
className="flex items-center space-x-1 px-4 py-2 text-sm font-medium bg-white border border-neutral-300 rounded-lg hover:bg-neutral-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
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" />
|
<ChevronLeft className="w-4 h-4" />
|
||||||
<span>{t('pagination.previous')}</span>
|
<span>{t('pagination.previous')}</span>
|
||||||
@ -385,7 +385,7 @@ export default function NotificationsPage() {
|
|||||||
onClick={() => setCurrentPage(pageNum)}
|
onClick={() => setCurrentPage(pageNum)}
|
||||||
className={`w-10 h-10 text-sm font-medium rounded-lg transition-colors ${
|
className={`w-10 h-10 text-sm font-medium rounded-lg transition-colors ${
|
||||||
currentPage === pageNum
|
currentPage === pageNum
|
||||||
? 'bg-brand-turquoise text-brand-navy'
|
? 'bg-brand-navy text-white'
|
||||||
: 'bg-white text-neutral-700 hover:bg-neutral-100 border border-neutral-300'
|
: 'bg-white text-neutral-700 hover:bg-neutral-100 border border-neutral-300'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@ -397,7 +397,7 @@ export default function NotificationsPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
||||||
disabled={currentPage === totalPages}
|
disabled={currentPage === totalPages}
|
||||||
className="flex items-center space-x-1 px-4 py-2 text-sm font-medium bg-white border border-neutral-300 rounded-lg hover:bg-neutral-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
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>
|
<span>{t('pagination.next')}</span>
|
||||||
<ChevronRight className="w-4 h-4" />
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
|||||||
@ -6,22 +6,23 @@ import { dashboardApi } from '@/lib/api';
|
|||||||
import { Link, useRouter } from '@/i18n/navigation';
|
import { Link, useRouter } from '@/i18n/navigation';
|
||||||
import { useTranslations, useLocale } from 'next-intl';
|
import { useTranslations, useLocale } from 'next-intl';
|
||||||
import {
|
import {
|
||||||
|
AlertTriangle,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
Clock,
|
Clock,
|
||||||
|
Container as ContainerIcon,
|
||||||
|
Euro,
|
||||||
Package,
|
Package,
|
||||||
PackageCheck,
|
|
||||||
PackageX,
|
|
||||||
Plus,
|
Plus,
|
||||||
|
Ship,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
Weight,
|
Weight,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
|
Area,
|
||||||
|
AreaChart,
|
||||||
Bar,
|
Bar,
|
||||||
BarChart,
|
BarChart,
|
||||||
CartesianGrid,
|
CartesianGrid,
|
||||||
Cell,
|
|
||||||
Pie,
|
|
||||||
PieChart,
|
|
||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
XAxis,
|
XAxis,
|
||||||
@ -36,7 +37,14 @@ import { EmptyState, Skeleton } from '@/components/ui/states';
|
|||||||
import { StatCard } from '@/components/data/stat-card';
|
import { StatCard } from '@/components/data/stat-card';
|
||||||
import { useSubscription } from '@/lib/context/subscription-context';
|
import { useSubscription } from '@/lib/context/subscription-context';
|
||||||
import ExportButton from '@/components/ExportButton';
|
import ExportButton from '@/components/ExportButton';
|
||||||
import { CHART_COLORS, CHART_UI, chartAxisProps, chartTooltipStyle } from '@/lib/chart-colors';
|
import {
|
||||||
|
CHART_UI,
|
||||||
|
MARK,
|
||||||
|
SERIES,
|
||||||
|
STATUS,
|
||||||
|
chartAxisProps,
|
||||||
|
chartTooltipStyle,
|
||||||
|
} from '@/lib/chart-colors';
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -50,6 +58,28 @@ export default function DashboardPage() {
|
|||||||
}
|
}
|
||||||
}, [subLoading, hasFeature, router]);
|
}, [subLoading, hasFeature, router]);
|
||||||
|
|
||||||
|
// Indicateurs de tete : ils portent une variation mensuelle, donc une
|
||||||
|
// tendance. Cet endpoint existait deja cote backend sans etre consomme.
|
||||||
|
const { data: kpis, isLoading: kpisLoading } = useQuery({
|
||||||
|
queryKey: ['dashboard', 'kpis'],
|
||||||
|
queryFn: () => dashboardApi.getKPIs(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: volume, isLoading: volumeLoading } = useQuery({
|
||||||
|
queryKey: ['dashboard', 'bookings-chart'],
|
||||||
|
queryFn: () => dashboardApi.getBookingsChart(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: lanes, isLoading: lanesLoading } = useQuery({
|
||||||
|
queryKey: ['dashboard', 'top-trade-lanes'],
|
||||||
|
queryFn: () => dashboardApi.getTopTradeLanes(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: alerts } = useQuery({
|
||||||
|
queryKey: ['dashboard', 'alerts'],
|
||||||
|
queryFn: () => dashboardApi.getAlerts(),
|
||||||
|
});
|
||||||
|
|
||||||
const { data: csvKpis, isLoading: csvKpisLoading } = useQuery({
|
const { data: csvKpis, isLoading: csvKpisLoading } = useQuery({
|
||||||
queryKey: ['dashboard', 'csv-booking-kpis'],
|
queryKey: ['dashboard', 'csv-booking-kpis'],
|
||||||
queryFn: () => dashboardApi.getCsvBookingKPIs(),
|
queryFn: () => dashboardApi.getCsvBookingKPIs(),
|
||||||
@ -60,39 +90,61 @@ export default function DashboardPage() {
|
|||||||
queryFn: () => dashboardApi.getTopCarriers(),
|
queryFn: () => dashboardApi.getTopCarriers(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const numberFormat = new Intl.NumberFormat(locale === 'fr' ? 'fr-FR' : 'en-US');
|
const intl = locale === 'fr' ? 'fr-FR' : 'en-US';
|
||||||
|
const numberFormat = new Intl.NumberFormat(intl);
|
||||||
|
const currencyFormat = new Intl.NumberFormat(intl, {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'EUR',
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
});
|
||||||
|
const cbmFormat = new Intl.NumberFormat(intl, { maximumFractionDigits: 1 });
|
||||||
|
|
||||||
const totalBookings =
|
/**
|
||||||
(csvKpis?.totalAccepted ?? 0) + (csvKpis?.totalRejected ?? 0) + (csvKpis?.totalPending ?? 0);
|
* N'affiche une variation que lorsqu'elle compare quelque chose : deux
|
||||||
|
* periodes vides donnent 0 %, un badge vert qui ne veut rien dire.
|
||||||
|
*/
|
||||||
|
const trendOf = (value: number | undefined, change: number | undefined, positiveIsGood = true) =>
|
||||||
|
kpis && ((value ?? 0) !== 0 || (change ?? 0) !== 0)
|
||||||
|
? { value: change ?? 0, label: t('kpi.vsLastMonth'), positiveIsGood }
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const statusDistribution = csvKpis
|
// Le backend renvoie des cles ISO `YYYY-MM` : le nom du mois se compose ici,
|
||||||
? [
|
// dans la langue de l'utilisateur.
|
||||||
{
|
const monthFormat = new Intl.DateTimeFormat(intl, { month: 'short' });
|
||||||
name: t('charts.distribution.accepted'),
|
const formatMonth = (iso: string) => {
|
||||||
value: csvKpis.totalAccepted,
|
const [year, month] = iso.split('-').map(Number);
|
||||||
color: CHART_COLORS.success,
|
if (!year || !month) return iso;
|
||||||
},
|
return monthFormat.format(new Date(year, month - 1, 1));
|
||||||
{
|
};
|
||||||
name: t('charts.distribution.rejected'),
|
|
||||||
value: csvKpis.totalRejected,
|
|
||||||
color: CHART_COLORS.danger,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: t('charts.distribution.pending'),
|
|
||||||
value: csvKpis.totalPending,
|
|
||||||
color: CHART_COLORS.warning,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const weightDataKey = t('charts.weightByCarrier.weight');
|
const volumeSeries = t('charts.volume.series');
|
||||||
|
const volumeData = (volume?.labels ?? []).map((label, i) => ({
|
||||||
|
label: formatMonth(label),
|
||||||
|
[volumeSeries]: volume?.data?.[i] ?? 0,
|
||||||
|
}));
|
||||||
|
const hasVolume = volumeData.some(d => (d[volumeSeries] as number) > 0);
|
||||||
|
|
||||||
const carrierWeightData = topCarriers
|
const laneSeries = t('charts.lanes.series');
|
||||||
? topCarriers.slice(0, 5).map(c => ({
|
const laneData = (lanes ?? []).slice(0, 5).map(lane => ({
|
||||||
name: c.carrierName.length > 15 ? c.carrierName.slice(0, 15) + '…' : c.carrierName,
|
route: lane.route,
|
||||||
[weightDataKey]: Math.round(c.totalWeightKG),
|
[laneSeries]: lane.bookingCount,
|
||||||
}))
|
}));
|
||||||
: [];
|
|
||||||
|
// Etats des demandes : trois parts d'un tout, avec le taux d'acceptation en
|
||||||
|
// chiffre de tete. Un anneau obligeait a comparer des angles ; une barre
|
||||||
|
// empilee legendee se lit d'un coup.
|
||||||
|
const accepted = csvKpis?.totalAccepted ?? 0;
|
||||||
|
const rejected = csvKpis?.totalRejected ?? 0;
|
||||||
|
const pending = csvKpis?.totalPending ?? 0;
|
||||||
|
const totalRequests = accepted + rejected + pending;
|
||||||
|
|
||||||
|
const statusParts = [
|
||||||
|
{ key: 'accepted', label: t('charts.distribution.accepted'), value: accepted, color: STATUS.good },
|
||||||
|
{ key: 'pending', label: t('charts.distribution.pending'), value: pending, color: STATUS.warning },
|
||||||
|
{ key: 'rejected', label: t('charts.distribution.rejected'), value: rejected, color: STATUS.critical },
|
||||||
|
];
|
||||||
|
|
||||||
|
const openAlerts = alerts ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -112,7 +164,7 @@ export default function DashboardPage() {
|
|||||||
{
|
{
|
||||||
key: 'totalWeightKG',
|
key: 'totalWeightKG',
|
||||||
label: t('export.totalWeight'),
|
label: t('export.totalWeight'),
|
||||||
format: v => v?.toLocaleString(locale === 'fr' ? 'fr-FR' : 'en-US') || '0',
|
format: v => v?.toLocaleString(intl) || '0',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'totalVolumeCBM',
|
key: 'totalVolumeCBM',
|
||||||
@ -142,40 +194,158 @@ export default function DashboardPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
{/* Indicateurs primaires : les quatre chiffres qui repondent a « ou en
|
{/* Ce qui demande une action passe avant ce qui informe. */}
|
||||||
sommes-nous ». Alignes a gauche pour se comparer en colonne. */}
|
{openAlerts.length > 0 && <AlertsStrip alerts={openAlerts} />}
|
||||||
|
|
||||||
|
{/* Indicateurs de tete : chacun porte sa variation mensuelle. */}
|
||||||
<section className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
<section className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
<StatCard
|
<StatCard
|
||||||
label={t('performance.totalBookings')}
|
label={t('kpi.bookingsThisMonth')}
|
||||||
value={numberFormat.format(totalBookings)}
|
value={numberFormat.format(kpis?.bookingsThisMonth ?? 0)}
|
||||||
|
trend={trendOf(kpis?.bookingsThisMonth, kpis?.bookingsThisMonthChange)}
|
||||||
|
hint={t('kpi.vsLastMonth')}
|
||||||
icon={Package}
|
icon={Package}
|
||||||
loading={csvKpisLoading}
|
loading={kpisLoading}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label={t('kpi.accepted')}
|
label={t('kpi.volume')}
|
||||||
value={numberFormat.format(csvKpis?.totalAccepted ?? 0)}
|
value={`${cbmFormat.format(kpis?.volumeCBM ?? 0)} CBM`}
|
||||||
hint={t('kpi.thisMonth', { count: csvKpis?.acceptedThisMonth ?? 0 })}
|
trend={trendOf(kpis?.volumeCBM, kpis?.volumeCBMChange)}
|
||||||
icon={PackageCheck}
|
hint={t('kpi.vsLastMonth')}
|
||||||
loading={csvKpisLoading}
|
icon={ContainerIcon}
|
||||||
|
loading={kpisLoading}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label={t('kpi.rejected')}
|
label={t('kpi.revenue')}
|
||||||
value={numberFormat.format(csvKpis?.totalRejected ?? 0)}
|
value={currencyFormat.format(kpis?.estimatedRevenue ?? 0)}
|
||||||
hint={t('kpi.thisMonth', { count: csvKpis?.rejectedThisMonth ?? 0 })}
|
trend={trendOf(kpis?.estimatedRevenue, kpis?.estimatedRevenueChange)}
|
||||||
icon={PackageX}
|
hint={t('kpi.vsLastMonth')}
|
||||||
loading={csvKpisLoading}
|
icon={Euro}
|
||||||
|
loading={kpisLoading}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label={t('kpi.pending')}
|
label={t('kpi.pendingConfirmations')}
|
||||||
value={numberFormat.format(csvKpis?.totalPending ?? 0)}
|
value={numberFormat.format(kpis?.pendingConfirmations ?? 0)}
|
||||||
hint={t('kpi.acceptanceRate', { rate: (csvKpis?.acceptanceRate ?? 0).toFixed(1) })}
|
// Une hausse des dossiers en attente n'est pas une bonne nouvelle :
|
||||||
|
// la couleur de la variation doit s'inverser.
|
||||||
|
trend={trendOf(kpis?.pendingConfirmations, kpis?.pendingConfirmationsChange, false)}
|
||||||
|
hint={t('kpi.vsLastMonth')}
|
||||||
icon={Clock}
|
icon={Clock}
|
||||||
loading={csvKpisLoading}
|
loading={kpisLoading}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Indicateurs secondaires : volumetrie et performance, en bandeau
|
{/* Evolution a gauche (la question « ou va-t-on »), etat des demandes a
|
||||||
compact pour ne pas concurrencer la rangee primaire. */}
|
droite (la question « ou en est-on »). */}
|
||||||
|
<section className="grid gap-4 lg:grid-cols-3">
|
||||||
|
<Card className="lg:col-span-2">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle>{t('charts.volume.title')}</CardTitle>
|
||||||
|
<CardDescription>{t('charts.volume.description')}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{volumeLoading ? (
|
||||||
|
<Skeleton className="h-56 w-full" />
|
||||||
|
) : !hasVolume ? (
|
||||||
|
<EmptyState size="sm" icon={TrendingUp} title={t('charts.volume.empty')} />
|
||||||
|
) : (
|
||||||
|
<ResponsiveContainer width="100%" height={224}>
|
||||||
|
<AreaChart data={volumeData} margin={{ top: 8, right: 8, bottom: 0, left: 0 }}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="volumeFill" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor={SERIES.primary} stopOpacity={0.18} />
|
||||||
|
<stop offset="100%" stopColor={SERIES.primary} stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
{/* Seules les horizontales aident a lire une hauteur. */}
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke={CHART_UI.grid} vertical={false} />
|
||||||
|
<XAxis dataKey="label" {...chartAxisProps} />
|
||||||
|
<YAxis width={40} allowDecimals={false} {...chartAxisProps} />
|
||||||
|
<Tooltip
|
||||||
|
{...chartTooltipStyle}
|
||||||
|
cursor={{ stroke: CHART_UI.border, strokeWidth: 1 }}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey={volumeSeries}
|
||||||
|
stroke={SERIES.primary}
|
||||||
|
strokeWidth={MARK.strokeWidth}
|
||||||
|
fill="url(#volumeFill)"
|
||||||
|
dot={false}
|
||||||
|
activeDot={{
|
||||||
|
r: MARK.activeDotRadius,
|
||||||
|
fill: SERIES.primary,
|
||||||
|
stroke: CHART_UI.surface,
|
||||||
|
strokeWidth: 2,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle>{t('charts.status.title')}</CardTitle>
|
||||||
|
<CardDescription>{t('charts.status.description')}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{csvKpisLoading ? (
|
||||||
|
<Skeleton className="h-56 w-full" />
|
||||||
|
) : totalRequests === 0 ? (
|
||||||
|
<EmptyState size="sm" icon={Package} title={t('topCarriers.empty.title')} />
|
||||||
|
) : (
|
||||||
|
<div className="flex h-56 flex-col justify-center">
|
||||||
|
<p className="text-label uppercase text-neutral-500">
|
||||||
|
{t('charts.status.rateLabel')}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 font-heading text-4xl font-extrabold tabular-nums text-brand-navy">
|
||||||
|
{(csvKpis?.acceptanceRate ?? 0).toFixed(1)} %
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Barre empilee. L'ecart de 2 px entre segments evite que
|
||||||
|
deux etats se touchent et paraissent continus. */}
|
||||||
|
<div className="mt-5 flex h-2.5 gap-0.5 overflow-hidden rounded-full">
|
||||||
|
{statusParts
|
||||||
|
.filter(p => p.value > 0)
|
||||||
|
.map(part => (
|
||||||
|
<div
|
||||||
|
key={part.key}
|
||||||
|
className="h-full first:rounded-l-full last:rounded-r-full"
|
||||||
|
style={{
|
||||||
|
width: `${(part.value / totalRequests) * 100}%`,
|
||||||
|
backgroundColor: part.color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Legende chiffree : la couleur ne porte jamais seule
|
||||||
|
l'information. */}
|
||||||
|
<ul className="mt-5 space-y-2.5">
|
||||||
|
{statusParts.map(part => (
|
||||||
|
<li key={part.key} className="flex items-center gap-2.5">
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="size-2.5 shrink-0 rounded-sm"
|
||||||
|
style={{ backgroundColor: part.color }}
|
||||||
|
/>
|
||||||
|
<span className="flex-1 text-body-sm text-neutral-600">{part.label}</span>
|
||||||
|
<span className="font-heading text-body-sm font-semibold tabular-nums text-brand-navy">
|
||||||
|
{numberFormat.format(part.value)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Volumetrie, en bandeau compact : utile mais sans tendance, donc sans
|
||||||
|
le poids visuel d'une tuile. */}
|
||||||
<section className="grid gap-px overflow-hidden rounded-lg border border-border bg-border sm:grid-cols-3">
|
<section className="grid gap-px overflow-hidden rounded-lg border border-border bg-border sm:grid-cols-3">
|
||||||
<SecondaryStat
|
<SecondaryStat
|
||||||
label={t('performance.acceptanceRate')}
|
label={t('performance.acceptanceRate')}
|
||||||
@ -185,7 +355,9 @@ export default function DashboardPage() {
|
|||||||
<SecondaryStat
|
<SecondaryStat
|
||||||
label={t('kpi.totalWeight')}
|
label={t('kpi.totalWeight')}
|
||||||
value={
|
value={
|
||||||
csvKpisLoading ? null : `${numberFormat.format(csvKpis?.totalWeightAcceptedKG ?? 0)} kg`
|
csvKpisLoading
|
||||||
|
? null
|
||||||
|
: `${numberFormat.format(csvKpis?.totalWeightAcceptedKG ?? 0)} kg`
|
||||||
}
|
}
|
||||||
icon={Weight}
|
icon={Weight}
|
||||||
/>
|
/>
|
||||||
@ -200,152 +372,201 @@ export default function DashboardPage() {
|
|||||||
|
|
||||||
<section className="grid gap-4 lg:grid-cols-2">
|
<section className="grid gap-4 lg:grid-cols-2">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle>{t('charts.distribution.title')}</CardTitle>
|
<CardTitle>{t('charts.lanes.title')}</CardTitle>
|
||||||
<CardDescription>{t('charts.distribution.description')}</CardDescription>
|
<CardDescription>{t('charts.lanes.description')}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{csvKpisLoading ? (
|
{lanesLoading ? (
|
||||||
<Skeleton className="h-52 w-full" />
|
<Skeleton className="h-56 w-full" />
|
||||||
) : totalBookings === 0 ? (
|
) : laneData.length === 0 ? (
|
||||||
<EmptyState size="sm" icon={Package} title={t('topCarriers.empty.title')} />
|
<EmptyState size="sm" icon={Ship} title={t('charts.lanes.empty')} />
|
||||||
) : (
|
) : (
|
||||||
<ResponsiveContainer width="100%" height={208}>
|
// Barres horizontales : les libelles de route sont longs et se
|
||||||
<PieChart>
|
// lisent a plat, sans rotation ni troncature.
|
||||||
<Pie
|
<ResponsiveContainer width="100%" height={224}>
|
||||||
data={statusDistribution}
|
<BarChart
|
||||||
cx="50%"
|
data={laneData}
|
||||||
cy="50%"
|
layout="vertical"
|
||||||
labelLine={false}
|
margin={{ top: 4, right: 16, bottom: 0, left: 0 }}
|
||||||
label={({ name, percent }: any) =>
|
barCategoryGap="28%"
|
||||||
`${name} ${((percent || 0) * 100).toFixed(0)}%`
|
>
|
||||||
}
|
<CartesianGrid strokeDasharray="3 3" stroke={CHART_UI.grid} horizontal={false} />
|
||||||
outerRadius={72}
|
<XAxis type="number" allowDecimals={false} {...chartAxisProps} />
|
||||||
innerRadius={44}
|
<YAxis
|
||||||
paddingAngle={2}
|
type="category"
|
||||||
dataKey="value"
|
dataKey="route"
|
||||||
>
|
width={110}
|
||||||
{statusDistribution.map(entry => (
|
{...chartAxisProps}
|
||||||
<Cell key={entry.name} fill={entry.color} stroke={CHART_UI.surface} />
|
tick={{ fontSize: 11, fill: CHART_UI.axis }}
|
||||||
))}
|
/>
|
||||||
</Pie>
|
<Tooltip {...chartTooltipStyle} cursor={{ fill: CHART_UI.grid }} />
|
||||||
<Tooltip {...chartTooltipStyle} />
|
<Bar
|
||||||
</PieChart>
|
dataKey={laneSeries}
|
||||||
|
fill={SERIES.primary}
|
||||||
|
radius={[0, MARK.barRadius, MARK.barRadius, 0]}
|
||||||
|
maxBarSize={18}
|
||||||
|
/>
|
||||||
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="flex-row items-start justify-between gap-3 pb-2">
|
||||||
<CardTitle>{t('charts.weightByCarrier.title')}</CardTitle>
|
<div className="min-w-0">
|
||||||
<CardDescription>{t('charts.weightByCarrier.description')}</CardDescription>
|
<CardTitle>{t('topCarriers.title')}</CardTitle>
|
||||||
|
<CardDescription>{t('topCarriers.description')}</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button asChild variant="ghost" size="sm" className="shrink-0">
|
||||||
|
<Link href="/dashboard/bookings">
|
||||||
|
{t('topCarriers.viewAll')}
|
||||||
|
<ArrowRight />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
|
||||||
|
<CardContent className="p-0">
|
||||||
{carriersLoading ? (
|
{carriersLoading ? (
|
||||||
<Skeleton className="h-52 w-full" />
|
<div className="divide-y divide-border">
|
||||||
) : carrierWeightData.length === 0 ? (
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
<EmptyState size="sm" icon={Weight} title={t('topCarriers.empty.title')} />
|
<div key={i} className="px-5 py-3">
|
||||||
|
<Skeleton className="h-10 w-full" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : topCarriers && topCarriers.length > 0 ? (
|
||||||
|
<ul className="divide-y divide-border">
|
||||||
|
{topCarriers.slice(0, 5).map((carrier, index) => (
|
||||||
|
<li
|
||||||
|
key={carrier.carrierName}
|
||||||
|
className="flex items-center gap-3 px-5 py-3 transition-colors hover:bg-neutral-50"
|
||||||
|
>
|
||||||
|
<span className="flex size-6 shrink-0 items-center justify-center rounded-md bg-neutral-100 text-body-xs font-semibold text-neutral-600">
|
||||||
|
{index + 1}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-body-sm font-medium text-brand-navy">
|
||||||
|
{carrier.carrierName}
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 truncate text-body-xs text-neutral-500">
|
||||||
|
{t('topCarriers.bookingsCount', { count: carrier.totalBookings })}
|
||||||
|
{' · '}
|
||||||
|
{numberFormat.format(carrier.totalWeightKG)} kg
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 items-center gap-1.5">
|
||||||
|
<Badge variant="success">{carrier.acceptedBookings}</Badge>
|
||||||
|
{carrier.rejectedBookings > 0 && (
|
||||||
|
<Badge variant="danger">{carrier.rejectedBookings}</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="hidden w-24 shrink-0 text-right sm:block">
|
||||||
|
<p className="text-body-sm font-medium text-brand-navy">
|
||||||
|
{carrier.acceptanceRate.toFixed(0)} %
|
||||||
|
</p>
|
||||||
|
<p className="text-body-xs text-neutral-500">
|
||||||
|
${carrier.avgPriceUSD.toFixed(0)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
) : (
|
) : (
|
||||||
<ResponsiveContainer width="100%" height={208}>
|
<EmptyState
|
||||||
<BarChart data={carrierWeightData} margin={{ top: 4, right: 4, bottom: 0, left: 0 }}>
|
icon={Package}
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke={CHART_UI.grid} vertical={false} />
|
title={t('topCarriers.empty.title')}
|
||||||
<XAxis dataKey="name" interval={0} height={44} {...chartAxisProps} />
|
description={t('topCarriers.empty.description')}
|
||||||
<YAxis width={48} {...chartAxisProps} />
|
action={
|
||||||
<Tooltip {...chartTooltipStyle} cursor={{ fill: CHART_UI.grid }} />
|
<Button asChild size="sm">
|
||||||
<Bar dataKey={weightDataKey} fill={CHART_COLORS.primary} radius={[4, 4, 0, 0]} />
|
<Link href="/dashboard/search-advanced">
|
||||||
</BarChart>
|
<Plus />
|
||||||
</ResponsiveContainer>
|
{t('topCarriers.empty.cta')}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex-row items-start justify-between gap-3 pb-3">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<CardTitle>{t('topCarriers.title')}</CardTitle>
|
|
||||||
<CardDescription>{t('topCarriers.description')}</CardDescription>
|
|
||||||
</div>
|
|
||||||
<Button asChild variant="ghost" size="sm" className="shrink-0">
|
|
||||||
<Link href="/dashboard/bookings">
|
|
||||||
{t('topCarriers.viewAll')}
|
|
||||||
<ArrowRight />
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent className="p-0">
|
|
||||||
{carriersLoading ? (
|
|
||||||
<div className="divide-y divide-border">
|
|
||||||
{Array.from({ length: 3 }).map((_, i) => (
|
|
||||||
<div key={i} className="px-5 py-3">
|
|
||||||
<Skeleton className="h-10 w-full" />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : topCarriers && topCarriers.length > 0 ? (
|
|
||||||
<ul className="divide-y divide-border">
|
|
||||||
{topCarriers.slice(0, 5).map((carrier, index) => (
|
|
||||||
<li
|
|
||||||
key={carrier.carrierName}
|
|
||||||
className="flex items-center gap-3 px-5 py-3 transition-colors hover:bg-neutral-50"
|
|
||||||
>
|
|
||||||
<span className="flex size-6 shrink-0 items-center justify-center rounded-md bg-neutral-100 text-body-xs font-semibold text-neutral-600">
|
|
||||||
{index + 1}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<p className="truncate text-body-sm font-medium text-brand-navy">
|
|
||||||
{carrier.carrierName}
|
|
||||||
</p>
|
|
||||||
<p className="mt-0.5 truncate text-body-xs text-neutral-500">
|
|
||||||
{t('topCarriers.bookingsCount', { count: carrier.totalBookings })}
|
|
||||||
{' · '}
|
|
||||||
{numberFormat.format(carrier.totalWeightKG)} kg
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex shrink-0 items-center gap-1.5">
|
|
||||||
<Badge variant="success">{carrier.acceptedBookings}</Badge>
|
|
||||||
{carrier.rejectedBookings > 0 && (
|
|
||||||
<Badge variant="danger">{carrier.rejectedBookings}</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="hidden w-24 shrink-0 text-right sm:block">
|
|
||||||
<p className="text-body-sm font-medium text-brand-navy">
|
|
||||||
{carrier.acceptanceRate.toFixed(0)} %
|
|
||||||
</p>
|
|
||||||
<p className="text-body-xs text-neutral-500">
|
|
||||||
${carrier.avgPriceUSD.toFixed(0)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
) : (
|
|
||||||
<EmptyState
|
|
||||||
icon={Package}
|
|
||||||
title={t('topCarriers.empty.title')}
|
|
||||||
description={t('topCarriers.empty.description')}
|
|
||||||
action={
|
|
||||||
<Button asChild size="sm">
|
|
||||||
<Link href="/dashboard/search-advanced">
|
|
||||||
<Plus />
|
|
||||||
{t('topCarriers.empty.cta')}
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Bandeau des points a traiter, en tete de tableau de bord. */
|
||||||
|
function AlertsStrip({
|
||||||
|
alerts,
|
||||||
|
}: {
|
||||||
|
alerts: Array<{
|
||||||
|
id: string;
|
||||||
|
severity: 'critical' | 'high' | 'medium' | 'low';
|
||||||
|
titleKey: string;
|
||||||
|
messageKey: string;
|
||||||
|
messageParams?: Record<string, string | number>;
|
||||||
|
bookingId?: string;
|
||||||
|
bookingNumber?: string;
|
||||||
|
}>;
|
||||||
|
}) {
|
||||||
|
const t = useTranslations('dashboard.home.alerts');
|
||||||
|
|
||||||
|
// Le plus urgent d'abord : trois alertes suffisent en tete de page, le reste
|
||||||
|
// se consulte dans les listes concernees.
|
||||||
|
const order = { critical: 0, high: 1, medium: 2, low: 3 } as const;
|
||||||
|
const sorted = [...alerts].sort((a, b) => order[a.severity] - order[b.severity]).slice(0, 3);
|
||||||
|
|
||||||
|
const tone: Record<string, string> = {
|
||||||
|
critical: 'text-destructive',
|
||||||
|
high: 'text-destructive',
|
||||||
|
medium: 'text-amber-600',
|
||||||
|
low: 'text-neutral-500',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="overflow-hidden rounded-lg border border-border bg-white">
|
||||||
|
<div className="flex items-center gap-2 border-b border-border px-5 py-3">
|
||||||
|
<AlertTriangle className="size-4 shrink-0 text-amber-500" aria-hidden="true" />
|
||||||
|
<p className="text-body-sm font-semibold text-brand-navy">{t('title')}</p>
|
||||||
|
<p className="truncate text-body-xs text-neutral-500">
|
||||||
|
{t('description', { count: alerts.length })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ul className="divide-y divide-border">
|
||||||
|
{sorted.map(alert => (
|
||||||
|
<li key={alert.id} className="flex items-center gap-3 px-5 py-3">
|
||||||
|
{/* Severite : jamais la couleur seule — un libelle l'accompagne. */}
|
||||||
|
<span
|
||||||
|
className={`w-16 shrink-0 text-label uppercase ${tone[alert.severity] ?? 'text-neutral-500'}`}
|
||||||
|
>
|
||||||
|
{t(`severity.${alert.severity}` as any)}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-body-sm font-medium text-brand-navy">
|
||||||
|
{t(`items.${alert.titleKey}` as any)}
|
||||||
|
</p>
|
||||||
|
<p className="truncate text-body-xs text-neutral-500">
|
||||||
|
{t(`items.${alert.messageKey}` as any, alert.messageParams ?? {})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{alert.bookingId && (
|
||||||
|
<Button asChild variant="outline" size="sm" className="shrink-0">
|
||||||
|
<Link href={`/dashboard/bookings/${alert.bookingId}`}>
|
||||||
|
{alert.bookingNumber ?? <ArrowRight />}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Indicateur secondaire, en bandeau, sans le poids visuel d'une carte. */
|
/** Indicateur secondaire, en bandeau, sans le poids visuel d'une carte. */
|
||||||
function SecondaryStat({
|
function SecondaryStat({
|
||||||
label,
|
label,
|
||||||
|
|||||||
@ -142,7 +142,7 @@ export default function ProfilePage() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-screen">
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-brand-turquoise mx-auto mb-4"></div>
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-brand-blue mx-auto mb-4"></div>
|
||||||
<p className="text-neutral-600">{t('loading')}</p>
|
<p className="text-neutral-600">{t('loading')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -156,7 +156,7 @@ export default function ProfilePage() {
|
|||||||
<p className="text-red-600 mb-4">{t('loadError')}</p>
|
<p className="text-red-600 mb-4">{t('loadError')}</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => window.location.reload()}
|
onClick={() => window.location.reload()}
|
||||||
className="px-4 py-2 bg-brand-turquoise text-brand-navy rounded-lg hover:bg-brand-turquoise/90"
|
className="px-4 py-2 bg-brand-navy text-white rounded-lg hover:bg-brand-navy/90"
|
||||||
>
|
>
|
||||||
{t('retry')}
|
{t('retry')}
|
||||||
</button>
|
</button>
|
||||||
@ -204,7 +204,7 @@ export default function ProfilePage() {
|
|||||||
|
|
||||||
<div className="bg-white rounded-lg shadow p-6">
|
<div className="bg-white rounded-lg shadow p-6">
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
<div className="w-16 h-16 bg-brand-turquoise rounded-full flex items-center justify-center text-white text-2xl font-bold">
|
<div className="w-16 h-16 bg-brand-blue rounded-full flex items-center justify-center text-white text-2xl font-bold">
|
||||||
{user?.firstName?.[0]}
|
{user?.firstName?.[0]}
|
||||||
{user?.lastName?.[0]}
|
{user?.lastName?.[0]}
|
||||||
</div>
|
</div>
|
||||||
@ -214,7 +214,7 @@ export default function ProfilePage() {
|
|||||||
</h2>
|
</h2>
|
||||||
<p className="text-neutral-600">{user?.email}</p>
|
<p className="text-neutral-600">{user?.email}</p>
|
||||||
<div className="flex items-center space-x-2 mt-2">
|
<div className="flex items-center space-x-2 mt-2">
|
||||||
<span className="px-3 py-1 text-xs font-medium text-brand-navy bg-brand-turquoise/10 rounded-full">
|
<span className="px-3 py-1 text-xs font-medium text-brand-navy bg-brand-blue/10 rounded-full">
|
||||||
{user?.role}
|
{user?.role}
|
||||||
</span>
|
</span>
|
||||||
<span className="px-3 py-1 text-xs font-medium text-green-800 bg-green-100 rounded-full">
|
<span className="px-3 py-1 text-xs font-medium text-green-800 bg-green-100 rounded-full">
|
||||||
@ -232,7 +232,7 @@ export default function ProfilePage() {
|
|||||||
onClick={() => setActiveTab('profile')}
|
onClick={() => setActiveTab('profile')}
|
||||||
className={`py-4 px-1 border-b-2 font-medium text-sm ${
|
className={`py-4 px-1 border-b-2 font-medium text-sm ${
|
||||||
activeTab === 'profile'
|
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'
|
: 'border-transparent text-neutral-500 hover:text-neutral-700 hover:border-neutral-300'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@ -242,7 +242,7 @@ export default function ProfilePage() {
|
|||||||
onClick={() => setActiveTab('password')}
|
onClick={() => setActiveTab('password')}
|
||||||
className={`py-4 px-1 border-b-2 font-medium text-sm ${
|
className={`py-4 px-1 border-b-2 font-medium text-sm ${
|
||||||
activeTab === 'password'
|
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'
|
: 'border-transparent text-neutral-500 hover:text-neutral-700 hover:border-neutral-300'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@ -314,7 +314,7 @@ export default function ProfilePage() {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={updateProfileMutation.isPending}
|
disabled={updateProfileMutation.isPending}
|
||||||
className="px-6 py-2 bg-brand-turquoise text-brand-navy font-medium rounded-lg hover:bg-brand-turquoise/90 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
className="px-6 py-2 bg-brand-navy text-white font-medium rounded-lg hover:bg-brand-navy/90 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
{updateProfileMutation.isPending
|
{updateProfileMutation.isPending
|
||||||
? t('profileForm.saving')
|
? t('profileForm.saving')
|
||||||
@ -437,7 +437,7 @@ export default function ProfilePage() {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={updatePasswordMutation.isPending}
|
disabled={updatePasswordMutation.isPending}
|
||||||
className="px-6 py-2 bg-brand-turquoise text-brand-navy font-medium rounded-lg hover:bg-brand-turquoise/90 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
className="px-6 py-2 bg-brand-navy text-white font-medium rounded-lg hover:bg-brand-navy/90 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
{updatePasswordMutation.isPending
|
{updatePasswordMutation.isPending
|
||||||
? t('passwordForm.submitting')
|
? t('passwordForm.submitting')
|
||||||
|
|||||||
@ -430,13 +430,13 @@ export default function AdvancedSearchPage() {
|
|||||||
onClick={() => selectDirection(value)}
|
onClick={() => selectDirection(value)}
|
||||||
className={`text-left p-6 rounded-xl border-2 transition-colors ${
|
className={`text-left p-6 rounded-xl border-2 transition-colors ${
|
||||||
isSelected
|
isSelected
|
||||||
? 'border-brand-turquoise bg-brand-turquoise/5'
|
? 'border-brand-blue bg-brand-blue/5'
|
||||||
: 'border-border hover:border-brand-turquoise hover:bg-neutral-50'
|
: 'border-border hover:border-brand-blue hover:bg-neutral-50'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`w-12 h-12 rounded-full flex items-center justify-center mb-4 ${
|
className={`w-12 h-12 rounded-full flex items-center justify-center mb-4 ${
|
||||||
isSelected ? 'bg-brand-turquoise text-brand-navy' : 'bg-neutral-100 text-neutral-600'
|
isSelected ? 'bg-brand-navy text-white' : 'bg-neutral-100 text-neutral-600'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Icon className="h-6 w-6" />
|
<Icon className="h-6 w-6" />
|
||||||
@ -461,7 +461,7 @@ export default function AdvancedSearchPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setCurrentStep(0)}
|
onClick={() => setCurrentStep(0)}
|
||||||
className="text-xs font-medium px-3 py-1 rounded-full bg-brand-turquoise/10 text-brand-navy hover:bg-brand-turquoise/20"
|
className="text-xs font-medium px-3 py-1 rounded-full bg-brand-blue/10 text-brand-navy hover:bg-brand-blue/20"
|
||||||
>
|
>
|
||||||
{t(`step0.${direction.toLowerCase() as 'export' | 'import'}.title`)} · {t('step0.change')}
|
{t(`step0.${direction.toLowerCase() as 'export' | 'import'}.title`)} · {t('step0.change')}
|
||||||
</button>
|
</button>
|
||||||
@ -493,7 +493,7 @@ export default function AdvancedSearchPage() {
|
|||||||
onFocus={() => setShowOriginDropdown(true)}
|
onFocus={() => setShowOriginDropdown(true)}
|
||||||
onBlur={() => setTimeout(() => setShowOriginDropdown(false), 200)}
|
onBlur={() => setTimeout(() => setShowOriginDropdown(false), 200)}
|
||||||
placeholder={t('step1.originPlaceholder')}
|
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'
|
searchForm.origin ? 'border-green-500 bg-green-50' : 'border-neutral-300'
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
@ -517,7 +517,7 @@ export default function AdvancedSearchPage() {
|
|||||||
setDestinationSearch('');
|
setDestinationSearch('');
|
||||||
setShowOriginDropdown(false);
|
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"
|
||||||
>
|
>
|
||||||
<div className="font-medium text-brand-navy">{port.name}</div>
|
<div className="font-medium text-brand-navy">{port.name}</div>
|
||||||
<div className="text-neutral-500 text-xs mt-1">
|
<div className="text-neutral-500 text-xs mt-1">
|
||||||
@ -574,7 +574,7 @@ export default function AdvancedSearchPage() {
|
|||||||
? t('step1.destinationPlaceholder')
|
? t('step1.destinationPlaceholder')
|
||||||
: t('step1.destinationDisabled')
|
: 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.destination ? 'border-green-500 bg-green-50' : 'border-neutral-300'
|
||||||
} ${!searchForm.origin ? 'bg-neutral-100 cursor-not-allowed' : ''}`}
|
} ${!searchForm.origin ? 'bg-neutral-100 cursor-not-allowed' : ''}`}
|
||||||
/>
|
/>
|
||||||
@ -604,7 +604,7 @@ export default function AdvancedSearchPage() {
|
|||||||
setSelectedDestinationPort(port);
|
setSelectedDestinationPort(port);
|
||||||
setShowDestinationDropdown(false);
|
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"
|
||||||
>
|
>
|
||||||
<div className="font-medium text-brand-navy">{port.name}</div>
|
<div className="font-medium text-brand-navy">{port.name}</div>
|
||||||
<div className="text-neutral-500 text-xs mt-1">
|
<div className="text-neutral-500 text-xs mt-1">
|
||||||
@ -674,7 +674,7 @@ export default function AdvancedSearchPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={addPackage}
|
onClick={addPackage}
|
||||||
className="px-4 py-2 text-sm font-medium text-brand-navy bg-brand-turquoise/5 rounded-md hover:bg-brand-turquoise/10"
|
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('step2.addPackage')}
|
{t('step2.addPackage')}
|
||||||
</button>
|
</button>
|
||||||
@ -794,7 +794,7 @@ export default function AdvancedSearchPage() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<div className="bg-brand-turquoise/5 border border-brand-turquoise/30 rounded-md p-4">
|
<div className="bg-brand-blue/5 border border-brand-blue/30 rounded-md p-4">
|
||||||
<h3 className="text-sm font-medium text-brand-navy mb-2">{t('step2.summary.title')}</h3>
|
<h3 className="text-sm font-medium text-brand-navy mb-2">{t('step2.summary.title')}</h3>
|
||||||
<div className="text-sm text-brand-navy space-y-1">
|
<div className="text-sm text-brand-navy space-y-1">
|
||||||
<div>{t('step2.summary.volume', { value: totals.totalVolumeCBM.toFixed(2) })}</div>
|
<div>{t('step2.summary.volume', { value: totals.totalVolumeCBM.toFixed(2) })}</div>
|
||||||
@ -1007,14 +1007,14 @@ export default function AdvancedSearchPage() {
|
|||||||
<div key={step} className="flex items-center">
|
<div key={step} className="flex items-center">
|
||||||
<div
|
<div
|
||||||
className={`flex items-center justify-center w-10 h-10 rounded-full ${
|
className={`flex items-center justify-center w-10 h-10 rounded-full ${
|
||||||
currentStep >= 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 + 1}
|
||||||
</div>
|
</div>
|
||||||
{index < wizardSteps.length - 1 && (
|
{index < wizardSteps.length - 1 && (
|
||||||
<div
|
<div
|
||||||
className={`w-20 h-1 mx-2 ${currentStep > step ? 'bg-brand-turquoise' : 'bg-neutral-200'}`}
|
className={`w-20 h-1 mx-2 ${currentStep > step ? 'bg-brand-blue' : 'bg-neutral-200'}`}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -1046,7 +1046,7 @@ export default function AdvancedSearchPage() {
|
|||||||
? !direction
|
? !direction
|
||||||
: !searchForm.origin || !searchForm.destination
|
: !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')}
|
{t('navigation.next')}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -178,10 +178,10 @@ export default function SearchResultsPage() {
|
|||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-brand-turquoise/5 to-indigo-100 py-12 px-4">
|
<div className="min-h-screen bg-gradient-to-br from-brand-blue/5 to-indigo-100 py-12 px-4">
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="inline-block animate-spin rounded-full h-16 w-16 border-b-4 border-brand-turquoise mb-4"></div>
|
<div className="inline-block animate-spin rounded-full h-16 w-16 border-b-4 border-brand-blue mb-4"></div>
|
||||||
<p className="text-xl text-neutral-700 font-medium">{t('loadingTitle')}</p>
|
<p className="text-xl text-neutral-700 font-medium">{t('loadingTitle')}</p>
|
||||||
<p className="text-neutral-500 mt-2">
|
<p className="text-neutral-500 mt-2">
|
||||||
{origin} → {destination}
|
{origin} → {destination}
|
||||||
@ -194,7 +194,7 @@ export default function SearchResultsPage() {
|
|||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-brand-turquoise/5 to-indigo-100 py-12 px-4">
|
<div className="min-h-screen bg-gradient-to-br from-brand-blue/5 to-indigo-100 py-12 px-4">
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
<div className="bg-red-50 border-2 border-red-200 rounded-lg p-8 text-center">
|
<div className="bg-red-50 border-2 border-red-200 rounded-lg p-8 text-center">
|
||||||
<div className="mb-4 flex justify-center">
|
<div className="mb-4 flex justify-center">
|
||||||
@ -220,11 +220,11 @@ export default function SearchResultsPage() {
|
|||||||
|
|
||||||
if (results.length === 0) {
|
if (results.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-brand-turquoise/5 to-indigo-100 py-12 px-4">
|
<div className="min-h-screen bg-gradient-to-br from-brand-blue/5 to-indigo-100 py-12 px-4">
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
<button
|
<button
|
||||||
onClick={() => router.back()}
|
onClick={() => router.back()}
|
||||||
className="mb-6 flex items-center text-brand-navy hover:text-brand-turquoise font-medium"
|
className="mb-6 flex items-center text-brand-navy hover:text-brand-blue font-medium"
|
||||||
>
|
>
|
||||||
{t('backToSearch')}
|
{t('backToSearch')}
|
||||||
</button>
|
</button>
|
||||||
@ -247,7 +247,7 @@ export default function SearchResultsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => router.back()}
|
onClick={() => router.back()}
|
||||||
className="px-6 py-3 bg-brand-turquoise text-brand-navy rounded-lg hover:bg-brand-turquoise/90 transition-colors"
|
className="px-6 py-3 bg-brand-navy text-white rounded-lg hover:bg-brand-navy/90 transition-colors"
|
||||||
>
|
>
|
||||||
{t('modifySearch')}
|
{t('modifySearch')}
|
||||||
</button>
|
</button>
|
||||||
@ -274,10 +274,10 @@ export default function SearchResultsPage() {
|
|||||||
type: t('options.standard'),
|
type: t('options.standard'),
|
||||||
option: bestOptions?.standard,
|
option: bestOptions?.standard,
|
||||||
colors: {
|
colors: {
|
||||||
border: 'border-brand-turquoise/30',
|
border: 'border-brand-blue/30',
|
||||||
bg: 'bg-brand-turquoise/5',
|
bg: 'bg-brand-blue/5',
|
||||||
text: 'text-brand-navy',
|
text: 'text-brand-navy',
|
||||||
button: 'bg-brand-turquoise hover:bg-brand-turquoise/90',
|
button: 'bg-brand-navy hover:bg-brand-navy/90',
|
||||||
},
|
},
|
||||||
icon: <Scale className="h-10 w-10 text-brand-navy" />,
|
icon: <Scale className="h-10 w-10 text-brand-navy" />,
|
||||||
badge: t('options.badgeBalanced'),
|
badge: t('options.badgeBalanced'),
|
||||||
@ -297,13 +297,13 @@ export default function SearchResultsPage() {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-brand-turquoise/5 to-indigo-100 py-12 px-4">
|
<div className="min-h-screen bg-gradient-to-br from-brand-blue/5 to-indigo-100 py-12 px-4">
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<button
|
<button
|
||||||
onClick={() => router.back()}
|
onClick={() => router.back()}
|
||||||
className="mb-4 flex items-center text-brand-navy hover:text-brand-turquoise font-medium"
|
className="mb-4 flex items-center text-brand-navy hover:text-brand-blue font-medium"
|
||||||
>
|
>
|
||||||
{t('backToSearch')}
|
{t('backToSearch')}
|
||||||
</button>
|
</button>
|
||||||
@ -333,7 +333,7 @@ export default function SearchResultsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{editBookingId && (
|
{editBookingId && (
|
||||||
<div className="mb-6 rounded-lg border border-brand-turquoise/30 bg-brand-turquoise/5 px-4 py-3 text-sm text-brand-navy">
|
<div className="mb-6 rounded-lg border border-brand-blue/30 bg-brand-blue/5 px-4 py-3 text-sm text-brand-navy">
|
||||||
{t('editMode')}
|
{t('editMode')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -501,7 +501,7 @@ export default function SearchResultsPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => handleSelect(result, `all-${index}`)}
|
onClick={() => handleSelect(result, `all-${index}`)}
|
||||||
disabled={submittingId === `all-${index}`}
|
disabled={submittingId === `all-${index}`}
|
||||||
className="px-6 py-2 bg-brand-turquoise text-brand-navy rounded-lg hover:bg-brand-turquoise/90 transition-colors disabled:opacity-60"
|
className="px-6 py-2 bg-brand-navy text-white rounded-lg hover:bg-brand-navy/90 transition-colors disabled:opacity-60"
|
||||||
>
|
>
|
||||||
{submittingId === `all-${index}`
|
{submittingId === `all-${index}`
|
||||||
? '...'
|
? '...'
|
||||||
|
|||||||
@ -32,7 +32,7 @@ function CopyButton({ text }: { text: string }) {
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={handleCopy}
|
onClick={handleCopy}
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border border-border hover:bg-neutral-50 transition-colors"
|
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-brand-navy rounded-lg border border-border hover:bg-neutral-50 transition-colors"
|
||||||
>
|
>
|
||||||
{copied ? (
|
{copied ? (
|
||||||
<>
|
<>
|
||||||
@ -141,7 +141,7 @@ function CreateKeyModal({
|
|||||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-md">
|
<div className="bg-white rounded-2xl shadow-xl w-full max-w-md">
|
||||||
<div className="flex items-center justify-between p-6 border-b">
|
<div className="flex items-center justify-between p-6 border-b">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-10 h-10 rounded-xl bg-brand-turquoise/10 flex items-center justify-center">
|
<div className="w-10 h-10 rounded-xl bg-brand-blue/10 flex items-center justify-center">
|
||||||
<Key className="w-5 h-5 text-brand-navy" />
|
<Key className="w-5 h-5 text-brand-navy" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-lg font-semibold text-brand-navy">{t('title')}</h2>
|
<h2 className="text-lg font-semibold text-brand-navy">{t('title')}</h2>
|
||||||
@ -193,7 +193,7 @@ function CreateKeyModal({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="flex-1 py-2.5 border border-border text-neutral-700 text-sm font-medium rounded-xl hover:bg-neutral-50 transition-colors"
|
className="flex-1 py-2.5 border border-border text-brand-navy text-sm font-medium rounded-lg hover:bg-neutral-50 transition-colors"
|
||||||
>
|
>
|
||||||
{t('cancel')}
|
{t('cancel')}
|
||||||
</button>
|
</button>
|
||||||
@ -240,7 +240,7 @@ function RevokeConfirmModal({
|
|||||||
<div className="px-6 pb-6 flex gap-3">
|
<div className="px-6 pb-6 flex gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="flex-1 py-2.5 border border-border text-neutral-700 text-sm font-medium rounded-xl hover:bg-neutral-50 transition-colors"
|
className="flex-1 py-2.5 border border-border text-brand-navy text-sm font-medium rounded-lg hover:bg-neutral-50 transition-colors"
|
||||||
>
|
>
|
||||||
{t('cancel')}
|
{t('cancel')}
|
||||||
</button>
|
</button>
|
||||||
@ -371,14 +371,14 @@ export default function ApiKeysPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="mb-6 p-4 bg-brand-turquoise/5 border border-brand-turquoise/20 rounded-xl flex gap-3">
|
<div className="mb-6 p-4 bg-brand-blue/5 border border-brand-blue/20 rounded-xl flex gap-3">
|
||||||
<ShieldCheck className="w-5 h-5 text-brand-navy flex-shrink-0 mt-0.5" />
|
<ShieldCheck className="w-5 h-5 text-brand-navy flex-shrink-0 mt-0.5" />
|
||||||
<div className="text-sm text-brand-navy">
|
<div className="text-sm text-brand-navy">
|
||||||
<p className="font-medium mb-0.5">{t('infoTitle')}</p>
|
<p className="font-medium mb-0.5">{t('infoTitle')}</p>
|
||||||
<p>
|
<p>
|
||||||
{t.rich('infoBody', {
|
{t.rich('infoBody', {
|
||||||
code: () => (
|
code: () => (
|
||||||
<code className="px-1.5 py-0.5 bg-brand-turquoise/10 rounded text-brand-navy font-mono text-xs">
|
<code className="px-1.5 py-0.5 bg-brand-blue/10 rounded text-brand-navy font-mono text-xs">
|
||||||
X-API-Key: xped_live_...
|
X-API-Key: xped_live_...
|
||||||
</code>
|
</code>
|
||||||
),
|
),
|
||||||
|
|||||||
@ -149,7 +149,7 @@ export default function OrganizationSettingsPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-screen">
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-4 border-brand-turquoise mb-4"></div>
|
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-4 border-brand-blue mb-4"></div>
|
||||||
<p className="text-neutral-600">{t('loading')}</p>
|
<p className="text-neutral-600">{t('loading')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -288,7 +288,7 @@ export default function OrganizationSettingsPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!canEdit && (activeTab === 'information' || activeTab === 'address') && (
|
{!canEdit && (activeTab === 'information' || activeTab === 'address') && (
|
||||||
<div className="mb-6 bg-brand-turquoise/5 border border-brand-turquoise/30 rounded-lg p-4">
|
<div className="mb-6 bg-brand-blue/5 border border-brand-blue/30 rounded-lg p-4">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<svg
|
<svg
|
||||||
className="w-5 h-5 text-brand-navy mr-3"
|
className="w-5 h-5 text-brand-navy mr-3"
|
||||||
@ -317,7 +317,7 @@ export default function OrganizationSettingsPage() {
|
|||||||
onClick={() => setActiveTab(tab.id)}
|
onClick={() => setActiveTab(tab.id)}
|
||||||
className={`flex-shrink-0 px-6 py-4 text-sm font-medium border-b-2 transition-colors ${
|
className={`flex-shrink-0 px-6 py-4 text-sm font-medium border-b-2 transition-colors ${
|
||||||
activeTab === tab.id
|
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'
|
: 'border-transparent text-neutral-500 hover:text-neutral-700 hover:border-neutral-300'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@ -498,7 +498,7 @@ export default function OrganizationSettingsPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={handleCancel}
|
onClick={handleCancel}
|
||||||
disabled={isSaving}
|
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')}
|
{t('actions.cancel')}
|
||||||
</button>
|
</button>
|
||||||
@ -506,7 +506,7 @@ export default function OrganizationSettingsPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={isSaving || !formData.name || !formData.address_street}
|
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 ? (
|
{isSaving ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@ -19,7 +19,7 @@ export default function SubscriptionPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-screen">
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-4 border-brand-turquoise mb-4"></div>
|
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-4 border-brand-blue mb-4"></div>
|
||||||
<p className="text-neutral-600">{t('loading')}</p>
|
<p className="text-neutral-600">{t('loading')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -50,7 +50,7 @@ function Pagination({
|
|||||||
onClick={() => onPage(p)}
|
onClick={() => onPage(p)}
|
||||||
className={`px-3 py-1 rounded text-sm border ${
|
className={`px-3 py-1 rounded text-sm border ${
|
||||||
p === page
|
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'
|
: 'border-neutral-300 hover:bg-neutral-50'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@ -199,7 +199,7 @@ export default function UsersManagementPage() {
|
|||||||
if (!currentUser || (currentUser.role !== 'ADMIN' && currentUser.role !== 'MANAGER')) {
|
if (!currentUser || (currentUser.role !== 'ADMIN' && currentUser.role !== 'MANAGER')) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-screen">
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-brand-turquoise"></div>
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-brand-blue"></div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -249,7 +249,7 @@ export default function UsersManagementPage() {
|
|||||||
const getRoleBadgeColor = (role: string) => {
|
const getRoleBadgeColor = (role: string) => {
|
||||||
const colors: Record<string, string> = {
|
const colors: Record<string, string> = {
|
||||||
ADMIN: 'bg-red-100 text-red-800',
|
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',
|
USER: 'bg-green-100 text-green-800',
|
||||||
VIEWER: 'bg-neutral-100 text-neutral-800',
|
VIEWER: 'bg-neutral-100 text-neutral-800',
|
||||||
};
|
};
|
||||||
@ -304,10 +304,10 @@ export default function UsersManagementPage() {
|
|||||||
licenseStatus.canInvite &&
|
licenseStatus.canInvite &&
|
||||||
licenseStatus.availableLicenses <= 2 &&
|
licenseStatus.availableLicenses <= 2 &&
|
||||||
licenseStatus.maxLicenses !== -1 && (
|
licenseStatus.maxLicenses !== -1 && (
|
||||||
<div className="bg-brand-turquoise/5 border border-brand-turquoise/30 rounded-lg p-4">
|
<div className="bg-brand-blue/5 border border-brand-blue/30 rounded-lg p-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<svg className="h-5 w-5 text-brand-turquoise mr-2" fill="currentColor" viewBox="0 0 20 20">
|
<svg className="h-5 w-5 text-brand-blue mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||||
<path
|
<path
|
||||||
fillRule="evenodd"
|
fillRule="evenodd"
|
||||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
|
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
|
||||||
@ -324,7 +324,7 @@ export default function UsersManagementPage() {
|
|||||||
</div>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
href="/dashboard/settings/subscription"
|
href="/dashboard/settings/subscription"
|
||||||
className="text-sm font-medium text-brand-navy hover:text-brand-turquoise"
|
className="text-sm font-medium text-brand-navy hover:text-brand-blue"
|
||||||
>
|
>
|
||||||
{t('license.manageLink')}
|
{t('license.manageLink')}
|
||||||
</Link>
|
</Link>
|
||||||
@ -365,7 +365,7 @@ export default function UsersManagementPage() {
|
|||||||
{licenseStatus?.canInvite ? (
|
{licenseStatus?.canInvite ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowInviteModal(true)}
|
onClick={() => setShowInviteModal(true)}
|
||||||
className="inline-flex items-center px-3 sm:px-4 py-2 text-sm font-medium rounded-md text-white bg-brand-turquoise hover:bg-brand-turquoise/90"
|
className="inline-flex items-center px-3 sm:px-4 py-2 text-sm font-medium rounded-md text-white bg-brand-navy hover:bg-brand-navy/90"
|
||||||
>
|
>
|
||||||
<span className="mr-1.5">+</span>
|
<span className="mr-1.5">+</span>
|
||||||
<span className="hidden sm:inline">{t('actions.invite')}</span>
|
<span className="hidden sm:inline">{t('actions.invite')}</span>
|
||||||
@ -408,7 +408,7 @@ export default function UsersManagementPage() {
|
|||||||
</div>
|
</div>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="px-6 py-12 text-center text-neutral-500">
|
<div className="px-6 py-12 text-center text-neutral-500">
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-brand-turquoise mx-auto mb-4"></div>
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-brand-blue mx-auto mb-4"></div>
|
||||||
{t('loading')}
|
{t('loading')}
|
||||||
</div>
|
</div>
|
||||||
) : pagedUsers.length > 0 ? (
|
) : pagedUsers.length > 0 ? (
|
||||||
@ -442,7 +442,7 @@ export default function UsersManagementPage() {
|
|||||||
<tr key={user.id} className="hover:bg-neutral-50">
|
<tr key={user.id} className="hover:bg-neutral-50">
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="flex-shrink-0 h-10 w-10 bg-brand-turquoise rounded-full flex items-center justify-center text-white font-semibold">
|
<div className="flex-shrink-0 h-10 w-10 bg-brand-blue rounded-full flex items-center justify-center text-white font-semibold">
|
||||||
{user.firstName[0]}
|
{user.firstName[0]}
|
||||||
{user.lastName[0]}
|
{user.lastName[0]}
|
||||||
</div>
|
</div>
|
||||||
@ -544,7 +544,7 @@ export default function UsersManagementPage() {
|
|||||||
{licenseStatus?.canInvite ? (
|
{licenseStatus?.canInvite ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowInviteModal(true)}
|
onClick={() => setShowInviteModal(true)}
|
||||||
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-brand-turquoise hover:bg-brand-turquoise/90"
|
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-brand-navy hover:bg-brand-navy/90"
|
||||||
>
|
>
|
||||||
<span className="mr-2">+</span>
|
<span className="mr-2">+</span>
|
||||||
{t('actions.invite')}
|
{t('actions.invite')}
|
||||||
@ -693,7 +693,7 @@ export default function UsersManagementPage() {
|
|||||||
setMenuPosition(null);
|
setMenuPosition(null);
|
||||||
}}
|
}}
|
||||||
disabled={toggleActiveMutation.isPending}
|
disabled={toggleActiveMutation.isPending}
|
||||||
className="w-full px-4 py-3 text-left hover:bg-neutral-50 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-3 border-b border-border"
|
className="w-full px-4 py-3 text-left text-brand-navy hover:bg-neutral-50 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-3 border-b border-border"
|
||||||
>
|
>
|
||||||
{users?.users.find(u => u.id === openMenuId)?.isActive ? (
|
{users?.users.find(u => u.id === openMenuId)?.isActive ? (
|
||||||
<>
|
<>
|
||||||
@ -802,7 +802,7 @@ export default function UsersManagementPage() {
|
|||||||
required
|
required
|
||||||
value={inviteForm.firstName}
|
value={inviteForm.firstName}
|
||||||
onChange={e => setInviteForm({ ...inviteForm, firstName: e.target.value })}
|
onChange={e => setInviteForm({ ...inviteForm, firstName: 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"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@ -814,7 +814,7 @@ export default function UsersManagementPage() {
|
|||||||
required
|
required
|
||||||
value={inviteForm.lastName}
|
value={inviteForm.lastName}
|
||||||
onChange={e => setInviteForm({ ...inviteForm, lastName: e.target.value })}
|
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"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -827,7 +827,7 @@ export default function UsersManagementPage() {
|
|||||||
required
|
required
|
||||||
value={inviteForm.email}
|
value={inviteForm.email}
|
||||||
onChange={e => setInviteForm({ ...inviteForm, email: e.target.value })}
|
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"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@ -837,7 +837,7 @@ export default function UsersManagementPage() {
|
|||||||
<select
|
<select
|
||||||
value={inviteForm.role}
|
value={inviteForm.role}
|
||||||
onChange={e => setInviteForm({ ...inviteForm, role: e.target.value as any })}
|
onChange={e => setInviteForm({ ...inviteForm, role: e.target.value as any })}
|
||||||
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"
|
||||||
>
|
>
|
||||||
<option value="USER">{t('modal.roles.USER')}</option>
|
<option value="USER">{t('modal.roles.USER')}</option>
|
||||||
<option value="MANAGER">{t('modal.roles.MANAGER')}</option>
|
<option value="MANAGER">{t('modal.roles.MANAGER')}</option>
|
||||||
@ -848,7 +848,7 @@ export default function UsersManagementPage() {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={inviteMutation.isPending}
|
disabled={inviteMutation.isPending}
|
||||||
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-brand-turquoise text-base font-medium text-white hover:bg-brand-turquoise/90 sm:col-start-2 sm:text-sm disabled:bg-neutral-300"
|
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-brand-navy text-base font-medium text-white hover:bg-brand-navy/90 sm:col-start-2 sm:text-sm disabled:bg-neutral-300"
|
||||||
>
|
>
|
||||||
{inviteMutation.isPending ? t('modal.submitting') : t('modal.submit')}
|
{inviteMutation.isPending ? t('modal.submitting') : t('modal.submit')}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -263,7 +263,7 @@ export default function TrackTracePage() {
|
|||||||
'flex min-h-[44px] items-center gap-2 rounded-md border p-2 text-left transition-colors',
|
'flex min-h-[44px] items-center gap-2 rounded-md border p-2 text-left transition-colors',
|
||||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||||
active
|
active
|
||||||
? 'border-brand-turquoise bg-brand-turquoise/5'
|
? 'border-brand-blue bg-brand-blue/5'
|
||||||
: 'border-border hover:bg-neutral-50'
|
: 'border-border hover:bg-neutral-50'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@ -340,7 +340,7 @@ export default function TrackTracePage() {
|
|||||||
<Card className={cn('overflow-hidden', isMapFullscreen && 'h-full rounded-none')}>
|
<Card className={cn('overflow-hidden', isMapFullscreen && 'h-full rounded-none')}>
|
||||||
<div className="flex items-center justify-between gap-3 bg-brand-navy px-4 py-3 text-white">
|
<div className="flex items-center justify-between gap-3 bg-brand-navy px-4 py-3 text-white">
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<Anchor className="size-4 shrink-0 text-brand-turquoise" aria-hidden="true" />
|
<Anchor className="size-4 shrink-0 text-brand-blue" aria-hidden="true" />
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="truncate font-heading text-body-sm font-semibold">
|
<p className="truncate font-heading text-body-sm font-semibold">
|
||||||
{t('map.title')}
|
{t('map.title')}
|
||||||
@ -387,7 +387,7 @@ export default function TrackTracePage() {
|
|||||||
role="status"
|
role="status"
|
||||||
className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-2 bg-neutral-50"
|
className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-2 bg-neutral-50"
|
||||||
>
|
>
|
||||||
<Ship className="size-8 animate-pulse text-brand-turquoise" aria-hidden="true" />
|
<Ship className="size-8 animate-pulse text-brand-blue" aria-hidden="true" />
|
||||||
<p className="text-body-sm font-medium text-brand-navy">{t('map.loading')}</p>
|
<p className="text-body-sm font-medium text-brand-navy">{t('map.loading')}</p>
|
||||||
<p className="text-body-xs text-neutral-500">{t('map.connecting')}</p>
|
<p className="text-body-xs text-neutral-500">{t('map.connecting')}</p>
|
||||||
</div>
|
</div>
|
||||||
@ -432,7 +432,7 @@ export default function TrackTracePage() {
|
|||||||
href="https://www.marinetraffic.com"
|
href="https://www.marinetraffic.com"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="flex shrink-0 items-center gap-1 rounded-md text-body-xs font-medium text-brand-turquoise underline-offset-4 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
className="flex shrink-0 items-center gap-1 rounded-md text-body-xs font-medium text-brand-blue underline-offset-4 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
>
|
>
|
||||||
{t('map.openOnSite')}
|
{t('map.openOnSite')}
|
||||||
<ExternalLink className="size-3" aria-hidden="true" />
|
<ExternalLink className="size-3" aria-hidden="true" />
|
||||||
|
|||||||
@ -99,7 +99,7 @@ export default async function AssurancePage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="mt-4 bg-brand-turquoise/5 border-brand-turquoise/30">
|
<Card className="mt-4 bg-brand-blue/5 border-brand-blue/30">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<h3 className="font-semibold text-brand-navy mb-2">{t('assurance.valueTitle')}</h3>
|
<h3 className="font-semibold text-brand-navy mb-2">{t('assurance.valueTitle')}</h3>
|
||||||
<p className="font-mono text-brand-navy bg-white p-3 rounded border text-sm">
|
<p className="font-mono text-brand-navy bg-white p-3 rounded border text-sm">
|
||||||
|
|||||||
@ -82,12 +82,12 @@ export default async function ConteneursPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="mt-8 bg-brand-turquoise/5 border-brand-turquoise/30">
|
<Card className="mt-8 bg-brand-blue/5 border-brand-blue/30">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<h3 className="font-semibold text-brand-navy mb-4">{t('conteneurs.selectionTitle')}</h3>
|
<h3 className="font-semibold text-brand-navy mb-4">{t('conteneurs.selectionTitle')}</h3>
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-brand-turquoise/30">
|
<tr className="border-b border-brand-blue/30">
|
||||||
<th className="text-left py-2 font-medium text-brand-navy">
|
<th className="text-left py-2 font-medium text-brand-navy">
|
||||||
{t('conteneurs.colCondition')}
|
{t('conteneurs.colCondition')}
|
||||||
</th>
|
</th>
|
||||||
@ -98,7 +98,7 @@ export default async function ConteneursPage() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{selectionGuide.map((row, i) => (
|
{selectionGuide.map((row, i) => (
|
||||||
<tr key={i} className="border-b border-brand-turquoise/20 last:border-0">
|
<tr key={i} className="border-b border-brand-blue/20 last:border-0">
|
||||||
<td className="py-2 text-brand-navy">{row.condition}</td>
|
<td className="py-2 text-brand-navy">{row.condition}</td>
|
||||||
<td className="py-2 font-medium text-brand-navy">{row.recommendation}</td>
|
<td className="py-2 font-medium text-brand-navy">{row.recommendation}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@ -45,7 +45,7 @@ export default async function DocumentsTransportPage() {
|
|||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<h4 className="font-semibold text-brand-navy">{doc.name}</h4>
|
<h4 className="font-semibold text-brand-navy">{doc.name}</h4>
|
||||||
<span className="px-2 py-0.5 text-xs bg-brand-turquoise/10 text-brand-navy rounded">
|
<span className="px-2 py-0.5 text-xs bg-brand-blue/10 text-brand-navy rounded">
|
||||||
{doc.type}
|
{doc.type}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -84,14 +84,14 @@ export default async function DocumentsTransportPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="mt-8 bg-brand-turquoise/5 border-brand-turquoise/30">
|
<Card className="mt-8 bg-brand-blue/5 border-brand-blue/30">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<h3 className="font-semibold text-brand-navy mb-4">
|
<h3 className="font-semibold text-brand-navy mb-4">
|
||||||
{t('documentsTransport.blFocusTitle')}
|
{t('documentsTransport.blFocusTitle')}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
{blFunctions.map(fn => (
|
{blFunctions.map(fn => (
|
||||||
<div key={fn.title} className="bg-white p-4 rounded-lg border border-brand-turquoise/30">
|
<div key={fn.title} className="bg-white p-4 rounded-lg border border-brand-blue/30">
|
||||||
<h4 className="font-medium text-brand-navy mb-1">{fn.title}</h4>
|
<h4 className="font-medium text-brand-navy mb-1">{fn.title}</h4>
|
||||||
<p className="text-sm text-neutral-600">{fn.description}</p>
|
<p className="text-sm text-neutral-600">{fn.description}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -96,7 +96,7 @@ export default async function ImdgPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="mt-8 bg-brand-turquoise/5 border-brand-turquoise/30">
|
<Card className="mt-8 bg-brand-blue/5 border-brand-blue/30">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<h3 className="font-semibold text-brand-navy mb-2">{t('imdg.labelingTitle')}</h3>
|
<h3 className="font-semibold text-brand-navy mb-2">{t('imdg.labelingTitle')}</h3>
|
||||||
<p className="text-sm text-brand-navy">{t('imdg.labelingContent')}</p>
|
<p className="text-sm text-brand-navy">{t('imdg.labelingContent')}</p>
|
||||||
|
|||||||
@ -24,7 +24,7 @@ export default async function IncotermsPage() {
|
|||||||
const categoryColors = [
|
const categoryColors = [
|
||||||
'bg-green-100 text-green-800',
|
'bg-green-100 text-green-800',
|
||||||
'bg-red-100 text-red-800',
|
'bg-red-100 text-red-800',
|
||||||
'bg-brand-turquoise/10 text-brand-navy',
|
'bg-brand-blue/10 text-brand-navy',
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -39,13 +39,13 @@ export default async function IncotermsPage() {
|
|||||||
<p className="mt-3 text-neutral-600 max-w-3xl">{t('incoterms.description')}</p>
|
<p className="mt-3 text-neutral-600 max-w-3xl">{t('incoterms.description')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="bg-brand-turquoise/5 border-brand-turquoise/30">
|
<Card className="bg-brand-blue/5 border-brand-blue/30">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<h3 className="font-semibold text-brand-navy mb-3">{t('incoterms.keyPointsTitle')}</h3>
|
<h3 className="font-semibold text-brand-navy mb-3">{t('incoterms.keyPointsTitle')}</h3>
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
{keyPoints.map((point, i) => (
|
{keyPoints.map((point, i) => (
|
||||||
<li key={i} className="flex items-start gap-2 text-brand-navy">
|
<li key={i} className="flex items-start gap-2 text-brand-navy">
|
||||||
<span className="mt-1 w-2 h-2 bg-brand-turquoise rounded-full flex-shrink-0" />
|
<span className="mt-1 w-2 h-2 bg-brand-blue rounded-full flex-shrink-0" />
|
||||||
<span className="text-sm">{point}</span>
|
<span className="text-sm">{point}</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -32,7 +32,7 @@ export default async function LclVsFclPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<Card className="bg-brand-turquoise/5 border-brand-turquoise/30">
|
<Card className="bg-brand-blue/5 border-brand-blue/30">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<h3 className="text-lg font-bold text-brand-navy mb-2">{t('lclVsFcl.lclTitle')}</h3>
|
<h3 className="text-lg font-bold text-brand-navy mb-2">{t('lclVsFcl.lclTitle')}</h3>
|
||||||
<p className="text-sm text-brand-navy">{t('lclVsFcl.lclDescription')}</p>
|
<p className="text-sm text-brand-navy">{t('lclVsFcl.lclDescription')}</p>
|
||||||
@ -75,7 +75,7 @@ export default async function LclVsFclPage() {
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{lclProcess.map(step => (
|
{lclProcess.map(step => (
|
||||||
<div key={step.step} className="flex items-start gap-4 bg-white p-4 rounded-lg border">
|
<div key={step.step} className="flex items-start gap-4 bg-white p-4 rounded-lg border">
|
||||||
<div className="flex-shrink-0 w-8 h-8 bg-brand-turquoise/10 rounded-full flex items-center justify-center text-brand-navy font-bold text-sm">
|
<div className="flex-shrink-0 w-8 h-8 bg-brand-blue/10 rounded-full flex items-center justify-center text-brand-navy font-bold text-sm">
|
||||||
{step.step}
|
{step.step}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@ -88,13 +88,13 @@ export default async function LclVsFclPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mt-8">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mt-8">
|
||||||
<Card className="bg-brand-turquoise/5 border-brand-turquoise/30">
|
<Card className="bg-brand-blue/5 border-brand-blue/30">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<h3 className="font-semibold text-brand-navy mb-3">{t('lclVsFcl.chooseLclTitle')}</h3>
|
<h3 className="font-semibold text-brand-navy mb-3">{t('lclVsFcl.chooseLclTitle')}</h3>
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
{chooseLcl.map((item, i) => (
|
{chooseLcl.map((item, i) => (
|
||||||
<li key={i} className="flex items-start gap-2 text-sm text-brand-navy">
|
<li key={i} className="flex items-start gap-2 text-sm text-brand-navy">
|
||||||
<span className="text-brand-turquoise mt-0.5">✓</span> {item}
|
<span className="text-brand-blue mt-0.5">✓</span> {item}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@ -95,7 +95,7 @@ export default async function LettreCreditPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="mt-4 bg-brand-turquoise/5 border-brand-turquoise/30">
|
<Card className="mt-4 bg-brand-blue/5 border-brand-blue/30">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<h3 className="font-semibold text-brand-navy mb-2">{t('lettreCredit.ucp600Title')}</h3>
|
<h3 className="font-semibold text-brand-navy mb-2">{t('lettreCredit.ucp600Title')}</h3>
|
||||||
<p className="text-sm text-brand-navy">{t('lettreCredit.ucp600Content')}</p>
|
<p className="text-sm text-brand-navy">{t('lettreCredit.ucp600Content')}</p>
|
||||||
|
|||||||
@ -115,14 +115,14 @@ export default async function WikiPage() {
|
|||||||
const IconComponent = topic.icon;
|
const IconComponent = topic.icon;
|
||||||
return (
|
return (
|
||||||
<Link key={topic.href} href={topic.href} className="block group">
|
<Link key={topic.href} href={topic.href} className="block group">
|
||||||
<Card className="h-full transition-all duration-200 hover:shadow-lg hover:border-brand-turquoise bg-white">
|
<Card className="h-full transition-all duration-200 hover:shadow-lg hover:border-brand-blue bg-white">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className="h-10 w-10 rounded-lg bg-brand-turquoise/5 flex items-center justify-center">
|
<div className="h-10 w-10 rounded-lg bg-brand-blue/5 flex items-center justify-center">
|
||||||
<IconComponent className="h-5 w-5 text-brand-navy" />
|
<IconComponent className="h-5 w-5 text-brand-navy" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<CardTitle className="mt-3 transition-colors group-hover:text-brand-turquoise">
|
<CardTitle className="mt-3 transition-colors group-hover:text-brand-blue">
|
||||||
{t(`topics.${topic.key}.title` as any)}
|
{t(`topics.${topic.key}.title` as any)}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="text-neutral-600">
|
<CardDescription className="text-neutral-600">
|
||||||
@ -134,7 +134,7 @@ export default async function WikiPage() {
|
|||||||
{topic.tags.map(tag => (
|
{topic.tags.map(tag => (
|
||||||
<span
|
<span
|
||||||
key={tag}
|
key={tag}
|
||||||
className="px-2 py-1 text-xs font-medium bg-brand-turquoise/5 text-brand-navy rounded-full"
|
className="px-2 py-1 text-xs font-medium bg-brand-blue/5 text-brand-navy rounded-full"
|
||||||
>
|
>
|
||||||
{tag}
|
{tag}
|
||||||
</span>
|
</span>
|
||||||
@ -148,7 +148,7 @@ export default async function WikiPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer info */}
|
{/* Footer info */}
|
||||||
<div className="mt-8 p-4 bg-brand-turquoise/5 rounded-lg">
|
<div className="mt-8 p-4 bg-brand-blue/5 rounded-lg">
|
||||||
<p className="text-sm text-brand-navy">
|
<p className="text-sm text-brand-navy">
|
||||||
<span className="font-semibold">{t('needHelp')} </span>
|
<span className="font-semibold">{t('needHelp')} </span>
|
||||||
{t('helpText')}
|
{t('helpText')}
|
||||||
|
|||||||
@ -63,7 +63,7 @@ export default async function PortsRoutesPage() {
|
|||||||
{route.majorPorts.map(port => (
|
{route.majorPorts.map(port => (
|
||||||
<span
|
<span
|
||||||
key={port}
|
key={port}
|
||||||
className="px-2 py-0.5 bg-brand-turquoise/5 text-brand-navy text-xs rounded"
|
className="px-2 py-0.5 bg-brand-blue/5 text-brand-navy text-xs rounded"
|
||||||
>
|
>
|
||||||
{port}
|
{port}
|
||||||
</span>
|
</span>
|
||||||
@ -79,7 +79,7 @@ export default async function PortsRoutesPage() {
|
|||||||
<h2 className="text-xl font-bold text-brand-navy mb-4">{t('portsRoutes.passagesTitle')}</h2>
|
<h2 className="text-xl font-bold text-brand-navy mb-4">{t('portsRoutes.passagesTitle')}</h2>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{passages.map(p => (
|
{passages.map(p => (
|
||||||
<Card key={p.name} className="bg-white border-brand-turquoise/30">
|
<Card key={p.name} className="bg-white border-brand-blue/30">
|
||||||
<CardContent className="pt-4">
|
<CardContent className="pt-4">
|
||||||
<div className="flex items-start justify-between mb-2">
|
<div className="flex items-start justify-between mb-2">
|
||||||
<div>
|
<div>
|
||||||
@ -88,7 +88,7 @@ export default async function PortsRoutesPage() {
|
|||||||
{p.location} — {t('portsRoutes.colLength')}: {p.length}
|
{p.location} — {t('portsRoutes.colLength')}: {p.length}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="px-2 py-1 bg-brand-turquoise/10 text-brand-navy text-xs rounded font-medium whitespace-nowrap">
|
<span className="px-2 py-1 bg-brand-blue/10 text-brand-navy text-xs rounded font-medium whitespace-nowrap">
|
||||||
{p.keyStat}
|
{p.keyStat}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -126,7 +126,7 @@ export default async function PortsRoutesPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mt-8">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mt-8">
|
||||||
<Card className="bg-brand-turquoise/5 border-brand-turquoise/30">
|
<Card className="bg-brand-blue/5 border-brand-blue/30">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<h3 className="font-semibold text-brand-navy mb-2">{t('portsRoutes.hubTitle')}</h3>
|
<h3 className="font-semibold text-brand-navy mb-2">{t('portsRoutes.hubTitle')}</h3>
|
||||||
<p className="text-sm text-brand-navy">{t('portsRoutes.hubDescription')}</p>
|
<p className="text-sm text-brand-navy">{t('portsRoutes.hubDescription')}</p>
|
||||||
|
|||||||
@ -47,7 +47,7 @@ export default async function TransitTimePage() {
|
|||||||
<p className="mt-3 text-neutral-600 max-w-3xl">{t('transitTime.description')}</p>
|
<p className="mt-3 text-neutral-600 max-w-3xl">{t('transitTime.description')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="bg-brand-turquoise/5 border-brand-turquoise/30">
|
<Card className="bg-brand-blue/5 border-brand-blue/30">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<h3 className="font-semibold text-brand-navy mb-3">{t('transitTime.keyTermsTitle')}</h3>
|
<h3 className="font-semibold text-brand-navy mb-3">{t('transitTime.keyTermsTitle')}</h3>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
@ -67,11 +67,11 @@ export default async function TransitTimePage() {
|
|||||||
{timeline.map((item, index) => (
|
{timeline.map((item, index) => (
|
||||||
<Card
|
<Card
|
||||||
key={index}
|
key={index}
|
||||||
className={`bg-white ${index === 5 || index === 7 ? 'border-brand-turquoise' : ''}`}
|
className={`bg-white ${index === 5 || index === 7 ? 'border-brand-blue' : ''}`}
|
||||||
>
|
>
|
||||||
<CardContent className="py-4">
|
<CardContent className="py-4">
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<div className="flex-shrink-0 w-8 h-8 bg-brand-turquoise/10 rounded-full flex items-center justify-center text-brand-navy font-bold text-sm">
|
<div className="flex-shrink-0 w-8 h-8 bg-brand-blue/10 rounded-full flex items-center justify-center text-brand-navy font-bold text-sm">
|
||||||
{index + 1}
|
{index + 1}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
|
|||||||
@ -39,7 +39,7 @@ export default async function VGMPage() {
|
|||||||
<p className="mt-3 text-neutral-600 max-w-3xl">{t('vgm.description')}</p>
|
<p className="mt-3 text-neutral-600 max-w-3xl">{t('vgm.description')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="bg-brand-turquoise/5 border-brand-turquoise/30">
|
<Card className="bg-brand-blue/5 border-brand-blue/30">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<h3 className="font-semibold text-brand-navy mb-3">{t('vgm.whyTitle')}</h3>
|
<h3 className="font-semibold text-brand-navy mb-3">{t('vgm.whyTitle')}</h3>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-brand-navy">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-brand-navy">
|
||||||
@ -58,7 +58,7 @@ export default async function VGMPage() {
|
|||||||
<Card className="bg-white">
|
<Card className="bg-white">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<div className="bg-neutral-50 p-4 rounded-lg border mb-4 text-center font-mono text-lg">
|
<div className="bg-neutral-50 p-4 rounded-lg border mb-4 text-center font-mono text-lg">
|
||||||
<span className="bg-brand-turquoise/10 text-brand-navy px-2 py-1 rounded">
|
<span className="bg-brand-blue/10 text-brand-navy px-2 py-1 rounded">
|
||||||
{t('vgm.formula')}
|
{t('vgm.formula')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -180,7 +180,7 @@ export default function ForgotPasswordPage() {
|
|||||||
<p className="text-body-lg text-neutral-200 mb-12">{t('sidePanel.description')}</p>
|
<p className="text-body-lg text-neutral-200 mb-12">{t('sidePanel.description')}</p>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-start">
|
<div className="flex items-start">
|
||||||
<div className="flex-shrink-0 w-12 h-12 bg-brand-turquoise rounded-lg flex items-center justify-center">
|
<div className="flex-shrink-0 w-12 h-12 bg-brand-blue rounded-lg flex items-center justify-center">
|
||||||
<svg
|
<svg
|
||||||
className="w-6 h-6 text-white"
|
className="w-6 h-6 text-white"
|
||||||
fill="none"
|
fill="none"
|
||||||
@ -205,7 +205,7 @@ export default function ForgotPasswordPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-start">
|
<div className="flex items-start">
|
||||||
<div className="flex-shrink-0 w-12 h-12 bg-brand-turquoise rounded-lg flex items-center justify-center">
|
<div className="flex-shrink-0 w-12 h-12 bg-brand-blue rounded-lg flex items-center justify-center">
|
||||||
<svg
|
<svg
|
||||||
className="w-6 h-6 text-white"
|
className="w-6 h-6 text-white"
|
||||||
fill="none"
|
fill="none"
|
||||||
|
|||||||
@ -377,7 +377,7 @@ function LoginPageContent() {
|
|||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-start">
|
<div className="flex items-start">
|
||||||
<div className="flex-shrink-0 w-12 h-12 bg-brand-turquoise rounded-lg flex items-center justify-center">
|
<div className="flex-shrink-0 w-12 h-12 bg-brand-blue rounded-lg flex items-center justify-center">
|
||||||
<svg
|
<svg
|
||||||
className="w-6 h-6 text-white"
|
className="w-6 h-6 text-white"
|
||||||
fill="none"
|
fill="none"
|
||||||
@ -403,7 +403,7 @@ function LoginPageContent() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-start">
|
<div className="flex items-start">
|
||||||
<div className="flex-shrink-0 w-12 h-12 bg-brand-turquoise rounded-lg flex items-center justify-center">
|
<div className="flex-shrink-0 w-12 h-12 bg-brand-blue rounded-lg flex items-center justify-center">
|
||||||
<svg
|
<svg
|
||||||
className="w-6 h-6 text-white"
|
className="w-6 h-6 text-white"
|
||||||
fill="none"
|
fill="none"
|
||||||
@ -427,7 +427,7 @@ function LoginPageContent() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-start">
|
<div className="flex items-start">
|
||||||
<div className="flex-shrink-0 w-12 h-12 bg-brand-turquoise rounded-lg flex items-center justify-center">
|
<div className="flex-shrink-0 w-12 h-12 bg-brand-blue rounded-lg flex items-center justify-center">
|
||||||
<svg
|
<svg
|
||||||
className="w-6 h-6 text-white"
|
className="w-6 h-6 text-white"
|
||||||
fill="none"
|
fill="none"
|
||||||
@ -456,13 +456,13 @@ function LoginPageContent() {
|
|||||||
className="grid grid-cols-3 gap-8 mt-12 pt-12 border-t border-neutral-700"
|
className="grid grid-cols-3 gap-8 mt-12 pt-12 border-t border-neutral-700"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-display-sm text-brand-turquoise">
|
<div className="text-display-sm text-brand-blue">
|
||||||
<AnimatedCounter end={50} suffix="+" isActive={statsActive} />
|
<AnimatedCounter end={50} suffix="+" isActive={statsActive} />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-body-sm text-neutral-300 mt-1">{tPanel('stats.carriers')}</div>
|
<div className="text-body-sm text-neutral-300 mt-1">{tPanel('stats.carriers')}</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-display-sm text-brand-turquoise">
|
<div className="text-display-sm text-brand-blue">
|
||||||
<AnimatedCounter end={10} suffix="k+" isActive={statsActive} />
|
<AnimatedCounter end={10} suffix="k+" isActive={statsActive} />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-body-sm text-neutral-300 mt-1">
|
<div className="text-body-sm text-neutral-300 mt-1">
|
||||||
@ -470,7 +470,7 @@ function LoginPageContent() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-display-sm text-brand-turquoise">
|
<div className="text-display-sm text-brand-blue">
|
||||||
<AnimatedCounter end={99.5} decimals={1} suffix="%" isActive={statsActive} />
|
<AnimatedCounter end={99.5} decimals={1} suffix="%" isActive={statsActive} />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-body-sm text-neutral-300 mt-1">
|
<div className="text-body-sm text-neutral-300 mt-1">
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -215,7 +215,7 @@ function RegisterPageContent() {
|
|||||||
<p className="text-body-lg text-neutral-200 mb-12">{t('sidePanel.description')}</p>
|
<p className="text-body-lg text-neutral-200 mb-12">{t('sidePanel.description')}</p>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-start">
|
<div className="flex items-start">
|
||||||
<div className="flex-shrink-0 w-12 h-12 bg-brand-turquoise rounded-lg flex items-center justify-center">
|
<div className="flex-shrink-0 w-12 h-12 bg-brand-blue rounded-lg flex items-center justify-center">
|
||||||
<svg
|
<svg
|
||||||
className="w-6 h-6 text-white"
|
className="w-6 h-6 text-white"
|
||||||
fill="none"
|
fill="none"
|
||||||
@ -238,7 +238,7 @@ function RegisterPageContent() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-start">
|
<div className="flex items-start">
|
||||||
<div className="flex-shrink-0 w-12 h-12 bg-brand-turquoise rounded-lg flex items-center justify-center">
|
<div className="flex-shrink-0 w-12 h-12 bg-brand-blue rounded-lg flex items-center justify-center">
|
||||||
<svg
|
<svg
|
||||||
className="w-6 h-6 text-white"
|
className="w-6 h-6 text-white"
|
||||||
fill="none"
|
fill="none"
|
||||||
@ -263,7 +263,7 @@ function RegisterPageContent() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-start">
|
<div className="flex items-start">
|
||||||
<div className="flex-shrink-0 w-12 h-12 bg-brand-turquoise rounded-lg flex items-center justify-center">
|
<div className="flex-shrink-0 w-12 h-12 bg-brand-blue rounded-lg flex items-center justify-center">
|
||||||
<svg
|
<svg
|
||||||
className="w-6 h-6 text-white"
|
className="w-6 h-6 text-white"
|
||||||
fill="none"
|
fill="none"
|
||||||
@ -291,7 +291,7 @@ function RegisterPageContent() {
|
|||||||
className="grid grid-cols-3 gap-8 mt-12 pt-12 border-t border-neutral-700"
|
className="grid grid-cols-3 gap-8 mt-12 pt-12 border-t border-neutral-700"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-display-sm text-brand-turquoise">
|
<div className="text-display-sm text-brand-blue">
|
||||||
<AnimatedCounter end={2} suffix="k+" isActive={statsActive} />
|
<AnimatedCounter end={2} suffix="k+" isActive={statsActive} />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-body-sm text-neutral-300 mt-1">
|
<div className="text-body-sm text-neutral-300 mt-1">
|
||||||
@ -299,7 +299,7 @@ function RegisterPageContent() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-display-sm text-brand-turquoise">
|
<div className="text-display-sm text-brand-blue">
|
||||||
<AnimatedCounter end={150} suffix="+" isActive={statsActive} />
|
<AnimatedCounter end={150} suffix="+" isActive={statsActive} />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-body-sm text-neutral-300 mt-1">
|
<div className="text-body-sm text-neutral-300 mt-1">
|
||||||
@ -307,7 +307,7 @@ function RegisterPageContent() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-display-sm text-brand-turquoise">
|
<div className="text-display-sm text-brand-blue">
|
||||||
<AnimatedCounter end={24} suffix="/7" isActive={statsActive} />
|
<AnimatedCounter end={24} suffix="/7" isActive={statsActive} />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-body-sm text-neutral-300 mt-1">
|
<div className="text-body-sm text-neutral-300 mt-1">
|
||||||
|
|||||||
@ -276,7 +276,7 @@ function ResetPasswordContent() {
|
|||||||
{tips.map(tip => (
|
{tips.map(tip => (
|
||||||
<div key={tip} className="flex items-center gap-3">
|
<div key={tip} className="flex items-center gap-3">
|
||||||
<svg
|
<svg
|
||||||
className="w-5 h-5 text-brand-turquoise flex-shrink-0"
|
className="w-5 h-5 text-brand-blue flex-shrink-0"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
|
|||||||
@ -47,7 +47,7 @@ function VerifyEmailContent() {
|
|||||||
<h1 className="text-4xl font-bold text-brand-navy">Xpeditis</h1>
|
<h1 className="text-4xl font-bold text-brand-navy">Xpeditis</h1>
|
||||||
<h2 className="mt-6 text-2xl font-bold text-brand-navy">{t('verifyingTitle')}</h2>
|
<h2 className="mt-6 text-2xl font-bold text-brand-navy">{t('verifyingTitle')}</h2>
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-brand-turquoise mx-auto"></div>
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-brand-blue mx-auto"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -89,7 +89,7 @@ function VerifyEmailContent() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<Link href="/dashboard" className="font-medium text-brand-navy hover:text-brand-turquoise">
|
<Link href="/dashboard" className="font-medium text-brand-navy hover:text-brand-blue">
|
||||||
{t('goToDashboard')}
|
{t('goToDashboard')}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@ -132,7 +132,7 @@ function VerifyEmailContent() {
|
|||||||
|
|
||||||
<div className="text-center space-y-2">
|
<div className="text-center space-y-2">
|
||||||
<p className="text-sm text-neutral-600">{t('errorHint')}</p>
|
<p className="text-sm text-neutral-600">{t('errorHint')}</p>
|
||||||
<Link href="/login" className="block font-medium text-brand-navy hover:text-brand-turquoise">
|
<Link href="/login" className="block font-medium text-brand-navy hover:text-brand-blue">
|
||||||
{t('backToLogin')}
|
{t('backToLogin')}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -22,11 +22,12 @@
|
|||||||
--destructive-foreground: 210 40% 98%;
|
--destructive-foreground: 210 40% 98%;
|
||||||
--border: 214.3 31.8% 91.4%;
|
--border: 214.3 31.8% 91.4%;
|
||||||
--input: 214.3 31.8% 91.4%;
|
--input: 214.3 31.8% 91.4%;
|
||||||
/* Anneau de focus = turquoise de marque #34CCCD.
|
/* Anneau de focus = accent applicatif #2563EB.
|
||||||
Restauration de la charte : la valeur precedente etait le bleu shadcn
|
Le turquoise #34CCCD reste l'accent du site vitrine, mais a l'interieur
|
||||||
par defaut (221.2 83.2% 53.3%), qui n'appartient pas au design system
|
du produit l'action est portee par le bleu : c'est la couleur d'origine
|
||||||
Xpeditis. Voir docs/design-system-audit.md §1.3. */
|
du dashboard, et elle tient le contraste que le turquoise n'atteint pas
|
||||||
--ring: 180 60.5% 50.4%;
|
sur fond clair (4,55:1 contre 2,05:1). */
|
||||||
|
--ring: 221.2 83.2% 53.3%;
|
||||||
--radius: 0.5rem;
|
--radius: 0.5rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -72,14 +73,13 @@
|
|||||||
Elles seront retirees quand ces pages passeront aux primitives de
|
Elles seront retirees quand ces pages passeront aux primitives de
|
||||||
src/components/ui. Ne pas les supprimer avant : voir docs/ui-audit.md §D6. */
|
src/components/ui. Ne pas les supprimer avant : voir docs/ui-audit.md §D6. */
|
||||||
|
|
||||||
/* Texte navy sur turquoise : 8,49:1 (AAA). Le blanc sur turquoise ne donne
|
/* Bouton d'action : navy plein, texte blanc (14,2:1, AAA). */
|
||||||
que 2,05:1 et echoue AA. Voir docs/ui-architecture.md §17. */
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
@apply bg-accent text-brand-navy font-heading font-semibold px-6 py-3 rounded-lg hover:bg-accent/90 transition-colors shadow-sm hover:shadow;
|
@apply bg-brand-navy text-white font-heading font-semibold px-6 py-3 rounded-lg hover:bg-brand-navy/90 transition-colors shadow-sm hover:shadow;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
@apply bg-primary text-white font-heading font-semibold px-6 py-3 rounded-lg hover:bg-neutral-800 transition-colors shadow-sm hover:shadow;
|
@apply bg-brand-navy text-white font-heading font-semibold px-6 py-3 rounded-lg hover:bg-brand-navy/90 transition-colors shadow-sm hover:shadow;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-success {
|
.btn-success {
|
||||||
@ -108,10 +108,8 @@
|
|||||||
@apply badge bg-success/10 text-success;
|
@apply badge bg-success/10 text-success;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Le turquoise en texte sur fond clair echoue AA : le libelle passe en navy,
|
|
||||||
le turquoise reste sur le fond. */
|
|
||||||
.badge-info {
|
.badge-info {
|
||||||
@apply badge bg-accent/10 text-brand-navy;
|
@apply badge bg-brand-blue-soft text-brand-blue-dark;
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge-warning {
|
.badge-warning {
|
||||||
@ -127,12 +125,12 @@
|
|||||||
/* Souligne en permanence : un lien identifie par la seule couleur echoue au
|
/* Souligne en permanence : un lien identifie par la seule couleur echoue au
|
||||||
critere WCAG 1.4.1. Le navy assure par ailleurs le contraste que le
|
critere WCAG 1.4.1. Le navy assure par ailleurs le contraste que le
|
||||||
turquoise ne tient pas sur fond clair. */
|
turquoise ne tient pas sur fond clair. */
|
||||||
@apply text-brand-navy underline underline-offset-2 transition-colors hover:text-accent;
|
@apply text-brand-blue underline underline-offset-2 transition-colors hover:text-brand-blue-dark;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Form elements */
|
/* Form elements */
|
||||||
.input {
|
.input {
|
||||||
@apply border border-neutral-300 rounded-lg px-4 py-2.5 font-body text-body focus:outline-none focus:ring-2 focus:ring-accent focus:border-transparent transition-colors;
|
@apply border border-neutral-300 rounded-lg px-4 py-2.5 font-body text-body focus:outline-none focus:ring-2 focus:ring-ring focus:border-transparent transition-colors;
|
||||||
}
|
}
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
|
|||||||
@ -157,7 +157,20 @@
|
|||||||
"subtitle": "Compare rates from 50+ shipping lines, book online and track your shipments in real time.",
|
"subtitle": "Compare rates from 50+ shipping lines, book online and track your shipments in real time.",
|
||||||
"ctaAuthenticated": "Go to dashboard",
|
"ctaAuthenticated": "Go to dashboard",
|
||||||
"ctaRegister": "Create a free account",
|
"ctaRegister": "Create a free account",
|
||||||
"ctaDemo": "See the demo"
|
"ctaDemo": "See the demo",
|
||||||
|
"demo": {
|
||||||
|
"ariaLabel": "Demo: ocean rate search from {origin} to {destination}, three offers compared",
|
||||||
|
"label": "Rate search",
|
||||||
|
"live": "Live",
|
||||||
|
"origin": "Origin",
|
||||||
|
"destination": "Destination",
|
||||||
|
"load": "LCL · 2.4 CBM · 850 kg · 1 pallet",
|
||||||
|
"transit": "{days} days transit",
|
||||||
|
"best": "Best price",
|
||||||
|
"searching": "Querying carriers...",
|
||||||
|
"resultCount": "{count} comparable offers",
|
||||||
|
"elapsed": "1.8 s"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"stats": {
|
"stats": {
|
||||||
"carriers": "Shipping Lines",
|
"carriers": "Shipping Lines",
|
||||||
@ -359,7 +372,12 @@
|
|||||||
"pending": "Pending",
|
"pending": "Pending",
|
||||||
"totalWeight": "Total Weight",
|
"totalWeight": "Total Weight",
|
||||||
"thisMonth": "+{count} this month",
|
"thisMonth": "+{count} this month",
|
||||||
"acceptanceRate": "{rate}% accepted"
|
"acceptanceRate": "{rate}% accepted",
|
||||||
|
"bookingsThisMonth": "Bookings this month",
|
||||||
|
"revenue": "Commission earned",
|
||||||
|
"pendingConfirmations": "Awaiting confirmation",
|
||||||
|
"vsLastMonth": "vs last month",
|
||||||
|
"volume": "Volume shipped"
|
||||||
},
|
},
|
||||||
"charts": {
|
"charts": {
|
||||||
"distribution": {
|
"distribution": {
|
||||||
@ -373,6 +391,23 @@
|
|||||||
"title": "Weight by Carrier",
|
"title": "Weight by Carrier",
|
||||||
"description": "Top 5 carriers by weight (KG)",
|
"description": "Top 5 carriers by weight (KG)",
|
||||||
"weight": "Weight"
|
"weight": "Weight"
|
||||||
|
},
|
||||||
|
"volume": {
|
||||||
|
"title": "Booking volume",
|
||||||
|
"description": "Last six months",
|
||||||
|
"series": "Bookings",
|
||||||
|
"empty": "Not enough history yet"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"title": "Request tracking",
|
||||||
|
"description": "Carrier responses",
|
||||||
|
"rateLabel": "Acceptance rate"
|
||||||
|
},
|
||||||
|
"lanes": {
|
||||||
|
"title": "Busiest trade lanes",
|
||||||
|
"description": "Top 5 by booking count",
|
||||||
|
"series": "Bookings",
|
||||||
|
"empty": "No trade lane recorded"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"performance": {
|
"performance": {
|
||||||
@ -400,6 +435,25 @@
|
|||||||
"totalVolume": "Total Volume (CBM)",
|
"totalVolume": "Total Volume (CBM)",
|
||||||
"acceptanceRate": "Acceptance rate (%)",
|
"acceptanceRate": "Acceptance rate (%)",
|
||||||
"avgPrice": "Average price ($)"
|
"avgPrice": "Average price ($)"
|
||||||
|
},
|
||||||
|
"alerts": {
|
||||||
|
"title": "Needs attention",
|
||||||
|
"description": "{count, plural, =1 {# item needs your attention} other {# items need your attention}}",
|
||||||
|
"severity": {
|
||||||
|
"critical": "Critical",
|
||||||
|
"high": "High",
|
||||||
|
"medium": "Medium",
|
||||||
|
"low": "Low"
|
||||||
|
},
|
||||||
|
"empty": "Nothing to report",
|
||||||
|
"items": {
|
||||||
|
"awaitingCarrier": "No carrier response",
|
||||||
|
"awaitingCarrierSince": "Waiting {days} days on {carrier}",
|
||||||
|
"awaitingPayment": "Payment pending",
|
||||||
|
"awaitingPaymentSince": "Blocked for {days} days awaiting payment",
|
||||||
|
"rejected": "Rejected by carrier",
|
||||||
|
"rejectedBy": "{carrier} declined the request — needs rebooking"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"bookingsList": {
|
"bookingsList": {
|
||||||
|
|||||||
@ -157,7 +157,20 @@
|
|||||||
"subtitle": "Comparez les tarifs de 50+ compagnies maritimes, réservez en ligne et suivez vos envois en temps réel.",
|
"subtitle": "Comparez les tarifs de 50+ compagnies maritimes, réservez en ligne et suivez vos envois en temps réel.",
|
||||||
"ctaAuthenticated": "Accéder au tableau de bord",
|
"ctaAuthenticated": "Accéder au tableau de bord",
|
||||||
"ctaRegister": "Créer un compte gratuit",
|
"ctaRegister": "Créer un compte gratuit",
|
||||||
"ctaDemo": "Voir la démo"
|
"ctaDemo": "Voir la démo",
|
||||||
|
"demo": {
|
||||||
|
"ariaLabel": "Démonstration : recherche de tarif maritime de {origin} vers {destination}, trois offres comparées",
|
||||||
|
"label": "Recherche de tarif",
|
||||||
|
"live": "En direct",
|
||||||
|
"origin": "Départ",
|
||||||
|
"destination": "Arrivée",
|
||||||
|
"load": "LCL · 2,4 CBM · 850 kg · 1 palette",
|
||||||
|
"transit": "{days} jours de transit",
|
||||||
|
"best": "Meilleur prix",
|
||||||
|
"searching": "Interrogation des compagnies...",
|
||||||
|
"resultCount": "{count} offres comparables",
|
||||||
|
"elapsed": "1,8 s"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"stats": {
|
"stats": {
|
||||||
"carriers": "Compagnies Maritimes",
|
"carriers": "Compagnies Maritimes",
|
||||||
@ -359,7 +372,12 @@
|
|||||||
"pending": "En Attente",
|
"pending": "En Attente",
|
||||||
"totalWeight": "Poids Total",
|
"totalWeight": "Poids Total",
|
||||||
"thisMonth": "+{count} ce mois",
|
"thisMonth": "+{count} ce mois",
|
||||||
"acceptanceRate": "{rate}% acceptés"
|
"acceptanceRate": "{rate}% acceptés",
|
||||||
|
"bookingsThisMonth": "Réservations ce mois",
|
||||||
|
"revenue": "Commissions encaissées",
|
||||||
|
"pendingConfirmations": "En attente de confirmation",
|
||||||
|
"vsLastMonth": "vs mois dernier",
|
||||||
|
"volume": "Volume expédié"
|
||||||
},
|
},
|
||||||
"charts": {
|
"charts": {
|
||||||
"distribution": {
|
"distribution": {
|
||||||
@ -373,6 +391,23 @@
|
|||||||
"title": "Poids par Transporteur",
|
"title": "Poids par Transporteur",
|
||||||
"description": "Top 5 transporteurs par poids (KG)",
|
"description": "Top 5 transporteurs par poids (KG)",
|
||||||
"weight": "Poids"
|
"weight": "Poids"
|
||||||
|
},
|
||||||
|
"volume": {
|
||||||
|
"title": "Volume de réservations",
|
||||||
|
"description": "Six derniers mois",
|
||||||
|
"series": "Réservations",
|
||||||
|
"empty": "Pas encore assez d’historique"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"title": "Suivi des demandes",
|
||||||
|
"description": "Réponses des transporteurs",
|
||||||
|
"rateLabel": "Taux d’acceptation"
|
||||||
|
},
|
||||||
|
"lanes": {
|
||||||
|
"title": "Routes les plus utilisées",
|
||||||
|
"description": "Top 5 par nombre de réservations",
|
||||||
|
"series": "Réservations",
|
||||||
|
"empty": "Aucune route enregistrée"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"performance": {
|
"performance": {
|
||||||
@ -400,6 +435,25 @@
|
|||||||
"totalVolume": "Volume Total (CBM)",
|
"totalVolume": "Volume Total (CBM)",
|
||||||
"acceptanceRate": "Taux d'acceptation (%)",
|
"acceptanceRate": "Taux d'acceptation (%)",
|
||||||
"avgPrice": "Prix moyen ($)"
|
"avgPrice": "Prix moyen ($)"
|
||||||
|
},
|
||||||
|
"alerts": {
|
||||||
|
"title": "À traiter",
|
||||||
|
"description": "{count, plural, =1 {# point demande votre attention} other {# points demandent votre attention}}",
|
||||||
|
"severity": {
|
||||||
|
"critical": "Critique",
|
||||||
|
"high": "Élevé",
|
||||||
|
"medium": "Moyen",
|
||||||
|
"low": "Faible"
|
||||||
|
},
|
||||||
|
"empty": "Rien à signaler",
|
||||||
|
"items": {
|
||||||
|
"awaitingCarrier": "Sans réponse du transporteur",
|
||||||
|
"awaitingCarrierSince": "En attente depuis {days} jours chez {carrier}",
|
||||||
|
"awaitingPayment": "Paiement en attente",
|
||||||
|
"awaitingPaymentSince": "Bloquée depuis {days} jours faute de règlement",
|
||||||
|
"rejected": "Refusée par le transporteur",
|
||||||
|
"rejectedBy": "{carrier} a refusé la demande — à replacer"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"bookingsList": {
|
"bookingsList": {
|
||||||
|
|||||||
@ -57,7 +57,7 @@ export default function CookieConsent() {
|
|||||||
exit={{ scale: 0, opacity: 0 }}
|
exit={{ scale: 0, opacity: 0 }}
|
||||||
transition={{ type: 'spring', stiffness: 260, damping: 20 }}
|
transition={{ type: 'spring', stiffness: 260, damping: 20 }}
|
||||||
onClick={openPreferences}
|
onClick={openPreferences}
|
||||||
className={`fixed left-4 z-40 p-3 bg-brand-navy text-white rounded-full shadow-lg hover:bg-brand-navy/90 focus:outline-none focus:ring-2 focus:ring-brand-turquoise focus:ring-offset-2 transition-colors ${mobileButtonOffset}`}
|
className={`fixed right-4 z-40 p-3 bg-brand-navy text-white rounded-full shadow-lg hover:bg-brand-navy/90 focus:outline-none focus:ring-2 focus:ring-brand-turquoise focus:ring-offset-2 transition-colors ${mobileButtonOffset}`}
|
||||||
aria-label={t('openAria')}
|
aria-label={t('openAria')}
|
||||||
>
|
>
|
||||||
<Cookie className="w-5 h-5" />
|
<Cookie className="w-5 h-5" />
|
||||||
|
|||||||
@ -41,7 +41,7 @@ export default function DebugUser() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-yellow-400">localStorage user:</span>
|
<span className="text-yellow-400">localStorage user:</span>
|
||||||
<pre className="mt-1 text-brand-turquoise whitespace-pre-wrap break-words">
|
<pre className="mt-1 text-brand-blue whitespace-pre-wrap break-words">
|
||||||
{storageData.user}
|
{storageData.user}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -177,8 +177,10 @@ export default function ExportButton<T extends Record<string, any>>({
|
|||||||
onClick={() => (canExport ? setIsOpen(!isOpen) : (window.location.href = '/pricing'))}
|
onClick={() => (canExport ? setIsOpen(!isOpen) : (window.location.href = '/pricing'))}
|
||||||
disabled={disabled || data.length === 0 || isExporting}
|
disabled={disabled || data.length === 0 || isExporting}
|
||||||
title={!canExport ? t('lockedTooltip') : undefined}
|
title={!canExport ? t('lockedTooltip') : undefined}
|
||||||
className={`inline-flex items-center px-4 py-2 border border-neutral-300 text-sm font-medium rounded-md bg-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-ring disabled:opacity-50 disabled:cursor-not-allowed transition-colors ${
|
// Action secondaire : meme gabarit que la variante `outline` de Button,
|
||||||
canExport ? 'text-neutral-700 hover:bg-neutral-50' : 'text-neutral-400 hover:bg-neutral-50'
|
// avec laquelle il se cotoie dans les en-tetes de page.
|
||||||
|
className={`inline-flex h-10 items-center rounded-lg border border-border bg-white px-6 text-body-sm font-medium transition-colors hover:bg-neutral-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 ${
|
||||||
|
canExport ? 'text-brand-navy' : 'text-neutral-400'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{isExporting ? (
|
{isExporting ? (
|
||||||
|
|||||||
@ -149,7 +149,7 @@ export default function NotificationDropdown() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => markAllAsReadMutation.mutate()}
|
onClick={() => markAllAsReadMutation.mutate()}
|
||||||
disabled={markAllAsReadMutation.isPending}
|
disabled={markAllAsReadMutation.isPending}
|
||||||
className="text-xs text-brand-navy hover:text-brand-turquoise font-medium disabled:opacity-50"
|
className="text-xs text-brand-navy hover:text-brand-blue font-medium disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{t('markAllRead')}
|
{t('markAllRead')}
|
||||||
</button>
|
</button>
|
||||||
@ -173,7 +173,7 @@ export default function NotificationDropdown() {
|
|||||||
key={notification.id}
|
key={notification.id}
|
||||||
onClick={() => handleNotificationClick(notification)}
|
onClick={() => handleNotificationClick(notification)}
|
||||||
className={`block px-4 py-3 hover:bg-neutral-50 transition-colors cursor-pointer ${
|
className={`block px-4 py-3 hover:bg-neutral-50 transition-colors cursor-pointer ${
|
||||||
!notification.read ? 'bg-brand-turquoise/5' : ''
|
!notification.read ? 'bg-brand-blue/5' : ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start space-x-3">
|
<div className="flex items-start space-x-3">
|
||||||
@ -189,7 +189,7 @@ export default function NotificationDropdown() {
|
|||||||
{notification.title}
|
{notification.title}
|
||||||
</p>
|
</p>
|
||||||
{!notification.read && (
|
{!notification.read && (
|
||||||
<span className="w-2 h-2 bg-brand-turquoise rounded-full"></span>
|
<span className="w-2 h-2 bg-brand-blue rounded-full"></span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-neutral-600 line-clamp-2">
|
<p className="text-sm text-neutral-600 line-clamp-2">
|
||||||
@ -216,7 +216,7 @@ export default function NotificationDropdown() {
|
|||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
setIsPanelOpen(true);
|
setIsPanelOpen(true);
|
||||||
}}
|
}}
|
||||||
className="w-full text-center text-sm text-brand-navy hover:text-brand-turquoise font-medium py-2 hover:bg-brand-turquoise/5 rounded transition-colors"
|
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')}
|
{t('viewAll')}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -111,7 +111,7 @@ export default function NotificationPanel({ isOpen, onClose }: NotificationPanel
|
|||||||
urgent: 'border-l-4 border-red-500 bg-red-50',
|
urgent: 'border-l-4 border-red-500 bg-red-50',
|
||||||
high: 'border-l-4 border-orange-500 bg-orange-50',
|
high: 'border-l-4 border-orange-500 bg-orange-50',
|
||||||
medium: 'border-l-4 border-yellow-500 bg-yellow-50',
|
medium: 'border-l-4 border-yellow-500 bg-yellow-50',
|
||||||
low: 'border-l-4 border-brand-turquoise bg-brand-turquoise/5',
|
low: 'border-l-4 border-brand-blue bg-brand-blue/5',
|
||||||
};
|
};
|
||||||
return colors[priority as keyof typeof colors] || 'border-l-4 border-neutral-300';
|
return colors[priority as keyof typeof colors] || 'border-l-4 border-neutral-300';
|
||||||
};
|
};
|
||||||
@ -191,7 +191,7 @@ export default function NotificationPanel({ isOpen, onClose }: NotificationPanel
|
|||||||
}}
|
}}
|
||||||
className={`px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
|
className={`px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
|
||||||
selectedFilter === filter
|
selectedFilter === filter
|
||||||
? 'bg-brand-turquoise text-brand-navy'
|
? 'bg-brand-navy text-white'
|
||||||
: 'bg-white text-neutral-700 hover:bg-neutral-100 border border-neutral-300'
|
: 'bg-white text-neutral-700 hover:bg-neutral-100 border border-neutral-300'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@ -204,7 +204,7 @@ export default function NotificationPanel({ isOpen, onClose }: NotificationPanel
|
|||||||
<button
|
<button
|
||||||
onClick={() => markAllAsReadMutation.mutate()}
|
onClick={() => markAllAsReadMutation.mutate()}
|
||||||
disabled={markAllAsReadMutation.isPending}
|
disabled={markAllAsReadMutation.isPending}
|
||||||
className="flex items-center space-x-1 text-sm text-brand-navy hover:text-brand-turquoise font-medium disabled:opacity-50"
|
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" />
|
<CheckCheck className="w-4 h-4" />
|
||||||
<span>{t('markAllRead')}</span>
|
<span>{t('markAllRead')}</span>
|
||||||
@ -217,7 +217,7 @@ export default function NotificationPanel({ isOpen, onClose }: NotificationPanel
|
|||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center justify-center h-64">
|
<div className="flex items-center justify-center h-64">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-brand-turquoise mx-auto mb-4" />
|
<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>
|
<p className="text-sm text-neutral-500">{t('loading')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -238,7 +238,7 @@ export default function NotificationPanel({ isOpen, onClose }: NotificationPanel
|
|||||||
key={notification.id}
|
key={notification.id}
|
||||||
onClick={() => handleNotificationClick(notification)}
|
onClick={() => handleNotificationClick(notification)}
|
||||||
className={`p-6 hover:bg-neutral-50 transition-all cursor-pointer group ${
|
className={`p-6 hover:bg-neutral-50 transition-all cursor-pointer group ${
|
||||||
!notification.read ? 'bg-brand-turquoise/5/50' : ''
|
!notification.read ? 'bg-brand-blue/5/50' : ''
|
||||||
} ${getPriorityColor(notification.priority || 'low')}`}
|
} ${getPriorityColor(notification.priority || 'low')}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start space-x-4">
|
<div className="flex items-start space-x-4">
|
||||||
@ -258,7 +258,7 @@ export default function NotificationPanel({ isOpen, onClose }: NotificationPanel
|
|||||||
{notification.title}
|
{notification.title}
|
||||||
</h3>
|
</h3>
|
||||||
{!notification.read && (
|
{!notification.read && (
|
||||||
<span className="w-2.5 h-2.5 bg-brand-turquoise rounded-full" />
|
<span className="w-2.5 h-2.5 bg-brand-blue rounded-full" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@ -290,7 +290,7 @@ export default function NotificationPanel({ isOpen, onClose }: NotificationPanel
|
|||||||
? 'bg-orange-100 text-orange-700'
|
? 'bg-orange-100 text-orange-700'
|
||||||
: notification.priority === 'medium'
|
: notification.priority === 'medium'
|
||||||
? 'bg-yellow-100 text-yellow-700'
|
? 'bg-yellow-100 text-yellow-700'
|
||||||
: 'bg-brand-turquoise/10 text-brand-navy'
|
: 'bg-brand-blue/10 text-brand-navy'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{notification.priority.toUpperCase()}
|
{notification.priority.toUpperCase()}
|
||||||
|
|||||||
@ -192,7 +192,7 @@ export const CarrierForm: React.FC<CarrierFormProps> = ({ carrier, onSubmit, onC
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
className="px-4 py-2 bg-brand-turquoise text-brand-navy rounded-md text-sm font-medium hover:bg-brand-turquoise/90 disabled:opacity-50"
|
className="px-4 py-2 bg-brand-navy text-white rounded-md text-sm font-medium hover:bg-brand-navy/90 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{submitting ? 'Saving...' : carrier ? 'Update Carrier' : 'Create Carrier'}
|
{submitting ? 'Saving...' : carrier ? 'Update Carrier' : 'Create Carrier'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -168,7 +168,7 @@ export function CsvUpload() {
|
|||||||
disabled={loading}
|
disabled={loading}
|
||||||
className={`px-4 py-2 text-sm rounded-md border transition-colors disabled:opacity-50 ${
|
className={`px-4 py-2 text-sm rounded-md border transition-colors disabled:opacity-50 ${
|
||||||
direction === option.value
|
direction === option.value
|
||||||
? 'border-brand-turquoise bg-brand-turquoise/5 text-brand-navy font-medium'
|
? 'border-brand-blue bg-brand-blue/5 text-brand-navy font-medium'
|
||||||
: 'border-neutral-300 hover:bg-neutral-50'
|
: 'border-neutral-300 hover:bg-neutral-50'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -307,7 +307,7 @@ export function RichTextEditor({
|
|||||||
Image.configure({ inline: false, allowBase64: false }),
|
Image.configure({ inline: false, allowBase64: false }),
|
||||||
Link.configure({
|
Link.configure({
|
||||||
openOnClick: false,
|
openOnClick: false,
|
||||||
HTMLAttributes: { class: 'text-brand-turquoise underline' },
|
HTMLAttributes: { class: 'text-brand-blue underline' },
|
||||||
}),
|
}),
|
||||||
Placeholder.configure({ placeholder }),
|
Placeholder.configure({ placeholder }),
|
||||||
Underline,
|
Underline,
|
||||||
|
|||||||
@ -43,7 +43,7 @@ export const BookingFilters: React.FC<BookingFiltersProps> = ({
|
|||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsExpanded(!isExpanded)}
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
className="text-sm text-brand-navy hover:text-brand-turquoise"
|
className="text-sm text-brand-navy hover:text-brand-blue"
|
||||||
>
|
>
|
||||||
{isExpanded ? t('showLess') : t('showMore')}
|
{isExpanded ? t('showLess') : t('showMore')}
|
||||||
</button>
|
</button>
|
||||||
@ -102,7 +102,7 @@ export const BookingFilters: React.FC<BookingFiltersProps> = ({
|
|||||||
onClick={() => handleStatusChange(status)}
|
onClick={() => handleStatusChange(status)}
|
||||||
className={`px-3 py-1 rounded-full text-sm font-medium transition-colors ${
|
className={`px-3 py-1 rounded-full text-sm font-medium transition-colors ${
|
||||||
filters.status?.includes(status)
|
filters.status?.includes(status)
|
||||||
? 'bg-brand-turquoise text-brand-navy'
|
? 'bg-brand-navy text-white'
|
||||||
: 'bg-neutral-100 text-neutral-700 hover:bg-neutral-200'
|
: 'bg-neutral-100 text-neutral-700 hover:bg-neutral-200'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -232,7 +232,7 @@ export const BookingsTable: React.FC<BookingsTableProps> = ({
|
|||||||
key={row.id}
|
key={row.id}
|
||||||
onClick={() => onRowClick?.(row.original)}
|
onClick={() => onRowClick?.(row.original)}
|
||||||
className={`hover:bg-neutral-50 cursor-pointer ${
|
className={`hover:bg-neutral-50 cursor-pointer ${
|
||||||
selectedBookings.has(row.original.id) ? 'bg-brand-turquoise/5' : ''
|
selectedBookings.has(row.original.id) ? 'bg-brand-blue/5' : ''
|
||||||
}`}
|
}`}
|
||||||
style={{ height: `${virtualRow.size}px` }}
|
style={{ height: `${virtualRow.size}px` }}
|
||||||
>
|
>
|
||||||
@ -265,7 +265,7 @@ function getStatusColor(status: BookingStatus): string {
|
|||||||
case BookingStatus.DRAFT:
|
case BookingStatus.DRAFT:
|
||||||
return 'bg-neutral-100 text-neutral-800';
|
return 'bg-neutral-100 text-neutral-800';
|
||||||
case BookingStatus.CONFIRMED:
|
case BookingStatus.CONFIRMED:
|
||||||
return 'bg-brand-turquoise/10 text-brand-navy';
|
return 'bg-brand-blue/10 text-brand-navy';
|
||||||
case BookingStatus.IN_PROGRESS:
|
case BookingStatus.IN_PROGRESS:
|
||||||
return 'bg-yellow-100 text-yellow-800';
|
return 'bg-yellow-100 text-yellow-800';
|
||||||
case BookingStatus.COMPLETED:
|
case BookingStatus.COMPLETED:
|
||||||
|
|||||||
@ -40,7 +40,7 @@ export const BulkActions: React.FC<BulkActionsProps> = ({
|
|||||||
if (selectedCount === 0) return null;
|
if (selectedCount === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-brand-turquoise/5 border border-brand-turquoise/30 rounded-lg p-4 mb-6">
|
<div className="bg-brand-blue/5 border border-brand-blue/30 rounded-lg p-4 mb-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<span className="text-sm font-medium text-brand-navy">
|
<span className="text-sm font-medium text-brand-navy">
|
||||||
@ -48,7 +48,7 @@ export const BulkActions: React.FC<BulkActionsProps> = ({
|
|||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={onClearSelection}
|
onClick={onClearSelection}
|
||||||
className="text-sm text-brand-navy hover:text-brand-turquoise underline"
|
className="text-sm text-brand-navy hover:text-brand-blue underline"
|
||||||
>
|
>
|
||||||
{t('clearSelection')}
|
{t('clearSelection')}
|
||||||
</button>
|
</button>
|
||||||
@ -60,7 +60,7 @@ export const BulkActions: React.FC<BulkActionsProps> = ({
|
|||||||
<button
|
<button
|
||||||
onClick={() => setShowExportMenu(!showExportMenu)}
|
onClick={() => setShowExportMenu(!showExportMenu)}
|
||||||
disabled={exporting}
|
disabled={exporting}
|
||||||
className="px-4 py-2 bg-white border border-neutral-300 rounded-md text-sm font-medium text-neutral-700 hover:bg-neutral-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
className="px-6 py-2 bg-white border border-border rounded-lg text-sm font-medium text-brand-navy hover:bg-neutral-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
>
|
>
|
||||||
{exporting ? t('exporting') : t('exportSelected')}
|
{exporting ? t('exporting') : t('exportSelected')}
|
||||||
</button>
|
</button>
|
||||||
@ -93,7 +93,7 @@ export const BulkActions: React.FC<BulkActionsProps> = ({
|
|||||||
|
|
||||||
{/* Bulk update button */}
|
{/* Bulk update button */}
|
||||||
<button
|
<button
|
||||||
className="px-4 py-2 bg-brand-turquoise text-brand-navy rounded-md text-sm font-medium hover:bg-brand-turquoise/90"
|
className="px-4 py-2 bg-brand-navy text-white rounded-md text-sm font-medium hover:bg-brand-navy/90"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
toast.error(t('bulkUpdateSoon'));
|
toast.error(t('bulkUpdateSoon'));
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -182,9 +182,9 @@ export function DataTable<TData>({
|
|||||||
>
|
>
|
||||||
{flexRender(header.column.columnDef.header, header.getContext())}
|
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
{sorted === 'asc' ? (
|
{sorted === 'asc' ? (
|
||||||
<ArrowUp className="size-3 text-brand-turquoise" />
|
<ArrowUp className="size-3 text-brand-blue" />
|
||||||
) : sorted === 'desc' ? (
|
) : sorted === 'desc' ? (
|
||||||
<ArrowDown className="size-3 text-brand-turquoise" />
|
<ArrowDown className="size-3 text-brand-blue" />
|
||||||
) : (
|
) : (
|
||||||
<ChevronsUpDown className="size-3 text-neutral-300" />
|
<ChevronsUpDown className="size-3 text-neutral-300" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -65,7 +65,7 @@ export function FilterBar({
|
|||||||
<SlidersHorizontal />
|
<SlidersHorizontal />
|
||||||
{labels.filters}
|
{labels.filters}
|
||||||
{active.length > 0 && (
|
{active.length > 0 && (
|
||||||
<span className="ml-1 inline-flex size-4 items-center justify-center rounded-full bg-brand-turquoise text-[10px] font-semibold text-white">
|
<span className="ml-1 inline-flex size-4 items-center justify-center rounded-full bg-brand-blue text-[10px] font-semibold text-white">
|
||||||
{active.length}
|
{active.length}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -23,7 +23,7 @@ export function DesignSystemShowcase() {
|
|||||||
|
|
||||||
{/* Turquoise */}
|
{/* Turquoise */}
|
||||||
<div>
|
<div>
|
||||||
<div className="bg-brand-turquoise h-32 rounded-lg shadow-lg mb-3"></div>
|
<div className="bg-brand-blue h-32 rounded-lg shadow-lg mb-3"></div>
|
||||||
<h4 className="mb-1">Turquoise</h4>
|
<h4 className="mb-1">Turquoise</h4>
|
||||||
<p className="text-body-sm text-neutral-600">#34CCCD</p>
|
<p className="text-body-sm text-neutral-600">#34CCCD</p>
|
||||||
<p className="text-body-xs text-neutral-500">Accent color for CTAs, links</p>
|
<p className="text-body-xs text-neutral-500">Accent color for CTAs, links</p>
|
||||||
@ -150,7 +150,7 @@ export function DesignSystemShowcase() {
|
|||||||
<div className="mt-8 p-6 bg-brand-navy rounded-lg">
|
<div className="mt-8 p-6 bg-brand-navy rounded-lg">
|
||||||
<h4 className="text-white mb-4">On Dark Background</h4>
|
<h4 className="text-white mb-4">On Dark Background</h4>
|
||||||
<div className="flex flex-wrap gap-4">
|
<div className="flex flex-wrap gap-4">
|
||||||
<button className="bg-brand-turquoise text-white font-heading font-semibold px-6 py-3 rounded-lg hover:bg-brand-turquoise/90 transition-colors">
|
<button className="bg-brand-navy text-white font-heading font-semibold px-6 py-3 rounded-lg hover:bg-brand-navy/90 transition-colors">
|
||||||
Primary on Dark
|
Primary on Dark
|
||||||
</button>
|
</button>
|
||||||
<button className="bg-white text-brand-navy font-heading font-semibold px-6 py-3 rounded-lg hover:bg-neutral-100 transition-colors">
|
<button className="bg-white text-brand-navy font-heading font-semibold px-6 py-3 rounded-lg hover:bg-neutral-100 transition-colors">
|
||||||
@ -196,7 +196,7 @@ export function DesignSystemShowcase() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="label">TOTAL PRICE</span>
|
<span className="label">TOTAL PRICE</span>
|
||||||
<p className="text-h3 text-brand-turquoise mt-1">$1,245 USD</p>
|
<p className="text-h3 text-brand-blue mt-1">$1,245 USD</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="badge-info">Best Rate</span>
|
<span className="badge-info">Best Rate</span>
|
||||||
@ -275,7 +275,7 @@ export function DesignSystemShowcase() {
|
|||||||
needs.
|
needs.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-4 justify-center">
|
<div className="flex gap-4 justify-center">
|
||||||
<button className="bg-brand-turquoise text-white font-heading font-semibold px-8 py-4 rounded-lg hover:bg-brand-turquoise/90 transition-colors text-lg">
|
<button className="bg-brand-navy text-white font-heading font-semibold px-8 py-4 rounded-lg hover:bg-brand-navy/90 transition-colors text-lg">
|
||||||
Start Free Trial
|
Start Free Trial
|
||||||
</button>
|
</button>
|
||||||
<button className="bg-white text-brand-navy font-heading font-semibold px-8 py-4 rounded-lg hover:bg-neutral-100 transition-colors text-lg">
|
<button className="bg-white text-brand-navy font-heading font-semibold px-8 py-4 rounded-lg hover:bg-neutral-100 transition-colors text-lg">
|
||||||
|
|||||||
@ -73,13 +73,13 @@ export function FileDropzone({
|
|||||||
'flex cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border border-dashed px-4 py-8 text-center transition-colors',
|
'flex cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border border-dashed px-4 py-8 text-center transition-colors',
|
||||||
'focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2',
|
'focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2',
|
||||||
dragging
|
dragging
|
||||||
? 'border-brand-turquoise bg-brand-turquoise/5'
|
? 'border-brand-blue bg-brand-blue/5'
|
||||||
: 'border-neutral-300 hover:border-brand-turquoise hover:bg-neutral-50',
|
: 'border-neutral-300 hover:border-brand-blue hover:bg-neutral-50',
|
||||||
disabled && 'pointer-events-none opacity-50'
|
disabled && 'pointer-events-none opacity-50'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Upload
|
<Upload
|
||||||
className={cn('size-5', dragging ? 'text-brand-turquoise' : 'text-neutral-400')}
|
className={cn('size-5', dragging ? 'text-brand-blue' : 'text-neutral-400')}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<span className="text-body-sm text-neutral-700">
|
<span className="text-body-sm text-neutral-700">
|
||||||
|
|||||||
280
apps/frontend/src/components/landing/RateSearchDemo.tsx
Normal file
280
apps/frontend/src/components/landing/RateSearchDemo.tsx
Normal file
@ -0,0 +1,280 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion';
|
||||||
|
import { ArrowRight, Check, Ship } from 'lucide-react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
|
import { EASE } from './motion';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Demonstration jouee en boucle de la recherche de tarif.
|
||||||
|
*
|
||||||
|
* C'est la promesse d'Xpeditis montree plutot que decrite : deux ports, une
|
||||||
|
* recherche, des offres comparables en moins de deux secondes — la ou le
|
||||||
|
* transitaire attend aujourd'hui plusieurs jours et cinq formats de devis
|
||||||
|
* differents. Aucune donnee reelle n'est appelee : la sequence est scriptee.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ORIGIN = { code: 'FRLEH', city: 'Le Havre' };
|
||||||
|
const DESTINATION = { code: 'CNSHA', city: 'Shanghai' };
|
||||||
|
|
||||||
|
/** Transporteurs repris du bandeau partenaires, tarifs indicatifs. */
|
||||||
|
const OFFERS = [
|
||||||
|
{ carrier: 'ICL', days: 26, price: 1390 },
|
||||||
|
{ carrier: 'ECU Line', days: 28, price: 1240 },
|
||||||
|
{ carrier: 'Vanguard', days: 31, price: 1180, best: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Duree de chaque etape de la sequence, en millisecondes.
|
||||||
|
* 0 remise a zero · 1 depart saisi · 2 arrivee saisie · 3 recherche ·
|
||||||
|
* 4 resultats. L'etape 0 est breve mais necessaire : sans elle, la reprise de
|
||||||
|
* boucle effacait l'arrivee au milieu de la sortie des resultats.
|
||||||
|
*/
|
||||||
|
const STEPS = [450, 900, 1000, 1100, 5000];
|
||||||
|
|
||||||
|
export function RateSearchDemo() {
|
||||||
|
const t = useTranslations('landing.hero.demo');
|
||||||
|
const prefersReducedMotion = useReducedMotion();
|
||||||
|
const [step, setStep] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Mouvement reduit : on affiche l'etat final, sans boucle.
|
||||||
|
if (prefersReducedMotion) {
|
||||||
|
setStep(4);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const id = setTimeout(() => setStep(s => (s + 1) % STEPS.length), STEPS[step]);
|
||||||
|
return () => clearTimeout(id);
|
||||||
|
}, [step, prefersReducedMotion]);
|
||||||
|
|
||||||
|
const hasOrigin = step >= 1;
|
||||||
|
const hasDestination = step >= 2;
|
||||||
|
const isSearching = step === 3;
|
||||||
|
const hasResults = step === 4;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
// La carte est un decor : le lecteur d'ecran recoit une phrase, pas la
|
||||||
|
// sequence entiere qui se rejoue toutes les huit secondes.
|
||||||
|
role="img"
|
||||||
|
aria-label={t('ariaLabel', { origin: ORIGIN.city, destination: DESTINATION.city })}
|
||||||
|
className="w-full max-w-md overflow-hidden rounded-2xl border border-white/15 bg-white/95 shadow-2xl shadow-brand-navy/40 backdrop-blur-sm"
|
||||||
|
>
|
||||||
|
{/* Barre de recherche */}
|
||||||
|
<div className="border-b border-border p-5">
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<span className="text-[11px] font-semibold uppercase tracking-[0.14em] text-neutral-400">
|
||||||
|
{t('label')}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-[0.14em] text-brand-navy">
|
||||||
|
<span className="relative flex size-1.5">
|
||||||
|
<span className="absolute inline-flex size-full animate-ping rounded-full bg-brand-turquoise opacity-75" />
|
||||||
|
<span className="relative inline-flex size-1.5 rounded-full bg-brand-turquoise" />
|
||||||
|
</span>
|
||||||
|
{t('live')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Ligne de route : deux escales et le trait qui les joint. */}
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<PortField
|
||||||
|
label={t('origin')}
|
||||||
|
code={ORIGIN.code}
|
||||||
|
city={ORIGIN.city}
|
||||||
|
filled={hasOrigin}
|
||||||
|
align="left"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="relative flex h-10 flex-1 items-center" aria-hidden="true">
|
||||||
|
<div className="h-px w-full bg-border" />
|
||||||
|
<motion.div
|
||||||
|
className="absolute left-0 h-px w-full origin-left bg-brand-turquoise"
|
||||||
|
initial={{ scaleX: 0 }}
|
||||||
|
animate={{ scaleX: hasDestination ? 1 : 0 }}
|
||||||
|
transition={{ duration: 0.6, ease: EASE }}
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
className="absolute left-1/2 -translate-x-1/2 rounded-full bg-white px-1"
|
||||||
|
initial={{ opacity: 0, scale: 0.6 }}
|
||||||
|
animate={{
|
||||||
|
opacity: hasDestination ? 1 : 0,
|
||||||
|
scale: hasDestination ? 1 : 0.6,
|
||||||
|
}}
|
||||||
|
transition={{ duration: 0.4, ease: EASE, delay: 0.3 }}
|
||||||
|
>
|
||||||
|
<Ship className="size-3.5 text-brand-turquoise" />
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PortField
|
||||||
|
label={t('destination')}
|
||||||
|
code={DESTINATION.code}
|
||||||
|
city={DESTINATION.city}
|
||||||
|
filled={hasDestination}
|
||||||
|
align="right"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-4 text-[11px] font-medium tabular-nums text-neutral-500">{t('load')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Resultats */}
|
||||||
|
<div className="min-h-[188px] bg-neutral-50 p-4">
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
{hasResults ? (
|
||||||
|
<motion.ul
|
||||||
|
key="results"
|
||||||
|
initial="hidden"
|
||||||
|
animate="visible"
|
||||||
|
exit={{ opacity: 0, transition: { duration: 0.2 } }}
|
||||||
|
variants={{ visible: { transition: { staggerChildren: 0.08 } } }}
|
||||||
|
className="space-y-2"
|
||||||
|
>
|
||||||
|
{OFFERS.map(offer => (
|
||||||
|
<motion.li
|
||||||
|
key={offer.carrier}
|
||||||
|
variants={{
|
||||||
|
hidden: { opacity: 0, y: 10 },
|
||||||
|
visible: { opacity: 1, y: 0, transition: { duration: 0.45, ease: EASE } },
|
||||||
|
}}
|
||||||
|
className={`flex items-center gap-3 rounded-xl border bg-white px-3.5 py-2.5 ${
|
||||||
|
offer.best ? 'border-brand-turquoise/50 ring-1 ring-brand-turquoise/30' : 'border-border'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-semibold text-brand-navy">{offer.carrier}</p>
|
||||||
|
<p className="text-[11px] tabular-nums text-neutral-500">
|
||||||
|
{t('transit', { days: offer.days })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{offer.best && (
|
||||||
|
<span className="flex items-center gap-1 rounded-full bg-brand-green/10 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-brand-green">
|
||||||
|
<Check className="size-3" />
|
||||||
|
{t('best')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<p className="font-heading text-base font-extrabold tabular-nums text-brand-navy">
|
||||||
|
{offer.price.toLocaleString('fr-FR')} €
|
||||||
|
</p>
|
||||||
|
</motion.li>
|
||||||
|
))}
|
||||||
|
</motion.ul>
|
||||||
|
) : (
|
||||||
|
<motion.div
|
||||||
|
key="pending"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0, transition: { duration: 0.2 } }}
|
||||||
|
className="space-y-2"
|
||||||
|
>
|
||||||
|
{[0, 1, 2].map(i => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex items-center gap-3 rounded-xl border border-border bg-white px-3.5 py-2.5"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1 space-y-1.5">
|
||||||
|
<ShimmerBar
|
||||||
|
className="h-3 w-24"
|
||||||
|
active={isSearching}
|
||||||
|
delay={i * 0.12}
|
||||||
|
reduced={!!prefersReducedMotion}
|
||||||
|
/>
|
||||||
|
<ShimmerBar
|
||||||
|
className="h-2 w-14"
|
||||||
|
active={isSearching}
|
||||||
|
delay={i * 0.12 + 0.06}
|
||||||
|
reduced={!!prefersReducedMotion}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ShimmerBar
|
||||||
|
className="h-4 w-16"
|
||||||
|
active={isSearching}
|
||||||
|
delay={i * 0.12}
|
||||||
|
reduced={!!prefersReducedMotion}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pied : le chiffre qui porte l'argument. */}
|
||||||
|
<div className="flex items-center justify-between border-t border-border bg-white px-5 py-3">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-neutral-400">
|
||||||
|
{hasResults ? t('resultCount', { count: OFFERS.length }) : t('searching')}
|
||||||
|
</p>
|
||||||
|
<p className="flex items-center gap-1.5 font-heading text-sm font-bold tabular-nums text-brand-navy">
|
||||||
|
{t('elapsed')}
|
||||||
|
<ArrowRight className="size-3.5 text-brand-turquoise" aria-hidden="true" />
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PortField({
|
||||||
|
label,
|
||||||
|
code,
|
||||||
|
city,
|
||||||
|
filled,
|
||||||
|
align,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
code: string;
|
||||||
|
city: string;
|
||||||
|
filled: boolean;
|
||||||
|
align: 'left' | 'right';
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={`w-[104px] ${align === 'right' ? 'text-right' : ''}`}>
|
||||||
|
<p className="text-[10px] font-semibold uppercase tracking-[0.14em] text-neutral-400">
|
||||||
|
{label}
|
||||||
|
</p>
|
||||||
|
<motion.p
|
||||||
|
// Le code port est la donnee, pas le nom de ville : chiffres tabulaires
|
||||||
|
// et interlettrage large, comme sur un connaissement.
|
||||||
|
className="font-heading text-lg font-extrabold tracking-[0.06em] text-brand-navy"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: filled ? 1 : 0.15 }}
|
||||||
|
transition={{ duration: 0.4, ease: EASE }}
|
||||||
|
>
|
||||||
|
{filled ? code : '·····'}
|
||||||
|
</motion.p>
|
||||||
|
<motion.p
|
||||||
|
className="truncate text-[11px] text-neutral-500"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: filled ? 1 : 0 }}
|
||||||
|
transition={{ duration: 0.4, ease: EASE, delay: 0.1 }}
|
||||||
|
>
|
||||||
|
{city}
|
||||||
|
</motion.p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ShimmerBar({
|
||||||
|
className,
|
||||||
|
active,
|
||||||
|
delay,
|
||||||
|
reduced,
|
||||||
|
}: {
|
||||||
|
className: string;
|
||||||
|
active: boolean;
|
||||||
|
delay: number;
|
||||||
|
reduced: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className={`rounded-full bg-neutral-200 ${className}`}
|
||||||
|
animate={active && !reduced ? { opacity: [0.4, 1, 0.4] } : { opacity: 0.4 }}
|
||||||
|
transition={
|
||||||
|
active && !reduced
|
||||||
|
? { duration: 1.1, repeat: Infinity, ease: 'easeInOut', delay }
|
||||||
|
: { duration: 0.2 }
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
apps/frontend/src/components/landing/motion.ts
Normal file
28
apps/frontend/src/components/landing/motion.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* Grammaire de mouvement de la page d'accueil.
|
||||||
|
*
|
||||||
|
* Une seule courbe, une seule distance, un seul decalage : les animations de
|
||||||
|
* la page doivent se ressembler entre elles. Les valeurs sont volontairement
|
||||||
|
* courtes — le mouvement accompagne la lecture, il ne la retarde pas.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Expo-out. Depart franc, arrivee amortie. */
|
||||||
|
export const EASE = [0.16, 1, 0.3, 1] as const;
|
||||||
|
|
||||||
|
/** Distance de reveal. Au-dela, le contenu semble « tomber » dans la page. */
|
||||||
|
export const RISE = 14;
|
||||||
|
|
||||||
|
/** Reveal standard, a utiliser avec `whileInView`. */
|
||||||
|
export const reveal = {
|
||||||
|
hidden: { opacity: 0, y: RISE },
|
||||||
|
visible: { opacity: 1, y: 0, transition: { duration: 0.55, ease: EASE } },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Conteneur d'une serie revelee en cascade. */
|
||||||
|
export const revealGroup = {
|
||||||
|
hidden: {},
|
||||||
|
visible: { transition: { staggerChildren: 0.07, delayChildren: 0.05 } },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Fenetre commune : le reveal se declenche un peu avant l'entree a l'ecran. */
|
||||||
|
export const VIEWPORT = { once: true, margin: '-80px' } as const;
|
||||||
@ -94,7 +94,7 @@ export default function LicensesTab() {
|
|||||||
? 'bg-red-600'
|
? 'bg-red-600'
|
||||||
: usagePercentage >= 70
|
: usagePercentage >= 70
|
||||||
? 'bg-yellow-500'
|
? 'bg-yellow-500'
|
||||||
: 'bg-brand-turquoise'
|
: 'bg-brand-blue'
|
||||||
}`}
|
}`}
|
||||||
style={{ width: `${Math.min(usagePercentage, 100)}%` }}
|
style={{ width: `${Math.min(usagePercentage, 100)}%` }}
|
||||||
></div>
|
></div>
|
||||||
@ -111,7 +111,7 @@ export default function LicensesTab() {
|
|||||||
</h3>
|
</h3>
|
||||||
<Link
|
<Link
|
||||||
href="/dashboard/settings/users"
|
href="/dashboard/settings/users"
|
||||||
className="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-brand-turquoise rounded-lg hover:bg-brand-turquoise/90 transition-colors"
|
className="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-brand-navy rounded-lg hover:bg-brand-navy/90 transition-colors"
|
||||||
>
|
>
|
||||||
<UserPlus className="w-4 h-4 mr-2" />
|
<UserPlus className="w-4 h-4 mr-2" />
|
||||||
Inviter un utilisateur
|
Inviter un utilisateur
|
||||||
@ -309,10 +309,10 @@ export default function LicensesTab() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Info Box */}
|
{/* Info Box */}
|
||||||
<div className="bg-brand-turquoise/5 border border-brand-turquoise/30 rounded-lg p-4">
|
<div className="bg-brand-blue/5 border border-brand-blue/30 rounded-lg p-4">
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
<svg className="h-5 w-5 text-brand-turquoise" viewBox="0 0 20 20" fill="currentColor">
|
<svg className="h-5 w-5 text-brand-blue" viewBox="0 0 20 20" fill="currentColor">
|
||||||
<path
|
<path
|
||||||
fillRule="evenodd"
|
fillRule="evenodd"
|
||||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
|
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
|
||||||
|
|||||||
@ -201,7 +201,7 @@ export default function SubscriptionTab() {
|
|||||||
<button
|
<button
|
||||||
onClick={handleRefresh}
|
onClick={handleRefresh}
|
||||||
disabled={isRefreshing}
|
disabled={isRefreshing}
|
||||||
className="text-sm text-brand-navy hover:text-brand-turquoise disabled:opacity-50 flex items-center gap-1"
|
className="text-sm text-brand-navy hover:text-brand-blue disabled:opacity-50 flex items-center gap-1"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className={`h-4 w-4 ${isRefreshing ? 'animate-spin' : ''}`}
|
className={`h-4 w-4 ${isRefreshing ? 'animate-spin' : ''}`}
|
||||||
@ -241,7 +241,7 @@ export default function SubscriptionTab() {
|
|||||||
<button
|
<button
|
||||||
onClick={handleManageBilling}
|
onClick={handleManageBilling}
|
||||||
disabled={portalMutation.isPending}
|
disabled={portalMutation.isPending}
|
||||||
className="px-4 py-2 text-sm font-medium text-brand-navy hover:text-brand-turquoise hover:bg-brand-turquoise/5 rounded-lg disabled:opacity-50 transition-colors"
|
className="px-4 py-2 text-sm font-medium text-brand-navy hover:bg-neutral-100 rounded-lg transition-colors disabled:opacity-50 transition-colors"
|
||||||
>
|
>
|
||||||
{portalMutation.isPending ? t('manageBillingLoading') : t('manageBilling')}
|
{portalMutation.isPending ? t('manageBillingLoading') : t('manageBilling')}
|
||||||
</button>
|
</button>
|
||||||
@ -264,7 +264,7 @@ export default function SubscriptionTab() {
|
|||||||
? 'bg-red-600'
|
? 'bg-red-600'
|
||||||
: usagePercentage >= 70
|
: usagePercentage >= 70
|
||||||
? 'bg-yellow-500'
|
? 'bg-yellow-500'
|
||||||
: 'bg-brand-turquoise'
|
: 'bg-brand-blue'
|
||||||
}`}
|
}`}
|
||||||
style={{
|
style={{
|
||||||
width:
|
width:
|
||||||
@ -330,7 +330,7 @@ export default function SubscriptionTab() {
|
|||||||
key={plan.plan}
|
key={plan.plan}
|
||||||
className={`bg-white rounded-lg border-2 p-5 ${
|
className={`bg-white rounded-lg border-2 p-5 ${
|
||||||
isCurrentPlan(plan.plan)
|
isCurrentPlan(plan.plan)
|
||||||
? 'border-brand-turquoise shadow-md'
|
? 'border-brand-blue shadow-md'
|
||||||
: 'border-border hover:border-neutral-300 hover:shadow transition'
|
: 'border-border hover:border-neutral-300 hover:shadow transition'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@ -387,7 +387,7 @@ export default function SubscriptionTab() {
|
|||||||
) : plan.plan === 'PLATINIUM' ? (
|
) : plan.plan === 'PLATINIUM' ? (
|
||||||
<a
|
<a
|
||||||
href="mailto:sales@xpeditis.com?subject=Demande Enterprise"
|
href="mailto:sales@xpeditis.com?subject=Demande Enterprise"
|
||||||
className="block w-full px-4 py-2 text-sm font-medium text-center text-brand-navy bg-brand-turquoise/5 rounded-lg hover:bg-brand-turquoise/10 transition"
|
className="block w-full px-4 py-2 text-sm font-medium text-center text-brand-navy bg-brand-blue/5 rounded-lg hover:bg-brand-blue/10 transition"
|
||||||
>
|
>
|
||||||
{t('contactUs')}
|
{t('contactUs')}
|
||||||
</a>
|
</a>
|
||||||
@ -395,7 +395,7 @@ export default function SubscriptionTab() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => handleUpgrade(plan.plan)}
|
onClick={() => handleUpgrade(plan.plan)}
|
||||||
disabled={checkoutMutation.isPending && selectedPlan === plan.plan}
|
disabled={checkoutMutation.isPending && selectedPlan === plan.plan}
|
||||||
className="w-full px-4 py-2 text-sm font-medium text-white bg-brand-turquoise rounded-lg hover:bg-brand-turquoise/90 transition disabled:opacity-50"
|
className="w-full px-4 py-2 text-sm font-medium text-white bg-brand-navy rounded-lg hover:bg-brand-navy/90 transition disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{checkoutMutation.isPending && selectedPlan === plan.plan
|
{checkoutMutation.isPending && selectedPlan === plan.plan
|
||||||
? t('upgradeLoading')
|
? t('upgradeLoading')
|
||||||
|
|||||||
@ -32,7 +32,7 @@ type SortOrder = 'asc' | 'desc';
|
|||||||
|
|
||||||
const SERVICE_LEVEL_LABELS: Record<ServiceLevel, { label: string; color: string }> = {
|
const SERVICE_LEVEL_LABELS: Record<ServiceLevel, { label: string; color: string }> = {
|
||||||
ECONOMIC: { label: 'Éco', color: 'bg-green-100 text-green-800 border-green-200' },
|
ECONOMIC: { label: 'Éco', color: 'bg-green-100 text-green-800 border-green-200' },
|
||||||
STANDARD: { label: 'Standard', color: 'bg-brand-turquoise/10 text-brand-navy border-brand-turquoise/30' },
|
STANDARD: { label: 'Standard', color: 'bg-brand-blue/10 text-brand-navy border-brand-blue/30' },
|
||||||
RAPID: { label: 'Rapide', color: 'bg-orange-100 text-orange-800 border-orange-200' },
|
RAPID: { label: 'Rapide', color: 'bg-orange-100 text-orange-800 border-orange-200' },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -90,7 +90,7 @@ export function VolumeWeightInput({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Info message */}
|
{/* Info message */}
|
||||||
<div className="flex items-start gap-2 p-3 bg-brand-turquoise/5 rounded-md border border-brand-turquoise/30">
|
<div className="flex items-start gap-2 p-3 bg-brand-blue/5 rounded-md border border-brand-blue/30">
|
||||||
<svg
|
<svg
|
||||||
className="w-5 h-5 text-brand-navy flex-shrink-0 mt-0.5"
|
className="w-5 h-5 text-brand-navy flex-shrink-0 mt-0.5"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
|
|||||||
@ -62,7 +62,7 @@ export function BottomNav({ pathname, labels, onMore }: BottomNavProps) {
|
|||||||
{active && (
|
{active && (
|
||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="absolute -top-1.5 left-1/2 h-0.5 w-4 -translate-x-1/2 rounded-full bg-brand-turquoise"
|
className="absolute -top-1.5 left-1/2 h-0.5 w-4 -translate-x-1/2 rounded-full bg-brand-blue"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@ -171,7 +171,7 @@ export function CommandMenu({
|
|||||||
{item.groupLabel}
|
{item.groupLabel}
|
||||||
</span>
|
</span>
|
||||||
{active && (
|
{active && (
|
||||||
<span className="size-1.5 shrink-0 rounded-full bg-brand-turquoise" />
|
<span className="size-1.5 shrink-0 rounded-full bg-brand-blue" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -41,7 +41,7 @@ export function SettingsNav({ hide = [] }: { hide?: string[] }) {
|
|||||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset',
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset',
|
||||||
'after:absolute after:inset-x-2 after:-bottom-px after:h-0.5 after:rounded-full',
|
'after:absolute after:inset-x-2 after:-bottom-px after:h-0.5 after:rounded-full',
|
||||||
active
|
active
|
||||||
? 'text-brand-navy after:bg-brand-turquoise'
|
? 'text-brand-navy after:bg-brand-blue'
|
||||||
: 'text-neutral-500 after:bg-transparent hover:text-brand-navy'
|
: 'text-neutral-500 after:bg-transparent hover:text-brand-navy'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -73,12 +73,12 @@ export function SidebarNav({
|
|||||||
: 'text-neutral-700 hover:bg-neutral-100 hover:text-brand-navy'
|
: 'text-neutral-700 hover:bg-neutral-100 hover:text-brand-navy'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Filet d'accent turquoise : marque l'item actif sans aplat colore. */}
|
{/* Filet d'accent bleu : marque l'item actif sans aplat colore. */}
|
||||||
{active && (
|
{active && (
|
||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute left-0 top-1/2 h-5 w-0.5 -translate-y-1/2 rounded-full bg-brand-turquoise',
|
'absolute left-0 top-1/2 h-5 w-0.5 -translate-y-1/2 rounded-full bg-brand-blue',
|
||||||
collapsed && '-left-1'
|
collapsed && '-left-1'
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -53,7 +53,7 @@ export default function FeatureGate({ feature, children, fallback }: FeatureGate
|
|||||||
</p>
|
</p>
|
||||||
<Link
|
<Link
|
||||||
href="/pricing"
|
href="/pricing"
|
||||||
className="inline-flex items-center px-4 py-2 bg-brand-turquoise text-white text-sm font-medium rounded-lg hover:opacity-90 transition-opacity"
|
className="inline-flex items-center px-4 py-2 bg-brand-navy text-white text-sm font-medium rounded-lg hover:opacity-90 transition-opacity"
|
||||||
>
|
>
|
||||||
Voir les plans
|
Voir les plans
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@ -18,7 +18,7 @@ const badgeVariants = cva(
|
|||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
neutral: 'border-transparent bg-neutral-100 text-neutral-700',
|
neutral: 'border-transparent bg-neutral-100 text-neutral-700',
|
||||||
info: 'border-transparent bg-brand-turquoise/10 text-brand-navy',
|
info: 'border-transparent bg-brand-blue/10 text-brand-blue-dark',
|
||||||
success: 'border-transparent bg-success/10 text-success',
|
success: 'border-transparent bg-success/10 text-success',
|
||||||
warning: 'border-transparent bg-amber-100 text-amber-800',
|
warning: 'border-transparent bg-amber-100 text-amber-800',
|
||||||
danger: 'border-transparent bg-destructive/10 text-destructive',
|
danger: 'border-transparent bg-destructive/10 text-destructive',
|
||||||
|
|||||||
@ -9,17 +9,23 @@ import { cn } from '@/lib/utils';
|
|||||||
/**
|
/**
|
||||||
* Bouton Xpeditis.
|
* Bouton Xpeditis.
|
||||||
*
|
*
|
||||||
* Charte : le turquoise porte l'action, le navy porte la structure.
|
* L'action est portee par le navy plein a texte blanc. Les actions de second
|
||||||
* - `primary` : action principale de l'ecran (une seule par vue)
|
* rang restent claires : peindre chaque bouton en navy noyait l'action
|
||||||
* - `secondary` : action structurante
|
* principale au milieu d'aplats sombres, notamment dans les tableaux.
|
||||||
* - `outline` : action secondaire
|
*
|
||||||
* - `ghost` : actions de table, boutons-icone
|
* - `primary` / `secondary` : action pleine, navy, texte blanc
|
||||||
|
* - `outline` : action secondaire, fond blanc, bordure, texte navy
|
||||||
|
* - `ghost` : actions de table, boutons-icone
|
||||||
|
* - `destructive` : suppression, rouge a texte blanc
|
||||||
*
|
*
|
||||||
* `default` est conserve comme alias de `primary` pour compatibilite.
|
* `default` est conserve comme alias de `primary` pour compatibilite.
|
||||||
*/
|
*/
|
||||||
|
const NAVY_BUTTON =
|
||||||
|
'bg-brand-navy text-white hover:bg-brand-navy/90 active:bg-brand-navy/95 disabled:cursor-not-allowed';
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
[
|
[
|
||||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md',
|
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg',
|
||||||
'font-body font-medium',
|
'font-body font-medium',
|
||||||
'transition-colors duration-150 ease-out',
|
'transition-colors duration-150 ease-out',
|
||||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||||
@ -29,28 +35,25 @@ const buttonVariants = cva(
|
|||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
// Texte navy sur turquoise : 8,49:1 (AAA). Le blanc sur turquoise ne
|
primary: NAVY_BUTTON,
|
||||||
// donne que 2,05:1 et echoue AA. Ce couple est deja celui employe par
|
default: NAVY_BUTTON,
|
||||||
// la sidebar d'administration d'origine.
|
secondary: NAVY_BUTTON,
|
||||||
primary:
|
outline:
|
||||||
'bg-brand-turquoise text-brand-navy hover:bg-brand-turquoise/90 active:bg-brand-turquoise/95',
|
'border border-border bg-white text-brand-navy hover:bg-neutral-50 active:bg-neutral-100',
|
||||||
default:
|
|
||||||
'bg-brand-turquoise text-brand-navy hover:bg-brand-turquoise/90 active:bg-brand-turquoise/95',
|
|
||||||
secondary: 'bg-brand-navy text-white hover:bg-brand-navy/90 active:bg-brand-navy/95',
|
|
||||||
outline: 'border border-border bg-white text-brand-navy hover:bg-neutral-50 active:bg-neutral-100',
|
|
||||||
ghost: 'text-neutral-700 hover:bg-neutral-100 hover:text-brand-navy active:bg-neutral-200',
|
ghost: 'text-neutral-700 hover:bg-neutral-100 hover:text-brand-navy active:bg-neutral-200',
|
||||||
destructive:
|
destructive:
|
||||||
'bg-destructive text-destructive-foreground hover:bg-destructive/90 active:bg-destructive/95',
|
'bg-destructive text-white hover:bg-destructive/90 active:bg-destructive/95 disabled:cursor-not-allowed',
|
||||||
// Souligne en permanence : identifier un lien par la seule couleur
|
// Souligne en permanence : identifier un lien par la seule couleur
|
||||||
// echoue au critere WCAG 1.4.1.
|
// echoue au critere WCAG 1.4.1.
|
||||||
link: 'text-brand-navy underline underline-offset-4 hover:text-brand-turquoise',
|
link: 'text-brand-navy underline underline-offset-4 hover:text-brand-blue',
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
sm: 'h-8 px-3 text-body-xs [&_svg]:size-3.5',
|
sm: 'h-8 px-3 text-body-xs [&_svg]:size-3.5',
|
||||||
md: 'h-9 px-4 text-body-sm [&_svg]:size-4',
|
/** Gabarit de reference du dashboard : 40 px de haut, texte 14 px. */
|
||||||
lg: 'h-10 px-6 text-body-sm [&_svg]:size-4',
|
md: 'h-10 px-6 text-body-sm [&_svg]:size-4',
|
||||||
icon: 'h-9 w-9 [&_svg]:size-4',
|
lg: 'h-11 px-8 text-body-sm [&_svg]:size-4',
|
||||||
'icon-sm': 'h-8 w-8 [&_svg]:size-3.5',
|
icon: 'size-10 [&_svg]:size-4',
|
||||||
|
'icon-sm': 'size-8 [&_svg]:size-3.5',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
|
|||||||
@ -13,8 +13,8 @@ export type CalloutVariant = 'info' | 'success' | 'warning' | 'danger';
|
|||||||
const config: Record<CalloutVariant, { icon: LucideIcon; wrapper: string; icon_: string }> = {
|
const config: Record<CalloutVariant, { icon: LucideIcon; wrapper: string; icon_: string }> = {
|
||||||
info: {
|
info: {
|
||||||
icon: Info,
|
icon: Info,
|
||||||
wrapper: 'border-brand-turquoise/30 bg-brand-turquoise/5',
|
wrapper: 'border-brand-blue/30 bg-brand-blue/5',
|
||||||
icon_: 'text-brand-turquoise',
|
icon_: 'text-brand-blue',
|
||||||
},
|
},
|
||||||
success: { icon: CheckCircle2, wrapper: 'border-success/30 bg-success/5', icon_: 'text-success' },
|
success: { icon: CheckCircle2, wrapper: 'border-success/30 bg-success/5', icon_: 'text-success' },
|
||||||
warning: { icon: AlertTriangle, wrapper: 'border-amber-300 bg-amber-50', icon_: 'text-amber-600' },
|
warning: { icon: AlertTriangle, wrapper: 'border-amber-300 bg-amber-50', icon_: 'text-amber-600' },
|
||||||
@ -48,7 +48,7 @@ export function Callout({
|
|||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-body-sm text-neutral-700 [&_a]:text-brand-turquoise [&_a]:underline-offset-4 hover:[&_a]:underline',
|
'text-body-sm text-neutral-700 [&_a]:text-brand-blue [&_a]:underline-offset-4 hover:[&_a]:underline',
|
||||||
title && 'mt-1'
|
title && 'mt-1'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -16,8 +16,8 @@ const Checkbox = React.forwardRef<
|
|||||||
'transition-colors duration-150 ease-out',
|
'transition-colors duration-150 ease-out',
|
||||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
'data-[state=checked]:border-brand-turquoise data-[state=checked]:bg-brand-turquoise',
|
'data-[state=checked]:border-brand-blue data-[state=checked]:bg-brand-blue',
|
||||||
'data-[state=indeterminate]:border-brand-turquoise data-[state=indeterminate]:bg-brand-turquoise',
|
'data-[state=indeterminate]:border-brand-blue data-[state=indeterminate]:bg-brand-blue',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@ -107,7 +107,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
|
|||||||
>
|
>
|
||||||
<span className="absolute left-2 flex size-4 items-center justify-center">
|
<span className="absolute left-2 flex size-4 items-center justify-center">
|
||||||
<DropdownMenuPrimitive.ItemIndicator>
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
<Check className="size-4 text-brand-turquoise" />
|
<Check className="size-4 text-brand-blue" />
|
||||||
</DropdownMenuPrimitive.ItemIndicator>
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
</span>
|
</span>
|
||||||
{children}
|
{children}
|
||||||
@ -122,7 +122,7 @@ const DropdownMenuRadioItem = React.forwardRef<
|
|||||||
<DropdownMenuPrimitive.RadioItem ref={ref} className={cn(itemBase, 'pl-8', className)} {...props}>
|
<DropdownMenuPrimitive.RadioItem ref={ref} className={cn(itemBase, 'pl-8', className)} {...props}>
|
||||||
<span className="absolute left-2 flex size-4 items-center justify-center">
|
<span className="absolute left-2 flex size-4 items-center justify-center">
|
||||||
<DropdownMenuPrimitive.ItemIndicator>
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
<Circle className="size-2 fill-brand-turquoise text-brand-turquoise" />
|
<Circle className="size-2 fill-brand-blue text-brand-blue" />
|
||||||
</DropdownMenuPrimitive.ItemIndicator>
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
</span>
|
</span>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@ -127,7 +127,7 @@ const SelectItem = React.forwardRef<
|
|||||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
<span className="absolute right-2 flex size-4 items-center justify-center">
|
<span className="absolute right-2 flex size-4 items-center justify-center">
|
||||||
<SelectPrimitive.ItemIndicator>
|
<SelectPrimitive.ItemIndicator>
|
||||||
<Check className="size-4 text-brand-turquoise" />
|
<Check className="size-4 text-brand-blue" />
|
||||||
</SelectPrimitive.ItemIndicator>
|
</SelectPrimitive.ItemIndicator>
|
||||||
</span>
|
</span>
|
||||||
</SelectPrimitive.Item>
|
</SelectPrimitive.Item>
|
||||||
|
|||||||
@ -30,7 +30,7 @@ export function Spinner({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Loader2
|
<Loader2
|
||||||
className={cn('animate-spin text-brand-turquoise', spinnerSizes[size], className)}
|
className={cn('animate-spin text-brand-blue', spinnerSizes[size], className)}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<span className="sr-only">{label}</span>
|
<span className="sr-only">{label}</span>
|
||||||
|
|||||||
@ -36,10 +36,10 @@ const TabsTrigger = React.forwardRef<
|
|||||||
'transition-colors hover:text-brand-navy',
|
'transition-colors hover:text-brand-navy',
|
||||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset',
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset',
|
||||||
'disabled:pointer-events-none disabled:opacity-50',
|
'disabled:pointer-events-none disabled:opacity-50',
|
||||||
// Filet turquoise sous l'onglet actif, plutot qu'un aplat colore.
|
// Filet bleu sous l'onglet actif, plutot qu'un aplat colore.
|
||||||
'data-[state=active]:text-brand-navy',
|
'data-[state=active]:text-brand-navy',
|
||||||
'after:absolute after:inset-x-2 after:-bottom-px after:h-0.5 after:rounded-full after:bg-transparent',
|
'after:absolute after:inset-x-2 after:-bottom-px after:h-0.5 after:rounded-full after:bg-transparent',
|
||||||
'data-[state=active]:after:bg-brand-turquoise',
|
'data-[state=active]:after:bg-brand-blue',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@ -132,7 +132,7 @@ const variantConfig: Record<
|
|||||||
success: { icon: CheckCircle2, iconClass: 'text-success', role: 'status' },
|
success: { icon: CheckCircle2, iconClass: 'text-success', role: 'status' },
|
||||||
error: { icon: XCircle, iconClass: 'text-destructive', role: 'alert' },
|
error: { icon: XCircle, iconClass: 'text-destructive', role: 'alert' },
|
||||||
warning: { icon: AlertTriangle, iconClass: 'text-amber-500', role: 'alert' },
|
warning: { icon: AlertTriangle, iconClass: 'text-amber-500', role: 'alert' },
|
||||||
info: { icon: Info, iconClass: 'text-brand-turquoise', role: 'status' },
|
info: { icon: Info, iconClass: 'text-brand-blue', role: 'status' },
|
||||||
};
|
};
|
||||||
|
|
||||||
function Toaster() {
|
function Toaster() {
|
||||||
@ -146,8 +146,9 @@ function Toaster() {
|
|||||||
aria-atomic="false"
|
aria-atomic="false"
|
||||||
className={cn(
|
className={cn(
|
||||||
'pointer-events-none fixed z-[100] flex flex-col gap-2',
|
'pointer-events-none fixed z-[100] flex flex-col gap-2',
|
||||||
// Mobile : en haut, pleine largeur. Desktop : en bas a droite.
|
// Mobile : en haut, pleine largeur. Desktop : en bas a droite, au-dessus
|
||||||
'inset-x-4 top-4 sm:inset-x-auto sm:bottom-4 sm:right-4 sm:top-auto sm:w-96'
|
// du bouton cookies qui occupe le coin (44 px + 16 px de marge).
|
||||||
|
'inset-x-4 top-4 sm:inset-x-auto sm:bottom-20 sm:right-4 sm:top-auto sm:w-96'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{toasts.map(t => {
|
{toasts.map(t => {
|
||||||
@ -174,7 +175,7 @@ function Toaster() {
|
|||||||
t.action?.onClick();
|
t.action?.onClick();
|
||||||
dismiss(t.id);
|
dismiss(t.id);
|
||||||
}}
|
}}
|
||||||
className="mt-2 rounded-md text-body-xs font-medium text-brand-turquoise underline-offset-4 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
className="mt-2 rounded-md text-body-xs font-medium text-brand-blue underline-offset-4 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
>
|
>
|
||||||
{t.action.label}
|
{t.action.label}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -12,8 +12,10 @@ import { get } from './client';
|
|||||||
export interface DashboardKPIs {
|
export interface DashboardKPIs {
|
||||||
bookingsThisMonth: number;
|
bookingsThisMonth: number;
|
||||||
bookingsThisMonthChange: number;
|
bookingsThisMonthChange: number;
|
||||||
totalTEUs: number;
|
/** Volume LCL du mois, en CBM. */
|
||||||
totalTEUsChange: number;
|
volumeCBM: number;
|
||||||
|
volumeCBMChange: number;
|
||||||
|
/** Commission Xpeditis sur les reservations acceptees, en EUR. */
|
||||||
estimatedRevenue: number;
|
estimatedRevenue: number;
|
||||||
estimatedRevenueChange: number;
|
estimatedRevenueChange: number;
|
||||||
pendingConfirmations: number;
|
pendingConfirmations: number;
|
||||||
@ -24,6 +26,7 @@ export interface DashboardKPIs {
|
|||||||
* Bookings Chart Data
|
* Bookings Chart Data
|
||||||
*/
|
*/
|
||||||
export interface BookingsChartData {
|
export interface BookingsChartData {
|
||||||
|
/** Cles de mois ISO `YYYY-MM` ; mises en forme cote client. */
|
||||||
labels: string[];
|
labels: string[];
|
||||||
data: number[];
|
data: number[];
|
||||||
}
|
}
|
||||||
@ -34,8 +37,10 @@ export interface BookingsChartData {
|
|||||||
export interface TradeLane {
|
export interface TradeLane {
|
||||||
route: string;
|
route: string;
|
||||||
bookingCount: number;
|
bookingCount: number;
|
||||||
origin: string;
|
originPort: string;
|
||||||
destination: string;
|
destinationPort: string;
|
||||||
|
totalVolumeCBM: number;
|
||||||
|
avgPrice: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -44,8 +49,10 @@ export interface TradeLane {
|
|||||||
export interface DashboardAlert {
|
export interface DashboardAlert {
|
||||||
id: string;
|
id: string;
|
||||||
severity: 'critical' | 'high' | 'medium' | 'low';
|
severity: 'critical' | 'high' | 'medium' | 'low';
|
||||||
title: string;
|
/** Cles de traduction resolues par le client. */
|
||||||
message: string;
|
titleKey: string;
|
||||||
|
messageKey: string;
|
||||||
|
messageParams?: Record<string, string | number>;
|
||||||
bookingId?: string;
|
bookingId?: string;
|
||||||
bookingNumber?: string;
|
bookingNumber?: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
|||||||
@ -1,27 +1,43 @@
|
|||||||
/**
|
/**
|
||||||
* Palette des graphiques.
|
* Palette et specifications des graphiques.
|
||||||
*
|
*
|
||||||
* Recharts n'accepte que des couleurs litterales : ces constantes reprennent
|
* Recharts n'accepte que des couleurs litterales : ces constantes centralisent
|
||||||
* exactement les valeurs de la charte (voir docs/design-system-audit.md §1) et
|
* les valeurs et evitent que chaque page reinvente les siennes.
|
||||||
* remplacent les couleurs qui avaient ete ecrites en dur dans les pages —
|
|
||||||
* dont `#8884d8`, le violet par defaut de Recharts reste par oubli.
|
|
||||||
*
|
*
|
||||||
* Aucune valeur de ce fichier n'est inventee.
|
* Deux familles, deux roles distincts — elles ne se melangent jamais :
|
||||||
|
*
|
||||||
|
* - `SERIES` : une grandeur qui se compare a elle-meme (volume dans le temps,
|
||||||
|
* classement). Une seule teinte, le bleu applicatif.
|
||||||
|
* - `STATUS` : un etat (accepte / en attente / refuse). Ces trois couleurs sont
|
||||||
|
* reservees a cet usage et ne servent jamais de « serie 4 ». Elles sont
|
||||||
|
* toujours accompagnees d'un libelle : la couleur seule n'est pas porteuse.
|
||||||
|
*
|
||||||
|
* Palette de statut verifiee (validate_palette, mode clair) : bande de clarte,
|
||||||
|
* plancher de chroma, separation daltonisme (pire paire rouge/ambre : ΔE 13,9
|
||||||
|
* deutan) et plancher vision normale (ΔE 19,8) — tous PASS. L'ambre passe sous
|
||||||
|
* 3:1 vis-a-vis du fond, d'ou l'obligation de libelles visibles, respectee par
|
||||||
|
* la legende et les etiquettes directes.
|
||||||
*/
|
*/
|
||||||
export const CHART_COLORS = {
|
|
||||||
/** brand-turquoise — serie principale */
|
/** Serie unique : magnitude, evolution, classement. */
|
||||||
primary: '#34CCCD',
|
export const SERIES = {
|
||||||
/** brand-navy — serie de contraste */
|
/** brand-blue — accent applicatif */
|
||||||
|
primary: '#2563EB',
|
||||||
|
/** brand-navy — seconde serie de contraste si necessaire */
|
||||||
navy: '#10183A',
|
navy: '#10183A',
|
||||||
/** success / brand-green */
|
|
||||||
success: '#067224',
|
|
||||||
/** --destructive */
|
|
||||||
danger: '#EF4444',
|
|
||||||
/** amber-500 — avertissement / en attente */
|
|
||||||
warning: '#F59E0B',
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/** Habillage : grilles, axes, infobulles. */
|
/** Etats. Reserves : jamais employes comme couleurs de serie. */
|
||||||
|
export const STATUS = {
|
||||||
|
/** brand-green — accepte */
|
||||||
|
good: '#067224',
|
||||||
|
/** amber-500 — en attente */
|
||||||
|
warning: '#F59E0B',
|
||||||
|
/** red-500 — refuse */
|
||||||
|
critical: '#EF4444',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Habillage : grilles, axes, infobulles. Volontairement recessif. */
|
||||||
export const CHART_UI = {
|
export const CHART_UI = {
|
||||||
/** neutral-100 */
|
/** neutral-100 */
|
||||||
grid: '#EDEEF5',
|
grid: '#EDEEF5',
|
||||||
@ -32,6 +48,17 @@ export const CHART_UI = {
|
|||||||
surface: '#FFFFFF',
|
surface: '#FFFFFF',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
/** Specifications de marque communes a tous les graphiques. */
|
||||||
|
export const MARK = {
|
||||||
|
/** Epaisseur des courbes. */
|
||||||
|
strokeWidth: 2,
|
||||||
|
/** Extremite de barre arrondie, cote oppose a la ligne de base. */
|
||||||
|
barRadius: 4,
|
||||||
|
/** Diametre minimal d'un point survolable. */
|
||||||
|
dotRadius: 4,
|
||||||
|
activeDotRadius: 5,
|
||||||
|
} as const;
|
||||||
|
|
||||||
/** Style d'infobulle commun, aux polices et couleurs de la charte. */
|
/** Style d'infobulle commun, aux polices et couleurs de la charte. */
|
||||||
export const chartTooltipStyle = {
|
export const chartTooltipStyle = {
|
||||||
contentStyle: {
|
contentStyle: {
|
||||||
@ -40,15 +67,17 @@ export const chartTooltipStyle = {
|
|||||||
borderRadius: '0.5rem',
|
borderRadius: '0.5rem',
|
||||||
fontSize: '0.75rem',
|
fontSize: '0.75rem',
|
||||||
fontFamily: 'var(--font-montserrat), sans-serif',
|
fontFamily: 'var(--font-montserrat), sans-serif',
|
||||||
color: CHART_COLORS.navy,
|
color: SERIES.navy,
|
||||||
boxShadow: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
|
boxShadow: '0 4px 12px -2px rgb(16 24 58 / 0.12)',
|
||||||
|
padding: '0.5rem 0.75rem',
|
||||||
},
|
},
|
||||||
labelStyle: { color: CHART_COLORS.navy, fontWeight: 600 },
|
labelStyle: { color: SERIES.navy, fontWeight: 600, marginBottom: '0.125rem' },
|
||||||
|
itemStyle: { padding: 0 },
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/** Style d'axe commun. */
|
/** Style d'axe commun : le trait recule, la donnee avance. */
|
||||||
export const chartAxisProps = {
|
export const chartAxisProps = {
|
||||||
tick: { fontSize: 11, fill: CHART_UI.axis },
|
tick: { fontSize: 11, fill: CHART_UI.axis },
|
||||||
tickLine: false,
|
tickLine: false,
|
||||||
axisLine: { stroke: CHART_UI.border },
|
axisLine: false,
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@ -24,6 +24,17 @@ const config: Config = {
|
|||||||
'brand-green': '#067224',
|
'brand-green': '#067224',
|
||||||
'brand-gray': '#F2F2F2',
|
'brand-gray': '#F2F2F2',
|
||||||
|
|
||||||
|
// Accent applicatif (dashboard, admin, portails).
|
||||||
|
// Le turquoise reste l'accent du site vitrine ; a l'interieur du
|
||||||
|
// produit l'action est portee par le bleu historique (ancien
|
||||||
|
// `blue-600` de Tailwind, deja la couleur du dashboard d'origine).
|
||||||
|
'brand-blue': {
|
||||||
|
DEFAULT: '#2563EB',
|
||||||
|
light: '#3B82F6',
|
||||||
|
dark: '#1D4ED8',
|
||||||
|
soft: '#EFF6FF',
|
||||||
|
},
|
||||||
|
|
||||||
// Shadcn UI colors (keep for components compatibility)
|
// Shadcn UI colors (keep for components compatibility)
|
||||||
border: 'hsl(var(--border))',
|
border: 'hsl(var(--border))',
|
||||||
input: 'hsl(var(--input))',
|
input: 'hsl(var(--input))',
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user