mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
update navigation paths for contract clearance pages
This commit is contained in:
@@ -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 },
|
||||
]}
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
|
||||
@@ -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 & 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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user