Enhance internal payment handling and e2e testing setup

This commit is contained in:
Marshal
2026-08-02 19:17:17 +00:00
parent 930e39c4fc
commit 3956c78c54
3 changed files with 201 additions and 154 deletions

View File

@@ -172,11 +172,11 @@ function emptyUnit(): UnitDraft {
function emptyLine(size: string): ContainerLineDraft {
return {
containerSize: size,
quantity: "1",
quantity: "0",
hazardousQuantity: "0",
reeferQuantity: "0",
returnQuantity: "0",
units: [emptyUnit()],
units: [],
};
}

View File

@@ -742,16 +742,7 @@ export default function TrainScheduleV2DetailPage() {
<WagonPlanGrid wagonPlan={displayWagonPlanOriented} freightType={freightType} />
{canEditBookings && (previewResult || displayWagonPlan.length) ? (
<Group>
{!hasContainerStep ? (
<Button
color="edr-green"
radius="md"
loading={assign.isPending}
onClick={handleAssign}
>
{assignedIds.length ? "Save assignments" : "Assign bookings"}
</Button>
) : (
{hasContainerStep ? (
<Button
color="edr-green"
radius="md"
@@ -760,7 +751,7 @@ export default function TrainScheduleV2DetailPage() {
>
Continue to containers
</Button>
)}
) : null}
<Button variant="default" radius="md" onClick={() => void runPreview()}>
Refresh preview
</Button>

View File

@@ -266,8 +266,7 @@ function NewShipmentBookingForm({
withReturn: contract.equipmentReturn === "WITH_RETURN",
// The contract quotes USD; the customer bills this shipment in the
// currency they pick here. Intercity is always ETB.
paymentCurrency:
contract.tradeDirection === "DOMESTIC" ? "ETB" : "USD",
paymentCurrency: contract.tradeDirection === "DOMESTIC" ? "ETB" : "USD",
},
resolver: zodResolver(
createShipmentFormSchema({
@@ -306,7 +305,10 @@ function NewShipmentBookingForm({
bookingId: completeBookingId,
dto,
})
: api.contracts.createBookingUnderContract.call({ id: contractId, dto }),
: api.contracts.createBookingUnderContract.call({
id: contractId,
dto,
}),
onSuccess: (booking) => {
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
queryClient.invalidateQueries({
@@ -366,7 +368,8 @@ function NewShipmentBookingForm({
.map((l) => ({
containerSize: l.containerSize,
quantity: Number(l.quantity),
hazardousQuantity: Number(l.hazardousQuantity || 0) || undefined,
hazardousQuantity:
Number(l.hazardousQuantity || 0) || undefined,
reeferQuantity: Number(l.reeferQuantity || 0) || undefined,
...(withReturnService
? { returnQuantity: Number(l.returnQuantity || 0) }
@@ -379,7 +382,9 @@ function NewShipmentBookingForm({
// line counts and bills each surcharge on the ticked containers.
isHazardous: Boolean(u.isHazardous),
isReefer: Boolean(u.isReefer),
...(withReturnService ? { isReturn: Boolean(u.isReturn) } : {}),
...(withReturnService
? { isReturn: Boolean(u.isReturn) }
: {}),
})),
})),
}
@@ -417,6 +422,12 @@ function NewShipmentBookingForm({
validateMutation.mutate(buildDto(values));
});
// The per-field messages render inline, but on a long single-page form the
// failing field is often scrolled out of view — mirror the backoffice's
// summary alert next to the submit button so the click never looks inert.
const showValidationSummary =
form.formState.isSubmitted && !form.formState.isValid;
const handleConfirm = () => {
if (!pendingValues) return;
// Guard: never let a booking with unresolved 20ft pairing errors submit.
@@ -457,8 +468,15 @@ function NewShipmentBookingForm({
mb="lg"
>
<Box>
<Title order={1} fw={800} fz={26} style={{ letterSpacing: "-0.01em" }}>
{completeBookingId ? "Complete Your Booking" : "New Shipment Booking"}
<Title
order={1}
fw={800}
fz={26}
style={{ letterSpacing: "-0.01em" }}
>
{completeBookingId
? "Complete Your Booking"
: "New Shipment Booking"}
</Title>
<Text size="sm" c="edr-muted" mt={4}>
{completeBookingId
@@ -534,28 +552,41 @@ function NewShipmentBookingForm({
marginTop: "auto",
}}
>
<Group justify="flex-end" className="mx-auto max-w-4xl">
<Tooltip
label={`Book an even number of 20ft containers — ${ft20Total} is odd and would leave one unpaired.`}
withArrow
disabled={!hasOdd20ft}
>
{/* Mantine tooltips get no pointer events from a disabled button,
<Box className="mx-auto max-w-4xl">
{showValidationSummary ? (
<Alert
color="red"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
mb="sm"
>
Fix the highlighted fields before reviewing the price.
</Alert>
) : null}
<Group justify="flex-end">
<Tooltip
label={`Book an even number of 20ft containers — ${ft20Total} is odd and would leave one unpaired.`}
withArrow
disabled={!hasOdd20ft}
>
{/* Mantine tooltips get no pointer events from a disabled button,
so the wrapper carries the hover target. */}
<Box>
<Button
type="button"
color="edr-green"
radius="md"
leftSection={<Receipt size={16} />}
onClick={handleReview}
disabled={hasOdd20ft}
>
Review price &amp; book
</Button>
</Box>
</Tooltip>
</Group>
<Box>
<Button
type="button"
color="edr-green"
radius="md"
leftSection={<Receipt size={16} />}
onClick={handleReview}
disabled={hasOdd20ft}
>
Review price &amp; book
</Button>
</Box>
</Tooltip>
</Group>
</Box>
</Box>
</form>
@@ -621,8 +652,7 @@ function PriceConfirmModal({
quantity: li.quantity,
amount: li.amount,
})),
total:
validation?.totalAmount ?? items.reduce((s, l) => s + l.amount, 0),
total: validation?.totalAmount ?? items.reduce((s, l) => s + l.amount, 0),
};
}, [validation, baseTotal]);
@@ -715,8 +745,8 @@ function PriceConfirmModal({
</Text>
))}
<Text fz="xs" c="red.7" mt={2}>
Adjust the 20ft container weights or quantities so pairs differ
by no more than 10 tons.
Adjust the 20ft container weights or quantities so pairs
differ by no more than 10 tons.
</Text>
</Stack>
</Alert>
@@ -810,7 +840,12 @@ function PriceConfirmModal({
</Alert>
)}
<Paper withBorder radius={16} p="lg" style={{ borderColor: "#E6ECF2" }}>
<Paper
withBorder
radius={16}
p="lg"
style={{ borderColor: "#E6ECF2" }}
>
<Stack gap={10}>
{total.lines.map((line, i) => (
<Group key={i} justify="space-between" wrap="nowrap" gap="sm">
@@ -1007,7 +1042,9 @@ function ScheduleStep({
const isIntercity = contract.tradeDirection === "DOMESTIC";
const { data: availableDays, isLoading } = useQuery({
...api.bookings.getAvailableDaysForCargo.queryOptions({
input: cargoQuery ?? ({ freightType: "BULK" } as Freight.AvailableDaysForCargoQuery),
input:
cargoQuery ??
({ freightType: "BULK" } as Freight.AvailableDaysForCargoQuery),
}),
enabled: cargoQuery !== null && !isIntercity,
});
@@ -1064,7 +1101,12 @@ function ScheduleStep({
title="Schedule"
description="Intercity shipments have no fixed day."
/>
<Alert color="blue" variant="light" radius="md" icon={<AlertCircle size={16} />}>
<Alert
color="blue"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
>
Your shipment rides the next import/export train passing through your
corridor. Operations assign it to a train with free capacity you
will be notified when it is accepted and payment is due.
@@ -1110,7 +1152,12 @@ function ScheduleStep({
)}
/>
{cargoQuery === null ? (
<Alert color="yellow" variant="light" radius="md" icon={<AlertCircle size={16} />}>
<Alert
color="yellow"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
>
Enter your cargo details first available shipment days depend on the
wagons your cargo needs.
</Alert>
@@ -1245,11 +1292,11 @@ function CargoStep({
"containers",
sizes.map((size) => ({
containerSize: size,
quantity: "1",
quantity: "0",
hazardousQuantity: "0",
reeferQuantity: "0",
returnQuantity: "0",
units: [emptyUnit()],
units: [],
})),
{ shouldValidate: false },
);
@@ -1289,11 +1336,11 @@ function CargoStep({
return (
current.find((l) => l.containerSize === size) ?? {
containerSize: size,
quantity: "1",
quantity: "0",
hazardousQuantity: "0",
reeferQuantity: "0",
returnQuantity: "0",
units: [emptyUnit()],
units: [],
}
);
}
@@ -1315,7 +1362,10 @@ function CargoStep({
})),
};
});
form.setValue("containers", next, { shouldValidate: true, shouldDirty: true });
form.setValue("containers", next, {
shouldValidate: true,
shouldDirty: true,
});
setImportErrors([]);
setImportSummary(`Imported ${rows.length} container(s) from ${file.name}.`);
};
@@ -1330,7 +1380,12 @@ function CargoStep({
/>
<Stack gap={18}>
{sizes.length > 0 && (
<Paper withBorder radius="md" p="md" style={{ borderColor: "#E6ECF2" }}>
<Paper
withBorder
radius="md"
p="md"
style={{ borderColor: "#E6ECF2" }}
>
<Group justify="space-between" wrap="wrap" gap="sm">
<Box>
<Text fz={13} fw={600} c="#10202F">
@@ -1458,10 +1513,11 @@ function CargoStep({
title={`Odd number of 20ft containers (${ft20})`}
>
<Text fz={13}>
20ft containers travel two per wagon, so they must be booked in
even numbers. Please add one more 20ft container or remove one
(e.g. book {ft20 + 1} or {ft20 - 1} instead of {ft20}) the
booking cannot be submitted with an unpaired 20ft container.
20ft containers travel two per wagon, so they must be booked
in even numbers. Please add one more 20ft container or remove
one (e.g. book {ft20 + 1} or {ft20 - 1} instead of {ft20})
the booking cannot be submitted with an unpaired 20ft
container.
</Text>
</Alert>
);
@@ -1660,16 +1716,6 @@ function NotesSection({ form }: { form: ShipmentForm }) {
);
}
/** A blank container row — handling switches start off. */
const emptyUnit = () => ({
containerNumber: "",
sealNumber: "",
vgmTons: "",
isHazardous: false,
isReefer: false,
isReturn: false,
});
function ContainerLineEditor({
form,
index,
@@ -1726,7 +1772,11 @@ function ContainerLineEditor({
* price estimate and the submitted payload stay in step with the switches.
*/
const syncHandlingCounts = (
units: Array<{ isHazardous?: boolean; isReefer?: boolean; isReturn?: boolean }>,
units: Array<{
isHazardous?: boolean;
isReefer?: boolean;
isReturn?: boolean;
}>,
) => {
const set = (
key: "hazardousQuantity" | "reeferQuantity" | "returnQuantity",
@@ -1856,94 +1906,100 @@ function ContainerLineEditor({
))}
</Group>
)}
{Array.from({ length: Math.max(quantity, units.length) }).map((_, u) => (
<Group key={u} gap={10} wrap="nowrap" align="flex-start">
<Controller
name={`containers.${index}.units.${u}.containerNumber`}
control={form.control}
render={({ field, fieldState }) => (
<TextInput
{...field}
onChange={(e) =>
field.onChange(e.currentTarget.value.toUpperCase())
}
placeholder="e.g. MSCU1234567"
error={fieldState.error?.message}
radius={10}
styles={fieldStyles}
style={{ flex: 1 }}
/>
)}
/>
<Controller
name={`containers.${index}.units.${u}.sealNumber`}
control={form.control}
render={({ field }) => (
<TextInput
{...field}
placeholder="Optional"
radius={10}
styles={fieldStyles}
style={{ flex: 1 }}
/>
)}
/>
<Controller
name={`containers.${index}.units.${u}.vgmTons`}
control={form.control}
render={({ field, fieldState }) => (
<TextInput
{...field}
type="number"
onKeyDown={blockNegative}
placeholder="e.g. 24.5"
min={0}
step={0.01}
error={fieldState.error?.message}
radius={10}
styles={fieldStyles}
style={{ flex: 1 }}
/>
)}
/>
{handlingColumns.map((col) => (
{Array.from({ length: Math.max(quantity, units.length) }).map(
(_, u) => (
<Group key={u} gap={10} wrap="nowrap" align="flex-start">
<Controller
key={col.key}
name={`containers.${index}.units.${u}.${col.key}`}
name={`containers.${index}.units.${u}.containerNumber`}
control={form.control}
render={({ field }) => (
<Box
style={{
width: 96,
flexShrink: 0,
height: 42,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Switch
checked={Boolean(field.value)}
aria-label={`${col.label} — container ${u + 1}`}
onChange={(e) =>
toggleUnitHandling(u, col.key, e.currentTarget.checked)
}
size="sm"
/>
</Box>
render={({ field, fieldState }) => (
<TextInput
{...field}
onChange={(e) =>
field.onChange(e.currentTarget.value.toUpperCase())
}
placeholder="e.g. MSCU1234567"
error={fieldState.error?.message}
radius={10}
styles={fieldStyles}
style={{ flex: 1 }}
/>
)}
/>
))}
<ActionIcon
variant="subtle"
color="red"
aria-label={`Remove container ${u + 1}`}
onClick={() => removeUnit(u)}
>
<X size={16} />
</ActionIcon>
</Group>
))}
<Controller
name={`containers.${index}.units.${u}.sealNumber`}
control={form.control}
render={({ field }) => (
<TextInput
{...field}
placeholder="Optional"
radius={10}
styles={fieldStyles}
style={{ flex: 1 }}
/>
)}
/>
<Controller
name={`containers.${index}.units.${u}.vgmTons`}
control={form.control}
render={({ field, fieldState }) => (
<TextInput
{...field}
type="number"
onKeyDown={blockNegative}
placeholder="e.g. 24.5"
min={0}
step={0.01}
error={fieldState.error?.message}
radius={10}
styles={fieldStyles}
style={{ flex: 1 }}
/>
)}
/>
{handlingColumns.map((col) => (
<Controller
key={col.key}
name={`containers.${index}.units.${u}.${col.key}`}
control={form.control}
render={({ field }) => (
<Box
style={{
width: 96,
flexShrink: 0,
height: 42,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Switch
checked={Boolean(field.value)}
aria-label={`${col.label} — container ${u + 1}`}
onChange={(e) =>
toggleUnitHandling(
u,
col.key,
e.currentTarget.checked,
)
}
size="sm"
/>
</Box>
)}
/>
))}
<ActionIcon
variant="subtle"
color="red"
aria-label={`Remove container ${u + 1}`}
onClick={() => removeUnit(u)}
>
<X size={16} />
</ActionIcon>
</Group>
),
)}
</Stack>
</Box>
);