Add ContractCourtBadge component and integrate into contract pages

- Introduced ContractCourtBadge to display the responsible party for contract actions.
- Updated ContractStatusBadge to include new court badge.
- Enhanced ClearanceDocumentsPage with additional filters for trade direction, freight type, and ownership.
- Modified ContractRequestDetailPage and ContractRequestsPage to utilize ContractCourtBadge.
This commit is contained in:
Marshal
2026-07-29 13:37:17 +00:00
parent f6e363cd6f
commit 47c25d3f3a
12 changed files with 499 additions and 77 deletions

View File

@@ -99,16 +99,20 @@ export function ContractMilestonesTimeline({
});
}
// Every acted approval step, not just hazardous ones — this is the one
// place the approval-time record shows up in the page's main content
// (the sidebar's ContractApprovalStepsCard has the same times, but only
// there, and only while the chain is still actionable).
for (const step of contract.approvalSteps ?? []) {
if (!(step.requiredRole in HAZARDOUS_APPROVAL_ROLE_PERMISSION)) continue;
if (!step.actedAt) continue;
const hazard = step.requiredRole in HAZARDOUS_APPROVAL_ROLE_PERMISSION;
items.push({
key: `hazard-${step.id}`,
key: `step-${step.id}`,
at: step.actedAt,
title: CONTRACT_APPROVAL_ROLE_LABELS[step.requiredRole] ?? step.requiredRole,
detail: step.status === "REJECTED" ? "Rejected" : "Approved",
color: step.status === "REJECTED" ? "red" : "orange",
icon: Flame,
title: `${CONTRACT_APPROVAL_ROLE_LABELS[step.requiredRole] ?? step.requiredRole} ${step.status === "REJECTED" ? "rejected" : "approved"}`,
detail: step.note ?? undefined,
color: step.status === "REJECTED" ? "red" : hazard ? "orange" : "edr-green",
icon: hazard ? Flame : ShieldCheck,
});
}

View File

@@ -1,9 +1,10 @@
import { Badge, Group } from "@mantine/core";
import { Repeat } from "lucide-react";
import { Building2, Repeat, UserRound } from "lucide-react";
import {
CONTRACT_STATUS_COLOR,
CONTRACT_STATUS_STYLES,
contractCourt,
} from "@/features/contracts/contract-status.config";
interface ContractStatusBadgeProps {
@@ -69,3 +70,42 @@ export function ContractStatusBadge({
</Group>
);
}
/** Whose court the contract sits in: customer, EDR, or nobody ("—"). */
export function ContractCourtBadge({ status }: { status: string }) {
const court = contractCourt(status);
if (!court) {
return (
<span className="text-sm text-muted-foreground" title="No party is awaited">
</span>
);
}
const isCustomer = court === "customer";
return (
<Badge
color={isCustomer ? "orange" : "edr-green"}
variant="light"
size="sm"
radius="md"
tt="uppercase"
fw={600}
leftSection={
isCustomer ? <UserRound size={12} /> : <Building2 size={12} />
}
title={
isCustomer
? "Waiting on the customer to act"
: "Waiting on EDR staff to act"
}
style={{
fontSize: "0.7rem",
letterSpacing: "0.05em",
display: "inline-flex",
whiteSpace: "nowrap",
}}
>
{isCustomer ? "With customer" : "With EDR"}
</Badge>
);
}

View File

@@ -34,12 +34,13 @@ import type {
} from "@/types/trainScheduling";
import { WindowPhasePill } from "./batchVisuals";
import { ForecastPanel } from "./ForecastPanel";
import { forecastIsLive } from "./batchForecast";
import { forecastIsLive, rankBookings } from "./batchForecast";
/**
* Priority Tracking tab — live, glanceable ranking of every booking on this
* schedule in the exact order the batch engine boards them (government first,
* then rule-engine priority score, then oldest). Bookings above the train's
* then window cycle — bookings compete only within their own cycle — then
* rule-engine priority score, then oldest). Bookings above the train's
* wagon-capacity line render as "selected" (green), below it as the waiting
* list; during the PAYMENT phase selected bookings show a live pay-window
* countdown. Purely presentational — data comes from the batch-board detail
@@ -286,19 +287,11 @@ export const PriorityTrackingTab = memo(function PriorityTrackingTab({
);
const showForecast = forecastAvailable && view === "forecast";
// Rank exactly as the batch engine does: government first, then priority score
// desc, then oldest (fullyExecutedAt / selectedForBatchAt as the tiebreak the
// backend uses). The board already returns them in this order, but re-sort
// defensively so the tab is correct even if the source order ever changes.
const ranked = useMemo(() => {
const time = (b: BatchBoardBookingDetail) =>
b.fullyExecutedAt ? new Date(b.fullyExecutedAt).getTime() : Number.MAX_SAFE_INTEGER;
return [...bookings].sort((a, b) => {
if (a.isGovernment !== b.isGovernment) return a.isGovernment ? -1 : 1;
if (b.priorityScore !== a.priorityScore) return b.priorityScore - a.priorityScore;
return time(a) - time(b);
});
}, [bookings]);
// Rank exactly as the batch engine does: government first, then window cycle
// (bookings only compete within the cycle they arrived in — an earlier cycle
// boards before a later one regardless of score), then priority desc, then
// oldest. Shared with the forecast sim so both views agree.
const ranked = useMemo(() => rankBookings(bookings), [bookings]);
const scoreMax = useMemo(() => maxScore(ranked), [ranked]);
// Wagon-slot cap from the board DTO (derived from train length and the
@@ -395,7 +388,8 @@ export const PriorityTrackingTab = memo(function PriorityTrackingTab({
<Stack gap={2}>
<Text fw={700}>Priority ranking</Text>
<Text size="xs" c="dimmed">
Government first, then rule-engine score, then earliest booked.
Government first, then booking window (earlier cycles board
first), then rule-engine score, then earliest booked.
</Text>
</Stack>
</Group>

View File

@@ -61,7 +61,12 @@ export interface ForecastResult {
full: boolean;
}
/** Engine rank order: government first, then priority desc, then oldest booked. */
/**
* Engine rank order: government first, then window cycle asc (bookings compete
* only within the cycle they arrived in — earlier cycles board first no matter
* the score; pending-contract rows sink last), then priority desc, then oldest
* booked.
*/
export function rankBookings(
bookings: BatchBoardBookingDetail[],
): BatchBoardBookingDetail[] {
@@ -69,8 +74,11 @@ export function rankBookings(
b.fullyExecutedAt
? new Date(b.fullyExecutedAt).getTime()
: Number.MAX_SAFE_INTEGER;
const cycle = (b: BatchBoardBookingDetail) =>
b.windowCycleNo ?? Number.MAX_SAFE_INTEGER;
return [...bookings].sort((a, b) => {
if (a.isGovernment !== b.isGovernment) return a.isGovernment ? -1 : 1;
if (cycle(a) !== cycle(b)) return cycle(a) - cycle(b);
if (b.priorityScore !== a.priorityScore)
return b.priorityScore - a.priorityScore;
return time(a) - time(b);

View File

@@ -270,6 +270,41 @@ export const CONTRACT_STATUS_META: Record<string, StatusMeta> = {
},
};
/** Statuses where the next action sits with the customer (portal side). */
const WITH_CUSTOMER_STATUSES = new Set([
"DRAFT",
"PRICE_CHANGED_PENDING_CONFIRM",
"CHANGES_REQUESTED",
"CONTRACT_READY", // generated contract awaits the customer's signature
"AWAITING_CLEARANCE_DOCUMENTS",
"RENEWAL_DRAFT",
]);
/** Statuses where the next action sits with EDR staff. */
const WITH_EDR_STATUSES = new Set([
"SUBMITTED",
"PENDING_APPROVAL",
"APPROVED",
"APPROVED_PENDING_SIGNATURE",
"SIGNED_CUSTOMER",
"CLEARANCE_UNDER_REVIEW",
"CLEARANCE_READY_FOR_BOOKING",
"RENEWAL_SUBMITTED",
"RENEWAL_PENDING_APPROVAL",
]);
/**
* Whose court the contract is in. Null for states with no pending party
* (active, closed, rejected…).
*/
export function contractCourt(
status: ContractStatus | string,
): "customer" | "edr" | null {
if (WITH_CUSTOMER_STATUSES.has(status)) return "customer";
if (WITH_EDR_STATUSES.has(status)) return "edr";
return null;
}
export const CONTRACT_LIST_TABS = [
{ key: "all", label: "All contracts", statuses: null as string[] | null },
{

View File

@@ -9,6 +9,7 @@ import {
TextInput,
ThemeIcon,
} from "@mantine/core";
import { DateInput } from "@mantine/dates";
import { useDebouncedValue } from "@mantine/hooks";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { FileText, Inbox, RefreshCw, Search, User, X } from "lucide-react";
@@ -16,6 +17,7 @@ import { useCallback, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
import { ContractReferenceLink } from "@/components/bookings/ContractReferenceLink";
import { bookingTable } from "@/components/bookings/booking-ui.styles";
import { PageContainer, PageHeader } from "@/components/page";
import { bookingsService } from "@/services/bookings.service";
@@ -49,6 +51,34 @@ const BOOKING_STATUS_OPTIONS = [
{ value: "CLEARANCE_READY", label: "Clearance ready" },
];
const TRADE_DIRECTION_OPTIONS = [
{ value: "IMPORT", label: "Import" },
{ value: "EXPORT", label: "Export" },
{ value: "DOMESTIC", label: "Domestic" },
];
const FREIGHT_TYPE_OPTIONS = [
{ value: "CONTAINER", label: "Container" },
{ value: "BULK", label: "Bulk" },
];
const OWNERSHIP_OPTIONS = [
{ value: "true", label: "Government" },
{ value: "false", label: "Private" },
];
function startOfDayIso(d: Date): string {
const x = new Date(d);
x.setHours(0, 0, 0, 0);
return x.toISOString();
}
function endOfDayIso(d: Date): string {
const x = new Date(d);
x.setHours(23, 59, 59, 999);
return x.toISOString();
}
export default function ClearanceDocumentsPage() {
const navigate = useNavigate();
const [query, setQuery] = useState("");
@@ -56,6 +86,11 @@ export default function ClearanceDocumentsPage() {
const [bookingStatuses, setBookingStatuses] = useState(
BOOKING_STATUS_OPTIONS[0].value,
);
const [directionFilter, setDirectionFilter] = useState<string | null>(null);
const [freightTypeFilter, setFreightTypeFilter] = useState<string | null>(null);
const [ownershipFilter, setOwnershipFilter] = useState<string | null>(null);
const [createdFrom, setCreatedFrom] = useState<Date | null>(null);
const [createdTo, setCreatedTo] = useState<Date | null>(null);
const { pagination, setPagination } = usePagination({ pageSize: PAGE_SIZE });
const search = debouncedQuery.trim() || undefined;
@@ -67,7 +102,18 @@ export default function ClearanceDocumentsPage() {
const page = pagination.pageIndex + 1;
const bookingsQuery = useQuery({
queryKey: ["clearance-documents", "bookings", bookingStatuses, page, search],
queryKey: [
"clearance-documents",
"bookings",
bookingStatuses,
directionFilter,
freightTypeFilter,
ownershipFilter,
createdFrom,
createdTo,
page,
search,
],
queryFn: () =>
// Self-clearance instances carry bookingType=ONE_TIME whatever their
// contract kind, so customsClearingEnabled=false + the three per-booking
@@ -78,6 +124,13 @@ export default function ClearanceDocumentsPage() {
page,
pageSize: PAGE_SIZE,
search,
...(directionFilter ? { tradeDirection: directionFilter } : {}),
...(freightTypeFilter ? { freightType: freightTypeFilter } : {}),
...(ownershipFilter
? { isGovernment: ownershipFilter as "true" | "false" }
: {}),
...(createdFrom ? { createdFrom: startOfDayIso(createdFrom) } : {}),
...(createdTo ? { createdTo: endOfDayIso(createdTo) } : {}),
}),
placeholderData: keepPreviousData,
});
@@ -115,9 +168,18 @@ export default function ClearanceDocumentsPage() {
{
id: "contractRef",
header: () => <span className={bookingTable.headerCell}>Contract</span>,
cell: ({ row }) => (
<Text size="sm">{row.original.contractReference ?? "—"}</Text>
),
cell: ({ row }) => {
const b = row.original;
return b.contractId && b.contractReference ? (
<ContractReferenceLink
contractId={b.contractId}
contractReference={b.contractReference}
className="block truncate text-sm text-foreground underline underline-offset-2 hover:text-muted-foreground"
/>
) : (
<Text size="sm"></Text>
);
},
},
{
id: "shipment",
@@ -238,6 +300,73 @@ export default function ClearanceDocumentsPage() {
{total} record{total !== 1 ? "s" : ""}
</Text>
</Group>
<Group gap="sm" mt="sm" wrap="wrap">
<Select
placeholder="Direction"
data={TRADE_DIRECTION_OPTIONS}
value={directionFilter}
onChange={(v) => {
setDirectionFilter(v);
resetPage();
}}
clearable
radius="lg"
style={{ minWidth: 130 }}
aria-label="Filter by direction"
/>
<Select
placeholder="Freight type"
data={FREIGHT_TYPE_OPTIONS}
value={freightTypeFilter}
onChange={(v) => {
setFreightTypeFilter(v);
resetPage();
}}
clearable
radius="lg"
style={{ minWidth: 140 }}
aria-label="Filter by freight type"
/>
<Select
placeholder="Gov / Private"
data={OWNERSHIP_OPTIONS}
value={ownershipFilter}
onChange={(v) => {
setOwnershipFilter(v);
resetPage();
}}
clearable
radius="lg"
style={{ minWidth: 140 }}
aria-label="Filter by ownership"
/>
<DateInput
placeholder="Created from"
value={createdFrom}
onChange={(v) => {
setCreatedFrom(v ? new Date(v) : null);
resetPage();
}}
maxDate={createdTo ?? undefined}
clearable
radius="lg"
style={{ minWidth: 140 }}
aria-label="Created from"
/>
<DateInput
placeholder="Created to"
value={createdTo}
onChange={(v) => {
setCreatedTo(v ? new Date(v) : null);
resetPage();
}}
minDate={createdFrom ?? undefined}
clearable
radius="lg"
style={{ minWidth: 140 }}
aria-label="Created to"
/>
</Group>
</Box>
{showEmpty ? (

View File

@@ -15,12 +15,14 @@ import {
Files,
Flame,
History,
Info,
LayoutGrid,
Milestone,
Package,
Receipt,
RefreshCw,
Route as RouteIcon,
ShieldCheck,
Snowflake,
Users,
} from "lucide-react";
@@ -35,6 +37,7 @@ import {
Group,
Loader,
Paper,
SimpleGrid,
Stack,
Tabs,
Text,
@@ -48,7 +51,10 @@ import { PageContainer } from "@/components/page";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { detailStyles } from "@/components/bookings/detail/booking-detail.styles";
import { ContractStatusBadge } from "@/components/contracts/ContractStatusBadge";
import {
ContractCourtBadge,
ContractStatusBadge,
} from "@/components/contracts/ContractStatusBadge";
import { ContractWorkflowStepper } from "@/components/contracts/ContractWorkflowStepper";
import { ContractActionsToolbar } from "@/components/contracts/ContractActionsToolbar";
import { ContractApprovalStepsCard } from "@/components/contracts/ContractApprovalStepsCard";
@@ -374,6 +380,7 @@ export default function ContractRequestDetailPage() {
status={contract.status}
isRenewal={Boolean(contract.renewalOfId)}
/>
<ContractCourtBadge status={contract.status} />
<Badge variant="light" color="gray" radius="sm" tt="uppercase">
{contract.contractKind === "GENERAL" ? "General" : "One-time"}
</Badge>
@@ -553,6 +560,100 @@ export default function ContractRequestDetailPage() {
<ContractCustomerCard contract={contract} />
) : (
<Stack gap="lg">
<SectionCard
icon={Info}
title="Contract information"
subtitle="Full commercial and operational detail for this contract."
>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="lg">
<InfoRow
label="Service type"
value={contract.serviceType?.serviceName ?? "—"}
/>
<InfoRow
label="Payment currency"
value={contract.paymentCurrency ?? "—"}
/>
<InfoRow
label="Customs clearing"
value={
contract.customsClearingEnabled
? "Included automatically"
: contract.customsClearingAgent
? `Customer's agent — ${contract.customsClearingAgent}`
: "Not included"
}
/>
{contract.equipmentReturn ? (
<InfoRow
label="Equipment return"
value={
contract.equipmentReturn === "WITH_RETURN"
? "With return"
: "Without return"
}
/>
) : null}
<InfoRow
label="Contract type"
value={contract.contractType ?? "Standard"}
/>
{contract.contractValidityDays != null ? (
<InfoRow
label="Validity period"
value={`${contract.contractValidityDays} days`}
/>
) : null}
{contract.estimatedShipmentDate ? (
<InfoRow
label="Estimated shipment date"
value={formatDate(contract.estimatedShipmentDate)}
/>
) : null}
{contract.firstMilePickupAddress ? (
<InfoRow
label="First-mile pickup"
value={contract.firstMilePickupAddress}
/>
) : null}
{contract.lastMileDeliveryAddress ? (
<InfoRow
label="Last-mile delivery"
value={contract.lastMileDeliveryAddress}
/>
) : null}
</SimpleGrid>
{contract.financialTerms ? (
<Box
mt="md"
pt="md"
style={{ borderTop: "1px solid var(--mantine-color-gray-2)" }}
>
<Text
size="xs"
c="dimmed"
fw={600}
tt="uppercase"
mb={4}
style={{ letterSpacing: 0.3 }}
>
Financial terms
</Text>
<Text size="sm" style={{ whiteSpace: "pre-wrap" }}>
{contract.financialTerms}
</Text>
</Box>
) : null}
</SectionCard>
<SectionCard
icon={ShieldCheck}
title="Approval & signing timeline"
subtitle="Every dated step in this contract's approval chain, plus signatures — the same record kept in the sidebar, always visible here."
>
<ContractMilestonesTimeline contract={contract} />
</SectionCard>
<SectionCard icon={RouteIcon} title="Routes">
{routes.length === 0 ? (
<Text size="sm" c="dimmed">
@@ -761,6 +862,25 @@ export default function ContractRequestDetailPage() {
);
}
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<div>
<Text
size="xs"
c="dimmed"
fw={600}
tt="uppercase"
style={{ letterSpacing: 0.3 }}
>
{label}
</Text>
<Text size="sm" fw={500} mt={2}>
{value}
</Text>
</div>
);
}
function MetaItem({
icon: Icon,
text,

View File

@@ -34,7 +34,10 @@ import { useCallback, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { ContractApprovalProgressCell } from "@/components/contracts/ContractApprovalProgressCell";
import { ContractStatusBadge } from "@/components/contracts/ContractStatusBadge";
import {
ContractCourtBadge,
ContractStatusBadge,
} from "@/components/contracts/ContractStatusBadge";
import {
ContractStatusTabs,
type ContractStatusTabKey,
@@ -334,6 +337,19 @@ export default function ContractRequestsPage() {
</div>
),
},
{
id: "court",
size: COLUMN_WIDTH,
meta: COLUMN_META,
header: () => (
<span className={bookingTable.headerCell}>Waiting on</span>
),
cell: ({ row }) => (
<div className="py-1">
<ContractCourtBadge status={row.original.status} />
</div>
),
},
{
id: "approval",
size: COLUMN_WIDTH,
@@ -666,7 +682,7 @@ export default function ContractRequestsPage() {
}}
// table-fixed makes the per-column 120px widths stick; without
// it auto-layout re-widens columns once cells wrap.
containerClassName="border-0 shadow-none bg-transparent [&_table]:table-fixed [&_table]:min-w-[840px]"
containerClassName="border-0 shadow-none bg-transparent [&_table]:table-fixed [&_table]:min-w-[960px]"
footer={DataTableFooter}
/>
</Box>

View File

@@ -242,23 +242,20 @@ export default function GlClearanceDetailPage() {
<Tabs.Panel value="workflow">
{/* GL Ethiopia cannot file the import customs declaration until this
desk names the officer handling the shipment in transit, so the
ask sits above everything else on the page. Exports have no such
gate — Djibouti's steps come after the declaration. */}
{isImport ? (
<Box mb="md">
<TransitAssigneePanel
entityId={id!}
isBooking={data.kind === "booking"}
transitAssignee={data.clearance.transitAssignee}
side="DJ"
readOnly={
!hasPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions)
}
onChanged={() => void refetch()}
/>
</Box>
) : null}
desk names the officer handling the shipment in transit. Exports also
need transit assignment at the DJ stage after ET requests it. */}
<Box mb="md">
<TransitAssigneePanel
entityId={id!}
isBooking={data.kind === "booking"}
transitAssignee={data.clearance.transitAssignee}
side="DJ"
readOnly={
!hasPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions)
}
onChanged={() => void refetch()}
/>
</Box>
<Grid>
<Grid.Col span={{ base: 12, lg: 7 }}>

View File

@@ -411,6 +411,12 @@ export type BookingAllocationStatus =
| "FAILED";
export interface BatchBoardBookingDetail extends BatchBoardBooking {
/**
* 0-based booking-window cycle the booking entered the pool in. Ranking is
* per-cycle: an earlier cycle always boards before a later one regardless of
* priority score. Null while the contract is still pending.
*/
windowCycleNo: number | null;
fullyExecutedAt: string | null;
selectedForBatchAt: string | null;
allocationStatus: BookingAllocationStatus;