mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 18:48:11 +00:00
finilize gl
This commit is contained in:
@@ -14,6 +14,7 @@ import {
|
||||
Send,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
Ship,
|
||||
SlidersHorizontal,
|
||||
Train,
|
||||
Truck,
|
||||
@@ -43,10 +44,11 @@ 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 GlDjiboutiClearanceListPage from "./pages/contracts/GlDjiboutiClearanceListPage";
|
||||
import GlClearanceDetailPage from "./pages/contracts/GlClearanceDetailPage";
|
||||
import ShipmentRequestsPage from "./pages/contracts/ShipmentRequestsPage";
|
||||
import ShipmentRequestDetailPage from "./pages/contracts/ShipmentRequestDetailPage";
|
||||
import GlCreateBookingForm from "./components/contracts/GlCreateBookingForm";
|
||||
import BookingMilestonesPage from "./pages/contracts/BookingMilestonesPage";
|
||||
import DocumentClearanceDetailPage from "./pages/bookings/DocumentClearanceDetailPage";
|
||||
import CustomerDetailPage from "./pages/customers/CustomerDetailPage";
|
||||
import CustomersPage from "./pages/customers/CustomersPage";
|
||||
@@ -148,14 +150,19 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
permission: [
|
||||
FREIGHT_PERMS.contracts.clearanceReview,
|
||||
FREIGHT_PERMS.contracts.clearanceEtActions,
|
||||
FREIGHT_PERMS.contracts.clearanceDjActions,
|
||||
],
|
||||
},
|
||||
// {
|
||||
// label: "Shipment Requests",
|
||||
// href: "/dashboard/shipment-requests",
|
||||
// icon: <Send />,
|
||||
// permission: FREIGHT_PERMS.contracts.createBooking,
|
||||
// },
|
||||
{
|
||||
label: "Shipment Requests",
|
||||
href: "/dashboard/shipment-requests",
|
||||
icon: <Send />,
|
||||
permission: FREIGHT_PERMS.contracts.createBooking,
|
||||
label: "GL Djibouti Clearance",
|
||||
href: "/dashboard/gl-djibouti/clearance",
|
||||
icon: <Ship />,
|
||||
permission: FREIGHT_PERMS.contracts.clearanceDjActions,
|
||||
},
|
||||
{
|
||||
label: "Train Schedules",
|
||||
@@ -549,7 +556,6 @@ const App = () => {
|
||||
permission={[
|
||||
FREIGHT_PERMS.contracts.clearanceReview,
|
||||
FREIGHT_PERMS.contracts.clearanceEtActions,
|
||||
FREIGHT_PERMS.contracts.clearanceDjActions,
|
||||
]}
|
||||
>
|
||||
<ContractClearanceListPage />
|
||||
@@ -563,17 +569,30 @@ const App = () => {
|
||||
permission={[
|
||||
FREIGHT_PERMS.contracts.clearanceReview,
|
||||
FREIGHT_PERMS.contracts.clearanceEtActions,
|
||||
FREIGHT_PERMS.contracts.clearanceDjActions,
|
||||
]}
|
||||
>
|
||||
<ContractClearanceDetailPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route path="gl-ethiopia/clearance" element={<LegacyGlClearanceRedirect />} />
|
||||
<Route path="gl-ethiopia/clearance/:id" element={<LegacyGlClearanceRedirect />} />
|
||||
<Route path="gl-djibouti/clearance" element={<LegacyGlClearanceRedirect />} />
|
||||
<Route path="gl-djibouti/clearance/:id" element={<LegacyGlClearanceRedirect />} />
|
||||
<Route path="gl-ethiopia/clearance" element={<LegacyGlEthiopiaClearanceRedirect />} />
|
||||
<Route path="gl-ethiopia/clearance/:id" element={<LegacyGlEthiopiaClearanceRedirect />} />
|
||||
<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 />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
{/* Path A ops queue out of scope for now → fold into the GL hub. */}
|
||||
<Route
|
||||
path="contracts/ops-clearance"
|
||||
@@ -589,11 +608,7 @@ const App = () => {
|
||||
/>
|
||||
<Route
|
||||
path="bookings/:id/milestones"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.contracts.clearanceReview}>
|
||||
<BookingMilestonesPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
element={<BookingMilestonesRedirect />}
|
||||
/>
|
||||
<Route path="warehouses" element={<WarehouseListPage />} />
|
||||
<Route path="warehouses/:id" element={<WarehouseDetailPage />} />
|
||||
@@ -917,8 +932,16 @@ const App = () => {
|
||||
);
|
||||
};
|
||||
|
||||
/** Redirect legacy GL Ethiopia/Djibouti clearance URLs to the unified hub. */
|
||||
function LegacyGlClearanceRedirect() {
|
||||
/** Redirect removed milestones page to document clearance. */
|
||||
function BookingMilestonesRedirect() {
|
||||
const { id } = useParams();
|
||||
return (
|
||||
<Navigate to={`/dashboard/bookings/${id}/clearance`} replace />
|
||||
);
|
||||
}
|
||||
|
||||
/** Redirect legacy GL Ethiopia clearance URLs to the unified document clearance hub. */
|
||||
function LegacyGlEthiopiaClearanceRedirect() {
|
||||
const { id } = useParams();
|
||||
if (id) {
|
||||
return <Navigate to={`/dashboard/contracts/clearance/${id}`} replace />;
|
||||
|
||||
@@ -41,6 +41,12 @@ export interface ClearanceReviewSectionProps {
|
||||
onChanged?: () => void;
|
||||
/** Hide the inline progress summary (e.g. when the parent renders its own). */
|
||||
hideSummary?: boolean;
|
||||
/** Lock approve actions after document review phase completes. */
|
||||
approvalsLocked?: boolean;
|
||||
/** Block new queries after pre-clearance finalization. */
|
||||
queriesLocked?: boolean;
|
||||
/** Read-only audit view — no approve/query actions. */
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
const STATUS_META: Record<
|
||||
@@ -64,6 +70,9 @@ export function ClearanceReviewSection({
|
||||
bookingId,
|
||||
onChanged,
|
||||
hideSummary,
|
||||
approvalsLocked = false,
|
||||
queriesLocked = false,
|
||||
readOnly = false,
|
||||
}: ClearanceReviewSectionProps) {
|
||||
const qc = useQueryClient();
|
||||
const [queryNotes, setQueryNotes] = useState<Record<string, string>>({});
|
||||
@@ -147,6 +156,11 @@ export function ClearanceReviewSection({
|
||||
return { total, approved, queried, pending, pct };
|
||||
}, [customerDocs]);
|
||||
|
||||
const hasDocsAwaitingApproval = customerDocs.some(
|
||||
(d) => d.file && d.reviewStatus !== "APPROVED",
|
||||
);
|
||||
const effectiveApprovalsLocked = approvalsLocked && !hasDocsAwaitingApproval;
|
||||
|
||||
if (isLoading || !clearance) {
|
||||
return (
|
||||
<Group justify="center" py="xl" gap={10}>
|
||||
@@ -194,6 +208,9 @@ export function ClearanceReviewSection({
|
||||
<DocReviewCard
|
||||
key={`${doc.settingCode}:${doc.fileKey}`}
|
||||
doc={doc}
|
||||
approvalsLocked={effectiveApprovalsLocked}
|
||||
queriesLocked={queriesLocked}
|
||||
readOnly={readOnly}
|
||||
note={queryNotes[doc.fileKey] ?? ""}
|
||||
queryOpen={openQuery[doc.fileKey] ?? false}
|
||||
onToggleQuery={(open) =>
|
||||
@@ -397,6 +414,9 @@ function StatPill({
|
||||
|
||||
function DocReviewCard({
|
||||
doc,
|
||||
approvalsLocked,
|
||||
queriesLocked,
|
||||
readOnly,
|
||||
note,
|
||||
queryOpen,
|
||||
onToggleQuery,
|
||||
@@ -407,6 +427,9 @@ function DocReviewCard({
|
||||
busy,
|
||||
}: {
|
||||
doc: Freight.ClearanceDocument;
|
||||
approvalsLocked: boolean;
|
||||
queriesLocked: boolean;
|
||||
readOnly: boolean;
|
||||
note: string;
|
||||
queryOpen: boolean;
|
||||
onToggleQuery: (open: boolean) => void;
|
||||
@@ -500,22 +523,24 @@ function DocReviewCard({
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{hasFile && (
|
||||
{hasFile && !readOnly && (
|
||||
<Box mt="sm">
|
||||
{!queryOpen ? (
|
||||
<Group justify="flex-end" gap={8}>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="light"
|
||||
color="red"
|
||||
radius="md"
|
||||
leftSection={<MessageSquareWarning size={14} />}
|
||||
disabled={busy}
|
||||
onClick={() => onToggleQuery(true)}
|
||||
>
|
||||
Open query
|
||||
</Button>
|
||||
{!isApproved && (
|
||||
{!queriesLocked && (
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="light"
|
||||
color="red"
|
||||
radius="md"
|
||||
leftSection={<MessageSquareWarning size={14} />}
|
||||
disabled={busy}
|
||||
onClick={() => onToggleQuery(true)}
|
||||
>
|
||||
Open query
|
||||
</Button>
|
||||
)}
|
||||
{!isApproved && !approvalsLocked && (
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="edr-green"
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Badge, Stack, Tabs, Text } from "@mantine/core";
|
||||
import { AlertTriangle, FileText, ShieldAlert } from "lucide-react";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
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";
|
||||
|
||||
export interface ClearanceOpsTabsProps {
|
||||
bookingId: string | undefined;
|
||||
milestones?: Freight.IClearanceMilestone[];
|
||||
/** When false, only the clearance tab content is rendered (no tab bar). */
|
||||
showOpsTabs?: boolean;
|
||||
clearanceTab: ReactNode;
|
||||
/** Phased customs workflow files — enables the Uploaded documents tab. */
|
||||
workflowFiles?: Freight.ClearanceWorkflowFile[];
|
||||
showWorkflowFilesTab?: boolean;
|
||||
tradeDirection?: string;
|
||||
onViewFile?: (file: { name: string; url: string }) => void;
|
||||
onDownloadFile?: (file: { id: string; name: string }) => void;
|
||||
}
|
||||
|
||||
function findMilestone(
|
||||
milestones: Freight.IClearanceMilestone[] | undefined,
|
||||
code: string,
|
||||
): Freight.IClearanceMilestone | undefined {
|
||||
return milestones?.find((m) => m.milestoneCode === code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Document Clearance detail layout: primary clearance workflow plus optional
|
||||
* uploaded documents, post-booking risk assignment, and incident reporting tabs.
|
||||
*/
|
||||
export function ClearanceOpsTabs({
|
||||
bookingId,
|
||||
milestones,
|
||||
showOpsTabs = true,
|
||||
clearanceTab,
|
||||
workflowFiles = [],
|
||||
showWorkflowFilesTab = false,
|
||||
tradeDirection = "IMPORT",
|
||||
onViewFile,
|
||||
onDownloadFile,
|
||||
}: ClearanceOpsTabsProps) {
|
||||
const riskMs = findMilestone(milestones, "RISK_ASSIGNED");
|
||||
const hasOps = Boolean(bookingId);
|
||||
const isExport = tradeDirection === "EXPORT";
|
||||
const uploadedDocCount = workflowFiles.filter((f) => {
|
||||
if (!f.file) return false;
|
||||
if (isExport) return f.category !== "duty";
|
||||
return true;
|
||||
}).length;
|
||||
const showDocuments = showWorkflowFilesTab && Boolean(onViewFile);
|
||||
const hasTabs = (showOpsTabs && hasOps) || showDocuments;
|
||||
|
||||
if (!hasTabs) {
|
||||
return <>{clearanceTab}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tabs defaultValue="clearance" keepMounted={false}>
|
||||
<Tabs.List mb="md">
|
||||
<Tabs.Tab value="clearance">Clearance</Tabs.Tab>
|
||||
{showDocuments ? (
|
||||
<Tabs.Tab
|
||||
value="documents"
|
||||
leftSection={<FileText size={14} />}
|
||||
rightSection={
|
||||
uploadedDocCount > 0 ? (
|
||||
<Badge size="xs" variant="light" color="edr-green" circle>
|
||||
{uploadedDocCount}
|
||||
</Badge>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
Uploaded documents
|
||||
</Tabs.Tab>
|
||||
) : null}
|
||||
{showOpsTabs && riskMs ? (
|
||||
<Tabs.Tab value="risk" leftSection={<ShieldAlert size={14} />}>
|
||||
Risk assignment
|
||||
</Tabs.Tab>
|
||||
) : null}
|
||||
{showOpsTabs && bookingId ? (
|
||||
<Tabs.Tab value="incidents" leftSection={<AlertTriangle size={14} />}>
|
||||
Incidents
|
||||
</Tabs.Tab>
|
||||
) : null}
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="clearance">{clearanceTab}</Tabs.Panel>
|
||||
|
||||
{showDocuments ? (
|
||||
<Tabs.Panel value="documents">
|
||||
<ClearanceUploadedDocumentsPanel
|
||||
files={workflowFiles}
|
||||
tradeDirection={tradeDirection}
|
||||
onView={onViewFile!}
|
||||
onDownload={onDownloadFile}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
) : null}
|
||||
|
||||
{showOpsTabs && riskMs && bookingId ? (
|
||||
<Tabs.Panel value="risk">
|
||||
<SectionCard icon={ShieldAlert} title="Customs risk" accent="edr-green">
|
||||
<AssignRiskCard bookingId={bookingId} milestone={riskMs} />
|
||||
</SectionCard>
|
||||
</Tabs.Panel>
|
||||
) : null}
|
||||
|
||||
{showOpsTabs && bookingId ? (
|
||||
<Tabs.Panel value="incidents">
|
||||
<SectionCard icon={AlertTriangle} title="Incident reports" accent="edr-green">
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" c="dimmed">
|
||||
Log container or seal issues discovered during clearance handling.
|
||||
</Text>
|
||||
<IncidentReportCard bookingId={bookingId} />
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
</Tabs.Panel>
|
||||
) : null}
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { useMemo } from "react";
|
||||
import { Badge, Box, Stack, Tabs, Text, ThemeIcon } from "@mantine/core";
|
||||
import { FileText, Receipt, Ship, Truck } from "lucide-react";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
import { PhasedUploadedFileRow } from "@/components/contracts/PhasedUploadedFileRow";
|
||||
|
||||
type TabValue = Freight.ClearanceWorkflowFileCategory;
|
||||
|
||||
type TabConfig = {
|
||||
value: TabValue;
|
||||
label: string;
|
||||
icon: typeof FileText;
|
||||
emptyHint: string;
|
||||
};
|
||||
|
||||
function tabConfigForTradeDirection(tradeDirection: string): TabConfig[] {
|
||||
if (tradeDirection === "EXPORT") {
|
||||
return [
|
||||
{
|
||||
value: "declaration",
|
||||
label: "Declaration",
|
||||
icon: FileText,
|
||||
emptyHint: "No declaration uploaded yet.",
|
||||
},
|
||||
{
|
||||
value: "djibouti",
|
||||
label: "Release order",
|
||||
icon: Ship,
|
||||
emptyHint: "No release order uploaded yet.",
|
||||
},
|
||||
{
|
||||
value: "transit",
|
||||
label: "Transit Permit",
|
||||
icon: Truck,
|
||||
emptyHint: "No transit permit uploaded yet.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
value: "declaration",
|
||||
label: "Declaration",
|
||||
icon: FileText,
|
||||
emptyHint: "No declaration uploaded yet.",
|
||||
},
|
||||
{
|
||||
value: "duty",
|
||||
label: "Duty notice",
|
||||
icon: Receipt,
|
||||
emptyHint: "No duty notice or payment slip uploaded yet.",
|
||||
},
|
||||
{
|
||||
value: "transit",
|
||||
label: "Transit permit",
|
||||
icon: Truck,
|
||||
emptyHint: "No transit permit uploaded yet.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function subtitleForTradeDirection(tradeDirection: string): string {
|
||||
return tradeDirection === "EXPORT"
|
||||
? "Declaration, release order, and transit permit files for this clearance."
|
||||
: "Declaration, duty notice, and transit permit files for this clearance.";
|
||||
}
|
||||
|
||||
function footerHintForTradeDirection(tradeDirection: string): string {
|
||||
return tradeDirection === "EXPORT"
|
||||
? "Files appear here once GL uploads the declaration and release order, and after booking when the transit permit is uploaded."
|
||||
: "Files appear here once GL Ethiopia uploads declaration, duty notice, or transit permit documents.";
|
||||
}
|
||||
|
||||
export interface ClearanceUploadedDocumentsPanelProps {
|
||||
files: Freight.ClearanceWorkflowFile[];
|
||||
tradeDirection?: string;
|
||||
onView: (file: { name: string; url: string }) => void;
|
||||
onDownload?: (file: { id: string; name: string }) => void;
|
||||
}
|
||||
|
||||
export function ClearanceUploadedDocumentsPanel({
|
||||
files,
|
||||
tradeDirection = "IMPORT",
|
||||
onView,
|
||||
onDownload,
|
||||
}: ClearanceUploadedDocumentsPanelProps) {
|
||||
const tabConfig = useMemo(
|
||||
() => tabConfigForTradeDirection(tradeDirection),
|
||||
[tradeDirection],
|
||||
);
|
||||
const isExport = tradeDirection === "EXPORT";
|
||||
|
||||
const visibleFiles = useMemo(
|
||||
() =>
|
||||
isExport ? files.filter((f) => f.category !== "duty") : files,
|
||||
[files, isExport],
|
||||
);
|
||||
|
||||
const uploadedCount = visibleFiles.filter((f) => f.file).length;
|
||||
|
||||
const defaultTab =
|
||||
tabConfig.find((tab) =>
|
||||
visibleFiles.some((f) => f.category === tab.value && f.file),
|
||||
)?.value ?? tabConfig[0]?.value ?? "declaration";
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
icon={FileText}
|
||||
title="Uploaded customs documents"
|
||||
subtitle={subtitleForTradeDirection(tradeDirection)}
|
||||
accent="edr-green"
|
||||
>
|
||||
<Tabs defaultValue={defaultTab} keepMounted={false}>
|
||||
<Tabs.List mb="md">
|
||||
{tabConfig.map((tab) => {
|
||||
const count = visibleFiles.filter(
|
||||
(f) => f.category === tab.value && f.file,
|
||||
).length;
|
||||
const Icon = tab.icon;
|
||||
return (
|
||||
<Tabs.Tab
|
||||
key={tab.value}
|
||||
value={tab.value}
|
||||
leftSection={<Icon size={14} />}
|
||||
rightSection={
|
||||
count > 0 ? (
|
||||
<Badge size="xs" variant="light" color="edr-green" circle>
|
||||
{count}
|
||||
</Badge>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{tab.label}
|
||||
</Tabs.Tab>
|
||||
);
|
||||
})}
|
||||
</Tabs.List>
|
||||
|
||||
{tabConfig.map((tab) => {
|
||||
const items = visibleFiles.filter(
|
||||
(f) => f.category === tab.value && f.file,
|
||||
);
|
||||
const Icon = tab.icon;
|
||||
|
||||
return (
|
||||
<Tabs.Panel key={tab.value} value={tab.value}>
|
||||
{items.length > 0 ? (
|
||||
<Stack gap="sm">
|
||||
{items.map((item) => (
|
||||
<PhasedUploadedFileRow
|
||||
key={item.code}
|
||||
label={item.label}
|
||||
file={item.file!}
|
||||
onView={onView}
|
||||
onDownload={onDownload}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
<EmptyTabState icon={Icon} hint={tab.emptyHint} />
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
);
|
||||
})}
|
||||
</Tabs>
|
||||
|
||||
{uploadedCount === 0 ? (
|
||||
<Text size="xs" c="dimmed" mt="md">
|
||||
{footerHintForTradeDirection(tradeDirection)}
|
||||
</Text>
|
||||
) : null}
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyTabState({
|
||||
icon: Icon,
|
||||
hint,
|
||||
}: {
|
||||
icon: typeof FileText;
|
||||
hint: string;
|
||||
}) {
|
||||
return (
|
||||
<Box
|
||||
py={40}
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
border: "1px dashed var(--mantine-color-gray-4)",
|
||||
background: "var(--mantine-color-gray-0)",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<Stack gap={8} align="center">
|
||||
<ThemeIcon variant="light" color="gray" radius="xl" size={44}>
|
||||
<Icon size={20} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" c="dimmed" maw={320}>
|
||||
{hint}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -56,9 +56,13 @@ export interface ContractClearanceReviewSectionProps {
|
||||
readOnly?: boolean;
|
||||
/**
|
||||
* Document approvals are locked (e.g. after all docs approved in phased flow)
|
||||
* but queries remain available until {@link readOnly}.
|
||||
* but queries remain available until {@link queriesLocked} or {@link readOnly}.
|
||||
*/
|
||||
approvalsLocked?: boolean;
|
||||
/**
|
||||
* Pre-clearance finalized — block opening new queries on customer documents.
|
||||
*/
|
||||
queriesLocked?: boolean;
|
||||
/**
|
||||
* ONE_TIME customs contracts use the phased milestone workflow. Hides the
|
||||
* legacy "Finalize clearance" shortcut; booking readiness follows delivery
|
||||
@@ -102,6 +106,7 @@ export function ContractClearanceReviewSection({
|
||||
readOnly = false,
|
||||
phasedCustoms = false,
|
||||
approvalsLocked = false,
|
||||
queriesLocked = false,
|
||||
}: ContractClearanceReviewSectionProps) {
|
||||
const [queryNotes, setQueryNotes] = useState<Record<string, string>>({});
|
||||
const [openQuery, setOpenQuery] = useState<Record<string, boolean>>({});
|
||||
@@ -150,6 +155,11 @@ export function ContractClearanceReviewSection({
|
||||
.filter((d) => d.file && d.reviewStatus !== "APPROVED")
|
||||
.map((d) => d.fileKey);
|
||||
|
||||
const hasDocsAwaitingApproval = customerDocs.some(
|
||||
(d) => d.file && d.reviewStatus !== "APPROVED",
|
||||
);
|
||||
const effectiveApprovalsLocked = approvalsLocked && !hasDocsAwaitingApproval;
|
||||
|
||||
if (isLoading || !clearance) {
|
||||
return (
|
||||
<Group justify="center" py="xl" gap={10}>
|
||||
@@ -183,7 +193,7 @@ export function ContractClearanceReviewSection({
|
||||
subtitle={
|
||||
readOnly
|
||||
? `Reviewed by the ${reviewerTeam} team.`
|
||||
: approvalsLocked
|
||||
: effectiveApprovalsLocked
|
||||
? "Documents are approved — you can still open a query if something needs fixing."
|
||||
: "Approve each document, or open a query to tell the customer what to fix."
|
||||
}
|
||||
@@ -192,7 +202,7 @@ export function ContractClearanceReviewSection({
|
||||
<Text size="xs" c="dimmed" fw={600}>
|
||||
{stats.approved}/{stats.total} approved
|
||||
</Text>
|
||||
{!readOnly && !approvalsLocked && approvableKeys.length > 0 && (
|
||||
{!readOnly && !effectiveApprovalsLocked && approvableKeys.length > 0 && (
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="edr-green"
|
||||
@@ -236,7 +246,8 @@ export function ContractClearanceReviewSection({
|
||||
doc={doc}
|
||||
reviewerTeam={reviewerTeam}
|
||||
readOnly={readOnly}
|
||||
approvalsLocked={approvalsLocked}
|
||||
approvalsLocked={effectiveApprovalsLocked}
|
||||
queriesLocked={queriesLocked}
|
||||
note={queryNotes[doc.fileKey] ?? ""}
|
||||
queryOpen={openQuery[doc.fileKey] ?? false}
|
||||
onToggleQuery={(open) =>
|
||||
@@ -394,7 +405,7 @@ export function ContractClearanceReviewSection({
|
||||
</Text>
|
||||
</Group>
|
||||
</Paper>
|
||||
) : phasedCustoms && approvalsLocked ? (
|
||||
) : phasedCustoms && effectiveApprovalsLocked ? (
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<ThemeIcon variant="light" color="edr-green" radius="md" size={28}>
|
||||
@@ -402,7 +413,10 @@ export function ContractClearanceReviewSection({
|
||||
</ThemeIcon>
|
||||
<Text fz="12.5px" c="dimmed">
|
||||
Document review is complete. Use the action panel for declaration, duty, and
|
||||
transit steps — or open a query above if a customer document needs correction.
|
||||
transit steps
|
||||
{queriesLocked
|
||||
? ". Pre-clearance is finalized — customer documents can no longer be queried."
|
||||
: " — or open a query above if a customer document needs correction."}
|
||||
</Text>
|
||||
</Group>
|
||||
</Paper>
|
||||
@@ -419,7 +433,7 @@ export function ContractClearanceReviewSection({
|
||||
</ThemeIcon>
|
||||
<Text fz="12.5px" c="dimmed">
|
||||
{clearance.allApproved
|
||||
? "All required documents are approved. Upload declaration, duty, transit permit, and delivery order in the action panel."
|
||||
? "All required documents are approved. Continue declaration, duty, and transit in the action panel."
|
||||
: "Approve every required document to unlock the customs milestone steps."}
|
||||
</Text>
|
||||
</Group>
|
||||
@@ -498,6 +512,7 @@ function DocReviewCard({
|
||||
reviewerTeam,
|
||||
readOnly,
|
||||
approvalsLocked,
|
||||
queriesLocked,
|
||||
note,
|
||||
queryOpen,
|
||||
onToggleQuery,
|
||||
@@ -511,6 +526,7 @@ function DocReviewCard({
|
||||
reviewerTeam: string;
|
||||
readOnly: boolean;
|
||||
approvalsLocked: boolean;
|
||||
queriesLocked: boolean;
|
||||
note: string;
|
||||
queryOpen: boolean;
|
||||
onToggleQuery: (open: boolean) => void;
|
||||
@@ -636,17 +652,19 @@ function DocReviewCard({
|
||||
<Box mt="sm">
|
||||
{!queryOpen ? (
|
||||
<Group justify="flex-end" gap={8}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="light"
|
||||
color="red"
|
||||
radius="md"
|
||||
leftSection={<MessageSquareWarning size={15} />}
|
||||
disabled={busy}
|
||||
onClick={() => onToggleQuery(true)}
|
||||
>
|
||||
Open query
|
||||
</Button>
|
||||
{!queriesLocked && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="light"
|
||||
color="red"
|
||||
radius="md"
|
||||
leftSection={<MessageSquareWarning size={15} />}
|
||||
disabled={busy}
|
||||
onClick={() => onToggleQuery(true)}
|
||||
>
|
||||
Open query
|
||||
</Button>
|
||||
)}
|
||||
{!isApproved && !approvalsLocked && (
|
||||
<Button
|
||||
size="sm"
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { useState } from "react";
|
||||
import { Button, Group, Modal, Stack, Text } from "@mantine/core";
|
||||
import { DateInput } from "@mantine/dates";
|
||||
import { Ship, Upload } from "lucide-react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { PhasedFileDropzone } from "@/components/contracts/PhasedFileDropzone";
|
||||
import { findWorkflowFile } from "@/components/contracts/PhasedUploadedFileRow";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
export type GlClearanceUploadKind = "do" | "ro";
|
||||
|
||||
export interface GlClearanceUploadModalProps {
|
||||
opened: boolean;
|
||||
kind: GlClearanceUploadKind | null;
|
||||
onClose: () => void;
|
||||
entityId: string;
|
||||
isBooking: boolean;
|
||||
workflowFiles?: Freight.ClearanceWorkflowFile[];
|
||||
vesselDepartureDate?: string | null;
|
||||
onSuccess?: () => void;
|
||||
onPreview?: (file: { name: string; url: string }) => void;
|
||||
}
|
||||
|
||||
export function GlClearanceUploadModal({
|
||||
opened,
|
||||
kind,
|
||||
onClose,
|
||||
entityId,
|
||||
isBooking,
|
||||
workflowFiles = [],
|
||||
vesselDepartureDate,
|
||||
onSuccess,
|
||||
onPreview,
|
||||
}: GlClearanceUploadModalProps) {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [vesselDate, setVesselDate] = useState<Date | null>(
|
||||
vesselDepartureDate ? new Date(vesselDepartureDate) : null,
|
||||
);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const isDo = kind === "do";
|
||||
const isRo = kind === "ro";
|
||||
const replaceMode = isDo
|
||||
? Boolean(findWorkflowFile(workflowFiles, "delivery_order"))
|
||||
: Boolean(findWorkflowFile(workflowFiles, "release_order"));
|
||||
|
||||
const close = () => {
|
||||
setFile(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!file || !kind) return;
|
||||
if (isRo && !vesselDate) {
|
||||
toast.error("Vessel departure date is required.");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
if (isDo) {
|
||||
if (isBooking) {
|
||||
await bookingsService.uploadDeliveryOrder(entityId, file);
|
||||
} else {
|
||||
await contractsService.uploadDeliveryOrder(entityId, file);
|
||||
}
|
||||
toast.success(replaceMode ? "Delivery Order updated" : "Delivery Order uploaded");
|
||||
} else {
|
||||
const iso = vesselDate!.toISOString().slice(0, 10);
|
||||
const result = isBooking
|
||||
? await bookingsService.uploadReleaseOrder(entityId, file, iso)
|
||||
: await contractsService.uploadReleaseOrder(entityId, file, iso);
|
||||
if (result.hold) {
|
||||
toast.error(result.holdReason ?? "Vessel date too soon");
|
||||
} else {
|
||||
toast.success(replaceMode ? "Release Order updated" : "Release Order uploaded");
|
||||
}
|
||||
}
|
||||
setFile(null);
|
||||
onSuccess?.();
|
||||
close();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Upload failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened && kind != null}
|
||||
onClose={close}
|
||||
title={
|
||||
<Group gap={8}>
|
||||
<Ship size={18} />
|
||||
<Text fw={700}>{isDo ? "Upload Delivery Order" : "Upload Release Order"}</Text>
|
||||
</Group>
|
||||
}
|
||||
radius="md"
|
||||
size="md"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
{isDo
|
||||
? "Upload the Djibouti Delivery Order (DO) for this import shipment."
|
||||
: "Upload the Release Order and confirm the vessel departure date."}
|
||||
</Text>
|
||||
|
||||
{isRo ? (
|
||||
<DateInput
|
||||
label="Vessel departure date"
|
||||
value={vesselDate}
|
||||
onChange={(v) => setVesselDate(v ? new Date(v) : null)}
|
||||
size="sm"
|
||||
required
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<PhasedFileDropzone
|
||||
label={isDo ? "Delivery Order file" : "Release Order file"}
|
||||
description="PDF or image."
|
||||
value={file}
|
||||
onChange={setFile}
|
||||
replaceMode={replaceMode}
|
||||
onPreview={onPreview}
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={close} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
loading={loading}
|
||||
disabled={!file || (isRo && !vesselDate)}
|
||||
leftSection={<Upload size={16} />}
|
||||
onClick={() => void submit()}
|
||||
>
|
||||
{replaceMode
|
||||
? isDo
|
||||
? "Replace DO"
|
||||
: "Replace RO"
|
||||
: isDo
|
||||
? "Upload DO"
|
||||
: "Upload RO"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,122 @@
|
||||
import { Box, Button, Group, Paper, Stack, Text } from "@mantine/core";
|
||||
import { FileText, Upload } from "lucide-react";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { PhasedFileDropzone } from "@/components/contracts/PhasedFileDropzone";
|
||||
import { PhasedUploadedFileRow, findWorkflowFile } from "@/components/contracts/PhasedUploadedFileRow";
|
||||
|
||||
export interface PhasedDocumentUploadFieldProps {
|
||||
fields: Array<{ key: string; label: string }>;
|
||||
files: Record<string, File | null>;
|
||||
onChange: (key: string, file: File | null) => void;
|
||||
workflowFiles?: Freight.ClearanceWorkflowFile[];
|
||||
helperText: string;
|
||||
replaceMode?: boolean;
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
submitLabel?: string;
|
||||
onSubmit: () => void;
|
||||
onViewFile?: (file: { name: string; url: string }) => void;
|
||||
onDownloadFile?: (file: { id: string; name: string }) => void;
|
||||
}
|
||||
|
||||
/** Consistent phased customs document upload with drag-and-drop, preview, and uploaded rows. */
|
||||
export function PhasedDocumentUploadField({
|
||||
fields,
|
||||
files,
|
||||
onChange,
|
||||
workflowFiles = [],
|
||||
helperText,
|
||||
replaceMode = false,
|
||||
loading = false,
|
||||
disabled = false,
|
||||
submitLabel,
|
||||
onSubmit,
|
||||
onViewFile,
|
||||
onDownloadFile,
|
||||
}: PhasedDocumentUploadFieldProps) {
|
||||
const hasStaged = Object.values(files).some(Boolean);
|
||||
const uploaded = fields
|
||||
.map((f) => ({ ...f, file: findWorkflowFile(workflowFiles, f.key) }))
|
||||
.filter((f) => f.file);
|
||||
const multiField = fields.length > 1;
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{uploaded.length > 0 ? (
|
||||
<Stack gap={8}>
|
||||
<Text size="xs" fw={700} c="dimmed" tt="uppercase">
|
||||
Current file{uploaded.length > 1 ? "s" : ""}
|
||||
</Text>
|
||||
{uploaded.map((row) => (
|
||||
<PhasedUploadedFileRow
|
||||
key={row.key}
|
||||
label={row.label}
|
||||
file={row.file!}
|
||||
onView={onViewFile}
|
||||
onDownload={onDownloadFile}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
) : null}
|
||||
|
||||
<Paper withBorder radius="md" p="md" bg="var(--mantine-color-gray-0)">
|
||||
<Group gap={8} mb="sm" wrap="nowrap">
|
||||
<Box
|
||||
c="edr-green"
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 8,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "var(--mantine-color-edr-green-1)",
|
||||
}}
|
||||
>
|
||||
<FileText size={16} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Text size="sm" fw={700}>
|
||||
{replaceMode ? "Replace document" : "Upload document"}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{helperText}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
|
||||
<Stack gap="md">
|
||||
{fields.map((f) => (
|
||||
<PhasedFileDropzone
|
||||
key={f.key}
|
||||
label={multiField ? f.label : "Choose file"}
|
||||
description={
|
||||
multiField
|
||||
? uploaded.some((u) => u.key === f.key)
|
||||
? "Drop a new file to replace the current one."
|
||||
: `Upload ${f.label} (optional if another declaration type is provided).`
|
||||
: undefined
|
||||
}
|
||||
value={files[f.key] ?? null}
|
||||
onChange={(file) => onChange(f.key, file)}
|
||||
replaceMode={replaceMode || uploaded.some((u) => u.key === f.key)}
|
||||
onPreview={onViewFile}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Button
|
||||
color="edr-green"
|
||||
loading={loading}
|
||||
disabled={disabled || !hasStaged}
|
||||
leftSection={<Upload size={16} />}
|
||||
onClick={onSubmit}
|
||||
fullWidth
|
||||
>
|
||||
{submitLabel ?? (replaceMode ? "Replace document" : "Upload document")}
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import { Eye, FileText, Trash2, UploadCloud } from "lucide-react";
|
||||
import { isViewable } from "@edr/ui-common";
|
||||
|
||||
export interface PhasedFileDropzoneProps {
|
||||
label: string;
|
||||
description?: string;
|
||||
value: File | null;
|
||||
onChange: (file: File | null) => void;
|
||||
accept?: string;
|
||||
replaceMode?: boolean;
|
||||
onPreview?: (file: { name: string; url: string; mimeType?: string | null }) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${parseFloat((bytes / k ** i).toFixed(1))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
function isImageFile(file: File): boolean {
|
||||
if (file.type.startsWith("image/")) return true;
|
||||
const ext = file.name.split(".").pop()?.toLowerCase() ?? "";
|
||||
return ["png", "jpg", "jpeg", "webp", "gif", "bmp", "svg"].includes(ext);
|
||||
}
|
||||
|
||||
export function PhasedFileDropzone({
|
||||
label,
|
||||
description,
|
||||
value,
|
||||
onChange,
|
||||
accept = "application/pdf,image/*",
|
||||
replaceMode = false,
|
||||
onPreview,
|
||||
disabled = false,
|
||||
}: PhasedFileDropzoneProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
|
||||
const previewUrl = useMemo(
|
||||
() => (value ? URL.createObjectURL(value) : null),
|
||||
[value],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (previewUrl) URL.revokeObjectURL(previewUrl);
|
||||
};
|
||||
}, [previewUrl]);
|
||||
|
||||
const pickFile = (file: File | null) => {
|
||||
if (disabled) return;
|
||||
onChange(file);
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
if (disabled) return;
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file) pickFile(file);
|
||||
};
|
||||
|
||||
if (value && previewUrl) {
|
||||
const canPreview = onPreview && isViewable({ name: value.name, url: previewUrl, mimeType: value.type });
|
||||
const image = isImageFile(value);
|
||||
|
||||
return (
|
||||
<Stack gap={6}>
|
||||
<Text size="sm" fw={600}>
|
||||
{label}
|
||||
</Text>
|
||||
<Box
|
||||
p="sm"
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
border: "1px solid var(--mantine-color-edr-green-4)",
|
||||
background:
|
||||
"linear-gradient(135deg, var(--mantine-color-edr-green-0) 0%, #fff 70%)",
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap" align="center">
|
||||
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
{image ? (
|
||||
<UnstyledButton
|
||||
onClick={() =>
|
||||
canPreview && onPreview?.({ name: value.name, url: previewUrl, mimeType: value.type })
|
||||
}
|
||||
style={{
|
||||
width: 52,
|
||||
height: 52,
|
||||
flexShrink: 0,
|
||||
borderRadius: 10,
|
||||
overflow: "hidden",
|
||||
border: "1px solid var(--mantine-color-gray-3)",
|
||||
cursor: canPreview ? "pointer" : "default",
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt=""
|
||||
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
||||
/>
|
||||
</UnstyledButton>
|
||||
) : (
|
||||
<ThemeIcon variant="light" color="edr-green" radius="md" size={52}>
|
||||
<FileText size={22} />
|
||||
</ThemeIcon>
|
||||
)}
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text size="xs" fw={700} c="edr-green" tt="uppercase">
|
||||
Ready to upload
|
||||
</Text>
|
||||
<Text size="sm" fw={600} truncate>
|
||||
{value.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{formatBytes(value.size)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{canPreview ? (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="default"
|
||||
leftSection={<Eye size={13} />}
|
||||
onClick={() =>
|
||||
onPreview({ name: value.name, url: previewUrl, mimeType: value.type })
|
||||
}
|
||||
>
|
||||
Preview
|
||||
</Button>
|
||||
) : null}
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
radius="md"
|
||||
aria-label="Remove file"
|
||||
onClick={() => pickFile(null)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap={6}>
|
||||
<Text size="sm" fw={600}>
|
||||
{label}
|
||||
</Text>
|
||||
{description ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{description}
|
||||
</Text>
|
||||
) : null}
|
||||
<Box
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
if (!disabled) setDragOver(true);
|
||||
}}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => !disabled && inputRef.current?.click()}
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
border: `2px dashed ${
|
||||
dragOver
|
||||
? "var(--mantine-color-edr-green-5)"
|
||||
: "var(--mantine-color-gray-4)"
|
||||
}`,
|
||||
background: dragOver
|
||||
? "var(--mantine-color-edr-green-0)"
|
||||
: "var(--mantine-color-gray-0)",
|
||||
padding: "28px 20px",
|
||||
textAlign: "center",
|
||||
cursor: disabled ? "not-allowed" : "pointer",
|
||||
opacity: disabled ? 0.6 : 1,
|
||||
transition: "border-color 120ms ease, background 120ms ease",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
hidden
|
||||
disabled={disabled}
|
||||
onChange={(e) => pickFile(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
<Stack gap={8} align="center">
|
||||
<ThemeIcon
|
||||
variant="light"
|
||||
color={dragOver ? "edr-green" : "gray"}
|
||||
radius="xl"
|
||||
size={48}
|
||||
>
|
||||
<UploadCloud size={24} />
|
||||
</ThemeIcon>
|
||||
<Box>
|
||||
<Text size="sm" fw={600}>
|
||||
{dragOver
|
||||
? "Drop to upload"
|
||||
: replaceMode
|
||||
? "Drag & drop to replace"
|
||||
: "Drag & drop your file here"}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
or <span style={{ color: "var(--mantine-color-edr-green-7)", fontWeight: 600 }}>browse</span>{" "}
|
||||
— PDF or image
|
||||
</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export interface PhasedMultiFileDropzoneProps {
|
||||
label: string;
|
||||
description?: string;
|
||||
value: File[];
|
||||
onChange: (files: File[]) => void;
|
||||
accept?: string;
|
||||
replaceMode?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function PhasedMultiFileDropzone({
|
||||
label,
|
||||
description,
|
||||
value,
|
||||
onChange,
|
||||
accept = "application/pdf,image/*",
|
||||
replaceMode = false,
|
||||
disabled = false,
|
||||
}: PhasedMultiFileDropzoneProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
|
||||
const addFiles = (incoming: FileList | File[]) => {
|
||||
if (disabled) return;
|
||||
const next = [...value];
|
||||
for (const file of Array.from(incoming)) {
|
||||
if (!next.some((f) => f.name === file.name && f.size === file.size)) {
|
||||
next.push(file);
|
||||
}
|
||||
}
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
const removeAt = (index: number) => {
|
||||
if (disabled) return;
|
||||
onChange(value.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
if (e.dataTransfer.files.length > 0) addFiles(e.dataTransfer.files);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap={6}>
|
||||
<Text size="sm" fw={600}>
|
||||
{label}
|
||||
</Text>
|
||||
{description ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{description}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{value.length > 0 ? (
|
||||
<Stack gap={8}>
|
||||
{value.map((file, index) => (
|
||||
<Box
|
||||
key={`${file.name}-${file.size}-${index}`}
|
||||
p="sm"
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
border: "1px solid var(--mantine-color-edr-green-4)",
|
||||
background:
|
||||
"linear-gradient(135deg, var(--mantine-color-edr-green-0) 0%, #fff 70%)",
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap" align="center">
|
||||
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<ThemeIcon variant="light" color="edr-green" radius="md" size={44}>
|
||||
<FileText size={18} />
|
||||
</ThemeIcon>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text size="xs" fw={700} c="edr-green" tt="uppercase">
|
||||
Ready to upload
|
||||
</Text>
|
||||
<Text size="sm" fw={600} truncate>
|
||||
{file.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{formatBytes(file.size)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
radius="md"
|
||||
aria-label="Remove file"
|
||||
onClick={() => removeAt(index)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
) : null}
|
||||
|
||||
<Box
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
if (!disabled) setDragOver(true);
|
||||
}}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => !disabled && inputRef.current?.click()}
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
border: `2px dashed ${
|
||||
dragOver
|
||||
? "var(--mantine-color-edr-green-5)"
|
||||
: "var(--mantine-color-gray-4)"
|
||||
}`,
|
||||
background: dragOver
|
||||
? "var(--mantine-color-edr-green-0)"
|
||||
: "var(--mantine-color-gray-0)",
|
||||
padding: "28px 20px",
|
||||
textAlign: "center",
|
||||
cursor: disabled ? "not-allowed" : "pointer",
|
||||
opacity: disabled ? 0.6 : 1,
|
||||
transition: "border-color 120ms ease, background 120ms ease",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
multiple
|
||||
hidden
|
||||
disabled={disabled}
|
||||
onChange={(e) => {
|
||||
if (e.target.files?.length) addFiles(e.target.files);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<Stack gap={8} align="center">
|
||||
<ThemeIcon
|
||||
variant="light"
|
||||
color={dragOver ? "edr-green" : "gray"}
|
||||
radius="xl"
|
||||
size={48}
|
||||
>
|
||||
<UploadCloud size={24} />
|
||||
</ThemeIcon>
|
||||
<Box>
|
||||
<Text size="sm" fw={600}>
|
||||
{dragOver
|
||||
? "Drop to add files"
|
||||
: replaceMode
|
||||
? "Drag & drop to replace declaration files"
|
||||
: "Drag & drop declaration files here"}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
or{" "}
|
||||
<span style={{ color: "var(--mantine-color-edr-green-7)", fontWeight: 600 }}>
|
||||
browse
|
||||
</span>{" "}
|
||||
— select one or more PDF or image files
|
||||
</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Badge, Box, Button, Group, Paper, Text, ThemeIcon, Tooltip } from "@mantine/core";
|
||||
import { Download, Eye, FileText } from "lucide-react";
|
||||
import { isViewable } from "@edr/ui-common";
|
||||
|
||||
import { fileViewUrl } from "@/constants/apiConfig";
|
||||
|
||||
export interface PhasedUploadedFileRowProps {
|
||||
label: string;
|
||||
file: { id: string; name: string };
|
||||
onView?: (file: { name: string; url: string }) => void;
|
||||
onDownload?: (file: { id: string; name: string }) => void;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
/** Inline preview row for a phased customs upload (declaration, transit permit, DO, etc.). */
|
||||
export function PhasedUploadedFileRow({
|
||||
label,
|
||||
file,
|
||||
onView,
|
||||
onDownload,
|
||||
compact = false,
|
||||
}: PhasedUploadedFileRowProps) {
|
||||
const viewUrl = fileViewUrl(file.id);
|
||||
const canPreview = isViewable({ name: file.name, url: viewUrl });
|
||||
|
||||
return (
|
||||
<Paper
|
||||
withBorder
|
||||
radius="md"
|
||||
p={compact ? "xs" : "sm"}
|
||||
style={{
|
||||
borderColor: "var(--mantine-color-edr-green-3)",
|
||||
background:
|
||||
"linear-gradient(135deg, var(--mantine-color-edr-green-0) 0%, #FFFFFF 75%)",
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap" align="center">
|
||||
<Group gap={10} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<ThemeIcon variant="light" color="edr-green" radius="md" size={compact ? 32 : 36}>
|
||||
<FileText size={compact ? 15 : 17} />
|
||||
</ThemeIcon>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text size="sm" fw={600} truncate>
|
||||
{label}
|
||||
</Text>
|
||||
<Group gap={6} wrap="nowrap" mt={2}>
|
||||
<Badge size="xs" variant="light" color="edr-green" radius="sm">
|
||||
Uploaded
|
||||
</Badge>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{file.name}
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
</Group>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{canPreview && onView ? (
|
||||
<Tooltip label="Preview">
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="default"
|
||||
radius="md"
|
||||
leftSection={<Eye size={13} />}
|
||||
onClick={() => onView({ name: file.name, url: viewUrl })}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{onDownload ? (
|
||||
<Tooltip label="Download">
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
radius="md"
|
||||
leftSection={<Download size={13} />}
|
||||
onClick={() => onDownload({ id: file.id, name: file.name })}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
export function findWorkflowFile(
|
||||
files: Array<{ code: string; file: { id: string; name: string } | null }> | undefined,
|
||||
code: string,
|
||||
): { id: string; name: string } | null {
|
||||
return files?.find((f) => f.code === code)?.file ?? null;
|
||||
}
|
||||
@@ -13,13 +13,13 @@ export function TransportDocumentCard({ bookingId }: { bookingId: string }) {
|
||||
return (
|
||||
<ActionShell
|
||||
icon={FileText}
|
||||
title="Export transport document"
|
||||
title="Transit permit"
|
||||
subtitle="Upload after wagon allocation (GL Ethiopia)"
|
||||
done={false}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<FileInput
|
||||
label="Transport document"
|
||||
label="Transit permit"
|
||||
value={file}
|
||||
onChange={setFile}
|
||||
size="sm"
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Alert, Badge, Group, Stack, Text } from "@mantine/core";
|
||||
import { Boxes } from "lucide-react";
|
||||
|
||||
import { useContractCapacity } from "@/hooks/contracts/useContracts";
|
||||
|
||||
export function ContractCapacityNotice({
|
||||
contractId,
|
||||
isContainer,
|
||||
}: {
|
||||
contractId: string;
|
||||
isContainer: boolean;
|
||||
}) {
|
||||
const { data: lines = [] } = useContractCapacity(contractId);
|
||||
|
||||
if (lines.length === 0) return null;
|
||||
|
||||
const allFull = lines.every((l) => l.remaining === 0);
|
||||
const unit = isContainer ? "" : " tons";
|
||||
|
||||
return (
|
||||
<Alert
|
||||
color={allFull ? "red" : "edr-green"}
|
||||
variant="light"
|
||||
radius="md"
|
||||
icon={<Boxes size={16} />}
|
||||
title={allFull ? "Contract capacity reached" : "Remaining contract capacity"}
|
||||
>
|
||||
{allFull ? (
|
||||
<Text fz={13}>
|
||||
This contract has been fully booked. No further shipments can be created
|
||||
against it.
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap={6} mt={4}>
|
||||
{lines.map((l, i) => (
|
||||
<Group key={i} justify="space-between" wrap="nowrap">
|
||||
<Text fz={13}>{l.containerSize ?? "Bulk"}</Text>
|
||||
<Badge
|
||||
color={l.remaining === 0 ? "red" : "edr-green"}
|
||||
variant="light"
|
||||
radius="sm"
|
||||
>
|
||||
{l.remaining}
|
||||
{unit} of {l.cap} left
|
||||
</Badge>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { Box, Group, Paper, Text, Title } from "@mantine/core";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
const INK = "#10202F";
|
||||
const MUTED = "#6B7C8E";
|
||||
const BORDER = "#E6ECF2";
|
||||
const GREEN_DARK = "#0A6F4D";
|
||||
|
||||
export const fieldStyles = {
|
||||
label: { fontWeight: 600, fontSize: 13, color: INK, marginBottom: 6 },
|
||||
input: {
|
||||
borderRadius: 12,
|
||||
minHeight: 46,
|
||||
height: 46,
|
||||
fontSize: 14,
|
||||
borderColor: BORDER,
|
||||
},
|
||||
};
|
||||
|
||||
export function StepLabel({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<Text
|
||||
fz={11}
|
||||
fw={700}
|
||||
tt="uppercase"
|
||||
c={MUTED}
|
||||
style={{ letterSpacing: "0.07em" }}
|
||||
>
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
export function StepCard({
|
||||
children,
|
||||
eyebrow,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
eyebrow?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Paper
|
||||
radius={20}
|
||||
p={{ base: "lg", sm: 28 }}
|
||||
withBorder
|
||||
bg="white"
|
||||
style={{ borderColor: BORDER, boxShadow: "0 2px 14px rgba(16,24,40,0.04)" }}
|
||||
>
|
||||
{eyebrow}
|
||||
{children}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
export function StepHeader({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
icon?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Group gap={14} align="flex-start" wrap="nowrap" mb={22}>
|
||||
{icon ? (
|
||||
<Box
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 13,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "linear-gradient(135deg, #ECF6F1, #E4F3EC)",
|
||||
color: GREEN_DARK,
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</Box>
|
||||
) : null}
|
||||
<Box>
|
||||
<Title order={3} fz={20} fw={800} c={INK} style={{ letterSpacing: "-0.01em" }}>
|
||||
{title}
|
||||
</Title>
|
||||
<Text size="sm" c={MUTED} mt={4} style={{ lineHeight: 1.5 }}>
|
||||
{description}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -172,6 +172,8 @@ export const URL_CONSTANTS = {
|
||||
`/contracts/${id}/clearance/ro-amendment`,
|
||||
CLEARANCE_EXPORT_RELEASE: (id: string) =>
|
||||
`/contracts/${id}/clearance/export-release`,
|
||||
CLEARANCE_FINALIZE_EXPORT: (id: string) =>
|
||||
`/contracts/${id}/clearance/finalize-export-clearance`,
|
||||
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.
|
||||
|
||||
@@ -295,10 +295,10 @@ export default function BookingRequestDetailPage() {
|
||||
variant="default"
|
||||
leftSection={<Milestone size={16} />}
|
||||
onClick={() =>
|
||||
navigate(`/dashboard/bookings/${booking.id}/milestones`)
|
||||
navigate(`/dashboard/bookings/${booking.id}/clearance`)
|
||||
}
|
||||
>
|
||||
View clearance milestones
|
||||
View document clearance
|
||||
</Button>
|
||||
)}
|
||||
{showContractButton && (
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { ClearanceOpsTabs } from "@/components/contracts/ClearanceOpsTabs";
|
||||
import { PageContainer } from "@/components/page/PageContainer";
|
||||
import { PageHeader } from "@/components/page/PageHeader";
|
||||
import { SectionCard } from "@/components/bookings/detail";
|
||||
@@ -32,12 +33,16 @@ import { ClearanceReviewSection } from "@/components/bookings/detail/ClearanceRe
|
||||
import { ClearancePhaseStepper } from "@/components/contracts/ClearancePhaseStepper";
|
||||
import { PhasedClearanceActionPanel } from "@/components/contracts/PhasedClearanceActionPanel";
|
||||
import { ClearanceMilestoneTimeline } from "@/components/contracts/ClearanceMilestoneTimeline";
|
||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||
import { useBookingMilestones } from "@/hooks/contracts/useContracts";
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
import { downloadBookingFile } from "@/services/files.service";
|
||||
import { useBookingDetail } from "@/hooks/bookings/useBookings";
|
||||
|
||||
export default function DocumentClearanceDetailPage() {
|
||||
const params = useParams<{ id?: string; bookingId?: string }>();
|
||||
const id = params.id ?? params.bookingId;
|
||||
const { view, viewer } = useFileViewer();
|
||||
|
||||
const { data: booking } = useBookingDetail(id);
|
||||
const {
|
||||
@@ -51,6 +56,8 @@ export default function DocumentClearanceDetailPage() {
|
||||
enabled: Boolean(id),
|
||||
});
|
||||
|
||||
const { data: bookingMilestones } = useBookingMilestones(id);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const docs = (clearance?.documents ?? []).filter(
|
||||
(d) => d.uploadedBy === "customer",
|
||||
@@ -69,6 +76,16 @@ export default function DocumentClearanceDetailPage() {
|
||||
booking?.contractKind === "GENERAL" &&
|
||||
Boolean(clearance?.phase);
|
||||
|
||||
const docsPhaseComplete =
|
||||
clearance?.milestones?.some(
|
||||
(m) => m.milestoneCode === "DOCUMENTS_APPROVED" && m.status === "COMPLETED",
|
||||
) ?? false;
|
||||
const queriesLocked = Boolean(
|
||||
(clearance as Freight.ContractClearanceView | undefined)?.preClearanceFinalized,
|
||||
);
|
||||
const workflowFiles =
|
||||
(clearance as Freight.ContractClearanceView | undefined)?.workflowFiles ?? [];
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PageContainer>
|
||||
@@ -85,9 +102,9 @@ export default function DocumentClearanceDetailPage() {
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Clearance not found"
|
||||
backTo="/dashboard/clearance"
|
||||
backTo="/dashboard/contracts/clearance"
|
||||
breadcrumbs={[
|
||||
{ label: "Document Clearance", href: "/dashboard/clearance" },
|
||||
{ label: "Document Clearance", href: "/dashboard/contracts/clearance" },
|
||||
{ label: "Not found" },
|
||||
]}
|
||||
/>
|
||||
@@ -103,9 +120,9 @@ export default function DocumentClearanceDetailPage() {
|
||||
<Stack gap="lg">
|
||||
<PageHeader
|
||||
title={reference}
|
||||
backTo="/dashboard/clearance"
|
||||
backTo="/dashboard/contracts/clearance"
|
||||
breadcrumbs={[
|
||||
{ label: "Document Clearance", href: "/dashboard/clearance" },
|
||||
{ label: "Document Clearance", href: "/dashboard/contracts/clearance" },
|
||||
{ label: reference },
|
||||
]}
|
||||
meta={
|
||||
@@ -142,77 +159,94 @@ export default function DocumentClearanceDetailPage() {
|
||||
</Paper>
|
||||
) : null}
|
||||
|
||||
<Grid gap="lg">
|
||||
<Grid.Col span={{ base: 12, lg: isPhasedGeneral ? 7 : 8 }}>
|
||||
<ClearanceReviewSection bookingId={id!} hideSummary />
|
||||
</Grid.Col>
|
||||
<ClearanceOpsTabs
|
||||
bookingId={id}
|
||||
milestones={bookingMilestones}
|
||||
showOpsTabs={Boolean(id)}
|
||||
showWorkflowFilesTab={isPhasedGeneral}
|
||||
tradeDirection={booking?.tradeDirection ?? "IMPORT"}
|
||||
workflowFiles={workflowFiles}
|
||||
onViewFile={view}
|
||||
onDownloadFile={(f) => void downloadBookingFile(f.id, f.name)}
|
||||
clearanceTab={
|
||||
<Grid gap="lg">
|
||||
<Grid.Col span={{ base: 12, lg: isPhasedGeneral ? 7 : 8 }}>
|
||||
<ClearanceReviewSection
|
||||
bookingId={id!}
|
||||
hideSummary
|
||||
approvalsLocked={isPhasedGeneral && docsPhaseComplete}
|
||||
queriesLocked={queriesLocked}
|
||||
onChanged={() => void refetch()}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ base: 12, lg: isPhasedGeneral ? 5 : 4 }}>
|
||||
{isPhasedGeneral ? (
|
||||
<PhasedClearanceActionPanel
|
||||
bookingId={id!}
|
||||
clearance={clearance}
|
||||
tradeDirection={booking?.tradeDirection ?? "IMPORT"}
|
||||
onChanged={() => void refetch()}
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
}
|
||||
<Grid.Col span={{ base: 12, lg: isPhasedGeneral ? 5 : 4 }}>
|
||||
{isPhasedGeneral ? (
|
||||
<PhasedClearanceActionPanel
|
||||
bookingId={id!}
|
||||
clearance={clearance}
|
||||
tradeDirection={booking?.tradeDirection ?? "IMPORT"}
|
||||
workflowFiles={workflowFiles}
|
||||
roleMode="ET"
|
||||
onChanged={() => void refetch()}
|
||||
onViewFile={view}
|
||||
onDownloadFile={(f) => void downloadBookingFile(f.id, f.name)}
|
||||
/>
|
||||
<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>
|
||||
)}
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
}
|
||||
/>
|
||||
|
||||
{isPhasedGeneral && clearance.milestones && clearance.milestones.length > 0 ? (
|
||||
<ClearanceMilestoneTimeline
|
||||
milestones={
|
||||
clearance.milestones as Parameters<
|
||||
typeof ClearanceMilestoneTimeline
|
||||
>[0]["milestones"]
|
||||
}
|
||||
/>
|
||||
<ClearanceMilestoneTimeline milestones={clearance.milestones} />
|
||||
) : null}
|
||||
</Stack>
|
||||
{viewer}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,51 +19,31 @@ import {
|
||||
AlertCircle,
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
ClipboardList,
|
||||
Clock,
|
||||
PackageCheck,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { ClearanceOpsTabs } from "@/components/contracts/ClearanceOpsTabs";
|
||||
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 { ClearanceWorkflowFilesPanel } from "@/components/contracts/ClearanceWorkflowFilesPanel";
|
||||
import { PhasedClearanceActionPanel } from "@/components/contracts/PhasedClearanceActionPanel";
|
||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
import { downloadBookingFile } from "@/services/files.service";
|
||||
import { useContractDetail } from "@/hooks/contracts/useContracts";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
|
||||
type RoleMode = "ET" | "DJ" | "ALL";
|
||||
|
||||
function resolveRoleMode(
|
||||
canReview: boolean,
|
||||
canEt: boolean,
|
||||
canDj: boolean,
|
||||
): RoleMode {
|
||||
if (canReview || (canEt && canDj)) return "ALL";
|
||||
if (canEt) return "ET";
|
||||
if (canDj) return "DJ";
|
||||
return "ALL";
|
||||
}
|
||||
import {
|
||||
useBookingMilestones,
|
||||
useContractDetail,
|
||||
} from "@/hooks/contracts/useContracts";
|
||||
|
||||
export default function ContractClearanceDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { user } = useAuth();
|
||||
const { view, viewer } = useFileViewer();
|
||||
|
||||
const canReview = hasPermission(user, FREIGHT_PERMS.contracts.clearanceReview);
|
||||
const canEt =
|
||||
hasPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions) || canReview;
|
||||
const canDj =
|
||||
hasPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions) || canReview;
|
||||
const roleMode = resolveRoleMode(canReview, canEt, canDj);
|
||||
|
||||
const { data: contract } = useContractDetail(id);
|
||||
const {
|
||||
data: clearance,
|
||||
@@ -109,14 +89,20 @@ export default function ContractClearanceDetailPage() {
|
||||
"EXPIRED",
|
||||
].includes(contract.status),
|
||||
);
|
||||
const reviewReadOnly =
|
||||
roleMode === "DJ" ? true : shipmentLocked;
|
||||
const reviewColSpan = roleMode === "DJ" ? 12 : 7;
|
||||
const actionColSpan = roleMode === "DJ" ? 12 : 5;
|
||||
const bookingHref =
|
||||
roleMode === "ET" || roleMode === "ALL"
|
||||
? `/dashboard/contracts/${id}/create-booking`
|
||||
: undefined;
|
||||
const linkedBookingId = useMemo(() => {
|
||||
const cycle = contract?.clearanceCycles?.find(
|
||||
(c) => c.cycleNumber === (contract?.clearanceCycleNumber ?? 1),
|
||||
);
|
||||
return cycle?.bookingId ?? undefined;
|
||||
}, [contract]);
|
||||
const bookingAlreadyCreated = Boolean(linkedBookingId) || shipmentLocked;
|
||||
const canCreateBooking = ready && !bookingAlreadyCreated;
|
||||
const reviewReadOnly = shipmentLocked;
|
||||
const queriesLocked = Boolean(clearance?.preClearanceFinalized);
|
||||
const bookingHref = `/dashboard/contracts/${id}/create-booking`;
|
||||
|
||||
const { data: bookingMilestones, refetch: refetchBookingMilestones } =
|
||||
useBookingMilestones(linkedBookingId);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -166,14 +152,23 @@ export default function ContractClearanceDetailPage() {
|
||||
{ label: reference },
|
||||
]}
|
||||
meta={
|
||||
ready ? (
|
||||
bookingAlreadyCreated ? (
|
||||
<Badge
|
||||
variant="light"
|
||||
color="blue"
|
||||
radius="sm"
|
||||
leftSection={<PackageCheck size={13} />}
|
||||
>
|
||||
Booking created
|
||||
</Badge>
|
||||
) : ready ? (
|
||||
<Badge
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="sm"
|
||||
leftSection={<PackageCheck size={13} />}
|
||||
>
|
||||
Ready — customer books
|
||||
Ready — create booking
|
||||
</Badge>
|
||||
) : clearance.allApproved ? (
|
||||
<Badge
|
||||
@@ -199,7 +194,30 @@ export default function ContractClearanceDetailPage() {
|
||||
|
||||
<ClearanceHero contract={contract} stats={stats} />
|
||||
|
||||
{ready ? (
|
||||
{bookingAlreadyCreated ? (
|
||||
<Alert
|
||||
color="blue"
|
||||
radius="md"
|
||||
icon={<PackageCheck size={16} />}
|
||||
title="Shipment booking created"
|
||||
>
|
||||
GL Ethiopia has created the shipment booking for this contract.
|
||||
{linkedBookingId ? (
|
||||
<>
|
||||
{" "}
|
||||
<Text
|
||||
component={Link}
|
||||
to={`/dashboard/bookings/${linkedBookingId}/clearance`}
|
||||
inherit
|
||||
fw={600}
|
||||
c="blue.7"
|
||||
>
|
||||
View booking →
|
||||
</Text>
|
||||
</>
|
||||
) : null}
|
||||
</Alert>
|
||||
) : canCreateBooking ? (
|
||||
<Alert
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
@@ -210,106 +228,103 @@ export default function ContractClearanceDetailPage() {
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Grid>
|
||||
<Grid.Col span={{ base: 12, lg: reviewColSpan }}>
|
||||
{roleMode === "DJ" ? (
|
||||
<SectionCard
|
||||
icon={ClipboardList}
|
||||
title="Contract context"
|
||||
accent="edr-green"
|
||||
>
|
||||
<Text size="sm" c="dimmed" mb="sm">
|
||||
Review upstream status before uploading Djibouti documents.
|
||||
</Text>
|
||||
<ClearanceOpsTabs
|
||||
bookingId={linkedBookingId}
|
||||
milestones={bookingMilestones}
|
||||
showOpsTabs={Boolean(linkedBookingId)}
|
||||
showWorkflowFilesTab={phasedCustoms}
|
||||
tradeDirection={contract?.tradeDirection ?? "IMPORT"}
|
||||
workflowFiles={workflowFiles}
|
||||
onViewFile={view}
|
||||
onDownloadFile={(f) => void downloadBookingFile(f.id, f.name)}
|
||||
clearanceTab={
|
||||
<Grid>
|
||||
<Grid.Col span={{ base: 12, lg: 7 }}>
|
||||
<ContractClearanceReviewSection
|
||||
contractId={id!}
|
||||
hideSummary
|
||||
selfClear={false}
|
||||
readOnly
|
||||
readOnly={reviewReadOnly}
|
||||
approvalsLocked={phasedCustoms && docReviewLocked}
|
||||
queriesLocked={queriesLocked}
|
||||
phasedCustoms={phasedCustoms}
|
||||
onChanged={() => {
|
||||
void refetch();
|
||||
void refetchBookingMilestones();
|
||||
}}
|
||||
/>
|
||||
</SectionCard>
|
||||
) : (
|
||||
<ContractClearanceReviewSection
|
||||
contractId={id!}
|
||||
hideSummary
|
||||
selfClear={false}
|
||||
readOnly={reviewReadOnly}
|
||||
approvalsLocked={phasedCustoms && docReviewLocked}
|
||||
phasedCustoms={phasedCustoms}
|
||||
onChanged={() => void refetch()}
|
||||
/>
|
||||
)}
|
||||
{workflowFiles.length > 0 ? (
|
||||
<Box mt="lg">
|
||||
<ClearanceWorkflowFilesPanel
|
||||
files={workflowFiles}
|
||||
onView={view}
|
||||
onDownload={(f) => void downloadBookingFile(f.id, f.name)}
|
||||
/>
|
||||
</Box>
|
||||
) : null}
|
||||
</Grid.Col>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ base: 12, lg: actionColSpan }}>
|
||||
<Stack gap="md">
|
||||
{phasedCustoms ? (
|
||||
<PhasedClearanceActionPanel
|
||||
contractId={id!}
|
||||
clearance={clearance}
|
||||
tradeDirection={contract?.tradeDirection ?? "IMPORT"}
|
||||
roleMode={roleMode}
|
||||
onChanged={() => void refetch()}
|
||||
bookingCreateHref={ready ? bookingHref : 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>
|
||||
<Grid.Col span={{ base: 12, lg: 5 }}>
|
||||
<Stack gap="md">
|
||||
{phasedCustoms ? (
|
||||
<PhasedClearanceActionPanel
|
||||
contractId={id!}
|
||||
bookingId={linkedBookingId}
|
||||
bookingMilestones={bookingMilestones ?? []}
|
||||
clearance={clearance}
|
||||
tradeDirection={contract?.tradeDirection ?? "IMPORT"}
|
||||
workflowFiles={workflowFiles}
|
||||
roleMode="ET"
|
||||
onChanged={() => {
|
||||
void refetch();
|
||||
void refetchBookingMilestones();
|
||||
}}
|
||||
onViewFile={view}
|
||||
onDownloadFile={(f) => void downloadBookingFile(f.id, f.name)}
|
||||
bookingCreateHref={canCreateBooking ? bookingHref : undefined}
|
||||
bookingCreated={bookingAlreadyCreated}
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
{viewer}
|
||||
</PageContainer>
|
||||
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
RefreshCw,
|
||||
Search,
|
||||
ShieldCheck,
|
||||
Ship,
|
||||
ShipWheel,
|
||||
Table as TableIcon,
|
||||
Truck,
|
||||
@@ -48,13 +47,12 @@ import { bookingTable } from "@/components/bookings/booking-ui.styles";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import {
|
||||
useContractClearanceQueue,
|
||||
useDjClearanceQueue,
|
||||
useEtClearanceQueue,
|
||||
} from "@/hooks/contracts/useContracts";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
|
||||
type ViewMode = "table" | "cards";
|
||||
type QueueTab = "all" | "et" | "dj";
|
||||
type QueueTab = "all" | "et";
|
||||
|
||||
interface ClearanceRow {
|
||||
id: string;
|
||||
@@ -68,8 +66,10 @@ interface ClearanceRow {
|
||||
serviceTypeName: string;
|
||||
customs: boolean;
|
||||
status: string;
|
||||
/** true once GL has finalized clearance — customer now books in the portal. */
|
||||
/** true once GL has finalized clearance — customer may book in the portal. */
|
||||
ready: boolean;
|
||||
/** true once GL Ethiopia created the shipment booking. */
|
||||
bookingCreated: boolean;
|
||||
}
|
||||
|
||||
function yardLabel(
|
||||
@@ -102,6 +102,7 @@ function toClearanceRow(contract: Freight.IContract): ClearanceRow {
|
||||
contract.serviceType?.includesCustoms ?? contract.customsClearingEnabled,
|
||||
status: contract.status,
|
||||
ready: contract.status === "CLEARANCE_READY_FOR_BOOKING",
|
||||
bookingCreated: contract.status === "ACTIVE_SHIPMENT_IN_PROGRESS",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -143,6 +144,21 @@ function DirectionIcon({ direction }: { direction: string }) {
|
||||
}
|
||||
|
||||
function StatusBadge({ row }: { row: ClearanceRow }) {
|
||||
if (row.bookingCreated) {
|
||||
return (
|
||||
<Tooltip label="GL Ethiopia created the shipment booking" withArrow>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color="blue"
|
||||
radius="sm"
|
||||
leftSection={<PackagePlus size={12} />}
|
||||
>
|
||||
Booking created
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
if (row.ready) {
|
||||
return (
|
||||
<Tooltip
|
||||
@@ -169,25 +185,16 @@ function StatusBadge({ row }: { row: ClearanceRow }) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Document Clearance hub. Lists every customs (Path B) contract that still needs
|
||||
* customs clearance — awaiting documents, under GL review, or finalized and
|
||||
* waiting for the customer to create the booking in the portal. A single list,
|
||||
* no queue/history/direction tabs.
|
||||
* Document Clearance hub. Lists every customs (Path B) contract in phased clearance,
|
||||
* including after booking is created — stays visible for reference and follow-up.
|
||||
*/
|
||||
export default function ContractClearanceListPage() {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const canReview = hasPermission(user, FREIGHT_PERMS.contracts.clearanceReview);
|
||||
const canEt = hasPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions);
|
||||
const canDj = hasPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions);
|
||||
|
||||
const defaultQueue: QueueTab = canReview
|
||||
? "all"
|
||||
: canEt
|
||||
? "et"
|
||||
: canDj
|
||||
? "dj"
|
||||
: "all";
|
||||
const defaultQueue: QueueTab = canReview ? "all" : "et";
|
||||
const [queueTab, setQueueTab] = useState<QueueTab>(defaultQueue);
|
||||
const [query, setQuery] = useState("");
|
||||
const [view, setView] = useState<ViewMode>("table");
|
||||
@@ -197,26 +204,19 @@ export default function ContractClearanceListPage() {
|
||||
useContractClearanceQueue(queueTab === "all");
|
||||
const { data: etData, isLoading: etLoading, isError: etError, isFetching: etFetching, refetch: refetchEt } =
|
||||
useEtClearanceQueue(queueTab === "et");
|
||||
const { data: djData, isLoading: djLoading, isError: djError, isFetching: djFetching, refetch: refetchDj } =
|
||||
useDjClearanceQueue(queueTab === "dj");
|
||||
|
||||
const data =
|
||||
queueTab === "et" ? etData : queueTab === "dj" ? djData : allData;
|
||||
const isLoading =
|
||||
queueTab === "et" ? etLoading : queueTab === "dj" ? djLoading : allLoading;
|
||||
const isError =
|
||||
queueTab === "et" ? etError : queueTab === "dj" ? djError : allError;
|
||||
const isFetching =
|
||||
queueTab === "et" ? etFetching : queueTab === "dj" ? djFetching : allFetching;
|
||||
const data = queueTab === "et" ? etData : allData;
|
||||
const isLoading = queueTab === "et" ? etLoading : allLoading;
|
||||
const isError = queueTab === "et" ? etError : allError;
|
||||
const isFetching = queueTab === "et" ? etFetching : allFetching;
|
||||
const refetch = () => {
|
||||
if (queueTab === "et") void refetchEt();
|
||||
else if (queueTab === "dj") void refetchDj();
|
||||
else void refetchAll();
|
||||
};
|
||||
|
||||
const queueTabOptions = useMemo(() => {
|
||||
const opts: { value: QueueTab; label: ReactNode }[] = [];
|
||||
if (canReview || (canEt && canDj)) {
|
||||
if (canReview) {
|
||||
opts.push({
|
||||
value: "all",
|
||||
label: (
|
||||
@@ -238,19 +238,8 @@ export default function ContractClearanceListPage() {
|
||||
),
|
||||
});
|
||||
}
|
||||
if (canDj) {
|
||||
opts.push({
|
||||
value: "dj",
|
||||
label: (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Ship size={15} />
|
||||
<Box visibleFrom="sm">DJ queue</Box>
|
||||
</Group>
|
||||
),
|
||||
});
|
||||
}
|
||||
return opts;
|
||||
}, [canReview, canEt, canDj]);
|
||||
}, [canReview, canEt]);
|
||||
|
||||
const allRows = useMemo(
|
||||
() => (data?.items ?? []).map(toClearanceRow),
|
||||
@@ -261,7 +250,8 @@ export default function ContractClearanceListPage() {
|
||||
() => ({
|
||||
all: allRows.length,
|
||||
ready: allRows.filter((r) => r.ready).length,
|
||||
review: allRows.filter((r) => !r.ready).length,
|
||||
booked: allRows.filter((r) => r.bookingCreated).length,
|
||||
review: allRows.filter((r) => !r.ready && !r.bookingCreated).length,
|
||||
}),
|
||||
[allRows],
|
||||
);
|
||||
@@ -407,7 +397,7 @@ export default function ContractClearanceListPage() {
|
||||
<Stack gap="lg">
|
||||
<PageHeader
|
||||
title="Document Clearance"
|
||||
subtitle="Review pre-booking customs documents on contracts and finalize clearance. Once finalized, the customer creates the shipment booking in the portal."
|
||||
subtitle="All customs contracts in phased clearance — stays visible after booking is created."
|
||||
meta={
|
||||
<Badge
|
||||
variant="light"
|
||||
@@ -415,7 +405,7 @@ export default function ContractClearanceListPage() {
|
||||
radius="sm"
|
||||
leftSection={<ShieldCheck size={13} />}
|
||||
>
|
||||
{counts.all} need clearance
|
||||
{counts.all} in clearance
|
||||
</Badge>
|
||||
}
|
||||
action={
|
||||
@@ -436,7 +426,7 @@ export default function ContractClearanceListPage() {
|
||||
loading={isLoading}
|
||||
items={[
|
||||
{
|
||||
label: "Need clearance",
|
||||
label: "In clearance",
|
||||
value: counts.all,
|
||||
icon: Inbox,
|
||||
color: "edr-green",
|
||||
@@ -448,8 +438,8 @@ export default function ContractClearanceListPage() {
|
||||
color: "yellow",
|
||||
},
|
||||
{
|
||||
label: "Ready — customer books",
|
||||
value: counts.ready,
|
||||
label: "Ready / booked",
|
||||
value: counts.ready + counts.booked,
|
||||
icon: PackageCheck,
|
||||
color: "edr-green",
|
||||
},
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useParams } from "react-router-dom";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Grid,
|
||||
Group,
|
||||
Loader,
|
||||
Stack,
|
||||
Tabs,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import { AlertCircle, ClipboardList, FileText, Upload } from "lucide-react";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { PageContainer } from "@/components/page/PageContainer";
|
||||
import { PageHeader } from "@/components/page/PageHeader";
|
||||
import { ClearanceReviewSection } from "@/components/bookings/detail/ClearanceReviewSection";
|
||||
import { ContractClearanceReviewSection } from "@/components/contracts/ContractClearanceReviewSection";
|
||||
import { ClearanceWorkflowFilesPanel } from "@/components/contracts/ClearanceWorkflowFilesPanel";
|
||||
import {
|
||||
GlClearanceUploadModal,
|
||||
type GlClearanceUploadKind,
|
||||
} from "@/components/contracts/GlClearanceUploadModal";
|
||||
import { PhasedClearanceActionPanel } from "@/components/contracts/PhasedClearanceActionPanel";
|
||||
import { findWorkflowFile } from "@/components/contracts/PhasedUploadedFileRow";
|
||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
import { downloadBookingFile } from "@/services/files.service";
|
||||
|
||||
type GlClearanceDetail =
|
||||
| {
|
||||
kind: "contract";
|
||||
reference: string;
|
||||
tradeDirection: string;
|
||||
clearance: Freight.ContractClearanceView;
|
||||
}
|
||||
| {
|
||||
kind: "booking";
|
||||
reference: string;
|
||||
tradeDirection: string;
|
||||
clearance: Freight.ClearanceView;
|
||||
};
|
||||
|
||||
async function loadGlClearanceDetail(id: string): Promise<GlClearanceDetail> {
|
||||
try {
|
||||
const [clearance, contract] = await Promise.all([
|
||||
contractsService.getClearance(id),
|
||||
contractsService.getById(id),
|
||||
]);
|
||||
return {
|
||||
kind: "contract",
|
||||
reference: contract.reference,
|
||||
tradeDirection: contract.tradeDirection,
|
||||
clearance,
|
||||
};
|
||||
} catch {
|
||||
const [clearance, booking] = await Promise.all([
|
||||
bookingsService.getClearance(id),
|
||||
bookingsService.getById(id),
|
||||
]);
|
||||
return {
|
||||
kind: "booking",
|
||||
reference: booking.reference,
|
||||
tradeDirection: booking.tradeDirection,
|
||||
clearance,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Djibouti GL clearance detail — RO/DO upload and read-only upstream context. */
|
||||
export default function GlClearanceDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { view, viewer } = useFileViewer();
|
||||
const [uploadKind, setUploadKind] = useState<GlClearanceUploadKind | null>(null);
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: ["gl-clearance-detail", id],
|
||||
queryFn: () => loadGlClearanceDetail(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 || !data) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<Alert color="red" icon={<AlertCircle size={16} />}>
|
||||
Could not load clearance for this item.
|
||||
</Alert>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const backTo = "/dashboard/gl-djibouti/clearance";
|
||||
const workflowFiles = data.clearance.workflowFiles ?? [];
|
||||
const workflowFileCount = workflowFiles.filter((f) => f.file).length;
|
||||
const isImport = data.tradeDirection === "IMPORT";
|
||||
const hasDo = Boolean(findWorkflowFile(workflowFiles, "delivery_order"));
|
||||
const hasRo = Boolean(findWorkflowFile(workflowFiles, "release_order"));
|
||||
const canUploadDo = isImport && Boolean(data.clearance.preClearanceFinalized || hasDo);
|
||||
const vesselDepartureDate =
|
||||
"vesselDepartureDate" in data.clearance
|
||||
? (data.clearance.vesselDepartureDate ?? null)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<Stack gap="lg">
|
||||
<PageHeader
|
||||
title={data.reference}
|
||||
backTo={backTo}
|
||||
breadcrumbs={[
|
||||
{ label: "GL Djibouti Clearance", href: backTo },
|
||||
{ label: data.reference },
|
||||
]}
|
||||
meta={
|
||||
<Badge variant="light" color={isImport ? "edr-green" : "gray"} radius="sm">
|
||||
{data.tradeDirection}
|
||||
</Badge>
|
||||
}
|
||||
action={
|
||||
<Group gap="sm">
|
||||
{isImport ? (
|
||||
<Button
|
||||
color="edr-green"
|
||||
leftSection={<Upload size={16} />}
|
||||
disabled={!canUploadDo}
|
||||
onClick={() => setUploadKind("do")}
|
||||
>
|
||||
{hasDo ? "Replace DO" : "Upload DO"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
color="edr-green"
|
||||
leftSection={<Upload size={16} />}
|
||||
onClick={() => setUploadKind("ro")}
|
||||
>
|
||||
{hasRo ? "Replace RO" : "Upload RO"}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
|
||||
<Tabs defaultValue="workflow" keepMounted={false}>
|
||||
<Tabs.List mb="md">
|
||||
<Tabs.Tab value="workflow" leftSection={<ClipboardList size={14} />}>
|
||||
Clearance workflow
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab
|
||||
value="documents"
|
||||
leftSection={<FileText size={14} />}
|
||||
rightSection={
|
||||
workflowFileCount > 0 ? (
|
||||
<Badge size="xs" variant="light" color="edr-green" circle>
|
||||
{workflowFileCount}
|
||||
</Badge>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
Customs documents (all steps)
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="workflow">
|
||||
<Grid gutter="lg">
|
||||
<Grid.Col span={{ base: 12, lg: 7 }}>
|
||||
{data.kind === "booking" ? (
|
||||
<ClearanceReviewSection bookingId={id!} hideSummary readOnly />
|
||||
) : (
|
||||
<ContractClearanceReviewSection
|
||||
contractId={id!}
|
||||
hideSummary
|
||||
selfClear={false}
|
||||
readOnly
|
||||
phasedCustoms
|
||||
/>
|
||||
)}
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ base: 12, lg: 5 }}>
|
||||
<PhasedClearanceActionPanel
|
||||
contractId={data.kind === "contract" ? id : undefined}
|
||||
bookingId={data.kind === "booking" ? id : undefined}
|
||||
clearance={data.clearance}
|
||||
tradeDirection={data.tradeDirection}
|
||||
workflowFiles={workflowFiles}
|
||||
roleMode="DJ"
|
||||
useUploadModals
|
||||
onUploadDoRequest={() => setUploadKind("do")}
|
||||
onUploadRoRequest={() => setUploadKind("ro")}
|
||||
onChanged={() => void refetch()}
|
||||
onViewFile={view}
|
||||
onDownloadFile={(f) => void downloadBookingFile(f.id, f.name)}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="documents">
|
||||
{workflowFiles.length > 0 ? (
|
||||
<ClearanceWorkflowFilesPanel
|
||||
files={workflowFiles}
|
||||
title="Customs documents (all steps)"
|
||||
onView={view}
|
||||
onDownload={(f) => void downloadBookingFile(f.id, f.name)}
|
||||
/>
|
||||
) : (
|
||||
<Box
|
||||
py={48}
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
border: "1px dashed var(--mantine-color-gray-4)",
|
||||
background: "var(--mantine-color-gray-0)",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<Stack gap={8} align="center">
|
||||
<ThemeIcon variant="light" color="gray" radius="xl" size={48}>
|
||||
<FileText size={22} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" c="dimmed" maw={360}>
|
||||
No customs workflow documents uploaded yet. Files from Ethiopia-side
|
||||
clearance and your DO/RO uploads will appear here.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</Stack>
|
||||
|
||||
<GlClearanceUploadModal
|
||||
opened={uploadKind != null}
|
||||
kind={uploadKind}
|
||||
onClose={() => setUploadKind(null)}
|
||||
entityId={id!}
|
||||
isBooking={data.kind === "booking"}
|
||||
workflowFiles={workflowFiles}
|
||||
vesselDepartureDate={vesselDepartureDate}
|
||||
onSuccess={() => void refetch()}
|
||||
onPreview={view}
|
||||
/>
|
||||
{viewer}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Badge, Card, Group, Loader, Stack, Tabs, Text } from "@mantine/core";
|
||||
import { ChevronRight, Container, Ship } from "lucide-react";
|
||||
|
||||
import { PageContainer } from "@/components/page/PageContainer";
|
||||
import { PageHeader } from "@/components/page/PageHeader";
|
||||
import { useDjClearanceQueue } from "@/hooks/contracts/useContracts";
|
||||
import { useBookingDjClearanceQueue } from "@/hooks/bookings/useBookings";
|
||||
|
||||
export default function GlDjiboutiClearanceListPage() {
|
||||
const navigate = useNavigate();
|
||||
const { data: contractQueue, isLoading: contractsLoading } = useDjClearanceQueue();
|
||||
const { data: bookingQueue, isLoading: bookingsLoading } = useBookingDjClearanceQueue();
|
||||
|
||||
const contractItems = contractQueue?.items ?? [];
|
||||
const bookingItems = bookingQueue ?? [];
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="GL Djibouti — Clearance"
|
||||
subtitle="All customs contracts and bookings handed off to Djibouti GL — stays visible after DO/RO upload and booking creation."
|
||||
/>
|
||||
<Tabs defaultValue="contracts" keepMounted={false}>
|
||||
<Tabs.List mb="md">
|
||||
<Tabs.Tab value="contracts">Contracts ({contractItems.length})</Tabs.Tab>
|
||||
<Tabs.Tab value="bookings">Bookings ({bookingItems.length})</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="contracts">
|
||||
{contractsLoading ? (
|
||||
<Group justify="center" py={60}>
|
||||
<Loader color="edr-green" />
|
||||
</Group>
|
||||
) : (
|
||||
<Stack gap="sm">
|
||||
{contractItems.length === 0 ? (
|
||||
<Text c="dimmed" ta="center" py="xl">
|
||||
No Djibouti customs contracts yet. Items appear here once Ethiopia-side
|
||||
pre-clearance is finalized.
|
||||
</Text>
|
||||
) : (
|
||||
contractItems.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="edr-green">
|
||||
Contract
|
||||
</Badge>
|
||||
<ChevronRight size={18} className="text-muted-foreground" />
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="bookings">
|
||||
{bookingsLoading ? (
|
||||
<Group justify="center" py={60}>
|
||||
<Loader color="edr-green" />
|
||||
</Group>
|
||||
) : (
|
||||
<Stack gap="sm">
|
||||
{bookingItems.length === 0 ? (
|
||||
<Text c="dimmed" ta="center" py="xl">
|
||||
No Djibouti customs bookings yet.
|
||||
</Text>
|
||||
) : (
|
||||
bookingItems.map((b) => (
|
||||
<Card
|
||||
key={b.id}
|
||||
withBorder
|
||||
radius="md"
|
||||
padding="md"
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => navigate(`/dashboard/gl-djibouti/clearance/${b.id}`)}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap="sm">
|
||||
<Container size={18} className="text-[color:var(--freight-brand)]" />
|
||||
<div>
|
||||
<Text fw={700}>{b.reference}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{b.tradeDirection} · {b.status}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
<Badge variant="light" color="blue">
|
||||
Booking
|
||||
</Badge>
|
||||
<ChevronRight size={18} className="text-muted-foreground" />
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -348,9 +348,14 @@ export const bookingsService = {
|
||||
finalizePreClearance: (id: string) =>
|
||||
postBooking<BookingDetail>(B.CLEARANCE_FINALIZE_PRE(id)),
|
||||
|
||||
uploadTransitPermit: async (id: string, file: File): Promise<BookingDetail> => {
|
||||
uploadTransitPermit: async (
|
||||
id: string,
|
||||
files: Record<string, File | null>,
|
||||
): Promise<BookingDetail> => {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
for (const [key, file] of Object.entries(files)) {
|
||||
if (file) form.append(key, file);
|
||||
}
|
||||
const response = await client.post(B.CLEARANCE_TRANSIT_PERMIT(id), form, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
|
||||
@@ -268,10 +268,12 @@ export const contractsService = {
|
||||
|
||||
uploadContractTransitPermit: async (
|
||||
id: string,
|
||||
file: File,
|
||||
files: Record<string, File | null>,
|
||||
): Promise<Freight.IContract> => {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
for (const [key, file] of Object.entries(files)) {
|
||||
if (file) form.append(key, file);
|
||||
}
|
||||
const response = await client.post(C.CLEARANCE_TRANSIT_PERMIT(id), form, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
@@ -314,6 +316,9 @@ export const contractsService = {
|
||||
confirmExportRelease: (id: string) =>
|
||||
postContract<Freight.IContract>(C.CLEARANCE_EXPORT_RELEASE(id)),
|
||||
|
||||
finalizeExportClearance: (id: string) =>
|
||||
postContract<Freight.IContract>(C.CLEARANCE_FINALIZE_EXPORT(id)),
|
||||
|
||||
uploadTransportDocument: async (bookingId: string, file: File) => {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
|
||||
@@ -177,6 +177,7 @@ export interface BookingDetail {
|
||||
equipmentReturn?: string;
|
||||
customsClearingEnabled?: boolean;
|
||||
customsClearingAgent?: string | null;
|
||||
contractKind?: "ONE_TIME" | "GENERAL" | null;
|
||||
contractSummary?: string | null;
|
||||
latestChangeRequestNote?: string | null;
|
||||
nextStep?: BookingNextStep | null;
|
||||
|
||||
Reference in New Issue
Block a user