Merge pull request #1000 from Tria-plc/freight_feature/usermanagement

New Transit Agents admin table (name, valid-from/to, active/suspended…
This commit is contained in:
marshal
2026-07-29 08:46:14 +03:00
committed by GitHub
59 changed files with 2903 additions and 451 deletions

View File

@@ -1,12 +1,15 @@
import type { ReactNode } from "react";
import { Badge, Stack, Tabs, Text } from "@mantine/core";
import { AlertTriangle, FileText, ShieldAlert } from "lucide-react";
import { AlertTriangle, FileText, Share2, ShieldAlert } from "lucide-react";
import type { Freight } from "@edr/types";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { AssignRiskCard } from "@/components/contracts/gl-actions/AssignRiskCard";
import { IncidentReportCard } from "@/components/contracts/gl-actions/IncidentReportCard";
import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel";
import { GlExchangePanel } from "@/components/contracts/GlExchangePanel";
export interface ClearanceOpsTabsProps {
bookingId: string | undefined;
@@ -17,6 +20,12 @@ export interface ClearanceOpsTabsProps {
/** Phased customs workflow files — enables the Uploaded documents tab. */
workflowFiles?: Freight.ClearanceWorkflowFile[];
showWorkflowFilesTab?: boolean;
/**
* Booking or contract id whose GL Ethiopia ↔ GL Djibouti document exchange
* belongs on this page. Undefined hides the tab; it is also hidden from staff
* who hold neither desk's clearance-actions permission.
*/
exchangeEntityId?: string;
tradeDirection?: string;
onViewFile?: (file: { name: string; url: string }) => void;
onDownloadFile?: (file: { id: string; name: string }) => void;
@@ -40,6 +49,7 @@ export function ClearanceOpsTabs({
clearanceTab,
workflowFiles = [],
showWorkflowFilesTab = false,
exchangeEntityId,
tradeDirection = "IMPORT",
onViewFile,
onDownloadFile,
@@ -53,7 +63,12 @@ export function ClearanceOpsTabs({
return true;
}).length;
const showDocuments = showWorkflowFilesTab && Boolean(onViewFile);
const hasTabs = (showOpsTabs && hasOps) || showDocuments;
const { user } = useAuth();
const showExchange =
Boolean(exchangeEntityId) &&
(hasPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions) ||
hasPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions));
const hasTabs = (showOpsTabs && hasOps) || showDocuments || showExchange;
if (!hasTabs) {
return <>{clearanceTab}</>;
@@ -78,6 +93,11 @@ export function ClearanceOpsTabs({
Uploaded documents
</Tabs.Tab>
) : null}
{showExchange ? (
<Tabs.Tab value="exchange" leftSection={<Share2 size={14} />}>
Document exchange
</Tabs.Tab>
) : null}
{showOpsTabs && riskMs ? (
<Tabs.Tab value="risk" leftSection={<ShieldAlert size={14} />}>
Risk assignment
@@ -103,6 +123,12 @@ export function ClearanceOpsTabs({
</Tabs.Panel>
) : null}
{showExchange ? (
<Tabs.Panel value="exchange">
<GlExchangePanel entityId={exchangeEntityId!} />
</Tabs.Panel>
) : null}
{showOpsTabs && riskMs && bookingId ? (
<Tabs.Panel value="risk">
<SectionCard icon={ShieldAlert} title="Customs risk" accent="edr-green">

View File

@@ -21,12 +21,14 @@ const CATEGORY_LABELS: Record<
string
> = {
declaration: "Declaration",
draft_declaration: "Draft declaration",
duty: "Duty & taxes",
transit: "Transit",
djibouti: "Djibouti",
};
const CATEGORY_ORDER: Freight.ClearanceWorkflowFileCategory[] = [
"draft_declaration",
"declaration",
"duty",
"transit",

View File

@@ -20,6 +20,7 @@ import {
CheckCircle2,
FileText,
PackageCheck,
PackageOpen,
Receipt,
Ship,
Train,
@@ -31,6 +32,7 @@ import toast from "react-hot-toast";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { PhasedFileDropzone } from "@/components/contracts/PhasedFileDropzone";
import { TransitAssigneePanel } from "@/components/contracts/TransitAssigneePanel";
import {
TransitPermitMultiUpload,
type TransitPermitUploadedRow,
@@ -41,9 +43,11 @@ import {
} from "@/components/contracts/PhasedUploadedFileRow";
import {
DeclarationStep,
OffloadStep,
StepStatus,
isBookingMilestoneDone,
isMilestoneDone,
offloadSummary,
type ClearanceViewLike,
type MilestoneRow,
} from "@/components/contracts/PhasedClearanceActionPanel";
@@ -59,7 +63,8 @@ function todayISODate(): string {
/**
* Export customs flow, ordered per the stakeholder process:
* customer docs → RO (DJ) → declaration (ET, auto-releases) → create booking (ET)
* customer docs → transit assignee (DJ names officer) → declaration (ET,
* releases the export) → RO (DJ, auto-releases) → create booking (ET)
* → payment + wagons → transport document / T1 (ET) → train to Djibouti
* → accept T1 (DJ, one button after arrival) → gate pass (DJ)
* → final invoice (DJ) + customer slip + GL confirm.
@@ -71,9 +76,10 @@ export function computeExportActiveStep(
): number {
const released = Boolean(clearance.bookingReady || clearance.operationReady);
if (!isMilestoneDone(clearance.milestones, "DOCUMENTS_APPROVED")) return 0;
if (!isMilestoneDone(clearance.milestones, "RELEASE_ORDER_SECURED")) return 1;
if (!isMilestoneDone(clearance.milestones, "DECLARED") || !released) return 2;
if (!bookingCreated) return 3;
if (!clearance.transitAssignee?.name) return 1;
if (!isMilestoneDone(clearance.milestones, "DECLARED")) return 2;
if (!isMilestoneDone(clearance.milestones, "RELEASE_ORDER_SECURED") || !released) return 3;
if (!bookingCreated) return 4;
if (
!isBookingMilestoneDone(bookingMilestones, "FREIGHT_PAYMENT_SETTLED") ||
!(
@@ -81,14 +87,17 @@ export function computeExportActiveStep(
clearance.train?.wagonAllocated
)
) {
return 4;
return 5;
}
if (!isBookingMilestoneDone(bookingMilestones, "EXPORT_TRANSPORT_ISSUED")) return 5;
if (!clearance.train?.arrivedAt) return 6;
if (!clearance.t1Closed) return 7;
if (!clearance.gatepassGranted) return 8;
if (clearance.finalInvoice?.status !== "PAID") return 9;
return 10;
if (!isBookingMilestoneDone(bookingMilestones, "EXPORT_TRANSPORT_ISSUED")) return 6;
if (!clearance.train?.arrivedAt) return 7;
if (!clearance.t1Closed) return 8;
if (!clearance.gatepassGranted) return 9;
// Step 10 is the read-only Offload step. It never gates the flow: the final
// invoice may be raised on a secured gate pass alone, so parking the stepper
// there would hide the invoice actions whenever operations lag on the offload.
if (clearance.finalInvoice?.status !== "PAID") return 11;
return 12;
}
export function exportTransitFilesFromWorkflow(
@@ -168,6 +177,7 @@ export function ExportClearanceStepper({
isBookingMilestoneDone(bookingMilestones, "WAGON_ALLOCATED") ||
Boolean(clearance.train?.wagonAllocated);
const transportIssued = isBookingMilestoneDone(bookingMilestones, "EXPORT_TRANSPORT_ISSUED");
const offloadDone = clearance.offload?.offloaded ?? Boolean(clearance.offloaded);
return (
<Stack gap="md">
@@ -213,6 +223,58 @@ export function ExportClearanceStepper({
/>
</Stepper.Step>
<Stepper.Step
label="Request transit assignee"
description="Ask GL Djibouti to name the officer handling this shipment"
icon={
clearance.transitAssignee?.name ? (
<CheckCircle2 size={14} />
) : undefined
}
>
{showEt && canEt ? (
<TransitAssigneePanel
entityId={entityId}
isBooking={isBooking}
transitAssignee={clearance.transitAssignee}
side="ET"
onChanged={onChanged}
/>
) : (
<StepStatus
done={Boolean(clearance.transitAssignee?.name)}
pendingLabel="Waiting for GL Ethiopia to request a transit assignee from GL Djibouti."
doneLabel={`Transit assignee: ${clearance.transitAssignee?.name ?? ""}`}
/>
)}
</Stepper.Step>
<Stepper.Step
label="Customs declaration"
description="GL Ethiopia uploads — releases the export"
icon={declared ? <CheckCircle2 size={14} /> : <FileText size={14} />}
>
{showEt && canEt && !effectiveBookingCreated && (activeStep >= 2 || declared) ? (
<Stack gap="sm">
<DeclarationStep
entityId={entityId}
isBooking={isBooking}
replaceMode={declared}
workflowFiles={workflowFiles}
onChanged={onChanged}
onViewFile={onViewFile}
onDownloadFile={onDownloadFile}
/>
</Stack>
) : (
<StepStatus
done={declared}
pendingLabel="Waiting for GL Ethiopia to upload the customs declaration."
doneLabel="Declaration uploaded."
/>
)}
</Stepper.Step>
<Stepper.Step
label="Release Order"
description="GL Djibouti uploads RO + vessel date"
@@ -255,31 +317,13 @@ export function ExportClearanceStepper({
</Text>
) : null}
<StepStatus
done={isMilestoneDone(clearance.milestones, "RELEASE_ORDER_SECURED")}
done={isMilestoneDone(clearance.milestones, "RELEASE_ORDER_SECURED") && released}
pendingLabel="Waiting for GL Djibouti to upload the Release Order."
doneLabel="Release Order secured."
doneLabel="Release Order secured — export released."
/>
</Stack>
)}
</Stepper.Step>
<Stepper.Step
label="Customs declaration"
description="GL Ethiopia uploads — releases the export"
icon={declared ? <CheckCircle2 size={14} /> : <FileText size={14} />}
>
{showEt && canEt && !effectiveBookingCreated && (activeStep >= 2 || declared) ? (
<Stack gap="sm">
<DeclarationStep
entityId={entityId}
isBooking={isBooking}
replaceMode={declared}
workflowFiles={workflowFiles}
onChanged={onChanged}
onViewFile={onViewFile}
onDownloadFile={onDownloadFile}
/>
{declared && !released ? (
{/* RO is secured but the auto-release never fired (legacy in-flight
contracts from before the RO step auto-released). */}
{isMilestoneDone(clearance.milestones, "RELEASE_ORDER_SECURED") && !released ? (
<ConfirmExportReleaseFallback
entityId={entityId}
isBooking={isBooking}
@@ -287,12 +331,6 @@ export function ExportClearanceStepper({
/>
) : null}
</Stack>
) : (
<StepStatus
done={declared && released}
pendingLabel="Waiting for GL Ethiopia to upload the customs declaration."
doneLabel="Declaration uploaded — export released."
/>
)}
</Stepper.Step>
@@ -432,6 +470,21 @@ export function ExportClearanceStepper({
<GatepassStep clearance={clearance} />
</Stepper.Step>
{/* Read-only: operations record the offload when the train is unloaded
at the Djibouti port. Stats ride in the description so they stay
visible after the flow moves on to the final invoice. */}
<Stepper.Step
label="Offload"
description={offloadSummary(clearance.offload, Boolean(clearance.offloaded))}
color={offloadDone ? undefined : "gray"}
icon={<PackageOpen size={14} />}
completedIcon={
offloadDone ? <CheckCircle2 size={14} /> : <PackageOpen size={14} />
}
>
<OffloadStep clearance={clearance} />
</Stepper.Step>
<Stepper.Step
label="Final invoice & payment"
description="GL Djibouti invoices after offload; customer pays"
@@ -656,6 +709,8 @@ function FinalInvoiceStep({
const invoice = clearance.finalInvoice ?? null;
const paid = invoice?.status === "PAID";
// Raised as a draft — the customer approves it before paying.
const approved = Boolean(invoice?.approvedAt);
// Export: OFFLOADED is a DJ doc milestone that may never be recorded, so the
// secured gate pass is enough to open invoicing. Sending an invoice is optional.
@@ -684,7 +739,7 @@ function FinalInvoiceStep({
</Text>
</div>
<Badge color={paid ? "edr-green" : "yellow"} variant="light">
{invoice.status}
{approved ? invoice.status : "AWAITING CUSTOMER APPROVAL"}
</Badge>
</Group>
</Paper>
@@ -722,9 +777,11 @@ function FinalInvoiceStep({
<StepStatus
done={false}
pendingLabel={
invoice.slipFile
? "Payment slip attached — confirm to settle the invoice."
: "Waiting for the customer to pay and attach the payment slip."
!approved
? "Waiting for the customer to review and approve the invoice."
: invoice.slipFile
? "Payment slip attached — confirm to settle the invoice."
: "Waiting for the customer to pay and attach the payment slip."
}
doneLabel=""
/>
@@ -754,6 +811,7 @@ function FinalInvoiceStep({
<>
<Text size="sm" c="dimmed">
Send the final invoice to the customer if post-arrival charges apply (optional).
The customer approves it before paying.
</Text>
<Button
color="edr-green"

View File

@@ -0,0 +1,479 @@
import { useMemo, useState } from "react";
import {
ActionIcon,
Badge,
Box,
Button,
Group,
Loader,
Menu,
Modal,
Paper,
Stack,
Switch,
Text,
TextInput,
ThemeIcon,
Tooltip,
} from "@mantine/core";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Download,
Eye,
EyeOff,
FileText,
MoreVertical,
Pencil,
Share2,
Trash2,
Upload,
UserCheck,
} from "lucide-react";
import dayjs from "dayjs";
import toast from "react-hot-toast";
import type { Freight } from "@edr/types";
import { isViewable } from "@edr/ui-common";
import { PhasedFileDropzone } from "@/components/contracts/PhasedFileDropzone";
import { useFileViewer } from "@/hooks/useFileViewer";
import { downloadBookingFile, fetchViewableFile } from "@/services/files.service";
import { glExchangeService } from "@/services/glExchange.service";
const SIDES: Record<Freight.GlExchangeDocument["side"], { label: string; color: string }> =
{
ET: { label: "GL Ethiopia", color: "edr-green" },
DJ: { label: "GL Djibouti", color: "blue" },
};
function formatBytes(bytes: number): string {
if (!bytes) return "0 B";
const units = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${parseFloat((bytes / 1024 ** i).toFixed(1))} ${units[i]}`;
}
export interface GlExchangePanelProps {
/** Booking or contract id both desks are working on — the thread key. */
entityId: string;
}
/**
* GL Ethiopia ↔ GL Djibouti document exchange. Either desk attaches any file
* under a title of its own choosing; both desks see the whole thread, only the
* uploader can change or remove what they posted, and each document is shared
* with the customer's portal or kept between the desks.
*/
export function GlExchangePanel({ entityId }: GlExchangePanelProps) {
const queryClient = useQueryClient();
const { view, viewer } = useFileViewer();
const [formDoc, setFormDoc] = useState<
Freight.GlExchangeDocument | "new" | null
>(null);
const [pendingDelete, setPendingDelete] =
useState<Freight.GlExchangeDocument | null>(null);
const {
data: documents = [],
isLoading,
isError,
} = useQuery({
queryKey: ["gl-exchange", entityId],
queryFn: () => glExchangeService.list(entityId),
enabled: Boolean(entityId),
});
const invalidate = () =>
queryClient.invalidateQueries({ queryKey: ["gl-exchange", entityId] });
const removeMutation = useMutation({
mutationFn: (id: string) => glExchangeService.remove(id),
onSuccess: async () => {
setPendingDelete(null);
await invalidate();
toast.success("Document removed");
},
onError: (e: unknown) =>
toast.error(e instanceof Error ? e.message : "Could not remove document"),
});
const stats = useMemo(
() => ({
et: documents.filter((d) => d.side === "ET").length,
dj: documents.filter((d) => d.side === "DJ").length,
shared: documents.filter((d) => d.visibleToCustomer).length,
}),
[documents],
);
return (
<Stack gap="md">
<Paper withBorder radius="md" p="md">
<Group justify="space-between" align="flex-start" wrap="wrap" gap="sm">
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon variant="light" color="edr-green" radius="md" size={44}>
<Share2 size={20} />
</ThemeIcon>
<Box style={{ minWidth: 0 }}>
<Text fw={700} fz={16}>
Document exchange
</Text>
<Text size="xs" c="dimmed">
Share any document with the other Global Logistics desk. Both
desks see everything here; only the uploader can edit or remove
a document, and only documents marked visible reach the customer.
</Text>
</Box>
</Group>
<Button
color="edr-green"
radius="md"
leftSection={<Upload size={16} />}
onClick={() => setFormDoc("new")}
>
Share document
</Button>
</Group>
{documents.length > 0 ? (
<Group gap={8} mt="md">
<Badge variant="light" color="edr-green" radius="sm" tt="none">
{stats.et} from GL Ethiopia
</Badge>
<Badge variant="light" color="blue" radius="sm" tt="none">
{stats.dj} from GL Djibouti
</Badge>
<Badge variant="light" color="gray" radius="sm" tt="none">
{stats.shared} visible to customer
</Badge>
</Group>
) : null}
</Paper>
{isLoading ? (
<Group justify="center" py={40} gap={10}>
<Loader size="sm" color="edr-green" />
<Text size="sm" c="dimmed">
Loading shared documents
</Text>
</Group>
) : isError ? (
<Text size="sm" c="red">
Could not load the shared documents.
</Text>
) : documents.length === 0 ? (
<EmptyState onShare={() => setFormDoc("new")} />
) : (
<Stack gap={8}>
{documents.map((doc) => (
<DocumentRow
key={doc.id}
doc={doc}
onView={view}
onEdit={() => setFormDoc(doc)}
onDelete={() => setPendingDelete(doc)}
/>
))}
</Stack>
)}
<DocumentFormModal
entityId={entityId}
doc={formDoc === "new" ? null : formDoc}
opened={formDoc != null}
onClose={() => setFormDoc(null)}
onSaved={() => {
setFormDoc(null);
void invalidate();
}}
/>
<Modal
opened={pendingDelete != null}
onClose={() => setPendingDelete(null)}
title={<Text fw={700}>Remove shared document</Text>}
radius="md"
size="sm"
>
<Stack gap="md">
<Text size="sm">
Remove <b>{pendingDelete?.title}</b> from the exchange? The other
desk and the customer, if it was shared will no longer see it.
</Text>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={() => setPendingDelete(null)}>
Cancel
</Button>
<Button
color="red"
loading={removeMutation.isPending}
leftSection={<Trash2 size={15} />}
onClick={() => removeMutation.mutate(pendingDelete!.id)}
>
Remove
</Button>
</Group>
</Stack>
</Modal>
{viewer}
</Stack>
);
}
function EmptyState({ onShare }: { onShare: () => void }) {
return (
<Box
py={44}
style={{
borderRadius: 12,
border: "1px dashed var(--mantine-color-gray-4)",
background: "var(--mantine-color-gray-0)",
textAlign: "center",
}}
>
<Stack gap={10} align="center">
<ThemeIcon variant="light" color="gray" radius="xl" size={48}>
<FileText size={22} />
</ThemeIcon>
<Text size="sm" c="dimmed" maw={380}>
Nothing shared yet. Anything either desk uploads here scans,
correspondence, corrected forms is visible to the other side
immediately.
</Text>
<Button
variant="light"
color="edr-green"
radius="md"
leftSection={<Upload size={15} />}
onClick={onShare}
>
Share the first document
</Button>
</Stack>
</Box>
);
}
function DocumentRow({
doc,
onView,
onEdit,
onDelete,
}: {
doc: Freight.GlExchangeDocument;
onView: (file: { name: string; url: string }) => void;
onEdit: () => void;
onDelete: () => void;
}) {
const side = SIDES[doc.side];
const canPreview = isViewable({ name: doc.file.name, url: "" });
return (
<Paper withBorder radius="md" p="sm">
<Group justify="space-between" wrap="nowrap" align="flex-start" gap="sm">
<Group gap={12} wrap="nowrap" align="flex-start" style={{ minWidth: 0 }}>
<ThemeIcon variant="light" color={side.color} radius="md" size={40}>
<FileText size={18} />
</ThemeIcon>
<Box style={{ minWidth: 0 }}>
<Group gap={8} wrap="nowrap">
<Text size="sm" fw={700} truncate>
{doc.title}
</Text>
<Badge size="xs" variant="light" color={side.color} radius="sm" tt="none">
{side.label}
</Badge>
<Badge
size="xs"
variant="light"
color={doc.visibleToCustomer ? "teal" : "gray"}
radius="sm"
tt="none"
leftSection={
doc.visibleToCustomer ? <Eye size={11} /> : <EyeOff size={11} />
}
>
{doc.visibleToCustomer ? "Visible to customer" : "GL only"}
</Badge>
</Group>
<Text size="xs" c="dimmed" mt={4} truncate>
{doc.file.name} · {formatBytes(doc.file.size)} ·{" "}
{doc.uploadedByName ?? "Global Logistics"} ·{" "}
{dayjs(doc.uploadedAt).format("D MMM YYYY, HH:mm")}
</Text>
</Box>
</Group>
<Group gap={6} wrap="nowrap">
{canPreview ? (
<Tooltip label="Preview">
<Button
size="compact-xs"
variant="default"
radius="md"
leftSection={<Eye size={13} />}
onClick={() =>
void fetchViewableFile(doc.file.id, doc.file.name).then(onView)
}
>
View
</Button>
</Tooltip>
) : null}
<Tooltip label="Download">
<Button
size="compact-xs"
variant="light"
radius="md"
leftSection={<Download size={13} />}
onClick={() =>
void downloadBookingFile(doc.file.id, doc.file.name)
}
>
Download
</Button>
</Tooltip>
{doc.canEdit ? (
<Menu position="bottom-end" radius="md" withinPortal>
<Menu.Target>
<ActionIcon variant="subtle" color="gray" aria-label="Document actions">
<MoreVertical size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item leftSection={<Pencil size={14} />} onClick={onEdit}>
Edit title, visibility or file
</Menu.Item>
<Menu.Item
color="red"
leftSection={<Trash2 size={14} />}
onClick={onDelete}
>
Remove
</Menu.Item>
</Menu.Dropdown>
</Menu>
) : (
<Tooltip label={`Only ${doc.uploadedByName ?? "the uploader"} can edit this`}>
<ThemeIcon variant="subtle" color="gray" size={28}>
<UserCheck size={15} />
</ThemeIcon>
</Tooltip>
)}
</Group>
</Group>
</Paper>
);
}
function DocumentFormModal({
entityId,
doc,
opened,
onClose,
onSaved,
}: {
entityId: string;
doc: Freight.GlExchangeDocument | null;
opened: boolean;
onClose: () => void;
onSaved: () => void;
}) {
const editing = doc != null;
const [title, setTitle] = useState("");
const [visible, setVisible] = useState(false);
const [file, setFile] = useState<File | null>(null);
// Re-seed the form whenever a different document (or "new") opens it.
const [seededFor, setSeededFor] = useState<string | null>(null);
const seedKey = opened ? (doc?.id ?? "new") : null;
if (seedKey !== seededFor) {
setSeededFor(seedKey);
setTitle(doc?.title ?? "");
setVisible(doc?.visibleToCustomer ?? false);
setFile(null);
}
const save = useMutation({
mutationFn: () =>
editing
? glExchangeService.update(doc.id, {
title: title.trim(),
visibleToCustomer: visible,
file,
})
: glExchangeService.upload(entityId, {
title: title.trim(),
visibleToCustomer: visible,
file: file!,
}),
onSuccess: () => {
toast.success(editing ? "Document updated" : "Document shared");
onSaved();
},
onError: (e: unknown) =>
toast.error(e instanceof Error ? e.message : "Could not save document"),
});
return (
<Modal
opened={opened}
onClose={onClose}
title={
<Group gap={8}>
<Share2 size={18} />
<Text fw={700}>{editing ? "Edit shared document" : "Share a document"}</Text>
</Group>
}
radius="md"
size="md"
>
<Stack gap="md">
<TextInput
label="Document title"
placeholder="e.g. Corrected packing list for container TCLU1234567"
description="What the other desk (and the customer, if shared) will see."
value={title}
onChange={(e) => setTitle(e.currentTarget.value)}
maxLength={300}
required
/>
<PhasedFileDropzone
label={editing ? "Replacement file (optional)" : "File"}
description={
editing
? "Leave empty to keep the current file."
: "Any document type — PDF, image, spreadsheet."
}
accept="*/*"
value={file}
onChange={setFile}
replaceMode={editing}
/>
<Switch
checked={visible}
onChange={(e) => setVisible(e.currentTarget.checked)}
color="edr-green"
label="Visible to the customer"
description="Shows in the customer's booking documents. Off keeps it between the two GL desks."
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={onClose} disabled={save.isPending}>
Cancel
</Button>
<Button
color="edr-green"
loading={save.isPending}
disabled={!title.trim() || (!editing && !file)}
leftSection={<Upload size={16} />}
onClick={() => save.mutate()}
>
{editing ? "Save changes" : "Share document"}
</Button>
</Group>
</Stack>
</Modal>
);
}

View File

@@ -27,6 +27,7 @@ import {
FileText,
MessageSquareWarning,
PackageCheck,
PackageOpen,
Receipt,
ShieldAlert,
Ship,
@@ -61,7 +62,8 @@ export type ClearanceViewLike = Pick<
| "nextAction"
| "dutyRequired"
| "dutyAdvice"
| "dutyDispute"
| "draftDeclaration"
| "draftDeclarationChangeRequest"
| "transitAssignee"
| "roHold"
| "roHoldReason"
@@ -77,6 +79,7 @@ export type ClearanceViewLike = Pick<
| "t1Closed"
| "t1ClosedAt"
| "offloaded"
| "offload"
| "finalInvoice"
| "vesselDepartureDate"
| "vesselArrivalDate"
@@ -113,32 +116,49 @@ function computeImportActiveStep(
bookingMilestones: MilestoneRow[],
t1Uploaded: boolean,
freightPaid: boolean,
isBooking: boolean,
): number {
if (!isMilestoneDone(clearance.milestones, "DOCUMENTS_APPROVED")) return 0;
if (!isMilestoneDone(clearance.milestones, "DECLARED")) return 1;
if (!clearance.transitAssignee?.name) return 1;
// Draft declaration is a booking-only step — the customer only ever reviews
// it on the booking-scoped portal page, so it never applies (and never
// gates) on the contract-scoped pre-booking page. Also a backward-compat
// guard: a booking that already has a real declaration filed got there
// before this step existed — never send it backward for a draft it was
// never asked to send.
if (
isBooking &&
!isMilestoneDone(clearance.milestones, "DRAFT_DECLARATION_ACCEPTED") &&
!isMilestoneDone(clearance.milestones, "DECLARED")
) {
return 2;
}
if (!isMilestoneDone(clearance.milestones, "DECLARED")) return 3;
if (
clearance.dutyRequired === null ||
clearance.dutyRequired === undefined ||
(clearance.dutyRequired && !isMilestoneDone(clearance.milestones, "DUTY_TAXES_ADVISED"))
) {
return 2;
return 4;
}
if (
clearance.dutyRequired &&
!isMilestoneDone(clearance.milestones, "DUTY_TAX_PAID")
) {
return 3;
return 5;
}
if (!isMilestoneDone(clearance.milestones, "TRANSIT_PERMIT_UPLOADED")) return 4;
if (!clearance.preClearanceFinalized) return 5;
if (!isMilestoneDone(clearance.milestones, "DO_COLLECTED")) return 6;
if (!bookingCreated) return 7;
if (!isMilestoneDone(clearance.milestones, "TRANSIT_PERMIT_UPLOADED")) return 6;
if (!clearance.preClearanceFinalized) return 7;
if (!isMilestoneDone(clearance.milestones, "DO_COLLECTED")) return 8;
if (!bookingCreated) return 9;
// The customer pays the train/freight charges on the booking. Until that
// settles the gate pass is not granted for this booking, so the flow stops here.
if (!freightPaid) return 8;
if (!clearance.gatepassGranted) return 9;
if (!t1Uploaded && !clearance.t1?.closed) return 10;
if (!clearance.t1?.closed) return 11;
if (!freightPaid) return 10;
if (!clearance.gatepassGranted) return 11;
// Step 12 is the read-only Offload step — cargo comes off the train at
// arrival, i.e. AFTER the T1 steps below, so it never gates the flow.
if (!t1Uploaded && !clearance.t1?.closed) return 13;
if (!clearance.t1?.closed) return 14;
// Risk is "assigned" when the booking milestone says so OR the clearance view
// already carries a riskLevel. The ET page derives its bookingMilestones from a
// separately-fetched booking id that can lag or mismatch the booking carrying
@@ -146,15 +166,15 @@ function computeImportActiveStep(
const riskAssigned =
Boolean(clearance.riskLevel) ||
isBookingMilestoneDone(bookingMilestones, "RISK_ASSIGNED");
if (!riskAssigned) return 12;
if (!riskAssigned) return 15;
// Additional duty round is optional — resolved once skipped or paid.
const secondDutyResolved =
clearance.secondDuty?.skipped ||
clearance.secondDuty?.paid ||
isBookingMilestoneDone(bookingMilestones, "SECOND_DUTY_PAID");
if (!secondDutyResolved) return 13;
if (!clearance.importReleaseGranted) return 14;
return 15;
if (!secondDutyResolved) return 16;
if (!clearance.importReleaseGranted) return 17;
return 18;
}
function t1FilesFromWorkflow(
@@ -267,6 +287,7 @@ export function PhasedClearanceActionPanel({
const freightPaid =
isBookingMilestoneDone(bookingMilestones, "FREIGHT_PAYMENT_SETTLED") ||
Boolean(clearance.gatepassGranted);
const offloadDone = clearance.offload?.offloaded ?? Boolean(clearance.offloaded);
const activeStep = useMemo(
() =>
isImport
@@ -276,6 +297,7 @@ export function PhasedClearanceActionPanel({
bookingMilestones,
t1Uploaded,
freightPaid,
isBooking,
)
: 0,
[
@@ -333,6 +355,76 @@ export function PhasedClearanceActionPanel({
/>
</Stepper.Step>
<Stepper.Step
label="Request transit assignee"
description="Ask GL Djibouti to name the officer handling this shipment"
icon={
clearance.transitAssignee?.name ? (
<CheckCircle2 size={14} />
) : undefined
}
>
{showEt && canEt ? (
<TransitAssigneePanel
entityId={entityId}
isBooking={isBooking}
transitAssignee={clearance.transitAssignee}
side="ET"
onChanged={onChanged}
/>
) : (
<StepStatus
done={Boolean(clearance.transitAssignee?.name)}
pendingLabel="Waiting for GL Ethiopia to request a transit assignee from GL Djibouti."
doneLabel={`Transit assignee: ${clearance.transitAssignee?.name ?? ""}`}
/>
)}
</Stepper.Step>
<Stepper.Step
label="Draft declaration"
description="Send the customer a draft declaration with an estimated price"
icon={
isMilestoneDone(clearance.milestones, "DRAFT_DECLARATION_ACCEPTED") ? (
<CheckCircle2 size={14} />
) : (
<FileText size={14} />
)
}
>
{showEt && canEt && activeStep === 2 ? (
<DraftDeclarationStep
bookingId={entityId}
clearance={clearance}
onChanged={onChanged}
onViewFile={onViewFile}
onDownloadFile={onDownloadFile}
/>
) : (
<Stack gap="sm">
{(clearance.draftDeclaration?.files ?? []).map((file, index) => (
<PhasedUploadedFileRow
key={file.id}
label={`Draft declaration document ${index + 1}`}
file={file}
onView={onViewFile}
onDownload={onDownloadFile}
compact
/>
))}
<StepStatus
done={isMilestoneDone(clearance.milestones, "DRAFT_DECLARATION_ACCEPTED")}
pendingLabel={
clearance.draftDeclaration
? "Waiting for the customer to accept the draft declaration."
: "Send the customer a draft declaration to review."
}
doneLabel="Draft declaration accepted by the customer."
/>
</Stack>
)}
</Stepper.Step>
<Stepper.Step
label="Customs declaration"
description="Upload declaration documents"
@@ -344,24 +436,10 @@ export function PhasedClearanceActionPanel({
)
}
>
{/* Djibouti must name the transit officer first — the declaration
is filed against whoever handles the shipment there, and the
API refuses the upload until the name is in. */}
{showEt &&
canEt &&
!clearance.transitAssignee?.name &&
!isMilestoneDone(clearance.milestones, "DECLARED") ? (
<TransitAssigneePanel
entityId={entityId}
isBooking={isBooking}
transitAssignee={clearance.transitAssignee}
side="ET"
onChanged={onChanged}
/>
) : showEt &&
canEt &&
!clearance.bookingReady &&
(activeStep >= 1 ||
(activeStep >= 3 ||
isMilestoneDone(clearance.milestones, "DECLARED")) ? (
<DeclarationStep
entityId={entityId}
@@ -398,7 +476,7 @@ export function PhasedClearanceActionPanel({
description="Advise amount and attach notice"
icon={<Receipt size={14} />}
>
{showEt && canEt && activeStep === 2 ? (
{showEt && canEt && activeStep === 3 ? (
<DutyStep
entityId={entityId}
isBooking={isBooking}
@@ -460,7 +538,7 @@ export function PhasedClearanceActionPanel({
{showEt &&
canEt &&
!bookingCreated &&
(activeStep >= 4 ||
(activeStep >= 5 ||
isMilestoneDone(clearance.milestones, "TRANSIT_PERMIT_UPLOADED")) ? (
<TransitPermitStep
entityId={entityId}
@@ -504,7 +582,7 @@ export function PhasedClearanceActionPanel({
description="Hand off to GL Djibouti"
icon={<PackageCheck size={14} />}
>
{showEt && canEt && activeStep === 5 ? (
{showEt && canEt && activeStep === 6 ? (
<FinalizePreClearanceStep
entityId={entityId}
isBooking={isBooking}
@@ -624,6 +702,22 @@ export function PhasedClearanceActionPanel({
<ImportGatepassStep clearance={clearance} freightPaid={freightPaid} />
</Stepper.Step>
{/* Read-only: offload is recorded by operations when the train
reaches the destination, which happens after the T1 steps — so
it never holds the active pointer, and its icon stays neutral
until it actually happens. */}
<Stepper.Step
label="Offload"
description={offloadSummary(clearance.offload, Boolean(clearance.offloaded))}
color={offloadDone ? undefined : "gray"}
icon={<PackageOpen size={14} />}
completedIcon={
offloadDone ? <CheckCircle2 size={14} /> : <PackageOpen size={14} />
}
>
<OffloadStep clearance={clearance} />
</Stepper.Step>
<Stepper.Step
label="T1 transport documents"
description="GL Djibouti uploads after the gate pass is secured"
@@ -1049,6 +1143,91 @@ function ImportGatepassStep({
);
}
/**
* Compact offload line for the step's description row — the only part of a
* Mantine step that stays visible once the flow has moved past it.
*/
export function offloadSummary(
offload: ClearanceViewLike["offload"],
offloaded: boolean,
): string {
if (!offload?.offloaded && !offloaded) {
return "Cargo comes off the train at its destination";
}
const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`;
const bits = [
offload?.containers ? plural(offload.containers, "container") : null,
offload?.wagons ? plural(offload.wagons, "wagon") : null,
offload?.weightTons ? `${offload.weightTons.toLocaleString()} t` : null,
offload?.destination ?? null,
].filter(Boolean);
return bits.length ? bits.join(" · ") : "Offloaded";
}
/**
* Offload stats for the booking, read-only. Recorded by the warehouse
* auto-unload that runs when the train reaches the booking's destination —
* nothing here is actioned from clearance.
*/
export function OffloadStep({
clearance,
}: {
clearance: ClearanceViewLike;
}) {
const offload = clearance.offload ?? null;
const done = offload?.offloaded ?? Boolean(clearance.offloaded);
if (!done) {
return (
<StepStatus
done={false}
pendingLabel="Waiting for the cargo to be offloaded at its destination (recorded by operations on arrival)."
doneLabel=""
/>
);
}
const stats: Array<[string, string]> = [
["Containers", offload?.containers ? String(offload.containers) : "—"],
["Wagons", offload?.wagons ? String(offload.wagons) : "—"],
[
"Weight",
offload?.weightTons ? `${offload.weightTons.toLocaleString()} t` : "—",
],
["Destination", offload?.destination ?? "—"],
["GRN", offload?.grnNumber ?? "—"],
["Location", offload?.location ?? "—"],
];
return (
<Paper withBorder radius="md" p="sm" bg="var(--mantine-color-edr-green-0)">
<Group gap="xs" wrap="nowrap" mb="xs">
<Badge color="edr-green" variant="light" leftSection={<CheckCircle2 size={12} />}>
Offloaded
</Badge>
<Text size="sm" c="dimmed">
{offload?.offloadedAt
? new Date(offload.offloadedAt).toLocaleString()
: "Recorded on arrival"}
{offload?.inventoryStatus ? ` · ${offload.inventoryStatus}` : ""}
</Text>
</Group>
<Group gap="lg" wrap="wrap">
{stats.map(([label, value]) => (
<Stack key={label} gap={0}>
<Text size="xs" c="dimmed">
{label}
</Text>
<Text size="sm" fw={600}>
{value}
</Text>
</Stack>
))}
</Group>
</Paper>
);
}
const RISK_LEVEL_COLOR: Record<string, string> = {
GREEN: "green",
YELLOW: "yellow",
@@ -1569,6 +1748,145 @@ export function DeclarationStep({
);
}
/**
* GL Ethiopia sends a draft customs declaration (estimated price + files) for
* the customer to review in the portal before the real declaration is filed.
* Booking-only — the customer only ever sees this on the booking-scoped page.
*/
function DraftDeclarationStep({
bookingId,
clearance,
onChanged,
onViewFile,
onDownloadFile,
}: {
bookingId: string;
clearance: ClearanceViewLike;
onChanged?: () => void;
onViewFile?: (file: { name: string; url: string }) => void;
onDownloadFile?: (file: { id: string; name: string }) => void;
}) {
const [files, setFiles] = useState<File[]>([]);
const [price, setPrice] = useState<number | string>(
clearance.draftDeclaration?.price ?? "",
);
const [currency, setCurrency] = useState(clearance.draftDeclaration?.currency ?? "ETB");
const [loading, setLoading] = useState(false);
const changeRequest = clearance.draftDeclarationChangeRequest;
const existingFiles = clearance.draftDeclaration?.files ?? [];
const replaceMode = existingFiles.length > 0;
return (
<Stack gap="md">
{/* The customer sent this draft back — their words drive the
correction, so they lead the step. */}
{changeRequest ? (
<Alert
color="orange"
radius="md"
icon={<MessageSquareWarning size={16} />}
title={
changeRequest.rounds > 1
? `Customer requested a change (round ${changeRequest.rounds})`
: "Customer requested a change"
}
>
<Stack gap={4}>
<Text size="sm" style={{ whiteSpace: "pre-wrap" }}>
{changeRequest.note}
</Text>
<Text size="xs" c="dimmed">
Raised {new Date(changeRequest.raisedAt).toLocaleString()} send a
corrected draft below.
</Text>
</Stack>
</Alert>
) : null}
{existingFiles.length > 0 ? (
<Stack gap={8}>
<Text size="xs" fw={700} c="dimmed" tt="uppercase">
Current draft
</Text>
{existingFiles.map((file, index) => (
<PhasedUploadedFileRow
key={file.id}
label={`Draft declaration document ${index + 1}`}
file={file}
onView={onViewFile}
onDownload={onDownloadFile}
/>
))}
</Stack>
) : null}
<Paper withBorder radius="md" p="md" bg="var(--mantine-color-gray-0)">
<Stack gap="md">
<Group grow align="flex-start">
<NumberInput
label="Estimated price"
placeholder="0.00"
value={price}
onChange={setPrice}
min={0}
size="sm"
thousandSeparator=","
/>
<Select
label="Currency"
data={["ETB", "USD"]}
value={currency}
onChange={(v) => setCurrency(v ?? "ETB")}
size="sm"
/>
</Group>
<PhasedMultiFileDropzone
label="Draft declaration documents"
description={
replaceMode
? "Replace the draft — upload one or more corrected documents."
: "Upload one or more draft declaration documents (PDF or image)."
}
value={files}
onChange={setFiles}
replaceMode={replaceMode}
disabled={loading}
/>
</Stack>
</Paper>
<Button
color="edr-green"
loading={loading}
disabled={files.length === 0 || price === ""}
leftSection={<Upload size={16} />}
fullWidth
onClick={async () => {
setLoading(true);
try {
await bookingsService.uploadDraftDeclaration(
bookingId,
files,
Number(price),
currency,
);
setFiles([]);
toast.success(replaceMode ? "Corrected draft sent" : "Draft sent to customer");
onChanged?.();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Upload failed");
} finally {
setLoading(false);
}
}}
>
{replaceMode ? "Send corrected draft" : "Send draft to customer"}
</Button>
</Stack>
);
}
function DutyStep({
entityId,
isBooking,
@@ -1597,35 +1915,9 @@ function DutyStep({
const noticeFile = findWorkflowFile(workflowFiles, "duty_tax_notice");
const hasExistingNotice = Boolean(noticeFile);
const dispute = clearance.dutyDispute;
return (
<Stack gap="md">
{/* The customer rejected the last advice — their words drive the
correction, so they lead the step. */}
{dispute ? (
<Alert
color="orange"
radius="md"
icon={<MessageSquareWarning size={16} />}
title={
dispute.rounds > 1
? `Customer asked for a correction (round ${dispute.rounds})`
: "Customer asked for a correction"
}
>
<Stack gap={4}>
<Text size="sm" style={{ whiteSpace: "pre-wrap" }}>
{dispute.note}
</Text>
<Text size="xs" c="dimmed">
Raised {new Date(dispute.raisedAt).toLocaleString()} re-advise
below to send a corrected notice.
</Text>
</Stack>
</Alert>
) : null}
{noticeFile ? (
<Stack gap={8}>
<Text size="xs" fw={700} c="dimmed" tt="uppercase">

View File

@@ -5,18 +5,19 @@ import {
Button,
Group,
Paper,
Select,
Stack,
Text,
TextInput,
Textarea,
} from "@mantine/core";
import { useMutation } from "@tanstack/react-query";
import { useMutation, useQuery } from "@tanstack/react-query";
import { CheckCircle2, Clock, Send, UserCheck } from "lucide-react";
import { useState } from "react";
import toast from "react-hot-toast";
import { bookingsService } from "@/services/bookings.service";
import { contractsService } from "@/services/contracts.service";
import { transitAgentsService } from "@/services/transit-agents.service";
export interface TransitAssigneePanelProps {
/** Booking id when `isBooking`, contract id otherwise. */
@@ -62,10 +63,20 @@ export function TransitAssigneePanel({
onChanged,
}: TransitAssigneePanelProps) {
const [note, setNote] = useState("");
const [assignee, setAssignee] = useState(transitAssignee?.name ?? "");
const [transitAgentId, setTransitAgentId] = useState<string | null>(null);
const [changing, setChanging] = useState(false);
const service = isBooking ? bookingsService : contractsService;
const { data: assignableAgents, isLoading: loadingAgents } = useQuery({
queryKey: ["transit-agents", "assignable"],
queryFn: () => transitAgentsService.listAssignable(),
enabled: side === "DJ",
});
const agentOptions = (assignableAgents ?? []).map((a) => ({
value: a.id,
label: a.name,
}));
const request = useMutation({
mutationFn: async () => {
await service.requestTransitAssignee(entityId, note.trim());
@@ -79,7 +90,8 @@ export function TransitAssigneePanel({
const assign = useMutation({
mutationFn: async () => {
await service.assignTransitAssignee(entityId, assignee.trim());
if (!transitAgentId) return;
await service.assignTransitAssignee(entityId, transitAgentId);
},
onSuccess: () => {
toast.success("Transit assignee sent to GL Ethiopia");
@@ -114,7 +126,7 @@ export function TransitAssigneePanel({
variant="light"
radius="md"
onClick={() => {
setAssignee(transitAssignee!.name ?? "");
setTransitAgentId(null);
setChanging(true);
}}
>
@@ -152,13 +164,16 @@ export function TransitAssigneePanel({
GL Ethiopia: {transitAssignee.requestNote}
</Text>
) : null}
<TextInput
<Select
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}
description="Active, currently-valid transit agents only — configure the roster in Transit Agents settings"
placeholder={loadingAgents ? "Loading…" : "Select transit officer"}
data={agentOptions}
value={transitAgentId}
onChange={setTransitAgentId}
searchable
disabled={readOnly || loadingAgents}
nothingFoundMessage="No active, valid transit agents — add one in Transit Agents settings"
/>
<Group justify="flex-end" gap="sm">
{changing ? (
@@ -171,7 +186,7 @@ export function TransitAssigneePanel({
radius="md"
leftSection={<Send size={15} />}
loading={assign.isPending}
disabled={readOnly || !assignee.trim()}
disabled={readOnly || !transitAgentId}
onClick={() => assign.mutate()}
>
Send assignment

View File

@@ -66,6 +66,19 @@ export const formatCell = (value: unknown, format?: ColumnFormat): ReactNode =>
);
}
if (format === "validityBadge") {
const status = String(value);
const label =
status === "VALID" ? "Valid" : status === "EXPIRED" ? "Expired" : "Not started";
const color =
status === "VALID" ? "edr-green" : status === "EXPIRED" ? "red" : "yellow";
return (
<Badge color={color} variant="filled" size="sm" radius="md">
{label}
</Badge>
);
}
if (format === "code") {
return (
<Badge

View File

@@ -142,7 +142,7 @@ export const TrainConsistView = ({
weightMax={trainSet?.locomotive?.maxPullWeightTons ?? null}
lengthUsed={lengthUsed}
lengthMax={trainSet?.locomotive?.maxTrainLengthMeters ?? null}
wagonCount={wagons.length}
wagonCount={loadedCount}
wagonMax={maxWagons}
/>