add GL operations for customs risk assignment, duty advising, and incident reporting

This commit is contained in:
Marshal
2026-06-28 16:09:45 +00:00
parent 7c744352d1
commit e1d54746c2
31 changed files with 1879 additions and 29 deletions

View File

@@ -0,0 +1,70 @@
import type { ReactNode } from "react";
import { Badge, Box, Group, Stack, Text, ThemeIcon } from "@mantine/core";
import { Check, type LucideIcon } from "lucide-react";
export interface ActionShellProps {
icon: LucideIcon;
title: string;
subtitle?: string;
/** When true the action is already done — children are hidden, a done badge shows. */
done?: boolean;
doneLabel?: ReactNode;
children: ReactNode;
}
/**
* Consistent container for one GL action card: icon, title, and either the
* input controls (pending) or a completed badge (done). Keeps every GL action
* visually uniform inside {@link GlActionsPanel}.
*/
export function ActionShell({
icon: Icon,
title,
subtitle,
done,
doneLabel,
children,
}: ActionShellProps) {
return (
<Box
p="md"
style={{
border: "1px solid var(--mantine-color-gray-2)",
borderRadius: 12,
}}
>
<Group justify="space-between" align="flex-start" wrap="nowrap" mb="sm">
<Group gap="sm" wrap="nowrap">
<ThemeIcon variant="light" color="edr-green" radius="md" size={32}>
<Icon size={16} />
</ThemeIcon>
<Stack gap={0}>
<Text size="sm" fw={600}>
{title}
</Text>
{subtitle ? (
<Text size="xs" c="dimmed">
{subtitle}
</Text>
) : null}
</Stack>
</Group>
{done ? (
typeof doneLabel === "string" || !doneLabel ? (
<Badge
color="edr-green"
variant="light"
radius="sm"
leftSection={<Check size={12} />}
>
{doneLabel ?? "Done"}
</Badge>
) : (
doneLabel
)
) : null}
</Group>
{!done ? children : null}
</Box>
);
}

View File

@@ -0,0 +1,94 @@
import { useState } from "react";
import {
Button,
Group,
NumberInput,
Select,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { Receipt } from "lucide-react";
import type { Freight } from "@edr/types";
import { useAdviseDuty } from "@/hooks/contracts/useContracts";
import { ActionShell } from "./ActionShell";
export function AdviseDutyCard({
bookingId,
milestone,
}: {
bookingId: string;
milestone: Freight.IClearanceMilestone;
}) {
const advise = useAdviseDuty(bookingId);
const [amount, setAmount] = useState<number | string>("");
const [currency, setCurrency] = useState("ETB");
const [serial, setSerial] = useState("");
const done = milestone.status === "COMPLETED";
const meta = milestone.metadata;
return (
<ActionShell
icon={Receipt}
title="Duty & tax"
subtitle="Advise the duty/tax amount and declaration serial."
done={done}
doneLabel={
meta?.dutyAmount != null
? `${meta.dutyAmount.toLocaleString()} ${meta.dutyCurrency ?? ""}`
: "Advised"
}
>
<Stack gap="sm">
<Group grow>
<NumberInput
label="Amount"
placeholder="0.00"
value={amount}
onChange={setAmount}
min={0}
thousandSeparator=","
size="sm"
/>
<Select
label="Currency"
data={["ETB", "USD"]}
value={currency}
onChange={(v) => setCurrency(v ?? "ETB")}
size="sm"
allowDeselect={false}
/>
</Group>
<TextInput
label="Declaration serial"
placeholder="e.g. IM4-2026-00123"
value={serial}
onChange={(e) => setSerial(e.currentTarget.value)}
size="sm"
/>
<Group justify="space-between">
<Text size="xs" c="dimmed">
Customer uploads the payment slip after being advised.
</Text>
<Button
size="compact-sm"
color="edr-green"
loading={advise.isPending}
disabled={!amount || Number(amount) <= 0}
onClick={() =>
advise.mutate({
amount: Number(amount),
currency,
declarationSerial: serial.trim() || undefined,
})
}
>
Advise customer
</Button>
</Group>
</Stack>
</ActionShell>
);
}

View File

@@ -0,0 +1,71 @@
import { useState } from "react";
import { Badge, Box, Button, Group, SegmentedControl, Text } from "@mantine/core";
import { ShieldAlert } from "lucide-react";
import type { Freight } from "@edr/types";
import { useAssignRisk } from "@/hooks/contracts/useContracts";
import { ActionShell } from "./ActionShell";
const RISK_COLOR: Record<Freight.CustomsRiskLevel, string> = {
GREEN: "green",
YELLOW: "yellow",
RED: "red",
};
export function AssignRiskCard({
bookingId,
milestone,
}: {
bookingId: string;
milestone: Freight.IClearanceMilestone;
}) {
const assign = useAssignRisk(bookingId);
const [level, setLevel] = useState<Freight.CustomsRiskLevel>("GREEN");
const assigned = milestone.status === "COMPLETED";
const current = milestone.metadata?.riskLevel;
return (
<ActionShell
icon={ShieldAlert}
title="Customs risk"
subtitle="Assign the customs examination risk level."
done={assigned}
doneLabel={
current ? (
<Badge color={RISK_COLOR[current]} variant="filled" radius="sm">
{current}
</Badge>
) : (
"Assigned"
)
}
>
<Box>
<SegmentedControl
fullWidth
value={level}
onChange={(v) => setLevel(v as Freight.CustomsRiskLevel)}
data={[
{ label: "Green", value: "GREEN" },
{ label: "Yellow", value: "YELLOW" },
{ label: "Red", value: "RED" },
]}
/>
<Group justify="space-between" mt="sm">
<Text size="xs" c="dimmed">
Customer is notified of the assigned risk.
</Text>
<Button
size="compact-sm"
color="edr-green"
loading={assign.isPending}
onClick={() => assign.mutate({ riskLevel: level })}
>
Assign risk
</Button>
</Group>
</Box>
</ActionShell>
);
}

View File

@@ -0,0 +1,53 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Button, Group, Select, Text } from "@mantine/core";
import { MapPin } from "lucide-react";
import { api } from "@/services/api";
import { useAssignStation } from "@/hooks/contracts/useContracts";
import { ActionShell } from "./ActionShell";
/**
* Routes the shipment to an origin station (GL US-02). Binding a staff user is
* optional here — the station manager can assign one later.
*/
export function AssignStationCard({ bookingId }: { bookingId: string }) {
const { data: yards = [] } = useQuery(api.routes.yards.queryOptions());
const assign = useAssignStation(bookingId);
const [stationYardId, setStationYardId] = useState<string | null>(null);
return (
<ActionShell
icon={MapPin}
title="Station routing"
subtitle="Route this shipment to the handling station."
>
<Group align="flex-end" wrap="nowrap" gap="sm">
<Select
flex={1}
label="Station"
placeholder="Select station"
searchable
data={yards.map((y) => ({ value: y.id, label: y.label }))}
value={stationYardId}
onChange={setStationYardId}
size="sm"
/>
<Button
size="compact-sm"
color="edr-green"
loading={assign.isPending}
disabled={!stationYardId}
onClick={() =>
stationYardId && assign.mutate({ stationYardId })
}
>
Route
</Button>
</Group>
<Text size="xs" c="dimmed" mt={6}>
The shipment moves to the selected station's queue.
</Text>
</ActionShell>
);
}

View File

@@ -0,0 +1,67 @@
import { useMemo } from "react";
import { Stack, Text } from "@mantine/core";
import type { Freight } from "@edr/types";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { Flag } from "lucide-react";
import { AssignStationCard } from "./AssignStationCard";
import { AssignRiskCard } from "./AssignRiskCard";
import { AdviseDutyCard } from "./AdviseDutyCard";
import { GlDocumentUploadCard } from "./GlDocumentUploadCard";
import { IncidentReportCard } from "./IncidentReportCard";
export interface GlActionsPanelProps {
bookingId: string;
milestones: Freight.IClearanceMilestone[];
}
/** Find a milestone by code (post-booking milestones live on the booking). */
function findMilestone(
milestones: Freight.IClearanceMilestone[],
code: string,
): Freight.IClearanceMilestone | undefined {
return milestones.find((m) => m.milestoneCode === code);
}
/**
* Global Logistics action surface for a shipment. Each card is gated by whether
* its milestone exists on this shipment (import vs export differ) and renders the
* structured action (risk level, duty advice, document upload, incident report,
* station routing) that the plain "Complete" button can't capture.
*/
export function GlActionsPanel({ bookingId, milestones }: GlActionsPanelProps) {
const riskMs = useMemo(
() => findMilestone(milestones, "RISK_ASSIGNED"),
[milestones],
);
const dutyMs = useMemo(
() => findMilestone(milestones, "DUTY_TAXES_ADVISED"),
[milestones],
);
return (
<SectionCard icon={Flag} title="Global Logistics actions" accent="edr-green">
<Stack gap="md">
<Text size="xs" c="dimmed">
Structured GL operations for this shipment. Uploading a document
advances its milestone automatically.
</Text>
<AssignStationCard bookingId={bookingId} />
{dutyMs ? (
<AdviseDutyCard bookingId={bookingId} milestone={dutyMs} />
) : null}
<GlDocumentUploadCard bookingId={bookingId} />
{riskMs ? (
<AssignRiskCard bookingId={bookingId} milestone={riskMs} />
) : null}
<IncidentReportCard bookingId={bookingId} />
</Stack>
</SectionCard>
);
}

View File

@@ -0,0 +1,80 @@
import { useState } from "react";
import { Button, FileButton, Group, Select, Stack, Text } from "@mantine/core";
import { FileUp, Upload } from "lucide-react";
import { useUploadGlDocuments } from "@/hooks/contracts/useContracts";
import { ActionShell } from "./ActionShell";
/**
* GL post-booking document slots. The fieldname (value) maps server-side to a
* doc-triggered milestone in gl-operations.service.ts — uploading auto-advances
* the matching milestone.
*/
const GL_DOC_SLOTS = [
{ value: "delivery_order", label: "Delivery Order (DO)" },
{ value: "release_order", label: "Release Order (RO)" },
{ value: "t1_transport_document", label: "T1 Transport Document" },
{ value: "import_release", label: "Import Release" },
{ value: "full_in_interchange", label: "Full-in Interchange" },
{ value: "final_declaration", label: "Final Declaration" },
];
export function GlDocumentUploadCard({ bookingId }: { bookingId: string }) {
const upload = useUploadGlDocuments(bookingId);
const [slot, setSlot] = useState<string | null>(GL_DOC_SLOTS[0].value);
const [file, setFile] = useState<File | null>(null);
const submit = () => {
if (!slot || !file) return;
upload.mutate({ [slot]: file });
setFile(null);
};
return (
<ActionShell
icon={FileUp}
title="GL documents"
subtitle="Upload DO, RO, T1, release, interchange — advances milestones."
>
<Stack gap="sm">
<Select
label="Document type"
data={GL_DOC_SLOTS}
value={slot}
onChange={setSlot}
size="sm"
allowDeselect={false}
/>
<Group justify="space-between" wrap="nowrap">
<FileButton onChange={setFile} accept="application/pdf,image/*">
{(props) => (
<Button
{...props}
variant="light"
color="gray"
size="compact-sm"
leftSection={<Upload size={14} />}
>
{file ? file.name : "Choose file"}
</Button>
)}
</FileButton>
<Button
size="compact-sm"
color="edr-green"
loading={upload.isPending}
disabled={!file || !slot}
onClick={submit}
>
Upload
</Button>
</Group>
{!file ? (
<Text size="xs" c="dimmed">
PDF or image. The matching milestone completes on upload.
</Text>
) : null}
</Stack>
</ActionShell>
);
}

View File

@@ -0,0 +1,121 @@
import { useState } from "react";
import {
Badge,
Button,
FileButton,
Group,
Select,
Stack,
Text,
Textarea,
} from "@mantine/core";
import { AlertTriangle, ImagePlus } from "lucide-react";
import type { Freight } from "@edr/types";
import {
useBookingIncidents,
useReportIncident,
} from "@/hooks/contracts/useContracts";
import { ActionShell } from "./ActionShell";
const INCIDENT_OPTIONS: { value: Freight.IncidentType; label: string }[] = [
{ value: "SEAL_BROKEN", label: "Seal is broken" },
{ value: "CONTAINER_OPENED", label: "Container opened" },
{ value: "CONTAINER_DAMAGED", label: "Container damaged" },
{ value: "FLUID_LEAKING", label: "Fluid leaking" },
];
const LABEL: Record<Freight.IncidentType, string> = {
SEAL_BROKEN: "Seal broken",
CONTAINER_OPENED: "Container opened",
CONTAINER_DAMAGED: "Container damaged",
FLUID_LEAKING: "Fluid leaking",
};
export function IncidentReportCard({ bookingId }: { bookingId: string }) {
const report = useReportIncident(bookingId);
const { data: incidents } = useBookingIncidents(bookingId);
const [type, setType] = useState<Freight.IncidentType>("SEAL_BROKEN");
const [description, setDescription] = useState("");
const [photos, setPhotos] = useState<File[]>([]);
const submit = () => {
if (!description.trim()) return;
report.mutate(
{ incidentType: type, description: description.trim(), photos },
{
onSuccess: () => {
setDescription("");
setPhotos([]);
},
},
);
};
return (
<ActionShell
icon={AlertTriangle}
title="Cargo exception"
subtitle="Log a damage/anomaly with photo evidence (GL Djibouti)."
>
<Stack gap="sm">
{incidents && incidents.length > 0 ? (
<Stack gap={4}>
{incidents.map((inc) => (
<Group key={inc.id} gap={8} wrap="nowrap">
<Badge color="red" variant="light" radius="sm">
{LABEL[inc.incidentType]}
</Badge>
<Text size="xs" c="dimmed" truncate>
{inc.description}
</Text>
</Group>
))}
</Stack>
) : null}
<Select
label="Incident type"
data={INCIDENT_OPTIONS}
value={type}
onChange={(v) => setType((v as Freight.IncidentType) ?? "SEAL_BROKEN")}
size="sm"
allowDeselect={false}
/>
<Textarea
label="Description"
placeholder="Describe the anomaly…"
value={description}
onChange={(e) => setDescription(e.currentTarget.value)}
autosize
minRows={2}
size="sm"
/>
<Group justify="space-between" wrap="nowrap">
<FileButton onChange={setPhotos} accept="image/jpeg,image/png" multiple>
{(props) => (
<Button
{...props}
variant="light"
color="gray"
size="compact-sm"
leftSection={<ImagePlus size={14} />}
>
{photos.length > 0 ? `${photos.length} photo(s)` : "Add photos"}
</Button>
)}
</FileButton>
<Button
size="compact-sm"
color="red"
loading={report.isPending}
disabled={!description.trim()}
onClick={submit}
>
Report incident
</Button>
</Group>
</Stack>
</ActionShell>
);
}