feat(bookings): persist Options & Services (customs/insurance/DG/handling)

Les options du formulaire de recherche etaient collectees mais jamais stockees.
Ajout d'une colonne jsonb options sur csv_bookings (+ migration), stockee a la
creation et a l'edition (editFromRate), renvoyee dans la reponse, et re-pre-
remplie a la reprise (search-advanced <- URL <- booking.options).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
David 2026-07-02 19:12:18 +02:00
parent 795e635df3
commit c5f823f8b7
12 changed files with 141 additions and 8 deletions

View File

@ -6,6 +6,7 @@ import {
Min,
IsOptional,
IsEnum,
IsObject,
MinLength,
MaxLength,
} from 'class-validator';
@ -169,6 +170,16 @@ export class CreateCsvBookingDto {
@MaxLength(1000)
notes?: string;
@ApiPropertyOptional({
description:
'Selected options & services as a JSON string (multipart), e.g. {"insurance":true}',
example: '{"insurance":true,"customsStop":true}',
})
@IsOptional()
@IsString()
@MaxLength(2000)
options?: string;
// Documents will be handled via file upload interceptor
// Not included in DTO validation but processed separately
}
@ -346,6 +357,14 @@ export class UpdateCsvBookingRateDto {
@IsString()
@MaxLength(2000)
notes?: string;
@ApiPropertyOptional({
description: 'Selected options & services (customs, insurance, DG, handling…)',
example: { insurance: true, customsStop: true },
})
@IsOptional()
@IsObject()
options?: Record<string, boolean>;
}
/**
@ -602,6 +621,12 @@ export class CsvBookingResponseDto {
example: 'EUR',
})
fobCurrency?: string;
@ApiPropertyOptional({
description: 'Selected options & services (customs, insurance, DG, handling…)',
example: { insurance: true },
})
options?: Record<string, boolean>;
}
/**

View File

@ -172,6 +172,16 @@ export class CsvBookingService {
: CsvBookingStatus.PENDING;
// Create domain entity (no email sent yet when a payment is required)
let parsedOptions: Record<string, boolean> = {};
if (dto.options) {
try {
const parsed = JSON.parse(dto.options);
if (parsed && typeof parsed === 'object') parsedOptions = parsed;
} catch {
this.logger.warn(`Ignoring invalid options JSON for booking creation`);
}
}
const booking = new CsvBooking(
bookingId,
userId,
@ -202,7 +212,8 @@ export class CsvBookingService {
dto.freightTotal,
dto.freightCurrency,
dto.fobTotal,
dto.fobCurrency
dto.fobCurrency,
parsedOptions
);
// Save to database
@ -1118,6 +1129,7 @@ export class CsvBookingService {
fobTotal: dto.fobTotal,
fobCurrency: dto.fobCurrency,
notes: dto.notes,
options: dto.options,
});
} catch (err) {
throw new BadRequestException(err instanceof Error ? err.message : 'Invalid booking rate');
@ -1571,6 +1583,7 @@ export class CsvBookingService {
freightCurrency: booking.freightCurrency,
fobTotal: booking.fobTotal,
fobCurrency: booking.fobCurrency,
options: booking.options ?? {},
};
}

View File

@ -95,7 +95,10 @@ export class CsvBooking {
public freightTotal?: number,
public freightCurrency?: string,
public fobTotal?: number,
public fobCurrency?: string
public fobCurrency?: string,
// Options & services selected in the search form (customs, insurance, DG,
// handling…). Stored as a flat map of enabled flags. Editable before payment.
public options: Record<string, boolean> = {}
) {
this.validate();
}
@ -394,6 +397,7 @@ export class CsvBooking {
fobTotal?: number;
fobCurrency?: string;
notes?: string;
options?: Record<string, boolean>;
}): void {
if (this.status !== CsvBookingStatus.PENDING_PAYMENT) {
throw new Error(
@ -430,6 +434,7 @@ export class CsvBooking {
this.fobTotal = data.fobTotal;
this.fobCurrency = data.fobCurrency;
if (data.notes !== undefined) this.notes = data.notes;
if (data.options !== undefined) this.options = data.options;
}
/**
@ -600,7 +605,8 @@ export class CsvBooking {
freightTotal?: number,
freightCurrency?: string,
fobTotal?: number,
fobCurrency?: string
fobCurrency?: string,
options?: Record<string, boolean>
): CsvBooking {
// Create instance without calling constructor validation
const booking = Object.create(CsvBooking.prototype);
@ -636,6 +642,7 @@ export class CsvBooking {
booking.freightCurrency = freightCurrency;
booking.fobTotal = fobTotal;
booking.fobCurrency = fobCurrency;
booking.options = options ?? {};
return booking;
}

View File

@ -183,6 +183,9 @@ export class CsvBookingOrmEntity {
@Column({ name: 'fob_currency', type: 'varchar', length: 3, nullable: true })
fobCurrency: string | null;
@Column({ name: 'options', type: 'jsonb', default: {} })
options: Record<string, boolean>;
@CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone' })
createdAt: Date;

View File

@ -49,7 +49,8 @@ export class CsvBookingMapper {
ormEntity.freightTotal != null ? Number(ormEntity.freightTotal) : undefined,
ormEntity.freightCurrency ?? undefined,
ormEntity.fobTotal != null ? Number(ormEntity.fobTotal) : undefined,
ormEntity.fobCurrency ?? undefined
ormEntity.fobCurrency ?? undefined,
ormEntity.options ?? {}
);
}
@ -87,6 +88,7 @@ export class CsvBookingMapper {
freightCurrency: domain.freightCurrency ?? null,
fobTotal: domain.fobTotal ?? null,
fobCurrency: domain.fobCurrency ?? null,
options: domain.options ?? {},
};
}
@ -113,6 +115,7 @@ export class CsvBookingMapper {
freightCurrency: domain.freightCurrency ?? null,
fobTotal: domain.fobTotal ?? null,
fobCurrency: domain.fobCurrency ?? null,
options: domain.options ?? {},
status: domain.status as CsvBookingOrmEntity['status'],
respondedAt: domain.respondedAt,
notes: domain.notes,

View File

@ -0,0 +1,19 @@
import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm';
export class AddOptionsToCsvBookings1749000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.addColumn(
'csv_bookings',
new TableColumn({
name: 'options',
type: 'jsonb',
isNullable: false,
default: "'{}'",
})
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropColumn('csv_bookings', 'options');
}
}

View File

@ -228,6 +228,10 @@ export default function PayCommissionPage() {
</p>
<button
onClick={() => {
const opts = (booking as any).options || {};
const enabledOptions = Object.keys(opts)
.filter(k => opts[k])
.join(',');
const params = new URLSearchParams({
editBookingId: bookingId,
origin: booking.origin || '',
@ -235,8 +239,9 @@ export default function PayCommissionPage() {
volumeCBM: String(booking.volumeCBM ?? ''),
weightKG: String(booking.weightKG ?? ''),
palletCount: String(booking.palletCount ?? ''),
hasDangerousGoods: 'false',
hasDangerousGoods: String(!!opts.dangerousGoods),
});
if (enabledOptions) params.set('options', enabledOptions);
router.push(`/dashboard/search-advanced?${params.toString()}`);
}}
className="mb-8 inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-blue-700 bg-blue-50 hover:bg-blue-100 rounded-lg transition-colors"

View File

@ -193,6 +193,17 @@ function NewBookingPageContent() {
formDataToSend.append('notes', formData.notes);
}
// Options & services selected in the search form (persisted with the booking)
const optionsParam = searchParams.get('options') || '';
if (optionsParam) {
const optionsObj = optionsParam
.split(',')
.map(s => s.trim())
.filter(Boolean)
.reduce((acc, k) => ({ ...acc, [k]: true }), {} as Record<string, boolean>);
formDataToSend.append('options', JSON.stringify(optionsObj));
}
// Append documents
formData.documents.forEach(file => {
formDataToSend.append('documents', file);

View File

@ -33,6 +33,9 @@ export default function BookingsListPage() {
// "Modifier" reopens the existing booking flow (search → carrier choice →
// payment) pre-filled with the booking's data, so a change re-quotes the price.
const buildEditUrl = (b: any) => {
const enabledOptions = Object.keys(b.options || {})
.filter(k => b.options[k])
.join(',');
const params = new URLSearchParams({
editBookingId: b.id,
origin: b.origin || b.originCity || '',
@ -40,8 +43,9 @@ export default function BookingsListPage() {
volumeCBM: String(b.volumeCBM ?? ''),
weightKG: String(b.weightKG ?? ''),
palletCount: String(b.palletCount ?? ''),
hasDangerousGoods: 'false',
hasDangerousGoods: String(!!(b.options && b.options.dangerousGoods)),
});
if (enabledOptions) params.set('options', enabledOptions);
return `/dashboard/search-advanced?${params.toString()}`;
};

View File

@ -51,6 +51,22 @@ interface SearchForm {
t1Document: boolean;
}
// Options & services persisted with the booking (order irrelevant).
const OPTION_KEYS: (keyof SearchForm)[] = [
'eurDocument',
't1Document',
'customsStop',
'exportAssistance',
'dangerousGoods',
'regulatedProducts',
'specialHandling',
'tailgate',
'straps',
'thermalCover',
'appointment',
'insurance',
];
export default function AdvancedSearchPage() {
const t = useTranslations('dashboard.rateSearch');
const router = useRouter();
@ -155,6 +171,16 @@ export default function AdvancedSearchPage() {
const pallets = parseInt(searchParams.get('palletCount') || '0', 10);
const dg = searchParams.get('hasDangerousGoods') === 'true';
// Prefill the options & services from the "options" CSV param.
const enabledOptions = (searchParams.get('options') || '')
.split(',')
.map(s => s.trim())
.filter(Boolean);
const optionFlags = OPTION_KEYS.reduce(
(acc, k) => ({ ...acc, [k]: enabledOptions.includes(k) }),
{} as Record<string, boolean>
);
// Reconstruct a single representative package that reproduces the aggregate
// totals (the booking only stores totals, not individual packages).
const q = pallets > 0 ? pallets : 1;
@ -172,9 +198,10 @@ export default function AdvancedSearchPage() {
setSearchForm(prev => ({
...prev,
...optionFlags,
origin: originParam || prev.origin,
destination: destinationParam || prev.destination,
dangerousGoods: dg,
dangerousGoods: dg || !!optionFlags.dangerousGoods,
packages: vol > 0 || wgt > 0 || pallets > 0 ? [pkg] : prev.packages,
}));
if (originParam) setOriginSearch(originParam);
@ -249,6 +276,10 @@ export default function AdvancedSearchPage() {
params.set('editBookingId', editBookingId);
}
// Carry the selected options & services so they can be persisted.
const enabledOptions = OPTION_KEYS.filter(k => searchForm[k]).join(',');
if (enabledOptions) params.set('options', enabledOptions);
router.push(`/dashboard/search-advanced/results?${params.toString()}`);
};

View File

@ -42,6 +42,14 @@ export default function SearchResultsPage() {
// When present, we are re-selecting a rate for an existing unpaid booking:
// update it in place and go straight to payment (skip the new-booking form).
const editBookingId = searchParams.get('editBookingId');
const optionsParam = searchParams.get('options') || '';
const optionsObj: Record<string, boolean> = optionsParam
? optionsParam
.split(',')
.map(s => s.trim())
.filter(Boolean)
.reduce((acc, k) => ({ ...acc, [k]: true }), {})
: {};
const [submittingId, setSubmittingId] = useState<string | null>(null);
const [selectError, setSelectError] = useState<string | null>(null);
@ -76,6 +84,7 @@ export default function SearchResultsPage() {
freightCurrency,
fobTotal,
fobCurrency,
options: optionsObj,
});
router.push(`/dashboard/booking/${editBookingId}/pay`);
} catch (err) {
@ -88,8 +97,9 @@ export default function SearchResultsPage() {
}
const rateData = encodeURIComponent(JSON.stringify(option));
const optionsQuery = optionsParam ? `&options=${encodeURIComponent(optionsParam)}` : '';
router.push(
`/dashboard/booking/new?rateData=${rateData}&volumeCBM=${volumeCBM}&weightKG=${weightKG}&palletCount=${palletCount}`
`/dashboard/booking/new?rateData=${rateData}&volumeCBM=${volumeCBM}&weightKG=${weightKG}&palletCount=${palletCount}${optionsQuery}`
);
};

View File

@ -66,6 +66,7 @@ export interface CsvBookingResponse {
updatedAt: string;
commissionRate?: number;
commissionAmountEur?: number;
options?: Record<string, boolean>;
}
export interface CommissionPaymentResponse {
@ -311,6 +312,7 @@ export interface UpdateCsvBookingRateRequest {
fobTotal?: number;
fobCurrency?: string;
notes?: string;
options?: Record<string, boolean>;
}
export async function updateCsvBookingRate(