mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 07:45:45 +00:00
feat: implement PDF regeneration for contracts and enhance document upload handling in clearance sections
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
@@ -20,7 +20,9 @@ import {
|
||||
import {
|
||||
ArrowRight,
|
||||
Calendar,
|
||||
CheckCircle,
|
||||
ChevronRight,
|
||||
History,
|
||||
Inbox,
|
||||
LayoutGrid,
|
||||
RefreshCw,
|
||||
@@ -46,12 +48,16 @@ import { bookingTable } from "@/components/bookings/booking-ui.styles";
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
import type { BookingDetail } from "@/types/booking";
|
||||
import {
|
||||
CLEARANCE_REVIEW_STATUS,
|
||||
CLEARANCE_TABS,
|
||||
type ClearanceTabKey,
|
||||
} from "@/features/clearance/clearance-tabs.config";
|
||||
|
||||
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 {
|
||||
id: string;
|
||||
@@ -62,6 +68,7 @@ interface ClearanceRow {
|
||||
originLabel: string;
|
||||
destinationLabel: string;
|
||||
scheduledDate: string;
|
||||
updatedAt: string;
|
||||
hasCustoms: boolean;
|
||||
}
|
||||
|
||||
@@ -85,6 +92,7 @@ function toClearanceRow(booking: BookingDetail): ClearanceRow {
|
||||
originLabel: labelFromRef(booking.originYard),
|
||||
destinationLabel: labelFromRef(booking.destinationYard),
|
||||
scheduledDate: booking.scheduledDate,
|
||||
updatedAt: booking.updatedAt ?? "",
|
||||
hasCustoms: Boolean(
|
||||
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 }) {
|
||||
const isImport = direction === "IMPORT";
|
||||
const Icon = isImport ? Truck : ShipWheel;
|
||||
@@ -129,33 +131,55 @@ function DirectionIcon({ direction }: { direction: string }) {
|
||||
|
||||
export default function DocumentClearanceListPage() {
|
||||
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 [query, setQuery] = useState("");
|
||||
const [view, setView] = useState<ViewMode>("table");
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
|
||||
const isHistory = pageTab === "history";
|
||||
|
||||
const { data, isLoading, isError, isFetching, refetch } = useQuery({
|
||||
queryKey: ["clearance", "list"],
|
||||
queryKey: ["clearance", "list", clearanceMode, isHistory],
|
||||
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
|
||||
// Marketing on the booking detail. Scope the queue defensively so a staff or
|
||||
// marketing user opening this page still sees the customs queue.
|
||||
const allRows = useMemo(
|
||||
() => (data?.items ?? []).map(toClearanceRow).filter((r) => r.hasCustoms),
|
||||
[data?.items],
|
||||
);
|
||||
const allRows = useMemo(() => {
|
||||
const rows = (data?.items ?? []).map(toClearanceRow);
|
||||
const filtered =
|
||||
clearanceMode === "gl"
|
||||
? rows.filter((r) => r.hasCustoms)
|
||||
: rows.filter((r) => !r.hasCustoms);
|
||||
|
||||
// Per-tab counts drive the badge on each tab.
|
||||
const tabCounts = useMemo(() => {
|
||||
return {
|
||||
if (isHistory) {
|
||||
// Latest cleared first — fall back to updatedAt
|
||||
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,
|
||||
import: allRows.filter((r) => r.tradeDirection === "IMPORT").length,
|
||||
export: allRows.filter((r) => r.tradeDirection === "EXPORT").length,
|
||||
} satisfies Record<ClearanceTabKey, number>;
|
||||
}, [allRows]);
|
||||
}),
|
||||
[allRows],
|
||||
);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
@@ -185,6 +209,35 @@ export default function DocumentClearanceListPage() {
|
||||
[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(
|
||||
() => [
|
||||
{
|
||||
@@ -249,11 +302,16 @@ export default function DocumentClearanceListPage() {
|
||||
{
|
||||
id: "status",
|
||||
header: () => <span className={bookingTable.headerCell}>Status</span>,
|
||||
cell: () => (
|
||||
<Badge size="sm" variant="light" color="edr-green" radius="sm">
|
||||
Under review
|
||||
</Badge>
|
||||
),
|
||||
cell: ({ row }) =>
|
||||
isHistory ? (
|
||||
<Badge size="sm" variant="light" color="edr-green" radius="sm">
|
||||
Cleared
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge size="sm" variant="light" color="yellow" radius="sm">
|
||||
Under review
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "go",
|
||||
@@ -265,7 +323,7 @@ export default function DocumentClearanceListPage() {
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
[isHistory],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -274,16 +332,7 @@ export default function DocumentClearanceListPage() {
|
||||
<PageHeader
|
||||
title="Document Clearance"
|
||||
subtitle="Review customer documents, raise queries, and finalize clearance for each booking."
|
||||
meta={
|
||||
<Badge
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="sm"
|
||||
leftSection={<ShieldCheck size={13} />}
|
||||
>
|
||||
{tabCounts.all} awaiting review
|
||||
</Badge>
|
||||
}
|
||||
meta={statusBadge}
|
||||
action={
|
||||
<ActionIcon
|
||||
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
|
||||
loading={isLoading}
|
||||
items={[
|
||||
{
|
||||
label: "Awaiting review",
|
||||
label: isHistory ? "Cleared" : "Awaiting review",
|
||||
value: tabCounts.all,
|
||||
icon: Inbox,
|
||||
icon: isHistory ? CheckCircle : Inbox,
|
||||
color: "edr-green",
|
||||
},
|
||||
{
|
||||
@@ -370,10 +462,7 @@ export default function DocumentClearanceListPage() {
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.currentTarget.value);
|
||||
setPagination({
|
||||
pageIndex: 0,
|
||||
pageSize: pagination.pageSize,
|
||||
});
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
}}
|
||||
rightSection={
|
||||
query ? (
|
||||
@@ -430,9 +519,7 @@ export default function DocumentClearanceListPage() {
|
||||
<DataTable<ClearanceRow, unknown>
|
||||
columns={columns}
|
||||
data={pagedRows}
|
||||
status={
|
||||
isLoading ? "loading" : isError ? "error" : "success"
|
||||
}
|
||||
status={isLoading ? "loading" : isError ? "error" : "success"}
|
||||
onRowClick={(row) => openDetail(row.id)}
|
||||
pagination={{
|
||||
pageIndex: pagination.pageIndex,
|
||||
@@ -454,6 +541,7 @@ export default function DocumentClearanceListPage() {
|
||||
<ClearanceCardGrid
|
||||
rows={pagedRows}
|
||||
loading={isLoading}
|
||||
isHistory={isHistory}
|
||||
onOpen={openDetail}
|
||||
/>
|
||||
)}
|
||||
@@ -467,10 +555,12 @@ export default function DocumentClearanceListPage() {
|
||||
function ClearanceCardGrid({
|
||||
rows,
|
||||
loading,
|
||||
isHistory,
|
||||
onOpen,
|
||||
}: {
|
||||
rows: ClearanceRow[];
|
||||
loading: boolean;
|
||||
isHistory: boolean;
|
||||
onOpen: (id: string) => void;
|
||||
}) {
|
||||
if (loading) {
|
||||
@@ -495,7 +585,12 @@ function ClearanceCardGrid({
|
||||
return (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, xl: 3 }} spacing="md" p="md">
|
||||
{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>
|
||||
);
|
||||
@@ -503,9 +598,11 @@ function ClearanceCardGrid({
|
||||
|
||||
function ClearanceCard({
|
||||
row,
|
||||
isHistory,
|
||||
onOpen,
|
||||
}: {
|
||||
row: ClearanceRow;
|
||||
isHistory: boolean;
|
||||
onOpen: () => void;
|
||||
}) {
|
||||
return (
|
||||
@@ -543,9 +640,15 @@ function ClearanceCard({
|
||||
</Group>
|
||||
</Box>
|
||||
</Group>
|
||||
<Badge size="sm" variant="light" color="edr-green" radius="sm">
|
||||
Under review
|
||||
</Badge>
|
||||
{isHistory ? (
|
||||
<Badge size="sm" variant="light" color="edr-green" radius="sm">
|
||||
Cleared
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge size="sm" variant="light" color="yellow" radius="sm">
|
||||
Under review
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Box
|
||||
|
||||
Reference in New Issue
Block a user