feature dashboard

This commit is contained in:
David 2025-12-16 00:26:03 +01:00
parent 71541c79e7
commit eab3d6f612
29 changed files with 1628 additions and 1365 deletions

View File

@ -11,7 +11,10 @@ import { ConfigService } from '@nestjs/config';
import * as argon2 from 'argon2'; import * as argon2 from 'argon2';
import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.repository'; import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.repository';
import { User, UserRole } from '@domain/entities/user.entity'; import { User, UserRole } from '@domain/entities/user.entity';
import { OrganizationRepository, ORGANIZATION_REPOSITORY } from '@domain/ports/out/organization.repository'; import {
OrganizationRepository,
ORGANIZATION_REPOSITORY,
} from '@domain/ports/out/organization.repository';
import { Organization, OrganizationType } from '@domain/entities/organization.entity'; import { Organization, OrganizationType } from '@domain/entities/organization.entity';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { DEFAULT_ORG_ID } from '@infrastructure/persistence/typeorm/seeds/test-organizations.seed'; import { DEFAULT_ORG_ID } from '@infrastructure/persistence/typeorm/seeds/test-organizations.seed';
@ -301,7 +304,9 @@ export class AuthService {
const savedOrganization = await this.organizationRepository.save(newOrganization); const savedOrganization = await this.organizationRepository.save(newOrganization);
this.logger.log(`New organization created: ${savedOrganization.id} - ${savedOrganization.name}`); this.logger.log(
`New organization created: ${savedOrganization.id} - ${savedOrganization.name}`
);
return savedOrganization.id; return savedOrganization.id;
} }

View File

@ -181,7 +181,7 @@ export class CsvRatesAdminController {
// Auto-convert CSV if needed (FOB FRET → Standard format) // Auto-convert CSV if needed (FOB FRET → Standard format)
const conversionResult = await this.csvConverter.autoConvert(file.path, dto.companyName); const conversionResult = await this.csvConverter.autoConvert(file.path, dto.companyName);
let filePathToValidate = conversionResult.convertedPath; const filePathToValidate = conversionResult.convertedPath;
if (conversionResult.wasConverted) { if (conversionResult.wasConverted) {
this.logger.log( this.logger.log(
@ -204,7 +204,11 @@ export class CsvRatesAdminController {
// Load rates to verify parsing using the converted path // Load rates to verify parsing using the converted path
// Pass company name from form to override CSV column value // Pass company name from form to override CSV column value
const rates = await this.csvLoader.loadRatesFromCsv(filePathToValidate, dto.companyEmail, dto.companyName); const rates = await this.csvLoader.loadRatesFromCsv(
filePathToValidate,
dto.companyEmail,
dto.companyName
);
const ratesCount = rates.length; const ratesCount = rates.length;
this.logger.log(`Successfully parsed ${ratesCount} rates from ${file.filename}`); this.logger.log(`Successfully parsed ${ratesCount} rates from ${file.filename}`);
@ -245,7 +249,9 @@ export class CsvRatesAdminController {
minioObjectKey = objectKey; minioObjectKey = objectKey;
this.logger.log(`✅ CSV file uploaded to MinIO: ${bucket}/${objectKey}`); this.logger.log(`✅ CSV file uploaded to MinIO: ${bucket}/${objectKey}`);
} catch (error: any) { } catch (error: any) {
this.logger.error(`⚠️ Failed to upload CSV to MinIO (will continue with local storage): ${error.message}`); this.logger.error(
`⚠️ Failed to upload CSV to MinIO (will continue with local storage): ${error.message}`
);
// Don't fail the entire operation if MinIO upload fails // Don't fail the entire operation if MinIO upload fails
// The file is still available locally // The file is still available locally
} }
@ -433,7 +439,8 @@ export class CsvRatesAdminController {
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ApiOperation({ @ApiOperation({
summary: 'List all CSV files (ADMIN only)', summary: 'List all CSV files (ADMIN only)',
description: 'Returns list of all uploaded CSV files with metadata. Alias for /config endpoint.', description:
'Returns list of all uploaded CSV files with metadata. Alias for /config endpoint.',
}) })
@ApiResponse({ @ApiResponse({
status: HttpStatus.OK, status: HttpStatus.OK,
@ -462,7 +469,7 @@ export class CsvRatesAdminController {
const configs = await this.csvConfigRepository.findAll(); const configs = await this.csvConfigRepository.findAll();
// Map configs to file info format expected by frontend // Map configs to file info format expected by frontend
const files = configs.map((config) => { const files = configs.map(config => {
const filePath = path.join( const filePath = path.join(
process.cwd(), process.cwd(),
'apps/backend/src/infrastructure/storage/csv-storage/rates', 'apps/backend/src/infrastructure/storage/csv-storage/rates',
@ -521,7 +528,7 @@ export class CsvRatesAdminController {
// Find config by file path // Find config by file path
const configs = await this.csvConfigRepository.findAll(); const configs = await this.csvConfigRepository.findAll();
const config = configs.find((c) => c.csvFilePath === filename); const config = configs.find(c => c.csvFilePath === filename);
if (!config) { if (!config) {
throw new BadRequestException(`No configuration found for file: ${filename}`); throw new BadRequestException(`No configuration found for file: ${filename}`);

View File

@ -12,9 +12,7 @@ import { CsvBookingService } from '../services/csv-booking.service';
@ApiTags('CSV Booking Actions') @ApiTags('CSV Booking Actions')
@Controller('csv-booking-actions') @Controller('csv-booking-actions')
export class CsvBookingActionsController { export class CsvBookingActionsController {
constructor( constructor(private readonly csvBookingService: CsvBookingService) {}
private readonly csvBookingService: CsvBookingService
) {}
/** /**
* Accept a booking request (PUBLIC - token-based) * Accept a booking request (PUBLIC - token-based)

View File

@ -47,9 +47,7 @@ import {
@ApiTags('CSV Bookings') @ApiTags('CSV Bookings')
@Controller('csv-bookings') @Controller('csv-bookings')
export class CsvBookingsController { export class CsvBookingsController {
constructor( constructor(private readonly csvBookingService: CsvBookingService) {}
private readonly csvBookingService: CsvBookingService
) {}
/** /**
* Create a new CSV booking request * Create a new CSV booking request
@ -219,10 +217,7 @@ export class CsvBookingsController {
status: 400, status: 400,
description: 'Booking cannot be rejected (invalid status or expired)', description: 'Booking cannot be rejected (invalid status or expired)',
}) })
async rejectBooking( async rejectBooking(@Param('token') token: string, @Query('reason') reason: string) {
@Param('token') token: string,
@Query('reason') reason: string
) {
// Reject the booking // Reject the booking
const booking = await this.csvBookingService.rejectBooking(token, reason); const booking = await this.csvBookingService.rejectBooking(token, reason);

View File

@ -10,15 +10,13 @@ import {
Param, Param,
ParseUUIDPipe, ParseUUIDPipe,
} from '@nestjs/common'; } from '@nestjs/common';
import { import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiParam } from '@nestjs/swagger';
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiParam,
} from '@nestjs/swagger';
import { InvitationService } from '../services/invitation.service'; import { InvitationService } from '../services/invitation.service';
import { CreateInvitationDto, InvitationResponseDto, VerifyInvitationDto } from '../dto/invitation.dto'; import {
CreateInvitationDto,
InvitationResponseDto,
VerifyInvitationDto,
} from '../dto/invitation.dto';
import { JwtAuthGuard } from '../guards/jwt-auth.guard'; import { JwtAuthGuard } from '../guards/jwt-auth.guard';
import { RolesGuard } from '../guards/roles.guard'; import { RolesGuard } from '../guards/roles.guard';
import { Roles } from '../decorators/roles.decorator'; import { Roles } from '../decorators/roles.decorator';

View File

@ -16,18 +16,13 @@ import { StorageModule } from '../infrastructure/storage/storage.module';
*/ */
@Module({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature([ TypeOrmModule.forFeature([CsvBookingOrmEntity]),
CsvBookingOrmEntity,
]),
NotificationsModule, NotificationsModule,
EmailModule, EmailModule,
StorageModule, StorageModule,
], ],
controllers: [CsvBookingsController, CsvBookingActionsController], controllers: [CsvBookingsController, CsvBookingActionsController],
providers: [ providers: [CsvBookingService, TypeOrmCsvBookingRepository],
CsvBookingService, exports: [CsvBookingService, TypeOrmCsvBookingRepository],
TypeOrmCsvBookingRepository,
],
exports: [CsvBookingService],
}) })
export class CsvBookingsModule {} export class CsvBookingsModule {}

View File

@ -52,4 +52,24 @@ export class DashboardController {
const organizationId = req.user.organizationId; const organizationId = req.user.organizationId;
return this.analyticsService.getAlerts(organizationId); return this.analyticsService.getAlerts(organizationId);
} }
/**
* Get CSV Booking KPIs
* GET /api/v1/dashboard/csv-booking-kpis
*/
@Get('csv-booking-kpis')
async getCsvBookingKPIs(@Request() req: any) {
const organizationId = req.user.organizationId;
return this.analyticsService.getCsvBookingKPIs(organizationId);
}
/**
* Get Top Carriers
* GET /api/v1/dashboard/top-carriers
*/
@Get('top-carriers')
async getTopCarriers(@Request() req: any) {
const organizationId = req.user.organizationId;
return this.analyticsService.getTopCarriers(organizationId, 5);
}
} }

View File

@ -7,9 +7,10 @@ import { DashboardController } from './dashboard.controller';
import { AnalyticsService } from '../services/analytics.service'; import { AnalyticsService } from '../services/analytics.service';
import { BookingsModule } from '../bookings/bookings.module'; import { BookingsModule } from '../bookings/bookings.module';
import { RatesModule } from '../rates/rates.module'; import { RatesModule } from '../rates/rates.module';
import { CsvBookingsModule } from '../csv-bookings.module';
@Module({ @Module({
imports: [BookingsModule, RatesModule], imports: [BookingsModule, RatesModule, CsvBookingsModule],
controllers: [DashboardController], controllers: [DashboardController],
providers: [AnalyticsService], providers: [AnalyticsService],
exports: [AnalyticsService], exports: [AnalyticsService],

View File

@ -1,4 +1,13 @@
import { IsEmail, IsString, MinLength, IsOptional, ValidateNested, IsEnum, MaxLength, Matches } from 'class-validator'; import {
IsEmail,
IsString,
MinLength,
IsOptional,
ValidateNested,
IsEnum,
MaxLength,
Matches,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { OrganizationType } from '@domain/entities/organization.entity'; import { OrganizationType } from '@domain/entities/organization.entity';
@ -145,7 +154,8 @@ export class RegisterDto {
@ApiPropertyOptional({ @ApiPropertyOptional({
example: '550e8400-e29b-41d4-a716-446655440000', example: '550e8400-e29b-41d4-a716-446655440000',
description: 'Organization ID (optional - for invited users). If not provided, organization data must be provided.', description:
'Organization ID (optional - for invited users). If not provided, organization data must be provided.',
required: false, required: false,
}) })
@IsString() @IsString()
@ -153,7 +163,8 @@ export class RegisterDto {
organizationId?: string; organizationId?: string;
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 'Organization data (required if organizationId and invitationToken are not provided)', description:
'Organization data (required if organizationId and invitationToken are not provided)',
type: RegisterOrganizationDto, type: RegisterOrganizationDto,
required: false, required: false,
}) })

View File

@ -9,6 +9,8 @@ import { BOOKING_REPOSITORY } from '@domain/ports/out/booking.repository';
import { BookingRepository } 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 { RATE_QUOTE_REPOSITORY } from '@domain/ports/out/rate-quote.repository';
import { RateQuoteRepository } 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 { CsvBooking, CsvBookingStatus } from '@domain/entities/csv-booking.entity';
export interface DashboardKPIs { export interface DashboardKPIs {
bookingsThisMonth: number; bookingsThisMonth: number;
@ -47,13 +49,36 @@ export interface DashboardAlert {
isRead: boolean; isRead: boolean;
} }
export interface CsvBookingKPIs {
totalAccepted: number;
totalRejected: number;
totalPending: number;
totalWeightAcceptedKG: number;
totalVolumeAcceptedCBM: number;
acceptanceRate: number; // percentage
acceptedThisMonth: number;
rejectedThisMonth: number;
}
export interface TopCarrier {
carrierName: string;
totalBookings: number;
acceptedBookings: number;
rejectedBookings: number;
acceptanceRate: number;
totalWeightKG: number;
totalVolumeCBM: number;
avgPriceUSD: number;
}
@Injectable() @Injectable()
export class AnalyticsService { export class AnalyticsService {
constructor( constructor(
@Inject(BOOKING_REPOSITORY) @Inject(BOOKING_REPOSITORY)
private readonly bookingRepository: BookingRepository, private readonly bookingRepository: BookingRepository,
@Inject(RATE_QUOTE_REPOSITORY) @Inject(RATE_QUOTE_REPOSITORY)
private readonly rateQuoteRepository: RateQuoteRepository private readonly rateQuoteRepository: RateQuoteRepository,
private readonly csvBookingRepository: TypeOrmCsvBookingRepository
) {} ) {}
/** /**
@ -307,4 +332,133 @@ export class AnalyticsService {
return alerts; return alerts;
} }
/**
* Get CSV Booking KPIs
*/
async getCsvBookingKPIs(organizationId: string): Promise<CsvBookingKPIs> {
const allCsvBookings = await this.csvBookingRepository.findByOrganizationId(organizationId);
const now = new Date();
const thisMonthStart = new Date(now.getFullYear(), now.getMonth(), 1);
// Filter by status
const acceptedBookings = allCsvBookings.filter(
(b: CsvBooking) => b.status === CsvBookingStatus.ACCEPTED
);
const rejectedBookings = allCsvBookings.filter(
(b: CsvBooking) => b.status === CsvBookingStatus.REJECTED
);
const pendingBookings = allCsvBookings.filter(
(b: CsvBooking) => b.status === CsvBookingStatus.PENDING
);
// This month stats
const acceptedThisMonth = acceptedBookings.filter(
(b: CsvBooking) => b.requestedAt >= thisMonthStart
).length;
const rejectedThisMonth = rejectedBookings.filter(
(b: CsvBooking) => b.requestedAt >= thisMonthStart
).length;
// Calculate total weight and volume for accepted bookings
const totalWeightAcceptedKG = acceptedBookings.reduce(
(sum: number, b: CsvBooking) => sum + b.weightKG,
0
);
const totalVolumeAcceptedCBM = acceptedBookings.reduce(
(sum: number, b: CsvBooking) => sum + b.volumeCBM,
0
);
// Calculate acceptance rate
const totalProcessed = acceptedBookings.length + rejectedBookings.length;
const acceptanceRate =
totalProcessed > 0 ? (acceptedBookings.length / totalProcessed) * 100 : 0;
return {
totalAccepted: acceptedBookings.length,
totalRejected: rejectedBookings.length,
totalPending: pendingBookings.length,
totalWeightAcceptedKG,
totalVolumeAcceptedCBM,
acceptanceRate,
acceptedThisMonth,
rejectedThisMonth,
};
}
/**
* Get Top Carriers by booking count
*/
async getTopCarriers(organizationId: string, limit: number = 5): Promise<TopCarrier[]> {
const allCsvBookings = await this.csvBookingRepository.findByOrganizationId(organizationId);
// Group by carrier
const carrierMap = new Map<
string,
{
totalBookings: number;
acceptedBookings: number;
rejectedBookings: number;
totalWeightKG: number;
totalVolumeCBM: number;
totalPriceUSD: number;
}
>();
for (const booking of allCsvBookings) {
const carrierName = booking.carrierName;
if (!carrierMap.has(carrierName)) {
carrierMap.set(carrierName, {
totalBookings: 0,
acceptedBookings: 0,
rejectedBookings: 0,
totalWeightKG: 0,
totalVolumeCBM: 0,
totalPriceUSD: 0,
});
}
const carrier = carrierMap.get(carrierName)!;
carrier.totalBookings++;
if (booking.status === CsvBookingStatus.ACCEPTED) {
carrier.acceptedBookings++;
carrier.totalWeightKG += booking.weightKG;
carrier.totalVolumeCBM += booking.volumeCBM;
}
if (booking.status === CsvBookingStatus.REJECTED) {
carrier.rejectedBookings++;
}
// Add price (prefer USD, fallback to EUR converted)
if (booking.priceUSD) {
carrier.totalPriceUSD += booking.priceUSD;
} else if (booking.priceEUR) {
// Simple EUR to USD conversion (1.1 rate) - in production, use real exchange rate
carrier.totalPriceUSD += booking.priceEUR * 1.1;
}
}
// Convert to array
const topCarriers: TopCarrier[] = Array.from(carrierMap.entries()).map(
([carrierName, data]) => ({
carrierName,
totalBookings: data.totalBookings,
acceptedBookings: data.acceptedBookings,
rejectedBookings: data.rejectedBookings,
acceptanceRate:
data.totalBookings > 0 ? (data.acceptedBookings / data.totalBookings) * 100 : 0,
totalWeightKG: data.totalWeightKG,
totalVolumeCBM: data.totalVolumeCBM,
avgPriceUSD: data.totalBookings > 0 ? data.totalPriceUSD / data.totalBookings : 0,
})
);
// Sort by total bookings (most bookings first)
return topCarriers.sort((a, b) => b.totalBookings - a.totalBookings).slice(0, limit);
}
} }

View File

@ -4,7 +4,13 @@
* Handles carrier authentication and automatic account creation * Handles carrier authentication and automatic account creation
*/ */
import { Injectable, Logger, UnauthorizedException, ConflictException, Inject } from '@nestjs/common'; import {
Injectable,
Logger,
UnauthorizedException,
ConflictException,
Inject,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
@ -111,7 +117,11 @@ export class CarrierAuthService {
// Send welcome email with credentials and WAIT for confirmation // Send welcome email with credentials and WAIT for confirmation
try { try {
await this.emailAdapter.sendCarrierAccountCreated(carrierEmail, carrierName, temporaryPassword); await this.emailAdapter.sendCarrierAccountCreated(
carrierEmail,
carrierName,
temporaryPassword
);
this.logger.log(`Account creation email sent to ${carrierEmail}`); this.logger.log(`Account creation email sent to ${carrierEmail}`);
} catch (error: any) { } catch (error: any) {
this.logger.error(`Failed to send account creation email: ${error?.message}`, error?.stack); this.logger.error(`Failed to send account creation email: ${error?.message}`, error?.stack);
@ -148,7 +158,10 @@ export class CarrierAuthService {
/** /**
* Standard login for carriers * Standard login for carriers
*/ */
async login(email: string, password: string): Promise<{ async login(
email: string,
password: string
): Promise<{
accessToken: string; accessToken: string;
refreshToken: string; refreshToken: string;
carrier: { carrier: {
@ -288,7 +301,11 @@ export class CarrierAuthService {
// Send password reset email and WAIT for confirmation // Send password reset email and WAIT for confirmation
try { try {
await this.emailAdapter.sendCarrierPasswordReset(email, carrier.companyName, temporaryPassword); await this.emailAdapter.sendCarrierPasswordReset(
email,
carrier.companyName,
temporaryPassword
);
this.logger.log(`Password reset email sent to ${email}`); this.logger.log(`Password reset email sent to ${email}`);
} catch (error: any) { } catch (error: any) {
this.logger.error(`Failed to send password reset email: ${error?.message}`, error?.stack); this.logger.error(`Failed to send password reset email: ${error?.message}`, error?.stack);

View File

@ -161,7 +161,11 @@ export class CsvBookingService {
* Get booking by ID * Get booking by ID
* Accessible by: booking owner OR assigned carrier * Accessible by: booking owner OR assigned carrier
*/ */
async getBookingById(id: string, userId: string, carrierId?: string): Promise<CsvBookingResponseDto> { async getBookingById(
id: string,
userId: string,
carrierId?: string
): Promise<CsvBookingResponseDto> {
const booking = await this.csvBookingRepository.findById(id); const booking = await this.csvBookingRepository.findById(id);
if (!booking) { if (!booking) {

View File

@ -7,9 +7,15 @@ import {
BadRequestException, BadRequestException,
} from '@nestjs/common'; } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { InvitationTokenRepository, INVITATION_TOKEN_REPOSITORY } from '@domain/ports/out/invitation-token.repository'; import {
InvitationTokenRepository,
INVITATION_TOKEN_REPOSITORY,
} from '@domain/ports/out/invitation-token.repository';
import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.repository'; import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.repository';
import { OrganizationRepository, ORGANIZATION_REPOSITORY } from '@domain/ports/out/organization.repository'; import {
OrganizationRepository,
ORGANIZATION_REPOSITORY,
} from '@domain/ports/out/organization.repository';
import { EmailPort, EMAIL_PORT } from '@domain/ports/out/email.port'; import { EmailPort, EMAIL_PORT } from '@domain/ports/out/email.port';
import { InvitationToken } from '@domain/entities/invitation-token.entity'; import { InvitationToken } from '@domain/entities/invitation-token.entity';
import { UserRole } from '@domain/entities/user.entity'; import { UserRole } from '@domain/entities/user.entity';
@ -189,7 +195,10 @@ export class InvitationService {
this.logger.log(`[INVITATION] ✅ Email sent successfully to ${invitation.email}`); this.logger.log(`[INVITATION] ✅ Email sent successfully to ${invitation.email}`);
} catch (error) { } catch (error) {
this.logger.error(`[INVITATION] ❌ Failed to send invitation email to ${invitation.email}`, error); this.logger.error(
`[INVITATION] ❌ Failed to send invitation email to ${invitation.email}`,
error
);
this.logger.error(`[INVITATION] Error details: ${JSON.stringify(error, null, 2)}`); this.logger.error(`[INVITATION] Error details: ${JSON.stringify(error, null, 2)}`);
throw error; throw error;
} }

View File

@ -17,7 +17,11 @@ export interface CsvRateLoaderPort {
* @returns Array of CSV rates * @returns Array of CSV rates
* @throws Error if file cannot be read or parsed * @throws Error if file cannot be read or parsed
*/ */
loadRatesFromCsv(filePath: string, companyEmail: string, companyNameOverride?: string): Promise<CsvRate[]>; loadRatesFromCsv(
filePath: string,
companyEmail: string,
companyNameOverride?: string
): Promise<CsvRate[]>;
/** /**
* Load rates for a specific company * Load rates for a specific company

View File

@ -177,9 +177,13 @@ export class CsvRateSearchService implements SearchCsvRatesPort {
}); });
// Apply service level price adjustment to the total price // Apply service level price adjustment to the total price
const adjustedTotalPrice = priceBreakdown.totalPrice * const adjustedTotalPrice =
(offer.serviceLevel === ServiceLevel.RAPID ? 1.20 : priceBreakdown.totalPrice *
offer.serviceLevel === ServiceLevel.ECONOMIC ? 0.85 : 1.00); (offer.serviceLevel === ServiceLevel.RAPID
? 1.2
: offer.serviceLevel === ServiceLevel.ECONOMIC
? 0.85
: 1.0);
return { return {
rate: offer.rate, rate: offer.rate,
@ -207,8 +211,8 @@ export class CsvRateSearchService implements SearchCsvRatesPort {
// Apply service level filter if specified // Apply service level filter if specified
let filteredResults = results; let filteredResults = results;
if (input.filters?.serviceLevels && input.filters.serviceLevels.length > 0) { if (input.filters?.serviceLevels && input.filters.serviceLevels.length > 0) {
filteredResults = results.filter(r => filteredResults = results.filter(
r.serviceLevel && input.filters!.serviceLevels!.includes(r.serviceLevel) r => r.serviceLevel && input.filters!.serviceLevels!.includes(r.serviceLevel)
); );
} }
@ -252,14 +256,20 @@ export class CsvRateSearchService implements SearchCsvRatesPort {
// Use allSettled to handle missing files gracefully // Use allSettled to handle missing files gracefully
const results = await Promise.allSettled(ratePromises); const results = await Promise.allSettled(ratePromises);
const rateArrays = results const rateArrays = results
.filter((result): result is PromiseFulfilledResult<CsvRate[]> => result.status === 'fulfilled') .filter(
(result): result is PromiseFulfilledResult<CsvRate[]> => result.status === 'fulfilled'
)
.map(result => result.value); .map(result => result.value);
// Log any failed file loads // Log any failed file loads
const failures = results.filter(result => result.status === 'rejected'); const failures = results.filter(result => result.status === 'rejected');
if (failures.length > 0) { if (failures.length > 0) {
console.warn(`Failed to load ${failures.length} CSV files:`, console.warn(
failures.map((f, idx) => `${configs[idx]?.csvFilePath}: ${(f as PromiseRejectedResult).reason}`)); `Failed to load ${failures.length} CSV files:`,
failures.map(
(f, idx) => `${configs[idx]?.csvFilePath}: ${(f as PromiseRejectedResult).reason}`
)
);
} }
return rateArrays.flat(); return rateArrays.flat();
@ -274,7 +284,9 @@ export class CsvRateSearchService implements SearchCsvRatesPort {
// Use allSettled here too for consistency // Use allSettled here too for consistency
const results = await Promise.allSettled(ratePromises); const results = await Promise.allSettled(ratePromises);
const rateArrays = results const rateArrays = results
.filter((result): result is PromiseFulfilledResult<CsvRate[]> => result.status === 'fulfilled') .filter(
(result): result is PromiseFulfilledResult<CsvRate[]> => result.status === 'fulfilled'
)
.map(result => result.value); .map(result => result.value);
return rateArrays.flat(); return rateArrays.flat();

View File

@ -98,7 +98,7 @@ describe('RateOfferGeneratorService', () => {
expect(rapid!.priceAdjustmentPercent).toBe(20); expect(rapid!.priceAdjustmentPercent).toBe(20);
}); });
it('STANDARD doit avoir le prix de base (pas d\'ajustement)', () => { it("STANDARD doit avoir le prix de base (pas d'ajustement)", () => {
const offers = service.generateOffers(mockRate); const offers = service.generateOffers(mockRate);
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD); const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD);
@ -141,7 +141,7 @@ describe('RateOfferGeneratorService', () => {
expect(economic!.transitAdjustmentPercent).toBe(50); expect(economic!.transitAdjustmentPercent).toBe(50);
}); });
it('STANDARD doit avoir le transit time de base (pas d\'ajustement)', () => { it("STANDARD doit avoir le transit time de base (pas d'ajustement)", () => {
const offers = service.generateOffers(mockRate); const offers = service.generateOffers(mockRate);
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD); const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD);
@ -257,7 +257,7 @@ describe('RateOfferGeneratorService', () => {
}); });
describe('getCheapestOffer', () => { describe('getCheapestOffer', () => {
it('doit retourner l\'offre ECONOMIC la moins chère', () => { it("doit retourner l'offre ECONOMIC la moins chère", () => {
const rate1 = mockRate; // 1000 USD base const rate1 = mockRate; // 1000 USD base
const rate2 = { const rate2 = {
...mockRate, ...mockRate,
@ -282,7 +282,7 @@ describe('RateOfferGeneratorService', () => {
}); });
describe('getFastestOffer', () => { describe('getFastestOffer', () => {
it('doit retourner l\'offre RAPID la plus rapide', () => { it("doit retourner l'offre RAPID la plus rapide", () => {
const rate1 = { ...mockRate, transitDays: 20 } as any; const rate1 = { ...mockRate, transitDays: 20 } as any;
const rate2 = { ...mockRate, transitDays: 10 } as any; const rate2 = { ...mockRate, transitDays: 10 } as any;

View File

@ -58,18 +58,18 @@ export class RateOfferGeneratorService {
*/ */
private readonly SERVICE_LEVEL_CONFIGS: Record<ServiceLevel, ServiceLevelConfig> = { private readonly SERVICE_LEVEL_CONFIGS: Record<ServiceLevel, ServiceLevelConfig> = {
[ServiceLevel.RAPID]: { [ServiceLevel.RAPID]: {
priceMultiplier: 1.20, // +20% du prix de base priceMultiplier: 1.2, // +20% du prix de base
transitMultiplier: 0.70, // -30% du temps de transit (plus rapide) transitMultiplier: 0.7, // -30% du temps de transit (plus rapide)
description: 'Express - Livraison rapide avec service prioritaire', description: 'Express - Livraison rapide avec service prioritaire',
}, },
[ServiceLevel.STANDARD]: { [ServiceLevel.STANDARD]: {
priceMultiplier: 1.00, // Prix de base (pas de changement) priceMultiplier: 1.0, // Prix de base (pas de changement)
transitMultiplier: 1.00, // Transit time de base (pas de changement) transitMultiplier: 1.0, // Transit time de base (pas de changement)
description: 'Standard - Service régulier au meilleur rapport qualité/prix', description: 'Standard - Service régulier au meilleur rapport qualité/prix',
}, },
[ServiceLevel.ECONOMIC]: { [ServiceLevel.ECONOMIC]: {
priceMultiplier: 0.85, // -15% du prix de base priceMultiplier: 0.85, // -15% du prix de base
transitMultiplier: 1.50, // +50% du temps de transit (plus lent) transitMultiplier: 1.5, // +50% du temps de transit (plus lent)
description: 'Économique - Tarif réduit avec délai étendu', description: 'Économique - Tarif réduit avec délai étendu',
}, },
}; };
@ -110,9 +110,7 @@ export class RateOfferGeneratorService {
// Calculer le transit time ajusté (avec contraintes min/max) // Calculer le transit time ajusté (avec contraintes min/max)
const rawTransitDays = baseTransitDays * config.transitMultiplier; const rawTransitDays = baseTransitDays * config.transitMultiplier;
const adjustedTransitDays = this.constrainTransitDays( const adjustedTransitDays = this.constrainTransitDays(Math.round(rawTransitDays));
Math.round(rawTransitDays)
);
// Calculer les pourcentages d'ajustement // Calculer les pourcentages d'ajustement
const priceAdjustmentPercent = Math.round((config.priceMultiplier - 1) * 100); const priceAdjustmentPercent = Math.round((config.priceMultiplier - 1) * 100);

View File

@ -90,8 +90,14 @@ export class CsvRateLoaderAdapter implements CsvRateLoaderPort {
} }
} }
async loadRatesFromCsv(filePath: string, companyEmail: string, companyNameOverride?: string): Promise<CsvRate[]> { async loadRatesFromCsv(
this.logger.log(`Loading rates from CSV: ${filePath} (email: ${companyEmail}, company: ${companyNameOverride || 'from CSV'})`); filePath: string,
companyEmail: string,
companyNameOverride?: string
): Promise<CsvRate[]> {
this.logger.log(
`Loading rates from CSV: ${filePath} (email: ${companyEmail}, company: ${companyNameOverride || 'from CSV'})`
);
try { try {
let fileContent: string; let fileContent: string;
@ -114,7 +120,9 @@ export class CsvRateLoaderAdapter implements CsvRateLoaderPort {
throw new Error('No MinIO object key found, using local file'); throw new Error('No MinIO object key found, using local file');
} }
} catch (minioError: any) { } catch (minioError: any) {
this.logger.warn(`⚠️ Failed to load from MinIO: ${minioError.message}. Falling back to local file.`); this.logger.warn(
`⚠️ Failed to load from MinIO: ${minioError.message}. Falling back to local file.`
);
// Fallback to local file system // Fallback to local file system
const fullPath = path.isAbsolute(filePath) const fullPath = path.isAbsolute(filePath)
? filePath ? filePath
@ -252,7 +260,9 @@ export class CsvRateLoaderAdapter implements CsvRateLoaderPort {
this.logger.warn('⚠️ No CSV files configured in MinIO, falling back to local files'); this.logger.warn('⚠️ No CSV files configured in MinIO, falling back to local files');
} }
} catch (minioError: any) { } catch (minioError: any) {
this.logger.warn(`⚠️ Failed to list MinIO files: ${minioError.message}. Falling back to local files.`); this.logger.warn(
`⚠️ Failed to list MinIO files: ${minioError.message}. Falling back to local files.`
);
} }
} }
@ -313,7 +323,11 @@ export class CsvRateLoaderAdapter implements CsvRateLoaderPort {
/** /**
* Map CSV row to CsvRate domain entity * Map CSV row to CsvRate domain entity
*/ */
private mapToCsvRate(record: CsvRow, companyEmail: string, companyNameOverride?: string): CsvRate { private mapToCsvRate(
record: CsvRow,
companyEmail: string,
companyNameOverride?: string
): CsvRate {
// Parse surcharges // Parse surcharges
const surcharges = this.parseSurcharges(record); const surcharges = this.parseSurcharges(record);

View File

@ -42,7 +42,7 @@ export class CarrierActivityOrmEntity {
@Column({ name: 'carrier_id', type: 'uuid' }) @Column({ name: 'carrier_id', type: 'uuid' })
carrierId: string; carrierId: string;
@ManyToOne(() => CarrierProfileOrmEntity, (carrier) => carrier.activities, { @ManyToOne(() => CarrierProfileOrmEntity, carrier => carrier.activities, {
onDelete: 'CASCADE', onDelete: 'CASCADE',
}) })
@JoinColumn({ name: 'carrier_id' }) @JoinColumn({ name: 'carrier_id' })

View File

@ -118,9 +118,9 @@ export class CarrierProfileOrmEntity {
updatedAt: Date; updatedAt: Date;
// Relations // Relations
@OneToMany(() => CsvBookingOrmEntity, (booking) => booking.carrierProfile) @OneToMany(() => CsvBookingOrmEntity, booking => booking.carrierProfile)
bookings: CsvBookingOrmEntity[]; bookings: CsvBookingOrmEntity[];
@OneToMany(() => CarrierActivityOrmEntity, (activity) => activity.carrierProfile) @OneToMany(() => CarrierActivityOrmEntity, activity => activity.carrierProfile)
activities: CarrierActivityOrmEntity[]; activities: CarrierActivityOrmEntity[];
} }

View File

@ -113,7 +113,7 @@ export class CsvBookingOrmEntity {
@Column({ name: 'carrier_id', type: 'uuid', nullable: true }) @Column({ name: 'carrier_id', type: 'uuid', nullable: true })
carrierId: string | null; carrierId: string | null;
@ManyToOne(() => CarrierProfileOrmEntity, (carrier) => carrier.bookings, { @ManyToOne(() => CarrierProfileOrmEntity, carrier => carrier.bookings, {
onDelete: 'SET NULL', onDelete: 'SET NULL',
}) })
@JoinColumn({ name: 'carrier_id' }) @JoinColumn({ name: 'carrier_id' })

View File

@ -16,7 +16,8 @@ export class SeedTestUsers1730000000007 implements MigrationInterface {
// Pre-hashed password: Password123! (Argon2id) // Pre-hashed password: Password123! (Argon2id)
// Generated with: argon2.hash('Password123!', { type: argon2.argon2id, memoryCost: 65536, timeCost: 3, parallelism: 4 }) // Generated with: argon2.hash('Password123!', { type: argon2.argon2id, memoryCost: 65536, timeCost: 3, parallelism: 4 })
const passwordHash = '$argon2id$v=19$m=65536,t=3,p=4$Uj+yeQiaqgBFqyTJ5FX3Cw$wpRCYORyFwjQFSuO3gpmzh10gx9wjYFOCvVZ8TVaP8Q'; const passwordHash =
'$argon2id$v=19$m=65536,t=3,p=4$Uj+yeQiaqgBFqyTJ5FX3Cw$wpRCYORyFwjQFSuO3gpmzh10gx9wjYFOCvVZ8TVaP8Q';
// Fixed UUIDs for test users (matching existing data in database) // Fixed UUIDs for test users (matching existing data in database)
const users = [ const users = [

View File

@ -7,7 +7,10 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { CarrierActivityOrmEntity, CarrierActivityType } from '../entities/carrier-activity.orm-entity'; import {
CarrierActivityOrmEntity,
CarrierActivityType,
} from '../entities/carrier-activity.orm-entity';
@Injectable() @Injectable()
export class CarrierActivityRepository { export class CarrierActivityRepository {
@ -27,7 +30,9 @@ export class CarrierActivityRepository {
ipAddress?: string | null; ipAddress?: string | null;
userAgent?: string | null; userAgent?: string | null;
}): Promise<CarrierActivityOrmEntity> { }): Promise<CarrierActivityOrmEntity> {
this.logger.log(`Creating carrier activity: ${data.activityType} for carrier ${data.carrierId}`); this.logger.log(
`Creating carrier activity: ${data.activityType} for carrier ${data.carrierId}`
);
const activity = this.repository.create(data); const activity = this.repository.create(data);
const saved = await this.repository.save(activity); const saved = await this.repository.save(activity);
@ -36,7 +41,10 @@ export class CarrierActivityRepository {
return saved; return saved;
} }
async findByCarrierId(carrierId: string, limit: number = 10): Promise<CarrierActivityOrmEntity[]> { async findByCarrierId(
carrierId: string,
limit: number = 10
): Promise<CarrierActivityOrmEntity[]> {
this.logger.log(`Finding activities for carrier: ${carrierId} (limit: ${limit})`); this.logger.log(`Finding activities for carrier: ${carrierId} (limit: ${limit})`);
const activities = await this.repository.find({ const activities = await this.repository.find({
@ -74,7 +82,9 @@ export class CarrierActivityRepository {
take: limit, take: limit,
}); });
this.logger.log(`Found ${activities.length} ${activityType} activities for carrier: ${carrierId}`); this.logger.log(
`Found ${activities.length} ${activityType} activities for carrier: ${carrierId}`
);
return activities; return activities;
} }

View File

@ -76,7 +76,10 @@ export class CarrierProfileRepository {
return saved; return saved;
} }
async update(id: string, data: Partial<CarrierProfileOrmEntity>): Promise<CarrierProfileOrmEntity> { async update(
id: string,
data: Partial<CarrierProfileOrmEntity>
): Promise<CarrierProfileOrmEntity> {
this.logger.log(`Updating carrier profile: ${id}`); this.logger.log(`Updating carrier profile: ${id}`);
await this.repository.update(id, data); await this.repository.update(id, data);
@ -131,7 +134,9 @@ export class CarrierProfileRepository {
relations: ['user', 'organization'], relations: ['user', 'organization'],
}); });
this.logger.log(`Found ${profiles.length} carrier profiles for organization: ${organizationId}`); this.logger.log(
`Found ${profiles.length} carrier profiles for organization: ${organizationId}`
);
return profiles; return profiles;
} }

View File

@ -14,7 +14,7 @@ async function deleteOrphanedConfig() {
console.log('🔍 Searching for orphaned test.csv configuration...'); console.log('🔍 Searching for orphaned test.csv configuration...');
const configs = await repository.findAll(); const configs = await repository.findAll();
const orphanedConfig = configs.find((c) => c.csvFilePath === 'test.csv'); const orphanedConfig = configs.find(c => c.csvFilePath === 'test.csv');
if (!orphanedConfig) { if (!orphanedConfig) {
console.log('✅ No orphaned test.csv configuration found'); console.log('✅ No orphaned test.csv configuration found');
@ -22,7 +22,9 @@ async function deleteOrphanedConfig() {
return; return;
} }
console.log(`📄 Found orphaned config: ${orphanedConfig.companyName} - ${orphanedConfig.csvFilePath}`); console.log(
`📄 Found orphaned config: ${orphanedConfig.companyName} - ${orphanedConfig.csvFilePath}`
);
console.log(` ID: ${orphanedConfig.id}`); console.log(` ID: ${orphanedConfig.id}`);
console.log(` Uploaded: ${orphanedConfig.uploadedAt}`); console.log(` Uploaded: ${orphanedConfig.uploadedAt}`);
@ -30,7 +32,6 @@ async function deleteOrphanedConfig() {
await repository.delete(orphanedConfig.companyName); await repository.delete(orphanedConfig.companyName);
console.log('✅ Successfully deleted orphaned test.csv configuration'); console.log('✅ Successfully deleted orphaned test.csv configuration');
} catch (error: any) { } catch (error: any) {
console.error('❌ Error deleting orphaned config:', error.message); console.error('❌ Error deleting orphaned config:', error.message);
process.exit(1); process.exit(1);

View File

@ -116,9 +116,7 @@ describe('Carrier Portal (e2e)', () => {
}); });
it('should return 401 without auth token', () => { it('should return 401 without auth token', () => {
return request(app.getHttpServer()) return request(app.getHttpServer()).get('/api/v1/carrier-auth/me').expect(401);
.get('/api/v1/carrier-auth/me')
.expect(401);
}); });
}); });
@ -169,9 +167,7 @@ describe('Carrier Portal (e2e)', () => {
}); });
it('should return 401 without auth token', () => { it('should return 401 without auth token', () => {
return request(app.getHttpServer()) return request(app.getHttpServer()).get('/api/v1/carrier-dashboard/stats').expect(401);
.get('/api/v1/carrier-dashboard/stats')
.expect(401);
}); });
}); });

View File

@ -1,361 +1,287 @@
/** /**
* Dashboard Home Page * Dashboard Home Page
* *
* Main dashboard with KPIs, charts, and alerts * Main dashboard with CSV Booking KPIs and carrier analytics
*/ */
'use client'; 'use client';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { dashboardApi, listBookings } from '@/lib/api'; import { dashboardApi } from '@/lib/api';
import Link from 'next/link'; import Link from 'next/link';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { import {
LineChart, Package,
Line, PackageCheck,
BarChart, PackageX,
Bar, Clock,
XAxis, Weight,
YAxis, TrendingUp,
CartesianGrid, Plus,
Tooltip, ArrowRight,
Legend, } from 'lucide-react';
ResponsiveContainer,
} from 'recharts';
export default function DashboardPage() { export default function DashboardPage() {
// Fetch dashboard data // Fetch CSV booking KPIs
const { data: kpis, isLoading: kpisLoading } = useQuery({ const { data: csvKpis, isLoading: csvKpisLoading } = useQuery({
queryKey: ['dashboard', 'kpis'], queryKey: ['dashboard', 'csv-booking-kpis'],
queryFn: () => dashboardApi.getKPIs(), queryFn: () => dashboardApi.getCsvBookingKPIs(),
}); });
const { data: chartData, isLoading: chartLoading } = useQuery({ // Fetch top carriers
queryKey: ['dashboard', 'bookings-chart'], const { data: topCarriers, isLoading: carriersLoading } = useQuery({
queryFn: () => dashboardApi.getBookingsChart(), queryKey: ['dashboard', 'top-carriers'],
queryFn: () => dashboardApi.getTopCarriers(),
}); });
const { data: tradeLanes, isLoading: tradeLanesLoading } = useQuery({
queryKey: ['dashboard', 'top-trade-lanes'],
queryFn: () => dashboardApi.getTopTradeLanes(),
});
const { data: alerts, isLoading: alertsLoading } = useQuery({
queryKey: ['dashboard', 'alerts'],
queryFn: () => dashboardApi.getAlerts(),
});
const { data: recentBookings, isLoading: bookingsLoading } = useQuery({
queryKey: ['bookings', 'recent'],
queryFn: () => listBookings({ limit: 5 }),
});
// Format chart data for Recharts
const formattedChartData = chartData
? chartData.labels.map((label, index) => ({
month: label,
bookings: chartData.data[index],
}))
: [];
// Format change percentage
const formatChange = (value: number) => {
const sign = value >= 0 ? '+' : '';
return `${sign}${value.toFixed(1)}%`;
};
// Get change color
const getChangeColor = (value: number) => {
if (value > 0) return 'text-green-600';
if (value < 0) return 'text-red-600';
return 'text-gray-600';
};
// Get alert color
const getAlertColor = (severity: string) => {
const colors = {
critical: 'bg-red-100 border-red-500 text-red-800',
high: 'bg-orange-100 border-orange-500 text-orange-800',
medium: 'bg-yellow-100 border-yellow-500 text-yellow-800',
low: 'bg-blue-100 border-blue-500 text-blue-800',
};
return colors[severity as keyof typeof colors] || colors.low;
};
return ( return (
<div className="space-y-6"> <div className="space-y-8 p-8">
{/* Welcome Section */} {/* Header Section */}
<div className="bg-gradient-to-r from-blue-600 to-blue-700 rounded-lg shadow-lg p-6 text-white"> <div className="flex items-center justify-between">
<h1 className="text-3xl font-bold mb-2">Welcome back!</h1> <div>
<p className="text-blue-100">Here's what's happening with your shipments today.</p> <h1 className="text-4xl font-bold tracking-tight">Dashboard</h1>
<p className="text-muted-foreground mt-2">
Suivez vos bookings et vos performances
</p>
</div> </div>
{/* KPI Cards */} {/* CTA Button */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6"> <Link href="/dashboard/bookings/new">
{kpisLoading ? ( <Button size="lg" className="gap-2">
// Loading skeletons <Plus className="h-5 w-5" />
Array.from({ length: 4 }).map((_, i) => ( Nouveau Booking
<div key={i} className="bg-white rounded-lg shadow p-6 animate-pulse"> </Button>
<div className="h-4 bg-gray-200 rounded w-1/2 mb-4"></div> </Link>
<div className="h-8 bg-gray-200 rounded w-3/4"></div>
</div> </div>
))
{/* KPI Cards Grid */}
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
{/* Bookings Acceptés */}
<Card className="hover:shadow-lg transition-shadow">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Bookings Acceptés</CardTitle>
<PackageCheck className="h-5 w-5 text-green-600" />
</CardHeader>
<CardContent>
{csvKpisLoading ? (
<div className="h-8 w-20 bg-gray-200 animate-pulse rounded" />
) : ( ) : (
<> <>
<div className="bg-white rounded-lg shadow p-6 hover:shadow-md transition-shadow"> <div className="text-3xl font-bold">{csvKpis?.totalAccepted || 0}</div>
<div className="flex items-center justify-between"> <p className="text-xs text-muted-foreground mt-2">
<div> +{csvKpis?.acceptedThisMonth || 0} ce mois
<p className="text-sm font-medium text-gray-600">Bookings This Month</p>
<p className="text-3xl font-bold text-gray-900 mt-2">
{kpis?.bookingsThisMonth || 0}
</p> </p>
</div>
<div className="text-4xl">📦</div>
</div>
<div className="mt-4">
<span
className={`text-sm font-medium ${getChangeColor(
kpis?.bookingsThisMonthChange || 0
)}`}
>
{formatChange(kpis?.bookingsThisMonthChange || 0)}
</span>
<span className="text-sm text-gray-500 ml-2">vs last month</span>
</div>
</div>
<div className="bg-white rounded-lg shadow p-6 hover:shadow-md transition-shadow">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Total TEUs</p>
<p className="text-3xl font-bold text-gray-900 mt-2">{kpis?.totalTEUs || 0}</p>
</div>
<div className="text-4xl">📊</div>
</div>
<div className="mt-4">
<span
className={`text-sm font-medium ${getChangeColor(kpis?.totalTEUsChange || 0)}`}
>
{formatChange(kpis?.totalTEUsChange || 0)}
</span>
<span className="text-sm text-gray-500 ml-2">vs last month</span>
</div>
</div>
<div className="bg-white rounded-lg shadow p-6 hover:shadow-md transition-shadow">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Estimated Revenue</p>
<p className="text-3xl font-bold text-gray-900 mt-2">
${(kpis?.estimatedRevenue || 0).toLocaleString()}
</p>
</div>
<div className="text-4xl">💰</div>
</div>
<div className="mt-4">
<span
className={`text-sm font-medium ${getChangeColor(
kpis?.estimatedRevenueChange || 0
)}`}
>
{formatChange(kpis?.estimatedRevenueChange || 0)}
</span>
<span className="text-sm text-gray-500 ml-2">vs last month</span>
</div>
</div>
<div className="bg-white rounded-lg shadow p-6 hover:shadow-md transition-shadow">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Pending Confirmations</p>
<p className="text-3xl font-bold text-gray-900 mt-2">
{kpis?.pendingConfirmations || 0}
</p>
</div>
<div className="text-4xl"></div>
</div>
<div className="mt-4">
<span
className={`text-sm font-medium ${getChangeColor(
kpis?.pendingConfirmationsChange || 0
)}`}
>
{formatChange(kpis?.pendingConfirmationsChange || 0)}
</span>
<span className="text-sm text-gray-500 ml-2">vs last month</span>
</div>
</div>
</> </>
)} )}
</CardContent>
</Card>
{/* Bookings Refusés */}
<Card className="hover:shadow-lg transition-shadow">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Bookings Refusés</CardTitle>
<PackageX className="h-5 w-5 text-red-600" />
</CardHeader>
<CardContent>
{csvKpisLoading ? (
<div className="h-8 w-20 bg-gray-200 animate-pulse rounded" />
) : (
<>
<div className="text-3xl font-bold">{csvKpis?.totalRejected || 0}</div>
<p className="text-xs text-muted-foreground mt-2">
+{csvKpis?.rejectedThisMonth || 0} ce mois
</p>
</>
)}
</CardContent>
</Card>
{/* Bookings En Attente */}
<Card className="hover:shadow-lg transition-shadow">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">En Attente</CardTitle>
<Clock className="h-5 w-5 text-yellow-600" />
</CardHeader>
<CardContent>
{csvKpisLoading ? (
<div className="h-8 w-20 bg-gray-200 animate-pulse rounded" />
) : (
<>
<div className="text-3xl font-bold">{csvKpis?.totalPending || 0}</div>
<p className="text-xs text-muted-foreground mt-2">
{csvKpis?.acceptanceRate.toFixed(1)}% taux d'acceptation
</p>
</>
)}
</CardContent>
</Card>
{/* Poids Total Accepté */}
<Card className="hover:shadow-lg transition-shadow">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Poids Total Accepté</CardTitle>
<Weight className="h-5 w-5 text-blue-600" />
</CardHeader>
<CardContent>
{csvKpisLoading ? (
<div className="h-8 w-20 bg-gray-200 animate-pulse rounded" />
) : (
<>
<div className="text-3xl font-bold">
{(csvKpis?.totalWeightAcceptedKG || 0).toLocaleString()}
</div>
<p className="text-xs text-muted-foreground mt-2">
KG ({(csvKpis?.totalVolumeAcceptedCBM || 0).toFixed(2)} CBM)
</p>
</>
)}
</CardContent>
</Card>
</div> </div>
{/* Alerts Section */} {/* Stats Overview Card */}
{!alertsLoading && alerts && alerts.length > 0 && ( <Card>
<div className="bg-white rounded-lg shadow p-6"> <CardHeader>
<h2 className="text-lg font-semibold text-gray-900 mb-4"> Alerts & Notifications</h2> <CardTitle>Vue d'ensemble</CardTitle>
<div className="space-y-3"> <CardDescription>Statistiques globales de vos bookings</CardDescription>
{alerts.slice(0, 5).map(alert => ( </CardHeader>
<div <CardContent>
key={alert.id} <div className="grid gap-6 md:grid-cols-3">
className={`border-l-4 p-4 rounded ${getAlertColor(alert.severity)}`} <div className="flex items-center space-x-4">
> <div className="flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
<div className="flex items-start justify-between"> <TrendingUp className="h-6 w-6 text-green-600" />
<div className="flex-1"> </div>
<h3 className="font-semibold">{alert.title}</h3> <div>
<p className="text-sm mt-1">{alert.message}</p> <p className="text-sm font-medium text-muted-foreground">Taux d'acceptation</p>
{alert.bookingNumber && ( <p className="text-2xl font-bold">
<Link {csvKpisLoading ? '--' : `${csvKpis?.acceptanceRate.toFixed(1)}%`}
href={`/dashboard/bookings/${alert.bookingId}`} </p>
className="text-sm font-medium underline mt-2 inline-block" </div>
> </div>
View Booking {alert.bookingNumber}
<div className="flex items-center space-x-4">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-blue-100">
<Package className="h-6 w-6 text-blue-600" />
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">Total bookings</p>
<p className="text-2xl font-bold">
{csvKpisLoading
? '--'
: (csvKpis?.totalAccepted || 0) +
(csvKpis?.totalRejected || 0) +
(csvKpis?.totalPending || 0)}
</p>
</div>
</div>
<div className="flex items-center space-x-4">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-purple-100">
<Weight className="h-6 w-6 text-purple-600" />
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">Volume total accepté</p>
<p className="text-2xl font-bold">
{csvKpisLoading ? '--' : `${(csvKpis?.totalVolumeAcceptedCBM || 0).toFixed(1)}`}
<span className="text-sm font-normal text-muted-foreground ml-1">CBM</span>
</p>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Top Carriers Section */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Meilleures Compagnies</CardTitle>
<CardDescription>Top 5 des transporteurs avec qui vous avez le plus booké</CardDescription>
</div>
<Link href="/dashboard/bookings">
<Button variant="ghost" size="sm" className="gap-2">
Voir tous
<ArrowRight className="h-4 w-4" />
</Button>
</Link> </Link>
</div>
</CardHeader>
<CardContent>
{carriersLoading ? (
<div className="space-y-4">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="h-20 bg-gray-100 animate-pulse rounded" />
))}
</div>
) : topCarriers && topCarriers.length > 0 ? (
<div className="space-y-4">
{topCarriers.map((carrier, index) => (
<div
key={carrier.carrierName}
className="flex items-center justify-between p-4 border rounded-lg hover:bg-accent/50 transition-colors"
>
<div className="flex items-center space-x-4">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10 font-bold text-primary">
#{index + 1}
</div>
<div>
<h3 className="font-semibold">{carrier.carrierName}</h3>
<p className="text-sm text-muted-foreground">
{carrier.totalBookings} bookings {carrier.totalWeightKG.toLocaleString()} KG
</p>
</div>
</div>
<div className="flex items-center space-x-4">
<div className="text-right">
<div className="flex items-center gap-2">
<Badge variant="secondary" className="bg-green-100 text-green-800">
{carrier.acceptedBookings} acceptés
</Badge>
{carrier.rejectedBookings > 0 && (
<Badge variant="secondary" className="bg-red-100 text-red-800">
{carrier.rejectedBookings} refusés
</Badge>
)} )}
</div> </div>
<span className="text-xs font-medium uppercase ml-4">{alert.severity}</span> <p className="text-sm text-muted-foreground mt-1">
Taux: {carrier.acceptanceRate.toFixed(0)}% Moy:{' '}
${carrier.avgPriceUSD.toFixed(0)}
</p>
</div>
</div> </div>
</div> </div>
))} ))}
</div> </div>
</div>
)}
{/* Charts Section */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Bookings Trend Chart */}
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Bookings Trend (6 Months)</h2>
{chartLoading ? (
<div className="h-64 bg-gray-100 animate-pulse rounded"></div>
) : ( ) : (
<ResponsiveContainer width="100%" height={300}> <div className="text-center py-12">
<LineChart data={formattedChartData}> <Package className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<CartesianGrid strokeDasharray="3 3" /> <h3 className="text-lg font-medium text-gray-900 mb-2">Aucun booking pour l'instant</h3>
<XAxis dataKey="month" /> <p className="text-muted-foreground mb-6">
<YAxis /> Créez votre premier booking pour voir vos statistiques
<Tooltip /> </p>
<Legend /> <Link href="/dashboard/bookings/new">
<Line type="monotone" dataKey="bookings" stroke="#3b82f6" strokeWidth={2} /> <Button>
</LineChart> <Plus className="mr-2 h-4 w-4" />
</ResponsiveContainer> Créer un booking
</Button>
</Link>
</div>
)} )}
</div> </CardContent>
</Card>
{/* Top Trade Lanes Chart */}
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Top 5 Trade Lanes</h2>
{tradeLanesLoading ? (
<div className="h-64 bg-gray-100 animate-pulse rounded"></div>
) : (
<ResponsiveContainer width="100%" height={300}>
<BarChart data={tradeLanes}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="route" angle={-45} textAnchor="end" height={100} />
<YAxis />
<Tooltip />
<Legend />
<Bar dataKey="bookingCount" fill="#3b82f6" name="Bookings" />
</BarChart>
</ResponsiveContainer>
)}
</div>
</div>
{/* Quick Actions */} {/* Quick Actions */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6"> <div className="grid gap-4 md:grid-cols-3">
<Link <Link href="/dashboard/search">
href="/dashboard/search" <Card className="hover:shadow-lg transition-all hover:-translate-y-1 cursor-pointer">
className="bg-white rounded-lg shadow p-6 hover:shadow-md transition-shadow group" <CardContent className="flex items-center space-x-4 p-6">
> <div className="flex h-12 w-12 items-center justify-center rounded-lg bg-blue-100">
<div className="flex items-center space-x-4">
<div className="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center text-2xl group-hover:bg-blue-200 transition-colors">
🔍
</div>
<div>
<h3 className="text-lg font-semibold text-gray-900">Search Rates</h3>
<p className="text-sm text-gray-500">Find the best shipping rates</p>
</div>
</div>
</Link>
<Link
href="/dashboard/bookings/new"
className="bg-white rounded-lg shadow p-6 hover:shadow-md transition-shadow group"
>
<div className="flex items-center space-x-4">
<div className="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center text-2xl group-hover:bg-green-200 transition-colors">
</div>
<div>
<h3 className="text-lg font-semibold text-gray-900">New Booking</h3>
<p className="text-sm text-gray-500">Create a new shipment</p>
</div>
</div>
</Link>
<Link
href="/dashboard/bookings"
className="bg-white rounded-lg shadow p-6 hover:shadow-md transition-shadow group"
>
<div className="flex items-center space-x-4">
<div className="w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center text-2xl group-hover:bg-purple-200 transition-colors">
📋
</div>
<div>
<h3 className="text-lg font-semibold text-gray-900">My Bookings</h3>
<p className="text-sm text-gray-500">View all your shipments</p>
</div>
</div>
</Link>
</div>
{/* Recent Bookings */}
<div className="bg-white rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-900">Recent Bookings</h2>
<Link href="/dashboard/bookings" className="text-sm text-blue-600 hover:text-blue-800">
View All
</Link>
</div>
<div className="p-6">
{bookingsLoading ? (
<div className="space-y-4">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-16 bg-gray-100 animate-pulse rounded"></div>
))}
</div>
) : recentBookings && recentBookings.bookings.length > 0 ? (
<div className="space-y-4">
{recentBookings.bookings.map((booking: any) => (
<Link
key={booking.id}
href={`/dashboard/bookings/${booking.id}`}
className="flex items-center justify-between p-4 hover:bg-gray-50 rounded-lg transition-colors"
>
<div className="flex items-center space-x-4">
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
📦
</div>
<div>
<div className="font-medium text-gray-900">{booking.bookingNumber}</div>
<div className="text-sm text-gray-500">
{new Date(booking.createdAt).toLocaleDateString()}
</div>
</div>
</div>
<div className="flex items-center space-x-4">
<span
className={`px-3 py-1 rounded-full text-xs font-medium ${
booking.status === 'confirmed'
? 'bg-green-100 text-green-800'
: booking.status === 'pending'
? 'bg-yellow-100 text-yellow-800'
: 'bg-gray-100 text-gray-800'
}`}
>
{booking.status}
</span>
<svg <svg
className="w-5 h-5 text-gray-400" className="h-6 w-6 text-blue-600"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@ -364,28 +290,64 @@ export default function DashboardPage() {
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth={2} strokeWidth={2}
d="M9 5l7 7-7 7" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/> />
</svg> </svg>
</div> </div>
</Link> <div>
))} <h3 className="font-semibold">Rechercher des tarifs</h3>
<p className="text-sm text-muted-foreground">Trouver les meilleurs prix</p>
</div> </div>
) : ( </CardContent>
<div className="text-center py-12"> </Card>
<div className="text-6xl mb-4">📦</div> </Link>
<h3 className="text-lg font-medium text-gray-900 mb-2">No bookings yet</h3>
<p className="text-gray-500 mb-6">Create your first booking to get started</p> <Link href="/dashboard/bookings">
<Link <Card className="hover:shadow-lg transition-all hover:-translate-y-1 cursor-pointer">
href="/dashboard/bookings/new" <CardContent className="flex items-center space-x-4 p-6">
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700" <div className="flex h-12 w-12 items-center justify-center rounded-lg bg-purple-100">
<Package className="h-6 w-6 text-purple-600" />
</div>
<div>
<h3 className="font-semibold">Mes Bookings</h3>
<p className="text-sm text-muted-foreground">Voir tous mes envois</p>
</div>
</CardContent>
</Card>
</Link>
<Link href="/dashboard/settings/organization">
<Card className="hover:shadow-lg transition-all hover:-translate-y-1 cursor-pointer">
<CardContent className="flex items-center space-x-4 p-6">
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-gray-100">
<svg
className="h-6 w-6 text-gray-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
> >
Create Booking <path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</div>
<div>
<h3 className="font-semibold">Paramètres</h3>
<p className="text-sm text-muted-foreground">Configuration du compte</p>
</div>
</CardContent>
</Card>
</Link> </Link>
</div> </div>
)}
</div>
</div>
</div> </div>
); );
} }

View File

@ -83,6 +83,50 @@ export async function getAlerts(): Promise<DashboardAlert[]> {
return get<DashboardAlert[]>('/api/v1/dashboard/alerts'); return get<DashboardAlert[]>('/api/v1/dashboard/alerts');
} }
/**
* CSV Booking KPIs Response
*/
export interface CsvBookingKPIs {
totalAccepted: number;
totalRejected: number;
totalPending: number;
totalWeightAcceptedKG: number;
totalVolumeAcceptedCBM: number;
acceptanceRate: number;
acceptedThisMonth: number;
rejectedThisMonth: number;
}
/**
* Top Carrier Data
*/
export interface TopCarrier {
carrierName: string;
totalBookings: number;
acceptedBookings: number;
rejectedBookings: number;
acceptanceRate: number;
totalWeightKG: number;
totalVolumeCBM: number;
avgPriceUSD: number;
}
/**
* Get CSV booking KPIs
* GET /api/v1/dashboard/csv-booking-kpis
*/
export async function getCsvBookingKPIs(): Promise<CsvBookingKPIs> {
return get<CsvBookingKPIs>('/api/v1/dashboard/csv-booking-kpis');
}
/**
* Get top carriers (top 5 by booking count)
* GET /api/v1/dashboard/top-carriers
*/
export async function getTopCarriers(): Promise<TopCarrier[]> {
return get<TopCarrier[]>('/api/v1/dashboard/top-carriers');
}
/** /**
* Export all dashboard APIs * Export all dashboard APIs
*/ */
@ -91,4 +135,6 @@ export const dashboardApi = {
getBookingsChart, getBookingsChart,
getTopTradeLanes, getTopTradeLanes,
getAlerts, getAlerts,
getCsvBookingKPIs,
getTopCarriers,
}; };