Merge pull request #433 from Tria-plc/freight_feature/usermanagement

feat: enhance contract clearance process with linked booking details
This commit is contained in:
marshal
2026-07-04 00:05:34 +03:00
committed by GitHub
22 changed files with 537 additions and 204 deletions

View File

@@ -1,5 +1,5 @@
import { Download, Zap, FileText, Clock } from "lucide-react";
import { Stack, Text, Button } from "@mantine/core";
import { Zap, Clock } from "lucide-react";
import { Stack, Text } from "@mantine/core";
import type { BookingDetail } from "@/types/booking";
import { BookingActionsMenu } from "./BookingActionsMenu";
@@ -14,21 +14,11 @@ interface BookingActionsToolbarProps {
mutations: Mutations;
}
/** Detail-page actions: primary toolbar + downloads. */
export function BookingActionsToolbar({ booking, mutations }: BookingActionsToolbarProps) {
/** Detail-page actions: primary staff-action toolbar. */
export function BookingActionsToolbar({ booking }: BookingActionsToolbarProps) {
const row = toBookingListRow(booking);
const { status } = booking;
const downloadBlob = async (fn: () => Promise<Blob>, filename: string) => {
const blob = await fn();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
};
if (status === "REJECTED" || status === "CANCELLED" || status === "COMPLETED") {
return null;
}
@@ -101,23 +91,6 @@ export function BookingActionsToolbar({ booking, mutations }: BookingActionsTool
<BookingActionsMenu row={row} variant="toolbar" />
</Stack>
</SectionCard>
{status === "CONTRACT_READY" && (
<SectionCard icon={FileText} title="Documents">
<Button
variant="default"
leftSection={<Download size={16} />}
onClick={() =>
downloadBlob(
() => mutations.downloadContract(),
`contract-${booking.reference}.txt`,
)
}
>
Download contract
</Button>
</SectionCard>
)}
</Stack>
);
}

View File

@@ -0,0 +1,202 @@
import { useMemo } from "react";
import { Boxes, Container as ContainerIcon, Snowflake, Flame } from "lucide-react";
import { Badge, Box, Group, Stack, Table, Text, ThemeIcon } from "@mantine/core";
import type { BookingDetail } from "@/types/booking";
import { SectionCard } from "./SectionCard";
export interface BookingContainerUnitsCardProps {
booking: BookingDetail;
}
interface FlatUnit {
id: string;
containerNumber: string;
sealNumber?: string | null;
vgmTons: number;
isHazardous?: boolean;
isReefer?: boolean;
typeLabel: string;
sizeFt?: number;
}
/**
* The physical container manifest: one row per container with its number, type,
* seal, and weight (VGM). Per-unit numbers are only captured for contract-drawdown
* bookings — when a line has no units the card falls back to the aggregate
* type/qty/weight so it still renders something for plain bookings.
*/
export function BookingContainerUnitsCard({ booking }: BookingContainerUnitsCardProps) {
const lines = booking.bookingContainers ?? [];
const units: FlatUnit[] = useMemo(
() =>
lines.flatMap((line) =>
(line.units ?? []).map((u) => ({
id: u.id,
containerNumber: u.containerNumber,
sealNumber: u.sealNumber,
vgmTons: Number(u.vgmTons) || 0,
isHazardous: u.isHazardous,
isReefer: u.isReefer,
typeLabel: line.containerType?.label ?? line.containerType?.code ?? "—",
sizeFt: line.containerType?.sizeFt,
})),
),
[lines],
);
// Container bookings only — bulk has no container manifest.
if (booking.freightType === "BULK" || lines.length === 0) return null;
const totalUnits = units.length;
const totalVgm = units.reduce((sum, u) => sum + u.vgmTons, 0);
return (
<SectionCard
icon={Boxes}
title="Containers"
subtitle={
totalUnits > 0
? "Each physical container with its number and weight"
: "Per-container numbers were not captured for this booking"
}
accent="teal"
extra={
totalUnits > 0 ? (
<Badge color="teal" variant="light" radius="sm">
{totalUnits} container{totalUnits === 1 ? "" : "s"}
</Badge>
) : (
<Badge color="gray" variant="light" radius="sm">
{lines.length} line{lines.length === 1 ? "" : "s"}
</Badge>
)
}
>
{totalUnits > 0 ? (
<Stack gap="md">
<Box style={{ overflowX: "auto" }}>
<Table verticalSpacing="sm" horizontalSpacing="md" highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th style={{ width: 40 }}>#</Table.Th>
<Table.Th>Container No.</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Seal</Table.Th>
<Table.Th ta="right">Weight (VGM)</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{units.map((u, i) => (
<Table.Tr key={u.id}>
<Table.Td>
<Text size="sm" c="dimmed">
{i + 1}
</Text>
</Table.Td>
<Table.Td>
<Group gap={8} wrap="nowrap" align="center">
<ThemeIcon size={26} radius="md" variant="light" color="teal">
<ContainerIcon size={15} />
</ThemeIcon>
<Text size="sm" fw={700} ff="monospace">
{u.containerNumber}
</Text>
{u.isReefer ? (
<ThemeIcon size={20} radius="sm" variant="light" color="blue" title="Reefer">
<Snowflake size={12} />
</ThemeIcon>
) : null}
{u.isHazardous ? (
<ThemeIcon size={20} radius="sm" variant="light" color="red" title="Hazardous">
<Flame size={12} />
</ThemeIcon>
) : null}
</Group>
</Table.Td>
<Table.Td>
<Group gap={6} wrap="nowrap">
<Text size="sm">{u.typeLabel}</Text>
{u.sizeFt ? (
<Badge color="gray" variant="light" radius="sm" size="sm">
{u.sizeFt}FT
</Badge>
) : null}
</Group>
</Table.Td>
<Table.Td>
<Text size="sm" c={u.sealNumber ? undefined : "dimmed"}>
{u.sealNumber || "—"}
</Text>
</Table.Td>
<Table.Td ta="right">
<Text size="sm" fw={700}>
{u.vgmTons.toFixed(3)} t
</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Box>
<Group
justify="space-between"
pt="sm"
style={{ borderTop: "1px solid var(--mantine-color-gray-2)" }}
>
<Text size="sm" fw={600} c="dimmed">
Total weight (VGM)
</Text>
<Text size="sm" fw={800} c="teal.7">
{totalVgm.toFixed(3)} t
</Text>
</Group>
</Stack>
) : (
// Fallback: no per-unit numbers — show the aggregate lines.
<Box style={{ overflowX: "auto" }}>
<Table verticalSpacing="sm" horizontalSpacing="md" highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Type</Table.Th>
<Table.Th>Qty</Table.Th>
<Table.Th>VGM / unit</Table.Th>
<Table.Th ta="right">Total VGM</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{lines.map((line) => {
const perUnit = Number(line.vgmPerUnitTons) || 0;
return (
<Table.Tr key={line.id}>
<Table.Td>
<Group gap={6} wrap="nowrap">
<Text size="sm" fw={600}>
{line.containerType?.label ?? line.containerType?.code ?? "—"}
</Text>
{line.containerType?.sizeFt ? (
<Badge color="gray" variant="light" radius="sm" size="sm">
{line.containerType.sizeFt}FT
</Badge>
) : null}
</Group>
</Table.Td>
<Table.Td>{line.quantity}</Table.Td>
<Table.Td>{perUnit.toFixed(3)} t</Table.Td>
<Table.Td ta="right">
<Text size="sm" fw={700}>
{(line.quantity * perUnit).toFixed(3)} t
</Text>
</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
</Box>
)}
</SectionCard>
);
}

View File

@@ -8,6 +8,7 @@ export * from "./BookingDetailHeader";
export * from "./BookingLifecycleStepper";
export * from "./BookingRouteCard";
export * from "./BookingContainersCard";
export * from "./BookingContainerUnitsCard";
export * from "./BookingApprovalCard";
export * from "./BookingReviewNotesCard";
export * from "./BookingPaymentCard";

View File

@@ -2,7 +2,6 @@ import type { LucideIcon } from "lucide-react";
import {
Ban,
Check,
FileSignature,
MessageSquareWarning,
Play,
ShieldCheck,
@@ -211,29 +210,6 @@ const CANCEL_ACTION: BookingActionDef = {
inputPlaceholder: "Reason for cancellation…",
};
const VIEW_CONTRACT_ACTION: BookingActionDef = {
id: "viewContract",
label: "View contract",
shortLabel: "Contract",
description: "Open contract document and signatures",
confirmTitle: "",
confirmDescription: "",
variant: "outline",
icon: FileSignature,
};
const SIGN_CONTRACT_STAFF_ACTION: BookingActionDef = {
id: "signContractStaff",
label: "Sign contract",
shortLabel: "Sign",
description: "Open contract page and apply staff counter-signature",
confirmTitle: "",
confirmDescription: "",
variant: "default",
icon: FileSignature,
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 = {
@@ -340,22 +316,14 @@ export function getBookingActions(
actions = withCancel(approvalActions(approvalSteps));
break;
case "APPROVED":
actions = [{ ...VIEW_CONTRACT_ACTION, primary: true }, CANCEL_ACTION];
actions = [CANCEL_ACTION];
break;
case "CONTRACT_READY":
actions = [{ ...VIEW_CONTRACT_ACTION, primary: true }];
break;
case "SIGNED_CUSTOMER":
actions = [SIGN_CONTRACT_STAFF_ACTION, VIEW_CONTRACT_ACTION];
break;
case "FULLY_EXECUTED":
actions = [
{
...VIEW_CONTRACT_ACTION,
label: "View executed contract",
primary: true,
},
];
// Contract view/sign/executed buttons intentionally removed from the
// booking-request page.
actions = [];
break;
case "AWAITING_DOCUMENTS":
case "DOCUMENTS_UNDER_REVIEW":

View File

@@ -1,7 +1,6 @@
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import {
ArrowLeft,
FileSignature,
Layers,
LayoutGrid,
Milestone,
@@ -36,31 +35,19 @@ import {
BookingCargoCard,
BookingCompanyCard,
BookingContractSummaryCard,
BookingDocumentsCard,
BookingContainerUnitsCard,
ClearanceReviewSection,
ContractOrdersPanel,
type BookingFileView,
} from "@/components/bookings/detail";
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,
useBookingMutations,
} from "@/hooks/bookings/useBookings";
import { useScrollToHash } from "@/hooks/useScrollToHash";
import toast from "react-hot-toast";
// Signature / generated-contract files are surfaced on the contract page, not
// in the booking's Documents list.
const SIGNATURE_FILE_CODES = new Set([
"signature",
"signature_customer",
"signature_staff",
"contract",
]);
export default function BookingRequestDetailPage() {
const { id } = useParams<{ id: string }>();
@@ -77,14 +64,6 @@ export default function BookingRequestDetailPage() {
} = useBookingDetail(id);
const mutations = useBookingMutations(id ?? "");
const handleDownloadFile = async (file: BookingFileView) => {
try {
await downloadBookingFile(file.id, file.name);
} catch {
toast.error("Could not download file.");
}
};
if (isLoading) {
return (
<PageContainer>
@@ -149,11 +128,6 @@ export default function BookingRequestDetailPage() {
const row = toBookingListRow(booking);
const statusMeta = getStatusMeta(booking.status);
const showContractButton = [
"CONTRACT_READY",
"SIGNED_CUSTOMER",
"FULLY_EXECUTED",
].includes(booking.status);
const showApprovalCard =
booking.status === "PENDING_APPROVAL" ||
booking.status === "APPROVED_PENDING_SIGNATURE";
@@ -246,11 +220,7 @@ export default function BookingRequestDetailPage() {
</Tabs.List>
<Tabs.Panel value="overview">
<OverviewPanel
booking={booking}
row={row}
onDownload={handleDownloadFile}
/>
<OverviewPanel booking={booking} row={row} />
</Tabs.Panel>
{isGeneralContract && (
<Tabs.Panel value="orders">
@@ -270,11 +240,7 @@ export default function BookingRequestDetailPage() {
)}
</Tabs>
) : (
<OverviewPanel
booking={booking}
row={row}
onDownload={handleDownloadFile}
/>
<OverviewPanel booking={booking} row={row} />
)}
</Grid.Col>
@@ -306,20 +272,6 @@ export default function BookingRequestDetailPage() {
View document clearance
</Button>
)}
{showContractButton && (
<Button
fullWidth
color="edr-green"
leftSection={<FileSignature size={16} />}
onClick={() =>
navigate(
`/dashboard/booking-requests/${booking.id}/contract`,
)
}
>
View & sign contract
</Button>
)}
{showApprovalCard && (
<ApprovalStepsCard booking={booking} mutations={mutations} />
)}
@@ -332,15 +284,13 @@ export default function BookingRequestDetailPage() {
);
}
/** The booking's primary detail cards — route, services, cargo, contract, docs. */
/** The booking's primary detail cards — route, services, cargo, containers. */
function OverviewPanel({
booking,
row,
onDownload,
}: {
booking: BookingDetail;
row: ReturnType<typeof toBookingListRow>;
onDownload: (file: BookingFileView) => void;
}) {
return (
<Stack gap="lg">
@@ -351,15 +301,10 @@ function OverviewPanel({
/>
<BookingMileServicesCard booking={booking} />
<BookingCargoCard booking={booking} />
<BookingContainerUnitsCard booking={booking} />
{booking.contractSummary && (
<BookingContractSummaryCard summary={booking.contractSummary} />
)}
<BookingDocumentsCard
files={(booking.files ?? []).filter(
(f) => !SIGNATURE_FILE_CODES.has(f.code ?? ""),
)}
onDownload={onDownload}
/>
</Stack>
);
}

View File

@@ -69,6 +69,17 @@ export interface BookingCompany {
website?: string | null;
}
/** One physical container under a line — its own number + verified gross mass. */
export interface BookingContainerUnit {
id: string;
containerNumber: string;
sealNumber?: string | null;
vgmTons: number;
isHazardous?: boolean;
isReefer?: boolean;
sortOrder?: number;
}
export interface BookingContainerLine {
id: string;
containerTypeId: string;
@@ -80,6 +91,8 @@ export interface BookingContainerLine {
label?: string;
sizeFt?: number;
};
/** Per-physical-container rows (number + weight). Empty when not captured. */
units?: BookingContainerUnit[];
}
export interface BookingApprovalStep {