mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
Add migration to repair schema drift and implement contract step banner in UI
This commit is contained in:
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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'
|
||||
? {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user