merge: integrer feat/suppression-reservation
This commit is contained in:
commit
8486f38a90
@ -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
|
||||||
*
|
*
|
||||||
|
|||||||
@ -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).
|
||||||
*
|
*
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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)
|
||||||
*
|
*
|
||||||
|
|||||||
@ -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')}
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -54,6 +54,7 @@ export {
|
|||||||
listCsvBookings,
|
listCsvBookings,
|
||||||
getCsvBookingStats,
|
getCsvBookingStats,
|
||||||
cancelCsvBooking,
|
cancelCsvBooking,
|
||||||
|
deleteCsvBooking,
|
||||||
acceptCsvBooking,
|
acceptCsvBooking,
|
||||||
rejectCsvBooking,
|
rejectCsvBooking,
|
||||||
type CsvBookingResponse,
|
type CsvBookingResponse,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user