add wagon allocation snapshot to train schedules

This commit is contained in:
Marshal
2026-07-13 08:56:06 +00:00
parent 95b17b2729
commit a4ccdb1173
12 changed files with 733 additions and 171 deletions

View File

@@ -196,11 +196,11 @@ const sidebarItems: SidebarItem[] = [
href: "/bookings",
icon: <Package size={18} />,
},
{
label: "Tracking",
href: "/tracking",
icon: <MapPin size={18} />,
},
// {
// label: "Tracking",
// href: "/tracking",
// icon: <MapPin size={18} />,
// },
{
label: "Invoices",
href: "/billing",

View File

@@ -1,5 +1,6 @@
import { Box, SimpleGrid, Text } from "@mantine/core";
import { Anchor, Box, SimpleGrid, Text } from "@mantine/core";
import type { ReactNode } from "react";
import { Link } from "react-router-dom";
import type { Freight } from "@edr/types";
@@ -59,6 +60,22 @@ export function KeyFactsStrip({ booking }: { booking: BookingLike }) {
}
/>
<Fact label="Cargo" value={freight} />
{booking.contractId ? (
<Fact
label="Contract"
value={
<Anchor
component={Link}
to={`/contracts/${booking.contractId}`}
fz="15px"
fw={700}
underline="hover"
>
{booking.contractReference ?? "View contract"}
</Anchor>
}
/>
) : null}
<Fact
label="Route"
value={`${yardLabel(booking.originYard)}${yardLabel(booking.destinationYard)}`}

View File

@@ -38,6 +38,7 @@ import { PayNowButton } from "./payments/PayNowButton";
import { BookingActionButton } from "./clearance/BookingActionButton";
import { bookingHasInlineAction } from "./clearance/bookingNextAction";
import {
BookingStatusBadge as StatusBadge,
BookingTypeBadge,
CargoModeCell,
PaymentBadge,
@@ -46,7 +47,6 @@ import {
import { api } from "@/services/api";
import type { BookingListFilter } from "@/services/bookings.service";
import { STATUS_CONFIG } from "@/pages/MyPortalPage/constants";
import type { Freight } from "@edr/types";
import {
DataTable,
@@ -157,46 +157,6 @@ const STAT_CARDS: Array<{
},
];
// ── Status badge (reuses the shared portal status config) ─────────────────────
function StatusBadge({ status }: { status: string }) {
const cfg = STATUS_CONFIG[status];
const label = cfg?.badgeLabel ?? status.replace(/_/g, " ");
const bg = cfg ? `var(--mantine-color-${cfg.badgeBg}-0, #F1F4F7)` : "#F1F4F7";
const text = cfg
? `var(--mantine-color-${cfg.badgeText}-7, #475569)`
: "#475569";
const dot = cfg
? `var(--mantine-color-${cfg.badgeDot}-6, #94A3B8)`
: "#94A3B8";
return (
<Group
gap={6}
align="center"
wrap="nowrap"
style={{
display: "inline-flex",
borderRadius: 999,
backgroundColor: bg,
padding: "5px 11px",
}}
>
<Box
style={{
width: 6,
height: 6,
borderRadius: "50%",
backgroundColor: dot,
flexShrink: 0,
}}
/>
<Text fz={11} fw={700} style={{ color: text, whiteSpace: "nowrap" }}>
{label}
</Text>
</Group>
);
}
// ── Context-sensitive action button ───────────────────────────────────────────
function PrimaryAction({

View File

@@ -1,4 +1,4 @@
import { Badge, Group, Text } from "@mantine/core";
import { Badge, Box, Group, Text } from "@mantine/core";
import type { Freight } from "@edr/types";
import { STATUS_CONFIG } from "@/pages/MyPortalPage/constants";
@@ -124,6 +124,45 @@ export function PaymentBadge({ status }: { status?: string | null }) {
);
}
/** Booking status pill (dot + label) driven by the shared STATUS_CONFIG. */
export function BookingStatusBadge({ status }: { status: string }) {
const cfg = STATUS_CONFIG[status];
const label = cfg?.badgeLabel ?? titleCaseStatus(status);
const bg = cfg ? `var(--mantine-color-${cfg.badgeBg}-0, #F1F4F7)` : "#F1F4F7";
const text = cfg
? `var(--mantine-color-${cfg.badgeText}-7, #475569)`
: "#475569";
const dot = cfg
? `var(--mantine-color-${cfg.badgeDot}-6, #94A3B8)`
: "#94A3B8";
return (
<Group
gap={6}
align="center"
wrap="nowrap"
style={{
display: "inline-flex",
borderRadius: 999,
backgroundColor: bg,
padding: "5px 11px",
}}
>
<Box
style={{
width: 6,
height: 6,
borderRadius: "50%",
backgroundColor: dot,
flexShrink: 0,
}}
/>
<Text fz={11} fw={700} style={{ color: text, whiteSpace: "nowrap" }}>
{label}
</Text>
</Group>
);
}
/** Whether a booking is assigned to a train yet (scheduling progress). */
export function SchedulingCell({ booking }: { booking: BookingLike & { trainScheduleId?: string | null; schedulingStatus?: string } }) {
const assigned = !!booking.trainScheduleId;

View File

@@ -21,6 +21,7 @@ import {
RingProgress,
SimpleGrid,
Stack,
Table,
Tabs,
Text,
Title,
@@ -60,6 +61,13 @@ import { fileViewUrl } from "@/constants/apiConfig";
import { useFileViewer } from "@/hooks/useFileViewer";
import toast from "react-hot-toast";
import { labelForDocCode } from "@/pages/bookings/resubmit";
import {
BookingStatusBadge,
BookingTypeBadge,
CargoModeCell,
PaymentBadge,
SchedulingCell,
} from "@/pages/bookings/booking-display";
import { ContractClearancePanel } from "./ContractClearancePanel";
import { ContractClearanceWorkflowBanner } from "./ContractClearanceWorkflowBanner";
import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel";
@@ -148,8 +156,15 @@ interface DocGroup {
* contract PDF, company profile / onboarding documents, and clearance documents
* (everything else — the clearance set uses dynamic per-contract codes). Empty
* groups are dropped so the tab only renders sections that have files.
*
* GENERAL contracts clear per booking, so their contract-level "clearance"
* leftovers are not shown — pass includeClearance: false to keep only the
* profile / business-licence sections.
*/
function groupContractDocuments(files: ContractFile[]): DocGroup[] {
function groupContractDocuments(
files: ContractFile[],
{ includeClearance = true }: { includeClearance?: boolean } = {},
): DocGroup[] {
const businessLicense: ContractFile[] = [];
const profile: ContractFile[] = [];
const clearance: ContractFile[] = [];
@@ -159,7 +174,7 @@ function groupContractDocuments(files: ContractFile[]): DocGroup[] {
if (f.code === "contract" || f.code.startsWith("signature_")) continue;
else if (BUSINESS_LICENSE_DOC_CODES.has(f.code)) businessLicense.push(f);
else if (PROFILE_DOC_CODES.has(f.code)) profile.push(f);
else clearance.push(f);
else if (includeClearance) clearance.push(f);
}
return [
{ key: "clearance", title: "Clearance documents", files: clearance },
@@ -328,7 +343,12 @@ export default function ContractDetailPage() {
const routes = contract.routes ?? [];
const pricing = contract.pricingBreakdown;
const files = contract.files ?? [];
const docGroups = groupContractDocuments(files);
// GENERAL contracts clear per booking — clearance documents live on each
// booking's detail page, so this tab keeps only profile/licence documents.
const docGroups = groupContractDocuments(files, {
includeClearance: contract.contractKind !== "GENERAL",
});
const docCount = docGroups.reduce((sum, g) => sum + g.files.length, 0);
// The generated contract PDF — surfaced via a dedicated "View contract" button
// in the header (it's excluded from the Documents tab groups).
const contractPdf = files.find((f) => f.code === "contract");
@@ -621,7 +641,7 @@ export default function ContractDetailPage() {
active={tab === "documents"}
icon={<Download size={16} />}
label="Documents"
count={files.length + (isPhasedCustomsClearance ? workflowFileCount : 0)}
count={docCount + (isPhasedCustomsClearance ? workflowFileCount : 0)}
/>
<DetailTab
value="bookings"
@@ -1191,8 +1211,9 @@ export default function ContractDetailPage() {
No documents yet
</Text>
<Text fz={13} c="dimmed" ta="center" maw={420}>
The signed contract and any uploaded clearance documents will
appear here.
{isGeneral
? "Your company profile documents (TIN certificate, business licence, ID) appear here. Clearance documents are managed on each booking."
: "The signed contract and any uploaded clearance documents will appear here."}
</Text>
</Stack>
) : (
@@ -1399,49 +1420,113 @@ export default function ContractDetailPage() {
</Text>
</Stack>
) : (
<Stack gap={10}>
{contractBookings.map((booking) => (
<Group
key={booking.id}
justify="space-between"
wrap="nowrap"
p="sm"
style={{
borderRadius: 12,
border: `1px solid ${BORDER}`,
cursor: "pointer",
}}
onClick={() => navigate(`/bookings/${booking.id}`)}
>
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
<Package
size={16}
color={MUTED}
style={{ flexShrink: 0 }}
/>
<Box style={{ minWidth: 0 }}>
<Text
fz={14}
fw={700}
style={{ color: INK }}
truncate
<Table.ScrollContainer minWidth={980}>
<Table
verticalSpacing="sm"
horizontalSpacing="md"
highlightOnHover
style={{ fontSize: 13 }}
>
<Table.Thead>
<Table.Tr>
<BookingsTh label="Booking" />
<BookingsTh label="Type" />
<BookingsTh label="Cargo" />
<BookingsTh label="Route" />
<BookingsTh label="Payment" />
<BookingsTh label="Train" />
<BookingsTh label="Status" />
<BookingsTh label="Amount" right />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{contractBookings.map((booking) => {
const origin =
booking.originYard?.label ??
booking.originYard?.code ??
"—";
const dest =
booking.destinationYard?.label ??
booking.destinationYard?.code ??
"—";
const amount = Number(booking.totalAmount ?? 0);
return (
<Table.Tr
key={booking.id}
style={{ cursor: "pointer" }}
onClick={() => navigate(`/bookings/${booking.id}`)}
>
{booking.reference}
</Text>
{booking.scheduledDate && (
<Text fz={12} c="dimmed" truncate>
Ship{" "}
{new Date(
booking.scheduledDate,
).toLocaleDateString()}
</Text>
)}
</Box>
</Group>
<ContractStatusBadge status={booking.status} />
</Group>
))}
</Stack>
<Table.Td>
<Group gap={10} wrap="nowrap">
<Package
size={16}
color={MUTED}
style={{ flexShrink: 0 }}
/>
<Box style={{ minWidth: 0 }}>
<Text
fz={13.5}
fw={700}
style={{ color: INK }}
truncate
>
{booking.reference}
</Text>
<Text fz={11.5} c="dimmed">
{booking.freightType === "BULK"
? "Bulk cargo"
: "Container"}
</Text>
</Box>
</Group>
</Table.Td>
<Table.Td>
<BookingTypeBadge booking={booking} />
</Table.Td>
<Table.Td>
<CargoModeCell booking={booking} />
</Table.Td>
<Table.Td>
<Text fz={13} fw={600} style={{ color: INK }}>
{origin} {dest}
</Text>
{booking.scheduledDate && (
<Text fz={11.5} c="dimmed">
{new Date(
booking.scheduledDate,
).toLocaleDateString()}
</Text>
)}
</Table.Td>
<Table.Td>
<PaymentBadge status={booking.paymentStatus} />
</Table.Td>
<Table.Td>
<SchedulingCell booking={booking} />
</Table.Td>
<Table.Td>
<BookingStatusBadge status={booking.status} />
</Table.Td>
<Table.Td style={{ textAlign: "right" }}>
<Text
fz={13.5}
fw={700}
style={{
color: amount > 0 ? INK : "#94A3B8",
whiteSpace: "nowrap",
}}
>
{amount > 0
? `ETB ${amount.toLocaleString()}`
: "—"}
</Text>
</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
</Card>
</Tabs.Panel>
@@ -1572,6 +1657,23 @@ function SectionLabel({
);
}
/** Column header for the bookings table — mirrors the /bookings list styling. */
function BookingsTh({ label, right }: { label: string; right?: boolean }) {
return (
<Table.Th style={{ textAlign: right ? "right" : "left" }}>
<Text
fz={11}
fw={700}
tt="uppercase"
c="dimmed"
style={{ letterSpacing: "0.06em", whiteSpace: "nowrap" }}
>
{label}
</Text>
</Table.Th>
);
}
/**
* Unit noun for a capacity line: "containers" for CONTAINER freight, else the
* bulk cargo's unit of measure ("tons" for PER_TON, "items" for PER_ITEM).