Merge branch 'freight_feature/contrat' of github.com:Tria-plc/edr-platform into freight_feature/contrat

This commit is contained in:
marshal
2026-06-28 23:13:13 +03:00
5 changed files with 362 additions and 269 deletions

View File

@@ -302,6 +302,26 @@ export class ContractTransitionService {
return { view, html, signatures: view.signatures }; return { view, html, signatures: view.signatures };
} }
/**
* Rebuild the stored `contract` PDF from the current aggregate (now including
* the latest signatures) so the downloaded/viewed file matches the live HTML
* view. Best-effort — a Chromium hiccup must never fail the signing
* transaction; the doc still re-renders live on /contract/view.
*/
private async regenerateContractPdf(
contractId: string,
reference: string,
): Promise<void> {
try {
const { view } = await this.documentViewModelBuilder.build(contractId);
await this.upsertContractPdf(contractId, reference, view);
} catch (err) {
this.logger.warn(
`Signed contract PDF regen deferred for ${reference}: ${err}. It re-renders live on view.`,
);
}
}
/** Render the contract PDF and upsert it as the `contract` file on the contract. */ /** Render the contract PDF and upsert it as the `contract` file on the contract. */
private async upsertContractPdf( private async upsertContractPdf(
contractId: string, contractId: string,
@@ -458,6 +478,7 @@ export class ContractTransitionService {
status: 'SIGNED_CUSTOMER', status: 'SIGNED_CUSTOMER',
customerSignedAt: new Date(), customerSignedAt: new Date(),
} as never); } as never);
await this.regenerateContractPdf(contractId, contract.reference);
return this.contractsService.findById(contractId); return this.contractsService.findById(contractId);
} }
@@ -517,6 +538,7 @@ export class ContractTransitionService {
} }
await this.contractsRepository.update(contractId, updates as never); await this.contractsRepository.update(contractId, updates as never);
await this.regenerateContractPdf(contractId, contract.reference);
return this.contractsService.findById(contractId); return this.contractsService.findById(contractId);
} }

View File

@@ -35,8 +35,6 @@ import NewBookingPage from "./pages/bookings/NewBookingPage";
import ContractRequestsPage from "./pages/contracts/ContractRequestsPage"; import ContractRequestsPage from "./pages/contracts/ContractRequestsPage";
import ContractRequestDetailPage from "./pages/contracts/ContractRequestDetailPage"; import ContractRequestDetailPage from "./pages/contracts/ContractRequestDetailPage";
import ContractViewPage from "./pages/contracts/ContractViewPage"; import ContractViewPage from "./pages/contracts/ContractViewPage";
import ContractClearanceListPage from "./pages/contracts/ContractClearanceListPage";
import ContractClearanceDetailPage from "./pages/contracts/ContractClearanceDetailPage";
import GlCreateBookingForm from "./components/contracts/GlCreateBookingForm"; import GlCreateBookingForm from "./components/contracts/GlCreateBookingForm";
import BookingMilestonesPage from "./pages/contracts/BookingMilestonesPage"; import BookingMilestonesPage from "./pages/contracts/BookingMilestonesPage";
import CustomerDetailPage from "./pages/customers/CustomerDetailPage"; import CustomerDetailPage from "./pages/customers/CustomerDetailPage";
@@ -134,18 +132,6 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
icon: <ShieldCheck />, icon: <ShieldCheck />,
permission: FREIGHT_PERMS.bookings.reviewDocuments, permission: FREIGHT_PERMS.bookings.reviewDocuments,
}, },
{
label: "Contract Clearance",
href: "/dashboard/contracts/clearance",
icon: <ShieldCheck />,
permission: FREIGHT_PERMS.contracts.clearanceReview,
},
{
label: "Self-Clearance Review",
href: "/dashboard/contracts/ops-clearance",
icon: <ShieldCheck />,
permission: FREIGHT_PERMS.contracts.opsClearanceReview,
},
{ {
label: "Train Schedules", label: "Train Schedules",
href: "/dashboard/operations/train-scheduling-v2", href: "/dashboard/operations/train-scheduling-v2",
@@ -467,34 +453,15 @@ const App = () => {
/> />
<Route <Route
path="contracts/clearance" path="contracts/clearance"
element={ element={<Navigate to="/dashboard/clearance" replace />}
<RequirePermission permission={FREIGHT_PERMS.contracts.clearanceReview}>
<ContractClearanceListPage />
</RequirePermission>
}
/> />
<Route <Route
path="contracts/ops-clearance" path="contracts/ops-clearance"
element={ element={<Navigate to="/dashboard/clearance?mode=ops" replace />}
<RequirePermission
permission={FREIGHT_PERMS.contracts.opsClearanceReview}
>
<ContractClearanceListPage opsMode />
</RequirePermission>
}
/> />
<Route <Route
path="contracts/clearance/:id" path="contracts/clearance/:id"
element={ element={<Navigate to="/dashboard/clearance" replace />}
<RequirePermission
permission={[
FREIGHT_PERMS.contracts.clearanceReview,
FREIGHT_PERMS.contracts.opsClearanceReview,
]}
>
<ContractClearanceDetailPage />
</RequirePermission>
}
/> />
<Route <Route
path="contracts/:id/create-booking" path="contracts/:id/create-booking"

View File

@@ -68,7 +68,7 @@ export function ClearanceReviewSection({
const qc = useQueryClient(); const qc = useQueryClient();
const [queryNotes, setQueryNotes] = useState<Record<string, string>>({}); const [queryNotes, setQueryNotes] = useState<Record<string, string>>({});
const [openQuery, setOpenQuery] = useState<Record<string, boolean>>({}); const [openQuery, setOpenQuery] = useState<Record<string, boolean>>({});
const [outputFiles, setOutputFiles] = useState<Record<string, File>>({}); const [uploadingKey, setUploadingKey] = useState<string | null>(null);
const { view, viewer } = useFileViewer(); const { view, viewer } = useFileViewer();
const { data: clearance, isLoading } = useQuery({ const { data: clearance, isLoading } = useQuery({
@@ -100,13 +100,17 @@ export function ClearanceReviewSection({
}); });
const outputMutation = useMutation({ const outputMutation = useMutation({
mutationFn: () => bookingsService.uploadClearanceOutput(bookingId, outputFiles), mutationFn: (files: Record<string, File>) =>
bookingsService.uploadClearanceOutput(bookingId, files),
onSuccess: () => { onSuccess: () => {
toast.success("Output documents uploaded"); toast.success("Document uploaded");
setOutputFiles({}); setUploadingKey(null);
refresh(); refresh();
}, },
onError: () => toast.error("Upload failed"), onError: () => {
toast.error("Upload failed");
setUploadingKey(null);
},
}); });
const finalizeMutation = useMutation({ const finalizeMutation = useMutation({
@@ -223,99 +227,100 @@ export function ClearanceReviewSection({
<SectionCard <SectionCard
icon={Upload} icon={Upload}
title="Customs output documents" title="Customs output documents"
subtitle="Upload the cleared/customs paperwork to hand back to the customer." subtitle="Upload each document individually — changes save immediately."
accent="edr-green" accent="edr-green"
> >
<Stack gap={10}> <Stack gap={10}>
{glDocs.map((doc) => ( {glDocs.map((doc) => {
<Group key={doc.fileKey} justify="space-between" wrap="nowrap"> const isUploading =
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}> uploadingKey === doc.fileKey && outputMutation.isPending;
<FileText size={16} color="var(--mantine-color-edr-green-6)" /> return (
<Text fz="13px" c="edr-text" truncate> <Group key={doc.fileKey} justify="space-between" wrap="nowrap">
{doc.label} <Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
{doc.required ? " *" : ""} <FileText size={16} color="var(--mantine-color-edr-green-6)" />
</Text> <Text fz="13px" c="edr-text" truncate>
</Group> {doc.label}
<Group gap={8} wrap="nowrap"> {doc.required ? " *" : ""}
{doc.file ? ( </Text>
<> </Group>
{isViewable({ <Group gap={8} wrap="nowrap">
name: doc.file.name, {doc.file ? (
url: fileViewUrl(doc.file.id), <>
}) && ( {isViewable({
<Tooltip label="View"> name: doc.file.name,
url: fileViewUrl(doc.file.id),
}) && (
<Tooltip label="View">
<Box
component="button"
type="button"
onClick={() =>
view({
name: doc.file!.name,
url: fileViewUrl(doc.file!.id),
})
}
c="edr-green"
style={{
display: "flex",
background: "transparent",
border: "none",
cursor: "pointer",
}}
>
<Eye size={15} />
</Box>
</Tooltip>
)}
<Tooltip label="Download">
<Box <Box
component="button" component="a"
type="button" href={fileViewUrl(doc.file.id, true)}
onClick={() =>
view({
name: doc.file!.name,
url: fileViewUrl(doc.file!.id),
})
}
c="edr-green" c="edr-green"
style={{ style={{ display: "flex" }}
display: "flex",
background: "transparent",
border: "none",
cursor: "pointer",
}}
> >
<Eye size={15} /> <Download size={15} />
</Box> </Box>
</Tooltip> </Tooltip>
)} </>
<Tooltip label="Download"> ) : (
<Box <Text fz="12px" c="edr-muted">
component="a" Not uploaded
href={fileViewUrl(doc.file.id, true)} </Text>
c="edr-green"
style={{ display: "flex" }}
>
<Download size={15} />
</Box>
</Tooltip>
</>
) : (
<Text fz="12px" c="edr-muted">
Not uploaded
</Text>
)}
<FileButton
onChange={(f) =>
f && setOutputFiles((o) => ({ ...o, [doc.fileKey]: f }))
}
accept="application/pdf,image/*"
>
{(props) => (
<Button
{...props}
size="compact-xs"
variant="light"
color="edr-green"
leftSection={<Upload size={13} />}
>
{outputFiles[doc.fileKey] ? "Selected" : "Upload"}
</Button>
)} )}
</FileButton> <FileButton
onChange={(f) => {
if (!f) return;
setUploadingKey(doc.fileKey);
outputMutation.mutate({ [doc.fileKey]: f });
}}
accept="application/pdf,image/*"
disabled={isUploading}
>
{(props) => (
<Button
{...props}
size="compact-xs"
variant="light"
color="edr-green"
leftSection={
isUploading ? (
<Loader size={12} color="edr-green" />
) : (
<Upload size={13} />
)
}
loading={isUploading}
>
{doc.file ? "Replace" : "Upload"}
</Button>
)}
</FileButton>
</Group>
</Group> </Group>
</Group> );
))} })}
</Stack> </Stack>
<Group justify="flex-end" mt="md">
<Button
variant="light"
color="edr-green"
radius="md"
leftSection={<Upload size={15} />}
disabled={Object.keys(outputFiles).length === 0}
loading={outputMutation.isPending}
onClick={() => outputMutation.mutate()}
>
Upload output documents
</Button>
</Group>
</SectionCard> </SectionCard>
)} )}

View File

@@ -92,7 +92,7 @@ export function ContractClearanceReviewSection({
}: ContractClearanceReviewSectionProps) { }: ContractClearanceReviewSectionProps) {
const [queryNotes, setQueryNotes] = useState<Record<string, string>>({}); const [queryNotes, setQueryNotes] = useState<Record<string, string>>({});
const [openQuery, setOpenQuery] = useState<Record<string, boolean>>({}); const [openQuery, setOpenQuery] = useState<Record<string, boolean>>({});
const [outputFiles, setOutputFiles] = useState<Record<string, File>>({}); const [uploadingKey, setUploadingKey] = useState<string | null>(null);
const { view, viewer } = useFileViewer(); const { view, viewer } = useFileViewer();
const reviewerTeam = selfClear ? "Operations" : "Global Logistics"; const reviewerTeam = selfClear ? "Operations" : "Global Logistics";
@@ -242,110 +242,106 @@ export function ContractClearanceReviewSection({
<SectionCard <SectionCard
icon={Upload} icon={Upload}
title="GL output documents" title="GL output documents"
subtitle="Upload IM4/IM5/EX3/EX8/T1 and other cleared paperwork." subtitle="Upload each document individually — changes save immediately."
accent="edr-green" accent="edr-green"
> >
<Stack gap={10}> <Stack gap={10}>
{glDocs.map((doc) => ( {glDocs.map((doc) => {
<Group key={doc.fileKey} justify="space-between" wrap="nowrap"> const isUploading =
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}> uploadingKey === doc.fileKey && uploadOutputDocuments.isPending;
<FileText size={16} color="var(--mantine-color-edr-green-6)" /> return (
<Text fz="13px" c="edr-text" truncate> <Group key={doc.fileKey} justify="space-between" wrap="nowrap">
{doc.label} <Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
{doc.required ? " *" : ""} <FileText size={16} color="var(--mantine-color-edr-green-6)" />
</Text> <Text fz="13px" c="edr-text" truncate>
</Group> {doc.label}
<Group gap={8} wrap="nowrap"> {doc.required ? " *" : ""}
{doc.file ? ( </Text>
<> </Group>
{isViewable({ <Group gap={8} wrap="nowrap">
name: doc.file.name, {doc.file ? (
url: fileViewUrl(doc.file.id), <>
}) && ( {isViewable({
<Tooltip label="View"> name: doc.file.name,
url: fileViewUrl(doc.file.id),
}) && (
<Tooltip label="View">
<Box
component="button"
type="button"
onClick={() =>
view({
name: doc.file!.name,
url: fileViewUrl(doc.file!.id),
})
}
c="edr-green"
style={{
display: "flex",
background: "transparent",
border: "none",
cursor: "pointer",
}}
>
<Eye size={15} />
</Box>
</Tooltip>
)}
<Tooltip label="Download">
<Box <Box
component="button" component="a"
type="button" href={fileViewUrl(doc.file.id, true)}
onClick={() =>
view({
name: doc.file!.name,
url: fileViewUrl(doc.file!.id),
})
}
c="edr-green" c="edr-green"
style={{ style={{ display: "flex" }}
display: "flex",
background: "transparent",
border: "none",
cursor: "pointer",
}}
> >
<Eye size={15} /> <Download size={15} />
</Box> </Box>
</Tooltip> </Tooltip>
)} </>
<Tooltip label="Download"> ) : (
<Box <Text fz="12px" c="edr-muted">
component="a" Not uploaded
href={fileViewUrl(doc.file.id, true)} </Text>
c="edr-green" )}
style={{ display: "flex" }} {!readOnly && (
> <FileButton
<Download size={15} /> onChange={(f) => {
</Box> if (!f) return;
</Tooltip> setUploadingKey(doc.fileKey);
</> uploadOutputDocuments.mutate(
) : ( { [doc.fileKey]: f },
<Text fz="12px" c="edr-muted"> { onSuccess: () => { setUploadingKey(null); onChanged?.(); },
Not uploaded onError: () => setUploadingKey(null) },
</Text> );
)} }}
{!readOnly && ( accept="application/pdf,image/*"
<FileButton disabled={isUploading}
onChange={(f) => >
f && setOutputFiles((o) => ({ ...o, [doc.fileKey]: f })) {(props) => (
} <Button
accept="application/pdf,image/*" {...props}
> size="compact-xs"
{(props) => ( variant="light"
<Button color="edr-green"
{...props} leftSection={
size="compact-xs" isUploading ? (
variant="light" <Loader size={12} color="edr-green" />
color="edr-green" ) : (
leftSection={<Upload size={13} />} <Upload size={13} />
> )
{outputFiles[doc.fileKey] ? "Selected" : "Upload"} }
</Button> loading={isUploading}
)} >
</FileButton> {doc.file ? "Replace" : "Upload"}
)} </Button>
)}
</FileButton>
)}
</Group>
</Group> </Group>
</Group> );
))} })}
</Stack> </Stack>
{!readOnly && (
<Group justify="flex-end" mt="md">
<Button
variant="light"
color="edr-green"
radius="md"
leftSection={<Upload size={15} />}
disabled={Object.keys(outputFiles).length === 0}
loading={uploadOutputDocuments.isPending}
onClick={() =>
uploadOutputDocuments.mutate(outputFiles, {
onSuccess: () => {
setOutputFiles({});
onChanged?.();
},
})
}
>
Upload output documents
</Button>
</Group>
)}
</SectionCard> </SectionCard>
)} )}

View File

@@ -1,6 +1,6 @@
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom"; import { useNavigate, useSearchParams } from "react-router-dom";
import { import {
ActionIcon, ActionIcon,
Badge, Badge,
@@ -20,7 +20,9 @@ import {
import { import {
ArrowRight, ArrowRight,
Calendar, Calendar,
CheckCircle,
ChevronRight, ChevronRight,
History,
Inbox, Inbox,
LayoutGrid, LayoutGrid,
RefreshCw, RefreshCw,
@@ -46,12 +48,16 @@ import { bookingTable } from "@/components/bookings/booking-ui.styles";
import { bookingsService } from "@/services/bookings.service"; import { bookingsService } from "@/services/bookings.service";
import type { BookingDetail } from "@/types/booking"; import type { BookingDetail } from "@/types/booking";
import { import {
CLEARANCE_REVIEW_STATUS,
CLEARANCE_TABS, CLEARANCE_TABS,
type ClearanceTabKey, type ClearanceTabKey,
} from "@/features/clearance/clearance-tabs.config"; } from "@/features/clearance/clearance-tabs.config";
type ViewMode = "table" | "cards"; type ViewMode = "table" | "cards";
type PageTab = "queue" | "history";
type ClearanceMode = "gl" | "ops";
const CLEARANCE_REVIEW_STATUS = "DOCUMENTS_UNDER_REVIEW";
const CLEARANCE_HISTORY_STATUS = "CLEARANCE_READY";
interface ClearanceRow { interface ClearanceRow {
id: string; id: string;
@@ -62,6 +68,7 @@ interface ClearanceRow {
originLabel: string; originLabel: string;
destinationLabel: string; destinationLabel: string;
scheduledDate: string; scheduledDate: string;
updatedAt: string;
hasCustoms: boolean; hasCustoms: boolean;
} }
@@ -85,6 +92,7 @@ function toClearanceRow(booking: BookingDetail): ClearanceRow {
originLabel: labelFromRef(booking.originYard), originLabel: labelFromRef(booking.originYard),
destinationLabel: labelFromRef(booking.destinationYard), destinationLabel: labelFromRef(booking.destinationYard),
scheduledDate: booking.scheduledDate, scheduledDate: booking.scheduledDate,
updatedAt: booking.updatedAt ?? "",
hasCustoms: Boolean( hasCustoms: Boolean(
booking.customsClearingEnabled ?? booking.serviceType?.includesCustoms, booking.customsClearingEnabled ?? booking.serviceType?.includesCustoms,
), ),
@@ -102,12 +110,6 @@ function formatDate(iso?: string): string {
}); });
} }
/**
* Icon-only chip for a booking's trade direction — Truck for import, ShipWheel
* for export — on a light background, matching the "awaiting review" badge
* styling. Keeps the cards within the white / light-gray / green palette and
* drops the text label in favour of a tooltip.
*/
function DirectionIcon({ direction }: { direction: string }) { function DirectionIcon({ direction }: { direction: string }) {
const isImport = direction === "IMPORT"; const isImport = direction === "IMPORT";
const Icon = isImport ? Truck : ShipWheel; const Icon = isImport ? Truck : ShipWheel;
@@ -129,33 +131,55 @@ function DirectionIcon({ direction }: { direction: string }) {
export default function DocumentClearanceListPage() { export default function DocumentClearanceListPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
// "mode=ops" query param lets the old ops-clearance redirect land on the right tab
const initialMode: ClearanceMode =
searchParams.get("mode") === "ops" ? "ops" : "gl";
const [clearanceMode, setClearanceMode] = useState<ClearanceMode>(initialMode);
const [pageTab, setPageTab] = useState<PageTab>("queue");
const [activeTab, setActiveTab] = useState<ClearanceTabKey>("all"); const [activeTab, setActiveTab] = useState<ClearanceTabKey>("all");
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [view, setView] = useState<ViewMode>("table"); const [view, setView] = useState<ViewMode>("table");
const { pagination, setPagination } = usePagination({ pageSize: 10 }); const { pagination, setPagination } = usePagination({ pageSize: 10 });
const isHistory = pageTab === "history";
const { data, isLoading, isError, isFetching, refetch } = useQuery({ const { data, isLoading, isError, isFetching, refetch } = useQuery({
queryKey: ["clearance", "list"], queryKey: ["clearance", "list", clearanceMode, isHistory],
queryFn: () => queryFn: () =>
bookingsService.list({ status: CLEARANCE_REVIEW_STATUS, pageSize: 200 }), bookingsService.list({
status: isHistory ? CLEARANCE_HISTORY_STATUS : CLEARANCE_REVIEW_STATUS,
pageSize: 200,
}),
}); });
// GL clears customs bookings only; non-customs clearance is reviewed by const allRows = useMemo(() => {
// Marketing on the booking detail. Scope the queue defensively so a staff or const rows = (data?.items ?? []).map(toClearanceRow);
// marketing user opening this page still sees the customs queue. const filtered =
const allRows = useMemo( clearanceMode === "gl"
() => (data?.items ?? []).map(toClearanceRow).filter((r) => r.hasCustoms), ? rows.filter((r) => r.hasCustoms)
[data?.items], : rows.filter((r) => !r.hasCustoms);
);
// Per-tab counts drive the badge on each tab. if (isHistory) {
const tabCounts = useMemo(() => { // Latest cleared first — fall back to updatedAt
return { return [...filtered].sort((a, b) => {
const ta = new Date(a.updatedAt || 0).getTime();
const tb = new Date(b.updatedAt || 0).getTime();
return tb - ta;
});
}
return filtered;
}, [data?.items, clearanceMode, isHistory]);
const tabCounts = useMemo(
() => ({
all: allRows.length, all: allRows.length,
import: allRows.filter((r) => r.tradeDirection === "IMPORT").length, import: allRows.filter((r) => r.tradeDirection === "IMPORT").length,
export: allRows.filter((r) => r.tradeDirection === "EXPORT").length, export: allRows.filter((r) => r.tradeDirection === "EXPORT").length,
} satisfies Record<ClearanceTabKey, number>; }),
}, [allRows]); [allRows],
);
const rows = useMemo(() => { const rows = useMemo(() => {
const q = query.trim().toLowerCase(); const q = query.trim().toLowerCase();
@@ -185,6 +209,35 @@ export default function DocumentClearanceListPage() {
[navigate], [navigate],
); );
const handleModeChange = (mode: ClearanceMode) => {
setClearanceMode(mode);
setPageTab("queue");
setActiveTab("all");
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
// clear the ?mode= param after first use
setSearchParams({});
};
const statusBadge = isHistory ? (
<Badge
variant="light"
color="edr-green"
radius="sm"
leftSection={<CheckCircle size={13} />}
>
{tabCounts.all} cleared
</Badge>
) : (
<Badge
variant="light"
color="edr-green"
radius="sm"
leftSection={<ShieldCheck size={13} />}
>
{tabCounts.all} awaiting review
</Badge>
);
const columns: ColumnDef<ClearanceRow>[] = useMemo( const columns: ColumnDef<ClearanceRow>[] = useMemo(
() => [ () => [
{ {
@@ -249,11 +302,16 @@ export default function DocumentClearanceListPage() {
{ {
id: "status", id: "status",
header: () => <span className={bookingTable.headerCell}>Status</span>, header: () => <span className={bookingTable.headerCell}>Status</span>,
cell: () => ( cell: ({ row }) =>
<Badge size="sm" variant="light" color="edr-green" radius="sm"> isHistory ? (
Under review <Badge size="sm" variant="light" color="edr-green" radius="sm">
</Badge> Cleared
), </Badge>
) : (
<Badge size="sm" variant="light" color="yellow" radius="sm">
Under review
</Badge>
),
}, },
{ {
id: "go", id: "go",
@@ -265,7 +323,7 @@ export default function DocumentClearanceListPage() {
), ),
}, },
], ],
[], [isHistory],
); );
return ( return (
@@ -274,16 +332,7 @@ export default function DocumentClearanceListPage() {
<PageHeader <PageHeader
title="Document Clearance" title="Document Clearance"
subtitle="Review customer documents, raise queries, and finalize clearance for each booking." subtitle="Review customer documents, raise queries, and finalize clearance for each booking."
meta={ meta={statusBadge}
<Badge
variant="light"
color="edr-green"
radius="sm"
leftSection={<ShieldCheck size={13} />}
>
{tabCounts.all} awaiting review
</Badge>
}
action={ action={
<ActionIcon <ActionIcon
variant="default" variant="default"
@@ -298,13 +347,56 @@ export default function DocumentClearanceListPage() {
} }
/> />
{/* Mode toggle: GL Customs vs Self-Clearance (Operations) */}
<Group>
<SegmentedControl
value={clearanceMode}
onChange={(v) => handleModeChange(v as ClearanceMode)}
data={[
{ value: "gl", label: "GL Clearance" },
{ value: "ops", label: "Self-Clearance" },
]}
radius="md"
color="edr-green"
/>
<SegmentedControl
value={pageTab}
onChange={(v) => {
setPageTab(v as PageTab);
setActiveTab("all");
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}}
data={[
{
value: "queue",
label: (
<Group gap={6} wrap="nowrap">
<Inbox size={14} />
<span>Queue</span>
</Group>
),
},
{
value: "history",
label: (
<Group gap={6} wrap="nowrap">
<History size={14} />
<span>History</span>
</Group>
),
},
]}
radius="md"
/>
</Group>
<KpiStrip <KpiStrip
loading={isLoading} loading={isLoading}
items={[ items={[
{ {
label: "Awaiting review", label: isHistory ? "Cleared" : "Awaiting review",
value: tabCounts.all, value: tabCounts.all,
icon: Inbox, icon: isHistory ? CheckCircle : Inbox,
color: "edr-green", color: "edr-green",
}, },
{ {
@@ -370,10 +462,7 @@ export default function DocumentClearanceListPage() {
value={query} value={query}
onChange={(e) => { onChange={(e) => {
setQuery(e.currentTarget.value); setQuery(e.currentTarget.value);
setPagination({ setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
pageIndex: 0,
pageSize: pagination.pageSize,
});
}} }}
rightSection={ rightSection={
query ? ( query ? (
@@ -430,9 +519,7 @@ export default function DocumentClearanceListPage() {
<DataTable<ClearanceRow, unknown> <DataTable<ClearanceRow, unknown>
columns={columns} columns={columns}
data={pagedRows} data={pagedRows}
status={ status={isLoading ? "loading" : isError ? "error" : "success"}
isLoading ? "loading" : isError ? "error" : "success"
}
onRowClick={(row) => openDetail(row.id)} onRowClick={(row) => openDetail(row.id)}
pagination={{ pagination={{
pageIndex: pagination.pageIndex, pageIndex: pagination.pageIndex,
@@ -454,6 +541,7 @@ export default function DocumentClearanceListPage() {
<ClearanceCardGrid <ClearanceCardGrid
rows={pagedRows} rows={pagedRows}
loading={isLoading} loading={isLoading}
isHistory={isHistory}
onOpen={openDetail} onOpen={openDetail}
/> />
)} )}
@@ -467,10 +555,12 @@ export default function DocumentClearanceListPage() {
function ClearanceCardGrid({ function ClearanceCardGrid({
rows, rows,
loading, loading,
isHistory,
onOpen, onOpen,
}: { }: {
rows: ClearanceRow[]; rows: ClearanceRow[];
loading: boolean; loading: boolean;
isHistory: boolean;
onOpen: (id: string) => void; onOpen: (id: string) => void;
}) { }) {
if (loading) { if (loading) {
@@ -495,7 +585,12 @@ function ClearanceCardGrid({
return ( return (
<SimpleGrid cols={{ base: 1, sm: 2, xl: 3 }} spacing="md" p="md"> <SimpleGrid cols={{ base: 1, sm: 2, xl: 3 }} spacing="md" p="md">
{rows.map((r) => ( {rows.map((r) => (
<ClearanceCard key={r.id} row={r} onOpen={() => onOpen(r.id)} /> <ClearanceCard
key={r.id}
row={r}
isHistory={isHistory}
onOpen={() => onOpen(r.id)}
/>
))} ))}
</SimpleGrid> </SimpleGrid>
); );
@@ -503,9 +598,11 @@ function ClearanceCardGrid({
function ClearanceCard({ function ClearanceCard({
row, row,
isHistory,
onOpen, onOpen,
}: { }: {
row: ClearanceRow; row: ClearanceRow;
isHistory: boolean;
onOpen: () => void; onOpen: () => void;
}) { }) {
return ( return (
@@ -543,9 +640,15 @@ function ClearanceCard({
</Group> </Group>
</Box> </Box>
</Group> </Group>
<Badge size="sm" variant="light" color="edr-green" radius="sm"> {isHistory ? (
Under review <Badge size="sm" variant="light" color="edr-green" radius="sm">
</Badge> Cleared
</Badge>
) : (
<Badge size="sm" variant="light" color="yellow" radius="sm">
Under review
</Badge>
)}
</Group> </Group>
<Box <Box