diff --git a/apps/edr-freight-api/src/migrations/1870000000000-RepairSynchronizeDrift.ts b/apps/edr-freight-api/src/migrations/1870000000000-RepairSynchronizeDrift.ts new file mode 100644 index 000000000..32f1e6f0c --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1870000000000-RepairSynchronizeDrift.ts @@ -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 { + // --- 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 { + // 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. + } +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts index 6d295c8fd..c60c316ac 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts @@ -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); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 3c4c614f8..0aadc62ab 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -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' ? { diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractStepBanner.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractStepBanner.tsx new file mode 100644 index 000000000..a339c214a --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractStepBanner.tsx @@ -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 = 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 ( + + {/* Stepper row */} + + {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 ( + + + + + {isComplete ? ( + + ) : ( + + )} + + + {stage.label} + + + {!isLast && ( + + )} + + + ); + })} + + + {/* Helper line + expiry hint */} + {(next || expirySoon) && ( + + {next && ( + + {isTerminalBad ? ( + + ) : ( + + )} + + {next} + + + )} + {expirySoon && ( + + + + {expiryDays === 0 + ? "Validity ends today" + : `Validity ends in ${expiryDays} day${expiryDays === 1 ? "" : "s"}`} + + + )} + + )} + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractsList.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractsList.tsx index fbb8ac0ce..41e77625b 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractsList.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractsList.tsx @@ -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(null); const [createdFrom, setCreatedFrom] = useState(""); const [createdTo, setCreatedTo] = useState(""); + const [expanded, setExpanded] = useState>(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() { > + Contract Cargo Route @@ -340,7 +352,7 @@ export default function ContractsList() { {isLoading && ( - +
@@ -350,7 +362,7 @@ export default function ContractsList() { {!isLoading && isError && ( - +
Failed to load contracts. Please try again. @@ -362,7 +374,7 @@ export default function ContractsList() { {!isLoading && !isError && rows.length === 0 && ( - + @@ -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 ( + navigate(`/contracts/${c.id}`)} > + + { + 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", + }} + > + + + {c.reference} @@ -483,6 +531,14 @@ export default function ContractsList() { + {isOpen && ( + + + + + + )} + ); })}