Add migration to repair schema drift and implement contract step banner in UI

This commit is contained in:
Marshal
2026-07-03 15:24:12 +00:00
parent 7eae1a920e
commit 6dc6914397
5 changed files with 640 additions and 6 deletions

View File

@@ -0,0 +1,269 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Repairs schema drift on databases that were originally built by TypeORM
* `synchronize` (at an older entity snapshot) and never had their migration
* history recorded. Such databases have `freight.migrations` empty while most
* of the schema already exists, so a from-scratch migration run aborts on the
* first non-idempotent statement and never reaches the columns/tables added
* after synchronize was last used.
*
* The deployment procedure for those databases is:
* 1. Baseline every pre-existing migration into `freight.migrations`.
* 2. Run migrations — this file is the only pending one and back-fills the
* objects the drift scan found missing.
*
* Every statement is idempotent (IF NOT EXISTS / guarded CREATE TYPE), so it is
* also safe on a clean database where the earlier migrations already created
* these objects — it simply no-ops.
*/
export class RepairSynchronizeDrift1870000000000
implements MigrationInterface
{
name = 'RepairSynchronizeDrift1870000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
// --- enum types (derived from entities that never had a source migration) ---
await queryRunner.query(`DO $$ BEGIN
CREATE TYPE freight.consignments_cargo_type_enum AS ENUM (
'CONTAINER', 'BULK_LIQUID', 'BULK_DRY', 'GENERAL', 'REFRIGERATED', 'HAZARDOUS'
);
EXCEPTION WHEN duplicate_object THEN null; END $$;`);
await queryRunner.query(`DO $$ BEGIN
CREATE TYPE freight.consignments_status_enum AS ENUM (
'PENDING', 'LOADED', 'IN_TRANSIT', 'AT_DESTINATION', 'DELIVERED', 'RETURNED'
);
EXCEPTION WHEN duplicate_object THEN null; END $$;`);
await queryRunner.query(`DO $$ BEGIN
CREATE TYPE freight.tracking_events_status_enum AS ENUM (
'PENDING', 'LOADED', 'IN_TRANSIT', 'AT_DESTINATION', 'DELIVERED', 'RETURNED'
);
EXCEPTION WHEN duplicate_object THEN null; END $$;`);
// --- missing tables ---
await queryRunner.query(`CREATE TABLE IF NOT EXISTS freight.consignments (
id uuid NOT NULL DEFAULT gen_random_uuid(),
booking_id uuid NOT NULL,
tracking_number varchar(64) NOT NULL,
cargo_type freight.consignments_cargo_type_enum NOT NULL,
weight_kg numeric(12, 2) NOT NULL,
status freight.consignments_status_enum NOT NULL DEFAULT 'PENDING',
origin_station varchar(128) NOT NULL,
destination_station varchar(128) NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
CONSTRAINT pk_consignments PRIMARY KEY (id),
CONSTRAINT uq_consignments_tracking_number UNIQUE (tracking_number)
);`);
await queryRunner.query(`CREATE TABLE IF NOT EXISTS freight.tracking_events (
id uuid NOT NULL DEFAULT gen_random_uuid(),
consignment_id uuid NOT NULL,
location varchar(256) NOT NULL,
status freight.tracking_events_status_enum NOT NULL,
occurred_at timestamptz NOT NULL,
description text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
CONSTRAINT pk_tracking_events PRIMARY KEY (id)
);`);
await queryRunner.query(`CREATE TABLE IF NOT EXISTS freight.fuel_purchases (
id uuid NOT NULL DEFAULT gen_random_uuid(),
vehicle_id uuid NOT NULL,
purchase_date timestamptz NOT NULL,
liters numeric(10, 2) NOT NULL,
cost_per_liter numeric(10, 2) NOT NULL,
total_cost numeric(14, 2) NOT NULL,
fuel_station varchar(255) NULL,
payment_method varchar(50) DEFAULT 'CASH',
odometer_reading numeric(10, 2) NULL,
driver_id uuid NULL,
receipt_number varchar(255) NULL,
notes text NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz NULL,
CONSTRAINT pk_fuel_purchases PRIMARY KEY (id),
CONSTRAINT fk_fuel_purchases_vehicle FOREIGN KEY (vehicle_id)
REFERENCES freight.vehicles (id) ON DELETE CASCADE
);`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_fuel_purchases_vehicle ON freight.fuel_purchases (vehicle_id);`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_fuel_purchases_date ON freight.fuel_purchases (purchase_date);`);
await queryRunner.query(`CREATE TABLE IF NOT EXISTS freight.fuel_consumption (
id uuid NOT NULL DEFAULT gen_random_uuid(),
vehicle_id uuid NOT NULL,
month date NOT NULL,
total_liters numeric(10, 2) NOT NULL,
total_cost numeric(14, 2) NOT NULL,
total_distance_km numeric(10, 2) NOT NULL,
fuel_efficiency_km_per_l numeric(10, 2) NULL,
number_of_purchases integer DEFAULT 0,
average_cost_per_liter numeric(10, 2) NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz NULL,
CONSTRAINT pk_fuel_consumption PRIMARY KEY (id),
CONSTRAINT fk_fuel_consumption_vehicle FOREIGN KEY (vehicle_id)
REFERENCES freight.vehicles (id) ON DELETE CASCADE,
CONSTRAINT uq_fuel_consumption_vehicle_month UNIQUE (vehicle_id, month)
);`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_fuel_consumption_vehicle_month ON freight.fuel_consumption (vehicle_id, month);`);
await queryRunner.query(`CREATE TABLE IF NOT EXISTS freight.maintenance_schedules (
id uuid NOT NULL DEFAULT gen_random_uuid(),
vehicle_id uuid NOT NULL,
maintenance_type varchar NOT NULL,
description varchar NOT NULL,
scheduled_date timestamptz NOT NULL,
completed_date timestamptz,
estimated_cost numeric(14,2),
actual_cost numeric(14,2),
status varchar NOT NULL DEFAULT 'SCHEDULED',
odometer_reading numeric,
service_provider varchar,
notes text,
next_due_km numeric,
next_due_date timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
PRIMARY KEY (id)
);`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_maintenance_schedules_vehicle_date ON freight.maintenance_schedules (vehicle_id, scheduled_date);`);
await queryRunner.query(`CREATE TABLE IF NOT EXISTS freight.maintenance_costs (
id uuid NOT NULL DEFAULT gen_random_uuid(),
vehicle_id uuid NOT NULL,
maintenance_schedule_id uuid,
incurred_date timestamptz NOT NULL,
cost_amount numeric(14,2) NOT NULL,
cost_type varchar NOT NULL,
description varchar NOT NULL,
service_provider varchar,
invoice_number varchar,
notes text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
PRIMARY KEY (id),
CONSTRAINT fk_maintenance_schedule FOREIGN KEY (maintenance_schedule_id)
REFERENCES freight.maintenance_schedules (id) ON DELETE SET NULL
);`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_maintenance_costs_vehicle_date ON freight.maintenance_costs (vehicle_id, incurred_date);`);
await queryRunner.query(`CREATE TABLE IF NOT EXISTS freight.otp_verifications (
id uuid NOT NULL DEFAULT gen_random_uuid(),
phone varchar NOT NULL,
otp varchar NOT NULL,
verified boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
CONSTRAINT pk_otp_verifications PRIMARY KEY (id),
CONSTRAINT uq_otp_verifications_phone UNIQUE (phone)
);`);
await queryRunner.query(`CREATE TABLE IF NOT EXISTS freight.booking_batch_offers (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
booking_id uuid NOT NULL REFERENCES freight.bookings(id) ON DELETE CASCADE,
train_schedule_id uuid NOT NULL REFERENCES freight.train_schedules(id) ON DELETE CASCADE,
offered_wagons integer NOT NULL,
total_wagons integer NOT NULL,
offered_lines jsonb NULL,
offered_weight_tons numeric(12, 3) NOT NULL,
offered_amount numeric(14, 2) NOT NULL,
offered_pricing_breakdown jsonb NULL,
invoice_id uuid NULL,
payment_deadline timestamptz NOT NULL,
status varchar(10) NOT NULL DEFAULT 'OFFERED',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz NULL
);`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_booking_batch_offers_booking ON freight.booking_batch_offers (booking_id);`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_booking_batch_offers_schedule ON freight.booking_batch_offers (train_schedule_id);`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_booking_batch_offers_status ON freight.booking_batch_offers (status);`);
// --- missing columns on existing tables ---
await queryRunner.query(`ALTER TABLE freight.invoices
ADD COLUMN IF NOT EXISTS subtotal_amount numeric(14, 2) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS tax_amount numeric(14, 2) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS paid_amount numeric(14, 2) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS balance_amount numeric(14, 2) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS paid_at timestamptz;`);
await queryRunner.query(`ALTER TABLE freight.bookings
ADD COLUMN IF NOT EXISTS booking_type varchar(20) NOT NULL DEFAULT 'ONE_TIME',
ADD COLUMN IF NOT EXISTS customer_truck_plate_number varchar(32),
ADD COLUMN IF NOT EXISTS customer_truck_driver_name varchar(120),
ADD COLUMN IF NOT EXISTS customer_truck_type varchar(60),
ADD COLUMN IF NOT EXISTS customer_truck_container_number varchar(16),
ADD COLUMN IF NOT EXISTS customer_truck_assigned_at timestamptz,
ADD COLUMN IF NOT EXISTS customer_truck_arrived_at timestamptz,
ADD COLUMN IF NOT EXISTS bulk_hazardous_quantity numeric(12,3) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS bulk_reefer_quantity numeric(12,3) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS clearance_current_phase varchar(40),
ADD COLUMN IF NOT EXISTS duty_required boolean,
ADD COLUMN IF NOT EXISTS vessel_departure_date date,
ADD COLUMN IF NOT EXISTS ro_amendment_requested_at timestamptz,
ADD COLUMN IF NOT EXISTS ro_hold_reason text,
ADD COLUMN IF NOT EXISTS pre_clearance_finalized_at timestamptz;`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_bookings_booking_type ON freight.bookings (booking_type);`);
await queryRunner.query(`ALTER TABLE freight.cargoes
ADD COLUMN IF NOT EXISTS receiver_name varchar,
ADD COLUMN IF NOT EXISTS delivered_at timestamp,
ADD COLUMN IF NOT EXISTS delivery_remarks text;`);
await queryRunner.query(`ALTER TABLE freight.contract_clearance_cycles
ADD COLUMN IF NOT EXISTS duty_required boolean,
ADD COLUMN IF NOT EXISTS vessel_departure_date date,
ADD COLUMN IF NOT EXISTS ro_amendment_requested_at timestamptz,
ADD COLUMN IF NOT EXISTS ro_hold_reason text,
ADD COLUMN IF NOT EXISTS current_phase varchar(40),
ADD COLUMN IF NOT EXISTS pre_clearance_finalized_at timestamptz;`);
await queryRunner.query(`ALTER TABLE freight.first_mile
ADD COLUMN IF NOT EXISTS paid boolean NOT NULL DEFAULT false;`);
await queryRunner.query(`ALTER TABLE freight.last_mile
ADD COLUMN IF NOT EXISTS paid boolean NOT NULL DEFAULT false;`);
await queryRunner.query(`ALTER TABLE freight.route_milestones
ADD COLUMN IF NOT EXISTS distance_km numeric(10,2);`);
await queryRunner.query(`ALTER TABLE freight.routes
ADD COLUMN IF NOT EXISTS status varchar(32) NOT NULL DEFAULT 'AVAILABLE';`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_routes_status" ON freight.routes (status);`);
await queryRunner.query(`ALTER TABLE freight.train_schedules
ADD COLUMN IF NOT EXISTS window_phase varchar(20) NULL,
ADD COLUMN IF NOT EXISTS window_opens_at timestamptz NULL,
ADD COLUMN IF NOT EXISTS window_closes_at timestamptz NULL,
ADD COLUMN IF NOT EXISTS doc_review_ends_at timestamptz NULL,
ADD COLUMN IF NOT EXISTS doc_review_completed_at timestamptz NULL,
ADD COLUMN IF NOT EXISTS payment_phase_ends_at timestamptz NULL,
ADD COLUMN IF NOT EXISTS booking_cycle_no integer NOT NULL DEFAULT 0;`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_train_schedules_window_phase
ON freight.train_schedules (window_phase) WHERE window_phase IS NOT NULL;`);
await queryRunner.query(`ALTER TABLE freight.train_scheduling_global_rules
ADD COLUMN IF NOT EXISTS import_window_lead_days integer NOT NULL DEFAULT 3,
ADD COLUMN IF NOT EXISTS export_booking_lead_hours integer NOT NULL DEFAULT 24,
ADD COLUMN IF NOT EXISTS window_open_hour integer NOT NULL DEFAULT 8,
ADD COLUMN IF NOT EXISTS window_duration_hours numeric(4, 2) NOT NULL DEFAULT 3,
ADD COLUMN IF NOT EXISTS doc_review_minutes integer NOT NULL DEFAULT 30,
ADD COLUMN IF NOT EXISTS payment_window_minutes integer NOT NULL DEFAULT 60,
ADD COLUMN IF NOT EXISTS reopen_delay_minutes integer NOT NULL DEFAULT 90;`);
}
public async down(): Promise<void> {
// No-op: this migration only repairs drift by additively creating objects
// that other migrations own. Rolling it back would drop objects those
// migrations legitimately created. Revert individual feature migrations
// instead if needed.
}
}

View File

@@ -181,6 +181,26 @@ export function computeExportWindowTimes(
};
}
/**
* Earliest departure a train may be scheduled for — staff cannot schedule inside
* the lead window. IMPORT/DOMESTIC lead is in whole EAT days: with lead 3 and
* today the 11th, the 12th and 13th are blocked and the 14th is the first
* allowed departure day (00:00 EAT). EXPORT lead is in hours: earliest departure
* is `now + exportBookingLeadHours` (24h = 1 day). Mirrors the booking-window
* math so a schedulable date always has a real booking window before it.
*/
export function earliestSchedulableDeparture(
direction: string | null | undefined,
cfg: { importWindowLeadDays: number; exportBookingLeadHours: number },
now: Date,
): Date {
if (direction === 'EXPORT') {
return new Date(now.getTime() + cfg.exportBookingLeadHours * 3_600_000);
}
const earliestDay = shiftEatDay(eatDay(now), cfg.importWindowLeadDays);
return eatDayToUtc(earliestDay, 0);
}
/** Which 3h EAT intake window a timestamp (e.g. fullyExecutedAt) belongs to. */
export function getBatchWindowForTimestamp(date: Date): BatchWindow {
const { year, month, day, hour } = eatParts(date);

View File

@@ -103,6 +103,7 @@ import {
import {
computeExportWindowTimes,
computeImportWindowTimes,
earliestSchedulableDeparture,
eatDay,
} from './batch-window.util';
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
@@ -469,6 +470,23 @@ export class TrainSchedulingService {
// (opens at 08:00 EAT `importWindowLeadDays` before departure); EXPORT opens
// 24h before departure (FCFS). No schedule is ever always-open now.
const windowCfg = await this.getWindowConfig();
// Staff cannot schedule inside the lead window — there must be room for a
// booking window before departure. IMPORT/DOMESTIC lead is in whole EAT
// days (lead 3, today 11th → first allowed departure is the 14th); EXPORT
// lead is in hours (24h = 1 day ahead).
const earliest = earliestSchedulableDeparture(direction, windowCfg, new Date());
if (departure.getTime() < earliest.getTime()) {
const detail =
direction === 'EXPORT'
? `at least ${windowCfg.exportBookingLeadHours} hour(s) ahead`
: `at least ${windowCfg.importWindowLeadDays} day(s) ahead`;
throw new BadRequestException(
`Departure ${departure.toISOString()} is inside the booking lead window; ` +
`${direction === 'EXPORT' ? 'export' : 'import'} trains must be scheduled ${detail} ` +
`(earliest ${earliest.toISOString()})`,
);
}
const windowFields =
direction === 'EXPORT'
? {

View File

@@ -0,0 +1,271 @@
import { Box, Group, Stack, Text } from "@mantine/core";
import {
AlertTriangle,
Check,
CircleDot,
FileEdit,
FilePlus2,
Gavel,
PenLine,
Send,
ShieldCheck,
Truck,
XCircle,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import type { Freight } from "@edr/types";
import { BORDER, GREEN, GREEN_DARK, INK, MUTED } from "./contract-ui";
/**
* The customer-facing contract journey, in order. This is the *contract track*
* (establishing the agreement) — the per-shipment booking/clearance journey is a
* separate stepper (`ClearancePhaseStepper`) shown on a booking, not here.
*/
interface Stage {
key: string;
label: string;
icon: LucideIcon;
}
const STAGES: Stage[] = [
{ key: "draft", label: "Draft", icon: FileEdit },
{ key: "submitted", label: "Submitted", icon: Send },
{ key: "accepted", label: "Accepted", icon: ShieldCheck },
{ key: "approval", label: "Approval", icon: Gavel },
{ key: "sign", label: "Signature", icon: PenLine },
{ key: "active", label: "Active", icon: FilePlus2 },
{ key: "shipping", label: "Shipping", icon: Truck },
];
const STAGE_INDEX: Record<string, number> = STAGES.reduce(
(acc, s, i) => ({ ...acc, [s.key]: i }),
{},
);
type Terminal = "REJECTED" | "CANCELLED" | "EXPIRED" | "CLOSED" | null;
interface StepState {
/** Index into STAGES of the stage the contract is currently working on. */
activeIdx: number;
/** Terminal state, if the contract ended off the happy path. */
terminal: Terminal;
/** One-line "what happens next" helper for the customer. */
next: string;
}
/**
* Map any contract status onto the journey. Statuses that share a stage (e.g.
* every approval/signature sub-state) collapse onto that stage; the helper line
* is what disambiguates them for the customer.
*/
function resolveStep(status: string): StepState {
const at = (key: string): number => STAGE_INDEX[key] ?? 0;
switch (status) {
case "DRAFT":
case "RENEWAL_DRAFT":
return { activeIdx: at("draft"), terminal: null, next: "Finish and submit this contract for review." };
case "SUBMITTED":
case "RENEWAL_SUBMITTED":
return { activeIdx: at("submitted"), terminal: null, next: "Waiting for staff to accept your submission." };
case "PRICE_CHANGED_PENDING_CONFIRM":
return { activeIdx: at("submitted"), terminal: null, next: "Price changed since preview — confirm to resubmit." };
case "CHANGES_REQUESTED":
return { activeIdx: at("submitted"), terminal: null, next: "Staff requested changes — update and resubmit." };
case "AMENDMENTS_PROPOSED":
return { activeIdx: at("submitted"), terminal: null, next: "Amendments proposed — review the proposed changes." };
case "PENDING_APPROVAL":
case "RENEWAL_PENDING_APPROVAL":
return { activeIdx: at("approval"), terminal: null, next: "Under internal approval (staff → director → CEO)." };
case "APPROVED":
return { activeIdx: at("approval"), terminal: null, next: "Approved — the contract document is being prepared." };
case "APPROVED_PENDING_SIGNATURE":
case "CONTRACT_READY":
return { activeIdx: at("sign"), terminal: null, next: "Contract is ready — review and sign it." };
case "SIGNED_CUSTOMER":
return { activeIdx: at("sign"), terminal: null, next: "You've signed — waiting for staff to counter-sign." };
case "CONTRACT_ACTIVE":
case "FULLY_EXECUTED":
case "CLEARANCE_READY_FOR_BOOKING":
return { activeIdx: at("active"), terminal: null, next: "Active — submit a shipment request to start shipping." };
case "AWAITING_CLEARANCE_DOCUMENTS":
return { activeIdx: at("active"), terminal: null, next: "Upload the pre-booking clearance documents." };
case "CLEARANCE_UNDER_REVIEW":
return { activeIdx: at("active"), terminal: null, next: "Global Logistics is reviewing your clearance documents." };
case "ACTIVE_SHIPMENT_IN_PROGRESS":
return { activeIdx: at("shipping"), terminal: null, next: "A shipment is in progress under this contract." };
// ── Terminal ──
case "REJECTED":
return { activeIdx: at("approval"), terminal: "REJECTED", next: "This contract was rejected." };
case "CANCELLED":
return { activeIdx: at("draft"), terminal: "CANCELLED", next: "This contract was cancelled." };
case "EXPIRED":
return { activeIdx: at("active"), terminal: "EXPIRED", next: "This contract's validity has expired." };
case "CONTRACT_CLOSED":
case "ARCHIVED":
return { activeIdx: STAGES.length - 1, terminal: "CLOSED", next: "This contract is closed." };
default:
return { activeIdx: at("draft"), terminal: null, next: "" };
}
}
/** Days until the contract validity lapses, if any (negative = already lapsed). */
function daysUntil(dateIso?: string | null): number | null {
if (!dateIso) return null;
const end = new Date(dateIso).getTime();
if (Number.isNaN(end)) return null;
const ms = end - Date.now();
return Math.ceil(ms / 86_400_000);
}
export interface ContractStepBannerProps {
contract: Freight.IContract;
}
/**
* A polished, branded step banner that shows where a contract sits in its
* lifecycle. Rendered inside the expanded region of a contract row.
*/
export function ContractStepBanner({ contract }: ContractStepBannerProps) {
const { activeIdx, terminal, next } = resolveStep(contract.status);
const isTerminalBad = terminal === "REJECTED" || terminal === "CANCELLED" || terminal === "EXPIRED";
const expiryDays = daysUntil(contract.contractValidUntil);
const expirySoon =
!terminal && expiryDays !== null && expiryDays >= 0 && expiryDays <= 14;
return (
<Box
style={{
borderRadius: 14,
padding: "18px 20px",
border: `1px solid ${isTerminalBad ? "#F3C6C1" : BORDER}`,
background: isTerminalBad
? "linear-gradient(135deg, #FEF3F2 0%, #FFFFFF 60%)"
: `linear-gradient(135deg, ${GREEN}12 0%, ${GREEN}06 26%, #FFFFFF 68%)`,
}}
>
{/* Stepper row */}
<Group gap={0} wrap="nowrap" align="flex-start" style={{ overflowX: "auto" }}>
{STAGES.map((stage, index) => {
const isComplete = !isTerminalBad && index < activeIdx;
const isActive = !isTerminalBad && index === activeIdx;
const isFailedHere = isTerminalBad && index === activeIdx;
const isLast = index === STAGES.length - 1;
const Icon = isFailedHere ? XCircle : stage.icon;
return (
<Box key={stage.key} style={{ flex: isLast ? "0 0 auto" : 1, minWidth: 78 }}>
<Group gap={0} wrap="nowrap" align="center">
<Stack gap={5} align="center" style={{ flexShrink: 0 }}>
<Box
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 36,
height: 36,
borderRadius: "50%",
background: isComplete
? GREEN_DARK
: isFailedHere
? "#D92D20"
: isActive
? "#FFFFFF"
: "#F1F5F8",
border: isActive
? `2px solid ${GREEN}`
: isComplete || isFailedHere
? "2px solid transparent"
: `2px solid ${BORDER}`,
color: isComplete || isFailedHere
? "#FFFFFF"
: isActive
? GREEN_DARK
: "#9AA9B7",
boxShadow: isActive ? `0 0 0 4px ${GREEN}22` : "none",
transition: "all 160ms ease",
}}
>
{isComplete ? (
<Check size={17} strokeWidth={3} />
) : (
<Icon size={17} strokeWidth={isActive ? 2.4 : 2} />
)}
</Box>
<Text
fz={11}
fw={isActive ? 700 : 600}
ta="center"
style={{
whiteSpace: "nowrap",
color: isActive ? GREEN_DARK : isComplete ? INK : MUTED,
}}
>
{stage.label}
</Text>
</Stack>
{!isLast && (
<Box
style={{
flex: 1,
height: 2.5,
marginInline: 6,
marginBottom: 20,
borderRadius: 2,
background: isComplete ? GREEN_DARK : BORDER,
transition: "background 160ms ease",
}}
/>
)}
</Group>
</Box>
);
})}
</Group>
{/* Helper line + expiry hint */}
{(next || expirySoon) && (
<Group gap={16} wrap="wrap" mt={14} align="center">
{next && (
<Group gap={7} wrap="nowrap" align="center">
{isTerminalBad ? (
<XCircle size={15} color="#D92D20" />
) : (
<CircleDot size={15} color={GREEN_DARK} />
)}
<Text fz={12.5} fw={600} style={{ color: isTerminalBad ? "#B42318" : INK }}>
{next}
</Text>
</Group>
)}
{expirySoon && (
<Group gap={6} wrap="nowrap" align="center">
<AlertTriangle size={14} color="#9A6700" />
<Text fz={12} fw={600} c="#9A6700">
{expiryDays === 0
? "Validity ends today"
: `Validity ends in ${expiryDays} day${expiryDays === 1 ? "" : "s"}`}
</Text>
</Group>
)}
</Group>
)}
</Box>
);
}

View File

@@ -1,4 +1,4 @@
import { useMemo, useState } from "react";
import { Fragment, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import {
@@ -17,6 +17,7 @@ import {
} from "@mantine/core";
import {
CheckCircle2,
ChevronDown,
ChevronLeft,
ChevronRight,
FileStack,
@@ -43,6 +44,7 @@ import {
MUTED,
StatCard,
} from "./contract-ui";
import { ContractStepBanner } from "./ContractStepBanner";
function primaryRoute(contract: Freight.IContract) {
const route = contract.routes?.[0];
@@ -61,6 +63,15 @@ export default function ContractsList() {
const [kindFilter, setKindFilter] = useState<string | null>(null);
const [createdFrom, setCreatedFrom] = useState<string>("");
const [createdTo, setCreatedTo] = useState<string>("");
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const toggleExpanded = (id: string) =>
setExpanded((prev) => {
const nextSet = new Set(prev);
if (nextSet.has(id)) nextSet.delete(id);
else nextSet.add(id);
return nextSet;
});
const resetPage = () =>
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
@@ -326,6 +337,7 @@ export default function ContractsList() {
>
<Table.Thead>
<Table.Tr>
<Table.Th style={{ width: 44 }} aria-label="Expand" />
<Table.Th>Contract</Table.Th>
<Table.Th>Cargo</Table.Th>
<Table.Th>Route</Table.Th>
@@ -340,7 +352,7 @@ export default function ContractsList() {
<Table.Tbody>
{isLoading && (
<Table.Tr>
<Table.Td colSpan={9}>
<Table.Td colSpan={10}>
<Center py={48}>
<Loader color="edr-green" size="sm" />
</Center>
@@ -350,7 +362,7 @@ export default function ContractsList() {
{!isLoading && isError && (
<Table.Tr>
<Table.Td colSpan={9}>
<Table.Td colSpan={10}>
<Center py={48}>
<Text fz={13} c="red">
Failed to load contracts. Please try again.
@@ -362,7 +374,7 @@ export default function ContractsList() {
{!isLoading && !isError && rows.length === 0 && (
<Table.Tr>
<Table.Td colSpan={9}>
<Table.Td colSpan={10}>
<Stack align="center" gap={8} py={48}>
<Inbox size={26} color={MUTED} style={{ opacity: 0.5 }} />
<Text fz={13} c="dimmed">
@@ -383,12 +395,48 @@ export default function ContractsList() {
const tradeLabel = dir
? dir.charAt(0) + dir.slice(1).toLowerCase()
: "—";
const isOpen = expanded.has(c.id);
return (
<Fragment key={c.id}>
<Table.Tr
key={c.id}
style={{ cursor: "pointer" }}
style={{
cursor: "pointer",
background: isOpen ? "#F4FBF8" : undefined,
}}
onClick={() => navigate(`/contracts/${c.id}`)}
>
<Table.Td>
<Box
component="button"
aria-label={isOpen ? "Hide progress" : "Show progress"}
aria-expanded={isOpen}
onClick={(e) => {
e.stopPropagation();
toggleExpanded(c.id);
}}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 28,
height: 28,
borderRadius: 8,
border: `1px solid ${BORDER}`,
background: isOpen ? GREEN : "#FFFFFF",
color: isOpen ? "#FFFFFF" : MUTED,
cursor: "pointer",
transition: "all 140ms ease",
}}
>
<ChevronDown
size={16}
style={{
transform: isOpen ? "rotate(180deg)" : "none",
transition: "transform 160ms ease",
}}
/>
</Box>
</Table.Td>
<Table.Td>
<Text fz={14} fw={700} style={{ color: INK }}>
{c.reference}
@@ -483,6 +531,14 @@ export default function ContractsList() {
</Group>
</Table.Td>
</Table.Tr>
{isOpen && (
<Table.Tr style={{ background: "#F4FBF8" }}>
<Table.Td colSpan={10} style={{ padding: "6px 20px 18px" }}>
<ContractStepBanner contract={c} />
</Table.Td>
</Table.Tr>
)}
</Fragment>
);
})}
</Table.Tbody>