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

@@ -232,6 +232,15 @@ export default function BookingWindowSettingsModal({
</Alert>
) : (
<Stack gap="lg">
{!isExport ? (
<Alert variant="light" color="orange" icon={<Info size={16} />}>
These settings apply to every train on this route (same origin and
destination) departing the same day they all share one booking
window, so it opens, moves to document review, opens for payment,
and closes at the same time for all of them.
</Alert>
) : null}
{isExport ? (
<Alert variant="light" color="blue" icon={<Info size={16} />}>
Export schedules use a single first-come-first-served window: it

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).