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

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