mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-05 04:53:38 +00:00
Enhance clearance milestone management and introduce new contract actions
- Added new milestones for 'Transit Permit Uploaded' and 'Export Transport Document Issued' in the clearance milestone catalog. - Implemented methods in ClearanceMilestoneService to skip milestones and complete them with metadata. - Updated ContractBookingService to check boundary conditions before booking creation. - Introduced new endpoints in ContractsController for uploading customs declarations, advising duty, and handling various document uploads. - Enhanced the UI to support new clearance actions and display relevant components based on milestone statuses.
This commit is contained in:
@@ -4,6 +4,7 @@ import {
|
||||
Container,
|
||||
FileSignature,
|
||||
FileText,
|
||||
Flag,
|
||||
LayoutDashboard,
|
||||
LayoutGrid,
|
||||
Network,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
Send,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
Ship,
|
||||
SlidersHorizontal,
|
||||
Train,
|
||||
Truck,
|
||||
@@ -35,6 +37,9 @@ import ContractRequestDetailPage from "./pages/contracts/ContractRequestDetailPa
|
||||
import ContractViewPage from "./pages/contracts/ContractViewPage";
|
||||
import ContractClearanceListPage from "./pages/contracts/ContractClearanceListPage";
|
||||
import ContractClearanceDetailPage from "./pages/contracts/ContractClearanceDetailPage";
|
||||
import GlEthiopiaClearanceListPage from "./pages/contracts/GlEthiopiaClearanceListPage";
|
||||
import GlDjiboutiClearanceListPage from "./pages/contracts/GlDjiboutiClearanceListPage";
|
||||
import GlClearanceDetailPage from "./pages/contracts/GlClearanceDetailPage";
|
||||
import GlCreateBookingForm from "./components/contracts/GlCreateBookingForm";
|
||||
import BookingMilestonesPage from "./pages/contracts/BookingMilestonesPage";
|
||||
import CustomerDetailPage from "./pages/customers/CustomerDetailPage";
|
||||
@@ -136,6 +141,18 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
icon: <ShieldCheck />,
|
||||
permission: FREIGHT_PERMS.contracts.clearanceReview,
|
||||
},
|
||||
{
|
||||
label: "GL Ethiopia Clearance",
|
||||
href: "/dashboard/gl-ethiopia/clearance",
|
||||
icon: <Flag />,
|
||||
permission: FREIGHT_PERMS.contracts.clearanceEtActions,
|
||||
},
|
||||
{
|
||||
label: "GL Djibouti Clearance",
|
||||
href: "/dashboard/gl-djibouti/clearance",
|
||||
icon: <Ship />,
|
||||
permission: FREIGHT_PERMS.contracts.clearanceDjActions,
|
||||
},
|
||||
{
|
||||
label: "Train Schedules",
|
||||
href: "/dashboard/operations/train-scheduling-v2",
|
||||
@@ -509,6 +526,46 @@ const App = () => {
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="gl-ethiopia/clearance"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.contracts.clearanceEtActions}>
|
||||
<GlEthiopiaClearanceListPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="gl-ethiopia/clearance/:id"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.contracts.clearanceEtActions}>
|
||||
<GlClearanceDetailPage
|
||||
backTo="/dashboard/gl-ethiopia/clearance"
|
||||
breadcrumbsLabel="GL Ethiopia Clearance"
|
||||
roleMode="ET"
|
||||
/>
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="gl-djibouti/clearance"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.contracts.clearanceDjActions}>
|
||||
<GlDjiboutiClearanceListPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="gl-djibouti/clearance/:id"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.contracts.clearanceDjActions}>
|
||||
<GlClearanceDetailPage
|
||||
backTo="/dashboard/gl-djibouti/clearance"
|
||||
breadcrumbsLabel="GL Djibouti Clearance"
|
||||
roleMode="DJ"
|
||||
/>
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
{/* Path A ops queue out of scope for now → fold into the GL hub. */}
|
||||
<Route
|
||||
path="contracts/ops-clearance"
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { Check } from "lucide-react";
|
||||
import { Box, Group, Stack, Text } from "@mantine/core";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
const BRAND_GREEN = "var(--freight-brand, #0A6F4D)";
|
||||
|
||||
const PHASE_LABELS: Record<string, string> = {
|
||||
CUSTOMER_INTAKE: "Customer docs",
|
||||
GL_ET_REVIEW: "GL ET review",
|
||||
GL_DJ_COLLECTION: "GL Djibouti",
|
||||
GL_ET_OUTPUT: "Declaration",
|
||||
CUSTOMER_DUTY: "Duty / tax",
|
||||
GL_ET_POST_CLEARANCE: "ET clearance",
|
||||
GL_DJ_LOADING: "Loading",
|
||||
POST_TRANSIT: "Transit",
|
||||
};
|
||||
|
||||
const IMPORT_PHASES = [
|
||||
"CUSTOMER_INTAKE",
|
||||
"GL_ET_REVIEW",
|
||||
"GL_ET_OUTPUT",
|
||||
"CUSTOMER_DUTY",
|
||||
"GL_ET_POST_CLEARANCE",
|
||||
"GL_DJ_COLLECTION",
|
||||
] as const;
|
||||
|
||||
const EXPORT_PHASES = [
|
||||
"CUSTOMER_INTAKE",
|
||||
"GL_ET_REVIEW",
|
||||
"GL_DJ_COLLECTION",
|
||||
"GL_ET_OUTPUT",
|
||||
"GL_ET_POST_CLEARANCE",
|
||||
] as const;
|
||||
|
||||
function phaseIndex(phases: readonly string[], current?: string | null): number {
|
||||
if (!current) return 0;
|
||||
const idx = phases.indexOf(current);
|
||||
return idx >= 0 ? idx : 0;
|
||||
}
|
||||
|
||||
export function ClearancePhaseStepper({
|
||||
clearance,
|
||||
tradeDirection,
|
||||
compact = false,
|
||||
}: {
|
||||
clearance?: Freight.ContractClearanceView | null;
|
||||
tradeDirection?: string;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const phases = tradeDirection === "EXPORT" ? EXPORT_PHASES : IMPORT_PHASES;
|
||||
const current = clearance?.phase ?? phases[0];
|
||||
const activeIdx = phaseIndex(phases, current);
|
||||
|
||||
return (
|
||||
<Group gap={0} wrap="nowrap" align="flex-start" style={{ overflowX: "auto" }}>
|
||||
{phases.map((phase, index) => {
|
||||
const isComplete = index < activeIdx;
|
||||
const isActive = index === activeIdx;
|
||||
const isLast = index === phases.length - 1;
|
||||
|
||||
return (
|
||||
<Box key={phase} style={{ flex: isLast ? "0 0 auto" : 1, minWidth: compact ? 72 : 88 }}>
|
||||
<Group gap={0} wrap="nowrap" align="center">
|
||||
<Stack gap={4} align="center" style={{ flexShrink: 0 }}>
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: compact ? 28 : 34,
|
||||
height: compact ? 28 : 34,
|
||||
borderRadius: "50%",
|
||||
background: isComplete ? BRAND_GREEN : isActive ? "white" : "var(--mantine-color-gray-1)",
|
||||
border: isActive
|
||||
? `2px solid ${BRAND_GREEN}`
|
||||
: isComplete
|
||||
? "2px solid transparent"
|
||||
: "2px solid var(--mantine-color-gray-3)",
|
||||
color: isComplete ? "white" : isActive ? BRAND_GREEN : "var(--mantine-color-gray-5)",
|
||||
}}
|
||||
>
|
||||
{isComplete ? <Check size={compact ? 14 : 16} strokeWidth={3} /> : null}
|
||||
</Box>
|
||||
<Text
|
||||
size={compact ? "10px" : "xs"}
|
||||
fw={isActive ? 600 : 500}
|
||||
c={isActive ? "edr-green.7" : isComplete ? "dark" : "dimmed"}
|
||||
ta="center"
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
>
|
||||
{PHASE_LABELS[phase] ?? phase}
|
||||
</Text>
|
||||
</Stack>
|
||||
{!isLast && (
|
||||
<Box
|
||||
style={{
|
||||
flex: 1,
|
||||
height: 2,
|
||||
marginInline: 6,
|
||||
marginBottom: compact ? 16 : 20,
|
||||
borderRadius: 2,
|
||||
background: isComplete ? BRAND_GREEN : "var(--mantine-color-gray-2)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
FileInput,
|
||||
Group,
|
||||
NumberInput,
|
||||
Select,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { DateInput } from "@mantine/dates";
|
||||
import { AlertTriangle, FileText, Receipt, Ship, Upload } from "lucide-react";
|
||||
import type { Freight } from "@edr/types";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
import { ActionShell } from "@/components/contracts/gl-actions/ActionShell";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
|
||||
type RoleMode = "ET" | "DJ" | "ALL";
|
||||
|
||||
export function PhasedClearanceActionPanel({
|
||||
contractId,
|
||||
clearance,
|
||||
tradeDirection,
|
||||
roleMode = "ALL",
|
||||
onChanged,
|
||||
bookingCreateHref,
|
||||
}: {
|
||||
contractId: string;
|
||||
clearance: Freight.ContractClearanceView;
|
||||
tradeDirection: string;
|
||||
roleMode?: RoleMode;
|
||||
onChanged?: () => void;
|
||||
bookingCreateHref?: string;
|
||||
}) {
|
||||
const { user } = useAuth();
|
||||
const canEt = hasPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions);
|
||||
const canDj = hasPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions);
|
||||
const showEt = roleMode === "ET" || roleMode === "ALL";
|
||||
const showDj = roleMode === "DJ" || roleMode === "ALL";
|
||||
|
||||
const next = clearance.nextAction;
|
||||
const isImport = tradeDirection === "IMPORT";
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{clearance.roHold && clearance.roHoldReason ? (
|
||||
<Alert color="orange" icon={<AlertTriangle size={16} />} title="Release Order on hold">
|
||||
{clearance.roHoldReason}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{next ? (
|
||||
<Alert color="blue" variant="light" title="Next step">
|
||||
<Text size="sm">
|
||||
<strong>{next.actor.replace("_", " ")}</strong> — {next.action}
|
||||
</Text>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{showEt && canEt && isImport ? (
|
||||
<DeclarationCard contractId={contractId} onChanged={onChanged} />
|
||||
) : null}
|
||||
|
||||
{showEt && canEt && isImport ? (
|
||||
<DutyCard contractId={contractId} clearance={clearance} onChanged={onChanged} />
|
||||
) : null}
|
||||
|
||||
{showEt && canEt && isImport ? (
|
||||
<TransitPermitCard contractId={contractId} onChanged={onChanged} />
|
||||
) : null}
|
||||
|
||||
{showDj && canDj && isImport ? (
|
||||
<DeliveryOrderCard contractId={contractId} onChanged={onChanged} />
|
||||
) : null}
|
||||
|
||||
{showDj && canDj && !isImport ? (
|
||||
<ReleaseOrderCard
|
||||
contractId={contractId}
|
||||
clearance={clearance}
|
||||
onChanged={onChanged}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{showEt && canEt && !isImport ? (
|
||||
<DeclarationCard contractId={contractId} onChanged={onChanged} exportMode />
|
||||
) : null}
|
||||
|
||||
{showEt && canEt && !isImport ? (
|
||||
<ExportReleaseCard contractId={contractId} clearance={clearance} onChanged={onChanged} />
|
||||
) : null}
|
||||
|
||||
{clearance.bookingReady && bookingCreateHref && showEt && canEt ? (
|
||||
<SectionCard icon={Ship} title="Create booking" accent="edr-green">
|
||||
<Text size="sm" c="dimmed" mb="sm">
|
||||
Pre-booking clearance is complete. Create the shipment booking for the customer.
|
||||
</Text>
|
||||
<Button component="a" href={bookingCreateHref} color="edr-green">
|
||||
Create shipment booking
|
||||
</Button>
|
||||
</SectionCard>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function DeclarationCard({
|
||||
contractId,
|
||||
onChanged,
|
||||
exportMode = false,
|
||||
}: {
|
||||
contractId: string;
|
||||
onChanged?: () => void;
|
||||
exportMode?: boolean;
|
||||
}) {
|
||||
const [files, setFiles] = useState<Record<string, File | null>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const fields = exportMode
|
||||
? [
|
||||
{ key: "ex3", label: "EX3" },
|
||||
{ key: "ex8", label: "EX8" },
|
||||
]
|
||||
: [
|
||||
{ key: "im4", label: "IM4" },
|
||||
{ key: "im5", label: "IM5 (optional)" },
|
||||
];
|
||||
|
||||
return (
|
||||
<ActionShell
|
||||
icon={FileText}
|
||||
title="Customs declaration"
|
||||
subtitle={exportMode ? "Upload EX3 / EX8" : "Upload IM4 / IM5"}
|
||||
done={false}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
{fields.map((f) => (
|
||||
<FileInput
|
||||
key={f.key}
|
||||
label={f.label}
|
||||
placeholder="Choose file"
|
||||
value={files[f.key] ?? null}
|
||||
onChange={(file) => setFiles((prev) => ({ ...prev, [f.key]: file }))}
|
||||
size="sm"
|
||||
/>
|
||||
))}
|
||||
<Button
|
||||
color="edr-green"
|
||||
loading={loading}
|
||||
leftSection={<Upload size={16} />}
|
||||
onClick={async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await contractsService.uploadDeclaration(contractId, files);
|
||||
toast.success("Declaration uploaded");
|
||||
onChanged?.();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Upload failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Submit declaration
|
||||
</Button>
|
||||
</Stack>
|
||||
</ActionShell>
|
||||
);
|
||||
}
|
||||
|
||||
function DutyCard({
|
||||
contractId,
|
||||
clearance,
|
||||
onChanged,
|
||||
}: {
|
||||
contractId: string;
|
||||
clearance: Freight.ContractClearanceView;
|
||||
onChanged?: () => void;
|
||||
}) {
|
||||
const [dutyRequired, setDutyRequired] = useState(clearance.dutyRequired ?? true);
|
||||
const [amount, setAmount] = useState<number | string>("");
|
||||
const [currency, setCurrency] = useState("ETB");
|
||||
const [serial, setSerial] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const advised = clearance.milestones?.some(
|
||||
(m) => m.milestoneCode === "DUTY_TAXES_ADVISED" && m.status === "COMPLETED",
|
||||
);
|
||||
|
||||
return (
|
||||
<ActionShell
|
||||
icon={Receipt}
|
||||
title="Duty & tax"
|
||||
subtitle="Toggle whether duty applies and advise the amount"
|
||||
done={advised && clearance.dutyRequired === false}
|
||||
doneLabel={clearance.dutyRequired === false ? "Not required" : advised ? "Advised" : undefined}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Switch
|
||||
label="Customer must pay duty/tax"
|
||||
checked={dutyRequired}
|
||||
onChange={(e) => setDutyRequired(e.currentTarget.checked)}
|
||||
/>
|
||||
{dutyRequired ? (
|
||||
<>
|
||||
<Group grow>
|
||||
<NumberInput label="Amount" value={amount} onChange={setAmount} min={0} size="sm" />
|
||||
<Select
|
||||
label="Currency"
|
||||
data={["ETB", "USD"]}
|
||||
value={currency}
|
||||
onChange={(v) => setCurrency(v ?? "ETB")}
|
||||
size="sm"
|
||||
/>
|
||||
</Group>
|
||||
<TextInput
|
||||
label="Declaration / payment code"
|
||||
value={serial}
|
||||
onChange={(e) => setSerial(e.currentTarget.value)}
|
||||
size="sm"
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
<Button
|
||||
color="edr-green"
|
||||
loading={loading}
|
||||
onClick={async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await contractsService.adviseContractDuty(contractId, {
|
||||
dutyRequired,
|
||||
amount: dutyRequired ? Number(amount) : undefined,
|
||||
currency,
|
||||
declarationSerial: serial || undefined,
|
||||
});
|
||||
toast.success(dutyRequired ? "Duty advised" : "Duty step skipped");
|
||||
onChanged?.();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Save duty settings
|
||||
</Button>
|
||||
</Stack>
|
||||
</ActionShell>
|
||||
);
|
||||
}
|
||||
|
||||
function TransitPermitCard({
|
||||
contractId,
|
||||
onChanged,
|
||||
}: {
|
||||
contractId: string;
|
||||
onChanged?: () => void;
|
||||
}) {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
return (
|
||||
<ActionShell
|
||||
icon={Upload}
|
||||
title="Transit permit"
|
||||
subtitle="Upload transit permitted screenshot"
|
||||
done={false}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<FileInput
|
||||
label="Transit permit screenshot"
|
||||
value={file}
|
||||
onChange={setFile}
|
||||
size="sm"
|
||||
/>
|
||||
<Button
|
||||
color="edr-green"
|
||||
loading={loading}
|
||||
disabled={!file}
|
||||
onClick={async () => {
|
||||
if (!file) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await contractsService.uploadContractTransitPermit(contractId, file);
|
||||
toast.success("Transit permit uploaded");
|
||||
onChanged?.();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Upload failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Upload transit permit
|
||||
</Button>
|
||||
</Stack>
|
||||
</ActionShell>
|
||||
);
|
||||
}
|
||||
|
||||
function DeliveryOrderCard({
|
||||
contractId,
|
||||
onChanged,
|
||||
}: {
|
||||
contractId: string;
|
||||
onChanged?: () => void;
|
||||
}) {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
return (
|
||||
<ActionShell
|
||||
icon={Ship}
|
||||
title="Delivery Order"
|
||||
subtitle="GL Djibouti uploads the DO"
|
||||
done={false}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<FileInput label="Delivery Order" value={file} onChange={setFile} size="sm" />
|
||||
<Button
|
||||
color="edr-green"
|
||||
loading={loading}
|
||||
disabled={!file}
|
||||
onClick={async () => {
|
||||
if (!file) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await contractsService.uploadDeliveryOrder(contractId, file);
|
||||
toast.success("Delivery Order uploaded");
|
||||
onChanged?.();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Upload failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Upload DO
|
||||
</Button>
|
||||
</Stack>
|
||||
</ActionShell>
|
||||
);
|
||||
}
|
||||
|
||||
function ReleaseOrderCard({
|
||||
contractId,
|
||||
clearance,
|
||||
onChanged,
|
||||
}: {
|
||||
contractId: string;
|
||||
clearance: Freight.ContractClearanceView;
|
||||
onChanged?: () => void;
|
||||
}) {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [vesselDate, setVesselDate] = useState<Date | null>(
|
||||
clearance.vesselDepartureDate ? new Date(clearance.vesselDepartureDate) : null,
|
||||
);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [amendLoading, setAmendLoading] = useState(false);
|
||||
|
||||
return (
|
||||
<ActionShell
|
||||
icon={Ship}
|
||||
title="Release Order"
|
||||
subtitle="Upload RO and vessel departure date"
|
||||
done={false}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<FileInput label="Release Order" value={file} onChange={setFile} size="sm" />
|
||||
<DateInput
|
||||
label="Vessel departure date"
|
||||
value={vesselDate}
|
||||
onChange={setVesselDate}
|
||||
size="sm"
|
||||
/>
|
||||
<Group>
|
||||
<Button
|
||||
color="edr-green"
|
||||
loading={loading}
|
||||
disabled={!file || !vesselDate}
|
||||
onClick={async () => {
|
||||
if (!file || !vesselDate) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const iso = vesselDate.toISOString().slice(0, 10);
|
||||
const result = await contractsService.uploadReleaseOrder(
|
||||
contractId,
|
||||
file,
|
||||
iso,
|
||||
);
|
||||
if (result.hold) {
|
||||
toast.error(result.holdReason ?? "Vessel date too soon");
|
||||
} else {
|
||||
toast.success("Release Order accepted");
|
||||
}
|
||||
onChanged?.();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Upload failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Upload RO
|
||||
</Button>
|
||||
<Button
|
||||
variant="light"
|
||||
color="orange"
|
||||
loading={amendLoading}
|
||||
onClick={async () => {
|
||||
setAmendLoading(true);
|
||||
try {
|
||||
await contractsService.requestRoAmendment(
|
||||
contractId,
|
||||
"Port amendment requested — vessel window too short.",
|
||||
);
|
||||
toast.success("Amendment request recorded");
|
||||
onChanged?.();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed");
|
||||
} finally {
|
||||
setAmendLoading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Request amendment
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</ActionShell>
|
||||
);
|
||||
}
|
||||
|
||||
function ExportReleaseCard({
|
||||
contractId,
|
||||
clearance,
|
||||
onChanged,
|
||||
}: {
|
||||
contractId: string;
|
||||
clearance: Freight.ContractClearanceView;
|
||||
onChanged?: () => void;
|
||||
}) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const done = clearance.bookingReady;
|
||||
|
||||
return (
|
||||
<ActionShell
|
||||
icon={FileText}
|
||||
title="Export release"
|
||||
subtitle="Confirm customs clearance complete"
|
||||
done={done}
|
||||
doneLabel={done ? "Ready for booking" : undefined}
|
||||
>
|
||||
<Button
|
||||
color="edr-green"
|
||||
loading={loading}
|
||||
disabled={done}
|
||||
onClick={async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await contractsService.confirmExportRelease(contractId);
|
||||
toast.success("Export released — ready for booking");
|
||||
onChanged?.();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Confirm export release
|
||||
</Button>
|
||||
</ActionShell>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { AssignStationCard } from "./AssignStationCard";
|
||||
import { AssignRiskCard } from "./AssignRiskCard";
|
||||
import { AdviseDutyCard } from "./AdviseDutyCard";
|
||||
import { GlDocumentUploadCard } from "./GlDocumentUploadCard";
|
||||
import { TransportDocumentCard } from "./TransportDocumentCard";
|
||||
import { IncidentReportCard } from "./IncidentReportCard";
|
||||
|
||||
export interface GlActionsPanelProps {
|
||||
@@ -39,6 +40,16 @@ export function GlActionsPanel({ bookingId, milestones }: GlActionsPanelProps) {
|
||||
() => findMilestone(milestones, "DUTY_TAXES_ADVISED"),
|
||||
[milestones],
|
||||
);
|
||||
const wagonMs = useMemo(
|
||||
() => findMilestone(milestones, "WAGON_ALLOCATED"),
|
||||
[milestones],
|
||||
);
|
||||
const transportMs = useMemo(
|
||||
() => findMilestone(milestones, "EXPORT_TRANSPORT_ISSUED"),
|
||||
[milestones],
|
||||
);
|
||||
const showTransport =
|
||||
wagonMs?.status === "COMPLETED" && transportMs?.status !== "COMPLETED";
|
||||
|
||||
return (
|
||||
<SectionCard icon={Flag} title="Global Logistics actions" accent="edr-green">
|
||||
@@ -56,6 +67,8 @@ export function GlActionsPanel({ bookingId, milestones }: GlActionsPanelProps) {
|
||||
|
||||
<GlDocumentUploadCard bookingId={bookingId} />
|
||||
|
||||
{showTransport ? <TransportDocumentCard bookingId={bookingId} /> : null}
|
||||
|
||||
{riskMs ? (
|
||||
<AssignRiskCard bookingId={bookingId} milestone={riskMs} />
|
||||
) : null}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useState } from "react";
|
||||
import { Button, FileInput, Stack } from "@mantine/core";
|
||||
import { FileText } from "lucide-react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { ActionShell } from "./ActionShell";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
|
||||
export function TransportDocumentCard({ bookingId }: { bookingId: string }) {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
return (
|
||||
<ActionShell
|
||||
icon={FileText}
|
||||
title="Export transport document"
|
||||
subtitle="Upload after wagon allocation (GL Ethiopia)"
|
||||
done={false}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<FileInput
|
||||
label="Transport document"
|
||||
value={file}
|
||||
onChange={setFile}
|
||||
size="sm"
|
||||
/>
|
||||
<Button
|
||||
color="edr-green"
|
||||
loading={loading}
|
||||
disabled={!file}
|
||||
onClick={async () => {
|
||||
if (!file) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await contractsService.uploadTransportDocument(bookingId, file);
|
||||
toast.success("Transport document uploaded");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Upload failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Upload document
|
||||
</Button>
|
||||
</Stack>
|
||||
</ActionShell>
|
||||
);
|
||||
}
|
||||
@@ -145,6 +145,21 @@ export const URL_CONSTANTS = {
|
||||
CLEARANCE_OUTPUT_DOCUMENTS: (id: string) =>
|
||||
`/contracts/${id}/clearance/output-documents`,
|
||||
CLEARANCE_FINALIZE: (id: string) => `/contracts/${id}/clearance/finalize`,
|
||||
CLEARANCE_DECLARATION: (id: string) => `/contracts/${id}/clearance/declaration`,
|
||||
CLEARANCE_DUTY: (id: string) => `/contracts/${id}/clearance/duty`,
|
||||
CLEARANCE_DUTY_SLIP: (id: string) => `/contracts/${id}/clearance/duty-slip`,
|
||||
CLEARANCE_TRANSIT_PERMIT: (id: string) =>
|
||||
`/contracts/${id}/clearance/transit-permit`,
|
||||
CLEARANCE_DELIVERY_ORDER: (id: string) =>
|
||||
`/contracts/${id}/clearance/delivery-order`,
|
||||
CLEARANCE_RELEASE_ORDER: (id: string) =>
|
||||
`/contracts/${id}/clearance/release-order`,
|
||||
CLEARANCE_RO_AMENDMENT: (id: string) =>
|
||||
`/contracts/${id}/clearance/ro-amendment`,
|
||||
CLEARANCE_EXPORT_RELEASE: (id: string) =>
|
||||
`/contracts/${id}/clearance/export-release`,
|
||||
CLEARANCE_ET_QUEUE: "/contracts/clearance/et-queue",
|
||||
CLEARANCE_DJ_QUEUE: "/contracts/clearance/dj-queue",
|
||||
// Path A self-clearance — Operations reviews the customer's own clearance docs.
|
||||
OPS_CLEARANCE_QUEUE: "/contracts/clearance/ops-queue",
|
||||
OPS_CLEARANCE_REVIEW: (id: string) =>
|
||||
@@ -178,6 +193,8 @@ export const URL_CONSTANTS = {
|
||||
`/contracts/bookings/${bookingId}/station-assign`,
|
||||
BOOKING_GL_DOCUMENTS: (bookingId: string) =>
|
||||
`/contracts/bookings/${bookingId}/documents`,
|
||||
BOOKING_TRANSPORT_DOCUMENT: (bookingId: string) =>
|
||||
`/contracts/bookings/${bookingId}/transport-document`,
|
||||
BOOKING_INCIDENTS: (bookingId: string) =>
|
||||
`/contracts/bookings/${bookingId}/incidents`,
|
||||
},
|
||||
|
||||
@@ -52,6 +52,22 @@ export function useContractClearanceQueue(enabled = true) {
|
||||
});
|
||||
}
|
||||
|
||||
export function useEtClearanceQueue(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.CONTRACTS.clearanceQueue("ET"),
|
||||
queryFn: () => contractsService.getEtClearanceQueue(),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useDjClearanceQueue(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.CONTRACTS.clearanceQueue("DJ"),
|
||||
queryFn: () => contractsService.getDjClearanceQueue(),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
/** Path A self-clearance queue (Operations reviews non-customs contracts). */
|
||||
export function useOpsClearanceQueue(enabled = true) {
|
||||
return useQuery({
|
||||
|
||||
@@ -33,7 +33,9 @@ export const FREIGHT_PERMS = {
|
||||
clearanceReview: "edr_freight_app:contracts:clearance_review",
|
||||
finalizeClearance: "edr_freight_app:contracts:finalize_clearance",
|
||||
createBooking: "edr_freight_app:contracts:create_booking",
|
||||
opsClearanceReview: "edr_freight_app:contracts:ops_clearance_review",
|
||||
clearanceDutyAdvise: "edr_freight_app:contracts:clearance_duty_advise",
|
||||
clearanceEtActions: "edr_freight_app:contracts:clearance_et_actions",
|
||||
clearanceDjActions: "edr_freight_app:contracts:clearance_dj_actions",
|
||||
},
|
||||
trainScheduling: {
|
||||
view: "edr_freight_app:train_scheduling:view",
|
||||
|
||||
@@ -28,6 +28,9 @@ import { PageContainer } from "@/components/page/PageContainer";
|
||||
import { PageHeader } from "@/components/page/PageHeader";
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
import { ContractClearanceReviewSection } from "@/components/contracts/ContractClearanceReviewSection";
|
||||
import { ClearancePhaseStepper } from "@/components/contracts/ClearancePhaseStepper";
|
||||
import { PhasedClearanceActionPanel } from "@/components/contracts/PhasedClearanceActionPanel";
|
||||
import { ClearanceMilestoneTimeline } from "@/components/contracts/ClearanceMilestoneTimeline";
|
||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
import { useContractDetail } from "@/hooks/contracts/useContracts";
|
||||
@@ -40,6 +43,7 @@ export default function ContractClearanceDetailPage() {
|
||||
data: clearance,
|
||||
isLoading,
|
||||
isError,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: QUERY_KEYS.CONTRACTS.clearance(id ?? ""),
|
||||
queryFn: () => contractsService.getClearance(id!),
|
||||
@@ -61,7 +65,7 @@ export default function ContractClearanceDetailPage() {
|
||||
const reference = contract?.reference ?? "Clearance";
|
||||
// Customs (Path B) hub. The customer always creates the booking in the portal
|
||||
// after GL finalizes clearance — there is no GL "Create booking" action here.
|
||||
const ready = clearance?.clearanceStatus === "CLEARANCE_READY_FOR_BOOKING";
|
||||
const ready = clearance?.bookingReady ?? clearance?.clearanceStatus === "CLEARANCE_READY_FOR_BOOKING";
|
||||
const clearanceReadOnly = Boolean(
|
||||
contract?.status &&
|
||||
[
|
||||
@@ -152,15 +156,23 @@ export default function ContractClearanceDetailPage() {
|
||||
|
||||
<ClearanceHero contract={contract} stats={stats} />
|
||||
|
||||
{contract?.contractKind === "ONE_TIME" && contract.customsClearingEnabled ? (
|
||||
<Paper withBorder radius="md" p="lg">
|
||||
<ClearancePhaseStepper
|
||||
clearance={clearance}
|
||||
tradeDirection={contract.tradeDirection}
|
||||
/>
|
||||
</Paper>
|
||||
) : null}
|
||||
|
||||
{ready ? (
|
||||
<Alert
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
icon={<PackageCheck size={16} />}
|
||||
title="Clearance finalized"
|
||||
title="Clearance complete"
|
||||
>
|
||||
Customs clearance is complete. The customer can now create the
|
||||
shipment booking from the portal — no further action is needed here.
|
||||
Pre-booking clearance is complete. GL Ethiopia can create the shipment booking.
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
@@ -171,55 +183,81 @@ export default function ContractClearanceDetailPage() {
|
||||
hideSummary
|
||||
selfClear={false}
|
||||
readOnly={clearanceReadOnly}
|
||||
onChanged={() => void refetch()}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<Box style={{ position: "sticky", top: 24 }}>
|
||||
<SectionCard
|
||||
icon={PackageCheck}
|
||||
title="Review progress"
|
||||
accent="edr-green"
|
||||
>
|
||||
<Stack align="center" gap="sm">
|
||||
<RingProgress
|
||||
size={140}
|
||||
thickness={12}
|
||||
roundCaps
|
||||
sections={[{ value: stats.pct, color: "edr-green" }]}
|
||||
label={
|
||||
<Stack gap={0} align="center">
|
||||
<Text fw={800} fz={26} lh={1}>
|
||||
{stats.pct}%
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
approved
|
||||
</Text>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
<Group gap="lg" justify="center">
|
||||
<ProgressStat
|
||||
color="edr-green"
|
||||
label="Approved"
|
||||
value={stats.approved}
|
||||
/>
|
||||
<ProgressStat
|
||||
color="red"
|
||||
label="Queried"
|
||||
value={stats.queried}
|
||||
/>
|
||||
<ProgressStat
|
||||
color="gray"
|
||||
label="Pending"
|
||||
value={stats.pending}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
</Box>
|
||||
<Stack gap="md">
|
||||
{contract?.contractKind === "ONE_TIME" && contract.customsClearingEnabled ? (
|
||||
<PhasedClearanceActionPanel
|
||||
contractId={id!}
|
||||
clearance={clearance}
|
||||
tradeDirection={contract.tradeDirection}
|
||||
roleMode="ALL"
|
||||
onChanged={() => void refetch()}
|
||||
bookingCreateHref={
|
||||
ready ? `/dashboard/contracts/${id}/create-booking` : undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Box style={{ position: "sticky", top: 24 }}>
|
||||
<SectionCard
|
||||
icon={PackageCheck}
|
||||
title="Review progress"
|
||||
accent="edr-green"
|
||||
>
|
||||
<Stack align="center" gap="sm">
|
||||
<RingProgress
|
||||
size={140}
|
||||
thickness={12}
|
||||
roundCaps
|
||||
sections={[{ value: stats.pct, color: "edr-green" }]}
|
||||
label={
|
||||
<Stack gap={0} align="center">
|
||||
<Text fw={800} fz={26} lh={1}>
|
||||
{stats.pct}%
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
approved
|
||||
</Text>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
<Group gap="lg" justify="center">
|
||||
<ProgressStat
|
||||
color="edr-green"
|
||||
label="Approved"
|
||||
value={stats.approved}
|
||||
/>
|
||||
<ProgressStat
|
||||
color="red"
|
||||
label="Queried"
|
||||
value={stats.queried}
|
||||
/>
|
||||
<ProgressStat
|
||||
color="gray"
|
||||
label="Pending"
|
||||
value={stats.pending}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
{clearance.milestones && clearance.milestones.length > 0 ? (
|
||||
<ClearanceMilestoneTimeline
|
||||
milestones={
|
||||
clearance.milestones as Parameters<
|
||||
typeof ClearanceMilestoneTimeline
|
||||
>[0]["milestones"]
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</Stack>
|
||||
</PageContainer>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { Alert, Grid, Loader, Paper, Stack, Text } from "@mantine/core";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
|
||||
import { PageContainer } from "@/components/page/PageContainer";
|
||||
import { PageHeader } from "@/components/page/PageHeader";
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
import { ContractClearanceReviewSection } from "@/components/contracts/ContractClearanceReviewSection";
|
||||
import { ClearancePhaseStepper } from "@/components/contracts/ClearancePhaseStepper";
|
||||
import { PhasedClearanceActionPanel } from "@/components/contracts/PhasedClearanceActionPanel";
|
||||
import { ClearanceMilestoneTimeline } from "@/components/contracts/ClearanceMilestoneTimeline";
|
||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
import { useContractDetail } from "@/hooks/contracts/useContracts";
|
||||
|
||||
export default function GlClearanceDetailPage({
|
||||
backTo,
|
||||
breadcrumbsLabel,
|
||||
roleMode,
|
||||
}: {
|
||||
backTo: string;
|
||||
breadcrumbsLabel: string;
|
||||
roleMode: "ET" | "DJ";
|
||||
}) {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { data: contract } = useContractDetail(id);
|
||||
const {
|
||||
data: clearance,
|
||||
isLoading,
|
||||
isError,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: QUERY_KEYS.CONTRACTS.clearance(id ?? ""),
|
||||
queryFn: () => contractsService.getClearance(id!),
|
||||
enabled: Boolean(id),
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<Stack align="center" py={80}>
|
||||
<Loader color="edr-green" />
|
||||
<Text c="dimmed">Loading clearance…</Text>
|
||||
</Stack>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !clearance || !contract) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<Alert color="red" icon={<AlertCircle size={16} />}>
|
||||
Could not load clearance for this contract.
|
||||
</Alert>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const bookingHref =
|
||||
roleMode === "ET" ? `/dashboard/contracts/${id}/create-booking` : undefined;
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<Stack gap="lg">
|
||||
<PageHeader
|
||||
title={contract.reference}
|
||||
backTo={backTo}
|
||||
breadcrumbs={[
|
||||
{ label: breadcrumbsLabel, href: backTo },
|
||||
{ label: contract.reference },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Paper withBorder radius="md" p="lg">
|
||||
<ClearancePhaseStepper
|
||||
clearance={clearance}
|
||||
tradeDirection={contract.tradeDirection}
|
||||
/>
|
||||
</Paper>
|
||||
|
||||
<Grid>
|
||||
<Grid.Col span={{ base: 12, lg: roleMode === "DJ" ? 12 : 7 }}>
|
||||
{roleMode === "ET" ? (
|
||||
<ContractClearanceReviewSection
|
||||
contractId={id!}
|
||||
hideSummary
|
||||
selfClear={false}
|
||||
readOnly={false}
|
||||
onChanged={() => void refetch()}
|
||||
/>
|
||||
) : (
|
||||
<SectionCard title="Contract context" accent="edr-green">
|
||||
<Text size="sm" c="dimmed" mb="sm">
|
||||
Review upstream status before uploading Djibouti documents.
|
||||
</Text>
|
||||
<ContractClearanceReviewSection
|
||||
contractId={id!}
|
||||
hideSummary
|
||||
selfClear={false}
|
||||
readOnly
|
||||
/>
|
||||
</SectionCard>
|
||||
)}
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: roleMode === "DJ" ? 12 : 5 }}>
|
||||
<PhasedClearanceActionPanel
|
||||
contractId={id!}
|
||||
clearance={clearance}
|
||||
tradeDirection={contract.tradeDirection}
|
||||
roleMode={roleMode}
|
||||
onChanged={() => void refetch()}
|
||||
bookingCreateHref={bookingHref}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
{clearance.milestones && clearance.milestones.length > 0 ? (
|
||||
<ClearanceMilestoneTimeline
|
||||
milestones={clearance.milestones as Parameters<typeof ClearanceMilestoneTimeline>[0]["milestones"]}
|
||||
/>
|
||||
) : null}
|
||||
</Stack>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Badge, Card, Group, Loader, Stack, Text } from "@mantine/core";
|
||||
import { ChevronRight, Ship } from "lucide-react";
|
||||
|
||||
import { PageContainer } from "@/components/page/PageContainer";
|
||||
import { PageHeader } from "@/components/page/PageHeader";
|
||||
import { useDjClearanceQueue } from "@/hooks/contracts/useContracts";
|
||||
|
||||
export default function GlDjiboutiClearanceListPage() {
|
||||
const navigate = useNavigate();
|
||||
const { data, isLoading } = useDjClearanceQueue();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="GL Djibouti — Clearance"
|
||||
subtitle="Contracts awaiting Djibouti GL action (DO / RO)."
|
||||
/>
|
||||
{isLoading ? (
|
||||
<Group justify="center" py={60}>
|
||||
<Loader color="edr-green" />
|
||||
</Group>
|
||||
) : (
|
||||
<Stack gap="sm">
|
||||
{(data?.items ?? []).length === 0 ? (
|
||||
<Text c="dimmed" ta="center" py="xl">
|
||||
No contracts need Djibouti GL action right now.
|
||||
</Text>
|
||||
) : (
|
||||
(data?.items ?? []).map((c) => (
|
||||
<Card
|
||||
key={c.id}
|
||||
withBorder
|
||||
radius="md"
|
||||
padding="md"
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => navigate(`/dashboard/gl-djibouti/clearance/${c.id}`)}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap="sm">
|
||||
<Ship size={18} className="text-[color:var(--freight-brand)]" />
|
||||
<div>
|
||||
<Text fw={700}>{c.reference}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{c.tradeDirection} · {c.status}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
<Badge variant="light" color="cyan">
|
||||
{c.tradeDirection}
|
||||
</Badge>
|
||||
<ChevronRight size={18} className="text-muted-foreground" />
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Badge, Card, Group, Loader, Stack, Text } from "@mantine/core";
|
||||
import { ChevronRight, Flag } from "lucide-react";
|
||||
|
||||
import { PageContainer } from "@/components/page/PageContainer";
|
||||
import { PageHeader } from "@/components/page/PageHeader";
|
||||
import { useEtClearanceQueue } from "@/hooks/contracts/useContracts";
|
||||
|
||||
export default function GlEthiopiaClearanceListPage() {
|
||||
const navigate = useNavigate();
|
||||
const { data, isLoading } = useEtClearanceQueue();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="GL Ethiopia — Clearance"
|
||||
subtitle="Contracts awaiting Ethiopia GL action in the phased clearance workflow."
|
||||
/>
|
||||
{isLoading ? (
|
||||
<Group justify="center" py={60}>
|
||||
<Loader color="edr-green" />
|
||||
</Group>
|
||||
) : (
|
||||
<Stack gap="sm">
|
||||
{(data?.items ?? []).length === 0 ? (
|
||||
<Text c="dimmed" ta="center" py="xl">
|
||||
No contracts need ET GL action right now.
|
||||
</Text>
|
||||
) : (
|
||||
(data?.items ?? []).map((c) => (
|
||||
<Card
|
||||
key={c.id}
|
||||
withBorder
|
||||
radius="md"
|
||||
padding="md"
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => navigate(`/dashboard/gl-ethiopia/clearance/${c.id}`)}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap="sm">
|
||||
<Flag size={18} className="text-[color:var(--freight-brand)]" />
|
||||
<div>
|
||||
<Text fw={700}>{c.reference}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{c.tradeDirection} · {c.status}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
<Badge variant="light" color="edr-green">
|
||||
{c.tradeDirection}
|
||||
</Badge>
|
||||
<ChevronRight size={18} className="text-muted-foreground" />
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -207,6 +207,105 @@ export const contractsService = {
|
||||
finalizeClearance: (id: string) =>
|
||||
postContract<Freight.IContract>(C.CLEARANCE_FINALIZE(id)),
|
||||
|
||||
getEtClearanceQueue: async (): Promise<PaginatedContracts> => {
|
||||
const response = await client.get<PaginatedContracts>(C.CLEARANCE_ET_QUEUE);
|
||||
const data = unwrap(response.data);
|
||||
return {
|
||||
items: (data.items ?? []) as Freight.IContract[],
|
||||
total: data.total ?? 0,
|
||||
};
|
||||
},
|
||||
|
||||
getDjClearanceQueue: async (): Promise<PaginatedContracts> => {
|
||||
const response = await client.get<PaginatedContracts>(C.CLEARANCE_DJ_QUEUE);
|
||||
const data = unwrap(response.data);
|
||||
return {
|
||||
items: (data.items ?? []) as Freight.IContract[],
|
||||
total: data.total ?? 0,
|
||||
};
|
||||
},
|
||||
|
||||
uploadDeclaration: async (
|
||||
id: string,
|
||||
files: Record<string, File | null>,
|
||||
): Promise<Freight.IContract> => {
|
||||
const form = new FormData();
|
||||
for (const [key, file] of Object.entries(files)) {
|
||||
if (file) form.append(key, file);
|
||||
}
|
||||
const response = await client.post(C.CLEARANCE_DECLARATION(id), form, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
return unwrap(response.data) as Freight.IContract;
|
||||
},
|
||||
|
||||
adviseContractDuty: (
|
||||
id: string,
|
||||
payload: {
|
||||
dutyRequired: boolean;
|
||||
amount?: number;
|
||||
currency?: string;
|
||||
declarationSerial?: string;
|
||||
},
|
||||
) => postContract<Freight.IContract>(C.CLEARANCE_DUTY(id), payload),
|
||||
|
||||
uploadContractTransitPermit: async (
|
||||
id: string,
|
||||
file: File,
|
||||
): Promise<Freight.IContract> => {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
const response = await client.post(C.CLEARANCE_TRANSIT_PERMIT(id), form, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
return unwrap(response.data) as Freight.IContract;
|
||||
},
|
||||
|
||||
uploadDeliveryOrder: async (
|
||||
id: string,
|
||||
file: File,
|
||||
): Promise<Freight.IContract> => {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
const response = await client.post(C.CLEARANCE_DELIVERY_ORDER(id), form, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
return unwrap(response.data) as Freight.IContract;
|
||||
},
|
||||
|
||||
uploadReleaseOrder: async (
|
||||
id: string,
|
||||
file: File,
|
||||
vesselDepartureDate: string,
|
||||
): Promise<{ contract: Freight.IContract; hold: boolean; holdReason?: string }> => {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
form.append("vesselDepartureDate", vesselDepartureDate);
|
||||
const response = await client.post(C.CLEARANCE_RELEASE_ORDER(id), form, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
return unwrap(response.data) as {
|
||||
contract: Freight.IContract;
|
||||
hold: boolean;
|
||||
holdReason?: string;
|
||||
};
|
||||
},
|
||||
|
||||
requestRoAmendment: (id: string, note?: string) =>
|
||||
postContract<Freight.IContract>(C.CLEARANCE_RO_AMENDMENT(id), { note }),
|
||||
|
||||
confirmExportRelease: (id: string) =>
|
||||
postContract<Freight.IContract>(C.CLEARANCE_EXPORT_RELEASE(id)),
|
||||
|
||||
uploadTransportDocument: async (bookingId: string, file: File) => {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
const response = await client.post(C.BOOKING_TRANSPORT_DOCUMENT(bookingId), form, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
// ── Path A self-clearance (Operations review) ──
|
||||
getOpsClearanceQueue: async (): Promise<PaginatedContracts> => {
|
||||
const response = await client.get<PaginatedContracts>(
|
||||
|
||||
Reference in New Issue
Block a user