mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 21:08:12 +00:00
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:
@@ -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">
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user