mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 02:58:11 +00:00
add GL operations for customs risk assignment, duty advising, and incident reporting
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user