Merge pull request #1006 from Tria-plc/freight_feature/usermanagement

Add ContractCourtBadge component and integrate into contract pages
This commit is contained in:
marshal
2026-07-29 16:37:51 +03:00
committed by GitHub
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 { TrainScheduleBookingsRepository } from '../train-schedules/train-schedule-bookings.repository';
import { BookingNotifierService } from './booking-notifier.service';
import { TrainSchedulingService } from './train-scheduling.service';
import { eatDay } from './batch-window.util';
import {
TrainSchedulingService,
effectiveWindowConfig,
} from './train-scheduling.service';
import { eatDay, listConfigBookingWindows } from './batch-window.util';
import {
BATCH_BOARD_STATUSES,
BatchBoardQueryDto,
@@ -167,6 +170,13 @@ export type BookingAllocationStatus =
| "FAILED";
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;
selectedForBatchAt: string | null;
allocationStatus: BookingAllocationStatus;
@@ -1321,10 +1331,12 @@ export class BookingBatchService implements OnModuleInit {
}
}
const cycleOf = await this.windowCycleIndexer(s);
const items: BatchBoardBookingDetail[] = bookings.map((b) => {
const need = this.needFor(b, wagonDims);
const alloc = allocationByBooking.get(b.id);
return {
windowCycleNo: b.fullyExecutedAt ? cycleOf(b.fullyExecutedAt) : null,
id: b.id,
reference: b.reference ?? b.id.slice(0, 8),
company: b.isGovernment
@@ -1632,7 +1644,7 @@ export class BookingBatchService implements OnModuleInit {
// Same bulk re-score as fillRouteDayInternal — the legacy per-schedule fill
// must rank bulk bookings by their wagon-derived priority too.
await this.recomputeBulkPriorities(pool, wagonDims);
this.resortPoolByPriority(pool);
this.resortPoolByPriority(pool, await this.windowCycleIndexer(schedule));
const units = this.groupConsolidatedPool(pool);
let armed = false;
let preempted = false;
@@ -1847,6 +1859,9 @@ export class BookingBatchService implements OnModuleInit {
armed: 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) {
const schedule =
await this.trainSchedulesRepository.findByIdWithFullGraph(id);
@@ -1857,6 +1872,7 @@ export class BookingBatchService implements OnModuleInit {
);
continue;
}
cycleSchedule ??= schedule;
const limits = await this.capacityLimits(locomotive);
await this.syncScheduleMaxWagons(schedule, locomotive);
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
// batch time — stamp it and re-rank before the fill consumes the pool.
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 booking whose partner isn't ready this cycle is skipped.
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(
(a, b) =>
Number(b.isGovernment) - Number(a.isGovernment) ||
cycleOf(a.fullyExecutedAt) - cycleOf(b.fullyExecutedAt) ||
Number(b.priorityScore ?? 0) - Number(a.priorityScore ?? 0) ||
(a.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 ?? []) {
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;

View File

@@ -422,11 +422,13 @@ export function Step8Review({
label="Customs clearing"
value={customsValue}
/>
<SummaryItem
icon={<Package size={18} />}
label="Hazardous cargo"
value={
values.isHazardous ? (
{/* Step-3 toggles appear only when the customer selected them —
an off toggle is left off the summary entirely. */}
{values.isHazardous && (
<SummaryItem
icon={<Package size={18} />}
label="Hazardous cargo"
value={
<>
Yes
<Group gap={6} mt={6}>
@@ -438,27 +440,24 @@ export function Step8Review({
</Badge>
</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>
</Paper>