full change-booking flow for op revisions

This commit is contained in:
Marshal
2026-08-06 21:57:08 +00:00
parent c61b66fd23
commit 1b8c7e4296
3 changed files with 197 additions and 12 deletions

View File

@@ -51,7 +51,12 @@ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) {
}
const summary =
status === "CLEARANCE_READY" ? (
status === "OPERATION_CHANGES_REQUESTED" ? (
<Alert color="yellow" radius="md" icon={<Clock size={18} />}>
Operations returned this order for changes. Update the booking details,
pick a new shipment day and resubmit.
</Alert>
) : status === "CLEARANCE_READY" ? (
<Alert color="teal" radius="md" icon={<CheckCircle2 size={18} />}>
{`${
booking.customsClearingEnabled
@@ -103,9 +108,11 @@ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) {
{summary}
<Text fz="12.5px" c="dimmed" mt="sm">
{isBookAction
? "Use “Book” to enter the cargo details and schedule your shipment."
: `Use “${action?.label ?? "the action button"}” to manage your ${docNoun}.`}
{status === "OPERATION_CHANGES_REQUESTED"
? "Use “Change booking” to update the details and pick a new shipment day."
: isBookAction
? "Use “Book” to enter the cargo details and schedule your shipment."
: `Use “${action?.label ?? "the action button"}” to manage your ${docNoun}.`}
</Text>
{!isBookAction && (

View File

@@ -90,11 +90,21 @@ function actionByStatus(booking: ActionBooking): BookingNextAction | null {
title: "Schedule your shipment",
};
case "OPERATION_CHANGES_REQUESTED":
return {
kind: "SCHEDULE_OPERATION",
label: "Choose day & resubmit",
title: "Resubmit your shipment",
};
// Contract bookings reopen the full completion form (cargo + shipment
// day, prefilled from the booking) — same page as the initial booking.
// Contract-less bookings keep the in-place day-picker modal.
return booking.contractId
? {
kind: "BOOK",
label: "Change booking",
title: "Change your booking",
to: `/contracts/${booking.contractId}/bookings/${booking.id}/complete`,
}
: {
kind: "SCHEDULE_OPERATION",
label: "Choose day & resubmit",
title: "Resubmit your shipment",
};
default:
return null;
}

View File

@@ -38,6 +38,7 @@ import {
CheckCircle2,
ChevronLeft,
FileDown,
FileText,
FileUp,
Flame,
MapPin,
@@ -59,6 +60,7 @@ import {
contractsService,
type ShipmentValidation,
} from "@/services/contracts.service";
import { downloadStoredFile } from "@/services/files.service";
import {
SelectField,
StepCard,
@@ -245,6 +247,127 @@ function bulkUnitOfMeasure(
return hasPerItem ? "PER_ITEM" : "PER_TON";
}
/**
* Prefill for a changes-requested resubmit: the booking's persisted cargo,
* currency and route become the form's starting values so the customer edits
* what exists instead of retyping it. The shipment day is deliberately left
* empty — a new day must be picked.
*/
function mapBookingToShipmentValues(
booking: Freight.IBooking,
contract: Freight.IContract,
): Partial<ShipmentFormInputValues> {
const b = booking as unknown as {
cargoFreeText?: string | null;
contractRouteId?: string | null;
cargoTotalWeightVgm?: number | string | null;
bulkTotalWeightTons?: number | string | null;
bulkHazardousQuantity?: number | string | null;
bulkReeferQuantity?: number | string | null;
bookingContainers?: Array<{
quantity?: number;
hazardousQuantity?: number | string | null;
reeferQuantity?: number | string | null;
returnQuantity?: number | string | null;
containerType?: { sizeFt?: number | null } | null;
units?: Array<{
containerNumber?: string;
sealNumber?: string | null;
vgmTons?: number | string;
isHazardous?: boolean;
isReefer?: boolean;
isReturn?: boolean;
}>;
}>;
};
const values: Partial<ShipmentFormInputValues> = {
paymentCurrency: booking.paymentCurrency === "ETB" ? "ETB" : "USD",
withReturn: booking.equipmentReturn === "WITH_RETURN",
cargoDescription: b.cargoFreeText ?? "",
...(b.contractRouteId ? { contractRouteId: b.contractRouteId } : {}),
};
if (contract.freightType === "CONTAINER") {
const rows = b.bookingContainers ?? [];
const lineFor = (size: "20ft" | "40ft") => {
const bc = rows.find(
(r) => (r.containerType?.sizeFt === 40 ? "40ft" : "20ft") === size,
);
return {
containerSize: size,
quantity: String(bc?.quantity ?? 0),
hazardousQuantity: String(Number(bc?.hazardousQuantity ?? 0)),
reeferQuantity: String(Number(bc?.reeferQuantity ?? 0)),
returnQuantity: String(Number(bc?.returnQuantity ?? 0)),
units: (bc?.units ?? []).map((u) => ({
containerNumber: u.containerNumber ?? "",
sealNumber: u.sealNumber ?? "",
vgmTons: String(Number(u.vgmTons ?? 0)),
isHazardous: Boolean(u.isHazardous),
isReefer: Boolean(u.isReefer),
isReturn: Boolean(u.isReturn),
})),
};
};
const sizes = (contract.cargoScope ?? [])
.map((s) => s.containerSize)
.filter((s): s is "20ft" | "40ft" => s === "20ft" || s === "40ft");
values.containers = (sizes.length ? sizes : ["20ft", "40ft"]).map(lineFor);
} else {
const perItem = bulkUnitOfMeasure(contract) === "PER_ITEM";
const amount = Number(b.cargoTotalWeightVgm ?? 0);
if (perItem) {
values.itemCount = amount ? String(amount) : "";
values.cargoWeightTons =
b.bulkTotalWeightTons != null
? String(Number(b.bulkTotalWeightTons))
: "";
} else {
values.cargoWeightTons = amount ? String(amount) : "";
}
values.bulkHazardousQuantity = String(Number(b.bulkHazardousQuantity ?? 0));
values.bulkReeferQuantity = String(Number(b.bulkReeferQuantity ?? 0));
}
return values;
}
/** Read-only list of the booking's already-uploaded documents (resubmit view). */
function UploadedDocumentsCard({ booking }: { booking: Freight.IBooking }) {
const files =
(booking as unknown as { files?: Array<{ id: string; name: string }> })
.files ?? [];
if (!files.length) return null;
return (
<Paper withBorder radius="lg" p="lg">
<Group gap={8} mb={4}>
<FileText size={16} />
<Text fw={700} fz="sm">
Your uploaded documents
</Text>
</Group>
<Text fz={12.5} c="dimmed" mb="sm">
These stay attached to the booking no need to upload them again.
</Text>
<Stack gap={6}>
{files.map((f) => (
<Group key={f.id} justify="space-between" wrap="nowrap">
<Text fz={13} truncate>
{f.name}
</Text>
<ActionIcon
variant="default"
radius="md"
onClick={() => void downloadStoredFile(f.id, f.name)}
aria-label={`Download ${f.name}`}
>
<FileDown size={15} />
</ActionIcon>
</Group>
))}
</Stack>
</Paper>
);
}
function NewShipmentBookingForm({
contract,
contractId,
@@ -306,6 +429,34 @@ function NewShipmentBookingForm({
: 0;
const hasOdd20ft = ft20Total % 2 === 1;
// COMPLETION mode: fetch the booking — a changes-requested resubmit prefills
// the form from it and shows the operations note + uploaded documents.
const { data: completeBooking } = useQuery(
api.bookings.get.queryOptions({
input: { id: completeBookingId! },
enabled: Boolean(completeBookingId),
}),
);
const isResubmit = Boolean(
completeBooking &&
["OPERATION_CHANGES_REQUESTED", "EXPIRED"].includes(
completeBooking.status as string,
) &&
(((completeBooking as unknown as { bookingContainers?: unknown[] })
.bookingContainers?.length ?? 0) > 0 ||
Number(completeBooking.cargoTotalWeightVgm ?? 0) > 0),
);
const prefilledRef = useRef(false);
useEffect(() => {
if (!isResubmit || prefilledRef.current || !completeBooking) return;
prefilledRef.current = true;
form.reset({
...form.getValues(),
...mapBookingToShipmentValues(completeBooking, contract),
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isResubmit]);
const submitMutation = useMutation({
mutationFn: (dto: Freight.CreateBookingUnderContractDto) =>
completeBookingId
@@ -488,12 +639,16 @@ function NewShipmentBookingForm({
style={{ letterSpacing: "-0.01em" }}
>
{completeBookingId
? "Complete Your Booking"
? isResubmit
? "Change Your Booking"
: "Complete Your Booking"
: "New Shipment Booking"}
</Title>
<Text size="sm" c="edr-muted" mt={4}>
{completeBookingId
? `Clearance is finalized — enter the cargo details and shipment day to complete your booking under contract ${contract.reference}.`
? isResubmit
? `Update the details below and pick a new shipment day, then resubmit your booking under contract ${contract.reference}.`
: `Clearance is finalized — enter the cargo details and shipment day to complete your booking under contract ${contract.reference}.`
: `Book a shipment against contract ${contract.reference}.`}
</Text>
</Box>
@@ -535,6 +690,16 @@ function NewShipmentBookingForm({
{/* Single-step form — all sections on one page. */}
<Stack gap="lg" className="mx-auto max-w-4xl">
{isResubmit && completeBooking?.latestChangeRequestNote && (
<Alert
color="yellow"
icon={<AlertCircle size={16} />}
radius="md"
title="Operations requested changes"
>
<Text size="sm">{completeBooking.latestChangeRequestNote}</Text>
</Alert>
)}
<RouteStep form={form} contract={contract} routes={routes} />
<CargoStep form={form} contract={contract} />
{/* Legacy contracts only — WITH_RETURN contracts capture per-line
@@ -547,6 +712,9 @@ function NewShipmentBookingForm({
routes={routes}
completeBookingId={completeBookingId ?? null}
/>
{isResubmit && completeBooking && (
<UploadedDocumentsCard booking={completeBooking} />
)}
{/* Notes are captured when the booking is initiated — completing
a bare booking does not re-ask for them. */}
{!completeBookingId && <NotesSection form={form} />}
@@ -594,7 +762,7 @@ function NewShipmentBookingForm({
onClick={handleReview}
disabled={hasOdd20ft}
>
Review price &amp; book
{isResubmit ? "Change booking" : "Review price & book"}
</Button>
</Box>
</Tooltip>