mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 16:28:12 +00:00
Merge pull request #433 from Tria-plc/freight_feature/usermanagement
feat: enhance contract clearance process with linked booking details
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user