Compare commits
No commits in common. "de4126a657c0dba2b5fe6f0e2505b482a283e73a" and "0d814e9a943f63c46564dc87db9c266cc0b34365" have entirely different histories.
de4126a657
...
0d814e9a94
@ -372,20 +372,14 @@ export class BookingsController {
|
|||||||
const endIndex = startIndex + pageSize;
|
const endIndex = startIndex + pageSize;
|
||||||
const paginatedBookings = filteredBookings.slice(startIndex, endIndex);
|
const paginatedBookings = filteredBookings.slice(startIndex, endIndex);
|
||||||
|
|
||||||
// Fetch rate quotes for all bookings (filter out those with missing rate quotes)
|
// Fetch rate quotes for all bookings
|
||||||
const bookingsWithQuotesRaw = await Promise.all(
|
const bookingsWithQuotes = await Promise.all(
|
||||||
paginatedBookings.map(async (booking: any) => {
|
paginatedBookings.map(async (booking: any) => {
|
||||||
const rateQuote = await this.rateQuoteRepository.findById(booking.rateQuoteId);
|
const rateQuote = await this.rateQuoteRepository.findById(booking.rateQuoteId);
|
||||||
return { booking, rateQuote };
|
return { booking, rateQuote: rateQuote! };
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// Filter out bookings with missing rate quotes to avoid null pointer errors
|
|
||||||
const bookingsWithQuotes = bookingsWithQuotesRaw.filter(
|
|
||||||
(item): item is { booking: any; rateQuote: NonNullable<typeof item.rateQuote> } =>
|
|
||||||
item.rateQuote !== null && item.rateQuote !== undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
// Convert to DTOs
|
// Convert to DTOs
|
||||||
const bookingDtos = BookingMapper.toListItemDtoArray(bookingsWithQuotes);
|
const bookingDtos = BookingMapper.toListItemDtoArray(bookingsWithQuotes);
|
||||||
|
|
||||||
@ -446,21 +440,14 @@ export class BookingsController {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Map ORM entities to domain and fetch rate quotes
|
// Map ORM entities to domain and fetch rate quotes
|
||||||
const bookingsWithQuotesRaw = await Promise.all(
|
const bookingsWithQuotes = await Promise.all(
|
||||||
bookingOrms.map(async bookingOrm => {
|
bookingOrms.map(async bookingOrm => {
|
||||||
const booking = await this.bookingRepository.findById(bookingOrm.id);
|
const booking = await this.bookingRepository.findById(bookingOrm.id);
|
||||||
const rateQuote = await this.rateQuoteRepository.findById(bookingOrm.rateQuoteId);
|
const rateQuote = await this.rateQuoteRepository.findById(bookingOrm.rateQuoteId);
|
||||||
return { booking, rateQuote };
|
return { booking: booking!, rateQuote: rateQuote! };
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// Filter out bookings or rate quotes that are null
|
|
||||||
const bookingsWithQuotes = bookingsWithQuotesRaw.filter(
|
|
||||||
(item): item is { booking: NonNullable<typeof item.booking>; rateQuote: NonNullable<typeof item.rateQuote> } =>
|
|
||||||
item.booking !== null && item.booking !== undefined &&
|
|
||||||
item.rateQuote !== null && item.rateQuote !== undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
// Convert to DTOs
|
// Convert to DTOs
|
||||||
const bookingDtos = bookingsWithQuotes.map(({ booking, rateQuote }) =>
|
const bookingDtos = bookingsWithQuotes.map(({ booking, rateQuote }) =>
|
||||||
BookingMapper.toDto(booking, rateQuote)
|
BookingMapper.toDto(booking, rateQuote)
|
||||||
@ -500,10 +487,8 @@ export class BookingsController {
|
|||||||
// Apply filters
|
// Apply filters
|
||||||
bookings = this.applyFilters(bookings, filter);
|
bookings = this.applyFilters(bookings, filter);
|
||||||
|
|
||||||
// Sort bookings (use defaults if not provided)
|
// Sort bookings
|
||||||
const sortBy = filter.sortBy || 'createdAt';
|
bookings = this.sortBookings(bookings, filter.sortBy!, filter.sortOrder!);
|
||||||
const sortOrder = filter.sortOrder || 'desc';
|
|
||||||
bookings = this.sortBookings(bookings, sortBy, sortOrder);
|
|
||||||
|
|
||||||
// Total count before pagination
|
// Total count before pagination
|
||||||
const total = bookings.length;
|
const total = bookings.length;
|
||||||
@ -513,20 +498,14 @@ export class BookingsController {
|
|||||||
const endIndex = startIndex + (filter.pageSize || 20);
|
const endIndex = startIndex + (filter.pageSize || 20);
|
||||||
const paginatedBookings = bookings.slice(startIndex, endIndex);
|
const paginatedBookings = bookings.slice(startIndex, endIndex);
|
||||||
|
|
||||||
// Fetch rate quotes (filter out those with missing rate quotes)
|
// Fetch rate quotes
|
||||||
const bookingsWithQuotesRaw = await Promise.all(
|
const bookingsWithQuotes = await Promise.all(
|
||||||
paginatedBookings.map(async booking => {
|
paginatedBookings.map(async booking => {
|
||||||
const rateQuote = await this.rateQuoteRepository.findById(booking.rateQuoteId);
|
const rateQuote = await this.rateQuoteRepository.findById(booking.rateQuoteId);
|
||||||
return { booking, rateQuote };
|
return { booking, rateQuote: rateQuote! };
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// Filter out bookings with missing rate quotes to avoid null pointer errors
|
|
||||||
const bookingsWithQuotes = bookingsWithQuotesRaw.filter(
|
|
||||||
(item): item is { booking: any; rateQuote: NonNullable<typeof item.rateQuote> } =>
|
|
||||||
item.rateQuote !== null && item.rateQuote !== undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
// Convert to DTOs
|
// Convert to DTOs
|
||||||
const bookingDtos = BookingMapper.toListItemDtoArray(bookingsWithQuotes);
|
const bookingDtos = BookingMapper.toListItemDtoArray(bookingsWithQuotes);
|
||||||
|
|
||||||
@ -583,20 +562,14 @@ export class BookingsController {
|
|||||||
bookings = this.applyFilters(bookings, filter);
|
bookings = this.applyFilters(bookings, filter);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch rate quotes (filter out those with missing rate quotes)
|
// Fetch rate quotes
|
||||||
const bookingsWithQuotesRaw = await Promise.all(
|
const bookingsWithQuotes = await Promise.all(
|
||||||
bookings.map(async booking => {
|
bookings.map(async booking => {
|
||||||
const rateQuote = await this.rateQuoteRepository.findById(booking.rateQuoteId);
|
const rateQuote = await this.rateQuoteRepository.findById(booking.rateQuoteId);
|
||||||
return { booking, rateQuote };
|
return { booking, rateQuote: rateQuote! };
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// Filter out bookings with missing rate quotes to avoid null pointer errors
|
|
||||||
const bookingsWithQuotes = bookingsWithQuotesRaw.filter(
|
|
||||||
(item): item is { booking: any; rateQuote: NonNullable<typeof item.rateQuote> } =>
|
|
||||||
item.rateQuote !== null && item.rateQuote !== undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
// Generate export file
|
// Generate export file
|
||||||
const exportResult = await this.exportService.exportBookings(
|
const exportResult = await this.exportService.exportBookings(
|
||||||
bookingsWithQuotes,
|
bookingsWithQuotes,
|
||||||
|
|||||||
@ -3,7 +3,6 @@ import {
|
|||||||
Post,
|
Post,
|
||||||
Get,
|
Get,
|
||||||
Patch,
|
Patch,
|
||||||
Delete,
|
|
||||||
Body,
|
Body,
|
||||||
Param,
|
Param,
|
||||||
Query,
|
Query,
|
||||||
@ -40,20 +39,12 @@ import {
|
|||||||
* CSV Bookings Controller
|
* CSV Bookings Controller
|
||||||
*
|
*
|
||||||
* Handles HTTP requests for CSV-based booking requests
|
* Handles HTTP requests for CSV-based booking requests
|
||||||
*
|
|
||||||
* IMPORTANT: Route order matters in NestJS!
|
|
||||||
* Static routes MUST come BEFORE parameterized routes.
|
|
||||||
* Otherwise, `:id` will capture "stats", "organization", etc.
|
|
||||||
*/
|
*/
|
||||||
@ApiTags('CSV Bookings')
|
@ApiTags('CSV Bookings')
|
||||||
@Controller('csv-bookings')
|
@Controller('csv-bookings')
|
||||||
export class CsvBookingsController {
|
export class CsvBookingsController {
|
||||||
constructor(private readonly csvBookingService: CsvBookingService) {}
|
constructor(private readonly csvBookingService: CsvBookingService) {}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// STATIC ROUTES (must come FIRST)
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new CSV booking request
|
* Create a new CSV booking request
|
||||||
*
|
*
|
||||||
@ -160,112 +151,6 @@ export class CsvBookingsController {
|
|||||||
return await this.csvBookingService.createBooking(sanitizedDto, files, userId, organizationId);
|
return await this.csvBookingService.createBooking(sanitizedDto, files, userId, organizationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get current user's bookings (paginated)
|
|
||||||
*
|
|
||||||
* GET /api/v1/csv-bookings
|
|
||||||
*/
|
|
||||||
@Get()
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Get user bookings',
|
|
||||||
description: 'Retrieve all bookings for the authenticated user with pagination.',
|
|
||||||
})
|
|
||||||
@ApiQuery({ name: 'page', required: false, type: Number, example: 1 })
|
|
||||||
@ApiQuery({ name: 'limit', required: false, type: Number, example: 10 })
|
|
||||||
@ApiResponse({
|
|
||||||
status: 200,
|
|
||||||
description: 'Bookings retrieved successfully',
|
|
||||||
type: CsvBookingListResponseDto,
|
|
||||||
})
|
|
||||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
|
||||||
async getUserBookings(
|
|
||||||
@Request() req: any,
|
|
||||||
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
|
|
||||||
@Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number
|
|
||||||
): Promise<CsvBookingListResponseDto> {
|
|
||||||
const userId = req.user.id;
|
|
||||||
return await this.csvBookingService.getUserBookings(userId, page, limit);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get booking statistics for user
|
|
||||||
*
|
|
||||||
* GET /api/v1/csv-bookings/stats/me
|
|
||||||
*/
|
|
||||||
@Get('stats/me')
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Get user booking statistics',
|
|
||||||
description:
|
|
||||||
'Get aggregated statistics for the authenticated user (pending, accepted, rejected, cancelled).',
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: 200,
|
|
||||||
description: 'Statistics retrieved successfully',
|
|
||||||
type: CsvBookingStatsDto,
|
|
||||||
})
|
|
||||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
|
||||||
async getUserStats(@Request() req: any): Promise<CsvBookingStatsDto> {
|
|
||||||
const userId = req.user.id;
|
|
||||||
return await this.csvBookingService.getUserStats(userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get organization booking statistics
|
|
||||||
*
|
|
||||||
* GET /api/v1/csv-bookings/stats/organization
|
|
||||||
*/
|
|
||||||
@Get('stats/organization')
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Get organization booking statistics',
|
|
||||||
description: "Get aggregated statistics for the user's organization. For managers/admins.",
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: 200,
|
|
||||||
description: 'Statistics retrieved successfully',
|
|
||||||
type: CsvBookingStatsDto,
|
|
||||||
})
|
|
||||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
|
||||||
async getOrganizationStats(@Request() req: any): Promise<CsvBookingStatsDto> {
|
|
||||||
const organizationId = req.user.organizationId;
|
|
||||||
return await this.csvBookingService.getOrganizationStats(organizationId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get organization bookings (for managers/admins)
|
|
||||||
*
|
|
||||||
* GET /api/v1/csv-bookings/organization/all
|
|
||||||
*/
|
|
||||||
@Get('organization/all')
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Get organization bookings',
|
|
||||||
description:
|
|
||||||
"Retrieve all bookings for the user's organization with pagination. For managers/admins.",
|
|
||||||
})
|
|
||||||
@ApiQuery({ name: 'page', required: false, type: Number, example: 1 })
|
|
||||||
@ApiQuery({ name: 'limit', required: false, type: Number, example: 10 })
|
|
||||||
@ApiResponse({
|
|
||||||
status: 200,
|
|
||||||
description: 'Organization bookings retrieved successfully',
|
|
||||||
type: CsvBookingListResponseDto,
|
|
||||||
})
|
|
||||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
|
||||||
async getOrganizationBookings(
|
|
||||||
@Request() req: any,
|
|
||||||
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
|
|
||||||
@Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number
|
|
||||||
): Promise<CsvBookingListResponseDto> {
|
|
||||||
const organizationId = req.user.organizationId;
|
|
||||||
return await this.csvBookingService.getOrganizationBookings(organizationId, page, limit);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Accept a booking request (PUBLIC - token-based)
|
* Accept a booking request (PUBLIC - token-based)
|
||||||
*
|
*
|
||||||
@ -341,17 +226,10 @@ export class CsvBookingsController {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// PARAMETERIZED ROUTES (must come LAST)
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a booking by ID
|
* Get a booking by ID
|
||||||
*
|
*
|
||||||
* GET /api/v1/csv-bookings/:id
|
* GET /api/v1/csv-bookings/:id
|
||||||
*
|
|
||||||
* IMPORTANT: This route MUST be after all static GET routes
|
|
||||||
* Otherwise it will capture "stats", "organization", etc.
|
|
||||||
*/
|
*/
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@ -374,6 +252,59 @@ export class CsvBookingsController {
|
|||||||
return await this.csvBookingService.getBookingById(id, userId, carrierId);
|
return await this.csvBookingService.getBookingById(id, userId, carrierId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current user's bookings (paginated)
|
||||||
|
*
|
||||||
|
* GET /api/v1/csv-bookings
|
||||||
|
*/
|
||||||
|
@Get()
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Get user bookings',
|
||||||
|
description: 'Retrieve all bookings for the authenticated user with pagination.',
|
||||||
|
})
|
||||||
|
@ApiQuery({ name: 'page', required: false, type: Number, example: 1 })
|
||||||
|
@ApiQuery({ name: 'limit', required: false, type: Number, example: 10 })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'Bookings retrieved successfully',
|
||||||
|
type: CsvBookingListResponseDto,
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||||
|
async getUserBookings(
|
||||||
|
@Request() req: any,
|
||||||
|
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
|
||||||
|
@Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number
|
||||||
|
): Promise<CsvBookingListResponseDto> {
|
||||||
|
const userId = req.user.id;
|
||||||
|
return await this.csvBookingService.getUserBookings(userId, page, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get booking statistics for user
|
||||||
|
*
|
||||||
|
* GET /api/v1/csv-bookings/stats/me
|
||||||
|
*/
|
||||||
|
@Get('stats/me')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Get user booking statistics',
|
||||||
|
description:
|
||||||
|
'Get aggregated statistics for the authenticated user (pending, accepted, rejected, cancelled).',
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'Statistics retrieved successfully',
|
||||||
|
type: CsvBookingStatsDto,
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||||
|
async getUserStats(@Request() req: any): Promise<CsvBookingStatsDto> {
|
||||||
|
const userId = req.user.id;
|
||||||
|
return await this.csvBookingService.getUserStats(userId);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cancel a booking (user action)
|
* Cancel a booking (user action)
|
||||||
*
|
*
|
||||||
@ -404,165 +335,55 @@ export class CsvBookingsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add documents to an existing booking
|
* Get organization bookings (for managers/admins)
|
||||||
*
|
*
|
||||||
* POST /api/v1/csv-bookings/:id/documents
|
* GET /api/v1/csv-bookings/organization/all
|
||||||
*/
|
*/
|
||||||
@Post(':id/documents')
|
@Get('organization/all')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@UseInterceptors(FilesInterceptor('documents', 10))
|
|
||||||
@ApiConsumes('multipart/form-data')
|
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Add documents to an existing booking',
|
summary: 'Get organization bookings',
|
||||||
description:
|
description:
|
||||||
'Upload additional documents to a pending booking. Only the booking owner can add documents.',
|
"Retrieve all bookings for the user's organization with pagination. For managers/admins.",
|
||||||
})
|
|
||||||
@ApiParam({ name: 'id', description: 'Booking ID (UUID)' })
|
|
||||||
@ApiBody({
|
|
||||||
schema: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
documents: {
|
|
||||||
type: 'array',
|
|
||||||
items: { type: 'string', format: 'binary' },
|
|
||||||
description: 'Documents to add (max 10 files)',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
@ApiQuery({ name: 'page', required: false, type: Number, example: 1 })
|
||||||
|
@ApiQuery({ name: 'limit', required: false, type: Number, example: 10 })
|
||||||
@ApiResponse({
|
@ApiResponse({
|
||||||
status: 200,
|
status: 200,
|
||||||
description: 'Documents added successfully',
|
description: 'Organization bookings retrieved successfully',
|
||||||
schema: {
|
type: CsvBookingListResponseDto,
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
success: { type: 'boolean', example: true },
|
|
||||||
message: { type: 'string', example: 'Documents added successfully' },
|
|
||||||
documentsAdded: { type: 'number', example: 2 },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
@ApiResponse({ status: 400, description: 'Invalid request or booking cannot be modified' })
|
|
||||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||||
@ApiResponse({ status: 404, description: 'Booking not found' })
|
async getOrganizationBookings(
|
||||||
async addDocuments(
|
@Request() req: any,
|
||||||
@Param('id') id: string,
|
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
|
||||||
@UploadedFiles() files: Express.Multer.File[],
|
@Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number
|
||||||
@Request() req: any
|
): Promise<CsvBookingListResponseDto> {
|
||||||
) {
|
const organizationId = req.user.organizationId;
|
||||||
if (!files || files.length === 0) {
|
return await this.csvBookingService.getOrganizationBookings(organizationId, page, limit);
|
||||||
throw new BadRequestException('At least one document is required');
|
|
||||||
}
|
|
||||||
|
|
||||||
const userId = req.user.id;
|
|
||||||
return await this.csvBookingService.addDocuments(id, files, userId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Replace a document in a booking
|
* Get organization booking statistics
|
||||||
*
|
*
|
||||||
* PUT /api/v1/csv-bookings/:bookingId/documents/:documentId
|
* GET /api/v1/csv-bookings/stats/organization
|
||||||
*/
|
*/
|
||||||
@Patch(':bookingId/documents/:documentId')
|
@Get('stats/organization')
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@UseInterceptors(FilesInterceptor('document', 1))
|
|
||||||
@ApiConsumes('multipart/form-data')
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Replace a document in a booking',
|
|
||||||
description:
|
|
||||||
'Replace an existing document with a new one. Only the booking owner can replace documents.',
|
|
||||||
})
|
|
||||||
@ApiParam({ name: 'bookingId', description: 'Booking ID (UUID)' })
|
|
||||||
@ApiParam({ name: 'documentId', description: 'Document ID (UUID) to replace' })
|
|
||||||
@ApiBody({
|
|
||||||
schema: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
document: {
|
|
||||||
type: 'string',
|
|
||||||
format: 'binary',
|
|
||||||
description: 'New document file to replace the existing one',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: 200,
|
|
||||||
description: 'Document replaced successfully',
|
|
||||||
schema: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
success: { type: 'boolean', example: true },
|
|
||||||
message: { type: 'string', example: 'Document replaced successfully' },
|
|
||||||
newDocument: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
id: { type: 'string' },
|
|
||||||
type: { type: 'string' },
|
|
||||||
fileName: { type: 'string' },
|
|
||||||
filePath: { type: 'string' },
|
|
||||||
mimeType: { type: 'string' },
|
|
||||||
size: { type: 'number' },
|
|
||||||
uploadedAt: { type: 'string', format: 'date-time' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@ApiResponse({ status: 400, description: 'Invalid request - missing file' })
|
|
||||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
|
||||||
@ApiResponse({ status: 404, description: 'Booking or document not found' })
|
|
||||||
async replaceDocument(
|
|
||||||
@Param('bookingId') bookingId: string,
|
|
||||||
@Param('documentId') documentId: string,
|
|
||||||
@UploadedFiles() files: Express.Multer.File[],
|
|
||||||
@Request() req: any
|
|
||||||
) {
|
|
||||||
if (!files || files.length === 0) {
|
|
||||||
throw new BadRequestException('A document file is required');
|
|
||||||
}
|
|
||||||
|
|
||||||
const userId = req.user.id;
|
|
||||||
return await this.csvBookingService.replaceDocument(bookingId, documentId, files[0], userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a document from a booking
|
|
||||||
*
|
|
||||||
* DELETE /api/v1/csv-bookings/:bookingId/documents/:documentId
|
|
||||||
*/
|
|
||||||
@Delete(':bookingId/documents/:documentId')
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Delete a document from a booking',
|
summary: 'Get organization booking statistics',
|
||||||
description:
|
description: "Get aggregated statistics for the user's organization. For managers/admins.",
|
||||||
'Remove a document from a pending booking. Only the booking owner can delete documents.',
|
|
||||||
})
|
})
|
||||||
@ApiParam({ name: 'bookingId', description: 'Booking ID (UUID)' })
|
|
||||||
@ApiParam({ name: 'documentId', description: 'Document ID (UUID)' })
|
|
||||||
@ApiResponse({
|
@ApiResponse({
|
||||||
status: 200,
|
status: 200,
|
||||||
description: 'Document deleted successfully',
|
description: 'Statistics retrieved successfully',
|
||||||
schema: {
|
type: CsvBookingStatsDto,
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
success: { type: 'boolean', example: true },
|
|
||||||
message: { type: 'string', example: 'Document deleted successfully' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
@ApiResponse({ status: 400, description: 'Booking cannot be modified (not pending)' })
|
|
||||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||||
@ApiResponse({ status: 404, description: 'Booking or document not found' })
|
async getOrganizationStats(@Request() req: any): Promise<CsvBookingStatsDto> {
|
||||||
async deleteDocument(
|
const organizationId = req.user.organizationId;
|
||||||
@Param('bookingId') bookingId: string,
|
return await this.csvBookingService.getOrganizationStats(organizationId);
|
||||||
@Param('documentId') documentId: string,
|
|
||||||
@Request() req: any
|
|
||||||
) {
|
|
||||||
const userId = req.user.id;
|
|
||||||
return await this.csvBookingService.deleteDocument(bookingId, documentId, userId);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -454,219 +454,6 @@ export class CsvBookingService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Add documents to an existing booking
|
|
||||||
*/
|
|
||||||
async addDocuments(
|
|
||||||
bookingId: string,
|
|
||||||
files: Express.Multer.File[],
|
|
||||||
userId: string
|
|
||||||
): Promise<{ success: boolean; message: string; documentsAdded: number }> {
|
|
||||||
this.logger.log(`Adding ${files.length} documents to booking ${bookingId}`);
|
|
||||||
|
|
||||||
const booking = await this.csvBookingRepository.findById(bookingId);
|
|
||||||
|
|
||||||
if (!booking) {
|
|
||||||
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify user owns this booking
|
|
||||||
if (booking.userId !== userId) {
|
|
||||||
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify booking is still pending
|
|
||||||
if (booking.status !== CsvBookingStatus.PENDING) {
|
|
||||||
throw new BadRequestException('Cannot add documents to a booking that is not pending');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Upload new documents
|
|
||||||
const newDocuments = await this.uploadDocuments(files, bookingId);
|
|
||||||
|
|
||||||
// Add documents to booking
|
|
||||||
const updatedDocuments = [...booking.documents, ...newDocuments];
|
|
||||||
|
|
||||||
// Update booking in database using ORM repository directly
|
|
||||||
const ormBooking = await this.csvBookingRepository['repository'].findOne({
|
|
||||||
where: { id: bookingId },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (ormBooking) {
|
|
||||||
ormBooking.documents = updatedDocuments.map(doc => ({
|
|
||||||
id: doc.id,
|
|
||||||
type: doc.type,
|
|
||||||
fileName: doc.fileName,
|
|
||||||
filePath: doc.filePath,
|
|
||||||
mimeType: doc.mimeType,
|
|
||||||
size: doc.size,
|
|
||||||
uploadedAt: doc.uploadedAt,
|
|
||||||
}));
|
|
||||||
await this.csvBookingRepository['repository'].save(ormBooking);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.log(`Added ${newDocuments.length} documents to booking ${bookingId}`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: 'Documents added successfully',
|
|
||||||
documentsAdded: newDocuments.length,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a document from a booking
|
|
||||||
*/
|
|
||||||
async deleteDocument(
|
|
||||||
bookingId: string,
|
|
||||||
documentId: string,
|
|
||||||
userId: string
|
|
||||||
): Promise<{ success: boolean; message: string }> {
|
|
||||||
this.logger.log(`Deleting document ${documentId} from booking ${bookingId}`);
|
|
||||||
|
|
||||||
const booking = await this.csvBookingRepository.findById(bookingId);
|
|
||||||
|
|
||||||
if (!booking) {
|
|
||||||
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify user owns this booking
|
|
||||||
if (booking.userId !== userId) {
|
|
||||||
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify booking is still pending
|
|
||||||
if (booking.status !== CsvBookingStatus.PENDING) {
|
|
||||||
throw new BadRequestException('Cannot delete documents from a booking that is not pending');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the document
|
|
||||||
const documentIndex = booking.documents.findIndex(doc => doc.id === documentId);
|
|
||||||
|
|
||||||
if (documentIndex === -1) {
|
|
||||||
throw new NotFoundException(`Document with ID ${documentId} not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure at least one document remains
|
|
||||||
if (booking.documents.length <= 1) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
'Cannot delete the last document. At least one document is required.'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the document to delete (for potential S3 cleanup - currently kept for audit)
|
|
||||||
const _documentToDelete = booking.documents[documentIndex];
|
|
||||||
|
|
||||||
// Remove document from array
|
|
||||||
const updatedDocuments = booking.documents.filter(doc => doc.id !== documentId);
|
|
||||||
|
|
||||||
// Update booking in database using ORM repository directly
|
|
||||||
const ormBooking = await this.csvBookingRepository['repository'].findOne({
|
|
||||||
where: { id: bookingId },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (ormBooking) {
|
|
||||||
ormBooking.documents = updatedDocuments.map(doc => ({
|
|
||||||
id: doc.id,
|
|
||||||
type: doc.type,
|
|
||||||
fileName: doc.fileName,
|
|
||||||
filePath: doc.filePath,
|
|
||||||
mimeType: doc.mimeType,
|
|
||||||
size: doc.size,
|
|
||||||
uploadedAt: doc.uploadedAt,
|
|
||||||
}));
|
|
||||||
await this.csvBookingRepository['repository'].save(ormBooking);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Optionally delete from S3 (commented out for safety - keep files for audit)
|
|
||||||
// try {
|
|
||||||
// await this.storageAdapter.delete({
|
|
||||||
// bucket: 'xpeditis-documents',
|
|
||||||
// key: documentToDelete.filePath,
|
|
||||||
// });
|
|
||||||
// } catch (error) {
|
|
||||||
// this.logger.warn(`Failed to delete file from S3: ${documentToDelete.filePath}`);
|
|
||||||
// }
|
|
||||||
|
|
||||||
this.logger.log(`Deleted document ${documentId} from booking ${bookingId}`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: 'Document deleted successfully',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Replace a document in an existing booking
|
|
||||||
*/
|
|
||||||
async replaceDocument(
|
|
||||||
bookingId: string,
|
|
||||||
documentId: string,
|
|
||||||
file: Express.Multer.File,
|
|
||||||
userId: string
|
|
||||||
): Promise<{ success: boolean; message: string; newDocument: any }> {
|
|
||||||
this.logger.log(`Replacing document ${documentId} in booking ${bookingId}`);
|
|
||||||
|
|
||||||
const booking = await this.csvBookingRepository.findById(bookingId);
|
|
||||||
|
|
||||||
if (!booking) {
|
|
||||||
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify user owns this booking
|
|
||||||
if (booking.userId !== userId) {
|
|
||||||
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the document to replace
|
|
||||||
const documentIndex = booking.documents.findIndex(doc => doc.id === documentId);
|
|
||||||
|
|
||||||
if (documentIndex === -1) {
|
|
||||||
throw new NotFoundException(`Document with ID ${documentId} not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Upload the new document
|
|
||||||
const newDocuments = await this.uploadDocuments([file], bookingId);
|
|
||||||
const newDocument = newDocuments[0];
|
|
||||||
|
|
||||||
// Replace the document in the array
|
|
||||||
const updatedDocuments = [...booking.documents];
|
|
||||||
updatedDocuments[documentIndex] = newDocument;
|
|
||||||
|
|
||||||
// Update booking in database using ORM repository directly
|
|
||||||
const ormBooking = await this.csvBookingRepository['repository'].findOne({
|
|
||||||
where: { id: bookingId },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (ormBooking) {
|
|
||||||
ormBooking.documents = updatedDocuments.map(doc => ({
|
|
||||||
id: doc.id,
|
|
||||||
type: doc.type,
|
|
||||||
fileName: doc.fileName,
|
|
||||||
filePath: doc.filePath,
|
|
||||||
mimeType: doc.mimeType,
|
|
||||||
size: doc.size,
|
|
||||||
uploadedAt: doc.uploadedAt,
|
|
||||||
}));
|
|
||||||
await this.csvBookingRepository['repository'].save(ormBooking);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.log(`Replaced document ${documentId} with ${newDocument.id} in booking ${bookingId}`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: 'Document replaced successfully',
|
|
||||||
newDocument: {
|
|
||||||
id: newDocument.id,
|
|
||||||
type: newDocument.type,
|
|
||||||
fileName: newDocument.fileName,
|
|
||||||
filePath: newDocument.filePath,
|
|
||||||
mimeType: newDocument.mimeType,
|
|
||||||
size: newDocument.size,
|
|
||||||
uploadedAt: newDocument.uploadedAt,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Infer document type from filename
|
* Infer document type from filename
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -332,64 +332,4 @@ export class CsvBooking {
|
|||||||
toString(): string {
|
toString(): string {
|
||||||
return this.getSummary();
|
return this.getSummary();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a CsvBooking from persisted data (skips document validation)
|
|
||||||
*
|
|
||||||
* Use this when loading from database where bookings might have been created
|
|
||||||
* before document requirement was enforced, or documents were lost.
|
|
||||||
*/
|
|
||||||
static fromPersistence(
|
|
||||||
id: string,
|
|
||||||
userId: string,
|
|
||||||
organizationId: string,
|
|
||||||
carrierName: string,
|
|
||||||
carrierEmail: string,
|
|
||||||
origin: PortCode,
|
|
||||||
destination: PortCode,
|
|
||||||
volumeCBM: number,
|
|
||||||
weightKG: number,
|
|
||||||
palletCount: number,
|
|
||||||
priceUSD: number,
|
|
||||||
priceEUR: number,
|
|
||||||
primaryCurrency: string,
|
|
||||||
transitDays: number,
|
|
||||||
containerType: string,
|
|
||||||
status: CsvBookingStatus,
|
|
||||||
documents: CsvBookingDocument[],
|
|
||||||
confirmationToken: string,
|
|
||||||
requestedAt: Date,
|
|
||||||
respondedAt?: Date,
|
|
||||||
notes?: string,
|
|
||||||
rejectionReason?: string
|
|
||||||
): CsvBooking {
|
|
||||||
// Create instance without calling constructor validation
|
|
||||||
const booking = Object.create(CsvBooking.prototype);
|
|
||||||
|
|
||||||
// Assign all properties directly
|
|
||||||
booking.id = id;
|
|
||||||
booking.userId = userId;
|
|
||||||
booking.organizationId = organizationId;
|
|
||||||
booking.carrierName = carrierName;
|
|
||||||
booking.carrierEmail = carrierEmail;
|
|
||||||
booking.origin = origin;
|
|
||||||
booking.destination = destination;
|
|
||||||
booking.volumeCBM = volumeCBM;
|
|
||||||
booking.weightKG = weightKG;
|
|
||||||
booking.palletCount = palletCount;
|
|
||||||
booking.priceUSD = priceUSD;
|
|
||||||
booking.priceEUR = priceEUR;
|
|
||||||
booking.primaryCurrency = primaryCurrency;
|
|
||||||
booking.transitDays = transitDays;
|
|
||||||
booking.containerType = containerType;
|
|
||||||
booking.status = status;
|
|
||||||
booking.documents = documents || [];
|
|
||||||
booking.confirmationToken = confirmationToken;
|
|
||||||
booking.requestedAt = requestedAt;
|
|
||||||
booking.respondedAt = respondedAt;
|
|
||||||
booking.notes = notes;
|
|
||||||
booking.rejectionReason = rejectionReason;
|
|
||||||
|
|
||||||
return booking;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,12 +14,9 @@ import { CsvBookingOrmEntity } from '../entities/csv-booking.orm-entity';
|
|||||||
export class CsvBookingMapper {
|
export class CsvBookingMapper {
|
||||||
/**
|
/**
|
||||||
* Map ORM entity to domain entity
|
* Map ORM entity to domain entity
|
||||||
*
|
|
||||||
* Uses fromPersistence to avoid validation errors when loading legacy data
|
|
||||||
* that might have empty documents array
|
|
||||||
*/
|
*/
|
||||||
static toDomain(ormEntity: CsvBookingOrmEntity): CsvBooking {
|
static toDomain(ormEntity: CsvBookingOrmEntity): CsvBooking {
|
||||||
return CsvBooking.fromPersistence(
|
return new CsvBooking(
|
||||||
ormEntity.id,
|
ormEntity.id,
|
||||||
ormEntity.userId,
|
ormEntity.userId,
|
||||||
ormEntity.organizationId,
|
ormEntity.organizationId,
|
||||||
|
|||||||
@ -1,981 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
||||||
import { listCsvBookings, CsvBookingResponse } from '@/lib/api/bookings';
|
|
||||||
|
|
||||||
interface Document {
|
|
||||||
id: string;
|
|
||||||
fileName: string;
|
|
||||||
filePath: string;
|
|
||||||
type: string;
|
|
||||||
mimeType: string;
|
|
||||||
size: number;
|
|
||||||
uploadedAt?: Date;
|
|
||||||
// Legacy fields for compatibility
|
|
||||||
name?: string;
|
|
||||||
url?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DocumentWithBooking extends Document {
|
|
||||||
bookingId: string;
|
|
||||||
quoteNumber: string;
|
|
||||||
route: string;
|
|
||||||
status: string;
|
|
||||||
carrierName: string;
|
|
||||||
fileType?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function UserDocumentsPage() {
|
|
||||||
const [bookings, setBookings] = useState<CsvBookingResponse[]>([]);
|
|
||||||
const [documents, setDocuments] = useState<DocumentWithBooking[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
|
||||||
const [filterStatus, setFilterStatus] = useState('all');
|
|
||||||
const [filterQuoteNumber, setFilterQuoteNumber] = useState('');
|
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
|
||||||
const [itemsPerPage, setItemsPerPage] = useState(10);
|
|
||||||
|
|
||||||
// Modal state for adding documents
|
|
||||||
const [showAddModal, setShowAddModal] = useState(false);
|
|
||||||
const [selectedBookingId, setSelectedBookingId] = useState<string | null>(null);
|
|
||||||
const [uploadingFiles, setUploadingFiles] = useState(false);
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
// Modal state for replacing documents
|
|
||||||
const [showReplaceModal, setShowReplaceModal] = useState(false);
|
|
||||||
const [documentToReplace, setDocumentToReplace] = useState<DocumentWithBooking | null>(null);
|
|
||||||
const [replacingFile, setReplacingFile] = useState(false);
|
|
||||||
const replaceFileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
// Dropdown menu state
|
|
||||||
const [openDropdownId, setOpenDropdownId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Helper function to get formatted quote number
|
|
||||||
const getQuoteNumber = (booking: CsvBookingResponse): string => {
|
|
||||||
return `#${booking.bookingId || booking.id.slice(0, 8).toUpperCase()}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get file extension and type
|
|
||||||
const getFileType = (fileName: string): string => {
|
|
||||||
const ext = fileName.split('.').pop()?.toLowerCase() || '';
|
|
||||||
const typeMap: Record<string, string> = {
|
|
||||||
pdf: 'PDF',
|
|
||||||
doc: 'Word',
|
|
||||||
docx: 'Word',
|
|
||||||
xls: 'Excel',
|
|
||||||
xlsx: 'Excel',
|
|
||||||
jpg: 'Image',
|
|
||||||
jpeg: 'Image',
|
|
||||||
png: 'Image',
|
|
||||||
gif: 'Image',
|
|
||||||
txt: 'Text',
|
|
||||||
csv: 'CSV',
|
|
||||||
};
|
|
||||||
return typeMap[ext] || ext.toUpperCase();
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchBookingsAndDocuments = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
// Fetch all user's bookings (paginated, get all pages)
|
|
||||||
const response = await listCsvBookings({ page: 1, limit: 1000 });
|
|
||||||
const allBookings = response.bookings || [];
|
|
||||||
setBookings(allBookings);
|
|
||||||
|
|
||||||
// Extract all documents from all bookings
|
|
||||||
const allDocuments: DocumentWithBooking[] = [];
|
|
||||||
|
|
||||||
allBookings.forEach((booking: CsvBookingResponse) => {
|
|
||||||
if (booking.documents && booking.documents.length > 0) {
|
|
||||||
booking.documents.forEach((doc: any, index: number) => {
|
|
||||||
// Use the correct field names from the backend
|
|
||||||
const actualFileName = doc.fileName || doc.name || 'document';
|
|
||||||
const actualFilePath = doc.filePath || doc.url || '';
|
|
||||||
const actualMimeType = doc.mimeType || doc.type || '';
|
|
||||||
|
|
||||||
// Extract clean file type from mimeType or fileName
|
|
||||||
let fileType = '';
|
|
||||||
if (actualMimeType.includes('/')) {
|
|
||||||
const parts = actualMimeType.split('/');
|
|
||||||
fileType = getFileType(parts[1]);
|
|
||||||
} else {
|
|
||||||
fileType = getFileType(actualFileName);
|
|
||||||
}
|
|
||||||
|
|
||||||
allDocuments.push({
|
|
||||||
id: doc.id || `${booking.id}-doc-${index}`,
|
|
||||||
fileName: actualFileName,
|
|
||||||
filePath: actualFilePath,
|
|
||||||
type: doc.type || '',
|
|
||||||
mimeType: actualMimeType,
|
|
||||||
size: doc.size || 0,
|
|
||||||
uploadedAt: doc.uploadedAt,
|
|
||||||
bookingId: booking.id,
|
|
||||||
quoteNumber: getQuoteNumber(booking),
|
|
||||||
route: `${booking.origin || 'N/A'} → ${booking.destination || 'N/A'}`,
|
|
||||||
status: booking.status,
|
|
||||||
carrierName: booking.carrierName || 'N/A',
|
|
||||||
fileType: fileType,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
setDocuments(allDocuments);
|
|
||||||
setError(null);
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(err.message || 'Erreur lors du chargement des documents');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchBookingsAndDocuments();
|
|
||||||
}, [fetchBookingsAndDocuments]);
|
|
||||||
|
|
||||||
// Filter documents
|
|
||||||
const filteredDocuments = documents.filter(doc => {
|
|
||||||
const matchesSearch =
|
|
||||||
searchTerm === '' ||
|
|
||||||
(doc.fileName && doc.fileName.toLowerCase().includes(searchTerm.toLowerCase())) ||
|
|
||||||
(doc.type && doc.type.toLowerCase().includes(searchTerm.toLowerCase())) ||
|
|
||||||
doc.route.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
||||||
doc.carrierName.toLowerCase().includes(searchTerm.toLowerCase());
|
|
||||||
|
|
||||||
const matchesStatus = filterStatus === 'all' || doc.status === filterStatus;
|
|
||||||
|
|
||||||
const matchesQuote =
|
|
||||||
filterQuoteNumber === '' ||
|
|
||||||
doc.quoteNumber.toLowerCase().includes(filterQuoteNumber.toLowerCase());
|
|
||||||
|
|
||||||
return matchesSearch && matchesStatus && matchesQuote;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Pagination
|
|
||||||
const totalPages = Math.ceil(filteredDocuments.length / itemsPerPage);
|
|
||||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
|
||||||
const endIndex = startIndex + itemsPerPage;
|
|
||||||
const paginatedDocuments = filteredDocuments.slice(startIndex, endIndex);
|
|
||||||
|
|
||||||
// Reset to page 1 when filters change
|
|
||||||
useEffect(() => {
|
|
||||||
setCurrentPage(1);
|
|
||||||
}, [searchTerm, filterStatus, filterQuoteNumber]);
|
|
||||||
|
|
||||||
const getDocumentIcon = (type: string) => {
|
|
||||||
const typeLower = type.toLowerCase();
|
|
||||||
const icons: Record<string, string> = {
|
|
||||||
'application/pdf': '📄',
|
|
||||||
'image/jpeg': '🖼️',
|
|
||||||
'image/png': '🖼️',
|
|
||||||
'image/jpg': '🖼️',
|
|
||||||
pdf: '📄',
|
|
||||||
jpeg: '🖼️',
|
|
||||||
jpg: '🖼️',
|
|
||||||
png: '🖼️',
|
|
||||||
gif: '🖼️',
|
|
||||||
image: '🖼️',
|
|
||||||
word: '📝',
|
|
||||||
doc: '📝',
|
|
||||||
docx: '📝',
|
|
||||||
excel: '📊',
|
|
||||||
xls: '📊',
|
|
||||||
xlsx: '📊',
|
|
||||||
csv: '📊',
|
|
||||||
text: '📄',
|
|
||||||
txt: '📄',
|
|
||||||
};
|
|
||||||
return icons[typeLower] || '📎';
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatusColor = (status: string) => {
|
|
||||||
const colors: Record<string, string> = {
|
|
||||||
PENDING: 'bg-yellow-100 text-yellow-800',
|
|
||||||
ACCEPTED: 'bg-green-100 text-green-800',
|
|
||||||
REJECTED: 'bg-red-100 text-red-800',
|
|
||||||
CANCELLED: 'bg-gray-100 text-gray-800',
|
|
||||||
};
|
|
||||||
return colors[status] || 'bg-gray-100 text-gray-800';
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatusLabel = (status: string) => {
|
|
||||||
const labels: Record<string, string> = {
|
|
||||||
PENDING: 'En attente',
|
|
||||||
ACCEPTED: 'Accepté',
|
|
||||||
REJECTED: 'Refusé',
|
|
||||||
CANCELLED: 'Annulé',
|
|
||||||
};
|
|
||||||
return labels[status] || status;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDownload = async (url: string, fileName: string) => {
|
|
||||||
try {
|
|
||||||
// Try direct download first
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.href = url;
|
|
||||||
link.download = fileName;
|
|
||||||
link.target = '_blank';
|
|
||||||
link.setAttribute('download', fileName);
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
document.body.removeChild(link);
|
|
||||||
|
|
||||||
// If direct download doesn't work, try fetch with blob
|
|
||||||
setTimeout(async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(url, {
|
|
||||||
mode: 'cors',
|
|
||||||
credentials: 'include',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const blob = await response.blob();
|
|
||||||
const blobUrl = window.URL.createObjectURL(blob);
|
|
||||||
const link2 = document.createElement('a');
|
|
||||||
link2.href = blobUrl;
|
|
||||||
link2.download = fileName;
|
|
||||||
document.body.appendChild(link2);
|
|
||||||
link2.click();
|
|
||||||
document.body.removeChild(link2);
|
|
||||||
window.URL.revokeObjectURL(blobUrl);
|
|
||||||
} catch (fetchError) {
|
|
||||||
console.error('Fetch download failed:', fetchError);
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error downloading file:', error);
|
|
||||||
alert(
|
|
||||||
`Erreur lors du téléchargement du document: ${error instanceof Error ? error.message : 'Erreur inconnue'}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get unique bookings for add document modal
|
|
||||||
const bookingsWithPendingStatus = bookings.filter(b => b.status === 'PENDING');
|
|
||||||
|
|
||||||
const handleAddDocumentClick = () => {
|
|
||||||
setShowAddModal(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCloseModal = () => {
|
|
||||||
setShowAddModal(false);
|
|
||||||
setSelectedBookingId(null);
|
|
||||||
if (fileInputRef.current) {
|
|
||||||
fileInputRef.current.value = '';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFileUpload = async () => {
|
|
||||||
if (!selectedBookingId || !fileInputRef.current?.files?.length) {
|
|
||||||
alert('Veuillez sélectionner une réservation et au moins un fichier');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setUploadingFiles(true);
|
|
||||||
try {
|
|
||||||
const formData = new FormData();
|
|
||||||
const files = fileInputRef.current.files;
|
|
||||||
for (let i = 0; i < files.length; i++) {
|
|
||||||
formData.append('documents', files[i]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = localStorage.getItem('access_token');
|
|
||||||
const response = await fetch(
|
|
||||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/csv-bookings/${selectedBookingId}/documents`,
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
body: formData,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Erreur lors de l\'ajout des documents');
|
|
||||||
}
|
|
||||||
|
|
||||||
alert('Documents ajoutés avec succès!');
|
|
||||||
handleCloseModal();
|
|
||||||
fetchBookingsAndDocuments(); // Refresh the list
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error uploading documents:', error);
|
|
||||||
alert(
|
|
||||||
`Erreur lors de l'ajout des documents: ${error instanceof Error ? error.message : 'Erreur inconnue'}`
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setUploadingFiles(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Toggle dropdown menu
|
|
||||||
const toggleDropdown = (docId: string) => {
|
|
||||||
setOpenDropdownId(openDropdownId === docId ? null : docId);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Close dropdown when clicking outside
|
|
||||||
useEffect(() => {
|
|
||||||
const handleClickOutside = () => {
|
|
||||||
setOpenDropdownId(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (openDropdownId) {
|
|
||||||
document.addEventListener('click', handleClickOutside);
|
|
||||||
return () => document.removeEventListener('click', handleClickOutside);
|
|
||||||
}
|
|
||||||
}, [openDropdownId]);
|
|
||||||
|
|
||||||
// Replace document handlers
|
|
||||||
const handleReplaceClick = (doc: DocumentWithBooking) => {
|
|
||||||
setOpenDropdownId(null);
|
|
||||||
setDocumentToReplace(doc);
|
|
||||||
setShowReplaceModal(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCloseReplaceModal = () => {
|
|
||||||
setShowReplaceModal(false);
|
|
||||||
setDocumentToReplace(null);
|
|
||||||
if (replaceFileInputRef.current) {
|
|
||||||
replaceFileInputRef.current.value = '';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleReplaceDocument = async () => {
|
|
||||||
if (!documentToReplace || !replaceFileInputRef.current?.files?.length) {
|
|
||||||
alert('Veuillez sélectionner un fichier de remplacement');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setReplacingFile(true);
|
|
||||||
try {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('document', replaceFileInputRef.current.files[0]);
|
|
||||||
|
|
||||||
const token = localStorage.getItem('access_token');
|
|
||||||
const response = await fetch(
|
|
||||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/csv-bookings/${documentToReplace.bookingId}/documents/${documentToReplace.id}`,
|
|
||||||
{
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
body: formData,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json().catch(() => ({}));
|
|
||||||
throw new Error(errorData.message || 'Erreur lors du remplacement du document');
|
|
||||||
}
|
|
||||||
|
|
||||||
alert('Document remplacé avec succès!');
|
|
||||||
handleCloseReplaceModal();
|
|
||||||
fetchBookingsAndDocuments(); // Refresh the list
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error replacing document:', error);
|
|
||||||
alert(
|
|
||||||
`Erreur lors du remplacement: ${error instanceof Error ? error.message : 'Erreur inconnue'}`
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setReplacingFile(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center h-96">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
|
|
||||||
<p className="mt-4 text-gray-600">Chargement des documents...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Mes Documents</h1>
|
|
||||||
<p className="mt-1 text-sm text-gray-500">
|
|
||||||
Gérez tous les documents de vos réservations
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={handleAddDocumentClick}
|
|
||||||
disabled={bookingsWithPendingStatus.length === 0}
|
|
||||||
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M12 4v16m8-8H4"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Ajouter un document
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stats */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
|
||||||
<div className="text-sm text-gray-500">Total Documents</div>
|
|
||||||
<div className="text-2xl font-bold text-gray-900">{documents.length}</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
|
||||||
<div className="text-sm text-gray-500">Réservations avec Documents</div>
|
|
||||||
<div className="text-2xl font-bold text-blue-600">
|
|
||||||
{bookings.filter(b => b.documents && b.documents.length > 0).length}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
|
||||||
<div className="text-sm text-gray-500">Documents Filtrés</div>
|
|
||||||
<div className="text-2xl font-bold text-green-600">{filteredDocuments.length}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filters */}
|
|
||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Recherche</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Nom, type, route, transporteur..."
|
|
||||||
value={searchTerm}
|
|
||||||
onChange={e => setSearchTerm(e.target.value)}
|
|
||||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Numéro de Devis</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Ex: #F2CAD5E1"
|
|
||||||
value={filterQuoteNumber}
|
|
||||||
onChange={e => setFilterQuoteNumber(e.target.value)}
|
|
||||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Statut</label>
|
|
||||||
<select
|
|
||||||
value={filterStatus}
|
|
||||||
onChange={e => setFilterStatus(e.target.value)}
|
|
||||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
|
|
||||||
>
|
|
||||||
<option value="all">Tous les statuts</option>
|
|
||||||
<option value="PENDING">En attente</option>
|
|
||||||
<option value="ACCEPTED">Accepté</option>
|
|
||||||
<option value="REJECTED">Refusé</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Error Message */}
|
|
||||||
{error && (
|
|
||||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Documents Table */}
|
|
||||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
|
||||||
<table className="min-w-full divide-y divide-gray-200">
|
|
||||||
<thead className="bg-gray-50">
|
|
||||||
<tr>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Nom du Document
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Type
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
N° de Devis
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Route
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Transporteur
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Statut
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Actions
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
|
||||||
{paginatedDocuments.length === 0 ? (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={7} className="px-6 py-12 text-center text-gray-500">
|
|
||||||
{documents.length === 0
|
|
||||||
? 'Aucun document trouvé. Ajoutez des documents à vos réservations.'
|
|
||||||
: 'Aucun document ne correspond aux filtres sélectionnés.'}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : (
|
|
||||||
paginatedDocuments.map((doc, index) => (
|
|
||||||
<tr key={`${doc.bookingId}-${doc.id}-${index}`} className="hover:bg-gray-50">
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<div className="text-sm font-medium text-gray-900">{doc.fileName}</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<span className="text-2xl mr-2">
|
|
||||||
{getDocumentIcon(doc.fileType || doc.type)}
|
|
||||||
</span>
|
|
||||||
<div className="text-xs text-gray-500">{doc.fileType || doc.type}</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
|
||||||
<div className="text-sm font-medium text-gray-900">{doc.quoteNumber}</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
|
||||||
<div className="text-sm text-gray-900">{doc.route}</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
|
||||||
<div className="text-sm text-gray-900">{doc.carrierName}</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
|
||||||
<span
|
|
||||||
className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(doc.status)}`}
|
|
||||||
>
|
|
||||||
{getStatusLabel(doc.status)}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-center">
|
|
||||||
<div className="relative inline-block text-left">
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
toggleDropdown(`${doc.bookingId}-${doc.id}`);
|
|
||||||
}}
|
|
||||||
className="inline-flex items-center justify-center w-8 h-8 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-full transition-colors"
|
|
||||||
title="Actions"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className="w-5 h-5"
|
|
||||||
fill="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<circle cx="12" cy="5" r="2" />
|
|
||||||
<circle cx="12" cy="12" r="2" />
|
|
||||||
<circle cx="12" cy="19" r="2" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Dropdown Menu */}
|
|
||||||
{openDropdownId === `${doc.bookingId}-${doc.id}` && (
|
|
||||||
<div
|
|
||||||
className="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-gray-200 z-50"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<div className="py-1">
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setOpenDropdownId(null);
|
|
||||||
handleDownload(doc.filePath || doc.url || '', doc.fileName);
|
|
||||||
}}
|
|
||||||
className="flex items-center w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className="w-4 h-4 mr-3 text-green-600"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Télécharger
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleReplaceClick(doc)}
|
|
||||||
className="flex items-center w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className="w-4 h-4 mr-3 text-blue-600"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Remplacer
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
{/* Pagination Controls */}
|
|
||||||
{filteredDocuments.length > 0 && (
|
|
||||||
<div className="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6">
|
|
||||||
<div className="flex-1 flex justify-between sm:hidden">
|
|
||||||
<button
|
|
||||||
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
|
|
||||||
disabled={currentPage === 1}
|
|
||||||
className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
Précédent
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
|
|
||||||
disabled={currentPage === totalPages}
|
|
||||||
className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
Suivant
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-gray-700">
|
|
||||||
Affichage de <span className="font-medium">{startIndex + 1}</span> à{' '}
|
|
||||||
<span className="font-medium">
|
|
||||||
{Math.min(endIndex, filteredDocuments.length)}
|
|
||||||
</span>{' '}
|
|
||||||
sur <span className="font-medium">{filteredDocuments.length}</span> résultats
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<label className="text-sm text-gray-700">Par page:</label>
|
|
||||||
<select
|
|
||||||
value={itemsPerPage}
|
|
||||||
onChange={e => {
|
|
||||||
setItemsPerPage(Number(e.target.value));
|
|
||||||
setCurrentPage(1);
|
|
||||||
}}
|
|
||||||
className="border border-gray-300 rounded-md px-2 py-1 text-sm"
|
|
||||||
>
|
|
||||||
<option value={5}>5</option>
|
|
||||||
<option value={10}>10</option>
|
|
||||||
<option value={25}>25</option>
|
|
||||||
<option value={50}>50</option>
|
|
||||||
<option value={100}>100</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<nav
|
|
||||||
className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px"
|
|
||||||
aria-label="Pagination"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
|
|
||||||
disabled={currentPage === 1}
|
|
||||||
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
<span className="sr-only">Précédent</span>
|
|
||||||
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path
|
|
||||||
fillRule="evenodd"
|
|
||||||
d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z"
|
|
||||||
clipRule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Page numbers */}
|
|
||||||
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
|
||||||
let pageNum;
|
|
||||||
if (totalPages <= 5) {
|
|
||||||
pageNum = i + 1;
|
|
||||||
} else if (currentPage <= 3) {
|
|
||||||
pageNum = i + 1;
|
|
||||||
} else if (currentPage >= totalPages - 2) {
|
|
||||||
pageNum = totalPages - 4 + i;
|
|
||||||
} else {
|
|
||||||
pageNum = currentPage - 2 + i;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={pageNum}
|
|
||||||
onClick={() => setCurrentPage(pageNum)}
|
|
||||||
className={`relative inline-flex items-center px-4 py-2 border text-sm font-medium ${
|
|
||||||
currentPage === pageNum
|
|
||||||
? 'z-10 bg-blue-50 border-blue-500 text-blue-600'
|
|
||||||
: 'bg-white border-gray-300 text-gray-500 hover:bg-gray-50'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{pageNum}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
|
|
||||||
disabled={currentPage === totalPages}
|
|
||||||
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
<span className="sr-only">Suivant</span>
|
|
||||||
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path
|
|
||||||
fillRule="evenodd"
|
|
||||||
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
|
|
||||||
clipRule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Add Document Modal */}
|
|
||||||
{showAddModal && (
|
|
||||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
|
||||||
<div className="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
|
||||||
{/* Background overlay */}
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
|
|
||||||
onClick={handleCloseModal}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Modal panel */}
|
|
||||||
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
|
|
||||||
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
|
||||||
<div className="sm:flex sm:items-start">
|
|
||||||
<div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-blue-100 sm:mx-0 sm:h-10 sm:w-10">
|
|
||||||
<svg
|
|
||||||
className="h-6 w-6 text-blue-600"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M12 4v16m8-8H4"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left flex-1">
|
|
||||||
<h3 className="text-lg leading-6 font-medium text-gray-900">
|
|
||||||
Ajouter un document
|
|
||||||
</h3>
|
|
||||||
<div className="mt-4 space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Sélectionner une réservation (en attente)
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={selectedBookingId || ''}
|
|
||||||
onChange={e => setSelectedBookingId(e.target.value)}
|
|
||||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
|
|
||||||
>
|
|
||||||
<option value="">-- Choisir une réservation --</option>
|
|
||||||
{bookingsWithPendingStatus.map(booking => (
|
|
||||||
<option key={booking.id} value={booking.id}>
|
|
||||||
{getQuoteNumber(booking)} - {booking.origin} → {booking.destination}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Fichiers à ajouter
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
multiple
|
|
||||||
accept=".pdf,.doc,.docx,.xls,.xlsx,.jpg,.jpeg,.png,.gif"
|
|
||||||
className="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100"
|
|
||||||
/>
|
|
||||||
<p className="mt-1 text-xs text-gray-500">
|
|
||||||
Formats acceptés: PDF, Word, Excel, Images (max 10 fichiers)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleFileUpload}
|
|
||||||
disabled={uploadingFiles || !selectedBookingId}
|
|
||||||
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-600 text-base font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:ml-3 sm:w-auto sm:text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
{uploadingFiles ? (
|
|
||||||
<>
|
|
||||||
<svg
|
|
||||||
className="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<circle
|
|
||||||
className="opacity-25"
|
|
||||||
cx="12"
|
|
||||||
cy="12"
|
|
||||||
r="10"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="4"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
className="opacity-75"
|
|
||||||
fill="currentColor"
|
|
||||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Envoi en cours...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
'Ajouter'
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleCloseModal}
|
|
||||||
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
|
|
||||||
>
|
|
||||||
Annuler
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Replace Document Modal */}
|
|
||||||
{showReplaceModal && documentToReplace && (
|
|
||||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
|
||||||
<div className="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
|
||||||
{/* Background overlay */}
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
|
|
||||||
onClick={handleCloseReplaceModal}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Modal panel */}
|
|
||||||
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
|
|
||||||
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
|
||||||
<div className="sm:flex sm:items-start">
|
|
||||||
<div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-blue-100 sm:mx-0 sm:h-10 sm:w-10">
|
|
||||||
<svg
|
|
||||||
className="h-6 w-6 text-blue-600"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left flex-1">
|
|
||||||
<h3 className="text-lg leading-6 font-medium text-gray-900">
|
|
||||||
Remplacer le document
|
|
||||||
</h3>
|
|
||||||
<div className="mt-4 space-y-4">
|
|
||||||
{/* Current document info */}
|
|
||||||
<div className="bg-gray-50 rounded-lg p-3">
|
|
||||||
<p className="text-sm text-gray-500">Document actuel:</p>
|
|
||||||
<p className="text-sm font-medium text-gray-900 mt-1">
|
|
||||||
{documentToReplace.fileName}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
|
||||||
Réservation: {documentToReplace.quoteNumber} - {documentToReplace.route}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Nouveau fichier
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
ref={replaceFileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept=".pdf,.doc,.docx,.xls,.xlsx,.jpg,.jpeg,.png,.gif"
|
|
||||||
className="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100"
|
|
||||||
/>
|
|
||||||
<p className="mt-1 text-xs text-gray-500">
|
|
||||||
Formats acceptés: PDF, Word, Excel, Images
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleReplaceDocument}
|
|
||||||
disabled={replacingFile}
|
|
||||||
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-600 text-base font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:ml-3 sm:w-auto sm:text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
{replacingFile ? (
|
|
||||||
<>
|
|
||||||
<svg
|
|
||||||
className="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<circle
|
|
||||||
className="opacity-25"
|
|
||||||
cx="12"
|
|
||||||
cy="12"
|
|
||||||
r="10"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="4"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
className="opacity-75"
|
|
||||||
fill="currentColor"
|
|
||||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Remplacement en cours...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
'Remplacer'
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleCloseReplaceModal}
|
|
||||||
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
|
|
||||||
>
|
|
||||||
Annuler
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -24,7 +24,6 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
|||||||
const navigation = [
|
const navigation = [
|
||||||
{ name: 'Dashboard', href: '/dashboard', icon: '📊' },
|
{ name: 'Dashboard', href: '/dashboard', icon: '📊' },
|
||||||
{ name: 'Bookings', href: '/dashboard/bookings', icon: '📦' },
|
{ name: 'Bookings', href: '/dashboard/bookings', icon: '📦' },
|
||||||
{ name: 'Documents', href: '/dashboard/documents', icon: '📄' },
|
|
||||||
{ name: 'My Profile', href: '/dashboard/profile', icon: '👤' },
|
{ name: 'My Profile', href: '/dashboard/profile', icon: '👤' },
|
||||||
{ name: 'Organization', href: '/dashboard/settings/organization', icon: '🏢' },
|
{ name: 'Organization', href: '/dashboard/settings/organization', icon: '🏢' },
|
||||||
// ADMIN and MANAGER only navigation items
|
// ADMIN and MANAGER only navigation items
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user