mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 21:48:18 +00:00
feat: enhance cargo details handling and add document clearance features
- Improved handling of commodity selection in the cargo details form to prevent unwanted resets on re-renders. - Added `isReefer` flag to the CreateBookingDto interface for booking-level refrigerated status. - Introduced a new configuration file for clearance tabs to manage document clearance views. - Implemented DocumentClearanceDetailPage for detailed review and management of clearance documents. - Created DocumentClearanceListPage for listing and filtering clearance bookings with enhanced UI components.
This commit is contained in:
@@ -276,6 +276,12 @@ export class BookingPricingService {
|
|||||||
allowConsolidation,
|
allowConsolidation,
|
||||||
shippingLineId: booking.shippingLineId,
|
shippingLineId: booking.shippingLineId,
|
||||||
totalWagons,
|
totalWagons,
|
||||||
|
// Bulk tonnage scales PER_TON surcharges (e.g. the bulk reefer surcharge).
|
||||||
|
// Container freight carries 0 here — its surcharges scale by container count.
|
||||||
|
bulkTons:
|
||||||
|
booking.freightType === 'BULK'
|
||||||
|
? Number(booking.cargoTotalWeightVgm ?? 0)
|
||||||
|
: 0,
|
||||||
containers,
|
containers,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,8 +126,10 @@ export class BookingsService {
|
|||||||
paymentCurrency: string;
|
paymentCurrency: string;
|
||||||
tradeDirection: string;
|
tradeDirection: string;
|
||||||
isHazardous?: boolean;
|
isHazardous?: boolean;
|
||||||
|
isReefer?: boolean;
|
||||||
isGovernment?: boolean;
|
isGovernment?: boolean;
|
||||||
shippingLineId?: string | null;
|
shippingLineId?: string | null;
|
||||||
|
bulkTons?: number;
|
||||||
containers: CreateBookingContainerDto[];
|
containers: CreateBookingContainerDto[];
|
||||||
}): Promise<BookingEvaluationInput> {
|
}): Promise<BookingEvaluationInput> {
|
||||||
const containerLines =
|
const containerLines =
|
||||||
@@ -166,10 +168,14 @@ export class BookingsService {
|
|||||||
paymentCurrency: dto.paymentCurrency,
|
paymentCurrency: dto.paymentCurrency,
|
||||||
tradeDirection: dto.tradeDirection,
|
tradeDirection: dto.tradeDirection,
|
||||||
isHazardous: dto.isHazardous ?? false,
|
isHazardous: dto.isHazardous ?? false,
|
||||||
|
// Bulk reefer comes from the customer toggle; container reefer is derived
|
||||||
|
// from the container type and ORed in by the engine.
|
||||||
|
isReefer: dto.freightType === 'BULK' ? (dto.isReefer ?? false) : false,
|
||||||
isGovernment: dto.isGovernment ?? false,
|
isGovernment: dto.isGovernment ?? false,
|
||||||
allowConsolidation,
|
allowConsolidation,
|
||||||
shippingLineId: dto.shippingLineId,
|
shippingLineId: dto.shippingLineId,
|
||||||
totalWagons,
|
totalWagons,
|
||||||
|
bulkTons: dto.freightType === 'BULK' ? Number(dto.bulkTons ?? 0) : 0,
|
||||||
containers,
|
containers,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -387,8 +393,10 @@ export class BookingsService {
|
|||||||
paymentCurrency: dto.paymentCurrency,
|
paymentCurrency: dto.paymentCurrency,
|
||||||
tradeDirection,
|
tradeDirection,
|
||||||
isHazardous: dto.isHazardous,
|
isHazardous: dto.isHazardous,
|
||||||
|
isReefer: dto.isReefer,
|
||||||
isGovernment,
|
isGovernment,
|
||||||
shippingLineId: dto.shippingLineId,
|
shippingLineId: dto.shippingLineId,
|
||||||
|
bulkTons: dto.cargoTotalWeightVgm,
|
||||||
containers,
|
containers,
|
||||||
});
|
});
|
||||||
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
|
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
|
||||||
@@ -425,6 +433,10 @@ export class BookingsService {
|
|||||||
shippingLineId: dto.shippingLineId,
|
shippingLineId: dto.shippingLineId,
|
||||||
cargoTotalWeightVgm: dto.cargoTotalWeightVgm,
|
cargoTotalWeightVgm: dto.cargoTotalWeightVgm,
|
||||||
isHazardous: dto.isHazardous ?? false,
|
isHazardous: dto.isHazardous ?? false,
|
||||||
|
// Bulk reefer is the customer's toggle; container reefer is derived from
|
||||||
|
// the container type at pricing time, so the booking-level flag stays off
|
||||||
|
// for container freight to avoid double-counting.
|
||||||
|
isReefer: dto.freightType === 'BULK' ? (dto.isReefer ?? false) : false,
|
||||||
paymentCurrency: dto.paymentCurrency,
|
paymentCurrency: dto.paymentCurrency,
|
||||||
pnrCode: dto.pnrCode,
|
pnrCode: dto.pnrCode,
|
||||||
financialTerms: dto.financialTerms,
|
financialTerms: dto.financialTerms,
|
||||||
@@ -586,7 +598,9 @@ export class BookingsService {
|
|||||||
paymentCurrency: dto.paymentCurrency ?? existing.paymentCurrency,
|
paymentCurrency: dto.paymentCurrency ?? existing.paymentCurrency,
|
||||||
tradeDirection,
|
tradeDirection,
|
||||||
isHazardous: dto.isHazardous ?? existing.isHazardous,
|
isHazardous: dto.isHazardous ?? existing.isHazardous,
|
||||||
|
isReefer: dto.isReefer ?? existing.isReefer,
|
||||||
shippingLineId: dto.shippingLineId ?? existing.shippingLineId ?? undefined,
|
shippingLineId: dto.shippingLineId ?? existing.shippingLineId ?? undefined,
|
||||||
|
bulkTons: dto.cargoTotalWeightVgm ?? Number(existing.cargoTotalWeightVgm ?? 0),
|
||||||
containers,
|
containers,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -606,6 +620,12 @@ export class BookingsService {
|
|||||||
...dto,
|
...dto,
|
||||||
freightType,
|
freightType,
|
||||||
cargoTypeId: freightType === 'BULK' ? cargoTypeId : null,
|
cargoTypeId: freightType === 'BULK' ? cargoTypeId : null,
|
||||||
|
// Booking-level reefer is only meaningful for bulk; container reefer is
|
||||||
|
// derived from the container type at pricing time.
|
||||||
|
isReefer:
|
||||||
|
freightType === 'BULK'
|
||||||
|
? (dto.isReefer ?? existing.isReefer ?? false)
|
||||||
|
: false,
|
||||||
priorityScore: ruleResult.priorityScore,
|
priorityScore: ruleResult.priorityScore,
|
||||||
tradeDirection,
|
tradeDirection,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -306,6 +306,17 @@ export class CreateBookingDto {
|
|||||||
@Transform(({ value }) => value === 'true' || value === true)
|
@Transform(({ value }) => value === 'true' || value === true)
|
||||||
isHazardous?: boolean;
|
isHazardous?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Booking-level refrigerated flag. For bulk freight this is the customer's
|
||||||
|
* reefer choice (containers derive reefer from the container type instead).
|
||||||
|
* ORed with per-container reefer when the REEFER surcharge is evaluated.
|
||||||
|
*/
|
||||||
|
@ApiPropertyOptional({ default: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
@Transform(({ value }) => value === 'true' || value === true)
|
||||||
|
isReefer?: boolean;
|
||||||
|
|
||||||
@ApiProperty({ enum: PAYMENT_CURRENCIES })
|
@ApiProperty({ enum: PAYMENT_CURRENCIES })
|
||||||
@IsIn([...PAYMENT_CURRENCIES])
|
@IsIn([...PAYMENT_CURRENCIES])
|
||||||
paymentCurrency!: string;
|
paymentCurrency!: string;
|
||||||
|
|||||||
@@ -57,6 +57,12 @@ export interface BookingEvaluationInput {
|
|||||||
allowConsolidation?: boolean;
|
allowConsolidation?: boolean;
|
||||||
shippingLineId?: string | null;
|
shippingLineId?: string | null;
|
||||||
totalWagons: number;
|
totalWagons: number;
|
||||||
|
/**
|
||||||
|
* Total bulk tonnage on the booking (cargoTotalWeightVgm). Used to scale
|
||||||
|
* PER_TON surcharges (e.g. the bulk reefer surcharge). 0/undefined for
|
||||||
|
* container freight, which is scaled by container count instead.
|
||||||
|
*/
|
||||||
|
bulkTons?: number;
|
||||||
containers: BookingContainerEvalInput[];
|
containers: BookingContainerEvalInput[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,16 +230,46 @@ export class RuleEngineService {
|
|||||||
});
|
});
|
||||||
if (!triggered) continue;
|
if (!triggered) continue;
|
||||||
|
|
||||||
let triggerValue: number | null = null;
|
// Surcharges scale by their own rateUnit, so the same trigger can bill the
|
||||||
let calculatedAmount = Number(rate.rateValue);
|
// right way per freight shape — e.g. a PER_TON reefer rate multiplies the
|
||||||
|
// bulk tonnage, while a PER_CONTAINER reefer rate multiplies the container
|
||||||
|
// count. triggerValue records the quantity billed (shown on the breakdown).
|
||||||
|
const rateValue = Number(rate.rateValue);
|
||||||
|
const containerCount = input.containers.reduce(
|
||||||
|
(sum, c) => sum + Number(c.quantity || 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const overweightExcessTons = containerWeightResults.reduce(
|
||||||
|
(sum, r) => sum + (r.overweightExcessTons ?? 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
// Per-ton surcharges (typically OVERWEIGHT) bill against the excess tons.
|
let triggerValue: number | null = null;
|
||||||
if (rate.rateUnit === 'PER_TON' && rate.trigger === 'OVERWEIGHT') {
|
let calculatedAmount: number;
|
||||||
triggerValue = containerWeightResults.reduce(
|
|
||||||
(sum, r) => sum + (r.overweightExcessTons ?? 0),
|
switch (rate.rateUnit) {
|
||||||
0,
|
case 'PER_TON':
|
||||||
);
|
// OVERWEIGHT bills the excess tons; every other PER_TON surcharge
|
||||||
calculatedAmount = triggerValue * Number(rate.rateValue);
|
// (e.g. bulk reefer) bills the full bulk tonnage.
|
||||||
|
triggerValue =
|
||||||
|
rate.trigger === 'OVERWEIGHT'
|
||||||
|
? overweightExcessTons
|
||||||
|
: Number(input.bulkTons ?? 0);
|
||||||
|
calculatedAmount = triggerValue * rateValue;
|
||||||
|
break;
|
||||||
|
case 'PER_CONTAINER':
|
||||||
|
triggerValue = containerCount;
|
||||||
|
calculatedAmount = triggerValue * rateValue;
|
||||||
|
break;
|
||||||
|
case 'PER_WAGON':
|
||||||
|
triggerValue = input.totalWagons;
|
||||||
|
calculatedAmount = triggerValue * rateValue;
|
||||||
|
break;
|
||||||
|
case 'FLAT':
|
||||||
|
default:
|
||||||
|
// FLAT (and any unknown unit) bills once.
|
||||||
|
calculatedAmount = rateValue;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Safety guard: never include a surcharge with a non-positive amount (a
|
// Safety guard: never include a surcharge with a non-positive amount (a
|
||||||
|
|||||||
@@ -474,7 +474,13 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
|
|||||||
// ── Surcharges (trigger-based) ──────────────────────────────────────
|
// ── Surcharges (trigger-based) ──────────────────────────────────────
|
||||||
{ appliesTo: "OTHER", trigger: "OVERWEIGHT", rateType: "OVERWEIGHT_PER_TON", rateValue: 25, rateUnit: "PER_TON" },
|
{ appliesTo: "OTHER", trigger: "OVERWEIGHT", rateType: "OVERWEIGHT_PER_TON", rateValue: 25, rateUnit: "PER_TON" },
|
||||||
{ appliesTo: "OTHER", trigger: "HAZARDOUS", rateType: "HAZARD_SURCHARGE", rateValue: 150, rateUnit: "FLAT" },
|
{ appliesTo: "OTHER", trigger: "HAZARDOUS", rateType: "HAZARD_SURCHARGE", rateValue: 150, rateUnit: "FLAT" },
|
||||||
{ appliesTo: "OTHER", trigger: "REEFER", rateType: "REEFER_SURCHARGE", rateValue: 200, rateUnit: "FLAT" },
|
// Reefer surcharge scales with the freight shape: container bookings bill
|
||||||
|
// per reefer container, bulk bookings bill per ton. The engine now honors
|
||||||
|
// each rate's unit, so both rows can coexist — only the matching one
|
||||||
|
// produces a non-zero line (the other multiplies by 0 and is dropped).
|
||||||
|
// Small test values (< 20) so the surcharge stays a minor add for now.
|
||||||
|
{ appliesTo: "OTHER", trigger: "REEFER", rateType: "REEFER_SURCHARGE", rateValue: 15, rateUnit: "PER_CONTAINER" },
|
||||||
|
{ appliesTo: "OTHER", trigger: "REEFER", rateType: "REEFER_SURCHARGE", rateValue: 2, rateUnit: "PER_TON" },
|
||||||
{ appliesTo: "OTHER", trigger: "SHIPPING_LINE", rateType: "DOUBLE_HANDLING", rateValue: 100, rateUnit: "PER_CONTAINER" },
|
{ appliesTo: "OTHER", trigger: "SHIPPING_LINE", rateType: "DOUBLE_HANDLING", rateValue: 100, rateUnit: "PER_CONTAINER" },
|
||||||
{ appliesTo: "OTHER", trigger: "CONSOLIDATION", rateType: "LASHING", rateValue: 50, rateUnit: "PER_CONTAINER" },
|
{ appliesTo: "OTHER", trigger: "CONSOLIDATION", rateType: "LASHING", rateValue: 50, rateUnit: "PER_CONTAINER" },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ import LoginPage from "./pages/auth/LoginPage";
|
|||||||
import BookingContractPage from "./pages/bookings/BookingContractPage";
|
import BookingContractPage from "./pages/bookings/BookingContractPage";
|
||||||
import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage";
|
import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage";
|
||||||
import BookingRequestsPage from "./pages/bookings/BookingRequestsPage";
|
import BookingRequestsPage from "./pages/bookings/BookingRequestsPage";
|
||||||
import GlClearancePage from "./pages/bookings/GlClearancePage";
|
import DocumentClearanceListPage from "./pages/bookings/DocumentClearanceListPage";
|
||||||
|
import DocumentClearanceDetailPage from "./pages/bookings/DocumentClearanceDetailPage";
|
||||||
import NewBookingPage from "./pages/bookings/NewBookingPage";
|
import NewBookingPage from "./pages/bookings/NewBookingPage";
|
||||||
import CustomerDetailPage from "./pages/customers/CustomerDetailPage";
|
import CustomerDetailPage from "./pages/customers/CustomerDetailPage";
|
||||||
import CustomersPage from "./pages/customers/CustomersPage";
|
import CustomersPage from "./pages/customers/CustomersPage";
|
||||||
@@ -417,7 +418,15 @@ const App = () => {
|
|||||||
path="clearance"
|
path="clearance"
|
||||||
element={
|
element={
|
||||||
<RequirePermission permission={FREIGHT_PERMS.bookings.reviewDocuments}>
|
<RequirePermission permission={FREIGHT_PERMS.bookings.reviewDocuments}>
|
||||||
<GlClearancePage />
|
<DocumentClearanceListPage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="clearance/:id"
|
||||||
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.bookings.reviewDocuments}>
|
||||||
|
<DocumentClearanceDetailPage />
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import { Layers, ShieldCheck, ShipWheel, Truck } from "lucide-react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The document-clearance queue is a single backend status
|
||||||
|
* (`DOCUMENTS_UNDER_REVIEW`); the tabs slice that queue by the operational axis
|
||||||
|
* that matters to a clearance officer — trade direction and customs scope —
|
||||||
|
* rather than by booking status (which is uniform here).
|
||||||
|
*/
|
||||||
|
export type ClearanceTabKey = "all" | "import" | "export" | "customs";
|
||||||
|
|
||||||
|
export interface ClearanceTab {
|
||||||
|
key: ClearanceTabKey;
|
||||||
|
label: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CLEARANCE_TABS: ClearanceTab[] = [
|
||||||
|
{ key: "all", label: "All", icon: Layers },
|
||||||
|
{ key: "import", label: "Import", icon: Truck },
|
||||||
|
{ key: "export", label: "Export", icon: ShipWheel },
|
||||||
|
{ key: "customs", label: "With customs", icon: ShieldCheck },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** The backend booking status that places a booking in the clearance queue. */
|
||||||
|
export const CLEARANCE_REVIEW_STATUS = "DOCUMENTS_UNDER_REVIEW";
|
||||||
@@ -0,0 +1,731 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Badge,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
FileButton,
|
||||||
|
Grid,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
Paper,
|
||||||
|
Progress,
|
||||||
|
RingProgress,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
Textarea,
|
||||||
|
ThemeIcon,
|
||||||
|
Tooltip,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
ArrowRight,
|
||||||
|
CheckCircle2,
|
||||||
|
Clock,
|
||||||
|
Download,
|
||||||
|
ExternalLink,
|
||||||
|
FileCheck2,
|
||||||
|
FileText,
|
||||||
|
MessageSquareWarning,
|
||||||
|
PackageCheck,
|
||||||
|
ShieldCheck,
|
||||||
|
Upload,
|
||||||
|
} from "lucide-react";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
|
import { PageContainer } from "@/components/page/PageContainer";
|
||||||
|
import { PageHeader } from "@/components/page/PageHeader";
|
||||||
|
import { SectionCard } from "@/components/bookings/detail";
|
||||||
|
import { bookingsService } from "@/services/bookings.service";
|
||||||
|
import { useBookingDetail } from "@/hooks/bookings/useBookings";
|
||||||
|
|
||||||
|
export default function DocumentClearanceDetailPage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
const { data: booking } = useBookingDetail(id);
|
||||||
|
const {
|
||||||
|
data: clearance,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["clearance", id],
|
||||||
|
queryFn: () => bookingsService.getClearance(id!),
|
||||||
|
enabled: Boolean(id),
|
||||||
|
});
|
||||||
|
|
||||||
|
const [queryNotes, setQueryNotes] = useState<Record<string, string>>({});
|
||||||
|
const [openQuery, setOpenQuery] = useState<Record<string, boolean>>({});
|
||||||
|
const [outputFiles, setOutputFiles] = useState<Record<string, File>>({});
|
||||||
|
|
||||||
|
const refresh = () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["clearance", id] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["clearance", "list"] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const reviewMutation = useMutation({
|
||||||
|
mutationFn: (p: {
|
||||||
|
fileKey: string;
|
||||||
|
status: "APPROVED" | "QUERIED";
|
||||||
|
note?: string;
|
||||||
|
}) => bookingsService.reviewClearanceDocument(id!, p),
|
||||||
|
onSuccess: (_d, p) => {
|
||||||
|
toast.success(
|
||||||
|
p.status === "APPROVED" ? "Document approved" : "Query sent to customer",
|
||||||
|
);
|
||||||
|
if (p.status === "QUERIED")
|
||||||
|
setOpenQuery((o) => ({ ...o, [p.fileKey]: false }));
|
||||||
|
refresh();
|
||||||
|
},
|
||||||
|
onError: () => toast.error("Could not update document"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const outputMutation = useMutation({
|
||||||
|
mutationFn: () => bookingsService.uploadClearanceOutput(id!, outputFiles),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Output documents uploaded");
|
||||||
|
setOutputFiles({});
|
||||||
|
refresh();
|
||||||
|
},
|
||||||
|
onError: () => toast.error("Upload failed"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const finalizeMutation = useMutation({
|
||||||
|
mutationFn: () => bookingsService.finalizeClearance(id!),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Clearance finalized");
|
||||||
|
refresh();
|
||||||
|
navigate("/dashboard/clearance");
|
||||||
|
},
|
||||||
|
onError: (e) =>
|
||||||
|
toast.error(
|
||||||
|
e instanceof Error ? e.message : "Could not finalize clearance",
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
const customerDocs = useMemo(
|
||||||
|
() => (clearance?.documents ?? []).filter((d) => d.uploadedBy === "customer"),
|
||||||
|
[clearance],
|
||||||
|
);
|
||||||
|
const glDocs = useMemo(
|
||||||
|
() => (clearance?.documents ?? []).filter((d) => d.uploadedBy === "gl"),
|
||||||
|
[clearance],
|
||||||
|
);
|
||||||
|
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
const total = customerDocs.length;
|
||||||
|
const approved = customerDocs.filter(
|
||||||
|
(d) => d.reviewStatus === "APPROVED",
|
||||||
|
).length;
|
||||||
|
const queried = customerDocs.filter(
|
||||||
|
(d) => d.reviewStatus === "QUERIED",
|
||||||
|
).length;
|
||||||
|
const pending = total - approved - queried;
|
||||||
|
const pct = total === 0 ? 0 : Math.round((approved / total) * 100);
|
||||||
|
return { total, approved, queried, pending, pct };
|
||||||
|
}, [customerDocs]);
|
||||||
|
|
||||||
|
const reference = booking?.reference ?? "Clearance";
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<Group justify="center" py={80} gap={10}>
|
||||||
|
<Loader color="edr-green" />
|
||||||
|
<Text c="dimmed">Loading clearance…</Text>
|
||||||
|
</Group>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError || !clearance) {
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<PageHeader
|
||||||
|
title="Clearance not found"
|
||||||
|
backTo="/dashboard/clearance"
|
||||||
|
breadcrumbs={[
|
||||||
|
{ label: "Document Clearance", href: "/dashboard/clearance" },
|
||||||
|
{ label: "Not found" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Alert color="red" radius="md" icon={<AlertCircle size={16} />}>
|
||||||
|
We couldn’t load this booking’s clearance.
|
||||||
|
</Alert>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<Stack gap="lg">
|
||||||
|
<PageHeader
|
||||||
|
title={reference}
|
||||||
|
backTo="/dashboard/clearance"
|
||||||
|
breadcrumbs={[
|
||||||
|
{ label: "Document Clearance", href: "/dashboard/clearance" },
|
||||||
|
{ label: reference },
|
||||||
|
]}
|
||||||
|
meta={
|
||||||
|
clearance.allApproved ? (
|
||||||
|
<Badge
|
||||||
|
variant="light"
|
||||||
|
color="edr-green"
|
||||||
|
radius="sm"
|
||||||
|
leftSection={<CheckCircle2 size={13} />}
|
||||||
|
>
|
||||||
|
All approved
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge
|
||||||
|
variant="light"
|
||||||
|
color="edr-blue"
|
||||||
|
radius="sm"
|
||||||
|
leftSection={<Clock size={13} />}
|
||||||
|
>
|
||||||
|
Review pending
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Hero */}
|
||||||
|
<ClearanceHero
|
||||||
|
booking={booking}
|
||||||
|
clearance={clearance}
|
||||||
|
stats={stats}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Grid gap="lg">
|
||||||
|
{/* LEFT — document review */}
|
||||||
|
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||||
|
<Stack gap="lg">
|
||||||
|
<SectionCard
|
||||||
|
icon={FileText}
|
||||||
|
title="Customer documents"
|
||||||
|
subtitle="Approve each document, or open a query to tell the customer what to fix."
|
||||||
|
extra={
|
||||||
|
<Text size="xs" c="dimmed" fw={600}>
|
||||||
|
{stats.approved}/{stats.total} approved
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Stack gap={12}>
|
||||||
|
{customerDocs.length === 0 ? (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
No customer documents are required for this booking.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
customerDocs.map((doc) => (
|
||||||
|
<DocReviewCard
|
||||||
|
key={`${doc.settingCode}:${doc.fileKey}`}
|
||||||
|
doc={doc}
|
||||||
|
note={queryNotes[doc.fileKey] ?? ""}
|
||||||
|
queryOpen={openQuery[doc.fileKey] ?? false}
|
||||||
|
onToggleQuery={(open) =>
|
||||||
|
setOpenQuery((o) => ({ ...o, [doc.fileKey]: open }))
|
||||||
|
}
|
||||||
|
onNote={(v) =>
|
||||||
|
setQueryNotes((n) => ({ ...n, [doc.fileKey]: v }))
|
||||||
|
}
|
||||||
|
onApprove={() =>
|
||||||
|
reviewMutation.mutate({
|
||||||
|
fileKey: doc.fileKey,
|
||||||
|
status: "APPROVED",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onQuery={() =>
|
||||||
|
reviewMutation.mutate({
|
||||||
|
fileKey: doc.fileKey,
|
||||||
|
status: "QUERIED",
|
||||||
|
note: queryNotes[doc.fileKey],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
busy={reviewMutation.isPending}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
{clearance.outputCode && (
|
||||||
|
<SectionCard
|
||||||
|
icon={Upload}
|
||||||
|
title="Customs output documents"
|
||||||
|
subtitle="Upload the cleared/customs paperwork to hand back to the customer."
|
||||||
|
accent="edr-blue"
|
||||||
|
>
|
||||||
|
<Stack gap={10}>
|
||||||
|
{glDocs.map((doc) => (
|
||||||
|
<Group
|
||||||
|
key={doc.fileKey}
|
||||||
|
justify="space-between"
|
||||||
|
wrap="nowrap"
|
||||||
|
>
|
||||||
|
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||||
|
<FileText
|
||||||
|
size={16}
|
||||||
|
color="var(--mantine-color-edr-blue-6)"
|
||||||
|
/>
|
||||||
|
<Text fz="13px" c="edr-text" truncate>
|
||||||
|
{doc.label}
|
||||||
|
{doc.required ? " *" : ""}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Group gap={8} wrap="nowrap">
|
||||||
|
{doc.file ? (
|
||||||
|
<Tooltip label="Download">
|
||||||
|
<Box
|
||||||
|
component="a"
|
||||||
|
href={doc.file.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
c="edr-blue"
|
||||||
|
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>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Grid.Col>
|
||||||
|
|
||||||
|
{/* RIGHT — sticky summary + finalize */}
|
||||||
|
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||||
|
<Box style={{ position: "sticky", top: 24 }}>
|
||||||
|
<Stack gap="lg">
|
||||||
|
<SectionCard icon={PackageCheck} title="Review progress">
|
||||||
|
<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="edr-slate"
|
||||||
|
label="Pending"
|
||||||
|
value={stats.pending}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
{finalizeMutation.isError && (
|
||||||
|
<Alert
|
||||||
|
color="red"
|
||||||
|
radius="md"
|
||||||
|
icon={<AlertCircle size={16} />}
|
||||||
|
>
|
||||||
|
{finalizeMutation.error instanceof Error
|
||||||
|
? finalizeMutation.error.message
|
||||||
|
: "Could not finalize clearance."}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Paper withBorder radius="md" p="md">
|
||||||
|
<Group gap={8} mb="xs">
|
||||||
|
<ThemeIcon
|
||||||
|
variant="light"
|
||||||
|
color={clearance.allApproved ? "edr-green" : "gray"}
|
||||||
|
radius="md"
|
||||||
|
size={28}
|
||||||
|
>
|
||||||
|
<FileCheck2 size={15} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text fw={700} size="sm" c="edr-text">
|
||||||
|
Finalize clearance
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Text fz="12.5px" c="dimmed" mb="md">
|
||||||
|
{clearance.allApproved
|
||||||
|
? "All required documents are approved — you can finalize."
|
||||||
|
: "Approve every required document to unlock finalization."}
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
color="edr-green"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<CheckCircle2 size={16} />}
|
||||||
|
disabled={!clearance.allApproved}
|
||||||
|
loading={finalizeMutation.isPending}
|
||||||
|
onClick={() => finalizeMutation.mutate()}
|
||||||
|
>
|
||||||
|
Finalize clearance
|
||||||
|
</Button>
|
||||||
|
</Paper>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
</Stack>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ClearanceHero({
|
||||||
|
booking,
|
||||||
|
clearance,
|
||||||
|
stats,
|
||||||
|
}: {
|
||||||
|
booking: ReturnType<typeof useBookingDetail>["data"];
|
||||||
|
clearance: Freight.ClearanceView;
|
||||||
|
stats: { pct: number; approved: number; total: number };
|
||||||
|
}) {
|
||||||
|
const direction = booking?.tradeDirection ?? "—";
|
||||||
|
const origin =
|
||||||
|
booking?.originYard?.label ?? booking?.originYard?.code ?? "Origin";
|
||||||
|
const destination =
|
||||||
|
booking?.destinationYard?.label ??
|
||||||
|
booking?.destinationYard?.code ??
|
||||||
|
"Destination";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper withBorder radius="md" p="lg">
|
||||||
|
<Group justify="space-between" align="flex-start" wrap="wrap" gap="lg">
|
||||||
|
<Group gap="md" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||||
|
<ThemeIcon variant="light" color="edr-green" radius="md" size={52}>
|
||||||
|
<ShieldCheck size={26} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Box style={{ minWidth: 0 }}>
|
||||||
|
<Group gap={8} wrap="nowrap">
|
||||||
|
<Text fw={800} fz={20} c="edr-text" truncate>
|
||||||
|
{booking?.reference ?? "Clearance"}
|
||||||
|
</Text>
|
||||||
|
<Badge
|
||||||
|
size="sm"
|
||||||
|
variant="light"
|
||||||
|
color={direction === "IMPORT" ? "edr-blue" : "edr-accent"}
|
||||||
|
radius="sm"
|
||||||
|
>
|
||||||
|
{direction}
|
||||||
|
</Badge>
|
||||||
|
{clearance.includesCustoms ? (
|
||||||
|
<Badge
|
||||||
|
size="sm"
|
||||||
|
variant="light"
|
||||||
|
color="edr-green"
|
||||||
|
radius="sm"
|
||||||
|
leftSection={<ShieldCheck size={12} />}
|
||||||
|
>
|
||||||
|
Customs
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
|
<Group gap={8} mt={6} wrap="nowrap">
|
||||||
|
<Text size="sm" fw={600} truncate maw={160}>
|
||||||
|
{origin}
|
||||||
|
</Text>
|
||||||
|
<ArrowRight size={15} className="shrink-0 text-muted-foreground" />
|
||||||
|
<Text size="sm" fw={600} truncate maw={160}>
|
||||||
|
{destination}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Box style={{ minWidth: 200, flex: 1, maxWidth: 320 }}>
|
||||||
|
<Group justify="space-between" mb={6}>
|
||||||
|
<Text size="xs" c="dimmed" fw={600}>
|
||||||
|
Document review
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{stats.approved}/{stats.total}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Progress value={stats.pct} color="edr-green" radius="xl" size="md" />
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProgressStat({
|
||||||
|
color,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
color: string;
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Stack gap={2} align="center">
|
||||||
|
<Text fw={700} fz={18} c="edr-text">
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
<Group gap={4} wrap="nowrap">
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
width: 7,
|
||||||
|
height: 7,
|
||||||
|
borderRadius: 999,
|
||||||
|
background: `var(--mantine-color-${color}-6)`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Text fz="11px" c="dimmed">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_META: Record<
|
||||||
|
Freight.DocumentReviewStatus,
|
||||||
|
{ label: string; color: string }
|
||||||
|
> = {
|
||||||
|
APPROVED: { label: "Approved", color: "edr-green" },
|
||||||
|
QUERIED: { label: "Queried", color: "red" },
|
||||||
|
PENDING: { label: "Pending", color: "edr-slate" },
|
||||||
|
};
|
||||||
|
|
||||||
|
function DocReviewCard({
|
||||||
|
doc,
|
||||||
|
note,
|
||||||
|
queryOpen,
|
||||||
|
onToggleQuery,
|
||||||
|
onNote,
|
||||||
|
onApprove,
|
||||||
|
onQuery,
|
||||||
|
busy,
|
||||||
|
}: {
|
||||||
|
doc: Freight.ClearanceDocument;
|
||||||
|
note: string;
|
||||||
|
queryOpen: boolean;
|
||||||
|
onToggleQuery: (open: boolean) => void;
|
||||||
|
onNote: (v: string) => void;
|
||||||
|
onApprove: () => void;
|
||||||
|
onQuery: () => void;
|
||||||
|
busy: boolean;
|
||||||
|
}) {
|
||||||
|
const status = doc.reviewStatus ?? "PENDING";
|
||||||
|
const meta = STATUS_META[status];
|
||||||
|
const hasFile = !!doc.file;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
withBorder
|
||||||
|
radius="md"
|
||||||
|
p="md"
|
||||||
|
style={{
|
||||||
|
borderColor:
|
||||||
|
status === "QUERIED"
|
||||||
|
? "var(--mantine-color-red-2)"
|
||||||
|
: status === "APPROVED"
|
||||||
|
? "var(--mantine-color-edr-green-2)"
|
||||||
|
: "var(--mantine-color-edr-border-6)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||||
|
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||||
|
<ThemeIcon
|
||||||
|
variant="light"
|
||||||
|
color={hasFile ? "edr-blue" : "gray"}
|
||||||
|
radius="md"
|
||||||
|
size={40}
|
||||||
|
>
|
||||||
|
<FileText size={19} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Box style={{ minWidth: 0 }}>
|
||||||
|
<Text fz="14px" fw={700} c="edr-text" truncate>
|
||||||
|
{doc.label}
|
||||||
|
{doc.required ? " *" : ""}
|
||||||
|
</Text>
|
||||||
|
<Text fz="12px" c="edr-muted" truncate>
|
||||||
|
{hasFile ? doc.file!.name : "Not uploaded by customer"}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group gap={8} wrap="nowrap">
|
||||||
|
<Badge variant="light" color={meta.color} radius="sm">
|
||||||
|
{meta.label}
|
||||||
|
</Badge>
|
||||||
|
{hasFile && (
|
||||||
|
<Tooltip label="Open document">
|
||||||
|
<Button
|
||||||
|
component="a"
|
||||||
|
href={doc.file!.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
size="compact-xs"
|
||||||
|
variant="default"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<ExternalLink size={13} />}
|
||||||
|
>
|
||||||
|
View
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{status === "QUERIED" && doc.note && (
|
||||||
|
<Alert
|
||||||
|
mt="sm"
|
||||||
|
color="red"
|
||||||
|
variant="light"
|
||||||
|
radius="md"
|
||||||
|
icon={<MessageSquareWarning size={15} />}
|
||||||
|
p="xs"
|
||||||
|
>
|
||||||
|
<Text fz="12.5px" c="red.9">
|
||||||
|
{doc.note}
|
||||||
|
</Text>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasFile && (
|
||||||
|
<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>
|
||||||
|
<Button
|
||||||
|
size="compact-sm"
|
||||||
|
color="edr-green"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<CheckCircle2 size={14} />}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={onApprove}
|
||||||
|
>
|
||||||
|
Approve
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
) : (
|
||||||
|
<Box
|
||||||
|
p="sm"
|
||||||
|
style={{
|
||||||
|
borderRadius: 12,
|
||||||
|
background: "var(--mantine-color-red-0)",
|
||||||
|
border: "1px solid var(--mantine-color-red-2)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group gap={6} mb={6}>
|
||||||
|
<MessageSquareWarning
|
||||||
|
size={14}
|
||||||
|
color="var(--mantine-color-red-7)"
|
||||||
|
/>
|
||||||
|
<Text fz="12.5px" fw={700} c="red.8">
|
||||||
|
Describe the problem for the customer
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Textarea
|
||||||
|
placeholder="e.g. The commercial invoice is missing the HS code and the totals don't match the packing list."
|
||||||
|
value={note}
|
||||||
|
onChange={(e) => onNote(e.currentTarget.value)}
|
||||||
|
autosize
|
||||||
|
minRows={2}
|
||||||
|
radius="md"
|
||||||
|
size="sm"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<Group justify="flex-end" gap={8} mt={8}>
|
||||||
|
<Button
|
||||||
|
size="compact-sm"
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
radius="md"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => onToggleQuery(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="compact-sm"
|
||||||
|
color="red"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<MessageSquareWarning size={14} />}
|
||||||
|
loading={busy}
|
||||||
|
disabled={!note.trim()}
|
||||||
|
onClick={onQuery}
|
||||||
|
>
|
||||||
|
Send query to customer
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,615 @@
|
|||||||
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Badge,
|
||||||
|
Box,
|
||||||
|
Card,
|
||||||
|
Group,
|
||||||
|
ScrollArea,
|
||||||
|
SegmentedControl,
|
||||||
|
SimpleGrid,
|
||||||
|
Stack,
|
||||||
|
Tabs,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
ThemeIcon,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import {
|
||||||
|
ArrowRight,
|
||||||
|
Calendar,
|
||||||
|
ChevronRight,
|
||||||
|
Inbox,
|
||||||
|
LayoutGrid,
|
||||||
|
RefreshCw,
|
||||||
|
Search,
|
||||||
|
ShieldCheck,
|
||||||
|
ShipWheel,
|
||||||
|
Table as TableIcon,
|
||||||
|
Truck,
|
||||||
|
User,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
DataTable,
|
||||||
|
DataTableFooter,
|
||||||
|
usePagination,
|
||||||
|
type ColumnDef,
|
||||||
|
} from "@edr/ui-common";
|
||||||
|
|
||||||
|
import { PageContainer } from "@/components/page/PageContainer";
|
||||||
|
import { PageHeader } from "@/components/page/PageHeader";
|
||||||
|
import { KpiStrip } from "@/components/page/KpiStrip";
|
||||||
|
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";
|
||||||
|
|
||||||
|
interface ClearanceRow {
|
||||||
|
id: string;
|
||||||
|
reference: string;
|
||||||
|
customerLabel: string;
|
||||||
|
tradeDirection: string;
|
||||||
|
freightType: string;
|
||||||
|
originLabel: string;
|
||||||
|
destinationLabel: string;
|
||||||
|
scheduledDate: string;
|
||||||
|
hasCustoms: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function labelFromRef(
|
||||||
|
ref?: { companyName?: string; label?: string; name?: string; code?: string },
|
||||||
|
fallback = "—",
|
||||||
|
): string {
|
||||||
|
if (!ref) return fallback;
|
||||||
|
return ref.companyName ?? ref.label ?? ref.name ?? ref.code ?? fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toClearanceRow(booking: BookingDetail): ClearanceRow {
|
||||||
|
return {
|
||||||
|
id: booking.id,
|
||||||
|
reference: booking.reference,
|
||||||
|
customerLabel: booking.isGovernment
|
||||||
|
? (booking.governmentInstitution ?? "Government")
|
||||||
|
: labelFromRef(booking.company, booking.companyId ?? undefined),
|
||||||
|
tradeDirection: booking.tradeDirection ?? "—",
|
||||||
|
freightType: booking.freightType ?? "—",
|
||||||
|
originLabel: labelFromRef(booking.originYard),
|
||||||
|
destinationLabel: labelFromRef(booking.destinationYard),
|
||||||
|
scheduledDate: booking.scheduledDate,
|
||||||
|
hasCustoms: Boolean(
|
||||||
|
booking.customsClearingEnabled ?? booking.serviceType?.includesCustoms,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso?: string): string {
|
||||||
|
if (!iso) return "—";
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) return "—";
|
||||||
|
return d.toLocaleDateString(undefined, {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function directionColor(direction: string): string {
|
||||||
|
return direction === "IMPORT" ? "edr-blue" : "edr-accent";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DocumentClearanceListPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [activeTab, setActiveTab] = useState<ClearanceTabKey>("all");
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [view, setView] = useState<ViewMode>("table");
|
||||||
|
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||||
|
|
||||||
|
const { data, isLoading, isError, isFetching, refetch } = useQuery({
|
||||||
|
queryKey: ["clearance", "list"],
|
||||||
|
queryFn: () =>
|
||||||
|
bookingsService.list({ status: CLEARANCE_REVIEW_STATUS, pageSize: 200 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const allRows = useMemo(
|
||||||
|
() => (data?.items ?? []).map(toClearanceRow),
|
||||||
|
[data?.items],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Per-tab counts drive the badge on each tab.
|
||||||
|
const tabCounts = useMemo(() => {
|
||||||
|
return {
|
||||||
|
all: allRows.length,
|
||||||
|
import: allRows.filter((r) => r.tradeDirection === "IMPORT").length,
|
||||||
|
export: allRows.filter((r) => r.tradeDirection === "EXPORT").length,
|
||||||
|
customs: allRows.filter((r) => r.hasCustoms).length,
|
||||||
|
} satisfies Record<ClearanceTabKey, number>;
|
||||||
|
}, [allRows]);
|
||||||
|
|
||||||
|
const rows = useMemo(() => {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
return allRows.filter((r) => {
|
||||||
|
if (activeTab === "import" && r.tradeDirection !== "IMPORT") return false;
|
||||||
|
if (activeTab === "export" && r.tradeDirection !== "EXPORT") return false;
|
||||||
|
if (activeTab === "customs" && !r.hasCustoms) return false;
|
||||||
|
if (!q) return true;
|
||||||
|
return (
|
||||||
|
r.reference.toLowerCase().includes(q) ||
|
||||||
|
r.customerLabel.toLowerCase().includes(q) ||
|
||||||
|
r.originLabel.toLowerCase().includes(q) ||
|
||||||
|
r.destinationLabel.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, [allRows, activeTab, query]);
|
||||||
|
|
||||||
|
const total = rows.length;
|
||||||
|
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
||||||
|
|
||||||
|
const pagedRows = useMemo(() => {
|
||||||
|
const start = pagination.pageIndex * pagination.pageSize;
|
||||||
|
return rows.slice(start, start + pagination.pageSize);
|
||||||
|
}, [rows, pagination.pageIndex, pagination.pageSize]);
|
||||||
|
|
||||||
|
const openDetail = useCallback(
|
||||||
|
(id: string) => navigate(`/dashboard/clearance/${id}`),
|
||||||
|
[navigate],
|
||||||
|
);
|
||||||
|
|
||||||
|
const columns: ColumnDef<ClearanceRow>[] = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: "booking",
|
||||||
|
header: () => <span className={bookingTable.headerCell}>Booking</span>,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const r = row.original;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 py-1.5">
|
||||||
|
<div className={bookingTable.rowIcon}>
|
||||||
|
<ShieldCheck className="size-4" strokeWidth={1.75} />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate font-medium text-foreground">
|
||||||
|
{r.reference}
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground">
|
||||||
|
<User className="size-3 shrink-0 opacity-70" />
|
||||||
|
{r.customerLabel}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "route",
|
||||||
|
header: () => <span className={bookingTable.headerCell}>Route</span>,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const r = row.original;
|
||||||
|
return (
|
||||||
|
<Stack gap={4} py={2}>
|
||||||
|
<Group gap={6} wrap="nowrap">
|
||||||
|
<Text size="sm" fw={500} truncate maw={120}>
|
||||||
|
{r.originLabel}
|
||||||
|
</Text>
|
||||||
|
<ArrowRight size={14} className="shrink-0 text-muted-foreground" />
|
||||||
|
<Text size="sm" fw={500} truncate maw={120}>
|
||||||
|
{r.destinationLabel}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Group gap={6}>
|
||||||
|
<Badge
|
||||||
|
size="xs"
|
||||||
|
variant="light"
|
||||||
|
radius="sm"
|
||||||
|
color={directionColor(r.tradeDirection)}
|
||||||
|
>
|
||||||
|
{r.tradeDirection}
|
||||||
|
</Badge>
|
||||||
|
<Badge size="xs" variant="default" radius="sm">
|
||||||
|
{r.freightType}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "customs",
|
||||||
|
header: () => <span className={bookingTable.headerCell}>Customs</span>,
|
||||||
|
cell: ({ row }) =>
|
||||||
|
row.original.hasCustoms ? (
|
||||||
|
<Badge
|
||||||
|
size="sm"
|
||||||
|
variant="light"
|
||||||
|
color="edr-green"
|
||||||
|
radius="sm"
|
||||||
|
leftSection={<ShieldCheck size={12} />}
|
||||||
|
>
|
||||||
|
Customs
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
—
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "scheduled",
|
||||||
|
header: () => <span className={bookingTable.headerCell}>Scheduled</span>,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||||
|
<Calendar className="size-3.5" />
|
||||||
|
{formatDate(row.original.scheduledDate)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "status",
|
||||||
|
header: () => <span className={bookingTable.headerCell}>Status</span>,
|
||||||
|
cell: () => (
|
||||||
|
<Badge size="sm" variant="light" color="edr-blue" radius="sm">
|
||||||
|
Under review
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "go",
|
||||||
|
size: 56,
|
||||||
|
cell: () => (
|
||||||
|
<Group justify="flex-end" pr="xs">
|
||||||
|
<ChevronRight size={16} className="text-muted-foreground" />
|
||||||
|
</Group>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<Stack gap="lg">
|
||||||
|
<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>
|
||||||
|
}
|
||||||
|
action={
|
||||||
|
<ActionIcon
|
||||||
|
variant="default"
|
||||||
|
size="lg"
|
||||||
|
radius="md"
|
||||||
|
onClick={() => refetch()}
|
||||||
|
loading={isFetching}
|
||||||
|
aria-label="Refresh"
|
||||||
|
>
|
||||||
|
<RefreshCw size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<KpiStrip
|
||||||
|
loading={isLoading}
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
label: "Awaiting review",
|
||||||
|
value: tabCounts.all,
|
||||||
|
icon: Inbox,
|
||||||
|
color: "edr-green",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Import",
|
||||||
|
value: tabCounts.import,
|
||||||
|
icon: Truck,
|
||||||
|
color: "edr-blue",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Export",
|
||||||
|
value: tabCounts.export,
|
||||||
|
icon: ShipWheel,
|
||||||
|
color: "edr-accent",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "With customs",
|
||||||
|
value: tabCounts.customs,
|
||||||
|
icon: ShieldCheck,
|
||||||
|
color: "yellow",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Tabs
|
||||||
|
value={activeTab}
|
||||||
|
onChange={(v) => {
|
||||||
|
setActiveTab((v as ClearanceTabKey) ?? "all");
|
||||||
|
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||||
|
}}
|
||||||
|
variant="pills"
|
||||||
|
color="edr-green"
|
||||||
|
>
|
||||||
|
<ScrollArea type="auto" scrollbarSize={6} offsetScrollbars="x">
|
||||||
|
<Tabs.List style={{ flexWrap: "nowrap", width: "max-content" }}>
|
||||||
|
{CLEARANCE_TABS.map((tab) => {
|
||||||
|
const Icon = tab.icon;
|
||||||
|
const isActive = activeTab === tab.key;
|
||||||
|
return (
|
||||||
|
<Tabs.Tab
|
||||||
|
key={tab.key}
|
||||||
|
value={tab.key}
|
||||||
|
leftSection={<Icon size={15} />}
|
||||||
|
rightSection={
|
||||||
|
<Badge
|
||||||
|
size="sm"
|
||||||
|
radius="sm"
|
||||||
|
variant={isActive ? "white" : "light"}
|
||||||
|
color={isActive ? "edr-green" : "gray"}
|
||||||
|
>
|
||||||
|
{tabCounts[tab.key]}
|
||||||
|
</Badge>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</Tabs.Tab>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Tabs.List>
|
||||||
|
</ScrollArea>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<Card p={0} withBorder shadow="sm" radius="lg">
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Box px="md" pt="md" pb="sm">
|
||||||
|
<Group justify="space-between" gap="md" wrap="wrap">
|
||||||
|
<TextInput
|
||||||
|
placeholder="Search reference, customer, or route…"
|
||||||
|
leftSection={<Search size={18} />}
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => {
|
||||||
|
setQuery(e.currentTarget.value);
|
||||||
|
setPagination({
|
||||||
|
pageIndex: 0,
|
||||||
|
pageSize: pagination.pageSize,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
rightSection={
|
||||||
|
query ? (
|
||||||
|
<ActionIcon
|
||||||
|
size="sm"
|
||||||
|
color="gray"
|
||||||
|
radius="md"
|
||||||
|
variant="transparent"
|
||||||
|
onClick={() => setQuery("")}
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
radius="lg"
|
||||||
|
style={{ flex: 1, minWidth: 220 }}
|
||||||
|
/>
|
||||||
|
<Group gap="sm" wrap="nowrap">
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{total} record{total !== 1 ? "s" : ""}
|
||||||
|
</Text>
|
||||||
|
<SegmentedControl
|
||||||
|
size="sm"
|
||||||
|
radius="md"
|
||||||
|
value={view}
|
||||||
|
onChange={(v) => setView(v as ViewMode)}
|
||||||
|
data={[
|
||||||
|
{
|
||||||
|
value: "table",
|
||||||
|
label: (
|
||||||
|
<Group gap={6} wrap="nowrap">
|
||||||
|
<TableIcon size={15} />
|
||||||
|
<Box visibleFrom="sm">Table</Box>
|
||||||
|
</Group>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "cards",
|
||||||
|
label: (
|
||||||
|
<Group gap={6} wrap="nowrap">
|
||||||
|
<LayoutGrid size={15} />
|
||||||
|
<Box visibleFrom="sm">Cards</Box>
|
||||||
|
</Group>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{view === "table" ? (
|
||||||
|
<Box style={{ overflowX: "auto" }} px="xs" pb="xs">
|
||||||
|
<DataTable<ClearanceRow, unknown>
|
||||||
|
columns={columns}
|
||||||
|
data={pagedRows}
|
||||||
|
status={
|
||||||
|
isLoading ? "loading" : isError ? "error" : "success"
|
||||||
|
}
|
||||||
|
onRowClick={(row) => openDetail(row.id)}
|
||||||
|
pagination={{
|
||||||
|
pageIndex: pagination.pageIndex,
|
||||||
|
pageSize: pagination.pageSize,
|
||||||
|
pageCount,
|
||||||
|
totalCount: total,
|
||||||
|
}}
|
||||||
|
tableOptions={{
|
||||||
|
state: { pagination },
|
||||||
|
onPaginationChange: setPagination,
|
||||||
|
manualPagination: true,
|
||||||
|
pageCount,
|
||||||
|
}}
|
||||||
|
containerClassName="border-0 shadow-none bg-transparent"
|
||||||
|
footer={DataTableFooter}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<ClearanceCardGrid
|
||||||
|
rows={pagedRows}
|
||||||
|
loading={isLoading}
|
||||||
|
onOpen={openDetail}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</Stack>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ClearanceCardGrid({
|
||||||
|
rows,
|
||||||
|
loading,
|
||||||
|
onOpen,
|
||||||
|
}: {
|
||||||
|
rows: ClearanceRow[];
|
||||||
|
loading: boolean;
|
||||||
|
onOpen: (id: string) => void;
|
||||||
|
}) {
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Box px="md" py="xl">
|
||||||
|
<Text c="dimmed" ta="center">
|
||||||
|
Loading…
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return (
|
||||||
|
<Stack align="center" gap={8} py={48}>
|
||||||
|
<ThemeIcon variant="light" color="gray" radius="xl" size={48}>
|
||||||
|
<Inbox size={22} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text c="dimmed">No bookings match this view.</Text>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
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)} />
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ClearanceCard({
|
||||||
|
row,
|
||||||
|
onOpen,
|
||||||
|
}: {
|
||||||
|
row: ClearanceRow;
|
||||||
|
onOpen: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
withBorder
|
||||||
|
shadow="sm"
|
||||||
|
radius="lg"
|
||||||
|
p="md"
|
||||||
|
onClick={onOpen}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
onOpen();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{ cursor: "pointer", transition: "all 120ms ease" }}
|
||||||
|
className="hover:border-edr-green-4 hover:shadow-md"
|
||||||
|
>
|
||||||
|
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||||
|
<Group gap={10} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||||
|
<ThemeIcon variant="light" color="edr-green" radius="md" size={40}>
|
||||||
|
<ShieldCheck size={19} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Box style={{ minWidth: 0 }}>
|
||||||
|
<Text fw={700} size="sm" c="edr-text" truncate>
|
||||||
|
{row.reference}
|
||||||
|
</Text>
|
||||||
|
<Group gap={4} wrap="nowrap">
|
||||||
|
<User size={11} className="shrink-0 opacity-70" />
|
||||||
|
<Text size="xs" c="dimmed" truncate>
|
||||||
|
{row.customerLabel}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
<Badge size="sm" variant="light" color="edr-blue" radius="sm">
|
||||||
|
Under review
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
mt="md"
|
||||||
|
p="sm"
|
||||||
|
style={{
|
||||||
|
borderRadius: 12,
|
||||||
|
background: "var(--mantine-color-edr-card-6)",
|
||||||
|
border: "1px solid var(--mantine-color-edr-border-6)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group gap={8} wrap="nowrap" justify="center">
|
||||||
|
<Text size="sm" fw={600} truncate maw={130}>
|
||||||
|
{row.originLabel}
|
||||||
|
</Text>
|
||||||
|
<ArrowRight size={15} className="shrink-0 text-muted-foreground" />
|
||||||
|
<Text size="sm" fw={600} truncate maw={130}>
|
||||||
|
{row.destinationLabel}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Group justify="space-between" mt="md" wrap="nowrap">
|
||||||
|
<Group gap={6} wrap="nowrap">
|
||||||
|
<Badge
|
||||||
|
size="xs"
|
||||||
|
variant="light"
|
||||||
|
radius="sm"
|
||||||
|
color={directionColor(row.tradeDirection)}
|
||||||
|
>
|
||||||
|
{row.tradeDirection}
|
||||||
|
</Badge>
|
||||||
|
<Badge size="xs" variant="default" radius="sm">
|
||||||
|
{row.freightType}
|
||||||
|
</Badge>
|
||||||
|
{row.hasCustoms ? (
|
||||||
|
<Badge
|
||||||
|
size="xs"
|
||||||
|
variant="light"
|
||||||
|
color="edr-green"
|
||||||
|
radius="sm"
|
||||||
|
leftSection={<ShieldCheck size={11} />}
|
||||||
|
>
|
||||||
|
Customs
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
|
<Group gap={4} wrap="nowrap">
|
||||||
|
<Calendar size={13} className="text-muted-foreground" />
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{formatDate(row.scheduledDate)}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,765 +0,0 @@
|
|||||||
import { useMemo, useState } from "react";
|
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import {
|
|
||||||
Alert,
|
|
||||||
Badge,
|
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
FileButton,
|
|
||||||
Group,
|
|
||||||
Loader,
|
|
||||||
Progress,
|
|
||||||
ScrollArea,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
TextInput,
|
|
||||||
Textarea,
|
|
||||||
ThemeIcon,
|
|
||||||
Tooltip,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import {
|
|
||||||
AlertCircle,
|
|
||||||
CheckCircle2,
|
|
||||||
Clock,
|
|
||||||
Download,
|
|
||||||
ExternalLink,
|
|
||||||
FileText,
|
|
||||||
Inbox,
|
|
||||||
MessageSquareWarning,
|
|
||||||
Search,
|
|
||||||
ShieldCheck,
|
|
||||||
Upload,
|
|
||||||
X,
|
|
||||||
} from "lucide-react";
|
|
||||||
import toast from "react-hot-toast";
|
|
||||||
import type { Freight } from "@edr/types";
|
|
||||||
|
|
||||||
import { PageContainer } from "@/components/page/PageContainer";
|
|
||||||
import { PageHeader } from "@/components/page/PageHeader";
|
|
||||||
import { bookingsService } from "@/services/bookings.service";
|
|
||||||
|
|
||||||
const REVIEW_STATUS = "DOCUMENTS_UNDER_REVIEW";
|
|
||||||
|
|
||||||
export default function GlClearancePage() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
||||||
const [search, setSearch] = useState("");
|
|
||||||
|
|
||||||
// Bookings currently awaiting GL document review.
|
|
||||||
const { data: list, isLoading } = useQuery({
|
|
||||||
queryKey: ["gl-clearance", "list"],
|
|
||||||
queryFn: () =>
|
|
||||||
bookingsService.list({ status: REVIEW_STATUS, pageSize: 100 }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const bookings = list?.items ?? [];
|
|
||||||
const filtered = useMemo(() => {
|
|
||||||
const q = search.trim().toLowerCase();
|
|
||||||
if (!q) return bookings;
|
|
||||||
return bookings.filter(
|
|
||||||
(b) =>
|
|
||||||
b.reference?.toLowerCase().includes(q) ||
|
|
||||||
b.tradeDirection?.toLowerCase().includes(q) ||
|
|
||||||
b.freightType?.toLowerCase().includes(q),
|
|
||||||
);
|
|
||||||
}, [bookings, search]);
|
|
||||||
|
|
||||||
const activeId =
|
|
||||||
selectedId && filtered.some((b) => b.id === selectedId)
|
|
||||||
? selectedId
|
|
||||||
: (filtered[0]?.id ?? null);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageContainer>
|
|
||||||
<PageHeader
|
|
||||||
title="Document Clearance"
|
|
||||||
subtitle="Review customer documents, approve or raise a query, and finalize clearance."
|
|
||||||
meta={
|
|
||||||
<Badge
|
|
||||||
variant="light"
|
|
||||||
color="edr-green"
|
|
||||||
radius="sm"
|
|
||||||
leftSection={<ShieldCheck size={13} />}
|
|
||||||
>
|
|
||||||
{bookings.length} awaiting review
|
|
||||||
</Badge>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-5 lg:flex-row lg:items-start">
|
|
||||||
{/* ── Review queue ─────────────────────────────────────────────── */}
|
|
||||||
<Card
|
|
||||||
withBorder
|
|
||||||
shadow="sm"
|
|
||||||
radius="lg"
|
|
||||||
p="sm"
|
|
||||||
className="w-full shrink-0 lg:w-[320px]"
|
|
||||||
>
|
|
||||||
<Group justify="space-between" align="center" mb="xs" px={4}>
|
|
||||||
<Text fz="13px" fw={700} c="edr-text">
|
|
||||||
Review queue
|
|
||||||
</Text>
|
|
||||||
<Badge size="sm" variant="default" radius="sm">
|
|
||||||
{filtered.length}
|
|
||||||
</Badge>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<TextInput
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
|
||||||
placeholder="Search reference…"
|
|
||||||
size="xs"
|
|
||||||
radius="md"
|
|
||||||
mb="xs"
|
|
||||||
leftSection={<Search size={14} />}
|
|
||||||
rightSection={
|
|
||||||
search ? (
|
|
||||||
<X
|
|
||||||
size={14}
|
|
||||||
style={{ cursor: "pointer" }}
|
|
||||||
onClick={() => setSearch("")}
|
|
||||||
/>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{isLoading ? (
|
|
||||||
<Group justify="center" py="lg" gap={8}>
|
|
||||||
<Loader size="xs" color="edr-green" />
|
|
||||||
<Text fz="13px" c="dimmed">
|
|
||||||
Loading…
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
) : filtered.length === 0 ? (
|
|
||||||
<Stack align="center" gap={6} py="xl">
|
|
||||||
<ThemeIcon variant="light" color="gray" radius="xl" size={40}>
|
|
||||||
<Inbox size={20} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<Text fz="13px" c="dimmed" ta="center">
|
|
||||||
{search
|
|
||||||
? "No bookings match your search."
|
|
||||||
: "Nothing awaiting document review."}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
) : (
|
|
||||||
<ScrollArea.Autosize mah={620} type="hover" offsetScrollbars>
|
|
||||||
<Stack gap={6}>
|
|
||||||
{filtered.map((b) => (
|
|
||||||
<QueueItem
|
|
||||||
key={b.id}
|
|
||||||
booking={b}
|
|
||||||
active={b.id === activeId}
|
|
||||||
onSelect={() => setSelectedId(b.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
</ScrollArea.Autosize>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* ── Review panel ─────────────────────────────────────────────── */}
|
|
||||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
{activeId ? (
|
|
||||||
<ClearanceReviewPanel
|
|
||||||
key={activeId}
|
|
||||||
bookingId={activeId}
|
|
||||||
onChanged={() =>
|
|
||||||
qc.invalidateQueries({ queryKey: ["gl-clearance", "list"] })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<EmptyPanel />
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</div>
|
|
||||||
</PageContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A single booking row in the left-hand review queue. */
|
|
||||||
function QueueItem({
|
|
||||||
booking,
|
|
||||||
active,
|
|
||||||
onSelect,
|
|
||||||
}: {
|
|
||||||
booking: Freight.IBooking;
|
|
||||||
active: boolean;
|
|
||||||
onSelect: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
component="button"
|
|
||||||
type="button"
|
|
||||||
onClick={onSelect}
|
|
||||||
ta="left"
|
|
||||||
p="xs"
|
|
||||||
style={{
|
|
||||||
cursor: "pointer",
|
|
||||||
borderRadius: 12,
|
|
||||||
border: "1px solid",
|
|
||||||
borderColor: active
|
|
||||||
? "var(--mantine-color-edr-green-5)"
|
|
||||||
: "var(--mantine-color-edr-border-6)",
|
|
||||||
background: active
|
|
||||||
? "var(--mantine-color-edr-green-0)"
|
|
||||||
: "var(--mantine-color-edr-card-6)",
|
|
||||||
transition: "all 120ms ease",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Group justify="space-between" wrap="nowrap" gap={8}>
|
|
||||||
<Box style={{ minWidth: 0 }}>
|
|
||||||
<Text fz="13.5px" fw={700} c="edr-text" truncate>
|
|
||||||
{booking.reference}
|
|
||||||
</Text>
|
|
||||||
<Group gap={6} mt={3} wrap="nowrap">
|
|
||||||
<Badge
|
|
||||||
size="xs"
|
|
||||||
variant="light"
|
|
||||||
radius="sm"
|
|
||||||
color={
|
|
||||||
booking.tradeDirection === "IMPORT" ? "edr-blue" : "edr-accent"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{booking.tradeDirection}
|
|
||||||
</Badge>
|
|
||||||
<Text fz="11px" c="edr-muted" truncate>
|
|
||||||
{booking.freightType}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
</Box>
|
|
||||||
</Group>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function EmptyPanel() {
|
|
||||||
return (
|
|
||||||
<Card withBorder shadow="sm" radius="lg" p={48}>
|
|
||||||
<Stack align="center" gap={10}>
|
|
||||||
<ThemeIcon variant="light" color="edr-green" radius="xl" size={56}>
|
|
||||||
<ShieldCheck size={28} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<Text fw={700} c="edr-text">
|
|
||||||
No booking selected
|
|
||||||
</Text>
|
|
||||||
<Text fz="13px" c="dimmed" ta="center" maw={320}>
|
|
||||||
Pick a booking from the review queue to inspect its customer documents
|
|
||||||
and start clearance.
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ClearanceReviewPanel({
|
|
||||||
bookingId,
|
|
||||||
onChanged,
|
|
||||||
}: {
|
|
||||||
bookingId: string;
|
|
||||||
onChanged: () => void;
|
|
||||||
}) {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
const [queryNotes, setQueryNotes] = useState<Record<string, string>>({});
|
|
||||||
const [openQuery, setOpenQuery] = useState<Record<string, boolean>>({});
|
|
||||||
const [outputFiles, setOutputFiles] = useState<Record<string, File>>({});
|
|
||||||
|
|
||||||
const { data: clearance, isLoading } = useQuery({
|
|
||||||
queryKey: ["gl-clearance", bookingId],
|
|
||||||
queryFn: () => bookingsService.getClearance(bookingId),
|
|
||||||
});
|
|
||||||
|
|
||||||
const refresh = () => {
|
|
||||||
qc.invalidateQueries({ queryKey: ["gl-clearance", bookingId] });
|
|
||||||
onChanged();
|
|
||||||
};
|
|
||||||
|
|
||||||
const reviewMutation = useMutation({
|
|
||||||
mutationFn: (p: {
|
|
||||||
fileKey: string;
|
|
||||||
status: "APPROVED" | "QUERIED";
|
|
||||||
note?: string;
|
|
||||||
}) => bookingsService.reviewClearanceDocument(bookingId, p),
|
|
||||||
onSuccess: (_d, p) => {
|
|
||||||
toast.success(
|
|
||||||
p.status === "APPROVED" ? "Document approved" : "Query sent to customer",
|
|
||||||
);
|
|
||||||
if (p.status === "QUERIED")
|
|
||||||
setOpenQuery((o) => ({ ...o, [p.fileKey]: false }));
|
|
||||||
refresh();
|
|
||||||
},
|
|
||||||
onError: () => toast.error("Could not update document"),
|
|
||||||
});
|
|
||||||
|
|
||||||
const outputMutation = useMutation({
|
|
||||||
mutationFn: () =>
|
|
||||||
bookingsService.uploadClearanceOutput(bookingId, outputFiles),
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success("Output documents uploaded");
|
|
||||||
setOutputFiles({});
|
|
||||||
refresh();
|
|
||||||
},
|
|
||||||
onError: () => toast.error("Upload failed"),
|
|
||||||
});
|
|
||||||
|
|
||||||
const finalizeMutation = useMutation({
|
|
||||||
mutationFn: () => bookingsService.finalizeClearance(bookingId),
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success("Clearance finalized");
|
|
||||||
refresh();
|
|
||||||
},
|
|
||||||
onError: (e) =>
|
|
||||||
toast.error(
|
|
||||||
e instanceof Error ? e.message : "Could not finalize clearance",
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
const customerDocs = useMemo(
|
|
||||||
() => (clearance?.documents ?? []).filter((d) => d.uploadedBy === "customer"),
|
|
||||||
[clearance],
|
|
||||||
);
|
|
||||||
const glDocs = useMemo(
|
|
||||||
() => (clearance?.documents ?? []).filter((d) => d.uploadedBy === "gl"),
|
|
||||||
[clearance],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Review progress across the customer documents — drives the summary bar.
|
|
||||||
const stats = useMemo(() => {
|
|
||||||
const total = customerDocs.length;
|
|
||||||
const approved = customerDocs.filter(
|
|
||||||
(d) => d.reviewStatus === "APPROVED",
|
|
||||||
).length;
|
|
||||||
const queried = customerDocs.filter(
|
|
||||||
(d) => d.reviewStatus === "QUERIED",
|
|
||||||
).length;
|
|
||||||
const pending = total - approved - queried;
|
|
||||||
return { total, approved, queried, pending };
|
|
||||||
}, [customerDocs]);
|
|
||||||
|
|
||||||
if (isLoading || !clearance) {
|
|
||||||
return (
|
|
||||||
<Card withBorder shadow="sm" radius="lg" p={48}>
|
|
||||||
<Group justify="center" gap={10}>
|
|
||||||
<Loader size="sm" color="edr-green" />
|
|
||||||
<Text c="dimmed">Loading clearance…</Text>
|
|
||||||
</Group>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const progressPct =
|
|
||||||
stats.total === 0 ? 0 : Math.round((stats.approved / stats.total) * 100);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack gap="md">
|
|
||||||
{/* ── Progress summary ───────────────────────────────────────────── */}
|
|
||||||
<Card withBorder shadow="sm" radius="lg" p="lg">
|
|
||||||
<Group justify="space-between" align="flex-start" mb="md">
|
|
||||||
<Box>
|
|
||||||
<Text fw={700} fz="15px" c="edr-text">
|
|
||||||
Customer documents
|
|
||||||
</Text>
|
|
||||||
<Text fz="12.5px" c="dimmed" mt={2}>
|
|
||||||
Approve each document, or open a query to tell the customer what to
|
|
||||||
fix.
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
{clearance.allApproved ? (
|
|
||||||
<Badge
|
|
||||||
variant="light"
|
|
||||||
color="edr-green"
|
|
||||||
radius="sm"
|
|
||||||
size="lg"
|
|
||||||
leftSection={<CheckCircle2 size={14} />}
|
|
||||||
>
|
|
||||||
All approved
|
|
||||||
</Badge>
|
|
||||||
) : (
|
|
||||||
<Badge
|
|
||||||
variant="light"
|
|
||||||
color="edr-blue"
|
|
||||||
radius="sm"
|
|
||||||
size="lg"
|
|
||||||
leftSection={<Clock size={14} />}
|
|
||||||
>
|
|
||||||
Review pending
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<Progress
|
|
||||||
value={progressPct}
|
|
||||||
color="edr-green"
|
|
||||||
radius="xl"
|
|
||||||
size="sm"
|
|
||||||
mb="sm"
|
|
||||||
/>
|
|
||||||
<Group gap="lg">
|
|
||||||
<StatPill color="edr-green" label="Approved" value={stats.approved} />
|
|
||||||
<StatPill color="red" label="Queried" value={stats.queried} />
|
|
||||||
<StatPill color="edr-slate" label="Pending" value={stats.pending} />
|
|
||||||
<Text fz="12.5px" c="dimmed" ml="auto">
|
|
||||||
{stats.approved}/{stats.total} approved
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* ── Document review list ───────────────────────────────────────── */}
|
|
||||||
<Stack gap={12}>
|
|
||||||
{customerDocs.map((doc) => (
|
|
||||||
<DocReviewCard
|
|
||||||
key={`${doc.settingCode}:${doc.fileKey}`}
|
|
||||||
doc={doc}
|
|
||||||
note={queryNotes[doc.fileKey] ?? ""}
|
|
||||||
queryOpen={openQuery[doc.fileKey] ?? false}
|
|
||||||
onToggleQuery={(open) =>
|
|
||||||
setOpenQuery((o) => ({ ...o, [doc.fileKey]: open }))
|
|
||||||
}
|
|
||||||
onNote={(v) => setQueryNotes((n) => ({ ...n, [doc.fileKey]: v }))}
|
|
||||||
onApprove={() =>
|
|
||||||
reviewMutation.mutate({ fileKey: doc.fileKey, status: "APPROVED" })
|
|
||||||
}
|
|
||||||
onQuery={() =>
|
|
||||||
reviewMutation.mutate({
|
|
||||||
fileKey: doc.fileKey,
|
|
||||||
status: "QUERIED",
|
|
||||||
note: queryNotes[doc.fileKey],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
busy={reviewMutation.isPending}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
{/* ── Customs output documents (GL-supplied) ─────────────────────── */}
|
|
||||||
{clearance.outputCode && (
|
|
||||||
<Card withBorder shadow="sm" radius="lg" p="lg">
|
|
||||||
<Group gap={8} mb="md">
|
|
||||||
<ThemeIcon variant="light" color="edr-blue" radius="md" size={28}>
|
|
||||||
<Upload size={15} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<Text fw={700} c="edr-text">
|
|
||||||
Customs output documents
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
<Stack gap={10}>
|
|
||||||
{glDocs.map((doc) => (
|
|
||||||
<Group key={doc.fileKey} justify="space-between" wrap="nowrap">
|
|
||||||
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
|
|
||||||
<FileText size={16} color="var(--mantine-color-edr-blue-6)" />
|
|
||||||
<Text fz="13px" c="edr-text" truncate>
|
|
||||||
{doc.label}
|
|
||||||
{doc.required ? " *" : ""}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
<Group gap={8} wrap="nowrap">
|
|
||||||
{doc.file ? (
|
|
||||||
<Tooltip label="Download">
|
|
||||||
<Box
|
|
||||||
component="a"
|
|
||||||
href={doc.file.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
c="edr-blue"
|
|
||||||
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>
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
))}
|
|
||||||
</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>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{finalizeMutation.isError && (
|
|
||||||
<Alert color="red" radius="md" icon={<AlertCircle size={16} />}>
|
|
||||||
{finalizeMutation.error instanceof Error
|
|
||||||
? finalizeMutation.error.message
|
|
||||||
: "Could not finalize clearance."}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── Finalize bar ───────────────────────────────────────────────── */}
|
|
||||||
<Card withBorder shadow="sm" radius="lg" p="md">
|
|
||||||
<Group justify="space-between" wrap="nowrap">
|
|
||||||
<Text fz="12.5px" c="dimmed">
|
|
||||||
{clearance.allApproved
|
|
||||||
? "All required documents are approved. You can finalize clearance."
|
|
||||||
: "Approve every required document to unlock finalization."}
|
|
||||||
</Text>
|
|
||||||
<Button
|
|
||||||
color="edr-green"
|
|
||||||
radius="md"
|
|
||||||
leftSection={<CheckCircle2 size={16} />}
|
|
||||||
disabled={!clearance.allApproved}
|
|
||||||
loading={finalizeMutation.isPending}
|
|
||||||
onClick={() => finalizeMutation.mutate()}
|
|
||||||
>
|
|
||||||
Finalize clearance
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Card>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatPill({
|
|
||||||
color,
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
}: {
|
|
||||||
color: string;
|
|
||||||
label: string;
|
|
||||||
value: number;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Group gap={6} wrap="nowrap">
|
|
||||||
<Box
|
|
||||||
style={{
|
|
||||||
width: 8,
|
|
||||||
height: 8,
|
|
||||||
borderRadius: 999,
|
|
||||||
background: `var(--mantine-color-${color}-6)`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Text fz="12.5px" c="edr-text" fw={600}>
|
|
||||||
{value}
|
|
||||||
</Text>
|
|
||||||
<Text fz="12.5px" c="dimmed">
|
|
||||||
{label}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Visual treatment for each document review state. */
|
|
||||||
const STATUS_META: Record<
|
|
||||||
Freight.DocumentReviewStatus,
|
|
||||||
{ label: string; color: string }
|
|
||||||
> = {
|
|
||||||
APPROVED: { label: "Approved", color: "edr-green" },
|
|
||||||
QUERIED: { label: "Queried", color: "red" },
|
|
||||||
PENDING: { label: "Pending", color: "edr-slate" },
|
|
||||||
};
|
|
||||||
|
|
||||||
function DocReviewCard({
|
|
||||||
doc,
|
|
||||||
note,
|
|
||||||
queryOpen,
|
|
||||||
onToggleQuery,
|
|
||||||
onNote,
|
|
||||||
onApprove,
|
|
||||||
onQuery,
|
|
||||||
busy,
|
|
||||||
}: {
|
|
||||||
doc: Freight.ClearanceDocument;
|
|
||||||
note: string;
|
|
||||||
queryOpen: boolean;
|
|
||||||
onToggleQuery: (open: boolean) => void;
|
|
||||||
onNote: (v: string) => void;
|
|
||||||
onApprove: () => void;
|
|
||||||
onQuery: () => void;
|
|
||||||
busy: boolean;
|
|
||||||
}) {
|
|
||||||
const status = doc.reviewStatus ?? "PENDING";
|
|
||||||
const meta = STATUS_META[status];
|
|
||||||
const hasFile = !!doc.file;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
withBorder
|
|
||||||
shadow="sm"
|
|
||||||
radius="lg"
|
|
||||||
p="md"
|
|
||||||
style={{
|
|
||||||
borderColor:
|
|
||||||
status === "QUERIED"
|
|
||||||
? "var(--mantine-color-red-2)"
|
|
||||||
: status === "APPROVED"
|
|
||||||
? "var(--mantine-color-edr-green-2)"
|
|
||||||
: "var(--mantine-color-edr-border-6)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
|
||||||
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
|
|
||||||
<ThemeIcon
|
|
||||||
variant="light"
|
|
||||||
color={hasFile ? "edr-blue" : "gray"}
|
|
||||||
radius="md"
|
|
||||||
size={40}
|
|
||||||
>
|
|
||||||
<FileText size={19} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<Box style={{ minWidth: 0 }}>
|
|
||||||
<Text fz="14px" fw={700} c="edr-text" truncate>
|
|
||||||
{doc.label}
|
|
||||||
{doc.required ? " *" : ""}
|
|
||||||
</Text>
|
|
||||||
<Text fz="12px" c="edr-muted" truncate>
|
|
||||||
{hasFile ? doc.file!.name : "Not uploaded by customer"}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<Group gap={8} wrap="nowrap">
|
|
||||||
<Badge variant="light" color={meta.color} radius="sm">
|
|
||||||
{meta.label}
|
|
||||||
</Badge>
|
|
||||||
{hasFile && (
|
|
||||||
<Tooltip label="Open document">
|
|
||||||
<Button
|
|
||||||
component="a"
|
|
||||||
href={doc.file!.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
size="compact-xs"
|
|
||||||
variant="default"
|
|
||||||
radius="md"
|
|
||||||
leftSection={<ExternalLink size={13} />}
|
|
||||||
>
|
|
||||||
View
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
{/* Previously raised query — visible so staff see what was asked. */}
|
|
||||||
{status === "QUERIED" && doc.note && (
|
|
||||||
<Alert
|
|
||||||
mt="sm"
|
|
||||||
color="red"
|
|
||||||
variant="light"
|
|
||||||
radius="md"
|
|
||||||
icon={<MessageSquareWarning size={15} />}
|
|
||||||
p="xs"
|
|
||||||
>
|
|
||||||
<Text fz="12.5px" c="red.9">
|
|
||||||
{doc.note}
|
|
||||||
</Text>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Action row — only when the customer actually uploaded a file. */}
|
|
||||||
{hasFile && (
|
|
||||||
<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>
|
|
||||||
<Button
|
|
||||||
size="compact-sm"
|
|
||||||
color="edr-green"
|
|
||||||
radius="md"
|
|
||||||
leftSection={<CheckCircle2 size={14} />}
|
|
||||||
disabled={busy}
|
|
||||||
onClick={onApprove}
|
|
||||||
>
|
|
||||||
Approve
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
) : (
|
|
||||||
<Box
|
|
||||||
p="sm"
|
|
||||||
style={{
|
|
||||||
borderRadius: 12,
|
|
||||||
background: "var(--mantine-color-red-0)",
|
|
||||||
border: "1px solid var(--mantine-color-red-2)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Group gap={6} mb={6}>
|
|
||||||
<MessageSquareWarning
|
|
||||||
size={14}
|
|
||||||
color="var(--mantine-color-red-7)"
|
|
||||||
/>
|
|
||||||
<Text fz="12.5px" fw={700} c="red.8">
|
|
||||||
Describe the problem for the customer
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
<Textarea
|
|
||||||
placeholder="e.g. The commercial invoice is missing the HS code and the totals don't match the packing list."
|
|
||||||
value={note}
|
|
||||||
onChange={(e) => onNote(e.currentTarget.value)}
|
|
||||||
autosize
|
|
||||||
minRows={2}
|
|
||||||
radius="md"
|
|
||||||
size="sm"
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
<Group justify="flex-end" gap={8} mt={8}>
|
|
||||||
<Button
|
|
||||||
size="compact-sm"
|
|
||||||
variant="subtle"
|
|
||||||
color="gray"
|
|
||||||
radius="md"
|
|
||||||
disabled={busy}
|
|
||||||
onClick={() => onToggleQuery(false)}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="compact-sm"
|
|
||||||
color="red"
|
|
||||||
radius="md"
|
|
||||||
leftSection={<MessageSquareWarning size={14} />}
|
|
||||||
loading={busy}
|
|
||||||
disabled={!note.trim()}
|
|
||||||
onClick={onQuery}
|
|
||||||
>
|
|
||||||
Send query to customer
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -447,6 +447,9 @@ export default function NewBookingPage() {
|
|||||||
// engine assigns the train, so no trainScheduleId is sent.
|
// engine assigns the train, so no trainScheduleId is sent.
|
||||||
cargoTotalWeightVgm: totalWeight,
|
cargoTotalWeightVgm: totalWeight,
|
||||||
isHazardous: data.isHazardous,
|
isHazardous: data.isHazardous,
|
||||||
|
// Reefer is a customer choice for bulk only; container reefer is decided by
|
||||||
|
// the container type on the backend, so never send it for containers.
|
||||||
|
isReefer: data.cargoType === "bulk" ? data.isRefrigerated : false,
|
||||||
freightType:
|
freightType:
|
||||||
data.cargoType === "container"
|
data.cargoType === "container"
|
||||||
? ("CONTAINER" as const)
|
? ("CONTAINER" as const)
|
||||||
@@ -755,7 +758,7 @@ export default function NewBookingPage() {
|
|||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
{priceModalMode === "submit"
|
{priceModalMode === "submit"
|
||||||
? "Review your total price below. Confirm to submit for EDR staff review, or reject to discard this booking."
|
? "Review your total price below. Confirm to submit for EDR staff review, edit & regenerate to change details and re-price, or reject to discard this booking."
|
||||||
: "Your booking has been saved as a draft. Here is your estimated total price."}
|
: "Your booking has been saved as a draft. Here is your estimated total price."}
|
||||||
</Text>
|
</Text>
|
||||||
{pricingData.lineItems.length > 0 && (
|
{pricingData.lineItems.length > 0 && (
|
||||||
@@ -852,6 +855,21 @@ export default function NewBookingPage() {
|
|||||||
>
|
>
|
||||||
Reject
|
Reject
|
||||||
</Button>
|
</Button>
|
||||||
|
{/* Go back to the wizard to change details, then Submit again to
|
||||||
|
regenerate the price. The draft booking is kept (priceBookingId
|
||||||
|
stays set), so re-submitting updates it instead of creating a
|
||||||
|
new one. */}
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<ChevronLeft size={16} />}
|
||||||
|
onClick={() => setPriceModalMode(null)}
|
||||||
|
disabled={
|
||||||
|
rejectMutation.isPending || confirmMutation.isPending
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Edit & regenerate
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
color="edr-green"
|
color="edr-green"
|
||||||
radius="md"
|
radius="md"
|
||||||
@@ -860,7 +878,7 @@ export default function NewBookingPage() {
|
|||||||
loading={confirmMutation.isPending}
|
loading={confirmMutation.isPending}
|
||||||
disabled={rejectMutation.isPending}
|
disabled={rejectMutation.isPending}
|
||||||
>
|
>
|
||||||
Confirm & submit
|
Confirm & submit
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -296,7 +296,11 @@ export function SelectField({
|
|||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
data={data}
|
data={data}
|
||||||
value={String(field.value) || null}
|
value={
|
||||||
|
field.value === undefined || field.value === null || field.value === ""
|
||||||
|
? null
|
||||||
|
: String(field.value)
|
||||||
|
}
|
||||||
onChange={(v) => field.onChange(v ?? "")}
|
onChange={(v) => field.onChange(v ?? "")}
|
||||||
onBlur={field.onBlur}
|
onBlur={field.onBlur}
|
||||||
error={error?.message}
|
error={error?.message}
|
||||||
|
|||||||
@@ -157,6 +157,14 @@ export function Step4Route({
|
|||||||
const quantityStep = isPerItem ? 1 : 0.01;
|
const quantityStep = isPerItem ? 1 : 0.01;
|
||||||
const showRouteQuantity = isGeneralContract && !isContainer;
|
const showRouteQuantity = isGeneralContract && !isContainer;
|
||||||
|
|
||||||
|
// The reefer toggle only exists for bulk; if the customer switches to
|
||||||
|
// containers, drop any reefer flag they set so it can't ride along unseen.
|
||||||
|
useEffect(() => {
|
||||||
|
if (cargoType !== "bulk" && form.getValues("isRefrigerated")) {
|
||||||
|
form.setValue("isRefrigerated", false);
|
||||||
|
}
|
||||||
|
}, [cargoType, form]);
|
||||||
|
|
||||||
// Earliest selectable shipment date (today, local) for the date input's `min`.
|
// Earliest selectable shipment date (today, local) for the date input's `min`.
|
||||||
const todayISODate = useMemo(() => {
|
const todayISODate = useMemo(() => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -399,21 +407,26 @@ export function Step4Route({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<Controller
|
{/* Reefer is a customer choice for bulk freight only. For containers the
|
||||||
name="isRefrigerated"
|
reefer surcharge is driven by the container type, so the toggle is
|
||||||
control={form.control}
|
hidden there to avoid a control that doesn't affect the price. */}
|
||||||
render={({ field }) => (
|
{cargoType === "bulk" && (
|
||||||
<ToggleRow
|
<Controller
|
||||||
icon={<Snowflake size={18} />}
|
name="isRefrigerated"
|
||||||
iconBg="#E9F0F8"
|
control={form.control}
|
||||||
iconColor="#2E5B96"
|
render={({ field }) => (
|
||||||
title="Refrigerated Cargo"
|
<ToggleRow
|
||||||
description="Temperature-controlled transport applies a refrigeration surcharge."
|
icon={<Snowflake size={18} />}
|
||||||
checked={field.value}
|
iconBg="#E9F0F8"
|
||||||
onChange={(v) => field.onChange(v)}
|
iconColor="#2E5B96"
|
||||||
/>
|
title="Refrigerated Cargo"
|
||||||
)}
|
description="Temperature-controlled transport applies a refrigeration surcharge."
|
||||||
/>
|
checked={field.value}
|
||||||
|
onChange={(v) => field.onChange(v)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</StepCard>
|
</StepCard>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo } from "react";
|
import { useEffect, useMemo, useRef } from "react";
|
||||||
import { Controller, useFieldArray, type UseFormReturn } from "react-hook-form";
|
import { Controller, useFieldArray, type UseFormReturn } from "react-hook-form";
|
||||||
import { Package, Plus, Trash2, Weight } from "lucide-react";
|
import { Package, Plus, Trash2, Weight } from "lucide-react";
|
||||||
import { ActionIcon, Button, Skeleton, Text, TextInput } from "@mantine/core";
|
import { ActionIcon, Button, Skeleton, Text, TextInput } from "@mantine/core";
|
||||||
@@ -56,11 +56,25 @@ export function Step5CargoDetails({
|
|||||||
);
|
);
|
||||||
}, [referenceData]);
|
}, [referenceData]);
|
||||||
|
|
||||||
|
// Reset the chosen commodity ONLY when the parent group actually changes to a
|
||||||
|
// different one. The previous version reset on every render where parentId was
|
||||||
|
// truthy, which wiped a valid commodity whenever the step re-rendered or was
|
||||||
|
// revisited (e.g. navigating Back/Next or restoring a saved draft) — making the
|
||||||
|
// commodity selection appear not to stick. Tracking the previous parent lets us
|
||||||
|
// clear the child on a real parent switch while leaving an existing selection
|
||||||
|
// intact on mount/re-render.
|
||||||
|
const prevParentIdRef = useRef<string | undefined>(parentId);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (parentId) {
|
if (prevParentIdRef.current === parentId) return;
|
||||||
form.setValue("cargoTypePath", [parentId, ""], { shouldDirty: true });
|
const switchedToAnotherParent =
|
||||||
|
!!prevParentIdRef.current && !!parentId;
|
||||||
|
prevParentIdRef.current = parentId;
|
||||||
|
if (switchedToAnotherParent) {
|
||||||
|
form.setValue("cargoTypePath", [parentId as string, ""], {
|
||||||
|
shouldDirty: true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, [parentId]);
|
}, [parentId, form]);
|
||||||
|
|
||||||
const selectedCommodity = useMemo(() => {
|
const selectedCommodity = useMemo(() => {
|
||||||
if (!referenceData?.cargo_type || !parentId || !childId) return null;
|
if (!referenceData?.cargo_type || !parentId || !childId) return null;
|
||||||
|
|||||||
@@ -673,6 +673,8 @@ export interface CreateBookingDto {
|
|||||||
shippingLineId?: string | undefined;
|
shippingLineId?: string | undefined;
|
||||||
cargoTotalWeightVgm: number;
|
cargoTotalWeightVgm: number;
|
||||||
isHazardous?: boolean | undefined;
|
isHazardous?: boolean | undefined;
|
||||||
|
/** Booking-level refrigerated flag (bulk freight only; containers derive reefer from the container type). */
|
||||||
|
isReefer?: boolean | undefined;
|
||||||
paymentCurrency: string;
|
paymentCurrency: string;
|
||||||
pnrCode?: string | undefined;
|
pnrCode?: string | undefined;
|
||||||
startDate?: string | undefined;
|
startDate?: string | undefined;
|
||||||
|
|||||||
Reference in New Issue
Block a user