mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 05:18:11 +00:00
feat: add clearance review section and update booking status handling
- Introduced `ClearanceReviewSection` component for document review and approval process. - Updated booking status configuration to include new clearance statuses. - Modified `DocumentClearanceDetailPage` and `BookingRequestDetailPage` to integrate the new clearance review functionality. - Adjusted `DocumentClearanceListPage` to filter customs bookings appropriately. - Enhanced `ClearanceCard` in the portal to reflect customs clearance status. - Removed unnecessary customs-related logic from clearance tabs and document review components.
This commit is contained in:
@@ -63,7 +63,7 @@ describe('BookingTransitionService — finalizeClearance gate', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('moves to CLEARANCE_READY when all required documents are APPROVED', async () => {
|
it('moves to CLEARANCE_READY when all required documents are APPROVED (non-customs, no output set)', async () => {
|
||||||
const { service, bookingsRepository } = makeService([
|
const { service, bookingsRepository } = makeService([
|
||||||
{ settingCode: inputSetting.code, fileKey: 'commercial_invoice', status: 'APPROVED' },
|
{ settingCode: inputSetting.code, fileKey: 'commercial_invoice', status: 'APPROVED' },
|
||||||
{ settingCode: inputSetting.code, fileKey: 'packing_list', status: 'APPROVED' },
|
{ settingCode: inputSetting.code, fileKey: 'packing_list', status: 'APPROVED' },
|
||||||
@@ -75,3 +75,74 @@ describe('BookingTransitionService — finalizeClearance gate', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customs bookings additionally require the GL output documents before
|
||||||
|
* finalizing — they are cleared by Global Logistics, not the customer alone.
|
||||||
|
*/
|
||||||
|
describe('BookingTransitionService — finalizeClearance customs output gate', () => {
|
||||||
|
const customsBooking = {
|
||||||
|
id: 'b-2',
|
||||||
|
status: 'DOCUMENTS_UNDER_REVIEW',
|
||||||
|
tradeDirection: 'IMPORT',
|
||||||
|
freightType: 'CONTAINER',
|
||||||
|
serviceType: { includesCustoms: true }, // input + output sets apply
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputSetting = {
|
||||||
|
code: 'clearance_import_container_with_customs',
|
||||||
|
fields: [{ fileKey: 'commercial_invoice', isRequired: true }],
|
||||||
|
};
|
||||||
|
const outputSetting = {
|
||||||
|
code: 'clearance_output_import_container',
|
||||||
|
fields: [{ fileKey: 'im4', fileLabel: 'IM4 declaration', isRequired: true }],
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeCustomsService(uploadedOutputCodes: string[]) {
|
||||||
|
const bookingsRepository = {
|
||||||
|
findDocumentReviews: jest.fn().mockResolvedValue([
|
||||||
|
{ settingCode: inputSetting.code, fileKey: 'commercial_invoice', status: 'APPROVED' },
|
||||||
|
]),
|
||||||
|
update: jest.fn().mockResolvedValue({ id: 'b-2' }),
|
||||||
|
};
|
||||||
|
const bookingsService = { findById: jest.fn().mockResolvedValue(customsBooking) };
|
||||||
|
const fileUploadSettingsService = {
|
||||||
|
getByCode: jest.fn((code: string) =>
|
||||||
|
Promise.resolve(code === outputSetting.code ? outputSetting : inputSetting),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
const filesService = {
|
||||||
|
findByResource: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue(uploadedOutputCodes.map((code) => ({ code }))),
|
||||||
|
};
|
||||||
|
|
||||||
|
const service = new BookingTransitionService(
|
||||||
|
bookingsRepository as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
filesService as never,
|
||||||
|
fileUploadSettingsService as never,
|
||||||
|
{} as never,
|
||||||
|
bookingsService as never,
|
||||||
|
);
|
||||||
|
return { service, bookingsRepository };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('rejects when required customs output documents are missing', async () => {
|
||||||
|
const { service } = makeCustomsService([]); // no output uploaded
|
||||||
|
await expect(service.finalizeClearance('b-2')).rejects.toBeInstanceOf(
|
||||||
|
BadRequestException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves to CLEARANCE_READY when input is approved and output docs are present', async () => {
|
||||||
|
const { service, bookingsRepository } = makeCustomsService(['im4']);
|
||||||
|
await service.finalizeClearance('b-2');
|
||||||
|
expect(bookingsRepository.update).toHaveBeenCalledWith(
|
||||||
|
'b-2',
|
||||||
|
expect.objectContaining({ status: 'CLEARANCE_READY' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export interface BookingListFilterOptions {
|
|||||||
paymentCurrency?: string;
|
paymentCurrency?: string;
|
||||||
paymentStatus?: string;
|
paymentStatus?: string;
|
||||||
excludePaymentStatus?: string;
|
excludePaymentStatus?: string;
|
||||||
|
customsClearingEnabled?: boolean;
|
||||||
createdFrom?: string;
|
createdFrom?: string;
|
||||||
createdTo?: string;
|
createdTo?: string;
|
||||||
consolidationPaired?: string;
|
consolidationPaired?: string;
|
||||||
@@ -728,6 +729,11 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
excludePaymentStatus: options.excludePaymentStatus,
|
excludePaymentStatus: options.excludePaymentStatus,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (options.customsClearingEnabled !== undefined) {
|
||||||
|
qb.andWhere('booking.customs_clearing_enabled = :customsClearingEnabled', {
|
||||||
|
customsClearingEnabled: options.customsClearingEnabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
if (options.consolidationPaired === 'true') {
|
if (options.consolidationPaired === 'true') {
|
||||||
qb.andWhere('booking.consolidation_partner_id IS NOT NULL');
|
qb.andWhere('booking.consolidation_partner_id IS NOT NULL');
|
||||||
} else if (options.consolidationPaired === 'false') {
|
} else if (options.consolidationPaired === 'false') {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { DataSource, In } from 'typeorm';
|
|||||||
|
|
||||||
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||||
|
import { ServiceType } from '../rule-engine/entities/service-type.entity';
|
||||||
import { ContractRouteLine } from '../booking-orders/entities/contract-route-line.entity';
|
import { ContractRouteLine } from '../booking-orders/entities/contract-route-line.entity';
|
||||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||||
import { BookingsRepository } from './bookings.repository';
|
import { BookingsRepository } from './bookings.repository';
|
||||||
@@ -119,6 +120,18 @@ export class BookingsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Build evaluation input from booking freight shape. */
|
/** Build evaluation input from booking freight shape. */
|
||||||
|
/**
|
||||||
|
* Whether a service type bundles customs clearance. This is the single source
|
||||||
|
* of truth for a booking's `customsClearingEnabled` — the customer cannot
|
||||||
|
* diverge from it, and it decides who clears the documents (GL vs Marketing).
|
||||||
|
*/
|
||||||
|
private async resolveIncludesCustoms(serviceTypeId: string): Promise<boolean> {
|
||||||
|
const serviceType = await this.dataSource
|
||||||
|
.getRepository(ServiceType)
|
||||||
|
.findOne({ where: { id: serviceTypeId } });
|
||||||
|
return serviceType?.includesCustoms ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
private async buildEvalInput(dto: {
|
private async buildEvalInput(dto: {
|
||||||
freightType: FreightType;
|
freightType: FreightType;
|
||||||
cargoTypeId?: string | null;
|
cargoTypeId?: string | null;
|
||||||
@@ -404,6 +417,11 @@ export class BookingsService {
|
|||||||
|
|
||||||
warnings.push(...ruleResult.warnings);
|
warnings.push(...ruleResult.warnings);
|
||||||
|
|
||||||
|
// Customs clearing is owned by the service type, not the customer: when the
|
||||||
|
// service includes customs, EDR/GL clears it (no external agent); otherwise
|
||||||
|
// the customer clears it themselves and may name their broker.
|
||||||
|
const includesCustoms = await this.resolveIncludesCustoms(dto.serviceTypeId);
|
||||||
|
|
||||||
const booking = await this.bookingsRepository.create({
|
const booking = await this.bookingsRepository.create({
|
||||||
reference,
|
reference,
|
||||||
companyId: companyId ?? null,
|
companyId: companyId ?? null,
|
||||||
@@ -421,8 +439,8 @@ export class BookingsService {
|
|||||||
lastMileDeliveryAddress: dto.lastMileDeliveryAddress,
|
lastMileDeliveryAddress: dto.lastMileDeliveryAddress,
|
||||||
lastMileDeliveryLat: dto.lastMileDeliveryLat ?? null,
|
lastMileDeliveryLat: dto.lastMileDeliveryLat ?? null,
|
||||||
lastMileDeliveryLng: dto.lastMileDeliveryLng ?? null,
|
lastMileDeliveryLng: dto.lastMileDeliveryLng ?? null,
|
||||||
customsClearingEnabled: dto.customsClearingEnabled ?? false,
|
customsClearingEnabled: includesCustoms,
|
||||||
customsClearingAgent: dto.customsClearingAgent ?? null,
|
customsClearingAgent: includesCustoms ? null : (dto.customsClearingAgent ?? null),
|
||||||
equipmentReturn: dto.equipmentReturn,
|
equipmentReturn: dto.equipmentReturn,
|
||||||
originYardId: dto.originYardId,
|
originYardId: dto.originYardId,
|
||||||
destinationYardId: dto.destinationYardId,
|
destinationYardId: dto.destinationYardId,
|
||||||
@@ -652,6 +670,16 @@ export class BookingsService {
|
|||||||
if (dto.endDate) updates.endDate = new Date(dto.endDate);
|
if (dto.endDate) updates.endDate = new Date(dto.endDate);
|
||||||
delete updates.containers;
|
delete updates.containers;
|
||||||
|
|
||||||
|
// Customs clearing always mirrors the (possibly changed) service type — never
|
||||||
|
// the client payload — so it can't diverge from the service's customs scope.
|
||||||
|
const includesCustoms = await this.resolveIncludesCustoms(
|
||||||
|
dto.serviceTypeId ?? existing.serviceTypeId,
|
||||||
|
);
|
||||||
|
updates.customsClearingEnabled = includesCustoms;
|
||||||
|
updates.customsClearingAgent = includesCustoms
|
||||||
|
? null
|
||||||
|
: (dto.customsClearingAgent ?? existing.customsClearingAgent ?? null);
|
||||||
|
|
||||||
await this.bookingsRepository.update(id, updates);
|
await this.bookingsRepository.update(id, updates);
|
||||||
|
|
||||||
if (freightType === 'CONTAINER' && dto.containers) {
|
if (freightType === 'CONTAINER' && dto.containers) {
|
||||||
@@ -820,6 +848,9 @@ export class BookingsService {
|
|||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
statuses,
|
statuses,
|
||||||
|
// Global Logistics only clears customs bookings; non-customs clearance is
|
||||||
|
// reviewed by Marketing from the booking detail, not this queue.
|
||||||
|
customsClearingEnabled: true,
|
||||||
sortBy: filter.sortBy,
|
sortBy: filter.sortBy,
|
||||||
sortOrder: filter.sortOrder,
|
sortOrder: filter.sortOrder,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -184,7 +184,9 @@ export const ROLE_PERMISSION_PRESETS = {
|
|||||||
FREIGHT_PERMS.bookings.uploadClearanceOutput,
|
FREIGHT_PERMS.bookings.uploadClearanceOutput,
|
||||||
FREIGHT_PERMS.bookings.finalizeClearance,
|
FREIGHT_PERMS.bookings.finalizeClearance,
|
||||||
],
|
],
|
||||||
// Marketing handles intake through contract (same as line staff here).
|
// Marketing handles intake through contract (same as line staff here) and,
|
||||||
|
// for non-customs bookings, reviews/finalizes the customer's clearance
|
||||||
|
// documents from the booking detail (customs bookings go to Global Logistics).
|
||||||
marketing: [
|
marketing: [
|
||||||
FREIGHT_PERMS.bookings.view,
|
FREIGHT_PERMS.bookings.view,
|
||||||
FREIGHT_PERMS.bookings.staffAccept,
|
FREIGHT_PERMS.bookings.staffAccept,
|
||||||
@@ -195,6 +197,8 @@ export const ROLE_PERMISSION_PRESETS = {
|
|||||||
FREIGHT_PERMS.bookings.cancel,
|
FREIGHT_PERMS.bookings.cancel,
|
||||||
FREIGHT_PERMS.bookings.generateContract,
|
FREIGHT_PERMS.bookings.generateContract,
|
||||||
FREIGHT_PERMS.bookings.signStaff,
|
FREIGHT_PERMS.bookings.signStaff,
|
||||||
|
FREIGHT_PERMS.bookings.reviewDocuments,
|
||||||
|
FREIGHT_PERMS.bookings.finalizeClearance,
|
||||||
],
|
],
|
||||||
orgManager: [...BOOKING_RULE_ENGINE_PERMISSION_KEYS],
|
orgManager: [...BOOKING_RULE_ENGINE_PERMISSION_KEYS],
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ import { Badge, ScrollArea, Tabs } from "@mantine/core";
|
|||||||
import {
|
import {
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
ClipboardCheck,
|
ClipboardCheck,
|
||||||
|
ClipboardList,
|
||||||
FileSignature,
|
FileSignature,
|
||||||
Inbox,
|
Inbox,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
|
ShieldCheck,
|
||||||
Train,
|
Train,
|
||||||
Wallet,
|
Wallet,
|
||||||
XCircle,
|
XCircle,
|
||||||
@@ -21,7 +23,9 @@ const TAB_ICONS: Record<BookingStatusTabKey, React.ReactNode> = {
|
|||||||
intake: <Inbox size={17} strokeWidth={1.85} />,
|
intake: <Inbox size={17} strokeWidth={1.85} />,
|
||||||
in_approval: <ClipboardCheck size={17} strokeWidth={1.85} />,
|
in_approval: <ClipboardCheck size={17} strokeWidth={1.85} />,
|
||||||
approved_contract: <FileSignature size={17} strokeWidth={1.85} />,
|
approved_contract: <FileSignature size={17} strokeWidth={1.85} />,
|
||||||
|
clearance: <ShieldCheck size={17} strokeWidth={1.85} />,
|
||||||
payment: <Wallet size={17} strokeWidth={1.85} />,
|
payment: <Wallet size={17} strokeWidth={1.85} />,
|
||||||
|
ops_review: <ClipboardList size={17} strokeWidth={1.85} />,
|
||||||
operations: <Train size={17} strokeWidth={1.85} />,
|
operations: <Train size={17} strokeWidth={1.85} />,
|
||||||
completed: <CheckCircle size={17} strokeWidth={1.85} />,
|
completed: <CheckCircle size={17} strokeWidth={1.85} />,
|
||||||
closed: <XCircle size={17} strokeWidth={1.85} />,
|
closed: <XCircle size={17} strokeWidth={1.85} />,
|
||||||
|
|||||||
@@ -0,0 +1,540 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Badge,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
FileButton,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
Paper,
|
||||||
|
Progress,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
Textarea,
|
||||||
|
ThemeIcon,
|
||||||
|
Tooltip,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
CheckCircle2,
|
||||||
|
Download,
|
||||||
|
ExternalLink,
|
||||||
|
FileCheck2,
|
||||||
|
FileText,
|
||||||
|
MessageSquareWarning,
|
||||||
|
Upload,
|
||||||
|
} from "lucide-react";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
|
import { SectionCard } from "./SectionCard";
|
||||||
|
import { bookingsService } from "@/services/bookings.service";
|
||||||
|
|
||||||
|
export interface ClearanceReviewSectionProps {
|
||||||
|
bookingId: string;
|
||||||
|
/** Called after any review/finalize mutation so the parent can refetch. */
|
||||||
|
onChanged?: () => void;
|
||||||
|
/** Hide the inline progress summary (e.g. when the parent renders its own). */
|
||||||
|
hideSummary?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
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" },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Staff-facing clearance document review: approve / query each customer
|
||||||
|
* document, upload customs output documents (customs bookings only) and
|
||||||
|
* finalize once every required document is approved. Shared by the Global
|
||||||
|
* Logistics clearance detail page (customs) and the Marketing booking detail
|
||||||
|
* (non-customs) — the only difference is the output-docs block, which renders
|
||||||
|
* only when the booking has a customs output set.
|
||||||
|
*/
|
||||||
|
export function ClearanceReviewSection({
|
||||||
|
bookingId,
|
||||||
|
onChanged,
|
||||||
|
hideSummary,
|
||||||
|
}: ClearanceReviewSectionProps) {
|
||||||
|
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: ["clearance", bookingId],
|
||||||
|
queryFn: () => bookingsService.getClearance(bookingId),
|
||||||
|
});
|
||||||
|
|
||||||
|
const refresh = () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["clearance", bookingId] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["clearance", "list"] });
|
||||||
|
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],
|
||||||
|
);
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
|
if (isLoading || !clearance) {
|
||||||
|
return (
|
||||||
|
<Group justify="center" py="xl" gap={10}>
|
||||||
|
<Loader size="sm" color="edr-green" />
|
||||||
|
<Text c="dimmed">Loading clearance…</Text>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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}>
|
||||||
|
{!hideSummary && stats.total > 0 && (
|
||||||
|
<Box>
|
||||||
|
<Progress
|
||||||
|
value={stats.pct}
|
||||||
|
color="edr-green"
|
||||||
|
radius="xl"
|
||||||
|
size="sm"
|
||||||
|
mb={6}
|
||||||
|
/>
|
||||||
|
<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} />
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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 justify="space-between" wrap="nowrap">
|
||||||
|
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||||
|
<ThemeIcon
|
||||||
|
variant="light"
|
||||||
|
color={clearance.allApproved ? "edr-green" : "gray"}
|
||||||
|
radius="md"
|
||||||
|
size={28}
|
||||||
|
>
|
||||||
|
<FileCheck2 size={15} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text fz="12.5px" c="dimmed">
|
||||||
|
{clearance.allApproved
|
||||||
|
? "All required documents are approved — you can finalize."
|
||||||
|
: "Approve every required document to unlock finalization."}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<CheckCircle2 size={16} />}
|
||||||
|
disabled={!clearance.allApproved}
|
||||||
|
loading={finalizeMutation.isPending}
|
||||||
|
onClick={() => finalizeMutation.mutate()}
|
||||||
|
>
|
||||||
|
Finalize clearance
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
export * from "./booking-detail.styles";
|
export * from "./booking-detail.styles";
|
||||||
export * from "./SectionCard";
|
export * from "./SectionCard";
|
||||||
|
export * from "./ClearanceReviewSection";
|
||||||
export * from "./MetricTile";
|
export * from "./MetricTile";
|
||||||
export * from "./BookingDetailToolbar";
|
export * from "./BookingDetailToolbar";
|
||||||
export * from "./BookingDetailHeader";
|
export * from "./BookingDetailHeader";
|
||||||
|
|||||||
@@ -262,6 +262,11 @@ export const BOOKING_LIST_TABS = [
|
|||||||
"FULLY_EXECUTED",
|
"FULLY_EXECUTED",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "clearance",
|
||||||
|
label: "Clearance",
|
||||||
|
statuses: ["AWAITING_DOCUMENTS", "DOCUMENTS_UNDER_REVIEW", "CLEARANCE_READY"],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "payment",
|
key: "payment",
|
||||||
label: "Payment",
|
label: "Payment",
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
import { Layers, ShieldCheck, ShipWheel, Truck } from "lucide-react";
|
import { Layers, ShipWheel, Truck } from "lucide-react";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The document-clearance queue is a single backend status
|
* The Global Logistics clearance queue holds only customs bookings
|
||||||
* (`DOCUMENTS_UNDER_REVIEW`); the tabs slice that queue by the operational axis
|
* (`DOCUMENTS_UNDER_REVIEW` + customsClearingEnabled); non-customs clearance is
|
||||||
* that matters to a clearance officer — trade direction and customs scope —
|
* reviewed by Marketing on the booking detail. Since every row here is a customs
|
||||||
* rather than by booking status (which is uniform here).
|
* booking, the tabs slice by trade direction rather than customs scope.
|
||||||
*/
|
*/
|
||||||
export type ClearanceTabKey = "all" | "import" | "export" | "customs";
|
export type ClearanceTabKey = "all" | "import" | "export";
|
||||||
|
|
||||||
export interface ClearanceTab {
|
export interface ClearanceTab {
|
||||||
key: ClearanceTabKey;
|
key: ClearanceTabKey;
|
||||||
@@ -19,7 +19,6 @@ export const CLEARANCE_TABS: ClearanceTab[] = [
|
|||||||
{ key: "all", label: "All", icon: Layers },
|
{ key: "all", label: "All", icon: Layers },
|
||||||
{ key: "import", label: "Import", icon: Truck },
|
{ key: "import", label: "Import", icon: Truck },
|
||||||
{ key: "export", label: "Export", icon: ShipWheel },
|
{ 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. */
|
/** The backend booking status that places a booking in the clearance queue. */
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
BookingCompanyCard,
|
BookingCompanyCard,
|
||||||
BookingContractSummaryCard,
|
BookingContractSummaryCard,
|
||||||
BookingDocumentsCard,
|
BookingDocumentsCard,
|
||||||
|
ClearanceReviewSection,
|
||||||
type BookingFileView,
|
type BookingFileView,
|
||||||
} from "@/components/bookings/detail";
|
} from "@/components/bookings/detail";
|
||||||
import { WarehouseInfoCard } from "@/components/warehouses";
|
import { WarehouseInfoCard } from "@/components/warehouses";
|
||||||
@@ -175,6 +176,17 @@ export default function BookingRequestDetailPage() {
|
|||||||
{/* LEFT — primary content */}
|
{/* LEFT — primary content */}
|
||||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
|
{/* Non-customs clearance is reviewed here by Marketing; customs
|
||||||
|
bookings are handled in the Global Logistics clearance queue. */}
|
||||||
|
{!booking.customsClearingEnabled &&
|
||||||
|
["AWAITING_DOCUMENTS", "DOCUMENTS_UNDER_REVIEW"].includes(
|
||||||
|
booking.status,
|
||||||
|
) ? (
|
||||||
|
<ClearanceReviewSection
|
||||||
|
bookingId={booking.id}
|
||||||
|
onChanged={() => refetch()}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<BookingRouteServiceCard
|
<BookingRouteServiceCard
|
||||||
booking={booking}
|
booking={booking}
|
||||||
originLabel={row.originLabel}
|
originLabel={row.originLabel}
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo } from "react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Badge,
|
Badge,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
|
||||||
FileButton,
|
|
||||||
Grid,
|
Grid,
|
||||||
Group,
|
Group,
|
||||||
Loader,
|
Loader,
|
||||||
@@ -15,37 +13,27 @@ import {
|
|||||||
RingProgress,
|
RingProgress,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
Textarea,
|
|
||||||
ThemeIcon,
|
ThemeIcon,
|
||||||
Tooltip,
|
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Clock,
|
Clock,
|
||||||
Download,
|
|
||||||
ExternalLink,
|
|
||||||
FileCheck2,
|
|
||||||
FileText,
|
|
||||||
MessageSquareWarning,
|
|
||||||
PackageCheck,
|
PackageCheck,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Upload,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import toast from "react-hot-toast";
|
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
import { PageContainer } from "@/components/page/PageContainer";
|
import { PageContainer } from "@/components/page/PageContainer";
|
||||||
import { PageHeader } from "@/components/page/PageHeader";
|
import { PageHeader } from "@/components/page/PageHeader";
|
||||||
import { SectionCard } from "@/components/bookings/detail";
|
import { SectionCard } from "@/components/bookings/detail";
|
||||||
|
import { ClearanceReviewSection } from "@/components/bookings/detail/ClearanceReviewSection";
|
||||||
import { bookingsService } from "@/services/bookings.service";
|
import { bookingsService } from "@/services/bookings.service";
|
||||||
import { useBookingDetail } from "@/hooks/bookings/useBookings";
|
import { useBookingDetail } from "@/hooks/bookings/useBookings";
|
||||||
|
|
||||||
export default function DocumentClearanceDetailPage() {
|
export default function DocumentClearanceDetailPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
|
||||||
const qc = useQueryClient();
|
|
||||||
|
|
||||||
const { data: booking } = useBookingDetail(id);
|
const { data: booking } = useBookingDetail(id);
|
||||||
const {
|
const {
|
||||||
@@ -58,76 +46,17 @@ export default function DocumentClearanceDetailPage() {
|
|||||||
enabled: Boolean(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 stats = useMemo(() => {
|
||||||
const total = customerDocs.length;
|
const docs = (clearance?.documents ?? []).filter(
|
||||||
const approved = customerDocs.filter(
|
(d) => d.uploadedBy === "customer",
|
||||||
(d) => d.reviewStatus === "APPROVED",
|
);
|
||||||
).length;
|
const total = docs.length;
|
||||||
const queried = customerDocs.filter(
|
const approved = docs.filter((d) => d.reviewStatus === "APPROVED").length;
|
||||||
(d) => d.reviewStatus === "QUERIED",
|
const queried = docs.filter((d) => d.reviewStatus === "QUERIED").length;
|
||||||
).length;
|
|
||||||
const pending = total - approved - queried;
|
const pending = total - approved - queried;
|
||||||
const pct = total === 0 ? 0 : Math.round((approved / total) * 100);
|
const pct = total === 0 ? 0 : Math.round((approved / total) * 100);
|
||||||
return { total, approved, queried, pending, pct };
|
return { total, approved, queried, pending, pct };
|
||||||
}, [customerDocs]);
|
}, [clearance]);
|
||||||
|
|
||||||
const reference = booking?.reference ?? "Clearance";
|
const reference = booking?.reference ?? "Clearance";
|
||||||
|
|
||||||
@@ -193,237 +122,54 @@ export default function DocumentClearanceDetailPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Hero */}
|
<ClearanceHero booking={booking} clearance={clearance} stats={stats} />
|
||||||
<ClearanceHero
|
|
||||||
booking={booking}
|
|
||||||
clearance={clearance}
|
|
||||||
stats={stats}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Grid gap="lg">
|
<Grid gap="lg">
|
||||||
{/* LEFT — document review */}
|
{/* LEFT — document review (shared with the Marketing booking detail) */}
|
||||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||||
<Stack gap="lg">
|
<ClearanceReviewSection bookingId={id!} hideSummary />
|
||||||
<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>
|
</Grid.Col>
|
||||||
|
|
||||||
{/* RIGHT — sticky summary + finalize */}
|
{/* RIGHT — sticky progress gauge */}
|
||||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||||
<Box style={{ position: "sticky", top: 24 }}>
|
<Box style={{ position: "sticky", top: 24 }}>
|
||||||
<Stack gap="lg">
|
<SectionCard icon={PackageCheck} title="Review progress">
|
||||||
<SectionCard icon={PackageCheck} title="Review progress">
|
<Stack align="center" gap="sm">
|
||||||
<Stack align="center" gap="sm">
|
<RingProgress
|
||||||
<RingProgress
|
size={140}
|
||||||
size={140}
|
thickness={12}
|
||||||
thickness={12}
|
roundCaps
|
||||||
roundCaps
|
sections={[{ value: stats.pct, color: "edr-green" }]}
|
||||||
sections={[
|
label={
|
||||||
{ value: stats.pct, color: "edr-green" },
|
<Stack gap={0} align="center">
|
||||||
]}
|
<Text fw={800} fz={26} lh={1}>
|
||||||
label={
|
{stats.pct}%
|
||||||
<Stack gap={0} align="center">
|
</Text>
|
||||||
<Text fw={800} fz={26} lh={1}>
|
<Text size="xs" c="dimmed">
|
||||||
{stats.pct}%
|
approved
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="xs" c="dimmed">
|
</Stack>
|
||||||
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 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>
|
</Group>
|
||||||
<Text fz="12.5px" c="dimmed" mb="md">
|
</Stack>
|
||||||
{clearance.allApproved
|
</SectionCard>
|
||||||
? "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>
|
</Box>
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
</Grid>
|
</Grid>
|
||||||
@@ -539,193 +285,3 @@ function ProgressStat({
|
|||||||
</Stack>
|
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -118,8 +118,11 @@ export default function DocumentClearanceListPage() {
|
|||||||
bookingsService.list({ status: CLEARANCE_REVIEW_STATUS, pageSize: 200 }),
|
bookingsService.list({ 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(
|
const allRows = useMemo(
|
||||||
() => (data?.items ?? []).map(toClearanceRow),
|
() => (data?.items ?? []).map(toClearanceRow).filter((r) => r.hasCustoms),
|
||||||
[data?.items],
|
[data?.items],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -129,7 +132,6 @@ export default function DocumentClearanceListPage() {
|
|||||||
all: allRows.length,
|
all: allRows.length,
|
||||||
import: allRows.filter((r) => r.tradeDirection === "IMPORT").length,
|
import: allRows.filter((r) => r.tradeDirection === "IMPORT").length,
|
||||||
export: allRows.filter((r) => r.tradeDirection === "EXPORT").length,
|
export: allRows.filter((r) => r.tradeDirection === "EXPORT").length,
|
||||||
customs: allRows.filter((r) => r.hasCustoms).length,
|
|
||||||
} satisfies Record<ClearanceTabKey, number>;
|
} satisfies Record<ClearanceTabKey, number>;
|
||||||
}, [allRows]);
|
}, [allRows]);
|
||||||
|
|
||||||
@@ -138,7 +140,6 @@ export default function DocumentClearanceListPage() {
|
|||||||
return allRows.filter((r) => {
|
return allRows.filter((r) => {
|
||||||
if (activeTab === "import" && r.tradeDirection !== "IMPORT") return false;
|
if (activeTab === "import" && r.tradeDirection !== "IMPORT") return false;
|
||||||
if (activeTab === "export" && r.tradeDirection !== "EXPORT") return false;
|
if (activeTab === "export" && r.tradeDirection !== "EXPORT") return false;
|
||||||
if (activeTab === "customs" && !r.hasCustoms) return false;
|
|
||||||
if (!q) return true;
|
if (!q) return true;
|
||||||
return (
|
return (
|
||||||
r.reference.toLowerCase().includes(q) ||
|
r.reference.toLowerCase().includes(q) ||
|
||||||
@@ -220,26 +221,6 @@ export default function DocumentClearanceListPage() {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
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",
|
id: "scheduled",
|
||||||
header: () => <span className={bookingTable.headerCell}>Scheduled</span>,
|
header: () => <span className={bookingTable.headerCell}>Scheduled</span>,
|
||||||
@@ -323,12 +304,6 @@ export default function DocumentClearanceListPage() {
|
|||||||
icon: ShipWheel,
|
icon: ShipWheel,
|
||||||
color: "edr-accent",
|
color: "edr-accent",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
label: "With customs",
|
|
||||||
value: tabCounts.customs,
|
|
||||||
icon: ShieldCheck,
|
|
||||||
color: "yellow",
|
|
||||||
},
|
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
export const BOOKING_STATUSES = [
|
export const BOOKING_STATUSES = [
|
||||||
"DRAFT",
|
"DRAFT",
|
||||||
"SUBMITTED",
|
"SUBMITTED",
|
||||||
|
"PRICE_CHANGED_PENDING_CONFIRM",
|
||||||
"CHANGES_REQUESTED",
|
"CHANGES_REQUESTED",
|
||||||
"PENDING_APPROVAL",
|
"PENDING_APPROVAL",
|
||||||
"APPROVED_PENDING_SIGNATURE",
|
"APPROVED_PENDING_SIGNATURE",
|
||||||
@@ -12,6 +13,8 @@ export const BOOKING_STATUSES = [
|
|||||||
"CONTRACT_READY",
|
"CONTRACT_READY",
|
||||||
"SIGNED_CUSTOMER",
|
"SIGNED_CUSTOMER",
|
||||||
"FULLY_EXECUTED",
|
"FULLY_EXECUTED",
|
||||||
|
"SELECTED_FOR_BATCH",
|
||||||
|
"EXPIRED",
|
||||||
"PNR_GENERATED",
|
"PNR_GENERATED",
|
||||||
"PAYMENT_VERIFICATION_IN_PROGRESS",
|
"PAYMENT_VERIFICATION_IN_PROGRESS",
|
||||||
"PAID",
|
"PAID",
|
||||||
@@ -21,6 +24,18 @@ export const BOOKING_STATUSES = [
|
|||||||
"CANCELLED",
|
"CANCELLED",
|
||||||
"PENDING_CONSOLIDATION",
|
"PENDING_CONSOLIDATION",
|
||||||
"CONSOLIDATED",
|
"CONSOLIDATED",
|
||||||
|
"CONTRACT_ACTIVE",
|
||||||
|
"CONTRACT_CLOSED",
|
||||||
|
// Post counter-sign document-clearance gate.
|
||||||
|
"AWAITING_DOCUMENTS",
|
||||||
|
"DOCUMENTS_UNDER_REVIEW",
|
||||||
|
"CLEARANCE_READY",
|
||||||
|
"ROAD_DISPATCH_PENDING",
|
||||||
|
"OPERATION_REQUESTED",
|
||||||
|
// Operations review gate.
|
||||||
|
"OPERATION_REQUEST_PENDING",
|
||||||
|
"OPERATION_CHANGES_REQUESTED",
|
||||||
|
"OPERATION_PRICE_PENDING_CONFIRM",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type BookingStatus = (typeof BOOKING_STATUSES)[number];
|
export type BookingStatus = (typeof BOOKING_STATUSES)[number];
|
||||||
|
|||||||
@@ -186,16 +186,21 @@ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) {
|
|||||||
|
|
||||||
{isReady ? (
|
{isReady ? (
|
||||||
<Alert color="teal" radius="md" icon={<CheckCircle2 size={18} />} mb="md">
|
<Alert color="teal" radius="md" icon={<CheckCircle2 size={18} />} mb="md">
|
||||||
Clearance is ready. You can now proceed to operation.
|
{clearance.includesCustoms
|
||||||
|
? "Customs clearance is complete and your cleared documents are available below. You can now proceed to operation."
|
||||||
|
: "Clearance is ready. You can now proceed to operation."}
|
||||||
</Alert>
|
</Alert>
|
||||||
) : status === "DOCUMENTS_UNDER_REVIEW" ? (
|
) : status === "DOCUMENTS_UNDER_REVIEW" ? (
|
||||||
<Alert color="blue" radius="md" icon={<Clock size={18} />} mb="md">
|
<Alert color="blue" radius="md" icon={<Clock size={18} />} mb="md">
|
||||||
Global Logistics is reviewing your documents. Queried documents below
|
{clearance.includesCustoms
|
||||||
need to be re-uploaded.
|
? "Global Logistics is reviewing your documents and will clear your shipment. Queried documents below need to be re-uploaded."
|
||||||
|
: "Our team is reviewing your documents. Queried documents below need to be re-uploaded."}
|
||||||
</Alert>
|
</Alert>
|
||||||
) : (
|
) : (
|
||||||
<Alert color="yellow" radius="md" icon={<AlertCircle size={18} />} mb="md">
|
<Alert color="yellow" radius="md" icon={<AlertCircle size={18} />} mb="md">
|
||||||
Upload the documents below to start the clearance review.
|
{clearance.includesCustoms
|
||||||
|
? "Upload the documents customs needs — Global Logistics will clear your shipment and return the cleared documents here."
|
||||||
|
: "Upload all the required clearance documents below to start the review."}
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user