update navigation paths for contract clearance pages

This commit is contained in:
Marshal
2026-06-28 21:15:10 +00:00
parent 01172daaa5
commit 006b4226e8
3 changed files with 258 additions and 213 deletions

View File

@@ -79,11 +79,11 @@ export default function ContractClearanceDetailPage() {
<PageContainer>
<PageHeader
title="Clearance not found"
backTo="/dashboard/clearance"
backTo="/dashboard/contracts/clearance"
breadcrumbs={[
{
label: "Document Clearance",
href: "/dashboard/clearance",
href: "/dashboard/contracts/clearance",
},
{ label: "Not found" },
]}
@@ -100,11 +100,11 @@ export default function ContractClearanceDetailPage() {
<Stack gap="lg">
<PageHeader
title={reference}
backTo="/dashboard/clearance"
backTo="/dashboard/contracts/clearance"
breadcrumbs={[
{
label: "Document Clearance",
href: "/dashboard/clearance",
href: "/dashboard/contracts/clearance",
},
{ label: reference },
]}

View File

@@ -184,7 +184,7 @@ export default function ContractClearanceListPage() {
}, [rows, pagination.pageIndex, pagination.pageSize]);
const openDetail = useCallback(
(id: string) => navigate(`/dashboard/clearance/${id}`),
(id: string) => navigate(`/dashboard/contracts/clearance/${id}`),
[navigate],
);

View File

@@ -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<ShipmentFormValues | null>(
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 (
<Center mih={400} p="xl">
@@ -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",
}}
>
<Group justify="space-between" px="24px" align="flex-end" wrap="wrap" gap="md" mb="lg">
<Group
justify="space-between"
px="24px"
align="flex-end"
wrap="wrap"
gap="md"
mb="lg"
>
<Box>
<Title order={1} fw={800} fz={26} style={{ letterSpacing: "-0.01em" }}>
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 &amp; 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 &amp; edit
</Button>
<Button
color="edr-green"
radius="md"
leftSection={<CheckCircle2 size={16} />}
onClick={onConfirm}
loading={loading}
>
Confirm &amp; 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>
);
}