mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 19:58:11 +00:00
add wagon allocation snapshot to train schedules
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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)}`}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user