merge: integrer feat/suppression-reservation

This commit is contained in:
David 2026-09-07 21:42:36 +02:00
commit 8486f38a90
9 changed files with 225 additions and 12 deletions

View File

@ -14,6 +14,7 @@ import {
BadRequestException, BadRequestException,
ForbiddenException, ForbiddenException,
ParseIntPipe, ParseIntPipe,
ParseUUIDPipe,
DefaultValuePipe, DefaultValuePipe,
Inject, Inject,
} from '@nestjs/common'; } from '@nestjs/common';
@ -591,6 +592,31 @@ export class CsvBookingsController {
return await this.csvBookingService.cancelBooking(id, userId); return await this.csvBookingService.cancelBooking(id, userId);
} }
/**
* Delete an unpaid booking
*
* DELETE /api/v1/csv-bookings/:id
*/
@Delete(':id')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({
summary: 'Delete an unpaid booking',
description:
'Permanently deletes a booking whose commission has not been paid. Only accessible by the booking owner. A paid booking has been sent to the carrier and can only be cancelled.',
})
@ApiParam({ name: 'id', description: 'Booking ID (UUID)' })
@ApiResponse({ status: 200, description: 'Booking deleted successfully' })
@ApiResponse({ status: 400, description: 'Booking has been paid and cannot be deleted' })
@ApiResponse({ status: 404, description: 'Booking not found' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
async deleteBooking(
@Param('id', ParseUUIDPipe) id: string,
@Request() req: any
): Promise<{ success: boolean; message: string }> {
return await this.csvBookingService.deleteBooking(id, req.user.id);
}
/** /**
* Update booking cargo details before payment * Update booking cargo details before payment
* *

View File

@ -1024,6 +1024,44 @@ export class CsvBookingService {
return this.toResponseDto(updatedBooking); return this.toResponseDto(updatedBooking);
} }
/**
* Delete an unpaid booking (user action).
*
* Seul le proprietaire peut supprimer, et seulement tant qu'aucun paiement
* n'a ete encaisse — voir `CsvBooking.isDeletable()`. Une reservation payee
* est partie chez le transporteur : elle s'annule, elle ne s'efface pas.
*
* Les documents deja televerses restent dans le stockage objet, comme lors de
* la suppression d'un document isole : la politique du projet est de les
* conserver pour l'audit.
*/
async deleteBooking(id: string, userId: string): Promise<{ success: boolean; message: string }> {
this.logger.log(`Deleting booking ${id} by user ${userId}`);
const booking = await this.csvBookingRepository.findById(id);
if (!booking) {
throw new NotFoundException('Booking not found');
}
// Meme reponse qu'une reservation inexistante : appartenir a quelqu'un
// d'autre ne doit pas etre distinguable de ne pas exister.
if (booking.userId !== userId) {
throw new NotFoundException('Booking not found');
}
if (!booking.isDeletable()) {
throw new BadRequestException(
`Cannot delete a booking with status ${booking.status}. Only unpaid bookings can be deleted.`
);
}
await this.csvBookingRepository.delete(id);
this.logger.log(`Booking ${id} deleted`);
return { success: true, message: 'Booking deleted successfully' };
}
/** /**
* Update the cargo details of a booking before payment (user action). * Update the cargo details of a booking before payment (user action).
* *

View File

@ -315,6 +315,41 @@ describe('CsvBooking Entity', () => {
}); });
}); });
describe('isDeletable', () => {
it('allows deleting a booking whose commission is still unpaid', () => {
const booking = createValidBooking();
booking.status = CsvBookingStatus.PENDING_PAYMENT;
expect(booking.isDeletable()).toBe(true);
});
it.each([
// Paye : la reservation est partie chez le transporteur.
CsvBookingStatus.PENDING,
CsvBookingStatus.ACCEPTED,
CsvBookingStatus.REJECTED,
CsvBookingStatus.CANCELLED,
// Virement declare : il peut etre en cours d'acheminement.
CsvBookingStatus.PENDING_BANK_TRANSFER,
])('refuses to delete a %s booking', status => {
const booking = createValidBooking();
booking.status = status;
expect(booking.isDeletable()).toBe(false);
});
it('stops being deletable once the payment is completed', () => {
const booking = createValidBooking();
booking.status = CsvBookingStatus.PENDING_PAYMENT;
expect(booking.isDeletable()).toBe(true);
booking.markPaymentCompleted();
expect(booking.status).toBe(CsvBookingStatus.PENDING);
expect(booking.isDeletable()).toBe(false);
});
});
describe('Expiration Logic', () => { describe('Expiration Logic', () => {
it('should not be expired for recent bookings', () => { it('should not be expired for recent bookings', () => {
const booking = createValidBooking(); const booking = createValidBooking();

View File

@ -14,6 +14,12 @@ export enum CsvBookingStatus {
CANCELLED = 'CANCELLED', // User cancelled the booking CANCELLED = 'CANCELLED', // User cancelled the booking
} }
/**
* Statuts dans lesquels aucun paiement n'a ete encaisse : la reservation peut
* alors etre supprimee. Voir `CsvBooking.isDeletable()`.
*/
export const DELETABLE_STATUSES: readonly CsvBookingStatus[] = [CsvBookingStatus.PENDING_PAYMENT];
/** /**
* Document Interface * Document Interface
* *
@ -276,6 +282,24 @@ export class CsvBooking {
} }
} }
/**
* Can this booking be deleted outright?
*
* Une reservation impayee n'engage personne : elle n'est pas partie chez le
* transporteur et ne porte aucune trace comptable. La supprimer est donc sans
* consequence, la ou une reservation payee doit rester tracable et ne peut
* qu'etre annulee.
*
* `PENDING_BANK_TRANSFER` est volontairement exclu : le virement declare peut
* etre en cours d'acheminement, et supprimer la reservation priverait
* l'administration de ce qu'elle doit rapprocher a sa reception. Etendre la
* regle a ce statut est une decision comptable, pas technique : il suffirait
* de l'ajouter a `DELETABLE_STATUSES`.
*/
isDeletable(): boolean {
return DELETABLE_STATUSES.includes(this.status);
}
/** /**
* Cancel the booking (by user) * Cancel the booking (by user)
* *

View File

@ -5,9 +5,19 @@ import { useQuery } from '@tanstack/react-query';
import { useSearchParams } from 'next/navigation'; import { useSearchParams } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl'; import { useLocale, useTranslations } from 'next-intl';
import type { ColumnDef } from '@tanstack/react-table'; import type { ColumnDef } from '@tanstack/react-table';
import { CreditCard, Eye, MoreVertical, Package, Pencil, Plus, Sparkles, X } from 'lucide-react'; import {
CreditCard,
Eye,
MoreVertical,
Package,
Pencil,
Plus,
Sparkles,
Trash2,
X,
} from 'lucide-react';
import { listCsvBookings } from '@/lib/api'; import { deleteCsvBooking, listCsvBookings } from '@/lib/api';
import { Link } from '@/i18n/navigation'; import { Link } from '@/i18n/navigation';
import { PageHeader } from '@/components/ui/PageHeader'; import { PageHeader } from '@/components/ui/PageHeader';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@ -33,6 +43,8 @@ import {
BookingStatusBadge, BookingStatusBadge,
useBookingStatusLabel, useBookingStatusLabel,
} from '@/components/bookings/BookingStatusBadge'; } from '@/components/bookings/BookingStatusBadge';
import { useConfirm } from '@/components/ui/use-confirm';
import { useToast } from '@/components/ui/toast';
import ExportButton from '@/components/ExportButton'; import ExportButton from '@/components/ExportButton';
import { useReservationQuota } from '@/hooks/useReservationQuota'; import { useReservationQuota } from '@/hooks/useReservationQuota';
import { useUrlState } from '@/hooks/use-url-state'; import { useUrlState } from '@/hooks/use-url-state';
@ -53,6 +65,8 @@ export default function BookingsListPage() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const url = useUrlState(); const url = useUrlState();
const statusLabel = useBookingStatusLabel(); const statusLabel = useBookingStatusLabel();
const confirm = useConfirm();
const { toast } = useToast();
// L'etat de vue vit dans l'URL : la liste est partageable et survit au // L'etat de vue vit dans l'URL : la liste est partageable et survit au
// rechargement, ce qui n'etait pas le cas avec un etat purement local. // rechargement, ce qui n'etait pas le cas avec un etat purement local.
@ -80,6 +94,31 @@ export default function BookingsListPage() {
const quota = useReservationQuota(); const quota = useReservationQuota();
/**
* Supprime une reservation impayee, apres confirmation.
*
* L'action est destructrice et sans annulation : la confirmation nomme la
* reservation concernee plutot que de demander « etes-vous sur ? ».
*/
const remove = async (b: any) => {
const reference = b.bookingNumber || b.quoteNumber || `${b.origin} → ${b.destination}`;
const confirmed = await confirm({
title: t('deleteConfirm.title'),
description: t('deleteConfirm.body', { reference }),
confirmLabel: t('actions.delete'),
destructive: true,
});
if (!confirmed) return;
try {
await deleteCsvBooking(b.id);
toast.success(t('deleteConfirm.done'));
await refetch();
} catch {
toast.error(t('deleteConfirm.failed'));
}
};
// « Modifier » rouvre le parcours existant (recherche → transporteur → // « Modifier » rouvre le parcours existant (recherche → transporteur →
// paiement) pre-rempli, afin qu'un changement redonne lieu a une cotation. // paiement) pre-rempli, afin qu'un changement redonne lieu a une cotation.
const buildEditUrl = (b: any) => { const buildEditUrl = (b: any) => {
@ -291,6 +330,16 @@ export default function BookingsListPage() {
{t('actions.pay')} {t('actions.pay')}
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
{/* Suppression reservee aux reservations impayees : une fois
la commission reglee, la reservation est partie chez le
transporteur et ne peut plus qu'etre annulee. */}
<DropdownMenuItem
onSelect={() => remove(b)}
className="text-destructive focus:text-destructive"
>
<Trash2 />
{t('actions.delete')}
</DropdownMenuItem>
</> </>
)} )}
</DropdownMenuContent> </DropdownMenuContent>
@ -409,10 +458,7 @@ export default function BookingsListPage() {
}} }}
filters={ filters={
<> <>
<Select <Select value={searchType} onValueChange={value => url.set('by', value)}>
value={searchType}
onValueChange={value => url.set('by', value)}
>
<SelectTrigger className="w-full md:w-44" aria-label={t('searchType.label')}> <SelectTrigger className="w-full md:w-44" aria-label={t('searchType.label')}>
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
@ -468,8 +514,7 @@ export default function BookingsListPage() {
empty={{ empty={{
icon: Package, icon: Package,
title: t('empty.title'), title: t('empty.title'),
description: description: searchTerm || statusFilter ? t('empty.hasFilters') : t('empty.noBookings'),
searchTerm || statusFilter ? t('empty.hasFilters') : t('empty.noBookings'),
action: newReservationAction, action: newReservationAction,
}} }}
caption={t('title')} caption={t('title')}

View File

@ -532,7 +532,14 @@
"actions": { "actions": {
"view": "View", "view": "View",
"pay": "Pay", "pay": "Pay",
"edit": "Edit" "edit": "Edit",
"delete": "Delete"
},
"deleteConfirm": {
"title": "Delete this booking?",
"body": "{reference} will be permanently deleted. This cannot be undone.",
"done": "Booking deleted",
"failed": "The booking could not be deleted"
}, },
"detail": { "detail": {
"title": "Booking", "title": "Booking",

View File

@ -532,7 +532,14 @@
"actions": { "actions": {
"view": "Voir", "view": "Voir",
"pay": "Payer", "pay": "Payer",
"edit": "Modifier" "edit": "Modifier",
"delete": "Supprimer"
},
"deleteConfirm": {
"title": "Supprimer cette réservation ?",
"body": "{reference} sera définitivement supprimée. Cette action est irréversible.",
"done": "Réservation supprimée",
"failed": "La réservation n’a pas pu être supprimée"
}, },
"detail": { "detail": {
"title": "Réservation", "title": "Réservation",

View File

@ -4,7 +4,7 @@
* Endpoints for managing container bookings * Endpoints for managing container bookings
*/ */
import { get, post, patch, upload } from './client'; import { get, post, patch, del, upload } from './client';
import type { import type {
CreateBookingRequest, CreateBookingRequest,
BookingResponse, BookingResponse,
@ -39,6 +39,8 @@ export interface UpdateBookingStatusRequest {
export interface CsvBookingResponse { export interface CsvBookingResponse {
id: string; id: string;
bookingId: string; bookingId: string;
/** Référence affichée, ex. « XPD-2026-R9KE8U ». */
bookingNumber?: string;
carrierName: string; carrierName: string;
carrierEmail: string; carrierEmail: string;
origin: string; origin: string;
@ -51,7 +53,13 @@ export interface CsvBookingResponse {
primaryCurrency: string; primaryCurrency: string;
transitDays: number; transitDays: number;
containerType: string; containerType: string;
status: 'PENDING_PAYMENT' | 'PENDING_BANK_TRANSFER' | 'PENDING' | 'ACCEPTED' | 'REJECTED' | 'CANCELLED'; status:
| 'PENDING_PAYMENT'
| 'PENDING_BANK_TRANSFER'
| 'PENDING'
| 'ACCEPTED'
| 'REJECTED'
| 'CANCELLED';
documents: Array<{ documents: Array<{
type: string; type: string;
fileName: string; fileName: string;
@ -67,6 +75,19 @@ export interface CsvBookingResponse {
commissionRate?: number; commissionRate?: number;
commissionAmountEur?: number; commissionAmountEur?: number;
options?: Record<string, boolean>; options?: Record<string, boolean>;
// Champs renvoyes par l'API mais absents de ce type : la page de detail les
// affichait sans que TypeScript puisse les verifier.
requestedAt?: string;
respondedAt?: string;
rejectionReason?: string;
routeDescription?: string;
isExpired?: boolean;
price?: number;
freightTotal?: number;
freightCurrency?: string;
fobTotal?: number;
fobCurrency?: string;
} }
export interface CommissionPaymentResponse { export interface CommissionPaymentResponse {
@ -279,6 +300,15 @@ export async function cancelCsvBooking(id: string): Promise<SuccessResponse> {
return patch<SuccessResponse>(`/api/v1/csv-bookings/${id}/cancel`, {}); return patch<SuccessResponse>(`/api/v1/csv-bookings/${id}/cancel`, {});
} }
/**
* Supprime définitivement une réservation dont la commission n'a pas été payée.
* Une réservation payée est partie chez le transporteur : elle s'annule.
* DELETE /api/v1/csv-bookings/:id
*/
export async function deleteCsvBooking(id: string): Promise<SuccessResponse> {
return del<SuccessResponse>(`/api/v1/csv-bookings/${id}`);
}
/** /**
* Update cargo details of a booking awaiting payment * Update cargo details of a booking awaiting payment
* PATCH /api/v1/csv-bookings/:id/details * PATCH /api/v1/csv-bookings/:id/details

View File

@ -54,6 +54,7 @@ export {
listCsvBookings, listCsvBookings,
getCsvBookingStats, getCsvBookingStats,
cancelCsvBooking, cancelCsvBooking,
deleteCsvBooking,
acceptCsvBooking, acceptCsvBooking,
rejectCsvBooking, rejectCsvBooking,
type CsvBookingResponse, type CsvBookingResponse,