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 { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.repository';
|
||||
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 { v4 as uuidv4 } from 'uuid';
|
||||
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);
|
||||
|
||||
this.logger.log(`New organization created: ${savedOrganization.id} - ${savedOrganization.name}`);
|
||||
this.logger.log(
|
||||
`New organization created: ${savedOrganization.id} - ${savedOrganization.name}`
|
||||
);
|
||||
|
||||
return savedOrganization.id;
|
||||
}
|
||||
|
||||
@ -181,7 +181,7 @@ export class CsvRatesAdminController {
|
||||
|
||||
// Auto-convert CSV if needed (FOB FRET → Standard format)
|
||||
const conversionResult = await this.csvConverter.autoConvert(file.path, dto.companyName);
|
||||
let filePathToValidate = conversionResult.convertedPath;
|
||||
const filePathToValidate = conversionResult.convertedPath;
|
||||
|
||||
if (conversionResult.wasConverted) {
|
||||
this.logger.log(
|
||||
@ -204,7 +204,11 @@ export class CsvRatesAdminController {
|
||||
|
||||
// Load rates to verify parsing using the converted path
|
||||
// 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;
|
||||
|
||||
this.logger.log(`Successfully parsed ${ratesCount} rates from ${file.filename}`);
|
||||
@ -245,7 +249,9 @@ export class CsvRatesAdminController {
|
||||
minioObjectKey = objectKey;
|
||||
this.logger.log(`✅ CSV file uploaded to MinIO: ${bucket}/${objectKey}`);
|
||||
} 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
|
||||
// The file is still available locally
|
||||
}
|
||||
@ -433,7 +439,8 @@ export class CsvRatesAdminController {
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
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({
|
||||
status: HttpStatus.OK,
|
||||
@ -462,7 +469,7 @@ export class CsvRatesAdminController {
|
||||
const configs = await this.csvConfigRepository.findAll();
|
||||
|
||||
// Map configs to file info format expected by frontend
|
||||
const files = configs.map((config) => {
|
||||
const files = configs.map(config => {
|
||||
const filePath = path.join(
|
||||
process.cwd(),
|
||||
'apps/backend/src/infrastructure/storage/csv-storage/rates',
|
||||
@ -521,7 +528,7 @@ export class CsvRatesAdminController {
|
||||
|
||||
// Find config by file path
|
||||
const configs = await this.csvConfigRepository.findAll();
|
||||
const config = configs.find((c) => c.csvFilePath === filename);
|
||||
const config = configs.find(c => c.csvFilePath === filename);
|
||||
|
||||
if (!config) {
|
||||
throw new BadRequestException(`No configuration found for file: ${filename}`);
|
||||
|
||||
@ -12,9 +12,7 @@ import { CsvBookingService } from '../services/csv-booking.service';
|
||||
@ApiTags('CSV Booking Actions')
|
||||
@Controller('csv-booking-actions')
|
||||
export class CsvBookingActionsController {
|
||||
constructor(
|
||||
private readonly csvBookingService: CsvBookingService
|
||||
) {}
|
||||
constructor(private readonly csvBookingService: CsvBookingService) {}
|
||||
|
||||
/**
|
||||
* Accept a booking request (PUBLIC - token-based)
|
||||
|
||||
@ -47,9 +47,7 @@ import {
|
||||
@ApiTags('CSV Bookings')
|
||||
@Controller('csv-bookings')
|
||||
export class CsvBookingsController {
|
||||
constructor(
|
||||
private readonly csvBookingService: CsvBookingService
|
||||
) {}
|
||||
constructor(private readonly csvBookingService: CsvBookingService) {}
|
||||
|
||||
/**
|
||||
* Create a new CSV booking request
|
||||
@ -219,10 +217,7 @@ export class CsvBookingsController {
|
||||
status: 400,
|
||||
description: 'Booking cannot be rejected (invalid status or expired)',
|
||||
})
|
||||
async rejectBooking(
|
||||
@Param('token') token: string,
|
||||
@Query('reason') reason: string
|
||||
) {
|
||||
async rejectBooking(@Param('token') token: string, @Query('reason') reason: string) {
|
||||
// Reject the booking
|
||||
const booking = await this.csvBookingService.rejectBooking(token, reason);
|
||||
|
||||
|
||||
@ -10,15 +10,13 @@ import {
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiBearerAuth,
|
||||
ApiParam,
|
||||
} from '@nestjs/swagger';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiParam } from '@nestjs/swagger';
|
||||
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 { RolesGuard } from '../guards/roles.guard';
|
||||
import { Roles } from '../decorators/roles.decorator';
|
||||
|
||||
@ -16,18 +16,13 @@ import { StorageModule } from '../infrastructure/storage/storage.module';
|
||||
*/
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
CsvBookingOrmEntity,
|
||||
]),
|
||||
TypeOrmModule.forFeature([CsvBookingOrmEntity]),
|
||||
NotificationsModule,
|
||||
EmailModule,
|
||||
StorageModule,
|
||||
],
|
||||
controllers: [CsvBookingsController, CsvBookingActionsController],
|
||||
providers: [
|
||||
CsvBookingService,
|
||||
TypeOrmCsvBookingRepository,
|
||||
],
|
||||
exports: [CsvBookingService],
|
||||
providers: [CsvBookingService, TypeOrmCsvBookingRepository],
|
||||
exports: [CsvBookingService, TypeOrmCsvBookingRepository],
|
||||
})
|
||||
export class CsvBookingsModule {}
|
||||
|
||||
@ -52,4 +52,24 @@ export class DashboardController {
|
||||
const organizationId = req.user.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 { BookingsModule } from '../bookings/bookings.module';
|
||||
import { RatesModule } from '../rates/rates.module';
|
||||
import { CsvBookingsModule } from '../csv-bookings.module';
|
||||
|
||||
@Module({
|
||||
imports: [BookingsModule, RatesModule],
|
||||
imports: [BookingsModule, RatesModule, CsvBookingsModule],
|
||||
controllers: [DashboardController],
|
||||
providers: [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 { Type } from 'class-transformer';
|
||||
import { OrganizationType } from '@domain/entities/organization.entity';
|
||||
@ -145,7 +154,8 @@ export class RegisterDto {
|
||||
|
||||
@ApiPropertyOptional({
|
||||
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,
|
||||
})
|
||||
@IsString()
|
||||
@ -153,7 +163,8 @@ export class RegisterDto {
|
||||
organizationId?: string;
|
||||
|
||||
@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,
|
||||
required: false,
|
||||
})
|
||||
|
||||
@ -9,6 +9,8 @@ import { BOOKING_REPOSITORY } from '@domain/ports/out/booking.repository';
|
||||
import { BookingRepository } from '@domain/ports/out/booking.repository';
|
||||
import { RATE_QUOTE_REPOSITORY } from '@domain/ports/out/rate-quote.repository';
|
||||
import { RateQuoteRepository } from '@domain/ports/out/rate-quote.repository';
|
||||
import { TypeOrmCsvBookingRepository } from '../../infrastructure/persistence/typeorm/repositories/csv-booking.repository';
|
||||
import { CsvBooking, CsvBookingStatus } from '@domain/entities/csv-booking.entity';
|
||||
|
||||
export interface DashboardKPIs {
|
||||
bookingsThisMonth: number;
|
||||
@ -47,13 +49,36 @@ export interface DashboardAlert {
|
||||
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()
|
||||
export class AnalyticsService {
|
||||
constructor(
|
||||
@Inject(BOOKING_REPOSITORY)
|
||||
private readonly bookingRepository: BookingRepository,
|
||||
@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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
|
||||
import { Injectable, Logger, UnauthorizedException, ConflictException, Inject } from '@nestjs/common';
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
UnauthorizedException,
|
||||
ConflictException,
|
||||
Inject,
|
||||
} from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
@ -111,7 +117,11 @@ export class CarrierAuthService {
|
||||
|
||||
// Send welcome email with credentials and WAIT for confirmation
|
||||
try {
|
||||
await this.emailAdapter.sendCarrierAccountCreated(carrierEmail, carrierName, temporaryPassword);
|
||||
await this.emailAdapter.sendCarrierAccountCreated(
|
||||
carrierEmail,
|
||||
carrierName,
|
||||
temporaryPassword
|
||||
);
|
||||
this.logger.log(`Account creation email sent to ${carrierEmail}`);
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Failed to send account creation email: ${error?.message}`, error?.stack);
|
||||
@ -148,7 +158,10 @@ export class CarrierAuthService {
|
||||
/**
|
||||
* Standard login for carriers
|
||||
*/
|
||||
async login(email: string, password: string): Promise<{
|
||||
async login(
|
||||
email: string,
|
||||
password: string
|
||||
): Promise<{
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
carrier: {
|
||||
@ -288,7 +301,11 @@ export class CarrierAuthService {
|
||||
|
||||
// Send password reset email and WAIT for confirmation
|
||||
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}`);
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Failed to send password reset email: ${error?.message}`, error?.stack);
|
||||
|
||||
@ -161,7 +161,11 @@ export class CsvBookingService {
|
||||
* Get booking by ID
|
||||
* 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);
|
||||
|
||||
if (!booking) {
|
||||
|
||||
@ -7,9 +7,15 @@ import {
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
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 { 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 { InvitationToken } from '@domain/entities/invitation-token.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}`);
|
||||
} 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)}`);
|
||||
throw error;
|
||||
}
|
||||
|
||||
@ -17,7 +17,11 @@ export interface CsvRateLoaderPort {
|
||||
* @returns Array of CSV rates
|
||||
* @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
|
||||
|
||||
@ -177,9 +177,13 @@ export class CsvRateSearchService implements SearchCsvRatesPort {
|
||||
});
|
||||
|
||||
// Apply service level price adjustment to the total price
|
||||
const adjustedTotalPrice = priceBreakdown.totalPrice *
|
||||
(offer.serviceLevel === ServiceLevel.RAPID ? 1.20 :
|
||||
offer.serviceLevel === ServiceLevel.ECONOMIC ? 0.85 : 1.00);
|
||||
const adjustedTotalPrice =
|
||||
priceBreakdown.totalPrice *
|
||||
(offer.serviceLevel === ServiceLevel.RAPID
|
||||
? 1.2
|
||||
: offer.serviceLevel === ServiceLevel.ECONOMIC
|
||||
? 0.85
|
||||
: 1.0);
|
||||
|
||||
return {
|
||||
rate: offer.rate,
|
||||
@ -207,8 +211,8 @@ export class CsvRateSearchService implements SearchCsvRatesPort {
|
||||
// Apply service level filter if specified
|
||||
let filteredResults = results;
|
||||
if (input.filters?.serviceLevels && input.filters.serviceLevels.length > 0) {
|
||||
filteredResults = results.filter(r =>
|
||||
r.serviceLevel && input.filters!.serviceLevels!.includes(r.serviceLevel)
|
||||
filteredResults = results.filter(
|
||||
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
|
||||
const results = await Promise.allSettled(ratePromises);
|
||||
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);
|
||||
|
||||
// Log any failed file loads
|
||||
const failures = results.filter(result => result.status === 'rejected');
|
||||
if (failures.length > 0) {
|
||||
console.warn(`Failed to load ${failures.length} CSV files:`,
|
||||
failures.map((f, idx) => `${configs[idx]?.csvFilePath}: ${(f as PromiseRejectedResult).reason}`));
|
||||
console.warn(
|
||||
`Failed to load ${failures.length} CSV files:`,
|
||||
failures.map(
|
||||
(f, idx) => `${configs[idx]?.csvFilePath}: ${(f as PromiseRejectedResult).reason}`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return rateArrays.flat();
|
||||
@ -274,7 +284,9 @@ export class CsvRateSearchService implements SearchCsvRatesPort {
|
||||
// Use allSettled here too for consistency
|
||||
const results = await Promise.allSettled(ratePromises);
|
||||
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);
|
||||
|
||||
return rateArrays.flat();
|
||||
|
||||
@ -98,7 +98,7 @@ describe('RateOfferGeneratorService', () => {
|
||||
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 standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD);
|
||||
@ -141,7 +141,7 @@ describe('RateOfferGeneratorService', () => {
|
||||
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 standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD);
|
||||
@ -257,7 +257,7 @@ describe('RateOfferGeneratorService', () => {
|
||||
});
|
||||
|
||||
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 rate2 = {
|
||||
...mockRate,
|
||||
@ -282,7 +282,7 @@ describe('RateOfferGeneratorService', () => {
|
||||
});
|
||||
|
||||
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 rate2 = { ...mockRate, transitDays: 10 } as any;
|
||||
|
||||
|
||||
@ -58,18 +58,18 @@ export class RateOfferGeneratorService {
|
||||
*/
|
||||
private readonly SERVICE_LEVEL_CONFIGS: Record<ServiceLevel, ServiceLevelConfig> = {
|
||||
[ServiceLevel.RAPID]: {
|
||||
priceMultiplier: 1.20, // +20% du prix de base
|
||||
transitMultiplier: 0.70, // -30% du temps de transit (plus rapide)
|
||||
priceMultiplier: 1.2, // +20% du prix de base
|
||||
transitMultiplier: 0.7, // -30% du temps de transit (plus rapide)
|
||||
description: 'Express - Livraison rapide avec service prioritaire',
|
||||
},
|
||||
[ServiceLevel.STANDARD]: {
|
||||
priceMultiplier: 1.00, // Prix de base (pas de changement)
|
||||
transitMultiplier: 1.00, // Transit time de base (pas de changement)
|
||||
priceMultiplier: 1.0, // Prix 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',
|
||||
},
|
||||
[ServiceLevel.ECONOMIC]: {
|
||||
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',
|
||||
},
|
||||
};
|
||||
@ -110,9 +110,7 @@ export class RateOfferGeneratorService {
|
||||
|
||||
// Calculer le transit time ajusté (avec contraintes min/max)
|
||||
const rawTransitDays = baseTransitDays * config.transitMultiplier;
|
||||
const adjustedTransitDays = this.constrainTransitDays(
|
||||
Math.round(rawTransitDays)
|
||||
);
|
||||
const adjustedTransitDays = this.constrainTransitDays(Math.round(rawTransitDays));
|
||||
|
||||
// Calculer les pourcentages d'ajustement
|
||||
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[]> {
|
||||
this.logger.log(`Loading rates from CSV: ${filePath} (email: ${companyEmail}, company: ${companyNameOverride || 'from CSV'})`);
|
||||
async loadRatesFromCsv(
|
||||
filePath: string,
|
||||
companyEmail: string,
|
||||
companyNameOverride?: string
|
||||
): Promise<CsvRate[]> {
|
||||
this.logger.log(
|
||||
`Loading rates from CSV: ${filePath} (email: ${companyEmail}, company: ${companyNameOverride || 'from CSV'})`
|
||||
);
|
||||
|
||||
try {
|
||||
let fileContent: string;
|
||||
@ -114,7 +120,9 @@ export class CsvRateLoaderAdapter implements CsvRateLoaderPort {
|
||||
throw new Error('No MinIO object key found, using local file');
|
||||
}
|
||||
} 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
|
||||
const fullPath = path.isAbsolute(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');
|
||||
}
|
||||
} 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
|
||||
*/
|
||||
private mapToCsvRate(record: CsvRow, companyEmail: string, companyNameOverride?: string): CsvRate {
|
||||
private mapToCsvRate(
|
||||
record: CsvRow,
|
||||
companyEmail: string,
|
||||
companyNameOverride?: string
|
||||
): CsvRate {
|
||||
// Parse surcharges
|
||||
const surcharges = this.parseSurcharges(record);
|
||||
|
||||
|
||||
@ -42,7 +42,7 @@ export class CarrierActivityOrmEntity {
|
||||
@Column({ name: 'carrier_id', type: 'uuid' })
|
||||
carrierId: string;
|
||||
|
||||
@ManyToOne(() => CarrierProfileOrmEntity, (carrier) => carrier.activities, {
|
||||
@ManyToOne(() => CarrierProfileOrmEntity, carrier => carrier.activities, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn({ name: 'carrier_id' })
|
||||
|
||||
@ -118,9 +118,9 @@ export class CarrierProfileOrmEntity {
|
||||
updatedAt: Date;
|
||||
|
||||
// Relations
|
||||
@OneToMany(() => CsvBookingOrmEntity, (booking) => booking.carrierProfile)
|
||||
@OneToMany(() => CsvBookingOrmEntity, booking => booking.carrierProfile)
|
||||
bookings: CsvBookingOrmEntity[];
|
||||
|
||||
@OneToMany(() => CarrierActivityOrmEntity, (activity) => activity.carrierProfile)
|
||||
@OneToMany(() => CarrierActivityOrmEntity, activity => activity.carrierProfile)
|
||||
activities: CarrierActivityOrmEntity[];
|
||||
}
|
||||
|
||||
@ -113,7 +113,7 @@ export class CsvBookingOrmEntity {
|
||||
@Column({ name: 'carrier_id', type: 'uuid', nullable: true })
|
||||
carrierId: string | null;
|
||||
|
||||
@ManyToOne(() => CarrierProfileOrmEntity, (carrier) => carrier.bookings, {
|
||||
@ManyToOne(() => CarrierProfileOrmEntity, carrier => carrier.bookings, {
|
||||
onDelete: 'SET NULL',
|
||||
})
|
||||
@JoinColumn({ name: 'carrier_id' })
|
||||
|
||||
@ -16,7 +16,8 @@ export class SeedTestUsers1730000000007 implements MigrationInterface {
|
||||
|
||||
// Pre-hashed password: Password123! (Argon2id)
|
||||
// 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)
|
||||
const users = [
|
||||
|
||||
@ -7,7 +7,10 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { CarrierActivityOrmEntity, CarrierActivityType } from '../entities/carrier-activity.orm-entity';
|
||||
import {
|
||||
CarrierActivityOrmEntity,
|
||||
CarrierActivityType,
|
||||
} from '../entities/carrier-activity.orm-entity';
|
||||
|
||||
@Injectable()
|
||||
export class CarrierActivityRepository {
|
||||
@ -27,7 +30,9 @@ export class CarrierActivityRepository {
|
||||
ipAddress?: string | null;
|
||||
userAgent?: string | null;
|
||||
}): 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 saved = await this.repository.save(activity);
|
||||
@ -36,7 +41,10 @@ export class CarrierActivityRepository {
|
||||
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})`);
|
||||
|
||||
const activities = await this.repository.find({
|
||||
@ -74,7 +82,9 @@ export class CarrierActivityRepository {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@ -76,7 +76,10 @@ export class CarrierProfileRepository {
|
||||
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}`);
|
||||
|
||||
await this.repository.update(id, data);
|
||||
@ -131,7 +134,9 @@ export class CarrierProfileRepository {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@ -14,7 +14,7 @@ async function deleteOrphanedConfig() {
|
||||
console.log('🔍 Searching for orphaned test.csv configuration...');
|
||||
|
||||
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) {
|
||||
console.log('✅ No orphaned test.csv configuration found');
|
||||
@ -22,7 +22,9 @@ async function deleteOrphanedConfig() {
|
||||
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(` Uploaded: ${orphanedConfig.uploadedAt}`);
|
||||
|
||||
@ -30,7 +32,6 @@ async function deleteOrphanedConfig() {
|
||||
await repository.delete(orphanedConfig.companyName);
|
||||
|
||||
console.log('✅ Successfully deleted orphaned test.csv configuration');
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('❌ Error deleting orphaned config:', error.message);
|
||||
process.exit(1);
|
||||
|
||||
@ -116,9 +116,7 @@ describe('Carrier Portal (e2e)', () => {
|
||||
});
|
||||
|
||||
it('should return 401 without auth token', () => {
|
||||
return request(app.getHttpServer())
|
||||
.get('/api/v1/carrier-auth/me')
|
||||
.expect(401);
|
||||
return request(app.getHttpServer()).get('/api/v1/carrier-auth/me').expect(401);
|
||||
});
|
||||
});
|
||||
|
||||
@ -169,9 +167,7 @@ describe('Carrier Portal (e2e)', () => {
|
||||
});
|
||||
|
||||
it('should return 401 without auth token', () => {
|
||||
return request(app.getHttpServer())
|
||||
.get('/api/v1/carrier-dashboard/stats')
|
||||
.expect(401);
|
||||
return request(app.getHttpServer()).get('/api/v1/carrier-dashboard/stats').expect(401);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -1,361 +1,287 @@
|
||||
/**
|
||||
* Dashboard Home Page
|
||||
*
|
||||
* Main dashboard with KPIs, charts, and alerts
|
||||
* Main dashboard with CSV Booking KPIs and carrier analytics
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { dashboardApi, listBookings } from '@/lib/api';
|
||||
import { dashboardApi } from '@/lib/api';
|
||||
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 {
|
||||
LineChart,
|
||||
Line,
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
Package,
|
||||
PackageCheck,
|
||||
PackageX,
|
||||
Clock,
|
||||
Weight,
|
||||
TrendingUp,
|
||||
Plus,
|
||||
ArrowRight,
|
||||
} from 'lucide-react';
|
||||
|
||||
export default function DashboardPage() {
|
||||
// Fetch dashboard data
|
||||
const { data: kpis, isLoading: kpisLoading } = useQuery({
|
||||
queryKey: ['dashboard', 'kpis'],
|
||||
queryFn: () => dashboardApi.getKPIs(),
|
||||
// Fetch CSV booking KPIs
|
||||
const { data: csvKpis, isLoading: csvKpisLoading } = useQuery({
|
||||
queryKey: ['dashboard', 'csv-booking-kpis'],
|
||||
queryFn: () => dashboardApi.getCsvBookingKPIs(),
|
||||
});
|
||||
|
||||
const { data: chartData, isLoading: chartLoading } = useQuery({
|
||||
queryKey: ['dashboard', 'bookings-chart'],
|
||||
queryFn: () => dashboardApi.getBookingsChart(),
|
||||
// Fetch top carriers
|
||||
const { data: topCarriers, isLoading: carriersLoading } = useQuery({
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
{/* Welcome Section */}
|
||||
<div className="bg-gradient-to-r from-blue-600 to-blue-700 rounded-lg shadow-lg p-6 text-white">
|
||||
<h1 className="text-3xl font-bold mb-2">Welcome back!</h1>
|
||||
<p className="text-blue-100">Here's what's happening with your shipments today.</p>
|
||||
<div className="space-y-8 p-8">
|
||||
{/* Header Section */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<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>
|
||||
|
||||
{/* KPI Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{kpisLoading ? (
|
||||
// Loading skeletons
|
||||
Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="bg-white rounded-lg shadow p-6 animate-pulse">
|
||||
<div className="h-4 bg-gray-200 rounded w-1/2 mb-4"></div>
|
||||
<div className="h-8 bg-gray-200 rounded w-3/4"></div>
|
||||
{/* CTA Button */}
|
||||
<Link href="/dashboard/bookings/new">
|
||||
<Button size="lg" className="gap-2">
|
||||
<Plus className="h-5 w-5" />
|
||||
Nouveau Booking
|
||||
</Button>
|
||||
</Link>
|
||||
</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="flex items-center justify-between">
|
||||
<div>
|
||||
<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}
|
||||
<div className="text-3xl font-bold">{csvKpis?.totalAccepted || 0}</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
+{csvKpis?.acceptedThisMonth || 0} ce mois
|
||||
</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>
|
||||
|
||||
{/* Alerts Section */}
|
||||
{!alertsLoading && alerts && alerts.length > 0 && (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">⚠️ Alerts & Notifications</h2>
|
||||
<div className="space-y-3">
|
||||
{alerts.slice(0, 5).map(alert => (
|
||||
<div
|
||||
key={alert.id}
|
||||
className={`border-l-4 p-4 rounded ${getAlertColor(alert.severity)}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold">{alert.title}</h3>
|
||||
<p className="text-sm mt-1">{alert.message}</p>
|
||||
{alert.bookingNumber && (
|
||||
<Link
|
||||
href={`/dashboard/bookings/${alert.bookingId}`}
|
||||
className="text-sm font-medium underline mt-2 inline-block"
|
||||
>
|
||||
View Booking {alert.bookingNumber}
|
||||
{/* Stats Overview Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Vue d'ensemble</CardTitle>
|
||||
<CardDescription>Statistiques globales de vos bookings</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
|
||||
<TrendingUp className="h-6 w-6 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Taux d'acceptation</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{csvKpisLoading ? '--' : `${csvKpis?.acceptanceRate.toFixed(1)}%`}
|
||||
</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-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>
|
||||
</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>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* 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}>
|
||||
<LineChart data={formattedChartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="month" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<Line type="monotone" dataKey="bookings" stroke="#3b82f6" strokeWidth={2} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="text-center py-12">
|
||||
<Package className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">Aucun booking pour l'instant</h3>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
Créez votre premier booking pour voir vos statistiques
|
||||
</p>
|
||||
<Link href="/dashboard/bookings/new">
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Créer un booking
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<Link
|
||||
href="/dashboard/search"
|
||||
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-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>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Link href="/dashboard/search">
|
||||
<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-blue-100">
|
||||
<svg
|
||||
className="w-5 h-5 text-gray-400"
|
||||
className="h-6 w-6 text-blue-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
@ -364,28 +290,64 @@ export default function DashboardPage() {
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
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>
|
||||
</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 className="text-center py-12">
|
||||
<div className="text-6xl mb-4">📦</div>
|
||||
<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/new"
|
||||
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"
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
<Link href="/dashboard/bookings">
|
||||
<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-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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -83,6 +83,50 @@ export async function getAlerts(): Promise<DashboardAlert[]> {
|
||||
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
|
||||
*/
|
||||
@ -91,4 +135,6 @@ export const dashboardApi = {
|
||||
getBookingsChart,
|
||||
getTopTradeLanes,
|
||||
getAlerts,
|
||||
getCsvBookingKPIs,
|
||||
getTopCarriers,
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue
Block a user