mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
247 lines
7.1 KiB
TypeScript
247 lines
7.1 KiB
TypeScript
import type { Freight } from "@edr/types";
|
|
import {
|
|
Alert,
|
|
Badge,
|
|
Button,
|
|
Group,
|
|
Paper,
|
|
Stack,
|
|
Text,
|
|
TextInput,
|
|
Textarea,
|
|
} from "@mantine/core";
|
|
import { useMutation } from "@tanstack/react-query";
|
|
import { CheckCircle2, Clock, Send, UserCheck } from "lucide-react";
|
|
import { useState } from "react";
|
|
import toast from "react-hot-toast";
|
|
|
|
import { contractsService } from "@/services/contracts.service";
|
|
|
|
export interface TransitAssigneePanelProps {
|
|
contractId: string;
|
|
transitAssignee: Freight.ContractClearanceView["transitAssignee"];
|
|
/**
|
|
* ET asks and waits; DJ answers with a name. The same state renders from both
|
|
* desks — only the action on offer differs.
|
|
*/
|
|
side: "ET" | "DJ";
|
|
/** Hide the action (read-only audit view, or the user lacks the permission). */
|
|
readOnly?: boolean;
|
|
onChanged?: () => void;
|
|
}
|
|
|
|
const fmt = (iso?: string | null) =>
|
|
iso
|
|
? new Date(iso).toLocaleString("en-GB", {
|
|
day: "numeric",
|
|
month: "short",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
hour12: false,
|
|
})
|
|
: "—";
|
|
|
|
/**
|
|
* The transit-assignee handshake that gates the customs declaration.
|
|
*
|
|
* GL Ethiopia cannot file a declaration until Djibouti says who will handle the
|
|
* shipment on their side, so ET raises the ask here and Djibouti answers with a
|
|
* name (free text — the officer is not a platform user). Djibouti can send a
|
|
* different name later; the newest one wins and Ethiopia is notified again.
|
|
*/
|
|
export function TransitAssigneePanel({
|
|
contractId,
|
|
transitAssignee,
|
|
side,
|
|
readOnly = false,
|
|
onChanged,
|
|
}: TransitAssigneePanelProps) {
|
|
const [note, setNote] = useState("");
|
|
const [assignee, setAssignee] = useState(transitAssignee?.name ?? "");
|
|
const [changing, setChanging] = useState(false);
|
|
|
|
const request = useMutation({
|
|
mutationFn: () => contractsService.requestTransitAssignee(contractId, note.trim()),
|
|
onSuccess: () => {
|
|
toast.success("Request sent to GL Djibouti");
|
|
setNote("");
|
|
onChanged?.();
|
|
},
|
|
});
|
|
|
|
const assign = useMutation({
|
|
mutationFn: () =>
|
|
contractsService.assignTransitAssignee(contractId, assignee.trim()),
|
|
onSuccess: () => {
|
|
toast.success("Transit assignee sent to GL Ethiopia");
|
|
setChanging(false);
|
|
onChanged?.();
|
|
},
|
|
});
|
|
|
|
const requested = Boolean(transitAssignee?.requestedAt);
|
|
const assigned = Boolean(transitAssignee?.name);
|
|
|
|
// Settled state — both desks see the same line; DJ keeps a way to change it.
|
|
if (assigned && !changing) {
|
|
return (
|
|
<Alert
|
|
color="edr-green"
|
|
radius="md"
|
|
icon={<CheckCircle2 size={16} />}
|
|
title="Transit assignee confirmed"
|
|
>
|
|
<Group justify="space-between" wrap="wrap" gap="sm">
|
|
<Text size="sm">
|
|
<Text span fw={700}>
|
|
{transitAssignee!.name}
|
|
</Text>{" "}
|
|
will handle this shipment in transit · assigned{" "}
|
|
{fmt(transitAssignee!.assignedAt)}
|
|
</Text>
|
|
{side === "DJ" && !readOnly ? (
|
|
<Button
|
|
size="compact-xs"
|
|
variant="light"
|
|
radius="md"
|
|
onClick={() => {
|
|
setAssignee(transitAssignee!.name ?? "");
|
|
setChanging(true);
|
|
}}
|
|
>
|
|
Change
|
|
</Button>
|
|
) : null}
|
|
</Group>
|
|
</Alert>
|
|
);
|
|
}
|
|
|
|
// Djibouti's desk: answer the ask.
|
|
if (side === "DJ") {
|
|
if (!requested) {
|
|
return (
|
|
<Alert color="gray" radius="md" icon={<Clock size={16} />}>
|
|
GL Ethiopia has not requested a transit assignee for this clearance yet.
|
|
</Alert>
|
|
);
|
|
}
|
|
return (
|
|
<Paper withBorder radius="md" p="md">
|
|
<Stack gap="sm">
|
|
<Group gap={8}>
|
|
<UserCheck size={16} />
|
|
<Text fw={700} size="sm">
|
|
{changing ? "Change the transit assignee" : "Assign a transit officer"}
|
|
</Text>
|
|
<Badge size="xs" color="orange" variant="light">
|
|
Requested {fmt(transitAssignee?.requestedAt)}
|
|
</Badge>
|
|
</Group>
|
|
{transitAssignee?.requestNote ? (
|
|
<Text size="sm" c="dimmed" style={{ whiteSpace: "pre-wrap" }}>
|
|
GL Ethiopia: {transitAssignee.requestNote}
|
|
</Text>
|
|
) : null}
|
|
<TextInput
|
|
label="Transit officer"
|
|
description="Name of the person handling this shipment in Djibouti"
|
|
placeholder="e.g. Ahmed Bourhan"
|
|
value={assignee}
|
|
onChange={(e) => setAssignee(e.currentTarget.value)}
|
|
disabled={readOnly}
|
|
/>
|
|
<Group justify="flex-end" gap="sm">
|
|
{changing ? (
|
|
<Button variant="default" radius="md" onClick={() => setChanging(false)}>
|
|
Cancel
|
|
</Button>
|
|
) : null}
|
|
<Button
|
|
color="edr-green"
|
|
radius="md"
|
|
leftSection={<Send size={15} />}
|
|
loading={assign.isPending}
|
|
disabled={readOnly || !assignee.trim()}
|
|
onClick={() => assign.mutate()}
|
|
>
|
|
Send assignment
|
|
</Button>
|
|
</Group>
|
|
</Stack>
|
|
</Paper>
|
|
);
|
|
}
|
|
|
|
// Ethiopia's desk: raise the ask, then wait.
|
|
if (requested) {
|
|
return (
|
|
<Alert
|
|
color="orange"
|
|
radius="md"
|
|
icon={<Clock size={16} />}
|
|
title="Waiting for GL Djibouti"
|
|
>
|
|
<Stack gap="sm" align="flex-start">
|
|
<Text size="sm">
|
|
Requested {fmt(transitAssignee?.requestedAt)}. The customs declaration
|
|
opens once Djibouti names the transit officer.
|
|
</Text>
|
|
{!readOnly ? (
|
|
<Button
|
|
size="compact-xs"
|
|
variant="light"
|
|
color="orange"
|
|
radius="md"
|
|
loading={request.isPending}
|
|
onClick={() => request.mutate()}
|
|
>
|
|
Send a reminder
|
|
</Button>
|
|
) : null}
|
|
</Stack>
|
|
</Alert>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Paper withBorder radius="md" p="md">
|
|
<Stack gap="sm">
|
|
<Group gap={8}>
|
|
<UserCheck size={16} />
|
|
<Text fw={700} size="sm">
|
|
Request a transit assignee
|
|
</Text>
|
|
</Group>
|
|
<Text size="xs" c="dimmed">
|
|
GL Djibouti must name the officer handling this shipment in transit
|
|
before the customs declaration can be filed.
|
|
</Text>
|
|
<Textarea
|
|
label="Note for GL Djibouti (optional)"
|
|
placeholder="Anything they need to know to pick the right officer"
|
|
value={note}
|
|
onChange={(e) => setNote(e.currentTarget.value)}
|
|
autosize
|
|
minRows={2}
|
|
disabled={readOnly}
|
|
/>
|
|
<Group justify="flex-end">
|
|
<Button
|
|
color="edr-green"
|
|
radius="md"
|
|
leftSection={<Send size={15} />}
|
|
loading={request.isPending}
|
|
disabled={readOnly}
|
|
onClick={() => request.mutate()}
|
|
>
|
|
Request assignee
|
|
</Button>
|
|
</Group>
|
|
</Stack>
|
|
</Paper>
|
|
);
|
|
}
|
|
|
|
export default TransitAssigneePanel;
|