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:
parent
795e635df3
commit
c5f823f8b7
@ -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>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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 ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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()}`;
|
||||
};
|
||||
|
||||
|
||||
@ -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()}`);
|
||||
};
|
||||
|
||||
|
||||
@ -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}`
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
Loading…
Reference in New Issue
Block a user