mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 02:58:11 +00:00
fix issue
This commit is contained in:
@@ -115,6 +115,7 @@ import TrainScheduleV2DetailPage from "./pages/trainScheduling/TrainScheduleV2De
|
|||||||
import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPage";
|
import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPage";
|
||||||
import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage";
|
import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage";
|
||||||
import TradeAccessPage from "./pages/configuration/TradeAccessPage";
|
import TradeAccessPage from "./pages/configuration/TradeAccessPage";
|
||||||
|
import ExchangeRateSettingsCard from "./pages/settings/ExchangeRateSettingsCard";
|
||||||
import ContractValidityPeriodsPage from "./pages/configuration/ContractValidityPeriodsPage";
|
import ContractValidityPeriodsPage from "./pages/configuration/ContractValidityPeriodsPage";
|
||||||
import FirstMilePage from "./pages/operations/FirstMilePage";
|
import FirstMilePage from "./pages/operations/FirstMilePage";
|
||||||
import LastMilePage from "./pages/operations/LastMilePage";
|
import LastMilePage from "./pages/operations/LastMilePage";
|
||||||
@@ -600,6 +601,11 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
href: "/dashboard/configuration/trade-access",
|
href: "/dashboard/configuration/trade-access",
|
||||||
permission: FREIGHT_PERMS.admin,
|
permission: FREIGHT_PERMS.admin,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "Exchange rate",
|
||||||
|
href: "/dashboard/configuration/exchange-rate",
|
||||||
|
permission: FREIGHT_PERMS.admin,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1574,6 +1580,16 @@ const App = () => {
|
|||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="configuration/exchange-rate"
|
||||||
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.admin}>
|
||||||
|
<div className="p-4">
|
||||||
|
<ExchangeRateSettingsCard />
|
||||||
|
</div>
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
{/* <Route
|
{/* <Route
|
||||||
path="configuration/contract-validity-periods"
|
path="configuration/contract-validity-periods"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ import {
|
|||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@/shared/common/ui/alert-dialog";
|
} from "@/shared/common/ui/alert-dialog";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import ExchangeRateSettingsCard from "./settings/ExchangeRateSettingsCard";
|
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||||
@@ -78,8 +77,6 @@ export default function SettingsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-6 space-y-6">
|
||||||
<ExchangeRateSettingsCard />
|
|
||||||
|
|
||||||
<Card className="shadow-lg border-gray-200 dark:border-gray-700">
|
<Card className="shadow-lg border-gray-200 dark:border-gray-700">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-xl font-semibold">
|
<CardTitle className="text-xl font-semibold">
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import { Group, Tabs } from "@mantine/core";
|
import { Group, Tabs } from "@mantine/core";
|
||||||
import { Clock, CreditCard, FileText, LayoutGrid, Truck } from "lucide-react";
|
import {
|
||||||
|
Clock,
|
||||||
|
CreditCard,
|
||||||
|
FileText,
|
||||||
|
LayoutGrid,
|
||||||
|
Package,
|
||||||
|
Truck,
|
||||||
|
} from "lucide-react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||||
@@ -9,6 +16,7 @@ import { ApproveDeliveryButton } from "../delivery/ApproveDeliveryButton";
|
|||||||
import { ActivityCard } from "./components/ActivityCard";
|
import { ActivityCard } from "./components/ActivityCard";
|
||||||
import { ClearanceCard } from "./components/ClearanceCard";
|
import { ClearanceCard } from "./components/ClearanceCard";
|
||||||
import { BookingClearanceWorkflowBanner } from "@/pages/bookings/BookingClearanceWorkflowBanner";
|
import { BookingClearanceWorkflowBanner } from "@/pages/bookings/BookingClearanceWorkflowBanner";
|
||||||
|
import { CargoTab } from "./components/CargoTab";
|
||||||
import { DocumentsTab } from "./components/DocumentsTab";
|
import { DocumentsTab } from "./components/DocumentsTab";
|
||||||
import { CompanyInfoCard } from "./components/CompanyInfoCard";
|
import { CompanyInfoCard } from "./components/CompanyInfoCard";
|
||||||
import { ContainersCard } from "./components/ContainersCard";
|
import { ContainersCard } from "./components/ContainersCard";
|
||||||
@@ -199,6 +207,9 @@ export function ReadonlyBookingView({
|
|||||||
<Tabs.Tab value="overview" leftSection={<LayoutGrid size={15} />}>
|
<Tabs.Tab value="overview" leftSection={<LayoutGrid size={15} />}>
|
||||||
Overview
|
Overview
|
||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
|
<Tabs.Tab value="cargo" leftSection={<Package size={15} />}>
|
||||||
|
Cargo
|
||||||
|
</Tabs.Tab>
|
||||||
<Tabs.Tab value="logistics" leftSection={<Truck size={15} />}>
|
<Tabs.Tab value="logistics" leftSection={<Truck size={15} />}>
|
||||||
Logistics
|
Logistics
|
||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
@@ -254,6 +265,10 @@ export function ReadonlyBookingView({
|
|||||||
</div>
|
</div>
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
|
||||||
|
<Tabs.Panel value="cargo">
|
||||||
|
<CargoTab booking={booking} />
|
||||||
|
</Tabs.Panel>
|
||||||
|
|
||||||
<Tabs.Panel value="logistics">
|
<Tabs.Panel value="logistics">
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<BodyGrid
|
<BodyGrid
|
||||||
|
|||||||
@@ -0,0 +1,465 @@
|
|||||||
|
import { Box, Group, SimpleGrid, Table, Text } from "@mantine/core";
|
||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
Box as BoxIcon,
|
||||||
|
Container,
|
||||||
|
Flame,
|
||||||
|
Package,
|
||||||
|
Scale,
|
||||||
|
Snowflake,
|
||||||
|
Undo2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
BookingContainerLineDetail,
|
||||||
|
BookingContainerUnitDetail,
|
||||||
|
BookingDetail,
|
||||||
|
} from "../booking-detail-types";
|
||||||
|
import {
|
||||||
|
commodityLabel,
|
||||||
|
fmtDate,
|
||||||
|
fmtWeight,
|
||||||
|
shippingLineLabel,
|
||||||
|
totalVgmTons,
|
||||||
|
} from "../utils";
|
||||||
|
import { CardTitle, SectionCard } from "./layout";
|
||||||
|
|
||||||
|
// ─── Shared bits ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function Flag({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
tone = "grey",
|
||||||
|
}: {
|
||||||
|
icon: ReactNode;
|
||||||
|
label: string;
|
||||||
|
tone?: "grey" | "amber" | "blue" | "green";
|
||||||
|
}) {
|
||||||
|
const palette = {
|
||||||
|
grey: { bg: "#F1F4F7", color: "#475569" },
|
||||||
|
amber: { bg: "#FFFBEB", color: "#92400E" },
|
||||||
|
blue: { bg: "#EAF1FE", color: "#1E40AF" },
|
||||||
|
green: { bg: "#E8F5EF", color: "#0A6F4D" },
|
||||||
|
}[tone];
|
||||||
|
return (
|
||||||
|
<Group
|
||||||
|
component="span"
|
||||||
|
gap={4}
|
||||||
|
align="center"
|
||||||
|
wrap="nowrap"
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
borderRadius: 999,
|
||||||
|
backgroundColor: palette.bg,
|
||||||
|
padding: "3px 9px",
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: palette.color,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
{label}
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatTile({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
sub,
|
||||||
|
}: {
|
||||||
|
icon: ReactNode;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
sub?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
p={14}
|
||||||
|
style={{
|
||||||
|
borderRadius: 12,
|
||||||
|
border: "1px solid #E6ECF2",
|
||||||
|
backgroundColor: "#FAFCFE",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group gap={6} align="center" mb={6} c="#6B7C8E">
|
||||||
|
{icon}
|
||||||
|
<Text fz="11px" fw={700} tt="uppercase" style={{ letterSpacing: "0.05em" }}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Text fz={18} fw={800} c="#10202F" truncate>
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
{sub && (
|
||||||
|
<Text fz={12} c="#9AA8B5" mt={2}>
|
||||||
|
{sub}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailRow({ label, value }: { label: string; value: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<Group justify="space-between" align="baseline" py={11} wrap="nowrap" style={{ borderBottom: "1px solid #F2F5F8" }}>
|
||||||
|
<Text fz={12.5} fw={600} c="#9AA8B5" style={{ flexShrink: 0 }}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Text fz={13.5} fw={700} c="#10202F" ta="right">
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const th = { color: "#9AA8B5", fontSize: 11 } as const;
|
||||||
|
|
||||||
|
// ─── Containers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function lineTypeLabel(line: BookingContainerLineDetail): string {
|
||||||
|
const t = line.containerType;
|
||||||
|
if (t?.label) return t.label;
|
||||||
|
if (t?.sizeFt) return `${t.sizeFt}ft${t.isReefer ? " Reefer" : ""} container`;
|
||||||
|
return t?.code ?? "Container";
|
||||||
|
}
|
||||||
|
|
||||||
|
function UnitRow({ unit }: { unit: BookingContainerUnitDetail }) {
|
||||||
|
return (
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Td>
|
||||||
|
<Text fz={13} fw={700} c="#10202F" style={{ fontFamily: "monospace" }}>
|
||||||
|
{unit.containerNumber}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text fz={13} c="#475569">
|
||||||
|
{unit.sealNumber || "—"}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text fz={13} c="#475569">
|
||||||
|
{Number(unit.vgmTons || 0) ? fmtWeight(Number(unit.vgmTons)) : "—"}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Group gap={4} wrap="wrap">
|
||||||
|
{unit.isHazardous && (
|
||||||
|
<Flag tone="amber" icon={<AlertTriangle size={11} />} label="Hazardous" />
|
||||||
|
)}
|
||||||
|
{unit.isReefer && (
|
||||||
|
<Flag tone="blue" icon={<Snowflake size={11} />} label="Reefer" />
|
||||||
|
)}
|
||||||
|
{unit.isReturn && <Flag icon={<Undo2 size={11} />} label="Return" />}
|
||||||
|
{!unit.isHazardous && !unit.isReefer && !unit.isReturn && (
|
||||||
|
<Text fz={12} c="#9AA8B5">
|
||||||
|
—
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text fz={13} c="#475569">
|
||||||
|
{unit.grnNumber || "—"}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
{unit.receivedToPort ? (
|
||||||
|
<Box>
|
||||||
|
<Text fz={12.5} fw={700} c="#0A6F4D">
|
||||||
|
Received
|
||||||
|
</Text>
|
||||||
|
{unit.receivedAt && (
|
||||||
|
<Text fz={11.5} c="#9AA8B5">
|
||||||
|
{fmtDate(unit.receivedAt)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Text fz={12.5} fw={600} c="#9AA8B5">
|
||||||
|
Pending
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContainerLineCard({ line, index }: { line: BookingContainerLineDetail; index: number }) {
|
||||||
|
const units = line.units ?? [];
|
||||||
|
return (
|
||||||
|
<SectionCard>
|
||||||
|
<Group justify="space-between" align="flex-start" mb={4} wrap="wrap">
|
||||||
|
<Group gap={10} align="center">
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
borderRadius: 10,
|
||||||
|
backgroundColor: "#E8F5EF",
|
||||||
|
color: "#0A6F4D",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Container size={18} />
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Text fz={15} fw={800} c="#10202F">
|
||||||
|
{lineTypeLabel(line)}
|
||||||
|
</Text>
|
||||||
|
<Text fz={12} c="#9AA8B5">
|
||||||
|
Line {index + 1} · {line.quantity} unit{line.quantity !== 1 ? "s" : ""}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
<Group gap={4} wrap="wrap" justify="flex-end">
|
||||||
|
{line.isOverweight && (
|
||||||
|
<Flag
|
||||||
|
tone="amber"
|
||||||
|
icon={<AlertTriangle size={11} />}
|
||||||
|
label={
|
||||||
|
Number(line.overweightExcessTons || 0)
|
||||||
|
? `Overweight +${Number(line.overweightExcessTons)} t`
|
||||||
|
: "Overweight"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!!line.hazardousQuantity && (
|
||||||
|
<Flag tone="amber" icon={<Flame size={11} />} label={`${line.hazardousQuantity} hazardous`} />
|
||||||
|
)}
|
||||||
|
{!!line.reeferQuantity && (
|
||||||
|
<Flag tone="blue" icon={<Snowflake size={11} />} label={`${line.reeferQuantity} reefer`} />
|
||||||
|
)}
|
||||||
|
{!!line.returnQuantity && (
|
||||||
|
<Flag icon={<Undo2 size={11} />} label={`${line.returnQuantity} return`} />
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing={10} my="md">
|
||||||
|
<StatTile
|
||||||
|
icon={<BoxIcon size={13} />}
|
||||||
|
label="Quantity"
|
||||||
|
value={`${line.quantity}`}
|
||||||
|
sub={`container${line.quantity !== 1 ? "s" : ""}`}
|
||||||
|
/>
|
||||||
|
<StatTile
|
||||||
|
icon={<Scale size={13} />}
|
||||||
|
label="VGM / unit"
|
||||||
|
value={fmtWeight(Number(line.vgmPerUnitTons || 0))}
|
||||||
|
/>
|
||||||
|
<StatTile
|
||||||
|
icon={<Scale size={13} />}
|
||||||
|
label="Line total VGM"
|
||||||
|
value={fmtWeight(Number(line.totalVgmTons || 0))}
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
{units.length > 0 && (
|
||||||
|
<Box style={{ overflowX: "auto" }}>
|
||||||
|
<Table verticalSpacing="sm" horizontalSpacing="sm" miw={640}>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th style={th}>Container no.</Table.Th>
|
||||||
|
<Table.Th style={th}>Seal no.</Table.Th>
|
||||||
|
<Table.Th style={th}>VGM</Table.Th>
|
||||||
|
<Table.Th style={th}>Flags</Table.Th>
|
||||||
|
<Table.Th style={th}>GRN</Table.Th>
|
||||||
|
<Table.Th style={th}>Port status</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{units.map((u) => (
|
||||||
|
<UnitRow key={u.id} unit={u} />
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{units.length === 0 && (
|
||||||
|
<Text fz={12.5} c="#9AA8B5">
|
||||||
|
Container numbers will appear here once the physical units are assigned.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Bulk ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function BulkCargoCard({ booking }: { booking: BookingDetail }) {
|
||||||
|
const unit = booking.cargoType?.unitOfMeasure;
|
||||||
|
const isPerItem = unit === "PER_ITEM";
|
||||||
|
// Break-bulk (PER_ITEM): cargoTotalWeightVgm holds the ITEM COUNT and the
|
||||||
|
// real tonnage lives in bulkTotalWeightTons; PER_TON stores tons directly.
|
||||||
|
const quantity = Number(booking.cargoTotalWeightVgm || 0);
|
||||||
|
const tons = totalVgmTons(booking);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionCard>
|
||||||
|
<Group gap={10} align="center" mb="md">
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
borderRadius: 10,
|
||||||
|
backgroundColor: "#E8F5EF",
|
||||||
|
color: "#0A6F4D",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Package size={18} />
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Text fz={15} fw={800} c="#10202F">
|
||||||
|
{commodityLabel(booking)}
|
||||||
|
</Text>
|
||||||
|
<Text fz={12} c="#9AA8B5">
|
||||||
|
Bulk cargo{booking.cargoType?.code ? ` · ${booking.cargoType.code}` : ""}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: isPerItem ? 3 : 2 }} spacing={10} mb="md">
|
||||||
|
{isPerItem && (
|
||||||
|
<StatTile
|
||||||
|
icon={<BoxIcon size={13} />}
|
||||||
|
label="Items"
|
||||||
|
value={quantity ? quantity.toLocaleString() : "—"}
|
||||||
|
sub="declared item count"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<StatTile
|
||||||
|
icon={<Scale size={13} />}
|
||||||
|
label="Total weight"
|
||||||
|
value={fmtWeight(tons)}
|
||||||
|
sub={isPerItem ? "actual tonnage" : "declared tonnage"}
|
||||||
|
/>
|
||||||
|
<StatTile
|
||||||
|
icon={<Package size={13} />}
|
||||||
|
label="Billing unit"
|
||||||
|
value={isPerItem ? "Per item" : "Per ton"}
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<DetailRow label="Commodity" value={commodityLabel(booking)} />
|
||||||
|
{booking.cargoType?.code && (
|
||||||
|
<DetailRow label="Cargo type code" value={booking.cargoType.code} />
|
||||||
|
)}
|
||||||
|
{booking.cargoFreeText && booking.cargoType?.cargoTypeName && (
|
||||||
|
<DetailRow label="Cargo description" value={booking.cargoFreeText} />
|
||||||
|
)}
|
||||||
|
<DetailRow
|
||||||
|
label="Hazardous"
|
||||||
|
value={
|
||||||
|
Number(booking.bulkHazardousQuantity || 0)
|
||||||
|
? `${Number(booking.bulkHazardousQuantity).toLocaleString()} ${isPerItem ? "items" : "t"}`
|
||||||
|
: booking.isHazardous
|
||||||
|
? "Yes"
|
||||||
|
: "No"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
label="Refrigerated"
|
||||||
|
value={
|
||||||
|
Number(booking.bulkReeferQuantity || 0)
|
||||||
|
? `${Number(booking.bulkReeferQuantity).toLocaleString()} ${isPerItem ? "items" : "t"}`
|
||||||
|
: booking.isRefrigerated
|
||||||
|
? "Yes"
|
||||||
|
: "No"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
label="Equipment return"
|
||||||
|
value={booking.equipmentReturn === "WITH_RETURN" ? "With return" : "Without return"}
|
||||||
|
/>
|
||||||
|
<DetailRow label="Shipping line" value={shippingLineLabel(booking)} />
|
||||||
|
</Box>
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tab ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dedicated cargo breakdown tab: bulk bookings get the full bulk declaration
|
||||||
|
* (commodity, unit of measure, item count vs tonnage, hazardous/reefer
|
||||||
|
* quantities); container bookings get one card per container line with its
|
||||||
|
* per-unit numbers, seals, VGM, GRN and port-arrival status.
|
||||||
|
*/
|
||||||
|
export function CargoTab({ booking }: { booking: BookingDetail }) {
|
||||||
|
const isBulk = booking.freightType === "BULK";
|
||||||
|
const lines = booking.bookingContainers ?? [];
|
||||||
|
const totalUnits = lines.reduce((s, c) => s + Number(c.quantity || 0), 0);
|
||||||
|
const totalVgm = totalVgmTons(booking);
|
||||||
|
const receivedCount = lines
|
||||||
|
.flatMap((l) => l.units ?? [])
|
||||||
|
.filter((u) => u.receivedToPort).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6" style={{ maxWidth: 980 }}>
|
||||||
|
<SectionCard>
|
||||||
|
<CardTitle>Cargo summary</CardTitle>
|
||||||
|
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing={10} mt="md">
|
||||||
|
<StatTile
|
||||||
|
icon={isBulk ? <Package size={13} /> : <Container size={13} />}
|
||||||
|
label="Freight type"
|
||||||
|
value={isBulk ? "Bulk" : "Container"}
|
||||||
|
/>
|
||||||
|
<StatTile
|
||||||
|
icon={<BoxIcon size={13} />}
|
||||||
|
label="Commodity"
|
||||||
|
value={commodityLabel(booking)}
|
||||||
|
/>
|
||||||
|
<StatTile
|
||||||
|
icon={<Scale size={13} />}
|
||||||
|
label="Total weight"
|
||||||
|
value={fmtWeight(totalVgm)}
|
||||||
|
/>
|
||||||
|
{isBulk ? (
|
||||||
|
<StatTile
|
||||||
|
icon={<Flame size={13} />}
|
||||||
|
label="Special handling"
|
||||||
|
value={
|
||||||
|
[
|
||||||
|
booking.isHazardous && "Hazardous",
|
||||||
|
booking.isRefrigerated && "Reefer",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ") || "None"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<StatTile
|
||||||
|
icon={<Container size={13} />}
|
||||||
|
label="Containers"
|
||||||
|
value={`${totalUnits}`}
|
||||||
|
sub={`${receivedCount} received at port`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</SimpleGrid>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
{isBulk ? (
|
||||||
|
<BulkCargoCard booking={booking} />
|
||||||
|
) : lines.length > 0 ? (
|
||||||
|
lines.map((line, i) => (
|
||||||
|
<ContainerLineCard key={line.id ?? i} line={line} index={i} />
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<SectionCard>
|
||||||
|
<Text fz={13.5} c="#9AA8B5">
|
||||||
|
No container details recorded for this booking yet.
|
||||||
|
</Text>
|
||||||
|
</SectionCard>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -292,7 +292,10 @@ export default function NewBookingPage() {
|
|||||||
// physically carry the selected cargo/container type. Quantity is NOT part
|
// physically carry the selected cargo/container type. Quantity is NOT part
|
||||||
// of this gate — an oversized booking is accepted and gets a partial split
|
// of this gate — an oversized booking is accepted and gets a partial split
|
||||||
// offer later. Only selectable days reach the UI; no capacity counts.
|
// offer later. Only selectable days reach the UI; no capacity counts.
|
||||||
const gateContainerTypeIds = useMemo(() => {
|
// Computed per render on purpose — NOT useMemo. react-hook-form mutates the
|
||||||
|
// watched containers array in place on nested edits (containers.0.containerType),
|
||||||
|
// so a reference-based dep list never sees per-line changes.
|
||||||
|
const gateContainerTypeIds = (() => {
|
||||||
if (watchedCargoKind !== "container") return [];
|
if (watchedCargoKind !== "container") return [];
|
||||||
const groups = referenceData?.containers ?? [];
|
const groups = referenceData?.containers ?? [];
|
||||||
const ids = new Set<string>();
|
const ids = new Set<string>();
|
||||||
@@ -304,7 +307,7 @@ export default function NewBookingPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return [...ids];
|
return [...ids];
|
||||||
}, [watchedCargoKind, watchedContainers, referenceData]);
|
})();
|
||||||
const gateCargoTypeId =
|
const gateCargoTypeId =
|
||||||
watchedCargoKind === "bulk" ? watchedCargoTypePath?.[1] : undefined;
|
watchedCargoKind === "bulk" ? watchedCargoTypePath?.[1] : undefined;
|
||||||
const gateReady =
|
const gateReady =
|
||||||
|
|||||||
@@ -1006,9 +1006,11 @@ function ScheduleStep({
|
|||||||
const isContainer = contract.freightType === "CONTAINER";
|
const isContainer = contract.freightType === "CONTAINER";
|
||||||
const containerLines = form.watch("containers");
|
const containerLines = form.watch("containers");
|
||||||
const cargoWeightTons = form.watch("cargoWeightTons");
|
const cargoWeightTons = form.watch("cargoWeightTons");
|
||||||
const itemCount = form.watch("itemCount");
|
|
||||||
|
|
||||||
const cargoQuery = useMemo<Freight.AvailableDaysForCargoQuery | null>(() => {
|
// Computed per render on purpose — NOT useMemo. react-hook-form mutates the
|
||||||
|
// watched containers array in place on nested edits (containers.0.quantity),
|
||||||
|
// so a reference-based dep list never sees manual quantity changes.
|
||||||
|
const cargoQuery = ((): Freight.AvailableDaysForCargoQuery | null => {
|
||||||
if (!route?.originYardId || !route?.destinationYardId) return null;
|
if (!route?.originYardId || !route?.destinationYardId) return null;
|
||||||
if (isContainer) {
|
if (isContainer) {
|
||||||
const containers = (containerLines ?? [])
|
const containers = (containerLines ?? [])
|
||||||
@@ -1036,17 +1038,7 @@ function ScheduleStep({
|
|||||||
?.cargoTypeCode ?? undefined,
|
?.cargoTypeCode ?? undefined,
|
||||||
totalWeightTons: tons,
|
totalWeightTons: tons,
|
||||||
};
|
};
|
||||||
// Tonnage is the sizing input the day-feasibility endpoint takes, and
|
})();
|
||||||
// PER_ITEM cargo now captures it too — itemCount stays in the deps so the
|
|
||||||
// query still refreshes when only the item count changes.
|
|
||||||
}, [
|
|
||||||
route,
|
|
||||||
isContainer,
|
|
||||||
containerLines,
|
|
||||||
cargoWeightTons,
|
|
||||||
itemCount,
|
|
||||||
contract.pricingBreakdown,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const isIntercity = contract.tradeDirection === "DOMESTIC";
|
const isIntercity = contract.tradeDirection === "DOMESTIC";
|
||||||
const { data: availableDays, isLoading } = useQuery({
|
const { data: availableDays, isLoading } = useQuery({
|
||||||
|
|||||||
Reference in New Issue
Block a user