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

@@ -32,8 +32,11 @@ import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository'; import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedule-bookings.repository'; import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedule-bookings.repository';
import { BookingNotifierService } from './booking-notifier.service'; import { BookingNotifierService } from './booking-notifier.service';
import { TrainSchedulingService } from './train-scheduling.service'; import {
import { eatDay } from './batch-window.util'; TrainSchedulingService,
effectiveWindowConfig,
} from './train-scheduling.service';
import { eatDay, listConfigBookingWindows } from './batch-window.util';
import { import {
BATCH_BOARD_STATUSES, BATCH_BOARD_STATUSES,
BatchBoardQueryDto, BatchBoardQueryDto,
@@ -167,6 +170,13 @@ export type BookingAllocationStatus =
| "FAILED"; | "FAILED";
export interface BatchBoardBookingDetail extends BatchBoardBooking { export interface BatchBoardBookingDetail extends BatchBoardBooking {
/**
* 0-based booking-window cycle this booking entered the pool in (derived from
* `fullyExecutedAt` against the schedule's window cycles). Ranking compares
* bookings within a cycle only — an earlier cycle always boards before a later
* one regardless of score. Null while the contract is still pending.
*/
windowCycleNo: number | null;
fullyExecutedAt: string | null; fullyExecutedAt: string | null;
selectedForBatchAt: string | null; selectedForBatchAt: string | null;
allocationStatus: BookingAllocationStatus; allocationStatus: BookingAllocationStatus;
@@ -1321,10 +1331,12 @@ export class BookingBatchService implements OnModuleInit {
} }
} }
const cycleOf = await this.windowCycleIndexer(s);
const items: BatchBoardBookingDetail[] = bookings.map((b) => { const items: BatchBoardBookingDetail[] = bookings.map((b) => {
const need = this.needFor(b, wagonDims); const need = this.needFor(b, wagonDims);
const alloc = allocationByBooking.get(b.id); const alloc = allocationByBooking.get(b.id);
return { return {
windowCycleNo: b.fullyExecutedAt ? cycleOf(b.fullyExecutedAt) : null,
id: b.id, id: b.id,
reference: b.reference ?? b.id.slice(0, 8), reference: b.reference ?? b.id.slice(0, 8),
company: b.isGovernment company: b.isGovernment
@@ -1632,7 +1644,7 @@ export class BookingBatchService implements OnModuleInit {
// Same bulk re-score as fillRouteDayInternal — the legacy per-schedule fill // Same bulk re-score as fillRouteDayInternal — the legacy per-schedule fill
// must rank bulk bookings by their wagon-derived priority too. // must rank bulk bookings by their wagon-derived priority too.
await this.recomputeBulkPriorities(pool, wagonDims); await this.recomputeBulkPriorities(pool, wagonDims);
this.resortPoolByPriority(pool); this.resortPoolByPriority(pool, await this.windowCycleIndexer(schedule));
const units = this.groupConsolidatedPool(pool); const units = this.groupConsolidatedPool(pool);
let armed = false; let armed = false;
let preempted = false; let preempted = false;
@@ -1847,6 +1859,9 @@ export class BookingBatchService implements OnModuleInit {
armed: boolean; armed: boolean;
changed: boolean; changed: boolean;
}> = []; }> = [];
// The day group shares one booking window (route+day grouping), so any
// member's window grid stands for the pool's cycle derivation.
let cycleSchedule: TrainSchedule | null = null;
for (const id of scheduleIds) { for (const id of scheduleIds) {
const schedule = const schedule =
await this.trainSchedulesRepository.findByIdWithFullGraph(id); await this.trainSchedulesRepository.findByIdWithFullGraph(id);
@@ -1857,6 +1872,7 @@ export class BookingBatchService implements OnModuleInit {
); );
continue; continue;
} }
cycleSchedule ??= schedule;
const limits = await this.capacityLimits(locomotive); const limits = await this.capacityLimits(locomotive);
await this.syncScheduleMaxWagons(schedule, locomotive); await this.syncScheduleMaxWagons(schedule, locomotive);
const budget = await this.remainingBudget(schedule, limits, wagonDims); const budget = await this.remainingBudget(schedule, limits, wagonDims);
@@ -1877,7 +1893,10 @@ export class BookingBatchService implements OnModuleInit {
// BULK bookings only get their real (wagon-derived) priority score now, at // BULK bookings only get their real (wagon-derived) priority score now, at
// batch time — stamp it and re-rank before the fill consumes the pool. // batch time — stamp it and re-rank before the fill consumes the pool.
await this.recomputeBulkPriorities(pool, wagonDims); await this.recomputeBulkPriorities(pool, wagonDims);
this.resortPoolByPriority(pool); this.resortPoolByPriority(
pool,
cycleSchedule ? await this.windowCycleIndexer(cycleSchedule) : undefined,
);
// Consolidated partners collapse into one atomic unit (both-or-neither); a // Consolidated partners collapse into one atomic unit (both-or-neither); a
// consolidated booking whose partner isn't ready this cycle is skipped. // consolidated booking whose partner isn't ready this cycle is skipped.
const units = this.groupConsolidatedPool(pool); const units = this.groupConsolidatedPool(pool);
@@ -3257,11 +3276,66 @@ export class BookingBatchService implements OnModuleInit {
} }
} }
/** Restore the batch pool ordering (mirrors findBatchPool's ORDER BY) after scores changed. */ /**
private resortPoolByPriority(pool: Booking[]): void { * Maps a booking's pool-entry time (`fullyExecutedAt`) to the 0-based
* booking-window cycle it arrived in: the last window whose open is at/before
* the timestamp (a timestamp in the doc-review/payment gap belongs to the
* cycle that just closed). The cycle grid comes from the schedule's frozen
* window-rule snapshot — the exact windows the cycle engine runs.
*/
private async windowCycleIndexer(
schedule: TrainSchedule,
): Promise<(ts: Date | null | undefined) => number> {
if (!schedule.scheduledDepartureDate) return () => 0;
let starts: number[];
try {
const liveCfg = await this.trainSchedulingService.getWindowConfig();
const cfg = effectiveWindowConfig(schedule, liveCfg);
const windows = listConfigBookingWindows(
schedule.direction,
schedule.scheduledDepartureDate,
{
...cfg,
reopenGapMinutes:
schedule.ruleReopenDelayMinutes ??
cfg.docReviewMinutes + cfg.paymentWindowMinutes,
},
);
starts = windows.map((w) => w.start.getTime());
} catch (err) {
// A failed cycle derivation must never block the batch — fall back to one
// flat cycle (pure priority order, the old behaviour).
this.logger.warn(
`Window-cycle derivation failed for schedule ${schedule.id}: ` +
`${(err as Error).message}`,
);
return () => 0;
}
return (ts) => {
if (!ts) return 0;
const ms = ts.getTime();
let idx = 0;
for (let i = 0; i < starts.length; i += 1) {
if (ms >= starts[i]) idx = i;
}
return idx;
};
}
/**
* Rank the batch pool: government first, then WINDOW CYCLE (bookings compete
* only within the cycle they arrived in — an earlier cycle's booking always
* outranks a later cycle's, whatever the scores), then priority score, then
* oldest. `cycleOf` comes from {@link windowCycleIndexer}.
*/
private resortPoolByPriority(
pool: Booking[],
cycleOf: (ts: Date | null | undefined) => number = () => 0,
): void {
pool.sort( pool.sort(
(a, b) => (a, b) =>
Number(b.isGovernment) - Number(a.isGovernment) || Number(b.isGovernment) - Number(a.isGovernment) ||
cycleOf(a.fullyExecutedAt) - cycleOf(b.fullyExecutedAt) ||
Number(b.priorityScore ?? 0) - Number(a.priorityScore ?? 0) || Number(b.priorityScore ?? 0) - Number(a.priorityScore ?? 0) ||
(a.fullyExecutedAt?.getTime() ?? Infinity) - (a.fullyExecutedAt?.getTime() ?? Infinity) -
(b.fullyExecutedAt?.getTime() ?? Infinity) || (b.fullyExecutedAt?.getTime() ?? Infinity) ||

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 ?? []) { for (const step of contract.approvalSteps ?? []) {
if (!(step.requiredRole in HAZARDOUS_APPROVAL_ROLE_PERMISSION)) continue;
if (!step.actedAt) continue; if (!step.actedAt) continue;
const hazard = step.requiredRole in HAZARDOUS_APPROVAL_ROLE_PERMISSION;
items.push({ items.push({
key: `hazard-${step.id}`, key: `step-${step.id}`,
at: step.actedAt, at: step.actedAt,
title: CONTRACT_APPROVAL_ROLE_LABELS[step.requiredRole] ?? step.requiredRole, title: `${CONTRACT_APPROVAL_ROLE_LABELS[step.requiredRole] ?? step.requiredRole} ${step.status === "REJECTED" ? "rejected" : "approved"}`,
detail: step.status === "REJECTED" ? "Rejected" : "Approved", detail: step.note ?? undefined,
color: step.status === "REJECTED" ? "red" : "orange", color: step.status === "REJECTED" ? "red" : hazard ? "orange" : "edr-green",
icon: Flame, icon: hazard ? Flame : ShieldCheck,
}); });
} }

View File

@@ -1,9 +1,10 @@
import { Badge, Group } from "@mantine/core"; import { Badge, Group } from "@mantine/core";
import { Repeat } from "lucide-react"; import { Building2, Repeat, UserRound } from "lucide-react";
import { import {
CONTRACT_STATUS_COLOR, CONTRACT_STATUS_COLOR,
CONTRACT_STATUS_STYLES, CONTRACT_STATUS_STYLES,
contractCourt,
} from "@/features/contracts/contract-status.config"; } from "@/features/contracts/contract-status.config";
interface ContractStatusBadgeProps { interface ContractStatusBadgeProps {
@@ -69,3 +70,42 @@ export function ContractStatusBadge({
</Group> </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"; } from "@/types/trainScheduling";
import { WindowPhasePill } from "./batchVisuals"; import { WindowPhasePill } from "./batchVisuals";
import { ForecastPanel } from "./ForecastPanel"; import { ForecastPanel } from "./ForecastPanel";
import { forecastIsLive } from "./batchForecast"; import { forecastIsLive, rankBookings } from "./batchForecast";
/** /**
* Priority Tracking tab — live, glanceable ranking of every booking on this * Priority Tracking tab — live, glanceable ranking of every booking on this
* schedule in the exact order the batch engine boards them (government first, * 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 * wagon-capacity line render as "selected" (green), below it as the waiting
* list; during the PAYMENT phase selected bookings show a live pay-window * list; during the PAYMENT phase selected bookings show a live pay-window
* countdown. Purely presentational — data comes from the batch-board detail * 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"; const showForecast = forecastAvailable && view === "forecast";
// Rank exactly as the batch engine does: government first, then priority score // Rank exactly as the batch engine does: government first, then window cycle
// desc, then oldest (fullyExecutedAt / selectedForBatchAt as the tiebreak the // (bookings only compete within the cycle they arrived in — an earlier cycle
// backend uses). The board already returns them in this order, but re-sort // boards before a later one regardless of score), then priority desc, then
// defensively so the tab is correct even if the source order ever changes. // oldest. Shared with the forecast sim so both views agree.
const ranked = useMemo(() => { const ranked = useMemo(() => rankBookings(bookings), [bookings]);
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]);
const scoreMax = useMemo(() => maxScore(ranked), [ranked]); const scoreMax = useMemo(() => maxScore(ranked), [ranked]);
// Wagon-slot cap from the board DTO (derived from train length and the // 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}> <Stack gap={2}>
<Text fw={700}>Priority ranking</Text> <Text fw={700}>Priority ranking</Text>
<Text size="xs" c="dimmed"> <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> </Text>
</Stack> </Stack>
</Group> </Group>

View File

@@ -61,7 +61,12 @@ export interface ForecastResult {
full: boolean; 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( export function rankBookings(
bookings: BatchBoardBookingDetail[], bookings: BatchBoardBookingDetail[],
): BatchBoardBookingDetail[] { ): BatchBoardBookingDetail[] {
@@ -69,8 +74,11 @@ export function rankBookings(
b.fullyExecutedAt b.fullyExecutedAt
? new Date(b.fullyExecutedAt).getTime() ? new Date(b.fullyExecutedAt).getTime()
: Number.MAX_SAFE_INTEGER; : Number.MAX_SAFE_INTEGER;
const cycle = (b: BatchBoardBookingDetail) =>
b.windowCycleNo ?? Number.MAX_SAFE_INTEGER;
return [...bookings].sort((a, b) => { return [...bookings].sort((a, b) => {
if (a.isGovernment !== b.isGovernment) return a.isGovernment ? -1 : 1; 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) if (b.priorityScore !== a.priorityScore)
return b.priorityScore - a.priorityScore; return b.priorityScore - a.priorityScore;
return time(a) - time(b); 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 = [ export const CONTRACT_LIST_TABS = [
{ key: "all", label: "All contracts", statuses: null as string[] | null }, { key: "all", label: "All contracts", statuses: null as string[] | null },
{ {

View File

@@ -9,6 +9,7 @@ import {
TextInput, TextInput,
ThemeIcon, ThemeIcon,
} from "@mantine/core"; } from "@mantine/core";
import { DateInput } from "@mantine/dates";
import { useDebouncedValue } from "@mantine/hooks"; import { useDebouncedValue } from "@mantine/hooks";
import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { FileText, Inbox, RefreshCw, Search, User, X } from "lucide-react"; 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 { useNavigate } from "react-router-dom";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge"; import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
import { ContractReferenceLink } from "@/components/bookings/ContractReferenceLink";
import { bookingTable } from "@/components/bookings/booking-ui.styles"; import { bookingTable } from "@/components/bookings/booking-ui.styles";
import { PageContainer, PageHeader } from "@/components/page"; import { PageContainer, PageHeader } from "@/components/page";
import { bookingsService } from "@/services/bookings.service"; import { bookingsService } from "@/services/bookings.service";
@@ -49,6 +51,34 @@ const BOOKING_STATUS_OPTIONS = [
{ value: "CLEARANCE_READY", label: "Clearance ready" }, { 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() { export default function ClearanceDocumentsPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
@@ -56,6 +86,11 @@ export default function ClearanceDocumentsPage() {
const [bookingStatuses, setBookingStatuses] = useState( const [bookingStatuses, setBookingStatuses] = useState(
BOOKING_STATUS_OPTIONS[0].value, 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 { pagination, setPagination } = usePagination({ pageSize: PAGE_SIZE });
const search = debouncedQuery.trim() || undefined; const search = debouncedQuery.trim() || undefined;
@@ -67,7 +102,18 @@ export default function ClearanceDocumentsPage() {
const page = pagination.pageIndex + 1; const page = pagination.pageIndex + 1;
const bookingsQuery = useQuery({ const bookingsQuery = useQuery({
queryKey: ["clearance-documents", "bookings", bookingStatuses, page, search], queryKey: [
"clearance-documents",
"bookings",
bookingStatuses,
directionFilter,
freightTypeFilter,
ownershipFilter,
createdFrom,
createdTo,
page,
search,
],
queryFn: () => queryFn: () =>
// Self-clearance instances carry bookingType=ONE_TIME whatever their // Self-clearance instances carry bookingType=ONE_TIME whatever their
// contract kind, so customsClearingEnabled=false + the three per-booking // contract kind, so customsClearingEnabled=false + the three per-booking
@@ -78,6 +124,13 @@ export default function ClearanceDocumentsPage() {
page, page,
pageSize: PAGE_SIZE, pageSize: PAGE_SIZE,
search, search,
...(directionFilter ? { tradeDirection: directionFilter } : {}),
...(freightTypeFilter ? { freightType: freightTypeFilter } : {}),
...(ownershipFilter
? { isGovernment: ownershipFilter as "true" | "false" }
: {}),
...(createdFrom ? { createdFrom: startOfDayIso(createdFrom) } : {}),
...(createdTo ? { createdTo: endOfDayIso(createdTo) } : {}),
}), }),
placeholderData: keepPreviousData, placeholderData: keepPreviousData,
}); });
@@ -115,9 +168,18 @@ export default function ClearanceDocumentsPage() {
{ {
id: "contractRef", id: "contractRef",
header: () => <span className={bookingTable.headerCell}>Contract</span>, header: () => <span className={bookingTable.headerCell}>Contract</span>,
cell: ({ row }) => ( cell: ({ row }) => {
<Text size="sm">{row.original.contractReference ?? "—"}</Text> 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", id: "shipment",
@@ -238,6 +300,73 @@ export default function ClearanceDocumentsPage() {
{total} record{total !== 1 ? "s" : ""} {total} record{total !== 1 ? "s" : ""}
</Text> </Text>
</Group> </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> </Box>
{showEmpty ? ( {showEmpty ? (

View File

@@ -15,12 +15,14 @@ import {
Files, Files,
Flame, Flame,
History, History,
Info,
LayoutGrid, LayoutGrid,
Milestone, Milestone,
Package, Package,
Receipt, Receipt,
RefreshCw, RefreshCw,
Route as RouteIcon, Route as RouteIcon,
ShieldCheck,
Snowflake, Snowflake,
Users, Users,
} from "lucide-react"; } from "lucide-react";
@@ -35,6 +37,7 @@ import {
Group, Group,
Loader, Loader,
Paper, Paper,
SimpleGrid,
Stack, Stack,
Tabs, Tabs,
Text, Text,
@@ -48,7 +51,10 @@ import { PageContainer } from "@/components/page";
import Breadcrumbs from "@/components/ui/Breadcrumbs"; import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { SectionCard } from "@/components/bookings/detail/SectionCard"; import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { detailStyles } from "@/components/bookings/detail/booking-detail.styles"; 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 { ContractWorkflowStepper } from "@/components/contracts/ContractWorkflowStepper";
import { ContractActionsToolbar } from "@/components/contracts/ContractActionsToolbar"; import { ContractActionsToolbar } from "@/components/contracts/ContractActionsToolbar";
import { ContractApprovalStepsCard } from "@/components/contracts/ContractApprovalStepsCard"; import { ContractApprovalStepsCard } from "@/components/contracts/ContractApprovalStepsCard";
@@ -374,6 +380,7 @@ export default function ContractRequestDetailPage() {
status={contract.status} status={contract.status}
isRenewal={Boolean(contract.renewalOfId)} isRenewal={Boolean(contract.renewalOfId)}
/> />
<ContractCourtBadge status={contract.status} />
<Badge variant="light" color="gray" radius="sm" tt="uppercase"> <Badge variant="light" color="gray" radius="sm" tt="uppercase">
{contract.contractKind === "GENERAL" ? "General" : "One-time"} {contract.contractKind === "GENERAL" ? "General" : "One-time"}
</Badge> </Badge>
@@ -553,6 +560,100 @@ export default function ContractRequestDetailPage() {
<ContractCustomerCard contract={contract} /> <ContractCustomerCard contract={contract} />
) : ( ) : (
<Stack gap="lg"> <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"> <SectionCard icon={RouteIcon} title="Routes">
{routes.length === 0 ? ( {routes.length === 0 ? (
<Text size="sm" c="dimmed"> <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({ function MetaItem({
icon: Icon, icon: Icon,
text, text,

View File

@@ -34,7 +34,10 @@ import { useCallback, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { ContractApprovalProgressCell } from "@/components/contracts/ContractApprovalProgressCell"; import { ContractApprovalProgressCell } from "@/components/contracts/ContractApprovalProgressCell";
import { ContractStatusBadge } from "@/components/contracts/ContractStatusBadge"; import {
ContractCourtBadge,
ContractStatusBadge,
} from "@/components/contracts/ContractStatusBadge";
import { import {
ContractStatusTabs, ContractStatusTabs,
type ContractStatusTabKey, type ContractStatusTabKey,
@@ -334,6 +337,19 @@ export default function ContractRequestsPage() {
</div> </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", id: "approval",
size: COLUMN_WIDTH, size: COLUMN_WIDTH,
@@ -666,7 +682,7 @@ export default function ContractRequestsPage() {
}} }}
// table-fixed makes the per-column 120px widths stick; without // table-fixed makes the per-column 120px widths stick; without
// it auto-layout re-widens columns once cells wrap. // 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} footer={DataTableFooter}
/> />
</Box> </Box>

View File

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

View File

@@ -411,6 +411,12 @@ export type BookingAllocationStatus =
| "FAILED"; | "FAILED";
export interface BatchBoardBookingDetail extends BatchBoardBooking { 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; fullyExecutedAt: string | null;
selectedForBatchAt: string | null; selectedForBatchAt: string | null;
allocationStatus: BookingAllocationStatus; allocationStatus: BookingAllocationStatus;

View File

@@ -422,11 +422,13 @@ export function Step8Review({
label="Customs clearing" label="Customs clearing"
value={customsValue} value={customsValue}
/> />
<SummaryItem {/* Step-3 toggles appear only when the customer selected them —
icon={<Package size={18} />} an off toggle is left off the summary entirely. */}
label="Hazardous cargo" {values.isHazardous && (
value={ <SummaryItem
values.isHazardous ? ( icon={<Package size={18} />}
label="Hazardous cargo"
value={
<> <>
Yes Yes
<Group gap={6} mt={6}> <Group gap={6} mt={6}>
@@ -438,27 +440,24 @@ export function Step8Review({
</Badge> </Badge>
</Group> </Group>
</> </>
) : (
"No"
)
}
/>
<SummaryItem
icon={<Package size={18} />}
label="Refrigerated"
value={values.isRefrigerated ? "Yes" : "No"}
/>
{values.cargoType === "container" && (
<SummaryItem
icon={<RotateCcw size={18} />}
label="Empty-container return"
value={
values.equipmentReturn === "with_return"
? "With return"
: "Without return"
} }
/> />
)} )}
{values.isRefrigerated && (
<SummaryItem
icon={<Package size={18} />}
label="Refrigerated"
value="Yes"
/>
)}
{values.cargoType === "container" &&
values.equipmentReturn === "with_return" && (
<SummaryItem
icon={<RotateCcw size={18} />}
label="Empty-container return"
value="With return"
/>
)}
</Box> </Box>
</Paper> </Paper>