mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 23:40:56 +00:00
Enhance internal payment handling and e2e testing setup
This commit is contained in:
@@ -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: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 & book
|
||||
</Button>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
<Box>
|
||||
<Button
|
||||
type="button"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<Receipt size={16} />}
|
||||
onClick={handleReview}
|
||||
disabled={hasOdd20ft}
|
||||
>
|
||||
Review price & 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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user