fix document
This commit is contained in:
parent
0a8e2043cc
commit
de4126a657
@ -40,12 +40,20 @@ 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
|
||||||
*
|
*
|
||||||
@ -152,6 +160,112 @@ 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)
|
||||||
*
|
*
|
||||||
@ -227,10 +341,17 @@ 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)
|
||||||
@ -253,59 +374,6 @@ 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)
|
||||||
*
|
*
|
||||||
@ -335,59 +403,6 @@ export class CsvBookingsController {
|
|||||||
return await this.csvBookingService.cancelBooking(id, userId);
|
return await this.csvBookingService.cancelBooking(id, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add documents to an existing booking
|
* Add documents to an existing booking
|
||||||
*
|
*
|
||||||
@ -444,6 +459,75 @@ export class CsvBookingsController {
|
|||||||
return await this.csvBookingService.addDocuments(id, files, userId);
|
return await this.csvBookingService.addDocuments(id, files, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace a document in a booking
|
||||||
|
*
|
||||||
|
* PUT /api/v1/csv-bookings/:bookingId/documents/:documentId
|
||||||
|
*/
|
||||||
|
@Patch(':bookingId/documents/:documentId')
|
||||||
|
@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 a document from a booking
|
||||||
*
|
*
|
||||||
|
|||||||
@ -595,6 +595,78 @@ export class CsvBookingService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,4 +332,64 @@ 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,9 +14,12 @@ 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 new CsvBooking(
|
return CsvBooking.fromPersistence(
|
||||||
ormEntity.id,
|
ormEntity.id,
|
||||||
ormEntity.userId,
|
ormEntity.userId,
|
||||||
ormEntity.organizationId,
|
ormEntity.organizationId,
|
||||||
|
|||||||
@ -42,6 +42,15 @@ export default function UserDocumentsPage() {
|
|||||||
const [uploadingFiles, setUploadingFiles] = useState(false);
|
const [uploadingFiles, setUploadingFiles] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
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
|
// Helper function to get formatted quote number
|
||||||
const getQuoteNumber = (booking: CsvBookingResponse): string => {
|
const getQuoteNumber = (booking: CsvBookingResponse): string => {
|
||||||
return `#${booking.bookingId || booking.id.slice(0, 8).toUpperCase()}`;
|
return `#${booking.bookingId || booking.id.slice(0, 8).toUpperCase()}`;
|
||||||
@ -304,34 +313,76 @@ export default function UserDocumentsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteDocument = async (bookingId: string, documentId: string, fileName: string) => {
|
// Toggle dropdown menu
|
||||||
if (!confirm(`Êtes-vous sûr de vouloir supprimer le document "${fileName}" ?`)) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setReplacingFile(true);
|
||||||
try {
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('document', replaceFileInputRef.current.files[0]);
|
||||||
|
|
||||||
const token = localStorage.getItem('access_token');
|
const token = localStorage.getItem('access_token');
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/csv-bookings/${bookingId}/documents/${documentId}`,
|
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/csv-bookings/${documentToReplace.bookingId}/documents/${documentToReplace.id}`,
|
||||||
{
|
{
|
||||||
method: 'DELETE',
|
method: 'PATCH',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
},
|
},
|
||||||
|
body: formData,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Erreur lors de la suppression du document');
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(errorData.message || 'Erreur lors du remplacement du document');
|
||||||
}
|
}
|
||||||
|
|
||||||
alert('Document supprimé avec succès!');
|
alert('Document remplacé avec succès!');
|
||||||
|
handleCloseReplaceModal();
|
||||||
fetchBookingsAndDocuments(); // Refresh the list
|
fetchBookingsAndDocuments(); // Refresh the list
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting document:', error);
|
console.error('Error replacing document:', error);
|
||||||
alert(
|
alert(
|
||||||
`Erreur lors de la suppression: ${error instanceof Error ? error.message : 'Erreur inconnue'}`
|
`Erreur lors du remplacement: ${error instanceof Error ? error.message : 'Erreur inconnue'}`
|
||||||
);
|
);
|
||||||
|
} finally {
|
||||||
|
setReplacingFile(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -505,16 +556,42 @@ export default function UserDocumentsPage() {
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-center">
|
<td className="px-6 py-4 whitespace-nowrap text-center">
|
||||||
<div className="flex items-center justify-center space-x-2">
|
<div className="relative inline-block text-left">
|
||||||
<button
|
<button
|
||||||
onClick={() =>
|
onClick={(e) => {
|
||||||
handleDownload(doc.filePath || doc.url || '', doc.fileName)
|
e.stopPropagation();
|
||||||
}
|
toggleDropdown(`${doc.bookingId}-${doc.id}`);
|
||||||
className="inline-flex items-center px-3 py-1.5 bg-green-600 text-white text-sm font-medium rounded-lg hover:bg-green-700 transition-colors"
|
}}
|
||||||
title="Télécharger"
|
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
|
<svg
|
||||||
className="w-4 h-4"
|
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"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@ -526,15 +603,14 @@ export default function UserDocumentsPage() {
|
|||||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
|
Télécharger
|
||||||
</button>
|
</button>
|
||||||
{doc.status === 'PENDING' && (
|
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDeleteDocument(doc.bookingId, doc.id, doc.fileName)}
|
onClick={() => handleReplaceClick(doc)}
|
||||||
className="inline-flex items-center px-3 py-1.5 bg-red-600 text-white text-sm font-medium rounded-lg hover:bg-red-700 transition-colors"
|
className="flex items-center w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||||
title="Supprimer"
|
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className="w-4 h-4"
|
className="w-4 h-4 mr-3 text-blue-600"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@ -543,10 +619,13 @@ export default function UserDocumentsPage() {
|
|||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
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>
|
</svg>
|
||||||
|
Remplacer
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@ -787,6 +866,116 @@ export default function UserDocumentsPage() {
|
|||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user