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

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