feat: add hazardous goods declaration feature

- Introduced HazardDeclarationPanel component to display dangerous goods declaration details.
- Updated URL constants to include CLEARANCE_PROCEED endpoint for re-requesting operations.
- Enhanced permissions to include hazardous approval roles for contract approvals.
- Integrated HazardDeclarationPanel into ContractRequestDetailPage and ContractClearanceDetailPage.
- Added proceedToOperation method in bookings service for handling operation re-requests.
- Updated contract forms and schemas to include hazard class and UN number fields.
- Implemented validation for hazardous contracts in the contract creation flow.
- Added expiry notice functionality for contracts nearing validity end.
- Created tests for expiry notice calculations and labels.
- Updated UI components to reflect hazardous cargo information and validation errors.
This commit is contained in:
Marshal
2026-07-25 21:10:09 +00:00
parent fde5e6de4b
commit 9a1c8e5603
41 changed files with 1663 additions and 99 deletions

View File

@@ -0,0 +1,132 @@
import { Alert, Button, Group, Paper, Stack, Text } from "@mantine/core";
import { DateInput } from "@mantine/dates";
import { AlertTriangle, Send } from "lucide-react";
import { useState } from "react";
import { Link } from "react-router-dom";
import toast from "react-hot-toast";
import { bookingsService } from "@/services/bookings.service";
export interface BookingChangesRequestedAlertProps {
bookingId: string;
reference?: string | null;
/** Operations' note — what has to change before this can go back to them. */
note?: string | null;
/** Shipment day the booking currently holds; the resubmit default. */
scheduledDate?: string | null;
/** GL Ethiopia owns customs bookings, so only they get the resubmit control. */
canResubmit: boolean;
onResubmitted?: () => void;
}
/**
* Operations sent a GL-created booking back for changes.
*
* The customer cannot act on this — GL created the booking on their behalf — so
* the note and the way out both live here, on the page GL works from. Resubmit
* re-requests operation on the chosen shipment day; the server re-checks the day
* has a departure that can carry the cargo and refuses with the reason if not.
*/
export function BookingChangesRequestedAlert({
bookingId,
reference,
note,
scheduledDate,
canResubmit,
onResubmitted,
}: BookingChangesRequestedAlertProps) {
const [day, setDay] = useState<Date | null>(
scheduledDate ? new Date(scheduledDate) : null,
);
const [sending, setSending] = useState(false);
const resubmit = async () => {
if (!day) return;
setSending(true);
try {
await bookingsService.proceedToOperation(bookingId, day.toISOString());
toast.success("Sent back to Operations for review");
onResubmitted?.();
} catch {
// The http interceptor already toasts the server's own reason (no
// departure that day, no wagon that can carry the cargo, export train
// full…) — a second toast here would just duplicate it.
} finally {
setSending(false);
}
};
return (
<Alert
color="red"
radius="md"
icon={<AlertTriangle size={16} />}
title={`Operations returned booking ${reference ?? ""} for changes`.trim()}
>
<Stack gap="sm" align="flex-start">
{note ? (
<Paper
withBorder
radius="md"
p="sm"
bg="red.0"
style={{ borderColor: "var(--mantine-color-red-3)", width: "100%" }}
>
<Text size="xs" fw={700} c="red.9" tt="uppercase" mb={4}>
What Operations asked for
</Text>
<Text size="sm" style={{ whiteSpace: "pre-wrap" }}>
{note}
</Text>
</Paper>
) : (
<Text size="sm">
Operations returned this booking without a note contact them for
the detail before resubmitting.
</Text>
)}
<Text size="sm">
This booking was created by GL Ethiopia, so the customer cannot fix it.
Make the correction Operations asked for, then send it back for review.{" "}
<Text
component={Link}
to={`/dashboard/bookings/${bookingId}/clearance`}
inherit
fw={600}
c="red.8"
>
Open the booking
</Text>
</Text>
{canResubmit ? (
<Group gap="sm" align="flex-end" wrap="wrap">
<DateInput
label="Shipment day"
description="Keep the day or pick another with an open departure"
value={day}
onChange={(v) => setDay(v ? new Date(v) : null)}
minDate={new Date()}
size="sm"
w={230}
/>
<Button
color="red"
radius="md"
size="sm"
loading={sending}
disabled={!day}
leftSection={<Send size={15} />}
onClick={() => void resubmit()}
>
Resubmit to Operations
</Button>
</Group>
) : null}
</Stack>
</Alert>
);
}
export default BookingChangesRequestedAlert;

View File

@@ -1,5 +1,5 @@
import { useMemo, useState } from "react";
import { Check, ShieldCheck, X } from "lucide-react";
import { Check, Flame, ShieldCheck, X } from "lucide-react";
import {
Stack,
Group,
@@ -14,10 +14,22 @@ import {
import type { Freight } from "@edr/types";
import { formatContractApprovalProgress } from "@/features/contracts/contract-approval-progress";
import { HazardDeclarationPanel } from "./HazardDeclarationPanel";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import type { useContractMutations } from "@/hooks/contracts/useContracts";
import { useAuth } from "@/auth/useAuth";
import { canApproveContractStep } from "@/lib/permissions";
import {
canApproveContractStep,
CONTRACT_APPROVAL_ROLE_LABELS,
HAZARDOUS_APPROVAL_ROLE_PERMISSION,
} from "@/lib/permissions";
/** Chain roles that exist only because the contract carries dangerous goods. */
const isHazardStep = (requiredRole: string): boolean =>
requiredRole in HAZARDOUS_APPROVAL_ROLE_PERMISSION;
const roleLabel = (requiredRole: string): string =>
CONTRACT_APPROVAL_ROLE_LABELS[requiredRole] ?? requiredRole;
type Mutations = ReturnType<typeof useContractMutations>;
@@ -126,7 +138,7 @@ export function ContractApprovalStepsCard({
const subtitle =
summary.detail ||
(nextPending
? `Next: ${nextPending.requiredRole} · step ${nextPending.stepOrder}`
? `Next: ${roleLabel(nextPending.requiredRole)} · step ${nextPending.stepOrder}`
: steps.length
? "All steps complete"
: "Accept submission to begin");
@@ -196,7 +208,7 @@ export function ContractApprovalStepsCard({
<Text size="sm" c="dimmed">
You are about to approve the{" "}
<Text span fw={600} c="dark">
{pendingStep?.requiredRole}
{roleLabel(pendingStep?.requiredRole ?? "")}
</Text>{" "}
step for contract{" "}
<Text span fw={600} c="dark">
@@ -204,6 +216,9 @@ export function ContractApprovalStepsCard({
</Text>
. This action cannot be undone from this screen.
</Text>
{pendingStep && isHazardStep(pendingStep.requiredRole) && (
<HazardDeclarationPanel contract={contract} />
)}
<Group justify="flex-end" gap="sm">
<Button variant="default" radius="md" onClick={closeApprove}>
Cancel
@@ -241,7 +256,7 @@ export function ContractApprovalStepsCard({
{ value: "CUSTOMER", label: "Customer — must resubmit" },
...returnableSteps.map((s) => ({
value: s.id,
label: `${s.requiredRole} — step ${s.stepOrder} re-approves`,
label: `${roleLabel(s.requiredRole)} — step ${s.stepOrder} re-approves`,
})),
]}
/>
@@ -254,7 +269,7 @@ export function ContractApprovalStepsCard({
</Text>{" "}
will go back to the{" "}
<Text span fw={600} c="dark">
{targetStep?.requiredRole}
{roleLabel(targetStep?.requiredRole ?? "")}
</Text>{" "}
step. That approver fixes the contract and approves again, and
every later step re-approves in order. The customer is not
@@ -264,7 +279,7 @@ export function ContractApprovalStepsCard({
<Text size="sm" c="dimmed">
Rejecting the{" "}
<Text span fw={600} c="dark">
{rejectStepRow?.requiredRole}
{roleLabel(rejectStepRow?.requiredRole ?? "")}
</Text>{" "}
step rejects contract{" "}
<Text span fw={600} c="dark">
@@ -300,7 +315,7 @@ export function ContractApprovalStepsCard({
onClick={runReject}
>
{sendBack
? `Send back to ${targetStep?.requiredRole ?? "step"}`
? `Send back to ${targetStep ? roleLabel(targetStep.requiredRole) : "step"}`
: "Reject contract"}
</Button>
</Group>
@@ -333,6 +348,7 @@ function StepRow({
: isNext
? "edr-green"
: "gray";
const hazard = isHazardStep(step.requiredRole);
return (
<Group
@@ -343,11 +359,19 @@ function StepRow({
py="xs"
style={{
borderRadius: 8,
border: "1px solid var(--mantine-color-gray-2)",
borderLeft: isNext
? "3px solid var(--freight-brand)"
border: hazard
? "1px solid #F3D5D0"
: "1px solid var(--mantine-color-gray-2)",
background: isNext ? "var(--mantine-color-gray-0)" : "white",
borderLeft: isNext
? `3px solid ${hazard ? "#C0392B" : "var(--freight-brand)"}`
: hazard
? "1px solid #F3D5D0"
: "1px solid var(--mantine-color-gray-2)",
background: hazard
? "#FEF7F6"
: isNext
? "var(--mantine-color-gray-0)"
: "white",
}}
>
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
@@ -371,9 +395,23 @@ function StepRow({
{step.stepOrder}
</Box>
<Box style={{ minWidth: 0 }}>
<Text size="sm" fw={600}>
{step.requiredRole}
</Text>
<Group gap={6} wrap="nowrap" align="center">
<Text size="sm" fw={600} truncate>
{roleLabel(step.requiredRole)}
</Text>
{hazard && (
<Badge
color="red"
variant="light"
size="xs"
radius="sm"
leftSection={<Flame size={10} />}
style={{ flexShrink: 0 }}
>
Hazmat
</Badge>
)}
</Group>
{step.note && (
<Text size="xs" c="dimmed" truncate>
{step.note}

View File

@@ -0,0 +1,65 @@
import { Badge, Box, Group, Stack, Text } from "@mantine/core";
import { Flame } from "lucide-react";
import { hazardClassLabel, type Freight } from "@edr/types";
/**
* The contract's dangerous-goods declaration — the UN/ADR class and UN number
* the customer declared alongside the hazard documents. Shown wherever a
* hazardous contract is reviewed: the cargo-scope card and the two hazardous
* approval confirmations, so no one signs off without seeing what is moving.
*/
export function HazardDeclarationPanel({
contract,
}: {
contract: Pick<Freight.IContract, "hazardClass" | "unNumber">;
}) {
const classLabel = hazardClassLabel(contract.hazardClass);
return (
<Group
gap="sm"
align="flex-start"
wrap="nowrap"
px="md"
py="sm"
style={{
borderRadius: 12,
border: "1px solid #F3D5D0",
background: "#FEF7F6",
}}
>
<Box
style={{
width: 34,
height: 34,
borderRadius: 10,
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#FBEAE7",
color: "#C0392B",
}}
>
<Flame size={16} />
</Box>
<Stack gap={6} style={{ minWidth: 0 }}>
<Text size="sm" fw={700}>
Dangerous goods declaration
</Text>
<Group gap={6} wrap="wrap">
<Badge color="red" variant="light" radius="sm" size="sm">
{classLabel ?? "Class not declared"}
</Badge>
<Badge color="red" variant="light" radius="sm" size="sm">
{contract.unNumber ? `UN ${contract.unNumber}` : "UN number not declared"}
</Badge>
</Group>
<Text size="xs" c="dimmed">
Check the declaration against the uploaded hazard documents before
approving.
</Text>
</Stack>
</Group>
);
}