From 006b4226e8e0f3204eda66f0232e278a1ebb08a5 Mon Sep 17 00:00:00 2001 From: Marshal Date: Sun, 28 Jun 2026 21:15:10 +0000 Subject: [PATCH] update navigation paths for contract clearance pages --- .../contracts/ContractClearanceDetailPage.tsx | 8 +- .../contracts/ContractClearanceListPage.tsx | 2 +- .../src/pages/contracts/NewShipmentPage.tsx | 461 ++++++++++-------- 3 files changed, 258 insertions(+), 213 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx index eb8b05bbc..1279177bf 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx @@ -79,11 +79,11 @@ export default function ContractClearanceDetailPage() { navigate(`/dashboard/clearance/${id}`), + (id: string) => navigate(`/dashboard/contracts/clearance/${id}`), [navigate], ); diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx index 62d6ef85f..c206fe951 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx @@ -8,23 +8,27 @@ import { Box, Button, Center, + Divider, Group, Loader, + Modal, Paper, Stack, Text, TextInput, Textarea, + ThemeIcon, Title, } from "@mantine/core"; import { AlertCircle, CalendarDays, + CheckCircle2, ChevronLeft, - ChevronRight, MapPin, Package, - Send, + Receipt, + X, } from "lucide-react"; import type { Freight } from "@edr/types"; @@ -37,15 +41,12 @@ import { StepLabel, fieldStyles, } from "./new-contract-form/shared"; -import { StepIndicator } from "./new-contract-form/StepIndicator"; import { formatRateUnit } from "./new-contract-form/unit-rates"; import { - SHIPMENT_STEPS, ShipmentFormInputValues, ShipmentFormValues, initialShipmentFormValues, shipmentFormSchema, - shipmentStepFields, } from "./new-shipment-form/schema"; import { computeShipmentTotal } from "./new-shipment-form/total"; import { ContractCapacityNotice } from "./new-shipment-form/ContractCapacityNotice"; @@ -58,7 +59,13 @@ export default function NewShipmentPage() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const queryClient = useQueryClient(); - const [step, setStep] = useState(0); + + // Holds the validated values awaiting price confirmation. When set, the price + // modal is open. The customer confirms (books) or rejects (back to the form + // to edit and re-book). + const [pendingValues, setPendingValues] = useState( + null, + ); const { data: contract, isLoading } = useQuery( api.contracts.get.queryOptions({ input: { id: id! }, enabled: !!id }), @@ -82,26 +89,6 @@ export default function NewShipmentPage() { }, }); - const visibleStepIds = useMemo(() => SHIPMENT_STEPS.map((s) => s.id), []); - const currentStepIndex = visibleStepIds.indexOf(step); - const isLastStep = currentStepIndex === visibleStepIds.length - 1; - const isFirstStep = currentStepIndex <= 0; - const goToStep = (delta: number) => { - const idx = visibleStepIds.indexOf(step); - const nextIdx = Math.min( - visibleStepIds.length - 1, - Math.max(0, idx + delta), - ); - setStep(visibleStepIds[nextIdx]); - }; - - async function handleContinue() { - const valid = await form.trigger(shipmentStepFields[step], { - shouldFocus: true, - }); - if (valid) goToStep(1); - } - if (isLoading) { return (
@@ -149,7 +136,9 @@ export default function NewShipmentPage() { ); } - function buildDto(values: ShipmentFormValues): Freight.CreateBookingUnderContractDto { + function buildDto( + values: ShipmentFormValues, + ): Freight.CreateBookingUnderContractDto { const isContainer = contract!.freightType === "CONTAINER"; return { ...(values.contractRouteId @@ -191,10 +180,22 @@ export default function NewShipmentPage() { }; } - const handleSubmit = form.handleSubmit((values) => { - submitMutation.mutate(buildDto(values)); + // Submit validates the whole form, then opens the price modal for confirmation. + const handleReview = form.handleSubmit((values) => { + setPendingValues(values); }); + const handleConfirm = () => { + if (!pendingValues) return; + submitMutation.mutate(buildDto(pendingValues)); + }; + + // Reject — close the modal and let the customer edit and re-book. + const handleReject = () => { + if (submitMutation.isPending) return; + setPendingValues(null); + }; + const routes = contract.routes ?? []; return ( @@ -206,7 +207,14 @@ export default function NewShipmentPage() { flexDirection: "column", }} > - + New Shipment Booking @@ -231,12 +239,13 @@ export default function NewShipmentPage() { onSubmit={(e) => e.preventDefault()} > <Box flex={1} p="24px"> - <Box mb="lg"> - <StepIndicator step={step} steps={SHIPMENT_STEPS} /> - </Box> - {submitMutation.isError && ( - <Alert color="red" icon={<AlertCircle size={16} />} radius="md" mb="lg"> + <Alert + color="red" + icon={<AlertCircle size={16} />} + radius="md" + mb="lg" + > <Text size="sm" fw={600}> Failed to create the shipment booking </Text> @@ -248,14 +257,13 @@ export default function NewShipmentPage() { </Alert> )} - {step === 0 && ( + {/* Single-step form — all sections on one page. */} + <Stack gap="lg" className="mx-auto max-w-4xl"> <RouteStep form={form} contract={contract} routes={routes} /> - )} - {step === 1 && ( <ScheduleStep form={form} contract={contract} routes={routes} /> - )} - {step === 2 && <CargoStep form={form} contract={contract} />} - {step === 3 && <ReviewStep form={form} contract={contract} />} + <CargoStep form={form} contract={contract} /> + <NotesSection form={form} /> + </Stack> </Box> <Box @@ -270,46 +278,153 @@ export default function NewShipmentPage() { marginTop: "auto", }} > - <Group justify="space-between" className="mx-auto max-w-4xl"> + <Group justify="flex-end" className="mx-auto max-w-4xl"> <Button type="button" - variant="default" + color="edr-green" radius="md" - leftSection={<ChevronLeft size={16} />} - onClick={() => goToStep(-1)} - disabled={isFirstStep} + leftSection={<Receipt size={16} />} + onClick={handleReview} > - Back + Review price & book </Button> - {!isLastStep ? ( - <Button - type="button" - color="edr-green" - radius="md" - rightSection={<ChevronRight size={16} />} - onClick={handleContinue} - > - Continue - </Button> - ) : ( - <Button - type="button" - color="edr-green" - radius="md" - leftSection={<Send size={16} />} - onClick={handleSubmit} - loading={submitMutation.isPending} - > - Submit booking - </Button> - )} </Group> </Box> </form> + + <PriceConfirmModal + contract={contract} + values={pendingValues} + loading={submitMutation.isPending} + onConfirm={handleConfirm} + onReject={handleReject} + /> </Box> ); } +function PriceConfirmModal({ + contract, + values, + loading, + onConfirm, + onReject, +}: { + contract: Freight.IContract; + values: ShipmentFormValues | null; + loading: boolean; + onConfirm: () => void; + onReject: () => void; +}) { + const total = useMemo( + () => (values ? computeShipmentTotal(contract, values) : null), + [contract, values], + ); + + return ( + <Modal + opened={Boolean(values)} + onClose={onReject} + closeOnClickOutside={!loading} + closeOnEscape={!loading} + withCloseButton={!loading} + centered + radius="lg" + size="lg" + title={ + <Group gap={10}> + <ThemeIcon variant="light" color="edr-green" radius="md" size={34}> + <Receipt size={18} /> + </ThemeIcon> + <Box> + <Text fw={800} fz={16} c="#10202F"> + Confirm shipment price + </Text> + <Text fz="xs" c="dimmed"> + Review the total before booking this shipment. + </Text> + </Box> + </Group> + } + > + {total ? ( + <Stack gap="md"> + <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"> + <Box style={{ minWidth: 0 }}> + <Text fz="sm" c="#10202F" fw={500}> + {line.label} + </Text> + <Text fz="xs" c="dimmed"> + {line.quantity.toLocaleString()} ×{" "} + {line.unitPrice.toLocaleString()} {total.currency} ·{" "} + {formatRateUnit(line.unit)} + </Text> + </Box> + <Text + fz="sm" + fw={600} + c="#10202F" + style={{ whiteSpace: "nowrap" }} + > + {line.amount.toLocaleString()} {total.currency} + </Text> + </Group> + ))} + {total.lines.length === 0 && ( + <Text fz="sm" c="dimmed"> + No priced lines — check the cargo details. + </Text> + )} + </Stack> + <Divider my="md" /> + <Group justify="space-between" align="flex-end"> + <Text + fz="xs" + fw={700} + tt="uppercase" + c="edr-green" + style={{ letterSpacing: "0.06em" }} + > + Total + </Text> + <Text fw={800} fz={28} c="#10202F"> + {total.total.toLocaleString()}{" "} + <Text span fz={16} fw={700} c="edr-muted"> + {total.currency} + </Text> + </Text> + </Group> + </Paper> + + <Group justify="space-between" mt="xs"> + <Button + variant="default" + radius="md" + leftSection={<X size={16} />} + onClick={onReject} + disabled={loading} + > + Reject & edit + </Button> + <Button + color="edr-green" + radius="md" + leftSection={<CheckCircle2 size={16} />} + onClick={onConfirm} + loading={loading} + > + Confirm & book + </Button> + </Group> + </Stack> + ) : null} + </Modal> + ); +} + function RouteStep({ form, contract, @@ -374,8 +489,7 @@ function ScheduleStep({ routes: Freight.IContractRoute[]; }) { const contractRouteId = form.watch("contractRouteId"); - const route = - routes.find((r) => r.id === contractRouteId) ?? routes[0]; + const route = routes.find((r) => r.id === contractRouteId) ?? routes[0]; return ( <StepCard> <StepHeader @@ -541,12 +655,31 @@ function CargoStep({ )} /> )} - {bulkUnitIsItem && null} </Stack> </StepCard> ); } +function NotesSection({ form }: { form: ShipmentForm }) { + return ( + <StepCard> + <Controller + name="notes" + control={form.control} + render={({ field }) => ( + <Textarea + {...field} + label="Additional notes" + placeholder="Any special instructions for this shipment…" + rows={3} + radius="md" + /> + )} + /> + </StepCard> + ); +} + function ContainerLineEditor({ form, index, @@ -638,143 +771,55 @@ function ContainerLineEditor({ <StepLabel>Per-container details</StepLabel> <Stack gap={10} mt={8}> - {Array.from({ length: Math.max(quantity, units.length) }).map( - (_, u) => ( - <Group key={u} gap={10} grow align="flex-start"> - <Controller - name={`containers.${index}.units.${u}.containerNumber`} - control={form.control} - render={({ field, fieldState }) => ( - <TextInput - {...field} - label={u === 0 ? "Container number *" : undefined} - placeholder="e.g. MSCU1234567" - error={fieldState.error?.message} - radius={10} - styles={fieldStyles} - /> - )} - /> - <Controller - name={`containers.${index}.units.${u}.sealNumber`} - control={form.control} - render={({ field }) => ( - <TextInput - {...field} - label={u === 0 ? "Seal number" : undefined} - placeholder="Optional" - radius={10} - styles={fieldStyles} - /> - )} - /> - <Controller - name={`containers.${index}.units.${u}.vgmTons`} - control={form.control} - render={({ field, fieldState }) => ( - <TextInput - {...field} - type="number" - label={u === 0 ? "VGM (tons) *" : undefined} - placeholder="e.g. 24.5" - min={0} - step={0.01} - error={fieldState.error?.message} - radius={10} - styles={fieldStyles} - /> - )} - /> - </Group> - ), - )} + {Array.from({ length: Math.max(quantity, units.length) }).map((_, u) => ( + <Group key={u} gap={10} grow align="flex-start"> + <Controller + name={`containers.${index}.units.${u}.containerNumber`} + control={form.control} + render={({ field, fieldState }) => ( + <TextInput + {...field} + label={u === 0 ? "Container number *" : undefined} + placeholder="e.g. MSCU1234567" + error={fieldState.error?.message} + radius={10} + styles={fieldStyles} + /> + )} + /> + <Controller + name={`containers.${index}.units.${u}.sealNumber`} + control={form.control} + render={({ field }) => ( + <TextInput + {...field} + label={u === 0 ? "Seal number" : undefined} + placeholder="Optional" + radius={10} + styles={fieldStyles} + /> + )} + /> + <Controller + name={`containers.${index}.units.${u}.vgmTons`} + control={form.control} + render={({ field, fieldState }) => ( + <TextInput + {...field} + type="number" + label={u === 0 ? "VGM (tons) *" : undefined} + placeholder="e.g. 24.5" + min={0} + step={0.01} + error={fieldState.error?.message} + radius={10} + styles={fieldStyles} + /> + )} + /> + </Group> + ))} </Stack> </Box> ); } - -function ReviewStep({ - form, - contract, -}: { - form: ShipmentForm; - contract: Freight.IContract; -}) { - const values = form.watch() as ShipmentFormValues; - const total = useMemo( - () => computeShipmentTotal(contract, values), - [contract, values], - ); - - return ( - <Stack gap="lg"> - <StepHeader - icon={<Package size={22} />} - title="Review & Submit" - description="Review your shipment and the computed total before submitting." - /> - <Paper withBorder radius={20} p="lg" style={{ borderColor: "#E6ECF2" }}> - <Text fz={14} fw={800} c="#10202F" mb="sm"> - Estimated total - </Text> - <Stack gap={10}> - {total.lines.map((line, i) => ( - <Group key={i} justify="space-between" wrap="nowrap" gap="sm"> - <Box style={{ minWidth: 0 }}> - <Text fz="sm" c="#10202F" fw={500}> - {line.label} - </Text> - <Text fz="xs" c="dimmed"> - {line.quantity.toLocaleString()} ×{" "} - {line.unitPrice.toLocaleString()} {total.currency} ·{" "} - {formatRateUnit(line.unit)} - </Text> - </Box> - <Text fz="sm" fw={600} c="#10202F" style={{ whiteSpace: "nowrap" }}> - {line.amount.toLocaleString()} {total.currency} - </Text> - </Group> - ))} - {total.lines.length === 0 && ( - <Text fz="sm" c="dimmed"> - Enter cargo details to see the computed total. - </Text> - )} - </Stack> - <Box - mt="md" - p="md" - style={{ - borderRadius: 14, - background: "linear-gradient(135deg, #F4FBF7 0%, #FFFFFF 70%)", - border: "1px solid #E6ECF2", - }} - > - <Text fz="xs" fw={700} tt="uppercase" c="edr-green" style={{ letterSpacing: "0.06em" }}> - Total - </Text> - <Text fw={800} fz={28} c="#10202F" mt={4}> - {total.total.toLocaleString()}{" "} - <Text span fz={16} fw={700} c="edr-muted"> - {total.currency} - </Text> - </Text> - </Box> - </Paper> - - <Controller - name="notes" - control={form.control} - render={({ field }) => ( - <Textarea - {...field} - label="Additional notes" - placeholder="Any special instructions for this shipment…" - rows={3} - radius="md" - /> - )} - /> - </Stack> - ); -}