mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'freight_feature/profile' of github.com:Tria-plc/edr-platform into freight_feature/profile
This commit is contained in:
@@ -8,6 +8,7 @@ import { useAuth } from "@/auth/useAuth";
|
||||
import {
|
||||
getNextPendingApprovalStep,
|
||||
isAllocateAction,
|
||||
isClearanceNavAction,
|
||||
isContractNavAction,
|
||||
listRowHasActions,
|
||||
type BookingActionContext,
|
||||
@@ -38,6 +39,7 @@ export function BookingActionsMenu({
|
||||
reference: row.reference,
|
||||
approvalSteps: row.approvalSteps,
|
||||
schedulingStatus: row.schedulingStatus,
|
||||
customsClearingEnabled: row.customsClearingEnabled,
|
||||
};
|
||||
|
||||
const flow = useBookingActionDialog(row.id, context);
|
||||
@@ -46,10 +48,15 @@ export function BookingActionsMenu({
|
||||
const goToContract = () =>
|
||||
navigate(`/dashboard/booking-requests/${row.id}/contract`);
|
||||
|
||||
const goToClearanceTab = () =>
|
||||
navigate(`/dashboard/booking-requests/${row.id}?tab=clearance`);
|
||||
|
||||
const handleAction = (action: (typeof actions)[number]) => {
|
||||
onSuppressRowClick?.();
|
||||
if (isContractNavAction(action.id)) {
|
||||
goToContract();
|
||||
} else if (isClearanceNavAction(action.id)) {
|
||||
goToClearanceTab();
|
||||
} else if (isAllocateAction(action.id)) {
|
||||
onAllocateBooking?.();
|
||||
} else {
|
||||
|
||||
@@ -44,9 +44,9 @@ const STATUS_META: Record<
|
||||
Freight.DocumentReviewStatus,
|
||||
{ label: string; color: string }
|
||||
> = {
|
||||
APPROVED: { label: "Approved", color: "edr-green" },
|
||||
APPROVED: { label: "Approved", color: "edr-blue" },
|
||||
QUERIED: { label: "Queried", color: "red" },
|
||||
PENDING: { label: "Pending", color: "edr-slate" },
|
||||
PENDING: { label: "Pending", color: "edr-accent" },
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -142,7 +142,7 @@ export function ClearanceReviewSection({
|
||||
if (isLoading || !clearance) {
|
||||
return (
|
||||
<Group justify="center" py="xl" gap={10}>
|
||||
<Loader size="sm" color="edr-green" />
|
||||
<Loader size="sm" color="edr-blue" />
|
||||
<Text c="dimmed">Loading clearance…</Text>
|
||||
</Group>
|
||||
);
|
||||
@@ -165,15 +165,15 @@ export function ClearanceReviewSection({
|
||||
<Box>
|
||||
<Progress
|
||||
value={stats.pct}
|
||||
color="edr-green"
|
||||
color="edr-blue"
|
||||
radius="xl"
|
||||
size="sm"
|
||||
mb={6}
|
||||
/>
|
||||
<Group gap="lg">
|
||||
<StatPill color="edr-green" label="Approved" value={stats.approved} />
|
||||
<StatPill color="edr-blue" label="Approved" value={stats.approved} />
|
||||
<StatPill color="red" label="Queried" value={stats.queried} />
|
||||
<StatPill color="edr-slate" label="Pending" value={stats.pending} />
|
||||
<StatPill color="edr-accent" label="Pending" value={stats.pending} />
|
||||
</Group>
|
||||
</Box>
|
||||
)}
|
||||
@@ -219,13 +219,13 @@ export function ClearanceReviewSection({
|
||||
icon={Upload}
|
||||
title="Customs output documents"
|
||||
subtitle="Upload the cleared/customs paperwork to hand back to the customer."
|
||||
accent="edr-blue"
|
||||
accent="edr-accent"
|
||||
>
|
||||
<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)" />
|
||||
<FileText size={16} color="var(--mantine-color-edr-accent-6)" />
|
||||
<Text fz="13px" c="edr-text" truncate>
|
||||
{doc.label}
|
||||
{doc.required ? " *" : ""}
|
||||
@@ -239,7 +239,7 @@ export function ClearanceReviewSection({
|
||||
href={doc.file.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
c="edr-blue"
|
||||
c="edr-accent"
|
||||
style={{ display: "flex" }}
|
||||
>
|
||||
<Download size={15} />
|
||||
@@ -261,7 +261,7 @@ export function ClearanceReviewSection({
|
||||
{...props}
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
color="edr-accent"
|
||||
leftSection={<Upload size={13} />}
|
||||
>
|
||||
{outputFiles[doc.fileKey] ? "Selected" : "Upload"}
|
||||
@@ -275,7 +275,7 @@ export function ClearanceReviewSection({
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
color="edr-accent"
|
||||
radius="md"
|
||||
leftSection={<Upload size={15} />}
|
||||
disabled={Object.keys(outputFiles).length === 0}
|
||||
@@ -301,7 +301,7 @@ export function ClearanceReviewSection({
|
||||
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<ThemeIcon
|
||||
variant="light"
|
||||
color={clearance.allApproved ? "edr-green" : "gray"}
|
||||
color={clearance.allApproved ? "edr-blue" : "gray"}
|
||||
radius="md"
|
||||
size={28}
|
||||
>
|
||||
@@ -314,7 +314,7 @@ export function ClearanceReviewSection({
|
||||
</Text>
|
||||
</Group>
|
||||
<Button
|
||||
color="edr-green"
|
||||
color="edr-blue"
|
||||
radius="md"
|
||||
leftSection={<CheckCircle2 size={16} />}
|
||||
disabled={!clearance.allApproved}
|
||||
@@ -391,7 +391,7 @@ function DocReviewCard({
|
||||
status === "QUERIED"
|
||||
? "var(--mantine-color-red-2)"
|
||||
: status === "APPROVED"
|
||||
? "var(--mantine-color-edr-green-2)"
|
||||
? "var(--mantine-color-edr-blue-2)"
|
||||
: "var(--mantine-color-edr-border-6)",
|
||||
}}
|
||||
>
|
||||
@@ -471,7 +471,7 @@ function DocReviewCard({
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="edr-green"
|
||||
color="edr-blue"
|
||||
radius="md"
|
||||
leftSection={<CheckCircle2 size={14} />}
|
||||
disabled={busy}
|
||||
|
||||
@@ -32,6 +32,7 @@ export type BookingActionId =
|
||||
| "rejectApproval"
|
||||
| "viewContract"
|
||||
| "signContractStaff"
|
||||
| "reviewClearance"
|
||||
| "allocateBooking"
|
||||
| "startTransit"
|
||||
| "complete"
|
||||
@@ -69,6 +70,7 @@ export type BookingActionContext = Pick<
|
||||
| "approvalSteps"
|
||||
| "reference"
|
||||
| "schedulingStatus"
|
||||
| "customsClearingEnabled"
|
||||
>;
|
||||
|
||||
const ALLOCATABLE_SCHEDULING_STATUSES = new Set([
|
||||
@@ -250,6 +252,20 @@ const SIGN_CONTRACT_STAFF_ACTION: BookingActionDef = {
|
||||
primary: true,
|
||||
};
|
||||
|
||||
// Opens the booking detail straight on the Clearance tab so Marketing can
|
||||
// review the customer's clearance documents (non-customs bookings only).
|
||||
const REVIEW_CLEARANCE_ACTION: BookingActionDef = {
|
||||
id: "reviewClearance",
|
||||
label: "Review clearance",
|
||||
shortLabel: "Clearance",
|
||||
description: "Approve or query the customer's clearance documents",
|
||||
confirmTitle: "",
|
||||
confirmDescription: "",
|
||||
variant: "default",
|
||||
icon: ShieldCheck,
|
||||
primary: true,
|
||||
};
|
||||
|
||||
function withCancel(actions: BookingActionDef[]): BookingActionDef[] {
|
||||
return [...actions, CANCEL_ACTION];
|
||||
}
|
||||
@@ -261,6 +277,7 @@ const ACTION_PERMISSION: Partial<Record<BookingActionId, string>> = {
|
||||
rejectApproval: FREIGHT_PERMS.bookings.rejectApproval,
|
||||
viewContract: FREIGHT_PERMS.bookings.view,
|
||||
signContractStaff: FREIGHT_PERMS.bookings.signStaff,
|
||||
reviewClearance: FREIGHT_PERMS.bookings.reviewDocuments,
|
||||
startTransit: FREIGHT_PERMS.bookings.operations,
|
||||
complete: FREIGHT_PERMS.bookings.operations,
|
||||
operationAccept: FREIGHT_PERMS.bookings.operations,
|
||||
@@ -359,6 +376,14 @@ export function getBookingActions(
|
||||
},
|
||||
];
|
||||
break;
|
||||
case "AWAITING_DOCUMENTS":
|
||||
case "DOCUMENTS_UNDER_REVIEW":
|
||||
// Marketing reviews non-customs clearance here; customs bookings are
|
||||
// handled in the Global Logistics clearance queue, not the booking list.
|
||||
actions = ctx.customsClearingEnabled
|
||||
? [CANCEL_ACTION]
|
||||
: withCancel([REVIEW_CLEARANCE_ACTION]);
|
||||
break;
|
||||
case "OPERATION_REQUEST_PENDING":
|
||||
actions = withCancel(OPERATION_REVIEW_ACTIONS);
|
||||
break;
|
||||
@@ -446,11 +471,17 @@ export function isAllocateAction(id: BookingActionId): boolean {
|
||||
return id === "allocateBooking";
|
||||
}
|
||||
|
||||
/** Opens the booking detail on the Clearance tab without a confirm dialog. */
|
||||
export function isClearanceNavAction(id: BookingActionId): boolean {
|
||||
return id === "reviewClearance";
|
||||
}
|
||||
|
||||
export function listRowHasActions(
|
||||
row: {
|
||||
status: BookingStatus;
|
||||
paymentCurrency: string;
|
||||
approvalSteps?: BookingApprovalStep[] | null;
|
||||
customsClearingEnabled?: boolean;
|
||||
},
|
||||
user?: AuthUser | null,
|
||||
): boolean {
|
||||
@@ -461,6 +492,7 @@ export function listRowHasActions(
|
||||
reference: "",
|
||||
approvalSteps: row.approvalSteps ?? undefined,
|
||||
schedulingStatus: row.status,
|
||||
customsClearingEnabled: row.customsClearingEnabled,
|
||||
},
|
||||
user,
|
||||
);
|
||||
|
||||
@@ -44,6 +44,7 @@ export function toBookingListRow(booking: BookingDetail): BookingListRow {
|
||||
governmentInstitution: booking.governmentInstitution ?? null,
|
||||
consolidationPartnerId: booking.consolidationPartnerId ?? null,
|
||||
consolidationPartnerReference: booking.consolidationPartner?.reference ?? null,
|
||||
customsClearingEnabled: booking.customsClearingEnabled ?? false,
|
||||
createdAt: booking.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { ArrowLeft, FileSignature, Package } from "lucide-react";
|
||||
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import {
|
||||
ArrowLeft,
|
||||
FileSignature,
|
||||
LayoutGrid,
|
||||
Package,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Container,
|
||||
Stack,
|
||||
Grid,
|
||||
Center,
|
||||
Loader,
|
||||
Tabs,
|
||||
Text,
|
||||
Paper,
|
||||
Button,
|
||||
@@ -34,6 +41,7 @@ import {
|
||||
import { WarehouseInfoCard } from "@/components/warehouses";
|
||||
import { getStatusMeta } from "@/features/bookings/booking-status.config";
|
||||
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
|
||||
import type { BookingDetail } from "@/types/booking";
|
||||
import { downloadBookingFile } from "@/services/files.service";
|
||||
import {
|
||||
useBookingDetail,
|
||||
@@ -53,6 +61,7 @@ const SIGNATURE_FILE_CODES = new Set([
|
||||
export default function BookingRequestDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const {
|
||||
data: booking,
|
||||
isLoading,
|
||||
@@ -143,6 +152,23 @@ export default function BookingRequestDetailPage() {
|
||||
booking.status === "PENDING_APPROVAL" ||
|
||||
booking.status === "APPROVED_PENDING_SIGNATURE";
|
||||
|
||||
// Non-customs clearance is reviewed here by Marketing in its own tab; customs
|
||||
// bookings are handled in the Global Logistics clearance queue instead.
|
||||
const showClearanceTab =
|
||||
!booking.customsClearingEnabled &&
|
||||
["AWAITING_DOCUMENTS", "DOCUMENTS_UNDER_REVIEW", "CLEARANCE_READY"].includes(
|
||||
booking.status,
|
||||
);
|
||||
const requestedTab = searchParams.get("tab");
|
||||
const activeTab =
|
||||
requestedTab === "clearance" && showClearanceTab ? "clearance" : "overview";
|
||||
const setActiveTab = (tab: string | null) => {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
if (tab && tab !== "overview") next.set("tab", tab);
|
||||
else next.delete("tab");
|
||||
setSearchParams(next, { replace: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<Breadcrumbs
|
||||
@@ -173,37 +199,52 @@ export default function BookingRequestDetailPage() {
|
||||
)}
|
||||
|
||||
<Grid gap="lg">
|
||||
{/* LEFT — primary content */}
|
||||
{/* LEFT — primary content, split into tabs to keep each view focused */}
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
<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
|
||||
{showClearanceTab ? (
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onChange={setActiveTab}
|
||||
variant="pills"
|
||||
color="edr-blue"
|
||||
keepMounted={false}
|
||||
>
|
||||
<Tabs.List mb="lg">
|
||||
<Tabs.Tab
|
||||
value="overview"
|
||||
leftSection={<LayoutGrid size={16} />}
|
||||
>
|
||||
Overview
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab
|
||||
value="clearance"
|
||||
leftSection={<ShieldCheck size={16} />}
|
||||
>
|
||||
Customer clearance
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="overview">
|
||||
<OverviewPanel
|
||||
booking={booking}
|
||||
row={row}
|
||||
onDownload={handleDownloadFile}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="clearance">
|
||||
<ClearanceReviewSection
|
||||
bookingId={booking.id}
|
||||
onChanged={() => refetch()}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
) : (
|
||||
<OverviewPanel
|
||||
booking={booking}
|
||||
originLabel={row.originLabel}
|
||||
destinationLabel={row.destinationLabel}
|
||||
/>
|
||||
<BookingMileServicesCard booking={booking} />
|
||||
<BookingCargoCard booking={booking} />
|
||||
{booking.contractSummary && (
|
||||
<BookingContractSummaryCard summary={booking.contractSummary} />
|
||||
)}
|
||||
<BookingDocumentsCard
|
||||
files={(booking.files ?? []).filter(
|
||||
(f) => !SIGNATURE_FILE_CODES.has(f.code ?? ""),
|
||||
)}
|
||||
row={row}
|
||||
onDownload={handleDownloadFile}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
</Grid.Col>
|
||||
|
||||
{/* RIGHT — sticky action / summary rail */}
|
||||
@@ -245,3 +286,35 @@ export default function BookingRequestDetailPage() {
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
/** The booking's primary detail cards — route, services, cargo, contract, docs. */
|
||||
function OverviewPanel({
|
||||
booking,
|
||||
row,
|
||||
onDownload,
|
||||
}: {
|
||||
booking: BookingDetail;
|
||||
row: ReturnType<typeof toBookingListRow>;
|
||||
onDownload: (file: BookingFileView) => void;
|
||||
}) {
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<BookingRouteServiceCard
|
||||
booking={booking}
|
||||
originLabel={row.originLabel}
|
||||
destinationLabel={row.destinationLabel}
|
||||
/>
|
||||
<BookingMileServicesCard booking={booking} />
|
||||
<BookingCargoCard booking={booking} />
|
||||
{booking.contractSummary && (
|
||||
<BookingContractSummaryCard summary={booking.contractSummary} />
|
||||
)}
|
||||
<BookingDocumentsCard
|
||||
files={(booking.files ?? []).filter(
|
||||
(f) => !SIGNATURE_FILE_CODES.has(f.code ?? ""),
|
||||
)}
|
||||
onDownload={onDownload}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ export default function DocumentClearanceDetailPage() {
|
||||
return (
|
||||
<PageContainer>
|
||||
<Group justify="center" py={80} gap={10}>
|
||||
<Loader color="edr-green" />
|
||||
<Loader color="edr-blue" />
|
||||
<Text c="dimmed">Loading clearance…</Text>
|
||||
</Group>
|
||||
</PageContainer>
|
||||
@@ -103,7 +103,7 @@ export default function DocumentClearanceDetailPage() {
|
||||
clearance.allApproved ? (
|
||||
<Badge
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
color="edr-blue"
|
||||
radius="sm"
|
||||
leftSection={<CheckCircle2 size={13} />}
|
||||
>
|
||||
@@ -112,7 +112,7 @@ export default function DocumentClearanceDetailPage() {
|
||||
) : (
|
||||
<Badge
|
||||
variant="light"
|
||||
color="edr-blue"
|
||||
color="edr-accent"
|
||||
radius="sm"
|
||||
leftSection={<Clock size={13} />}
|
||||
>
|
||||
@@ -133,13 +133,13 @@ export default function DocumentClearanceDetailPage() {
|
||||
{/* RIGHT — sticky progress gauge */}
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<Box style={{ position: "sticky", top: 24 }}>
|
||||
<SectionCard icon={PackageCheck} title="Review progress">
|
||||
<SectionCard icon={PackageCheck} title="Review progress" accent="blue">
|
||||
<Stack align="center" gap="sm">
|
||||
<RingProgress
|
||||
size={140}
|
||||
thickness={12}
|
||||
roundCaps
|
||||
sections={[{ value: stats.pct, color: "edr-green" }]}
|
||||
sections={[{ value: stats.pct, color: "edr-blue" }]}
|
||||
label={
|
||||
<Stack gap={0} align="center">
|
||||
<Text fw={800} fz={26} lh={1}>
|
||||
@@ -153,7 +153,7 @@ export default function DocumentClearanceDetailPage() {
|
||||
/>
|
||||
<Group gap="lg" justify="center">
|
||||
<ProgressStat
|
||||
color="edr-green"
|
||||
color="edr-blue"
|
||||
label="Approved"
|
||||
value={stats.approved}
|
||||
/>
|
||||
@@ -163,7 +163,7 @@ export default function DocumentClearanceDetailPage() {
|
||||
value={stats.queried}
|
||||
/>
|
||||
<ProgressStat
|
||||
color="edr-slate"
|
||||
color="edr-accent"
|
||||
label="Pending"
|
||||
value={stats.pending}
|
||||
/>
|
||||
@@ -199,7 +199,7 @@ function ClearanceHero({
|
||||
<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}>
|
||||
<ThemeIcon variant="light" color="edr-blue" radius="md" size={52}>
|
||||
<ShieldCheck size={26} />
|
||||
</ThemeIcon>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
@@ -219,7 +219,7 @@ function ClearanceHero({
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
color="edr-accent"
|
||||
radius="sm"
|
||||
leftSection={<ShieldCheck size={12} />}
|
||||
>
|
||||
@@ -248,7 +248,7 @@ function ClearanceHero({
|
||||
{stats.approved}/{stats.total}
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress value={stats.pct} color="edr-green" radius="xl" size="md" />
|
||||
<Progress value={stats.pct} color="edr-blue" radius="xl" size="md" />
|
||||
</Box>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
@@ -221,5 +221,6 @@ export interface BookingListRow {
|
||||
governmentInstitution?: string | null;
|
||||
consolidationPartnerId?: string | null;
|
||||
consolidationPartnerReference?: string | null;
|
||||
customsClearingEnabled?: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user