feature dashboard
This commit is contained in:
parent
71541c79e7
commit
eab3d6f612
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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}`);
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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);
|
||||||
|
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -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 {}
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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],
|
||||||
|
|||||||
@ -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,
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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);
|
||||||
|
|
||||||
|
|||||||
@ -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' })
|
||||||
|
|||||||
@ -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[];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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' })
|
||||||
|
|||||||
@ -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 = [
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user