mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 05:18:11 +00:00
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:
@@ -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;
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user