Files
edr-platform/apps/edr-freight-web/backoffice/src/components/contracts/TransitAssigneePanel.tsx

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;