fix(bookings,blog): Phase 1 correctifs test utilisateur
- Telechargement docs transporteur: proxy de streaming API (fin des URLs presignees MinIO injoignables depuis l'exterieur) - Documents: ajout de l'action "Supprimer" (endpoint DELETE deja existant) - Blog: suppression reelle de l'image de couverture (null explicite bout en bout) - Blog: le bouton Retour navigateur ferme l'editeur au lieu de quitter vers Logs - Paiement: bouton Retour revient au contexte precedent (router.back) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
71436cb9c4
commit
19b96517d2
@ -117,7 +117,7 @@ export class BlogController {
|
|||||||
slug: post.slug,
|
slug: post.slug,
|
||||||
excerpt: post.excerpt,
|
excerpt: post.excerpt,
|
||||||
content: post.content,
|
content: post.content,
|
||||||
coverImageUrl: post.coverImageUrl,
|
coverImageUrl: post.coverImageUrl ?? undefined,
|
||||||
category: post.category,
|
category: post.category,
|
||||||
tags: post.tags,
|
tags: post.tags,
|
||||||
authorName: post.authorName,
|
authorName: post.authorName,
|
||||||
|
|||||||
@ -1,4 +1,14 @@
|
|||||||
import { Controller, Get, Post, Param, Query, Body } from '@nestjs/common';
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Param,
|
||||||
|
Query,
|
||||||
|
Body,
|
||||||
|
Res,
|
||||||
|
StreamableFile,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Response } from 'express';
|
||||||
import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery, ApiBody } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery, ApiBody } from '@nestjs/swagger';
|
||||||
import { Public } from '../decorators/public.decorator';
|
import { Public } from '../decorators/public.decorator';
|
||||||
import { CsvBookingService } from '../services/csv-booking.service';
|
import { CsvBookingService } from '../services/csv-booking.service';
|
||||||
@ -173,4 +183,78 @@ export class CsvBookingActionsController {
|
|||||||
async getBookingDocuments(@Param('token') token: string): Promise<CarrierDocumentsResponseDto> {
|
async getBookingDocuments(@Param('token') token: string): Promise<CarrierDocumentsResponseDto> {
|
||||||
return this.csvBookingService.getDocumentsForCarrier(token);
|
return this.csvBookingService.getDocumentsForCarrier(token);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download a single booking document, streamed through the API (PUBLIC - token-based).
|
||||||
|
*
|
||||||
|
* Password-protected bookings must use POST with the password in the body.
|
||||||
|
*
|
||||||
|
* POST /api/v1/csv-booking-actions/documents/:token/:documentId/download
|
||||||
|
*/
|
||||||
|
@Public()
|
||||||
|
@Post('documents/:token/:documentId/download')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Download a booking document with password (public)',
|
||||||
|
description:
|
||||||
|
'Streams a single booking document to the carrier. Applies the same access rules as the documents list (booking accepted + password when protected).',
|
||||||
|
})
|
||||||
|
@ApiParam({ name: 'token', description: 'Booking confirmation token (UUID)' })
|
||||||
|
@ApiParam({ name: 'documentId', description: 'Document ID' })
|
||||||
|
@ApiBody({ type: VerifyDocumentAccessDto })
|
||||||
|
@ApiResponse({ status: 200, description: 'Document streamed successfully.' })
|
||||||
|
@ApiResponse({ status: 401, description: 'Invalid or missing password' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Booking or document not found' })
|
||||||
|
async downloadBookingDocumentWithPassword(
|
||||||
|
@Param('token') token: string,
|
||||||
|
@Param('documentId') documentId: string,
|
||||||
|
@Body() dto: VerifyDocumentAccessDto,
|
||||||
|
@Res({ passthrough: true }) res: Response
|
||||||
|
): Promise<StreamableFile> {
|
||||||
|
return this.streamDocument(token, documentId, dto?.password, res);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download a single booking document (PUBLIC - token-based) - Legacy without password.
|
||||||
|
*
|
||||||
|
* GET /api/v1/csv-booking-actions/documents/:token/:documentId/download
|
||||||
|
*/
|
||||||
|
@Public()
|
||||||
|
@Get('documents/:token/:documentId/download')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Download a booking document (public) - Legacy',
|
||||||
|
description:
|
||||||
|
'Streams a single booking document for bookings without password protection. Protected bookings must use the POST variant.',
|
||||||
|
})
|
||||||
|
@ApiParam({ name: 'token', description: 'Booking confirmation token (UUID)' })
|
||||||
|
@ApiParam({ name: 'documentId', description: 'Document ID' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Document streamed successfully.' })
|
||||||
|
@ApiResponse({ status: 401, description: 'Password required for this booking' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Booking or document not found' })
|
||||||
|
async downloadBookingDocument(
|
||||||
|
@Param('token') token: string,
|
||||||
|
@Param('documentId') documentId: string,
|
||||||
|
@Res({ passthrough: true }) res: Response
|
||||||
|
): Promise<StreamableFile> {
|
||||||
|
return this.streamDocument(token, documentId, undefined, res);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async streamDocument(
|
||||||
|
token: string,
|
||||||
|
documentId: string,
|
||||||
|
password: string | undefined,
|
||||||
|
res: Response
|
||||||
|
): Promise<StreamableFile> {
|
||||||
|
const { buffer, fileName, mimeType } = await this.csvBookingService.streamDocumentForCarrier(
|
||||||
|
token,
|
||||||
|
documentId,
|
||||||
|
password
|
||||||
|
);
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', mimeType);
|
||||||
|
res.setHeader(
|
||||||
|
'Content-Disposition',
|
||||||
|
`attachment; filename="${encodeURIComponent(fileName)}"`
|
||||||
|
);
|
||||||
|
return new StreamableFile(buffer);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import {
|
|||||||
MaxLength,
|
MaxLength,
|
||||||
MinLength,
|
MinLength,
|
||||||
Matches,
|
Matches,
|
||||||
|
ValidateIf,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { BlogPostStatus, type BlogPostCategory } from '@domain/entities/blog-post.entity';
|
import { BlogPostStatus, type BlogPostCategory } from '@domain/entities/blog-post.entity';
|
||||||
|
|
||||||
@ -121,11 +122,15 @@ export class UpdateBlogPostDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
content?: string;
|
content?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
@ApiPropertyOptional({
|
||||||
|
nullable: true,
|
||||||
|
description: 'Cover image URL. Pass null to remove the current cover image.',
|
||||||
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
@ValidateIf((_, value) => value !== null)
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(500)
|
@MaxLength(500)
|
||||||
coverImageUrl?: string;
|
coverImageUrl?: string | null;
|
||||||
|
|
||||||
@ApiPropertyOptional({ enum: CATEGORIES })
|
@ApiPropertyOptional({ enum: CATEGORIES })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@ -735,20 +735,17 @@ export class CsvBookingService {
|
|||||||
throw new NotFoundException('Réservation introuvable');
|
throw new NotFoundException('Réservation introuvable');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate signed URLs for all documents
|
// Build proxy download URLs. The file is streamed through the API rather than
|
||||||
const documentsWithUrls = await Promise.all(
|
// served via a presigned storage URL, whose internal host is unreachable from
|
||||||
booking.documents.map(async doc => {
|
// the carrier's browser. The frontend prepends the public API base URL.
|
||||||
const signedUrl = await this.generateSignedUrlForDocument(doc.filePath);
|
const documentsWithUrls = booking.documents.map(doc => ({
|
||||||
return {
|
|
||||||
id: doc.id,
|
id: doc.id,
|
||||||
type: doc.type,
|
type: doc.type,
|
||||||
fileName: doc.fileName,
|
fileName: doc.fileName,
|
||||||
mimeType: doc.mimeType,
|
mimeType: doc.mimeType,
|
||||||
size: doc.size,
|
size: doc.size,
|
||||||
downloadUrl: signedUrl,
|
downloadUrl: `/api/v1/csv-booking-actions/documents/${token}/${doc.id}/download`,
|
||||||
};
|
}));
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const primaryCurrency = booking.primaryCurrency as 'USD' | 'EUR';
|
const primaryCurrency = booking.primaryCurrency as 'USD' | 'EUR';
|
||||||
|
|
||||||
@ -795,12 +792,10 @@ export class CsvBookingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate signed URL for a document file path
|
* Extract the storage object key from a stored document file path.
|
||||||
|
* Handles raw keys, "bucket/key" paths and full URLs.
|
||||||
*/
|
*/
|
||||||
private async generateSignedUrlForDocument(filePath: string): Promise<string> {
|
private extractDocumentKey(filePath: string): string {
|
||||||
const bucket = 'xpeditis-documents';
|
|
||||||
|
|
||||||
// Extract key from the file path
|
|
||||||
let key = filePath;
|
let key = filePath;
|
||||||
if (filePath.includes('xpeditis-documents/')) {
|
if (filePath.includes('xpeditis-documents/')) {
|
||||||
key = filePath.split('xpeditis-documents/')[1];
|
key = filePath.split('xpeditis-documents/')[1];
|
||||||
@ -811,10 +806,65 @@ export class CsvBookingService {
|
|||||||
key = key.replace('xpeditis-documents/', '');
|
key = key.replace('xpeditis-documents/', '');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
// Generate signed URL with 1 hour expiration
|
/**
|
||||||
const signedUrl = await this.storageAdapter.getSignedUrl({ bucket, key }, 3600);
|
* Stream a booking document to the carrier through the API (public, token-based).
|
||||||
return signedUrl;
|
*
|
||||||
|
* Downloads the file from storage and returns it as a buffer so the API can
|
||||||
|
* proxy it to the carrier's browser. This avoids handing out presigned storage
|
||||||
|
* URLs whose host (internal MinIO/S3 endpoint) is not reachable from outside.
|
||||||
|
*
|
||||||
|
* Applies the same access rules as {@link getDocumentsForCarrier}: the booking
|
||||||
|
* must be ACCEPTED and, when protected, the correct password must be supplied.
|
||||||
|
*/
|
||||||
|
async streamDocumentForCarrier(
|
||||||
|
token: string,
|
||||||
|
documentId: string,
|
||||||
|
password?: string
|
||||||
|
): Promise<{ buffer: Buffer; fileName: string; mimeType: string }> {
|
||||||
|
const ormBooking = await this.csvBookingRepository['repository'].findOne({
|
||||||
|
where: { confirmationToken: token },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!ormBooking) {
|
||||||
|
throw new NotFoundException('Réservation introuvable');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ormBooking.status !== 'ACCEPTED') {
|
||||||
|
throw new BadRequestException("Cette réservation n'a pas encore été acceptée");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ormBooking.passwordHash) {
|
||||||
|
if (!password) {
|
||||||
|
throw new UnauthorizedException('Mot de passe requis pour accéder aux documents');
|
||||||
|
}
|
||||||
|
const isPasswordValid = await argon2.verify(ormBooking.passwordHash, password);
|
||||||
|
if (!isPasswordValid) {
|
||||||
|
throw new UnauthorizedException('Mot de passe incorrect');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const booking = await this.csvBookingRepository.findByToken(token);
|
||||||
|
if (!booking) {
|
||||||
|
throw new NotFoundException('Réservation introuvable');
|
||||||
|
}
|
||||||
|
|
||||||
|
const document = booking.documents.find(doc => doc.id === documentId);
|
||||||
|
if (!document) {
|
||||||
|
throw new NotFoundException('Document introuvable');
|
||||||
|
}
|
||||||
|
|
||||||
|
const bucket = 'xpeditis-documents';
|
||||||
|
const key = this.extractDocumentKey(document.filePath);
|
||||||
|
const buffer = await this.storageAdapter.download({ bucket, key });
|
||||||
|
|
||||||
|
return {
|
||||||
|
buffer,
|
||||||
|
fileName: document.fileName,
|
||||||
|
mimeType: document.mimeType || 'application/octet-stream',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -13,7 +13,7 @@ interface BlogPostProps {
|
|||||||
slug: string;
|
slug: string;
|
||||||
excerpt: string;
|
excerpt: string;
|
||||||
content: string;
|
content: string;
|
||||||
coverImageUrl?: string;
|
coverImageUrl?: string | null;
|
||||||
category: BlogPostCategory;
|
category: BlogPostCategory;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
authorName: string;
|
authorName: string;
|
||||||
@ -64,7 +64,7 @@ export class BlogPost {
|
|||||||
get content(): string {
|
get content(): string {
|
||||||
return this.props.content;
|
return this.props.content;
|
||||||
}
|
}
|
||||||
get coverImageUrl(): string | undefined {
|
get coverImageUrl(): string | null | undefined {
|
||||||
return this.props.coverImageUrl;
|
return this.props.coverImageUrl;
|
||||||
}
|
}
|
||||||
get category(): BlogPostCategory {
|
get category(): BlogPostCategory {
|
||||||
|
|||||||
@ -20,7 +20,7 @@ export class BlogPostOrmEntity {
|
|||||||
content: string;
|
content: string;
|
||||||
|
|
||||||
@Column('varchar', { length: 500, nullable: true })
|
@Column('varchar', { length: 500, nullable: true })
|
||||||
cover_image_url?: string;
|
cover_image_url?: string | null;
|
||||||
|
|
||||||
@Column('varchar', { length: 50 })
|
@Column('varchar', { length: 50 })
|
||||||
category: string;
|
category: string;
|
||||||
|
|||||||
@ -29,6 +29,7 @@ import {
|
|||||||
Clock,
|
Clock,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
|
ArrowLeft,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Link } from '@/i18n/navigation';
|
import { Link } from '@/i18n/navigation';
|
||||||
|
|
||||||
@ -181,7 +182,9 @@ export default function AdminBlogPage() {
|
|||||||
slug: formData.slug,
|
slug: formData.slug,
|
||||||
excerpt: formData.excerpt,
|
excerpt: formData.excerpt,
|
||||||
content: formData.content,
|
content: formData.content,
|
||||||
coverImageUrl: formData.coverImageUrl || undefined,
|
// Send explicit null (not undefined) when the cover was removed, so the
|
||||||
|
// backend clears the column instead of leaving the previous value.
|
||||||
|
coverImageUrl: formData.coverImageUrl?.trim() ? formData.coverImageUrl : null,
|
||||||
category: formData.category,
|
category: formData.category,
|
||||||
tags,
|
tags,
|
||||||
authorName: formData.authorName,
|
authorName: formData.authorName,
|
||||||
@ -318,7 +321,9 @@ export default function AdminBlogPage() {
|
|||||||
setShowEditModal(true);
|
setShowEditModal(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeModal = () => {
|
// Pure UI reset — does not touch browser history. Used by the popstate
|
||||||
|
// handler (the browser already popped our entry at that point).
|
||||||
|
const resetEditorState = () => {
|
||||||
setShowCreateModal(false);
|
setShowCreateModal(false);
|
||||||
setShowEditModal(false);
|
setShowEditModal(false);
|
||||||
setSelectedPost(null);
|
setSelectedPost(null);
|
||||||
@ -328,6 +333,28 @@ export default function AdminBlogPage() {
|
|||||||
setSeoOpen(false);
|
setSeoOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Explicit close (X / Retour / after save): reset UI and pop the history
|
||||||
|
// entry we pushed when opening, so the browser Back button stays on /admin/blog.
|
||||||
|
const closeModal = () => {
|
||||||
|
resetEditorState();
|
||||||
|
if (typeof window !== 'undefined' && window.history.state?.xpBlogEditor) {
|
||||||
|
window.history.back();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Keep the browser Back button inside the blog editor: pushing a history entry
|
||||||
|
// when the editor opens means Back closes the editor instead of navigating away
|
||||||
|
// (e.g. to the System Logs tab).
|
||||||
|
useEffect(() => {
|
||||||
|
const editorOpen = showCreateModal || showEditModal;
|
||||||
|
if (!editorOpen) return;
|
||||||
|
window.history.pushState({ xpBlogEditor: true }, '');
|
||||||
|
const handlePop = () => resetEditorState();
|
||||||
|
window.addEventListener('popstate', handlePop);
|
||||||
|
return () => window.removeEventListener('popstate', handlePop);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [showCreateModal, showEditModal]);
|
||||||
|
|
||||||
const handleTitleChange = (title: string) => {
|
const handleTitleChange = (title: string) => {
|
||||||
setFormData(prev => ({
|
setFormData(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
@ -535,9 +562,19 @@ export default function AdminBlogPage() {
|
|||||||
<div className="fixed inset-0 z-50 bg-gray-50 overflow-y-auto">
|
<div className="fixed inset-0 z-50 bg-gray-50 overflow-y-auto">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="sticky top-0 z-10 bg-white border-b border-gray-200 px-6 py-4 flex items-center justify-between shadow-sm">
|
<div className="sticky top-0 z-10 bg-white border-b border-gray-200 px-6 py-4 flex items-center justify-between shadow-sm">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={closeModal}
|
||||||
|
className="flex items-center gap-1.5 text-sm font-medium text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg px-3 py-1.5 transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
Retour
|
||||||
|
</button>
|
||||||
<h2 className="text-lg font-semibold text-gray-900">
|
<h2 className="text-lg font-semibold text-gray-900">
|
||||||
{showCreateModal ? 'Nouvel article' : `Modifier — ${selectedPost?.title}`}
|
{showCreateModal ? 'Nouvel article' : `Modifier — ${selectedPost?.title}`}
|
||||||
</h2>
|
</h2>
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{showEditModal && (
|
{showEditModal && (
|
||||||
<select
|
<select
|
||||||
|
|||||||
@ -277,7 +277,30 @@ export default function CarrierDocumentsPage() {
|
|||||||
setDownloading(doc.id);
|
setDownloading(doc.id);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
window.open(doc.downloadUrl, '_blank');
|
// Stream the document through the API instead of relying on a presigned
|
||||||
|
// storage URL (whose internal host is unreachable from outside).
|
||||||
|
const downloadUrl = `${apiUrl}/api/v1/csv-booking-actions/documents/${token}/${doc.id}/download`;
|
||||||
|
const usePassword = !!requirements?.requiresPassword && !!password;
|
||||||
|
|
||||||
|
const response = await fetch(downloadUrl, {
|
||||||
|
method: usePassword ? 'POST' : 'GET',
|
||||||
|
headers: usePassword ? { 'Content-Type': 'application/json' } : undefined,
|
||||||
|
body: usePassword ? JSON.stringify({ password }) : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
const objectUrl = window.URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = objectUrl;
|
||||||
|
link.download = doc.fileName;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
window.URL.revokeObjectURL(objectUrl);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error downloading document:', err);
|
console.error('Error downloading document:', err);
|
||||||
alert(t('downloadError'));
|
alert(t('downloadError'));
|
||||||
|
|||||||
@ -212,13 +212,14 @@ export default function PayCommissionPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-blue-50 py-10 px-4">
|
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-blue-50 py-10 px-4">
|
||||||
<div className="max-w-5xl mx-auto">
|
<div className="max-w-5xl mx-auto">
|
||||||
{/* Back button */}
|
{/* Back button — returns to the previous context (list or booking detail)
|
||||||
|
instead of always jumping to the global list. */}
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push('/dashboard/bookings')}
|
onClick={() => router.back()}
|
||||||
className="mb-6 flex items-center text-blue-600 hover:text-blue-800 font-medium"
|
className="mb-6 flex items-center text-blue-600 hover:text-blue-800 font-medium"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||||
Retour aux bookings
|
Retour
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<h1 className="text-2xl font-bold text-gray-900 mb-1">Paiement du forfait booking</h1>
|
<h1 className="text-2xl font-bold text-gray-900 mb-1">Paiement du forfait booking</h1>
|
||||||
|
|||||||
@ -3,7 +3,14 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { useTranslations, useLocale } from 'next-intl';
|
import { useTranslations, useLocale } from 'next-intl';
|
||||||
import { listCsvBookings, CsvBookingResponse } from '@/lib/api/bookings';
|
import { listCsvBookings, CsvBookingResponse } from '@/lib/api/bookings';
|
||||||
import { FileText, Image as ImageIcon, FileEdit, FileSpreadsheet, Paperclip } from 'lucide-react';
|
import {
|
||||||
|
FileText,
|
||||||
|
Image as ImageIcon,
|
||||||
|
FileEdit,
|
||||||
|
FileSpreadsheet,
|
||||||
|
Paperclip,
|
||||||
|
Trash2,
|
||||||
|
} from 'lucide-react';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import ExportButton from '@/components/ExportButton';
|
import ExportButton from '@/components/ExportButton';
|
||||||
import { PageHeader } from '@/components/ui/PageHeader';
|
import { PageHeader } from '@/components/ui/PageHeader';
|
||||||
@ -56,6 +63,10 @@ export default function UserDocumentsPage() {
|
|||||||
const [replaceFile, setReplaceFile] = useState<File | null>(null);
|
const [replaceFile, setReplaceFile] = useState<File | null>(null);
|
||||||
const replaceFileInputRef = useRef<HTMLInputElement>(null);
|
const replaceFileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||||
|
const [documentToDelete, setDocumentToDelete] = useState<DocumentWithBooking | null>(null);
|
||||||
|
const [deletingFile, setDeletingFile] = useState(false);
|
||||||
|
|
||||||
const [openDropdownId, setOpenDropdownId] = useState<string | null>(null);
|
const [openDropdownId, setOpenDropdownId] = useState<string | null>(null);
|
||||||
|
|
||||||
const getQuoteNumber = (booking: CsvBookingResponse): string => {
|
const getQuoteNumber = (booking: CsvBookingResponse): string => {
|
||||||
@ -355,6 +366,43 @@ export default function UserDocumentsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDeleteClick = (doc: DocumentWithBooking) => {
|
||||||
|
setOpenDropdownId(null);
|
||||||
|
setDocumentToDelete(doc);
|
||||||
|
setShowDeleteModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseDeleteModal = () => {
|
||||||
|
setShowDeleteModal(false);
|
||||||
|
setDocumentToDelete(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteDocument = async () => {
|
||||||
|
if (!documentToDelete) return;
|
||||||
|
|
||||||
|
setDeletingFile(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/csv-bookings/${documentToDelete.bookingId}/documents/${documentToDelete.id}`,
|
||||||
|
{ method: 'DELETE', credentials: 'include' }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(errorData.message || t('deleteDocument.errorMessage'));
|
||||||
|
}
|
||||||
|
|
||||||
|
alert(t('deleteDocument.successMessage'));
|
||||||
|
handleCloseDeleteModal();
|
||||||
|
fetchBookingsAndDocuments();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error deleting document:', err);
|
||||||
|
alert(`${t('deleteDocument.errorMessage')}: ${err instanceof Error ? err.message : ''}`);
|
||||||
|
} finally {
|
||||||
|
setDeletingFile(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-96">
|
<div className="flex items-center justify-center h-96">
|
||||||
@ -611,6 +659,13 @@ export default function UserDocumentsPage() {
|
|||||||
</svg>
|
</svg>
|
||||||
{t('actions.replace')}
|
{t('actions.replace')}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteClick(doc)}
|
||||||
|
className="flex items-center w-full px-4 py-2 text-sm text-red-600 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4 mr-3" />
|
||||||
|
{t('actions.delete')}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -1049,6 +1104,50 @@ export default function UserDocumentsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Delete Document Modal */}
|
||||||
|
{showDeleteModal && documentToDelete && (
|
||||||
|
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||||
|
<div className="flex items-center justify-center min-h-screen px-4 py-8">
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
|
||||||
|
onClick={handleCloseDeleteModal}
|
||||||
|
/>
|
||||||
|
<div className="relative bg-white rounded-xl shadow-xl w-full max-w-md p-6">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="flex-shrink-0 w-10 h-10 bg-red-100 rounded-full flex items-center justify-center">
|
||||||
|
<Trash2 className="w-5 h-5 text-red-600" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">
|
||||||
|
{t('deleteDocument.title')}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-2 text-sm text-gray-600">
|
||||||
|
{t('deleteDocument.message', { fileName: documentToDelete.fileName })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCloseDeleteModal}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
{t('deleteDocument.cancel')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDeleteDocument}
|
||||||
|
disabled={deletingFile}
|
||||||
|
className="px-6 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{deletingFile ? t('deleteDocument.deleting') : t('deleteDocument.confirm')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1524,7 +1524,8 @@
|
|||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"download": "Download",
|
"download": "Download",
|
||||||
"replace": "Replace"
|
"replace": "Replace",
|
||||||
|
"delete": "Delete"
|
||||||
},
|
},
|
||||||
"export": {
|
"export": {
|
||||||
"fileName": "File name",
|
"fileName": "File name",
|
||||||
@ -1566,6 +1567,15 @@
|
|||||||
"errorMessage": "Error replacing document",
|
"errorMessage": "Error replacing document",
|
||||||
"noFileError": "Please select a replacement file"
|
"noFileError": "Please select a replacement file"
|
||||||
},
|
},
|
||||||
|
"deleteDocument": {
|
||||||
|
"title": "Delete document",
|
||||||
|
"message": "Are you sure you want to delete \"{fileName}\"? This action cannot be undone.",
|
||||||
|
"confirm": "Delete",
|
||||||
|
"deleting": "Deleting...",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"successMessage": "Document deleted successfully!",
|
||||||
|
"errorMessage": "Error deleting document"
|
||||||
|
},
|
||||||
"downloadError": "Error downloading document"
|
"downloadError": "Error downloading document"
|
||||||
},
|
},
|
||||||
"wikiPages": {
|
"wikiPages": {
|
||||||
|
|||||||
@ -1524,7 +1524,8 @@
|
|||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"download": "Télécharger",
|
"download": "Télécharger",
|
||||||
"replace": "Remplacer"
|
"replace": "Remplacer",
|
||||||
|
"delete": "Supprimer"
|
||||||
},
|
},
|
||||||
"export": {
|
"export": {
|
||||||
"fileName": "Nom du fichier",
|
"fileName": "Nom du fichier",
|
||||||
@ -1566,6 +1567,15 @@
|
|||||||
"errorMessage": "Erreur lors du remplacement du document",
|
"errorMessage": "Erreur lors du remplacement du document",
|
||||||
"noFileError": "Veuillez sélectionner un fichier de remplacement"
|
"noFileError": "Veuillez sélectionner un fichier de remplacement"
|
||||||
},
|
},
|
||||||
|
"deleteDocument": {
|
||||||
|
"title": "Supprimer le document",
|
||||||
|
"message": "Êtes-vous sûr de vouloir supprimer « {fileName} » ? Cette action est irréversible.",
|
||||||
|
"confirm": "Supprimer",
|
||||||
|
"deleting": "Suppression en cours...",
|
||||||
|
"cancel": "Annuler",
|
||||||
|
"successMessage": "Document supprimé avec succès!",
|
||||||
|
"errorMessage": "Erreur lors de la suppression du document"
|
||||||
|
},
|
||||||
"downloadError": "Erreur lors du téléchargement du document"
|
"downloadError": "Erreur lors du téléchargement du document"
|
||||||
},
|
},
|
||||||
"wikiPages": {
|
"wikiPages": {
|
||||||
|
|||||||
@ -210,7 +210,7 @@ export interface CreateBlogPostRequest extends BlogPostSeoFields {
|
|||||||
slug: string;
|
slug: string;
|
||||||
excerpt: string;
|
excerpt: string;
|
||||||
content: string;
|
content: string;
|
||||||
coverImageUrl?: string;
|
coverImageUrl?: string | null;
|
||||||
category: BlogPostCategory;
|
category: BlogPostCategory;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
authorName: string;
|
authorName: string;
|
||||||
@ -221,7 +221,7 @@ export interface UpdateBlogPostRequest extends BlogPostSeoFields {
|
|||||||
slug?: string;
|
slug?: string;
|
||||||
excerpt?: string;
|
excerpt?: string;
|
||||||
content?: string;
|
content?: string;
|
||||||
coverImageUrl?: string;
|
coverImageUrl?: string | null;
|
||||||
category?: BlogPostCategory;
|
category?: BlogPostCategory;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
authorName?: string;
|
authorName?: string;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user