feat(bookings): add contract validity window and customs clearing features

- Introduced contract validity days, valid from and valid until fields in the booking model.
- Updated booking acceptance logic to enforce validity window constraints.
- Added customs clearing agent and first/last mile pickup/delivery coordinates to the booking model.
- Implemented LocationPicker component for address selection with map integration using Leaflet.
- Enhanced booking review step to display customs clearing agent details.
- Updated API and DTOs to accommodate new booking fields.
- Added migration scripts for database schema changes.
- Implemented tests for booking acceptance and DTO transformations.
This commit is contained in:
Marshal
2026-06-24 00:21:01 +00:00
parent 07cd7dc111
commit 8ef7641048
31 changed files with 1251 additions and 226 deletions

View File

@@ -8,10 +8,22 @@ import {
Button,
Textarea,
FileInput,
NumberInput,
} from "@mantine/core";
import type { BookingActionDef } from "@/features/bookings/booking-actions.config";
/** Today + `days`, formatted as a readable date for the validity preview. */
function validUntilLabel(days: number): string {
const until = new Date();
until.setDate(until.getDate() + days);
return until.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
}
interface BookingConfirmDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
@@ -46,8 +58,14 @@ export function BookingConfirmDialog({
const Icon = action.icon;
const needsTextInput = action.input === "note" || action.input === "reason";
const needsFileInput = action.input === "file";
const needsDaysInput = action.input === "days";
const daysValue = Number(inputValue.trim());
const daysValid =
Number.isInteger(daysValue) && daysValue >= 1 && daysValue <= 365;
const inputMissing =
(needsTextInput && !inputValue.trim()) || (needsFileInput && !selectedFile);
(needsTextInput && !inputValue.trim()) ||
(needsFileInput && !selectedFile) ||
(needsDaysInput && !daysValid);
const isDestructive = action.variant === "destructive";
const accent = isDestructive ? "red" : "edr-green";
@@ -129,6 +147,27 @@ export function BookingConfirmDialog({
clearable
/>
)}
{needsDaysInput && (
<Stack gap={4}>
<NumberInput
label={action.inputLabel ?? "Contract validity (days)"}
withAsterisk
min={1}
max={365}
clampBehavior="strict"
allowDecimal={false}
allowNegative={false}
placeholder={action.inputPlaceholder ?? "e.g. 30"}
value={inputValue === "" ? "" : Number(inputValue)}
onChange={(value) => onInputChange(value === "" ? "" : String(value))}
/>
<Text size="xs" c="dimmed">
{daysValid
? `Contract valid from today until ${validUntilLabel(daysValue)} (${daysValue} day${daysValue === 1 ? "" : "s"}).`
: "Enter a whole number of days between 1 and 365."}
</Text>
</Stack>
)}
{extra}
</Stack>

View File

@@ -9,6 +9,12 @@ import {
import { useAuth } from "@/auth/useAuth";
import { useBookingDetail, useBookingMutations } from "@/hooks/bookings/useBookings";
/** A contract validity window must be a whole number of days, 1365. */
function isValidValidityDays(value: string): boolean {
const days = Number(value.trim());
return Number.isInteger(days) && days >= 1 && days <= 365;
}
export function useBookingActionDialog(
bookingId: string,
context: BookingActionContext,
@@ -59,9 +65,12 @@ export function useBookingActionDialog(
const onSuccess = () => closeDialog();
switch (pendingAction.id) {
case "accept":
mutations.staffAccept.mutate(undefined, { onSuccess });
case "accept": {
const days = Number(inputValue.trim());
if (!Number.isInteger(days) || days < 1 || days > 365) return;
mutations.staffAccept.mutate(days, { onSuccess });
break;
}
case "requestChanges":
mutations.requestChanges.mutate(inputValue.trim(), { onSuccess });
break;
@@ -116,7 +125,8 @@ export function useBookingActionDialog(
!getNextPendingApprovalStep(mergedContext.approvalSteps)) ||
(pendingAction?.input === "file" && !selectedFile) ||
(pendingAction?.input === "reason" && !inputValue.trim()) ||
(pendingAction?.input === "note" && !inputValue.trim());
(pendingAction?.input === "note" && !inputValue.trim()) ||
(pendingAction?.input === "days" && !isValidValidityDays(inputValue));
return {
actions,

View File

@@ -36,7 +36,7 @@ export type BookingActionId =
| "complete"
| "cancel";
export type BookingActionInputKind = "note" | "reason" | "file";
export type BookingActionInputKind = "note" | "reason" | "file" | "days";
export interface BookingActionDef {
id: BookingActionId;
@@ -115,10 +115,13 @@ const SUBMITTED_ACTIONS: BookingActionDef[] = [
description: "Start the formal approval chain",
confirmTitle: "Accept submission?",
confirmDescription:
"The booking moves to pending approval and approval steps are created from the rule engine.",
"Set how long the contract stays valid, then the booking moves to pending approval and approval steps are created from the rule engine.",
variant: "default",
icon: ShieldCheck,
primary: true,
input: "days",
inputLabel: "Contract validity (days)",
inputPlaceholder: "e.g. 30",
},
{
id: "requestChanges",

View File

@@ -41,7 +41,8 @@ export function useBookingMutations(bookingId: string) {
};
const staffAccept = useMutation({
mutationFn: () => api.bookings.staffAccept.call({ id: bookingId }),
mutationFn: (validityDays: number) =>
api.bookings.staffAccept.call({ id: bookingId, validityDays }),
onSuccess: (data) => onSuccess(data, "Booking accepted for approval"),
onError: () => toast.error("Failed to accept booking"),
});

View File

@@ -1817,10 +1817,10 @@ export const api = {
({ id }) => bookingsService.remove(id),
),
staffAccept: endpoint<{ id: string }, BookingDetail>(
staffAccept: endpoint<{ id: string; validityDays: number }, BookingDetail>(
"bookings",
"staffAccept",
({ id }) => bookingsService.staffAccept(id),
({ id, validityDays }) => bookingsService.staffAccept(id, validityDays),
),
requestChanges: endpoint<{ id: string; note: string }, BookingDetail>(

View File

@@ -200,7 +200,8 @@ export const bookingsService = {
finalizeClearance: (id: string) =>
postBooking<BookingDetail>(`/bookings/${id}/clearance/finalize`),
staffAccept: (id: string) => postBooking<BookingDetail>(B.STAFF_ACCEPT(id)),
staffAccept: (id: string, validityDays: number) =>
postBooking<BookingDetail>(B.STAFF_ACCEPT(id), { validityDays }),
requestChanges: (id: string, note: string) =>
postBooking<BookingDetail>(B.STAFF_REQUEST_CHANGES(id), { note }),

View File

@@ -123,6 +123,10 @@ export interface BookingDetail {
adjustedByStaffId?: string | null;
adjustedAt?: string | null;
adjustmentReason?: string | null;
/** Contract validity window set by the backoffice when accepting. */
contractValidityDays?: number | null;
contractValidFrom?: string | null;
contractValidUntil?: string | null;
pricingBreakdown?: {
currency: string;
totalAmount: number;