mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight/feature/first_mile_invoice
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.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Widen train_scheduling_global_rules.window_duration_hours from numeric(4,2)
|
||||
* to numeric(6,4). The UI now lets staff enter the booking-window duration in
|
||||
* minutes / hours / days and converts to the column's native hours unit; a
|
||||
* 4-minute window is 0.0667h, which numeric(4,2) rounds to 0.07 (≈3.96 min).
|
||||
* Four decimals store sub-minute durations exactly (0.0667h → 4.00 min).
|
||||
*/
|
||||
export class WidenWindowDurationHoursPrecision1910000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = "WidenWindowDurationHoursPrecision1910000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_scheduling_global_rules
|
||||
ALTER COLUMN window_duration_hours TYPE numeric(6, 4);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_scheduling_global_rules
|
||||
ALTER COLUMN window_duration_hours TYPE numeric(4, 2);
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Snapshot the booking-window rule onto each train schedule.
|
||||
*
|
||||
* A schedule's window (open time + reopen cycles) must be frozen to the rule it
|
||||
* was created with: a later global-rules edit applies only to FUTURE schedules,
|
||||
* while an already-open schedule keeps its base rule. Previously the batch board
|
||||
* recomputed windows from the LIVE global config, so editing the rule redrew the
|
||||
* board for open schedules (a synthetic grid that no longer matched the window
|
||||
* the customer was shown). These columns give the board a per-schedule rule to
|
||||
* derive its display windows from.
|
||||
*
|
||||
* Existing rows are backfilled from the current global-rules singleton — the best
|
||||
* available base, since they never stored one. Their stamped windowOpensAt/
|
||||
* windowClosesAt are still real, so only projected reopen cycles rely on the
|
||||
* backfill.
|
||||
*/
|
||||
export class AddScheduleWindowRuleSnapshot1920000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = "AddScheduleWindowRuleSnapshot1920000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_schedules
|
||||
ADD COLUMN IF NOT EXISTS rule_window_open_hour integer,
|
||||
ADD COLUMN IF NOT EXISTS rule_window_duration_hours numeric(6, 4),
|
||||
ADD COLUMN IF NOT EXISTS rule_reopen_delay_minutes integer,
|
||||
ADD COLUMN IF NOT EXISTS rule_import_window_lead_days integer,
|
||||
ADD COLUMN IF NOT EXISTS rule_export_booking_lead_hours integer;
|
||||
`);
|
||||
|
||||
// Backfill from the global-rules singleton so pre-existing schedules render.
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.train_schedules ts
|
||||
SET
|
||||
rule_window_open_hour = COALESCE(ts.rule_window_open_hour, r.window_open_hour),
|
||||
rule_window_duration_hours = COALESCE(ts.rule_window_duration_hours, r.window_duration_hours),
|
||||
rule_reopen_delay_minutes = COALESCE(ts.rule_reopen_delay_minutes, r.reopen_delay_minutes),
|
||||
rule_import_window_lead_days = COALESCE(ts.rule_import_window_lead_days, r.import_window_lead_days),
|
||||
rule_export_booking_lead_hours = COALESCE(ts.rule_export_booking_lead_hours, r.export_booking_lead_hours)
|
||||
FROM freight.train_scheduling_global_rules r
|
||||
WHERE ts.rule_window_open_hour IS NULL;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_schedules
|
||||
DROP COLUMN IF EXISTS rule_window_open_hour,
|
||||
DROP COLUMN IF EXISTS rule_window_duration_hours,
|
||||
DROP COLUMN IF EXISTS rule_reopen_delay_minutes,
|
||||
DROP COLUMN IF EXISTS rule_import_window_lead_days,
|
||||
DROP COLUMN IF EXISTS rule_export_booking_lead_hours;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -1052,16 +1052,11 @@ export class BookingTransitionService {
|
||||
// only reserve once both partners are FULLY_EXECUTED (handled inside).
|
||||
const fresh = await this.bookingsService.findById(booking.id);
|
||||
await this.bookingBatchService.acceptExportBooking(fresh);
|
||||
} else if (booking.tradeDirection === "IMPORT") {
|
||||
// Import bookings wait for their booking-day window cycle — the batch runs
|
||||
// after staff document review, never at accept time.
|
||||
} else if (booking.scheduledDate) {
|
||||
this.bookingBatchService.enqueueRouteDayProcessing(
|
||||
booking.originYardId,
|
||||
booking.destinationYardId,
|
||||
eatDay(new Date(booking.scheduledDate)),
|
||||
);
|
||||
}
|
||||
// IMPORT and DOMESTIC bookings wait for their booking-day window cycle — the
|
||||
// batch runs after the window closes + staff document review, never at accept
|
||||
// time. (Legacy pre-migration schedules with no window phase are still served
|
||||
// by the periodic legacy fill.)
|
||||
return this.bookingsService.findById(booking.id);
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ export interface BookingListFilterOptions {
|
||||
serviceTypeId?: string;
|
||||
cargoTypeId?: string;
|
||||
freightType?: string;
|
||||
bookingType?: string;
|
||||
tradeDirection?: string;
|
||||
paymentCurrency?: string;
|
||||
paymentStatus?: string;
|
||||
@@ -89,6 +90,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
.createQueryBuilder('booking')
|
||||
.leftJoinAndSelect('booking.bookingContainers', 'bc')
|
||||
.leftJoinAndSelect('bc.containerType', 'ct')
|
||||
.leftJoinAndSelect('bc.units', 'bcu')
|
||||
.leftJoinAndSelect('booking.company', 'company')
|
||||
// .leftJoinAndSelect('booking.customer', 'customer')
|
||||
.leftJoinAndSelect('booking.train', 'train')
|
||||
@@ -103,6 +105,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
.leftJoinAndSelect('booking.reviewNotes', 'reviewNotes')
|
||||
.leftJoinAndSelect('booking.consolidationPartner', 'consolidationPartner')
|
||||
.where('booking.id = :id', { id })
|
||||
.addOrderBy('bcu.sort_order', 'ASC')
|
||||
.leftJoinAndMapMany(
|
||||
'booking.files',
|
||||
FileRecord,
|
||||
@@ -737,6 +740,11 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
freightType: options.freightType,
|
||||
});
|
||||
}
|
||||
if (options.bookingType) {
|
||||
qb.andWhere('booking.bookingType = :bookingType', {
|
||||
bookingType: options.bookingType,
|
||||
});
|
||||
}
|
||||
if (options.createdFrom) {
|
||||
qb.andWhere('booking.created_at >= :createdFrom', {
|
||||
createdFrom: options.createdFrom,
|
||||
|
||||
@@ -1001,6 +1001,7 @@ export class BookingsService {
|
||||
serviceTypeId: filter.serviceTypeId,
|
||||
cargoTypeId: filter.cargoTypeId,
|
||||
freightType: filter.freightType,
|
||||
bookingType: filter.bookingType,
|
||||
tradeDirection: filter.tradeDirection,
|
||||
paymentCurrency: filter.paymentCurrency,
|
||||
paymentStatus: filter.paymentStatus,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
|
||||
import { ContainerType } from '../../rule-engine/entities/container-type.entity';
|
||||
import { WeightLimitRule } from '../../rule-engine/entities/weight-limit-rule.entity';
|
||||
import { Booking } from './booking.entity';
|
||||
import { BookingContainerUnit } from './booking-container-unit.entity';
|
||||
|
||||
@Entity({ schema: 'freight', name: 'booking_container' })
|
||||
@Index(['bookingId'])
|
||||
@@ -61,4 +62,8 @@ export class BookingContainer extends BaseEntity {
|
||||
|
||||
@Column({ name: 'overweight_excess_tons', type: 'numeric', precision: 10, scale: 3, nullable: true })
|
||||
overweightExcessTons?: number | null;
|
||||
|
||||
/** The physical containers under this line — each with its own number + VGM. */
|
||||
@OneToMany(() => BookingContainerUnit, (u) => u.bookingContainer)
|
||||
units?: BookingContainerUnit[];
|
||||
}
|
||||
|
||||
@@ -157,6 +157,10 @@ export class Booking extends BaseEntity {
|
||||
@Column({ name: 'contract_route_id', type: 'uuid', nullable: true })
|
||||
contractRouteId?: string | null;
|
||||
|
||||
/** Booking origin: ONE_TIME (single-shipment) or GENERAL_CONTRACT (drawdown). */
|
||||
@Column({ name: 'booking_type', type: 'varchar', length: 20, default: 'ONE_TIME' })
|
||||
bookingType!: string;
|
||||
|
||||
/** Denormalized contract kind (ONE_TIME | GENERAL) for the single-active-booking index. */
|
||||
@Column({ name: 'contract_kind', type: 'varchar', length: 20, nullable: true })
|
||||
contractKind?: string | null;
|
||||
|
||||
@@ -334,15 +334,19 @@ export class CompaniesController {
|
||||
@Param("companyId", ParseUUIDPipe) companyId: string,
|
||||
) {
|
||||
const files = await this.filesService.findByResource(companyId, "companies");
|
||||
return files.map((f) => ({
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
code: f.code,
|
||||
mimeType: f.mimeType,
|
||||
size: f.size,
|
||||
uploadedAt: f.createdAt,
|
||||
url: f.url,
|
||||
}));
|
||||
return Promise.all(
|
||||
files.map(async (f) => ({
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
code: f.code,
|
||||
mimeType: f.mimeType,
|
||||
size: f.size,
|
||||
uploadedAt: f.createdAt,
|
||||
// Raw `f.url` is an un-signed MinIO path the browser can't open — sign
|
||||
// it so the file previews/downloads in the client.
|
||||
url: f.url ? await this.filesService.signUrl(f.url) : f.url,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
@Post(":companyId/documents")
|
||||
|
||||
@@ -77,6 +77,9 @@ export interface ContractClearanceView {
|
||||
/** Export post-booking clearance finalized (transit permit uploaded + GL confirmed). */
|
||||
exportClearanceFinalized?: boolean;
|
||||
linkedBookingId?: string | null;
|
||||
/** Reference + status of the GL-created shipment booking, once it exists. */
|
||||
linkedBookingReference?: string | null;
|
||||
linkedBookingStatus?: string | null;
|
||||
dutyAdvice?: {
|
||||
amount: number;
|
||||
currency: string;
|
||||
@@ -284,13 +287,22 @@ export class ContractClearanceService {
|
||||
);
|
||||
|
||||
let nextAction = this.workflowService.computeNextAction(contract, cycle, milestones);
|
||||
if (cycle?.bookingId && contract.tradeDirection === 'EXPORT') {
|
||||
// Once GL creates the shipment booking, surface its reference + status so the
|
||||
// customer sees the concrete booking instead of a stale "will be created
|
||||
// shortly" message. Reuse the export booking load; fetch for import too.
|
||||
let linkedBookingReference: string | null = null;
|
||||
let linkedBookingStatus: string | null = null;
|
||||
if (cycle?.bookingId) {
|
||||
const booking = await this.bookingsService.findById(cycle.bookingId);
|
||||
if (booking) {
|
||||
nextAction = this.workflowService.computeNextActionForBooking(
|
||||
booking,
|
||||
bookingMilestones,
|
||||
);
|
||||
linkedBookingReference = booking.reference ?? null;
|
||||
linkedBookingStatus = booking.status ?? null;
|
||||
if (contract.tradeDirection === 'EXPORT') {
|
||||
nextAction = this.workflowService.computeNextActionForBooking(
|
||||
booking,
|
||||
bookingMilestones,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -326,6 +338,8 @@ export class ContractClearanceService {
|
||||
preClearanceFinalized: Boolean(cycle?.preClearanceFinalizedAt),
|
||||
exportClearanceFinalized: Boolean(cycle?.completedAt),
|
||||
linkedBookingId: cycle?.bookingId ?? null,
|
||||
linkedBookingReference,
|
||||
linkedBookingStatus,
|
||||
dutyAdvice,
|
||||
workflowFiles,
|
||||
t1,
|
||||
|
||||
@@ -135,6 +135,7 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
||||
// Attach the generated contract PDF to each row so list/home can offer a
|
||||
// direct download. Loaded separately to keep pagination counts correct.
|
||||
await this.attachContractFiles(items);
|
||||
await this.attachClearancePhases(items);
|
||||
|
||||
const totalPages = pageSize > 0 ? Math.ceil(total / pageSize) : 0;
|
||||
return {
|
||||
@@ -173,6 +174,30 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach each contract's persisted clearance phase (latest cycle's
|
||||
* current_phase) so list consumers can show step-accurate customer actions
|
||||
* ("Pay duty & upload slip" vs generic "Update clearance") without a
|
||||
* per-contract clearance-view request. One query per page, like
|
||||
* `attachContractFiles`.
|
||||
*/
|
||||
private async attachClearancePhases(contracts: Contract[]): Promise<void> {
|
||||
if (contracts.length === 0) return;
|
||||
const ids = contracts.map((c) => c.id);
|
||||
const rows: Array<{ contract_id: string; current_phase: string | null }> =
|
||||
await this.dataSource.query(
|
||||
`SELECT DISTINCT ON (contract_id) contract_id, current_phase
|
||||
FROM freight.contract_clearance_cycles
|
||||
WHERE contract_id = ANY($1)
|
||||
ORDER BY contract_id, cycle_number DESC`,
|
||||
[ids],
|
||||
);
|
||||
const byContract = new Map(rows.map((r) => [r.contract_id, r.current_phase]));
|
||||
for (const contract of contracts) {
|
||||
contract.clearancePhase = byContract.get(contract.id) ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
async getStatusCounts(): Promise<Record<string, number>> {
|
||||
const rows = await this.repository
|
||||
.createQueryBuilder('contract')
|
||||
|
||||
@@ -254,4 +254,10 @@ export class Contract extends BaseEntity {
|
||||
createForeignKeyConstraints: false,
|
||||
})
|
||||
files?: FileRecord[];
|
||||
|
||||
/**
|
||||
* Latest clearance cycle's current_phase, attached by
|
||||
* ContractsRepository.attachClearancePhases for list responses. Not a column.
|
||||
*/
|
||||
clearancePhase?: string | null;
|
||||
}
|
||||
|
||||
@@ -123,6 +123,16 @@ export class FilesService {
|
||||
return this.filesRepository.findByResource(resourceId, resource);
|
||||
}
|
||||
|
||||
/**
|
||||
* Short-lived signed URL for a stored file's raw MinIO URL. The persisted
|
||||
* `url` is an un-signed object path that a browser cannot fetch directly;
|
||||
* callers that expose files for preview/download must sign them first.
|
||||
*/
|
||||
async signUrl(rawUrl: string, expirySeconds = 300): Promise<string> {
|
||||
const objectName = this.minioService.getObjectNameFromUrl(rawUrl);
|
||||
return this.minioService.getSignedUrl(objectName, expirySeconds);
|
||||
}
|
||||
|
||||
async findByCode(
|
||||
resourceId: string,
|
||||
resource: string,
|
||||
|
||||
@@ -111,6 +111,27 @@ export class TrainSchedule extends BaseEntity {
|
||||
@Column({ name: 'booking_cycle_no', type: 'int', default: 0 })
|
||||
bookingCycleNo!: number;
|
||||
|
||||
// ── Booking-window rule snapshot ──────────────────────────────────────────
|
||||
// The scheduling rule this train was created with, frozen at creation. A later
|
||||
// global-rules edit applies only to FUTURE schedules — an already-open schedule
|
||||
// keeps its base rule. The batch board derives its display windows (open time +
|
||||
// reopen cycles) from THIS snapshot, never from the live global config. NULL on
|
||||
// legacy rows created before the snapshot existed (board falls back to live cfg).
|
||||
@Column({ name: 'rule_window_open_hour', type: 'int', nullable: true })
|
||||
ruleWindowOpenHour?: number | null;
|
||||
|
||||
@Column({ name: 'rule_window_duration_hours', type: 'numeric', precision: 6, scale: 4, nullable: true })
|
||||
ruleWindowDurationHours?: number | null;
|
||||
|
||||
@Column({ name: 'rule_reopen_delay_minutes', type: 'int', nullable: true })
|
||||
ruleReopenDelayMinutes?: number | null;
|
||||
|
||||
@Column({ name: 'rule_import_window_lead_days', type: 'int', nullable: true })
|
||||
ruleImportWindowLeadDays?: number | null;
|
||||
|
||||
@Column({ name: 'rule_export_booking_lead_hours', type: 'int', nullable: true })
|
||||
ruleExportBookingLeadHours?: number | null;
|
||||
|
||||
@OneToMany(() => TrainScheduleBooking, (scheduleBooking) => scheduleBooking.trainSchedule)
|
||||
scheduleBookings?: TrainScheduleBooking[];
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -287,14 +307,22 @@ function boardWindowFromInterval(start: Date, end: Date): BoardWindow {
|
||||
* `computeImportWindowTimes` + `concludeCycle`'s reopen math so the board shows the
|
||||
* exact windows the engine runs.
|
||||
* EXPORT: a single FCFS window from `departure − exportBookingLeadHours` to departure.
|
||||
*
|
||||
* `anchorOpensAt` pins the FIRST window's open time to the schedule's stored
|
||||
* `windowOpensAt` instead of recomputing it from config. Pass it so the board
|
||||
* shows the real frozen window (and reopen cycles projected from it) even after
|
||||
* the global rule changed — the recomputed open time would otherwise drift.
|
||||
*/
|
||||
export function listConfigBookingWindows(
|
||||
direction: string | null | undefined,
|
||||
departure: Date,
|
||||
cfg: BoardWindowConfig,
|
||||
anchorOpensAt?: Date | null,
|
||||
): BoardWindow[] {
|
||||
if (direction === 'EXPORT') {
|
||||
const start = new Date(departure.getTime() - cfg.exportBookingLeadHours * 3_600_000);
|
||||
const start =
|
||||
anchorOpensAt ??
|
||||
new Date(departure.getTime() - cfg.exportBookingLeadHours * 3_600_000);
|
||||
return [boardWindowFromInterval(start, departure)];
|
||||
}
|
||||
|
||||
@@ -303,7 +331,7 @@ export function listConfigBookingWindows(
|
||||
const reopenMs = cfg.reopenDelayMinutes * 60_000;
|
||||
const windowDay = shiftEatDay(eatDay(departure), -cfg.importWindowLeadDays);
|
||||
|
||||
let opensAt = eatDayToUtc(windowDay, cfg.windowOpenHour);
|
||||
let opensAt = anchorOpensAt ?? eatDayToUtc(windowDay, cfg.windowOpenHour);
|
||||
// Reopen stays on the same EAT booking day and before departure; cap at 12 cycles.
|
||||
for (let cycle = 0; cycle < 12; cycle += 1) {
|
||||
if (opensAt.getTime() >= departure.getTime()) break;
|
||||
@@ -355,8 +383,9 @@ export function groupBookingsIntoBoardWindows<T>(
|
||||
departure: Date,
|
||||
cfg: BoardWindowConfig,
|
||||
pendingKey = 'pending-contract',
|
||||
anchorOpensAt?: Date | null,
|
||||
): Map<string, { window: BoardWindow | null; items: T[] }> {
|
||||
const windows = listConfigBookingWindows(direction, departure, cfg);
|
||||
const windows = listConfigBookingWindows(direction, departure, cfg, anchorOpensAt);
|
||||
const map = new Map<string, { window: BoardWindow | null; items: T[] }>();
|
||||
for (const w of windows) {
|
||||
map.set(w.key, { window: w, items: [] });
|
||||
|
||||
@@ -719,11 +719,34 @@ export class BookingBatchService implements OnModuleInit {
|
||||
|
||||
const loco = s.trainSet?.locomotive ?? null;
|
||||
|
||||
// Display windows are the REAL booking-window cycles from the global-rules
|
||||
// config (import: opens at windowOpenHour EAT importWindowLeadDays before
|
||||
// departure, lasts windowDurationHours, reopens per reopenDelayMinutes;
|
||||
// export: single FCFS lead window) — not a fixed clock grid.
|
||||
const windowCfg = await this.trainSchedulingService.getWindowConfig();
|
||||
// Display windows are the REAL booking-window cycles this schedule was FROZEN
|
||||
// with at creation (import: opens at its stored window time, lasts its rule's
|
||||
// duration, reopens per its rule's delay; export: single FCFS lead window) —
|
||||
// NOT the live global config. A later global-rules edit only re-derives
|
||||
// not-yet-open schedules (restampPendingWindows), so an already-open schedule
|
||||
// must keep drawing from its own snapshot, anchored on its stored open time.
|
||||
// Legacy rows with no snapshot fall back to the live config.
|
||||
const liveCfg = await this.trainSchedulingService.getWindowConfig();
|
||||
const num = (v: unknown, fallback: number) => {
|
||||
const n = v == null ? NaN : Number(v);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
};
|
||||
const windowCfg = {
|
||||
windowOpenHour: num(s.ruleWindowOpenHour, liveCfg.windowOpenHour),
|
||||
windowDurationHours: num(
|
||||
s.ruleWindowDurationHours,
|
||||
liveCfg.windowDurationHours,
|
||||
),
|
||||
reopenDelayMinutes: num(s.ruleReopenDelayMinutes, liveCfg.reopenDelayMinutes),
|
||||
importWindowLeadDays: num(
|
||||
s.ruleImportWindowLeadDays,
|
||||
liveCfg.importWindowLeadDays,
|
||||
),
|
||||
exportBookingLeadHours: num(
|
||||
s.ruleExportBookingLeadHours,
|
||||
liveCfg.exportBookingLeadHours,
|
||||
),
|
||||
};
|
||||
const departureDate = s.scheduledDepartureDate ?? new Date();
|
||||
const windowBuckets = groupBookingsIntoBoardWindows(
|
||||
items,
|
||||
@@ -731,6 +754,8 @@ export class BookingBatchService implements OnModuleInit {
|
||||
s.direction ?? null,
|
||||
departureDate,
|
||||
windowCfg,
|
||||
undefined,
|
||||
s.windowOpensAt ?? null,
|
||||
);
|
||||
|
||||
const emptyCounts = () => ({
|
||||
|
||||
@@ -7,6 +7,7 @@ import { TrainScheduleStatus as TrainScheduleStatusEnum } from '@edr/types';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { BookingBatchService } from './booking-batch.service';
|
||||
import { TrainSchedulingService } from './train-scheduling.service';
|
||||
import { BATCH_TIMEZONE } from './booking-batch.constants';
|
||||
@@ -19,12 +20,13 @@ import { type BookingWindowConfig } from './booking-window.config';
|
||||
* schedule row, so every transition is derived purely from the clock — a restart
|
||||
* resumes mid-phase with no loss (onModuleInit runs one tick immediately).
|
||||
*
|
||||
* Import phases: PRE_WINDOW → OPEN (customers book) → DOC_REVIEW (staff accept
|
||||
* documents) → PAYMENT (batch reserves in priority order, customers pay) →
|
||||
* reopen same day | CLOSED_FOR_DAY | DONE (full → auto-finalized).
|
||||
* Import & domestic phases: PRE_WINDOW → OPEN (customers book) → DOC_REVIEW
|
||||
* (staff accept documents) → PAYMENT (batch reserves in priority order, customers
|
||||
* pay) → reopen same day | CLOSED_FOR_DAY | DONE (full → auto-finalized).
|
||||
* Export phases: PRE_WINDOW → OPEN → DONE (no batch, no priority).
|
||||
* Legacy/DOMESTIC schedules have windowPhase NULL and are served by the legacy
|
||||
* fill (runBatchFill), which this tick invokes every 5th minute.
|
||||
* Only PRE-MIGRATION rows have windowPhase NULL; those are served by the legacy
|
||||
* fill (runBatchFill), which this tick invokes every 5th minute. New schedules of
|
||||
* every direction get a window phase.
|
||||
*/
|
||||
@Injectable()
|
||||
export class BookingWindowService implements OnModuleInit {
|
||||
@@ -37,6 +39,7 @@ export class BookingWindowService implements OnModuleInit {
|
||||
private readonly trainSchedulesRepository: TrainSchedulesRepository,
|
||||
private readonly bookingBatchService: BookingBatchService,
|
||||
private readonly trainSchedulingService: TrainSchedulingService,
|
||||
private readonly notifications: NotificationsService,
|
||||
) {}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
@@ -156,6 +159,7 @@ export class BookingWindowService implements OnModuleInit {
|
||||
await this.bookingBatchService.setWindow(schedule.id, 'OPEN');
|
||||
schedule.bookingWindowStatus = 'OPEN';
|
||||
}
|
||||
await this.notifyWindowOpened(schedule);
|
||||
this.logger.log(`Export booking window opened for schedule ${schedule.id}`);
|
||||
return true;
|
||||
}
|
||||
@@ -193,6 +197,8 @@ export class BookingWindowService implements OnModuleInit {
|
||||
await this.bookingBatchService.setWindow(schedule.id, 'OPEN');
|
||||
schedule.bookingWindowStatus = 'OPEN';
|
||||
}
|
||||
// Only announce the first opening of the day; reopen cycles don't re-notify.
|
||||
if (schedule.bookingCycleNo === 1) await this.notifyWindowOpened(schedule);
|
||||
this.logger.log(
|
||||
`Import booking window opened for schedule ${schedule.id} (cycle ${schedule.bookingCycleNo})`,
|
||||
);
|
||||
@@ -325,6 +331,67 @@ export class BookingWindowService implements OnModuleInit {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SMS + email every active-contract customer on this schedule's route when its
|
||||
* booking window opens, so they can book from the portal home before it closes.
|
||||
* Fire-and-forget; a failed notification never blocks the window transition.
|
||||
*/
|
||||
private async notifyWindowOpened(schedule: TrainSchedule): Promise<void> {
|
||||
try {
|
||||
const rows: Array<{ phone: string | null; email: string | null }> =
|
||||
await this.dataSource.query(
|
||||
`SELECT DISTINCT
|
||||
COALESCE(co.contact_person_phone, co.phone) AS phone,
|
||||
COALESCE(co.email, co.general_manager_email) AS email
|
||||
FROM freight.contract_routes cr
|
||||
JOIN freight.contracts c
|
||||
ON c.id = cr.contract_id
|
||||
AND c.status IN ('CONTRACT_ACTIVE', 'FULLY_EXECUTED')
|
||||
AND c.deleted_at IS NULL
|
||||
JOIN freight.companies co ON co.id = c.company_id
|
||||
WHERE cr.origin_yard_id = $1
|
||||
AND cr.destination_yard_id = $2
|
||||
AND cr.deleted_at IS NULL`,
|
||||
[schedule.originStationId, schedule.destinationStationId],
|
||||
);
|
||||
if (!rows.length) return;
|
||||
|
||||
const closes = schedule.windowClosesAt
|
||||
? schedule.windowClosesAt.toLocaleString('en-GB', { timeZone: BATCH_TIMEZONE })
|
||||
: 'later today';
|
||||
const depart = schedule.scheduledDepartureDate.toLocaleDateString('en-GB', {
|
||||
timeZone: BATCH_TIMEZONE,
|
||||
});
|
||||
const msg =
|
||||
`Booking is now open for the train departing ${depart}. ` +
|
||||
`Book your shipment from the portal home page before ${closes} EAT.`;
|
||||
|
||||
const seenPhone = new Set<string>();
|
||||
const seenEmail = new Set<string>();
|
||||
for (const r of rows) {
|
||||
if (r.phone && !seenPhone.has(r.phone)) {
|
||||
seenPhone.add(r.phone);
|
||||
await this.notifications
|
||||
.directSend('sms', r.phone, msg)
|
||||
.catch((e) => this.logger.warn(`Window-open SMS failed: ${(e as Error).message}`));
|
||||
}
|
||||
if (r.email && !seenEmail.has(r.email)) {
|
||||
seenEmail.add(r.email);
|
||||
await this.notifications
|
||||
.directSend('email', r.email, msg)
|
||||
.catch((e) => this.logger.warn(`Window-open email failed: ${(e as Error).message}`));
|
||||
}
|
||||
}
|
||||
this.logger.log(
|
||||
`Notified ${seenPhone.size} phone / ${seenEmail.size} email contacts of open window for schedule ${schedule.id}`,
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`notifyWindowOpened failed for ${schedule.id}: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async setPhase(
|
||||
schedule: TrainSchedule,
|
||||
patch: Partial<
|
||||
|
||||
@@ -60,11 +60,13 @@ export class UpdateTrainSchedulingGlobalRulesDto {
|
||||
@Max(23)
|
||||
windowOpenHour?: number;
|
||||
|
||||
// Stored in hours. The UI enters this in minutes/hours/days and converts to
|
||||
// hours before sending, so the floor is 1 minute (0.0166h) — not 15 min.
|
||||
@ApiPropertyOptional({ example: 3 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0.25)
|
||||
@Min(0.0166)
|
||||
@Max(12)
|
||||
windowDurationHours?: number;
|
||||
|
||||
|
||||
@@ -54,11 +54,13 @@ export class TrainSchedulingGlobalRules extends BaseEntity {
|
||||
@Column({ name: 'window_open_hour', type: 'int', default: 8 })
|
||||
windowOpenHour!: number;
|
||||
|
||||
// Stored in hours; 4 decimals so sub-minute UI durations (4 min = 0.0667h)
|
||||
// are exact. See WidenWindowDurationHoursPrecision migration.
|
||||
@Column({
|
||||
name: 'window_duration_hours',
|
||||
type: 'numeric',
|
||||
precision: 4,
|
||||
scale: 2,
|
||||
precision: 6,
|
||||
scale: 4,
|
||||
default: 3,
|
||||
})
|
||||
windowDurationHours!: number;
|
||||
|
||||
@@ -71,6 +71,17 @@ export class TrainSchedulingController {
|
||||
return this.trainSchedulingService.getBookingWindowsForCompany(companyId);
|
||||
}
|
||||
|
||||
@Get("contracts/:contractId/booking-windows")
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Upcoming/open booking windows on a contract's routes — gates the booking form for customer + Ethiopian GL",
|
||||
})
|
||||
getContractBookingWindows(
|
||||
@Param("contractId", ParseUUIDPipe) contractId: string,
|
||||
) {
|
||||
return this.trainSchedulingService.getBookingWindowsForContract(contractId);
|
||||
}
|
||||
|
||||
@Get("global-rules")
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({ summary: "Get global train scheduling rules (singleton)" })
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
@@ -104,6 +105,7 @@ import {
|
||||
import {
|
||||
computeExportWindowTimes,
|
||||
computeImportWindowTimes,
|
||||
earliestSchedulableDeparture,
|
||||
eatDay,
|
||||
} from './batch-window.util';
|
||||
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
|
||||
@@ -170,8 +172,30 @@ const DEFAULT_TRAIN_LIMITS: Required<TrainLimitConfig> = {
|
||||
max20ftPairWeightDiffTons: 10,
|
||||
};
|
||||
|
||||
/** Raw row shape for the booking-window queries (company- and contract-scoped). */
|
||||
interface BookingWindowRow {
|
||||
schedule_id: string;
|
||||
contract_id: string | null;
|
||||
contract_kind: string | null;
|
||||
direction: string | null;
|
||||
window_phase: string | null;
|
||||
window_opens_at: Date | null;
|
||||
window_closes_at: Date | null;
|
||||
doc_review_ends_at: Date | null;
|
||||
payment_phase_ends_at: Date | null;
|
||||
booking_window_status: string;
|
||||
booking_cycle_no: number;
|
||||
scheduled_departure_date: Date;
|
||||
origin_label: string | null;
|
||||
origin_code: string | null;
|
||||
destination_label: string | null;
|
||||
destination_code: string | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TrainSchedulingService {
|
||||
private readonly logger = new Logger(TrainSchedulingService.name);
|
||||
|
||||
constructor(
|
||||
@InjectDataSource()
|
||||
private readonly dataSource: DataSource,
|
||||
@@ -250,7 +274,76 @@ export class TrainSchedulingService {
|
||||
if (dto.docReviewMinutes != null) row.docReviewMinutes = dto.docReviewMinutes;
|
||||
if (dto.paymentWindowMinutes != null) row.paymentWindowMinutes = dto.paymentWindowMinutes;
|
||||
if (dto.reopenDelayMinutes != null) row.reopenDelayMinutes = dto.reopenDelayMinutes;
|
||||
return this.dataSource.getRepository(TrainSchedulingGlobalRules).save(row);
|
||||
|
||||
// Fields that change the STAMPED open/close times of a schedule. docReview/
|
||||
// payment/reopen are read live by the cron each tick, so they need no
|
||||
// re-stamp; only the four below feed computeImport/ExportWindowTimes.
|
||||
const windowTimingChanged =
|
||||
dto.importWindowLeadDays != null ||
|
||||
dto.windowOpenHour != null ||
|
||||
dto.windowDurationHours != null ||
|
||||
dto.exportBookingLeadHours != null;
|
||||
|
||||
const saved = await this.dataSource
|
||||
.getRepository(TrainSchedulingGlobalRules)
|
||||
.save(row);
|
||||
|
||||
// The cron reads config fresh every tick, so derived timings (doc review,
|
||||
// payment, reopen) take effect on the next tick with no restart. But each
|
||||
// schedule's initial open/close times were FROZEN at creation — re-stamp the
|
||||
// ones whose window has not opened yet so a config edit applies to them too.
|
||||
if (windowTimingChanged) {
|
||||
await this.restampPendingWindows();
|
||||
}
|
||||
|
||||
return saved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-derive windowOpensAt/windowClosesAt for schedules whose booking window has
|
||||
* not opened yet (windowPhase === 'PRE_WINDOW', still Draft/Scheduled, departure
|
||||
* in the future) using the CURRENT global-rules config. Schedules already OPEN or
|
||||
* past their window are left untouched — customers may have booked against the
|
||||
* times they were shown, so those stay frozen. Returns the count re-stamped.
|
||||
*/
|
||||
async restampPendingWindows(): Promise<number> {
|
||||
const cfg = await this.getWindowConfig();
|
||||
const now = new Date();
|
||||
const schedules = await this.trainSchedulesRepository.findAll({
|
||||
where: [
|
||||
{ status: TrainScheduleStatusEnum.Draft, windowPhase: 'PRE_WINDOW' },
|
||||
{ status: TrainScheduleStatusEnum.Scheduled, windowPhase: 'PRE_WINDOW' },
|
||||
],
|
||||
});
|
||||
|
||||
const repo = this.dataSource.getRepository(TrainSchedule);
|
||||
let restamped = 0;
|
||||
for (const s of schedules) {
|
||||
if (!s.scheduledDepartureDate || s.scheduledDepartureDate <= now) continue;
|
||||
const times =
|
||||
s.direction === 'EXPORT'
|
||||
? computeExportWindowTimes(s.scheduledDepartureDate, cfg)
|
||||
: computeImportWindowTimes(s.scheduledDepartureDate, cfg, now);
|
||||
// A not-yet-open schedule legitimately adopts the new rule, so refresh its
|
||||
// snapshot alongside the re-stamped times — the board then draws the new
|
||||
// window from this same rule.
|
||||
await repo.update(s.id, {
|
||||
windowOpensAt: times.windowOpensAt,
|
||||
windowClosesAt: times.windowClosesAt,
|
||||
ruleWindowOpenHour: cfg.windowOpenHour,
|
||||
ruleWindowDurationHours: cfg.windowDurationHours,
|
||||
ruleReopenDelayMinutes: cfg.reopenDelayMinutes,
|
||||
ruleImportWindowLeadDays: cfg.importWindowLeadDays,
|
||||
ruleExportBookingLeadHours: cfg.exportBookingLeadHours,
|
||||
});
|
||||
restamped += 1;
|
||||
}
|
||||
if (restamped > 0) {
|
||||
this.logger.log(
|
||||
`Re-stamped booking windows for ${restamped} pending schedule(s) after a global-rules change`,
|
||||
);
|
||||
}
|
||||
return restamped;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -385,24 +478,54 @@ export class TrainSchedulingService {
|
||||
// Effective capacity is capped by the weakest locomotive in the set.
|
||||
const limitLoco = minLocomotiveLimits(lockedLocomotives) ?? undefined;
|
||||
const departure = new Date(dto.scheduleDate);
|
||||
// IMPORT/EXPORT trains start with a CLOSED customer window; the window engine
|
||||
// opens it on schedule (import: booking day at 08:00 EAT; export: 24h lead).
|
||||
// DOMESTIC keeps the legacy always-OPEN behavior (windowPhase stays NULL).
|
||||
// Every schedule starts with a CLOSED customer window; the window engine opens
|
||||
// it on schedule. DOMESTIC runs the same one-booking-day cycle as IMPORT
|
||||
// (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()})`,
|
||||
);
|
||||
}
|
||||
// Freeze the rule this schedule is born with. A later global-rules edit
|
||||
// only re-derives NOT-YET-OPEN schedules (see restampPendingWindows); an
|
||||
// already-open schedule keeps this snapshot, and the batch board draws its
|
||||
// windows from it rather than the live config.
|
||||
const ruleSnapshot = {
|
||||
ruleWindowOpenHour: windowCfg.windowOpenHour,
|
||||
ruleWindowDurationHours: windowCfg.windowDurationHours,
|
||||
ruleReopenDelayMinutes: windowCfg.reopenDelayMinutes,
|
||||
ruleImportWindowLeadDays: windowCfg.importWindowLeadDays,
|
||||
ruleExportBookingLeadHours: windowCfg.exportBookingLeadHours,
|
||||
};
|
||||
const windowFields =
|
||||
direction === 'IMPORT'
|
||||
direction === 'EXPORT'
|
||||
? {
|
||||
bookingWindowStatus: 'CLOSED',
|
||||
windowPhase: 'PRE_WINDOW',
|
||||
...computeImportWindowTimes(departure, windowCfg, new Date()),
|
||||
...ruleSnapshot,
|
||||
...computeExportWindowTimes(departure, windowCfg),
|
||||
}
|
||||
: direction === 'EXPORT'
|
||||
? {
|
||||
bookingWindowStatus: 'CLOSED',
|
||||
windowPhase: 'PRE_WINDOW',
|
||||
...computeExportWindowTimes(departure, windowCfg),
|
||||
}
|
||||
: {};
|
||||
: {
|
||||
// IMPORT and DOMESTIC share the import booking-day window cycle.
|
||||
bookingWindowStatus: 'CLOSED',
|
||||
windowPhase: 'PRE_WINDOW',
|
||||
...ruleSnapshot,
|
||||
...computeImportWindowTimes(departure, windowCfg, new Date()),
|
||||
};
|
||||
const schedule = manager.getRepository(TrainSchedule).create({
|
||||
trainSetId: trainSet.id,
|
||||
routeId: route.id,
|
||||
@@ -3004,25 +3127,16 @@ export class TrainSchedulingService {
|
||||
* always open and need no announcement.
|
||||
*/
|
||||
async getBookingWindowsForCompany(companyId: string) {
|
||||
const rows: Array<{
|
||||
schedule_id: string;
|
||||
direction: string | null;
|
||||
window_phase: string | null;
|
||||
window_opens_at: Date | null;
|
||||
window_closes_at: Date | null;
|
||||
booking_window_status: string;
|
||||
booking_cycle_no: number;
|
||||
scheduled_departure_date: Date;
|
||||
origin_label: string | null;
|
||||
origin_code: string | null;
|
||||
destination_label: string | null;
|
||||
destination_code: string | null;
|
||||
}> = await this.dataSource.query(
|
||||
const rows: Array<BookingWindowRow> = await this.dataSource.query(
|
||||
`SELECT DISTINCT ts.id AS schedule_id,
|
||||
cr.contract_id AS contract_id,
|
||||
c.contract_kind AS contract_kind,
|
||||
ts.direction,
|
||||
ts.window_phase,
|
||||
ts.window_opens_at,
|
||||
ts.window_closes_at,
|
||||
ts.doc_review_ends_at,
|
||||
ts.payment_phase_ends_at,
|
||||
ts.booking_window_status,
|
||||
ts.booking_cycle_no,
|
||||
ts.scheduled_departure_date,
|
||||
@@ -3048,19 +3162,70 @@ export class TrainSchedulingService {
|
||||
ORDER BY ts.window_opens_at ASC NULLS LAST`,
|
||||
[companyId],
|
||||
);
|
||||
return rows.map((r) => ({
|
||||
return rows.map((r) => this.mapBookingWindowRow(r));
|
||||
}
|
||||
|
||||
/**
|
||||
* Upcoming/open booking windows on a single contract's routes. Used to gate the
|
||||
* booking form for the customer AND Ethiopian GL (who books on the customer's
|
||||
* behalf): no window row with isOpenNow=true → booking entry is hidden.
|
||||
*/
|
||||
async getBookingWindowsForContract(contractId: string) {
|
||||
const rows: Array<BookingWindowRow> = await this.dataSource.query(
|
||||
`SELECT DISTINCT ts.id AS schedule_id,
|
||||
cr.contract_id AS contract_id,
|
||||
c.contract_kind AS contract_kind,
|
||||
ts.direction,
|
||||
ts.window_phase,
|
||||
ts.window_opens_at,
|
||||
ts.window_closes_at,
|
||||
ts.doc_review_ends_at,
|
||||
ts.payment_phase_ends_at,
|
||||
ts.booking_window_status,
|
||||
ts.booking_cycle_no,
|
||||
ts.scheduled_departure_date,
|
||||
oy.label AS origin_label, oy.code AS origin_code,
|
||||
dy.label AS destination_label, dy.code AS destination_code
|
||||
FROM freight.train_schedules ts
|
||||
JOIN freight.contract_routes cr
|
||||
ON cr.origin_yard_id = ts.origin_station_id
|
||||
AND cr.destination_yard_id = ts.destination_station_id
|
||||
AND cr.contract_id = $1
|
||||
AND cr.deleted_at IS NULL
|
||||
JOIN freight.contracts c
|
||||
ON c.id = cr.contract_id
|
||||
AND c.deleted_at IS NULL
|
||||
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
|
||||
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
|
||||
WHERE ts.deleted_at IS NULL
|
||||
AND ts.status IN ('DRAFT', 'SCHEDULED')
|
||||
AND ts.window_phase IS NOT NULL
|
||||
AND ts.window_phase NOT IN ('DONE', 'CLOSED_FOR_DAY')
|
||||
AND ts.scheduled_departure_date >= now()
|
||||
ORDER BY ts.window_opens_at ASC NULLS LAST`,
|
||||
[contractId],
|
||||
);
|
||||
return rows.map((r) => this.mapBookingWindowRow(r));
|
||||
}
|
||||
|
||||
private mapBookingWindowRow(r: BookingWindowRow) {
|
||||
return {
|
||||
scheduleId: r.schedule_id,
|
||||
contractId: r.contract_id,
|
||||
contractKind: r.contract_kind,
|
||||
direction: r.direction,
|
||||
windowPhase: r.window_phase,
|
||||
isOpenNow: r.window_phase === 'OPEN' && r.booking_window_status === 'OPEN',
|
||||
windowOpensAt: r.window_opens_at,
|
||||
windowClosesAt: r.window_closes_at,
|
||||
docReviewEndsAt: r.doc_review_ends_at,
|
||||
paymentPhaseEndsAt: r.payment_phase_ends_at,
|
||||
bookingWindowStatus: r.booking_window_status,
|
||||
bookingCycleNo: r.booking_cycle_no,
|
||||
departureDate: r.scheduled_departure_date,
|
||||
origin: r.origin_label ?? r.origin_code ?? null,
|
||||
destination: r.destination_label ?? r.destination_code ?? null,
|
||||
}));
|
||||
};
|
||||
}
|
||||
|
||||
/** OPEN schedules a new booking may target (with rough remaining capacity).
|
||||
@@ -3342,6 +3507,21 @@ export class TrainSchedulingService {
|
||||
freightType: this.resolveScheduleFreightType(schedule),
|
||||
trainNumber: schedule.trainNumber ?? null,
|
||||
direction: schedule.direction ?? null,
|
||||
// Booking-window phase + phase deadlines drive the countdown timers in the
|
||||
// operations workspace (display only — the window engine enforces them).
|
||||
windowPhase: schedule.windowPhase ?? null,
|
||||
windowOpensAt: schedule.windowOpensAt
|
||||
? schedule.windowOpensAt.toISOString()
|
||||
: null,
|
||||
windowClosesAt: schedule.windowClosesAt
|
||||
? schedule.windowClosesAt.toISOString()
|
||||
: null,
|
||||
docReviewEndsAt: schedule.docReviewEndsAt
|
||||
? schedule.docReviewEndsAt.toISOString()
|
||||
: null,
|
||||
paymentPhaseEndsAt: schedule.paymentPhaseEndsAt
|
||||
? schedule.paymentPhaseEndsAt.toISOString()
|
||||
: null,
|
||||
route: schedule.route
|
||||
? { id: schedule.route.id, name: formatRouteLabel(schedule.route) }
|
||||
: null,
|
||||
|
||||
@@ -52,8 +52,9 @@ import ContractClearanceListPage from "./pages/contracts/ContractClearanceListPa
|
||||
import ContractClearanceDetailPage from "./pages/contracts/ContractClearanceDetailPage";
|
||||
import GlDjiboutiClearanceListPage from "./pages/contracts/GlDjiboutiClearanceListPage";
|
||||
import GlClearanceDetailPage from "./pages/contracts/GlClearanceDetailPage";
|
||||
import ShipmentRequestsPage from "./pages/contracts/ShipmentRequestsPage";
|
||||
import ShipmentRequestDetailPage from "./pages/contracts/ShipmentRequestDetailPage";
|
||||
// Hidden for now — Shipment Requests pages disabled (imports kept commented).
|
||||
// import ShipmentRequestsPage from "./pages/contracts/ShipmentRequestsPage";
|
||||
// import ShipmentRequestDetailPage from "./pages/contracts/ShipmentRequestDetailPage";
|
||||
import GlCreateBookingForm from "./components/contracts/GlCreateBookingForm";
|
||||
import DocumentClearanceDetailPage from "./pages/bookings/DocumentClearanceDetailPage";
|
||||
import CustomerDetailPage from "./pages/customers/CustomerDetailPage";
|
||||
@@ -181,12 +182,13 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
FREIGHT_PERMS.contracts.clearanceEtActions,
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Shipment Requests",
|
||||
href: "/dashboard/shipment-requests",
|
||||
icon: <Send />,
|
||||
permission: FREIGHT_PERMS.contracts.createBooking,
|
||||
},
|
||||
// Hidden for now — Shipment Requests nav item disabled.
|
||||
// {
|
||||
// label: "Shipment Requests",
|
||||
// href: "/dashboard/shipment-requests",
|
||||
// icon: <Send />,
|
||||
// permission: FREIGHT_PERMS.contracts.createBooking,
|
||||
// },
|
||||
{
|
||||
label: "GL Djibouti Clearance",
|
||||
href: "/dashboard/gl-djibouti/clearance",
|
||||
@@ -677,6 +679,7 @@ const App = () => {
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
{/* Hidden for now — Shipment Requests pages disabled.
|
||||
<Route
|
||||
path="shipment-requests"
|
||||
element={
|
||||
@@ -697,6 +700,7 @@ const App = () => {
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
*/}
|
||||
{/* GL (Path B) contract clearance review hub */}
|
||||
<Route
|
||||
path="contracts/clearance"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Download, Zap, FileText, Clock } from "lucide-react";
|
||||
import { Stack, Text, Button } from "@mantine/core";
|
||||
import { Zap, Clock } from "lucide-react";
|
||||
import { Stack, Text } from "@mantine/core";
|
||||
|
||||
import type { BookingDetail } from "@/types/booking";
|
||||
import { BookingActionsMenu } from "./BookingActionsMenu";
|
||||
@@ -14,21 +14,11 @@ interface BookingActionsToolbarProps {
|
||||
mutations: Mutations;
|
||||
}
|
||||
|
||||
/** Detail-page actions: primary toolbar + downloads. */
|
||||
export function BookingActionsToolbar({ booking, mutations }: BookingActionsToolbarProps) {
|
||||
/** Detail-page actions: primary staff-action toolbar. */
|
||||
export function BookingActionsToolbar({ booking }: BookingActionsToolbarProps) {
|
||||
const row = toBookingListRow(booking);
|
||||
const { status } = booking;
|
||||
|
||||
const downloadBlob = async (fn: () => Promise<Blob>, filename: string) => {
|
||||
const blob = await fn();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
if (status === "REJECTED" || status === "CANCELLED" || status === "COMPLETED") {
|
||||
return null;
|
||||
}
|
||||
@@ -101,23 +91,6 @@ export function BookingActionsToolbar({ booking, mutations }: BookingActionsTool
|
||||
<BookingActionsMenu row={row} variant="toolbar" />
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
|
||||
{status === "CONTRACT_READY" && (
|
||||
<SectionCard icon={FileText} title="Documents">
|
||||
<Button
|
||||
variant="default"
|
||||
leftSection={<Download size={16} />}
|
||||
onClick={() =>
|
||||
downloadBlob(
|
||||
() => mutations.downloadContract(),
|
||||
`contract-${booking.reference}.txt`,
|
||||
)
|
||||
}
|
||||
>
|
||||
Download contract
|
||||
</Button>
|
||||
</SectionCard>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import { useMemo } from "react";
|
||||
import { Boxes, Container as ContainerIcon, Snowflake, Flame } from "lucide-react";
|
||||
import { Badge, Box, Group, Stack, Table, Text, ThemeIcon } from "@mantine/core";
|
||||
|
||||
import type { BookingDetail } from "@/types/booking";
|
||||
import { SectionCard } from "./SectionCard";
|
||||
|
||||
export interface BookingContainerUnitsCardProps {
|
||||
booking: BookingDetail;
|
||||
}
|
||||
|
||||
interface FlatUnit {
|
||||
id: string;
|
||||
containerNumber: string;
|
||||
sealNumber?: string | null;
|
||||
vgmTons: number;
|
||||
isHazardous?: boolean;
|
||||
isReefer?: boolean;
|
||||
typeLabel: string;
|
||||
sizeFt?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The physical container manifest: one row per container with its number, type,
|
||||
* seal, and weight (VGM). Per-unit numbers are only captured for contract-drawdown
|
||||
* bookings — when a line has no units the card falls back to the aggregate
|
||||
* type/qty/weight so it still renders something for plain bookings.
|
||||
*/
|
||||
export function BookingContainerUnitsCard({ booking }: BookingContainerUnitsCardProps) {
|
||||
const lines = booking.bookingContainers ?? [];
|
||||
|
||||
const units: FlatUnit[] = useMemo(
|
||||
() =>
|
||||
lines.flatMap((line) =>
|
||||
(line.units ?? []).map((u) => ({
|
||||
id: u.id,
|
||||
containerNumber: u.containerNumber,
|
||||
sealNumber: u.sealNumber,
|
||||
vgmTons: Number(u.vgmTons) || 0,
|
||||
isHazardous: u.isHazardous,
|
||||
isReefer: u.isReefer,
|
||||
typeLabel: line.containerType?.label ?? line.containerType?.code ?? "—",
|
||||
sizeFt: line.containerType?.sizeFt,
|
||||
})),
|
||||
),
|
||||
[lines],
|
||||
);
|
||||
|
||||
// Container bookings only — bulk has no container manifest.
|
||||
if (booking.freightType === "BULK" || lines.length === 0) return null;
|
||||
|
||||
const totalUnits = units.length;
|
||||
const totalVgm = units.reduce((sum, u) => sum + u.vgmTons, 0);
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
icon={Boxes}
|
||||
title="Containers"
|
||||
subtitle={
|
||||
totalUnits > 0
|
||||
? "Each physical container with its number and weight"
|
||||
: "Per-container numbers were not captured for this booking"
|
||||
}
|
||||
accent="teal"
|
||||
extra={
|
||||
totalUnits > 0 ? (
|
||||
<Badge color="teal" variant="light" radius="sm">
|
||||
{totalUnits} container{totalUnits === 1 ? "" : "s"}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge color="gray" variant="light" radius="sm">
|
||||
{lines.length} line{lines.length === 1 ? "" : "s"}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
>
|
||||
{totalUnits > 0 ? (
|
||||
<Stack gap="md">
|
||||
<Box style={{ overflowX: "auto" }}>
|
||||
<Table verticalSpacing="sm" horizontalSpacing="md" highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th style={{ width: 40 }}>#</Table.Th>
|
||||
<Table.Th>Container No.</Table.Th>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Seal</Table.Th>
|
||||
<Table.Th ta="right">Weight (VGM)</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{units.map((u, i) => (
|
||||
<Table.Tr key={u.id}>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed">
|
||||
{i + 1}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap={8} wrap="nowrap" align="center">
|
||||
<ThemeIcon size={26} radius="md" variant="light" color="teal">
|
||||
<ContainerIcon size={15} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" fw={700} ff="monospace">
|
||||
{u.containerNumber}
|
||||
</Text>
|
||||
{u.isReefer ? (
|
||||
<ThemeIcon size={20} radius="sm" variant="light" color="blue" title="Reefer">
|
||||
<Snowflake size={12} />
|
||||
</ThemeIcon>
|
||||
) : null}
|
||||
{u.isHazardous ? (
|
||||
<ThemeIcon size={20} radius="sm" variant="light" color="red" title="Hazardous">
|
||||
<Flame size={12} />
|
||||
</ThemeIcon>
|
||||
) : null}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text size="sm">{u.typeLabel}</Text>
|
||||
{u.sizeFt ? (
|
||||
<Badge color="gray" variant="light" radius="sm" size="sm">
|
||||
{u.sizeFt}FT
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c={u.sealNumber ? undefined : "dimmed"}>
|
||||
{u.sealNumber || "—"}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<Text size="sm" fw={700}>
|
||||
{u.vgmTons.toFixed(3)} t
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
|
||||
<Group
|
||||
justify="space-between"
|
||||
pt="sm"
|
||||
style={{ borderTop: "1px solid var(--mantine-color-gray-2)" }}
|
||||
>
|
||||
<Text size="sm" fw={600} c="dimmed">
|
||||
Total weight (VGM)
|
||||
</Text>
|
||||
<Text size="sm" fw={800} c="teal.7">
|
||||
{totalVgm.toFixed(3)} t
|
||||
</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
) : (
|
||||
// Fallback: no per-unit numbers — show the aggregate lines.
|
||||
<Box style={{ overflowX: "auto" }}>
|
||||
<Table verticalSpacing="sm" horizontalSpacing="md" highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Qty</Table.Th>
|
||||
<Table.Th>VGM / unit</Table.Th>
|
||||
<Table.Th ta="right">Total VGM</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{lines.map((line) => {
|
||||
const perUnit = Number(line.vgmPerUnitTons) || 0;
|
||||
return (
|
||||
<Table.Tr key={line.id}>
|
||||
<Table.Td>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text size="sm" fw={600}>
|
||||
{line.containerType?.label ?? line.containerType?.code ?? "—"}
|
||||
</Text>
|
||||
{line.containerType?.sizeFt ? (
|
||||
<Badge color="gray" variant="light" radius="sm" size="sm">
|
||||
{line.containerType.sizeFt}FT
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>{line.quantity}</Table.Td>
|
||||
<Table.Td>{perUnit.toFixed(3)} t</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<Text size="sm" fw={700}>
|
||||
{(line.quantity * perUnit).toFixed(3)} t
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
)}
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ export * from "./BookingDetailHeader";
|
||||
export * from "./BookingLifecycleStepper";
|
||||
export * from "./BookingRouteCard";
|
||||
export * from "./BookingContainersCard";
|
||||
export * from "./BookingContainerUnitsCard";
|
||||
export * from "./BookingApprovalCard";
|
||||
export * from "./BookingReviewNotesCard";
|
||||
export * from "./BookingPaymentCard";
|
||||
|
||||
@@ -58,6 +58,25 @@ import {
|
||||
StepLabel,
|
||||
} from "./gl-booking-form/form-ui";
|
||||
|
||||
/** All booking-window times are communicated in East Africa Time. */
|
||||
const EAT_TZ = "Africa/Addis_Ababa";
|
||||
|
||||
function fmtWindowOpensAt(iso: string): string {
|
||||
const date = new Date(iso).toLocaleDateString("en-GB", {
|
||||
weekday: "short",
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
timeZone: EAT_TZ,
|
||||
});
|
||||
const time = new Date(iso).toLocaleTimeString("en-GB", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
timeZone: EAT_TZ,
|
||||
});
|
||||
return `${date} · ${time}`;
|
||||
}
|
||||
|
||||
interface UnitDraft {
|
||||
containerNumber: string;
|
||||
sealNumber: string;
|
||||
@@ -106,6 +125,33 @@ export default function GlCreateBookingForm() {
|
||||
enabled: Boolean(requestId),
|
||||
});
|
||||
|
||||
// Same window-gating the customer sees: GL may only create a booking while a
|
||||
// booking window is OPEN for one of the contract's routes.
|
||||
const contractId = contract?.id ?? id;
|
||||
const { data: bookingWindows, isLoading: windowsLoading } = useQuery({
|
||||
...api.trainScheduling.contractBookingWindows.queryOptions({
|
||||
input: { contractId: contractId ?? "" },
|
||||
}),
|
||||
enabled: Boolean(contractId),
|
||||
});
|
||||
|
||||
const windowOpen = useMemo(
|
||||
() => (bookingWindows ?? []).some((w) => w.isOpenNow),
|
||||
[bookingWindows],
|
||||
);
|
||||
|
||||
// Soonest future window across all routes, used for the "next window" notice.
|
||||
const nextWindow = useMemo(() => {
|
||||
const now = Date.now();
|
||||
return (bookingWindows ?? [])
|
||||
.filter((w) => w.windowOpensAt && new Date(w.windowOpensAt).getTime() > now)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(a.windowOpensAt!).getTime() -
|
||||
new Date(b.windowOpensAt!).getTime(),
|
||||
)[0];
|
||||
}, [bookingWindows]);
|
||||
|
||||
const [scheduledDate, setScheduledDate] = useState("");
|
||||
const [contractRouteId, setContractRouteId] = useState<string | null>(null);
|
||||
const [notes, setNotes] = useState("");
|
||||
@@ -314,12 +360,13 @@ export default function GlCreateBookingForm() {
|
||||
);
|
||||
|
||||
const canSubmit =
|
||||
windowOpen &&
|
||||
Boolean(scheduledDate) &&
|
||||
(!needsRouteSelect || Boolean(contractRouteId)) &&
|
||||
(isContainer ? containerLines.some((l) => l.units.length > 0) : bulkLines.length > 0);
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!scheduledDate || !contract) return;
|
||||
if (!scheduledDate || !contract || !windowOpen) return;
|
||||
|
||||
const payload: Freight.CreateBookingUnderContractDto = {
|
||||
scheduledDate,
|
||||
@@ -451,6 +498,33 @@ export default function GlCreateBookingForm() {
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{!windowsLoading && !windowOpen ? (
|
||||
<Alert
|
||||
color="orange"
|
||||
variant="light"
|
||||
radius="md"
|
||||
icon={<AlertCircle size={16} />}
|
||||
title="Booking window is closed"
|
||||
mb="lg"
|
||||
>
|
||||
GL can create a booking only while a window is open.{" "}
|
||||
{nextWindow?.windowOpensAt ? (
|
||||
<>
|
||||
Next window: <b>{fmtWindowOpensAt(nextWindow.windowOpensAt)} EAT</b>{" "}
|
||||
for{" "}
|
||||
<b>
|
||||
{nextWindow.origin ?? "Origin"} → {nextWindow.destination ?? "Destination"}
|
||||
</b>
|
||||
.
|
||||
</>
|
||||
) : (
|
||||
<>No upcoming booking window scheduled.</>
|
||||
)}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{windowsLoading || windowOpen ? (
|
||||
<>
|
||||
<Stack gap="lg" maw={896} mx="auto">
|
||||
<StepCard>
|
||||
<StepHeader
|
||||
@@ -846,6 +920,8 @@ export default function GlCreateBookingForm() {
|
||||
</Stack>
|
||||
) : null}
|
||||
</Modal>
|
||||
</>
|
||||
) : null}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -208,6 +208,10 @@ function codeLabel(code?: string | null): string | null {
|
||||
|
||||
export interface ContractDocumentsCardProps {
|
||||
files: ContractFile[];
|
||||
/** Card heading. Defaults to "Documents". */
|
||||
title?: string;
|
||||
/** Message shown when there are no files. */
|
||||
emptyText?: string;
|
||||
/** Open the file inline in a viewer modal. */
|
||||
onView?: (file: ContractFile) => void;
|
||||
/** Download the file to disk. */
|
||||
@@ -217,13 +221,15 @@ export interface ContractDocumentsCardProps {
|
||||
/** Rich list of the contract's attached documents: type, size, view + download. */
|
||||
export function ContractDocumentsCard({
|
||||
files,
|
||||
title = "Documents",
|
||||
emptyText = "No documents attached to this contract.",
|
||||
onView,
|
||||
onDownload,
|
||||
}: ContractDocumentsCardProps) {
|
||||
return (
|
||||
<SectionCard
|
||||
icon={FileText}
|
||||
title="Documents"
|
||||
title={title}
|
||||
accent="indigo"
|
||||
extra={
|
||||
<Badge color="gray" variant="light" radius="sm">
|
||||
@@ -233,7 +239,7 @@ export function ContractDocumentsCard({
|
||||
>
|
||||
{files.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
No documents attached to this contract.
|
||||
{emptyText}
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="xs">
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Group, NumberInput, Select, Stack } from "@mantine/core";
|
||||
|
||||
export type DurationUnit = "minutes" | "hours" | "days";
|
||||
|
||||
const UNIT_MINUTES: Record<DurationUnit, number> = {
|
||||
minutes: 1,
|
||||
hours: 60,
|
||||
days: 1440,
|
||||
};
|
||||
|
||||
const UNIT_OPTIONS: { value: DurationUnit; label: string }[] = [
|
||||
{ value: "minutes", label: "min" },
|
||||
{ value: "hours", label: "hr" },
|
||||
{ value: "days", label: "day" },
|
||||
];
|
||||
|
||||
/** Convert a value expressed in `from` units to `to` units. */
|
||||
function convert(value: number, from: DurationUnit, to: DurationUnit): number {
|
||||
return (value * UNIT_MINUTES[from]) / UNIT_MINUTES[to];
|
||||
}
|
||||
|
||||
/** Pick the largest unit that keeps a value a clean-ish whole number, so a
|
||||
* stored 0.0667h loads back as "4 min" rather than "0.0667 hr". */
|
||||
function bestDisplayUnit(minutes: number): DurationUnit {
|
||||
if (minutes <= 0) return "minutes";
|
||||
if (minutes % 1440 === 0) return "days";
|
||||
if (minutes % 60 === 0) return "hours";
|
||||
return "minutes";
|
||||
}
|
||||
|
||||
export interface DurationFieldProps {
|
||||
label: string;
|
||||
description?: string;
|
||||
/** Current value, expressed in `nativeUnit` (what the API/DB stores). */
|
||||
value: number | string;
|
||||
/** The unit the parent stores/sends. The field converts to this on change. */
|
||||
nativeUnit: DurationUnit;
|
||||
/** Called with the value converted back to `nativeUnit` (or "" when blank). */
|
||||
onChange: (nativeValue: number | "") => void;
|
||||
/** Smallest allowed value, in `nativeUnit`. */
|
||||
min?: number;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function DurationField({
|
||||
label,
|
||||
description,
|
||||
value,
|
||||
nativeUnit,
|
||||
onChange,
|
||||
min,
|
||||
disabled,
|
||||
}: DurationFieldProps) {
|
||||
const nativeMinutes = useMemo(() => {
|
||||
const num = value === "" || value == null ? NaN : Number(value);
|
||||
return Number.isFinite(num) ? num * UNIT_MINUTES[nativeUnit] : NaN;
|
||||
}, [value, nativeUnit]);
|
||||
|
||||
// Display unit is user-driven; seed it from the incoming value once.
|
||||
const [unit, setUnit] = useState<DurationUnit>(() =>
|
||||
Number.isFinite(nativeMinutes) ? bestDisplayUnit(nativeMinutes) : nativeUnit,
|
||||
);
|
||||
|
||||
// The value usually arrives async (after the initial "" render), so the
|
||||
// useState seed above runs before it exists. Re-pick the friendliest display
|
||||
// unit the first time a real value shows up — but never again, so the user's
|
||||
// manual unit choice sticks.
|
||||
const seeded = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!seeded.current && Number.isFinite(nativeMinutes)) {
|
||||
seeded.current = true;
|
||||
setUnit(bestDisplayUnit(nativeMinutes));
|
||||
}
|
||||
}, [nativeMinutes]);
|
||||
|
||||
const displayValue: number | "" = Number.isFinite(nativeMinutes)
|
||||
? Number(convert(nativeMinutes, "minutes", unit).toFixed(4))
|
||||
: "";
|
||||
|
||||
const emitNative = (display: number | "", displayUnit: DurationUnit) => {
|
||||
if (display === "" || !Number.isFinite(Number(display))) {
|
||||
onChange("");
|
||||
return;
|
||||
}
|
||||
const native = convert(Number(display), displayUnit, nativeUnit);
|
||||
onChange(Number(native.toFixed(6)));
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap={4}>
|
||||
<Group gap="xs" align="flex-end" wrap="nowrap">
|
||||
<NumberInput
|
||||
label={label}
|
||||
description={description}
|
||||
value={displayValue}
|
||||
onChange={(v) =>
|
||||
emitNative(v === "" ? "" : Number(v), unit)
|
||||
}
|
||||
clampBehavior="none"
|
||||
allowDecimal
|
||||
min={min != null ? convert(min, nativeUnit, unit) : 0}
|
||||
disabled={disabled}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Select
|
||||
aria-label={`${label} unit`}
|
||||
data={UNIT_OPTIONS}
|
||||
value={unit}
|
||||
onChange={(next) => {
|
||||
if (!next) return;
|
||||
// Only the display unit changes; the stored native value stays put.
|
||||
// displayValue re-derives from it on the next render.
|
||||
setUnit(next as DurationUnit);
|
||||
}}
|
||||
allowDeselect={false}
|
||||
disabled={disabled}
|
||||
w={90}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,593 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
Paper,
|
||||
Progress,
|
||||
ScrollArea,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowLeftRight,
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
Inbox,
|
||||
PackageCheck,
|
||||
Repeat,
|
||||
Train,
|
||||
Weight,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
import { CountdownTimer } from "@edr/ui-common";
|
||||
|
||||
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
||||
import { api } from "@/services/api";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import type {
|
||||
EligibleContainerBooking,
|
||||
FreightType,
|
||||
TrainScheduleDetail,
|
||||
} from "@/types/trainScheduling";
|
||||
|
||||
interface ScheduleWorkspacePanelProps {
|
||||
schedule: TrainScheduleDetail;
|
||||
/** Refetch the schedule detail after a mutation so both panels refresh. */
|
||||
onChanged: () => void;
|
||||
}
|
||||
|
||||
const GREEN = "var(--mantine-color-edr-green-6)";
|
||||
|
||||
/**
|
||||
* Deadline + label for the window phase this schedule is currently in.
|
||||
* Phases run: window open (windowClosesAt) → document review (docReviewEndsAt)
|
||||
* → payment (paymentPhaseEndsAt). Display only. Returns null off-phase.
|
||||
*/
|
||||
function phaseCountdown(
|
||||
schedule: TrainScheduleDetail,
|
||||
): { label: string; deadline: string } | null {
|
||||
switch (schedule.windowPhase) {
|
||||
case "OPEN":
|
||||
return schedule.windowClosesAt
|
||||
? { label: "Booking window closes in", deadline: schedule.windowClosesAt }
|
||||
: null;
|
||||
case "DOC_REVIEW":
|
||||
return schedule.docReviewEndsAt
|
||||
? { label: "Document review ends in", deadline: schedule.docReviewEndsAt }
|
||||
: null;
|
||||
case "PAYMENT":
|
||||
return schedule.paymentPhaseEndsAt
|
||||
? { label: "Payment window ends in", deadline: schedule.paymentPhaseEndsAt }
|
||||
: null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Cargo weight already allocated to this train (sum of on-train bookings). */
|
||||
function usedWeight(schedule: TrainScheduleDetail): number {
|
||||
return (schedule.bookings ?? []).reduce(
|
||||
(sum, b) => sum + (Number(b.weightTons) || 0),
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
/** Max pull weight across all locomotives on the set (0 when unknown). */
|
||||
function pullCapacity(schedule: TrainScheduleDetail): number {
|
||||
const set = schedule.trainSet;
|
||||
if (!set) return 0;
|
||||
const locos =
|
||||
set.locomotives && set.locomotives.length > 0
|
||||
? set.locomotives
|
||||
: set.locomotive
|
||||
? [set.locomotive]
|
||||
: [];
|
||||
return locos.reduce((sum, l) => sum + (Number(l.maxPullWeightTons) || 0), 0);
|
||||
}
|
||||
|
||||
export function ScheduleWorkspacePanel({
|
||||
schedule,
|
||||
onChanged,
|
||||
}: ScheduleWorkspacePanelProps) {
|
||||
const { toast } = useToast();
|
||||
|
||||
const freightType: FreightType | undefined =
|
||||
schedule.freightType === "CONTAINER" || schedule.freightType === "BULK"
|
||||
? schedule.freightType
|
||||
: undefined;
|
||||
|
||||
const locked = ["DISPATCHED", "ARRIVED"].includes(schedule.status);
|
||||
const canManage = ["DRAFT", "SCHEDULED"].includes(schedule.status);
|
||||
|
||||
// Pool = accepted, ready-to-pay bookings on THIS train's route+day that are not
|
||||
// yet linked to any schedule (same filter the auto-batch uses).
|
||||
const poolQuery = useQuery(
|
||||
api.trainScheduling.eligibleBookings.queryOptions({
|
||||
input: {
|
||||
filters: {
|
||||
originStationId: schedule.originStation?.id,
|
||||
destinationStationId: schedule.destinationStation?.id,
|
||||
trainScheduleId: schedule.id,
|
||||
},
|
||||
freightType,
|
||||
},
|
||||
enabled: Boolean(schedule.originStation?.id && schedule.destinationStation?.id),
|
||||
}),
|
||||
);
|
||||
|
||||
const onTrainIds = useMemo(
|
||||
() => new Set((schedule.bookings ?? []).map((b) => b.id)),
|
||||
[schedule.bookings],
|
||||
);
|
||||
|
||||
const pool: EligibleContainerBooking[] = useMemo(
|
||||
() => (poolQuery.data?.items ?? []).filter((b) => !onTrainIds.has(b.id)),
|
||||
[poolQuery.data, onTrainIds],
|
||||
);
|
||||
|
||||
const onTrain = schedule.bookings ?? [];
|
||||
|
||||
// ── Mutations (reuse the existing endpoints) ───────────────────────────────
|
||||
const assign = useMutation(api.trainScheduling.assignBookings.mutationOptions());
|
||||
const unassign = useMutation(api.trainScheduling.unassignBooking.mutationOptions());
|
||||
const moveSchedule = useMutation(
|
||||
api.trainScheduling.moveBookingSchedule.mutationOptions(),
|
||||
);
|
||||
|
||||
const [moveBookingId, setMoveBookingId] = useState<string | null>(null);
|
||||
const [moveTarget, setMoveTarget] = useState<string | null>(null);
|
||||
|
||||
const { data: targets } = useQuery(
|
||||
api.trainScheduling.bookableSchedules.queryOptions({
|
||||
input: {
|
||||
originYardId: schedule.originStation?.id,
|
||||
destinationYardId: schedule.destinationStation?.id,
|
||||
},
|
||||
enabled: Boolean(
|
||||
schedule.originStation?.id && schedule.destinationStation?.id,
|
||||
),
|
||||
}),
|
||||
);
|
||||
const moveOptions = useMemo(
|
||||
() =>
|
||||
(targets ?? [])
|
||||
.filter((s) => s.id !== schedule.id)
|
||||
.map((s) => ({
|
||||
value: s.id,
|
||||
label: `${s.routeName ?? `${s.origin} → ${s.destination}`} · ${new Date(
|
||||
s.scheduleDate,
|
||||
).toLocaleString()} · ${s.remainingWagons}/${s.maxWagons} free`,
|
||||
})),
|
||||
[targets, schedule.id],
|
||||
);
|
||||
|
||||
// ── Capacity meter (by cargo weight vs locomotive pull) ────────────────────
|
||||
const used = usedWeight(schedule);
|
||||
const capacity = pullCapacity(schedule);
|
||||
const pct = capacity > 0 ? Math.min(100, Math.round((used / capacity) * 100)) : 0;
|
||||
const over = capacity > 0 && used > capacity;
|
||||
|
||||
const forceAdd = (bookingId: string, ref: string, weightTons: number) => {
|
||||
const wouldOverfill = capacity > 0 && used + (weightTons || 0) > capacity;
|
||||
assign
|
||||
.mutateAsync({
|
||||
id: schedule.id,
|
||||
freightType,
|
||||
payload: {
|
||||
bookingIds: [...onTrainIds, bookingId],
|
||||
forceAssign: true,
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
toast({
|
||||
title: `${ref} added to train`,
|
||||
description: wouldOverfill
|
||||
? "Force-added past the pull-weight limit — review capacity."
|
||||
: "Wagons auto-pinned.",
|
||||
variant: wouldOverfill ? "destructive" : undefined,
|
||||
});
|
||||
onChanged();
|
||||
void poolQuery.refetch();
|
||||
})
|
||||
.catch(() =>
|
||||
toast({ title: "Could not add booking", variant: "destructive" }),
|
||||
);
|
||||
};
|
||||
|
||||
const removeFromTrain = (bookingId: string, ref: string) => {
|
||||
unassign
|
||||
.mutateAsync({ id: schedule.id, bookingId })
|
||||
.then(() => {
|
||||
toast({ title: `${ref} removed from train` });
|
||||
onChanged();
|
||||
void poolQuery.refetch();
|
||||
})
|
||||
.catch(() =>
|
||||
toast({ title: "Could not remove booking", variant: "destructive" }),
|
||||
);
|
||||
};
|
||||
|
||||
const doMove = () => {
|
||||
if (!moveBookingId || !moveTarget) return;
|
||||
moveSchedule
|
||||
.mutateAsync({ bookingId: moveBookingId, trainScheduleId: moveTarget })
|
||||
.then(() => {
|
||||
toast({ title: "Booking reassigned to another train" });
|
||||
setMoveBookingId(null);
|
||||
onChanged();
|
||||
void poolQuery.refetch();
|
||||
})
|
||||
.catch(() =>
|
||||
toast({ title: "Could not reassign booking", variant: "destructive" }),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper radius="xl" p="lg" withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
|
||||
<Stack gap="lg">
|
||||
{/* Header + capacity meter */}
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
|
||||
<Group gap="sm" align="center" wrap="nowrap">
|
||||
<ThemeIcon size={40} radius="md" variant="light" color="edr-green">
|
||||
<PackageCheck size={20} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700}>Allocation workspace</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Manually add ready-to-pay bookings, remove, or reassign them
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Box miw={240} style={{ flex: "0 1 320px" }}>
|
||||
<Group justify="space-between" mb={4} gap={4}>
|
||||
<Group gap={6} align="center">
|
||||
<Weight size={14} color={over ? "#B42318" : undefined} />
|
||||
<Text size="xs" fw={600} c={over ? "red" : "dimmed"}>
|
||||
Load {used.toFixed(1)}T
|
||||
{capacity > 0 ? ` / ${capacity.toFixed(0)}T pull` : ""}
|
||||
</Text>
|
||||
</Group>
|
||||
{over ? (
|
||||
<Badge color="red" variant="light" size="sm" radius="sm">
|
||||
Over capacity
|
||||
</Badge>
|
||||
) : (
|
||||
<Text size="xs" c="dimmed">
|
||||
{capacity > 0 ? `${pct}%` : "—"}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
<Progress
|
||||
value={capacity > 0 ? pct : 0}
|
||||
color={over ? "red" : pct > 85 ? "orange" : "edr-green"}
|
||||
radius="xl"
|
||||
size="md"
|
||||
/>
|
||||
</Box>
|
||||
</Group>
|
||||
|
||||
{(() => {
|
||||
const cd = phaseCountdown(schedule);
|
||||
return cd ? (
|
||||
<Group
|
||||
gap={8}
|
||||
p="xs"
|
||||
wrap="nowrap"
|
||||
align="center"
|
||||
style={{
|
||||
borderRadius: 10,
|
||||
background: "var(--mantine-color-blue-0)",
|
||||
border: "1px solid var(--mantine-color-blue-2)",
|
||||
}}
|
||||
>
|
||||
<CountdownTimer deadline={cd.deadline} label={cd.label} size="sm" />
|
||||
</Group>
|
||||
) : null;
|
||||
})()}
|
||||
|
||||
{over ? (
|
||||
<Group
|
||||
gap={8}
|
||||
p="xs"
|
||||
wrap="nowrap"
|
||||
align="center"
|
||||
style={{
|
||||
borderRadius: 10,
|
||||
background: "var(--mantine-color-red-0)",
|
||||
border: "1px solid var(--mantine-color-red-2)",
|
||||
}}
|
||||
>
|
||||
<AlertTriangle size={16} color="#B42318" />
|
||||
<Text size="xs" c="red.8" fw={500}>
|
||||
This train is loaded beyond its locomotive pull weight. Force-adds are
|
||||
allowed, but review before dispatch.
|
||||
</Text>
|
||||
</Group>
|
||||
) : null}
|
||||
|
||||
{locked ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
This train is {schedule.status.toLowerCase()} — bookings can no longer be
|
||||
changed.
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{/* Two-panel board */}
|
||||
<Group align="stretch" gap="lg" grow wrap="wrap">
|
||||
{/* Pool */}
|
||||
<PanelColumn
|
||||
title="Ready to pay"
|
||||
hint="Accepted · this route & day"
|
||||
count={pool.length}
|
||||
accent="#F2A516"
|
||||
loading={poolQuery.isLoading}
|
||||
emptyIcon={Inbox}
|
||||
emptyText="No ready-to-pay bookings waiting for this train."
|
||||
>
|
||||
{pool.map((b) => (
|
||||
<BookingCard
|
||||
key={b.id}
|
||||
reference={b.reference}
|
||||
customer={b.customer}
|
||||
weightTons={b.weightTons}
|
||||
status={b.status}
|
||||
right={
|
||||
canManage ? (
|
||||
<Tooltip label="Force-add to this train" withArrow>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
rightSection={<ArrowRight size={14} />}
|
||||
loading={assign.isPending}
|
||||
onClick={() => forceAdd(b.id, b.reference, b.weightTons)}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</PanelColumn>
|
||||
|
||||
{/* On train */}
|
||||
<PanelColumn
|
||||
title="On this train"
|
||||
hint="Allocated bookings"
|
||||
count={onTrain.length}
|
||||
accent="#0EA371"
|
||||
emptyIcon={Train}
|
||||
emptyText="No bookings allocated yet. Add one from the pool."
|
||||
>
|
||||
{onTrain.map((b) => (
|
||||
<BookingCard
|
||||
key={b.id}
|
||||
reference={b.reference ?? b.id.slice(0, 8)}
|
||||
customer={b.customer}
|
||||
weightTons={b.weightTons}
|
||||
status={b.status}
|
||||
right={
|
||||
canManage ? (
|
||||
<Group gap={6} wrap="nowrap" justify="flex-end">
|
||||
<Tooltip label="Reassign to another train" withArrow>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="subtle"
|
||||
color="orange"
|
||||
radius="md"
|
||||
leftSection={<Repeat size={13} />}
|
||||
onClick={() => {
|
||||
setMoveBookingId(b.id);
|
||||
setMoveTarget(null);
|
||||
}}
|
||||
>
|
||||
Move
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip label="Remove from this train" withArrow>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="light"
|
||||
color="red"
|
||||
radius="md"
|
||||
leftSection={<X size={13} />}
|
||||
loading={unassign.isPending}
|
||||
onClick={() =>
|
||||
removeFromTrain(b.id, b.reference ?? b.id.slice(0, 8))
|
||||
}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</PanelColumn>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
{/* Reassign modal */}
|
||||
<Modal
|
||||
opened={Boolean(moveBookingId)}
|
||||
onClose={() => setMoveBookingId(null)}
|
||||
title={
|
||||
<Group gap={8}>
|
||||
<ArrowLeftRight size={18} />
|
||||
<Text fw={700}>Reassign booking to another train</Text>
|
||||
</Group>
|
||||
}
|
||||
centered
|
||||
radius="lg"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Select
|
||||
label="Target train (same route, open window)"
|
||||
placeholder="Select an open schedule"
|
||||
data={moveOptions}
|
||||
value={moveTarget}
|
||||
onChange={setMoveTarget}
|
||||
searchable
|
||||
nothingFoundMessage="No other open schedules on this route"
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setMoveBookingId(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
disabled={!moveTarget}
|
||||
loading={moveSchedule.isPending}
|
||||
leftSection={<CheckCircle2 size={16} />}
|
||||
onClick={doMove}
|
||||
>
|
||||
Reassign
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Sub-components ───────────────────────────────────────────────────────────
|
||||
|
||||
function PanelColumn({
|
||||
title,
|
||||
hint,
|
||||
count,
|
||||
accent,
|
||||
loading,
|
||||
emptyIcon: EmptyIcon,
|
||||
emptyText,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
hint: string;
|
||||
count: number;
|
||||
accent: string;
|
||||
loading?: boolean;
|
||||
emptyIcon: typeof Inbox;
|
||||
emptyText: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const isEmpty = !loading && count === 0;
|
||||
return (
|
||||
<Paper
|
||||
radius="lg"
|
||||
withBorder
|
||||
p="md"
|
||||
miw={280}
|
||||
style={{
|
||||
flex: 1,
|
||||
borderColor: "var(--mantine-color-gray-2)",
|
||||
background: `linear-gradient(180deg, ${accent}0A 0%, transparent 90px)`,
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" align="center" mb="sm">
|
||||
<Group gap={8} align="center">
|
||||
<Box w={8} h={8} style={{ borderRadius: 999, background: accent }} />
|
||||
<Text fw={700} size="sm">
|
||||
{title}
|
||||
</Text>
|
||||
<Badge variant="light" color="gray" radius="sm" size="sm">
|
||||
{count}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
{hint}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{isEmpty ? (
|
||||
<Stack align="center" gap={6} py={32}>
|
||||
<EmptyIcon size={24} color="var(--mantine-color-gray-4)" />
|
||||
<Text size="xs" c="dimmed" ta="center" maw={220}>
|
||||
{emptyText}
|
||||
</Text>
|
||||
</Stack>
|
||||
) : (
|
||||
<ScrollArea.Autosize mah={420} type="hover">
|
||||
<Stack gap={8} pr={4}>
|
||||
{loading ? (
|
||||
<Text size="xs" c="dimmed" py="md" ta="center">
|
||||
Loading…
|
||||
</Text>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
function BookingCard({
|
||||
reference,
|
||||
customer,
|
||||
weightTons,
|
||||
status,
|
||||
right,
|
||||
}: {
|
||||
reference: string;
|
||||
customer?: string | null;
|
||||
weightTons?: number | null;
|
||||
status?: string | null;
|
||||
right?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Paper
|
||||
radius="md"
|
||||
withBorder
|
||||
p="sm"
|
||||
style={{
|
||||
borderColor: "var(--mantine-color-gray-2)",
|
||||
transition: "border-color 120ms ease, box-shadow 120ms ease",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = GREEN;
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = "var(--mantine-color-gray-2)";
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" align="center" wrap="nowrap" gap="sm">
|
||||
<Stack gap={3} style={{ minWidth: 0 }}>
|
||||
<Group gap={8} align="center" wrap="nowrap">
|
||||
<Text size="sm" fw={700} truncate>
|
||||
{reference}
|
||||
</Text>
|
||||
{status ? <BookingStatusBadge status={status} /> : null}
|
||||
</Group>
|
||||
<Group gap={10} align="center" wrap="nowrap">
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{customer ?? "—"}
|
||||
</Text>
|
||||
{weightTons != null ? (
|
||||
<Group gap={3} align="center" wrap="nowrap">
|
||||
<Weight size={11} color="var(--mantine-color-gray-5)" />
|
||||
<Text size="xs" c="dimmed">
|
||||
{Number(weightTons).toFixed(1)}T
|
||||
</Text>
|
||||
</Group>
|
||||
) : null}
|
||||
</Group>
|
||||
</Stack>
|
||||
{right ? <Box style={{ flexShrink: 0 }}>{right}</Box> : null}
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -286,6 +286,8 @@ export const URL_CONSTANTS = {
|
||||
`/train-scheduling/schedules/${id}/assign-unassigned-booking`,
|
||||
BOOKING_WINDOW: (id: string) =>
|
||||
`/train-scheduling/schedules/${id}/booking-window`,
|
||||
CONTRACT_BOOKING_WINDOWS: (contractId: string) =>
|
||||
`/train-scheduling/contracts/${contractId}/booking-windows`,
|
||||
MARK_BOOKING_PAID: (bookingId: string) =>
|
||||
`/train-scheduling/bookings/${bookingId}/mark-paid`,
|
||||
EXPIRE_BOOKING: (bookingId: string) =>
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { LucideIcon } from "lucide-react";
|
||||
import {
|
||||
Ban,
|
||||
Check,
|
||||
FileSignature,
|
||||
MessageSquareWarning,
|
||||
Play,
|
||||
ShieldCheck,
|
||||
@@ -211,29 +210,6 @@ const CANCEL_ACTION: BookingActionDef = {
|
||||
inputPlaceholder: "Reason for cancellation…",
|
||||
};
|
||||
|
||||
const VIEW_CONTRACT_ACTION: BookingActionDef = {
|
||||
id: "viewContract",
|
||||
label: "View contract",
|
||||
shortLabel: "Contract",
|
||||
description: "Open contract document and signatures",
|
||||
confirmTitle: "",
|
||||
confirmDescription: "",
|
||||
variant: "outline",
|
||||
icon: FileSignature,
|
||||
};
|
||||
|
||||
const SIGN_CONTRACT_STAFF_ACTION: BookingActionDef = {
|
||||
id: "signContractStaff",
|
||||
label: "Sign contract",
|
||||
shortLabel: "Sign",
|
||||
description: "Open contract page and apply staff counter-signature",
|
||||
confirmTitle: "",
|
||||
confirmDescription: "",
|
||||
variant: "default",
|
||||
icon: FileSignature,
|
||||
primary: true,
|
||||
};
|
||||
|
||||
// Opens the booking detail straight on the Clearance tab so Marketing can
|
||||
// review the customer's clearance documents (non-customs bookings only).
|
||||
const REVIEW_CLEARANCE_ACTION: BookingActionDef = {
|
||||
@@ -340,22 +316,14 @@ export function getBookingActions(
|
||||
actions = withCancel(approvalActions(approvalSteps));
|
||||
break;
|
||||
case "APPROVED":
|
||||
actions = [{ ...VIEW_CONTRACT_ACTION, primary: true }, CANCEL_ACTION];
|
||||
actions = [CANCEL_ACTION];
|
||||
break;
|
||||
case "CONTRACT_READY":
|
||||
actions = [{ ...VIEW_CONTRACT_ACTION, primary: true }];
|
||||
break;
|
||||
case "SIGNED_CUSTOMER":
|
||||
actions = [SIGN_CONTRACT_STAFF_ACTION, VIEW_CONTRACT_ACTION];
|
||||
break;
|
||||
case "FULLY_EXECUTED":
|
||||
actions = [
|
||||
{
|
||||
...VIEW_CONTRACT_ACTION,
|
||||
label: "View executed contract",
|
||||
primary: true,
|
||||
},
|
||||
];
|
||||
// Contract view/sign/executed buttons intentionally removed from the
|
||||
// booking-request page.
|
||||
actions = [];
|
||||
break;
|
||||
case "AWAITING_DOCUMENTS":
|
||||
case "DOCUMENTS_UNDER_REVIEW":
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useCallback } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
interface ToastOptions {
|
||||
@@ -8,7 +9,9 @@ interface ToastOptions {
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
const showToast = (options: ToastOptions) => {
|
||||
// Stable identity so callers can safely list `toast` in effect/callback deps
|
||||
// without re-firing on every render.
|
||||
const showToast = useCallback((options: ToastOptions) => {
|
||||
const { title, description, variant = 'default', duration = 3000 } = options;
|
||||
|
||||
const message = title ? `${title}${description ? ': ' + description : ''}` : description || '';
|
||||
@@ -18,7 +21,7 @@ export function useToast() {
|
||||
} else {
|
||||
toast.success(message, { duration });
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { toast: showToast };
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import {
|
||||
ArrowLeft,
|
||||
FileSignature,
|
||||
Layers,
|
||||
LayoutGrid,
|
||||
Milestone,
|
||||
@@ -36,31 +35,19 @@ import {
|
||||
BookingCargoCard,
|
||||
BookingCompanyCard,
|
||||
BookingContractSummaryCard,
|
||||
BookingDocumentsCard,
|
||||
BookingContainerUnitsCard,
|
||||
ClearanceReviewSection,
|
||||
ContractOrdersPanel,
|
||||
type BookingFileView,
|
||||
} from "@/components/bookings/detail";
|
||||
import { WarehouseInfoCard } from "@/components/warehouses";
|
||||
import { getStatusMeta } from "@/features/bookings/booking-status.config";
|
||||
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
|
||||
import type { BookingDetail } from "@/types/booking";
|
||||
import { downloadBookingFile } from "@/services/files.service";
|
||||
import {
|
||||
useBookingDetail,
|
||||
useBookingMutations,
|
||||
} from "@/hooks/bookings/useBookings";
|
||||
import { useScrollToHash } from "@/hooks/useScrollToHash";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
// Signature / generated-contract files are surfaced on the contract page, not
|
||||
// in the booking's Documents list.
|
||||
const SIGNATURE_FILE_CODES = new Set([
|
||||
"signature",
|
||||
"signature_customer",
|
||||
"signature_staff",
|
||||
"contract",
|
||||
]);
|
||||
|
||||
export default function BookingRequestDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
@@ -77,14 +64,6 @@ export default function BookingRequestDetailPage() {
|
||||
} = useBookingDetail(id);
|
||||
const mutations = useBookingMutations(id ?? "");
|
||||
|
||||
const handleDownloadFile = async (file: BookingFileView) => {
|
||||
try {
|
||||
await downloadBookingFile(file.id, file.name);
|
||||
} catch {
|
||||
toast.error("Could not download file.");
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PageContainer>
|
||||
@@ -149,11 +128,6 @@ export default function BookingRequestDetailPage() {
|
||||
|
||||
const row = toBookingListRow(booking);
|
||||
const statusMeta = getStatusMeta(booking.status);
|
||||
const showContractButton = [
|
||||
"CONTRACT_READY",
|
||||
"SIGNED_CUSTOMER",
|
||||
"FULLY_EXECUTED",
|
||||
].includes(booking.status);
|
||||
const showApprovalCard =
|
||||
booking.status === "PENDING_APPROVAL" ||
|
||||
booking.status === "APPROVED_PENDING_SIGNATURE";
|
||||
@@ -246,11 +220,7 @@ export default function BookingRequestDetailPage() {
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="overview">
|
||||
<OverviewPanel
|
||||
booking={booking}
|
||||
row={row}
|
||||
onDownload={handleDownloadFile}
|
||||
/>
|
||||
<OverviewPanel booking={booking} row={row} />
|
||||
</Tabs.Panel>
|
||||
{isGeneralContract && (
|
||||
<Tabs.Panel value="orders">
|
||||
@@ -270,11 +240,7 @@ export default function BookingRequestDetailPage() {
|
||||
)}
|
||||
</Tabs>
|
||||
) : (
|
||||
<OverviewPanel
|
||||
booking={booking}
|
||||
row={row}
|
||||
onDownload={handleDownloadFile}
|
||||
/>
|
||||
<OverviewPanel booking={booking} row={row} />
|
||||
)}
|
||||
</Grid.Col>
|
||||
|
||||
@@ -306,20 +272,6 @@ export default function BookingRequestDetailPage() {
|
||||
View document clearance
|
||||
</Button>
|
||||
)}
|
||||
{showContractButton && (
|
||||
<Button
|
||||
fullWidth
|
||||
color="edr-green"
|
||||
leftSection={<FileSignature size={16} />}
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/dashboard/booking-requests/${booking.id}/contract`,
|
||||
)
|
||||
}
|
||||
>
|
||||
View & sign contract
|
||||
</Button>
|
||||
)}
|
||||
{showApprovalCard && (
|
||||
<ApprovalStepsCard booking={booking} mutations={mutations} />
|
||||
)}
|
||||
@@ -332,15 +284,13 @@ export default function BookingRequestDetailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
/** The booking's primary detail cards — route, services, cargo, contract, docs. */
|
||||
/** The booking's primary detail cards — route, services, cargo, containers. */
|
||||
function OverviewPanel({
|
||||
booking,
|
||||
row,
|
||||
onDownload,
|
||||
}: {
|
||||
booking: BookingDetail;
|
||||
row: ReturnType<typeof toBookingListRow>;
|
||||
onDownload: (file: BookingFileView) => void;
|
||||
}) {
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
@@ -351,15 +301,10 @@ function OverviewPanel({
|
||||
/>
|
||||
<BookingMileServicesCard booking={booking} />
|
||||
<BookingCargoCard booking={booking} />
|
||||
<BookingContainerUnitsCard booking={booking} />
|
||||
{booking.contractSummary && (
|
||||
<BookingContractSummaryCard summary={booking.contractSummary} />
|
||||
)}
|
||||
<BookingDocumentsCard
|
||||
files={(booking.files ?? []).filter(
|
||||
(f) => !SIGNATURE_FILE_CODES.has(f.code ?? ""),
|
||||
)}
|
||||
onDownload={onDownload}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Select,
|
||||
Stack,
|
||||
Tabs,
|
||||
Text,
|
||||
@@ -30,17 +31,12 @@ import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu";
|
||||
import { BookingApprovalProgressCell } from "@/components/bookings/BookingApprovalProgressCell";
|
||||
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
|
||||
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
||||
import {
|
||||
BookingStatusTabs,
|
||||
type BookingStatusTabKey,
|
||||
} from "@/components/bookings/BookingStatusTabs";
|
||||
// BookingStatusTabs / Operations* queues removed — replaced by booking-kind tabs.
|
||||
import { BookingTableEmpty } from "@/components/bookings/BookingTableEmpty";
|
||||
import { OperationsBookingQueue } from "@/components/bookings/OperationsBookingQueue";
|
||||
import { OperationsScheduledBookings } from "@/components/bookings/OperationsScheduledBookings";
|
||||
import { bookingTable } from "@/components/bookings/booking-ui.styles";
|
||||
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
||||
import { AllocateBookingWizard } from "@/components/trainScheduling/AllocateBookingWizard";
|
||||
import { BOOKING_LIST_TABS } from "@/features/bookings/booking-status.config";
|
||||
import { BOOKING_STATUS_STYLES } from "@/features/bookings/booking-status.config";
|
||||
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
|
||||
import {
|
||||
useBookingDetail,
|
||||
@@ -57,11 +53,29 @@ import {
|
||||
type ColumnDef,
|
||||
} from "@edr/ui-common";
|
||||
|
||||
function getStatusesForTab(tab: BookingStatusTabKey): string | undefined {
|
||||
const match = BOOKING_LIST_TABS.find((t) => t.key === tab);
|
||||
if (!match?.statuses?.length) return undefined;
|
||||
return match.statuses.join(",");
|
||||
}
|
||||
/** The two booking-kind tabs: one-time vs general-contract bookings. */
|
||||
type BookingKindTab = "ONE_TIME" | "GENERAL_CONTRACT";
|
||||
|
||||
const BOOKING_KIND_TABS: { value: BookingKindTab; label: string }[] = [
|
||||
{ value: "ONE_TIME", label: "One-time booking" },
|
||||
{ value: "GENERAL_CONTRACT", label: "General booking" },
|
||||
];
|
||||
|
||||
/** Status options for the filter select — built from the shared status styles. */
|
||||
const STATUS_OPTIONS = Object.entries(BOOKING_STATUS_STYLES).map(
|
||||
([value, { label }]) => ({ value, label }),
|
||||
);
|
||||
|
||||
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" },
|
||||
];
|
||||
|
||||
function formatDate(value: string | null | undefined): string {
|
||||
if (!value) return "—";
|
||||
@@ -75,14 +89,16 @@ function formatDate(value: string | null | undefined): string {
|
||||
});
|
||||
}
|
||||
|
||||
type OperationsSubTab = "ready" | "scheduled";
|
||||
|
||||
export default function BookingRequestsPage() {
|
||||
const navigate = useNavigate();
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const [query, setQuery] = useState("");
|
||||
const [activeTab, setActiveTab] = useState<BookingStatusTabKey>("all");
|
||||
const [operationsSubTab, setOperationsSubTab] = useState<OperationsSubTab>("ready");
|
||||
// Booking-kind tabs (one-time vs general contract) replace the old status tabs.
|
||||
const [kindTab, setKindTab] = useState<BookingKindTab>("ONE_TIME");
|
||||
// Per-tab filter selects (each nullable = "all").
|
||||
const [statusFilter, setStatusFilter] = useState<string | null>(null);
|
||||
const [directionFilter, setDirectionFilter] = useState<string | null>(null);
|
||||
const [freightTypeFilter, setFreightTypeFilter] = useState<string | null>(null);
|
||||
const [allocateOpen, setAllocateOpen] = useState(false);
|
||||
const [allocateIds, setAllocateIds] = useState<string[]>([]);
|
||||
const suppressRowClickRef = useRef(false);
|
||||
@@ -93,47 +109,26 @@ export default function BookingRequestsPage() {
|
||||
}, 400);
|
||||
}, []);
|
||||
|
||||
const tabStatuses = getStatusesForTab(activeTab);
|
||||
const isOperationsTab = activeTab === "operations";
|
||||
|
||||
const filter: BookingListFilter = useMemo(() => {
|
||||
if (isOperationsTab) {
|
||||
if (operationsSubTab === "ready") {
|
||||
return {
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
statuses: "PAID",
|
||||
assignedToSchedule: "false",
|
||||
sortBy: "createdAt",
|
||||
sortOrder: "DESC",
|
||||
tab: activeTab,
|
||||
};
|
||||
}
|
||||
return {
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
statuses: "PAID",
|
||||
schedulingStatuses: "SCHEDULED,DISPATCHED",
|
||||
sortBy: "scheduledDate",
|
||||
sortOrder: "ASC",
|
||||
tab: activeTab,
|
||||
};
|
||||
}
|
||||
return {
|
||||
page: pagination.pageIndex + 1,
|
||||
pageSize: pagination.pageSize,
|
||||
sortBy: "createdAt",
|
||||
sortOrder: "DESC",
|
||||
tab: activeTab,
|
||||
...(tabStatuses ? { statuses: tabStatuses } : {}),
|
||||
// React Query cache key per kind tab.
|
||||
tab: kindTab,
|
||||
bookingType: kindTab,
|
||||
...(statusFilter ? { statuses: statusFilter } : {}),
|
||||
...(directionFilter ? { tradeDirection: directionFilter } : {}),
|
||||
...(freightTypeFilter ? { freightType: freightTypeFilter } : {}),
|
||||
};
|
||||
}, [
|
||||
isOperationsTab,
|
||||
operationsSubTab,
|
||||
pagination.pageIndex,
|
||||
pagination.pageSize,
|
||||
activeTab,
|
||||
tabStatuses,
|
||||
kindTab,
|
||||
statusFilter,
|
||||
directionFilter,
|
||||
freightTypeFilter,
|
||||
]);
|
||||
|
||||
const { data, isLoading, isError, refetch, isFetching } = useBookingList(filter);
|
||||
@@ -171,18 +166,6 @@ export default function BookingRequestsPage() {
|
||||
void refetchSummary();
|
||||
}, [refetch, refetchSummary]);
|
||||
|
||||
const handleAllocateFromQueue = useCallback(
|
||||
(ids: string[]) => {
|
||||
const selected = rows.filter((b) => ids.includes(b.id));
|
||||
const sorted = [...selected].sort(
|
||||
(a, b) => (b.priorityScore ?? 0) - (a.priorityScore ?? 0),
|
||||
);
|
||||
setAllocateIds(sorted.map((b) => b.id));
|
||||
setAllocateOpen(true);
|
||||
},
|
||||
[rows],
|
||||
);
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(row: BookingListRow) => {
|
||||
if (suppressRowClickRef.current) return;
|
||||
@@ -356,6 +339,8 @@ export default function BookingRequestsPage() {
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Status tabs replaced by booking-kind tabs (one-time / general). The
|
||||
old BookingStatusTabs is commented out — status is now a filter select.
|
||||
<BookingStatusTabs
|
||||
active={activeTab}
|
||||
onChange={(tab) => {
|
||||
@@ -364,73 +349,97 @@ export default function BookingRequestsPage() {
|
||||
}}
|
||||
counts={tabCounts}
|
||||
/>
|
||||
*/}
|
||||
|
||||
<Tabs
|
||||
value={kindTab}
|
||||
onChange={(value) => {
|
||||
setKindTab((value as BookingKindTab) ?? "ONE_TIME");
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
}}
|
||||
>
|
||||
<Tabs.List>
|
||||
{BOOKING_KIND_TABS.map((t) => (
|
||||
<Tabs.Tab key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</Tabs.Tab>
|
||||
))}
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
|
||||
<Card p={0}>
|
||||
<Stack gap={0}>
|
||||
<Box px="md" pt="md" pb="sm" w="100%">
|
||||
<Group justify="space-between" gap="md" wrap="wrap">
|
||||
<TextInput
|
||||
placeholder="Search reference or customer…"
|
||||
leftSection={<Search size={18} />}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
rightSection={
|
||||
query && (
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
color="gray"
|
||||
radius="md"
|
||||
variant="transparent"
|
||||
onClick={() => setQuery("")}
|
||||
>
|
||||
<X size={16} />
|
||||
</ActionIcon>
|
||||
)
|
||||
}
|
||||
style={{ flex: 1, minWidth: "200px" }}
|
||||
radius="lg"
|
||||
/>
|
||||
<Text size="sm" c="dimmed">
|
||||
{total} record{total !== 1 ? "s" : ""}
|
||||
</Text>
|
||||
</Group>
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" gap="md" wrap="wrap">
|
||||
<TextInput
|
||||
placeholder="Search reference or customer…"
|
||||
leftSection={<Search size={18} />}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
rightSection={
|
||||
query && (
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
color="gray"
|
||||
radius="md"
|
||||
variant="transparent"
|
||||
onClick={() => setQuery("")}
|
||||
>
|
||||
<X size={16} />
|
||||
</ActionIcon>
|
||||
)
|
||||
}
|
||||
style={{ flex: 1, minWidth: "200px" }}
|
||||
radius="lg"
|
||||
/>
|
||||
<Text size="sm" c="dimmed">
|
||||
{total} record{total !== 1 ? "s" : ""}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap="sm" wrap="wrap">
|
||||
<Select
|
||||
placeholder="All statuses"
|
||||
data={STATUS_OPTIONS}
|
||||
value={statusFilter}
|
||||
onChange={(v) => {
|
||||
setStatusFilter(v);
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
}}
|
||||
clearable
|
||||
searchable
|
||||
radius="lg"
|
||||
style={{ minWidth: 200 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="All directions"
|
||||
data={TRADE_DIRECTION_OPTIONS}
|
||||
value={directionFilter}
|
||||
onChange={(v) => {
|
||||
setDirectionFilter(v);
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
}}
|
||||
clearable
|
||||
radius="lg"
|
||||
style={{ minWidth: 170 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="All freight types"
|
||||
data={FREIGHT_TYPE_OPTIONS}
|
||||
value={freightTypeFilter}
|
||||
onChange={(v) => {
|
||||
setFreightTypeFilter(v);
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
}}
|
||||
clearable
|
||||
radius="lg"
|
||||
style={{ minWidth: 170 }}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{isOperationsTab ? (
|
||||
<Box px="md" pb="md">
|
||||
<Stack gap="md">
|
||||
<Tabs
|
||||
value={operationsSubTab}
|
||||
onChange={(value) =>
|
||||
setOperationsSubTab((value as OperationsSubTab) ?? "ready")
|
||||
}
|
||||
>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="ready">Ready to allocate</Tabs.Tab>
|
||||
<Tabs.Tab value="scheduled">On train / scheduled</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
{isError ? (
|
||||
<BookingTableEmpty
|
||||
isError
|
||||
hasSearch={false}
|
||||
onRetry={handleRefresh}
|
||||
/>
|
||||
) : operationsSubTab === "ready" ? (
|
||||
<OperationsBookingQueue
|
||||
bookings={rows}
|
||||
isLoading={isLoading}
|
||||
onAllocate={handleAllocateFromQueue}
|
||||
/>
|
||||
) : (
|
||||
<OperationsScheduledBookings
|
||||
bookings={rows}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
) : showEmpty ? (
|
||||
{showEmpty ? (
|
||||
<Box px="md" pb="md">
|
||||
<BookingTableEmpty
|
||||
isError={isError}
|
||||
|
||||
@@ -61,9 +61,11 @@ import {
|
||||
useContractMutations,
|
||||
} from "@/hooks/contracts/useContracts";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
import { api } from "@/services/api";
|
||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||
import { fileViewUrl } from "@/constants/apiConfig";
|
||||
import { downloadBookingFile } from "@/services/files.service";
|
||||
import type { CustomerDocument } from "@/types/customer";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
// Clearance phase — staff can still ACT (approve / query / finalize).
|
||||
@@ -152,6 +154,34 @@ export default function ContractRequestDetailPage() {
|
||||
enabled: Boolean(id) && showClearanceTabQuery,
|
||||
});
|
||||
|
||||
// Customer profile documents (national ID, TIN, import/business license) for
|
||||
// the company this contract belongs to. Shown as a separate section in the
|
||||
// Documents tab, alongside the contract's own attached files.
|
||||
const companyId = contract?.companyId ?? "";
|
||||
const profileDocumentsQuery = useQuery(
|
||||
api.customers.documents.queryOptions({
|
||||
input: { id: companyId },
|
||||
enabled: Boolean(companyId),
|
||||
}),
|
||||
);
|
||||
const profileDocumentsRaw = Array.isArray(profileDocumentsQuery.data)
|
||||
? profileDocumentsQuery.data
|
||||
: [];
|
||||
// Reshape to the contract-file shape so we can reuse ContractDocumentsCard.
|
||||
const profileDocuments = profileDocumentsRaw.map(
|
||||
(doc: CustomerDocument) =>
|
||||
({
|
||||
id: doc.id,
|
||||
code: doc.code,
|
||||
name: doc.name,
|
||||
url: doc.url ?? "",
|
||||
mimeType: doc.mimeType,
|
||||
size: doc.size,
|
||||
resourceId: companyId,
|
||||
resource: "company",
|
||||
}) satisfies NonNullable<Freight.IContract["files"]>[number],
|
||||
);
|
||||
|
||||
const downloadContractPdf = async () => {
|
||||
if (!contract?.id) return;
|
||||
try {
|
||||
@@ -247,6 +277,11 @@ export default function ContractRequestDetailPage() {
|
||||
const selfClear = !contract.customsClearingEnabled;
|
||||
const files = contract.files ?? [];
|
||||
const contractPdf = files.find((f) => f.code === "contract");
|
||||
// Signature files (code `signature_<role>`) are baked into the contract PDF —
|
||||
// don't list them as standalone documents in the Documents tab.
|
||||
const contractDocuments = files.filter(
|
||||
(f) => !f.code.startsWith("signature_"),
|
||||
);
|
||||
const hasContractDocument = Boolean(
|
||||
contractPdf || contract.contractGeneratedAt,
|
||||
);
|
||||
@@ -406,9 +441,9 @@ export default function ContractRequestDetailPage() {
|
||||
value="documents"
|
||||
leftSection={<Files size={16} />}
|
||||
rightSection={
|
||||
files.length > 0 ? (
|
||||
contractDocuments.length + profileDocuments.length > 0 ? (
|
||||
<Badge size="xs" variant="light" color="gray" radius="sm">
|
||||
{files.length}
|
||||
{contractDocuments.length + profileDocuments.length}
|
||||
</Badge>
|
||||
) : null
|
||||
}
|
||||
@@ -453,7 +488,18 @@ export default function ContractRequestDetailPage() {
|
||||
) : currentTab === "documents" ? (
|
||||
<Stack gap="lg">
|
||||
<ContractDocumentsCard
|
||||
files={files}
|
||||
files={contractDocuments}
|
||||
onView={handleViewFile}
|
||||
onDownload={handleDownloadFile}
|
||||
/>
|
||||
<ContractDocumentsCard
|
||||
files={profileDocuments}
|
||||
title="Customer profile documents"
|
||||
emptyText={
|
||||
profileDocumentsQuery.isLoading
|
||||
? "Loading customer documents…"
|
||||
: "No profile documents on file for this customer."
|
||||
}
|
||||
onView={handleViewFile}
|
||||
onDownload={handleDownloadFile}
|
||||
/>
|
||||
|
||||
@@ -135,9 +135,11 @@ export default function CustomerDetailPage() {
|
||||
}),
|
||||
);
|
||||
|
||||
const bookings = bookingsQuery.data ?? [];
|
||||
const documents = documentsQuery.data ?? [];
|
||||
const payments = paymentsQuery.data ?? [];
|
||||
const bookings = Array.isArray(bookingsQuery.data) ? bookingsQuery.data : [];
|
||||
const documents = Array.isArray(documentsQuery.data)
|
||||
? documentsQuery.data
|
||||
: [];
|
||||
const payments = Array.isArray(paymentsQuery.data) ? paymentsQuery.data : [];
|
||||
const invoices = invoicesQuery.data?.items ?? [];
|
||||
const invoiceTotal = invoicesQuery.data?.total ?? 0;
|
||||
const invoicePageCount = Math.max(
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Paper,
|
||||
RingProgress,
|
||||
Stack,
|
||||
Tabs,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
@@ -25,10 +26,12 @@ import {
|
||||
LayoutGrid,
|
||||
Navigation,
|
||||
Package,
|
||||
PackageCheck,
|
||||
Route as RouteIcon,
|
||||
Send,
|
||||
Train,
|
||||
Weight,
|
||||
Workflow as WorkflowIcon,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
@@ -46,6 +49,7 @@ import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/Imp
|
||||
import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog";
|
||||
import { ScheduleBatchPanel } from "@/components/trainScheduling/ScheduleBatchPanel";
|
||||
import { ScheduleBookingsStep } from "@/components/trainScheduling/ScheduleBookingsStep";
|
||||
import { ScheduleWorkspacePanel } from "@/components/trainScheduling/ScheduleWorkspacePanel";
|
||||
import { FreightTypeBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
|
||||
import {
|
||||
RouteCorridor,
|
||||
@@ -1053,6 +1057,18 @@ export default function TrainScheduleV2DetailPage() {
|
||||
</Paper>
|
||||
) : null}
|
||||
|
||||
<Tabs defaultValue="workflow" radius="md" color="edr-green" keepMounted={false}>
|
||||
<Tabs.List mb="md">
|
||||
<Tabs.Tab value="workflow" leftSection={<WorkflowIcon size={16} />}>
|
||||
Workflow
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="workspace" leftSection={<PackageCheck size={16} />}>
|
||||
Workspace
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="workflow">
|
||||
<Stack gap="lg">
|
||||
<Paper radius="xl" p="lg">
|
||||
<Stack gap="lg">
|
||||
{/* Workflow header with ring progress */}
|
||||
@@ -1110,7 +1126,20 @@ export default function TrainScheduleV2DetailPage() {
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<ScheduleBatchPanel schedule={schedule} />
|
||||
<ScheduleBatchPanel schedule={schedule} />
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="workspace">
|
||||
<ScheduleWorkspacePanel
|
||||
schedule={schedule}
|
||||
onChanged={() => {
|
||||
autoPreviewedRef.current = false;
|
||||
void detailQuery.refetch();
|
||||
}}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
|
||||
{scheduleId ? (
|
||||
<RescheduleTrainDialog
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
|
||||
import { Button, Card, Group, NumberInput, Stack } from "@mantine/core";
|
||||
|
||||
import { PageContainer, PageHeader } from "@/components/page";
|
||||
import DurationField from "@/components/trainScheduling/DurationField";
|
||||
import { trainSchedulingService } from "@/services/trainScheduling.service";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import type { TrainSchedulingGlobalRules } from "@/types/trainScheduling";
|
||||
@@ -19,32 +20,66 @@ export default function TrainSchedulingGlobalRulesPage() {
|
||||
void (async () => {
|
||||
try {
|
||||
const rules = await trainSchedulingService.getGlobalRules();
|
||||
setForm(rules);
|
||||
// `numeric` columns come back from the API as strings (e.g. "250.00").
|
||||
// Coerce every field to a real number so Mantine's controlled
|
||||
// NumberInput edits cleanly (a string value fights the caret) and the
|
||||
// default can be cleared and replaced.
|
||||
const numeric: Partial<Record<keyof TrainSchedulingGlobalRules, number | string>> = {};
|
||||
for (const [key, value] of Object.entries(rules)) {
|
||||
if (key === "id") continue;
|
||||
const num = value === "" || value == null ? "" : Number(value);
|
||||
numeric[key as keyof TrainSchedulingGlobalRules] =
|
||||
typeof num === "number" && Number.isNaN(num) ? "" : num;
|
||||
}
|
||||
setForm(numeric);
|
||||
} catch {
|
||||
toast({ title: "Failed to load train scheduling rules", variant: "destructive" });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [toast]);
|
||||
// Run once on mount only. `toast` from useToast is a fresh function every
|
||||
// render — listing it here re-fired the effect on every render, refetching
|
||||
// the rules and overwriting whatever the user was typing (values snapped
|
||||
// back to the saved defaults).
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
// Every field must hold a real number — an empty box (cleared but not
|
||||
// refilled) must not silently save as 0. Collect the numeric payload and
|
||||
// reject if any value is blank or NaN.
|
||||
const fields: (keyof TrainSchedulingGlobalRules)[] = [
|
||||
"maxTrainLengthMeters",
|
||||
"maxTrainWeightTons",
|
||||
"maxWagonsPerTrain",
|
||||
"max20ftContainerWeightTons",
|
||||
"max20ftPairWeightDiffTons",
|
||||
"importWindowLeadDays",
|
||||
"exportBookingLeadHours",
|
||||
"windowOpenHour",
|
||||
"windowDurationHours",
|
||||
"docReviewMinutes",
|
||||
"paymentWindowMinutes",
|
||||
"reopenDelayMinutes",
|
||||
];
|
||||
const payload: Partial<Record<keyof TrainSchedulingGlobalRules, number>> = {};
|
||||
for (const key of fields) {
|
||||
const raw = form[key];
|
||||
const num = raw === "" || raw == null ? NaN : Number(raw);
|
||||
if (!Number.isFinite(num)) {
|
||||
toast({
|
||||
title: "All fields are required — fill every value before saving.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
payload[key] = num;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const updated = await trainSchedulingService.updateGlobalRules({
|
||||
maxTrainLengthMeters: Number(form.maxTrainLengthMeters),
|
||||
maxTrainWeightTons: Number(form.maxTrainWeightTons),
|
||||
maxWagonsPerTrain: Number(form.maxWagonsPerTrain),
|
||||
max20ftContainerWeightTons: Number(form.max20ftContainerWeightTons),
|
||||
max20ftPairWeightDiffTons: Number(form.max20ftPairWeightDiffTons),
|
||||
importWindowLeadDays: Number(form.importWindowLeadDays),
|
||||
exportBookingLeadHours: Number(form.exportBookingLeadHours),
|
||||
windowOpenHour: Number(form.windowOpenHour),
|
||||
windowDurationHours: Number(form.windowDurationHours),
|
||||
docReviewMinutes: Number(form.docReviewMinutes),
|
||||
paymentWindowMinutes: Number(form.paymentWindowMinutes),
|
||||
reopenDelayMinutes: Number(form.reopenDelayMinutes),
|
||||
});
|
||||
const updated = await trainSchedulingService.updateGlobalRules(payload);
|
||||
setForm(updated);
|
||||
toast({ title: "Train scheduling rules saved" });
|
||||
} catch {
|
||||
@@ -70,6 +105,8 @@ export default function TrainSchedulingGlobalRulesPage() {
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({ ...current, maxTrainLengthMeters: value }))
|
||||
}
|
||||
clampBehavior="none"
|
||||
allowDecimal
|
||||
min={1}
|
||||
disabled={loading}
|
||||
/>
|
||||
@@ -80,6 +117,8 @@ export default function TrainSchedulingGlobalRulesPage() {
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({ ...current, maxTrainWeightTons: value }))
|
||||
}
|
||||
clampBehavior="none"
|
||||
allowDecimal
|
||||
min={1}
|
||||
disabled={loading}
|
||||
/>
|
||||
@@ -89,6 +128,8 @@ export default function TrainSchedulingGlobalRulesPage() {
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({ ...current, maxWagonsPerTrain: value }))
|
||||
}
|
||||
clampBehavior="none"
|
||||
allowDecimal
|
||||
min={1}
|
||||
disabled={loading}
|
||||
/>
|
||||
@@ -102,6 +143,8 @@ export default function TrainSchedulingGlobalRulesPage() {
|
||||
max20ftContainerWeightTons: value,
|
||||
}))
|
||||
}
|
||||
clampBehavior="none"
|
||||
allowDecimal
|
||||
min={0.001}
|
||||
disabled={loading}
|
||||
/>
|
||||
@@ -115,6 +158,8 @@ export default function TrainSchedulingGlobalRulesPage() {
|
||||
max20ftPairWeightDiffTons: value,
|
||||
}))
|
||||
}
|
||||
clampBehavior="none"
|
||||
allowDecimal
|
||||
min={0}
|
||||
disabled={loading}
|
||||
/>
|
||||
@@ -127,20 +172,22 @@ export default function TrainSchedulingGlobalRulesPage() {
|
||||
title="Booking windows"
|
||||
subtitle="Import booking-day cycle and export lead time. All times in Addis Ababa (EAT)."
|
||||
/>
|
||||
<NumberInput
|
||||
label="Import window lead (days)"
|
||||
description="The single booking day opens this many days before departure"
|
||||
<DurationField
|
||||
label="Import window lead"
|
||||
description="The single booking day opens this long before departure"
|
||||
value={form.importWindowLeadDays ?? ""}
|
||||
nativeUnit="days"
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({ ...current, importWindowLeadDays: value }))
|
||||
}
|
||||
min={0}
|
||||
disabled={loading}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Export booking lead (hours)"
|
||||
description="Export bookings are accepted first-come-first-serve starting this many hours before departure"
|
||||
<DurationField
|
||||
label="Export booking lead"
|
||||
description="Export bookings are accepted first-come-first-serve starting this long before departure"
|
||||
value={form.exportBookingLeadHours ?? ""}
|
||||
nativeUnit="hours"
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({ ...current, exportBookingLeadHours: value }))
|
||||
}
|
||||
@@ -154,45 +201,50 @@ export default function TrainSchedulingGlobalRulesPage() {
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({ ...current, windowOpenHour: value }))
|
||||
}
|
||||
clampBehavior="none"
|
||||
allowDecimal
|
||||
min={0}
|
||||
max={23}
|
||||
disabled={loading}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Window duration (hours)"
|
||||
<DurationField
|
||||
label="Window duration"
|
||||
description="How long the import booking window stays open"
|
||||
value={form.windowDurationHours ?? ""}
|
||||
nativeUnit="hours"
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({ ...current, windowDurationHours: value }))
|
||||
}
|
||||
min={0.25}
|
||||
max={12}
|
||||
step={0.25}
|
||||
min={1}
|
||||
disabled={loading}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Document review (minutes)"
|
||||
<DurationField
|
||||
label="Document review"
|
||||
description="Max staff time to accept booking documents after the window closes"
|
||||
value={form.docReviewMinutes ?? ""}
|
||||
nativeUnit="minutes"
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({ ...current, docReviewMinutes: value }))
|
||||
}
|
||||
min={0}
|
||||
disabled={loading}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Payment window (minutes)"
|
||||
<DurationField
|
||||
label="Payment window"
|
||||
description="Time a selected customer has to pay before the slot expires"
|
||||
value={form.paymentWindowMinutes ?? ""}
|
||||
nativeUnit="minutes"
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({ ...current, paymentWindowMinutes: value }))
|
||||
}
|
||||
min={1}
|
||||
disabled={loading}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Reopen delay (minutes)"
|
||||
description="Delay after window close before reopening when the train is not full (90 = 11:00 close → 12:30 reopen)"
|
||||
<DurationField
|
||||
label="Reopen delay"
|
||||
description="Delay after window close before reopening when the train is not full (90 min = 11:00 close → 12:30 reopen)"
|
||||
value={form.reopenDelayMinutes ?? ""}
|
||||
nativeUnit="minutes"
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({ ...current, reopenDelayMinutes: value }))
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ import type {
|
||||
BatchBoardSchedule,
|
||||
BatchBoardScheduleDetail,
|
||||
BookableSchedule,
|
||||
BookingWindow,
|
||||
CompositionRemovalEntry,
|
||||
CreateTrainSchedulePayload,
|
||||
EligibleContainerBookingsResponse,
|
||||
@@ -283,6 +284,18 @@ export const api = {
|
||||
],
|
||||
),
|
||||
|
||||
contractBookingWindows: endpoint<{ contractId: string }, BookingWindow[]>(
|
||||
"train-scheduling",
|
||||
"contract-booking-windows",
|
||||
({ contractId }) =>
|
||||
trainSchedulingService.getContractBookingWindows(contractId),
|
||||
({ contractId }) => [
|
||||
...QUERY_KEYS.TRAIN_SCHEDULING.ROOT,
|
||||
"contract-booking-windows",
|
||||
contractId,
|
||||
],
|
||||
),
|
||||
|
||||
availableDays: endpoint<
|
||||
{ originYardId?: string | null; destinationYardId?: string | null },
|
||||
string[]
|
||||
|
||||
@@ -17,6 +17,8 @@ export interface BookingListFilter {
|
||||
// customerId?: string;
|
||||
companyId?: string;
|
||||
freightType?: string;
|
||||
/** ONE_TIME | GENERAL_CONTRACT — the booking-kind tab filter. */
|
||||
bookingType?: string;
|
||||
tradeDirection?: string;
|
||||
paymentCurrency?: string;
|
||||
page?: number;
|
||||
@@ -125,6 +127,7 @@ export const bookingsService = {
|
||||
if (filter.pageSize != null) params.pageSize = filter.pageSize;
|
||||
if (filter.companyId) params.companyId = filter.companyId;
|
||||
if (filter.freightType) params.freightType = filter.freightType;
|
||||
if (filter.bookingType) params.bookingType = filter.bookingType;
|
||||
if (filter.tradeDirection) params.tradeDirection = filter.tradeDirection;
|
||||
if (filter.paymentCurrency) params.paymentCurrency = filter.paymentCurrency;
|
||||
}
|
||||
@@ -148,6 +151,7 @@ export const bookingsService = {
|
||||
if (filter.assignedToSchedule) params.assignedToSchedule = filter.assignedToSchedule;
|
||||
if (filter.companyId) params.companyId = filter.companyId;
|
||||
if (filter.freightType) params.freightType = filter.freightType;
|
||||
if (filter.bookingType) params.bookingType = filter.bookingType;
|
||||
if (filter.tradeDirection) params.tradeDirection = filter.tradeDirection;
|
||||
if (filter.paymentCurrency) params.paymentCurrency = filter.paymentCurrency;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
BatchBoardSchedule,
|
||||
BatchBoardScheduleDetail,
|
||||
BookableSchedule,
|
||||
BookingWindow,
|
||||
AssignBookingsPayload,
|
||||
CompositionRemovalEntry,
|
||||
UnassignedBookingsResponse,
|
||||
@@ -108,6 +109,19 @@ export const trainSchedulingService = {
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
/**
|
||||
* Booking windows for every route/schedule of a contract. A window with
|
||||
* `isOpenNow === true` means GL may create a booking right now for that route.
|
||||
*/
|
||||
getContractBookingWindows: async (
|
||||
contractId: string,
|
||||
): Promise<BookingWindow[]> => {
|
||||
const response = await client.get<BookingWindow[]>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.CONTRACT_BOOKING_WINDOWS(contractId),
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
getBookableSchedules: async (
|
||||
originYardId?: string,
|
||||
destinationYardId?: string,
|
||||
|
||||
@@ -69,6 +69,17 @@ export interface BookingCompany {
|
||||
website?: string | null;
|
||||
}
|
||||
|
||||
/** One physical container under a line — its own number + verified gross mass. */
|
||||
export interface BookingContainerUnit {
|
||||
id: string;
|
||||
containerNumber: string;
|
||||
sealNumber?: string | null;
|
||||
vgmTons: number;
|
||||
isHazardous?: boolean;
|
||||
isReefer?: boolean;
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
export interface BookingContainerLine {
|
||||
id: string;
|
||||
containerTypeId: string;
|
||||
@@ -80,6 +91,8 @@ export interface BookingContainerLine {
|
||||
label?: string;
|
||||
sizeFt?: number;
|
||||
};
|
||||
/** Per-physical-container rows (number + weight). Empty when not captured. */
|
||||
units?: BookingContainerUnit[];
|
||||
}
|
||||
|
||||
export interface BookingApprovalStep {
|
||||
|
||||
@@ -325,6 +325,25 @@ export interface BatchBoardScheduleDetail {
|
||||
allocationViolations: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A booking window for one of a contract's routes/schedules. `isOpenNow === true`
|
||||
* means a booking may be created right now for that route. Times are ISO strings;
|
||||
* render them in EAT (Africa/Addis_Ababa).
|
||||
*/
|
||||
export interface BookingWindow {
|
||||
scheduleId: string;
|
||||
direction: string | null;
|
||||
windowPhase: BookingWindowPhase | null;
|
||||
isOpenNow: boolean;
|
||||
windowOpensAt: string | null;
|
||||
windowClosesAt: string | null;
|
||||
bookingWindowStatus: string;
|
||||
bookingCycleNo: number;
|
||||
departureDate: string;
|
||||
origin: string | null;
|
||||
destination: string | null;
|
||||
}
|
||||
|
||||
export interface WagonAllocationAttemptResult {
|
||||
assignedBookingIds: string[];
|
||||
deferred: Array<{ id: string; reference: string; reason: string }>;
|
||||
@@ -366,6 +385,11 @@ export interface TrainScheduleDetail {
|
||||
freightType?: FreightType | null;
|
||||
trainNumber?: string | null;
|
||||
direction?: string | null;
|
||||
windowPhase?: BookingWindowPhase | string | null;
|
||||
windowOpensAt?: string | null;
|
||||
windowClosesAt?: string | null;
|
||||
docReviewEndsAt?: string | null;
|
||||
paymentPhaseEndsAt?: string | null;
|
||||
route?: {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button, Modal, Text, type ButtonProps } from "@mantine/core";
|
||||
import { AlertCircle, Upload } from "lucide-react";
|
||||
import { AlertCircle, Upload, type LucideIcon } from "lucide-react";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import { ContractClearancePanel } from "@/pages/contracts/ContractClearancePanel";
|
||||
@@ -13,6 +13,10 @@ interface ContractClearanceActionProps {
|
||||
label?: string;
|
||||
size?: ButtonProps["size"];
|
||||
urgent?: boolean;
|
||||
/** GL's turn — render as a calm status button, not a call to action. */
|
||||
waiting?: boolean;
|
||||
/** Icon override from the phase-aware action derivation. */
|
||||
icon?: LucideIcon;
|
||||
}
|
||||
|
||||
export function ContractClearanceAction({
|
||||
@@ -20,6 +24,8 @@ export function ContractClearanceAction({
|
||||
label: labelProp,
|
||||
size = "xs",
|
||||
urgent = false,
|
||||
waiting = false,
|
||||
icon: iconProp,
|
||||
}: ContractClearanceActionProps) {
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
|
||||
@@ -38,7 +44,13 @@ export function ContractClearanceAction({
|
||||
return urgent ? "Upload clearance" : "Manage clearance";
|
||||
}, [labelProp, clearance, urgent]);
|
||||
|
||||
const Icon = urgent || label.includes("Update") ? AlertCircle : Upload;
|
||||
const Icon =
|
||||
iconProp ?? (urgent || label.includes("Update") ? AlertCircle : Upload);
|
||||
|
||||
// Urgent (customer's turn) = filled orange so it stands out among the green
|
||||
// actions; waiting (GL's turn) = calm subtle gray; default = brand green.
|
||||
const color = urgent ? "orange" : waiting ? "gray" : "edr-green";
|
||||
const variant = waiting ? "light" : "filled";
|
||||
|
||||
return (
|
||||
<ModalSafeWrapper>
|
||||
@@ -47,7 +59,8 @@ export function ContractClearanceAction({
|
||||
radius="md"
|
||||
fw={700}
|
||||
fz={13}
|
||||
color="edr-green"
|
||||
color={color}
|
||||
variant={variant}
|
||||
leftSection={<Icon size={14} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
@@ -46,6 +46,8 @@ export function ContractCustomerAction({
|
||||
label={action.label}
|
||||
size={size}
|
||||
urgent={action.urgent}
|
||||
waiting={action.waiting}
|
||||
icon={action.icon}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,8 +4,10 @@ import {
|
||||
CreditCard,
|
||||
Eye,
|
||||
FileSignature,
|
||||
Hourglass,
|
||||
PackagePlus,
|
||||
PencilLine,
|
||||
Receipt,
|
||||
RotateCcw,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
@@ -61,6 +63,8 @@ export type ContractCustomerAction =
|
||||
primary: boolean;
|
||||
icon: LucideIcon;
|
||||
urgent: boolean;
|
||||
/** True when it's GL's turn — render calm/informational, not a call to action. */
|
||||
waiting?: boolean;
|
||||
}
|
||||
| {
|
||||
type: "pay";
|
||||
@@ -126,14 +130,56 @@ export function deriveContractCustomerAction(
|
||||
|
||||
const clr = contractNeedsClearanceAction(contract);
|
||||
if (clr.show) {
|
||||
return {
|
||||
type: "clearance",
|
||||
contractId: id,
|
||||
label: clr.urgent ? "Upload clearance" : "Update clearance",
|
||||
primary: true,
|
||||
icon: Upload,
|
||||
urgent: clr.urgent,
|
||||
};
|
||||
// Refine the generic clearance action by the persisted clearance phase so
|
||||
// the button says what the customer actually has to do right now (e.g.
|
||||
// "Pay duty & upload slip" during CUSTOMER_DUTY, not "Update clearance").
|
||||
const phase = contract.clearancePhase ?? null;
|
||||
switch (phase) {
|
||||
case "CUSTOMER_INTAKE":
|
||||
return {
|
||||
type: "clearance",
|
||||
contractId: id,
|
||||
label: "Upload clearance documents",
|
||||
primary: true,
|
||||
icon: Upload,
|
||||
urgent: true,
|
||||
};
|
||||
case "CUSTOMER_DUTY":
|
||||
return {
|
||||
type: "clearance",
|
||||
contractId: id,
|
||||
label: "Pay duty & upload slip",
|
||||
primary: true,
|
||||
icon: Receipt,
|
||||
urgent: true,
|
||||
};
|
||||
case "GL_ET_REVIEW":
|
||||
case "GL_DJ_COLLECTION":
|
||||
case "GL_ET_OUTPUT":
|
||||
case "GL_ET_POST_CLEARANCE":
|
||||
case "GL_DJ_LOADING":
|
||||
case "POST_TRANSIT":
|
||||
// GL's turn — nothing for the customer to do; show a calm status.
|
||||
return {
|
||||
type: "clearance",
|
||||
contractId: id,
|
||||
label: "Clearance in progress",
|
||||
primary: false,
|
||||
icon: Hourglass,
|
||||
urgent: false,
|
||||
waiting: true,
|
||||
};
|
||||
default:
|
||||
// No persisted phase (legacy / early cycles) — keep the status-derived label.
|
||||
return {
|
||||
type: "clearance",
|
||||
contractId: id,
|
||||
label: clr.urgent ? "Upload clearance" : "Update clearance",
|
||||
primary: true,
|
||||
icon: Upload,
|
||||
urgent: clr.urgent,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
|
||||
@@ -148,6 +148,8 @@ export const URL_CONSTANTS = {
|
||||
AVAILABLE_DAYS: "/api/train-scheduling/available-days",
|
||||
AVAILABLE_DAYS_FOR_CARGO: "/api/train-scheduling/available-days-for-cargo",
|
||||
MY_BOOKING_WINDOWS: "/api/train-scheduling/my-booking-windows",
|
||||
CONTRACT_BOOKING_WINDOWS: (contractId: string) =>
|
||||
`/api/train-scheduling/contracts/${contractId}/booking-windows`,
|
||||
},
|
||||
|
||||
PAYMENTS: {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { contractNeedsClearanceAction } from "@/components/customer-actions/deri
|
||||
export interface ActionItem {
|
||||
id: string;
|
||||
/** What the customer must do — drives the icon, label and modal. */
|
||||
kind: "clearance" | "sign" | "book" | "pay";
|
||||
kind: "clearance" | "duty" | "sign" | "book" | "pay";
|
||||
/** The contract/booking reference for display. */
|
||||
reference: string;
|
||||
/** Short human description of the action. */
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
FilePlus2,
|
||||
FileSignature,
|
||||
PackagePlus,
|
||||
Receipt,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import type { Freight } from "@edr/types";
|
||||
@@ -34,6 +35,7 @@ const KIND_META: Record<
|
||||
{ icon: typeof Upload; label: string; color: string }
|
||||
> = {
|
||||
clearance: { icon: Upload, label: "Clearance", color: "edr-green" },
|
||||
duty: { icon: Receipt, label: "Duty / tax", color: "orange" },
|
||||
sign: { icon: FileSignature, label: "Sign", color: "blue" },
|
||||
book: { icon: PackagePlus, label: "Book", color: "violet" },
|
||||
pay: { icon: CreditCard, label: "Payment", color: "orange" },
|
||||
@@ -96,6 +98,19 @@ export function ActionNeededSection({
|
||||
const awaiting =
|
||||
c.status === "AWAITING_CLEARANCE_DOCUMENTS" ||
|
||||
view?.clearanceStatus === "AWAITING_DOCUMENTS";
|
||||
// Duty phase: the customer's task is paying duty/tax and uploading the
|
||||
// slip — a distinct, money action, not a generic document upload.
|
||||
if (view?.phase === "CUSTOMER_DUTY") {
|
||||
out.push({
|
||||
id: `duty-${c.id}`,
|
||||
kind: "duty",
|
||||
reference: c.reference,
|
||||
description: "Duty / tax payment due — pay and upload the slip",
|
||||
targetId: c.id,
|
||||
urgent: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Only surface when there's something the customer can do: a query, or the
|
||||
// contract is awaiting their (re)upload.
|
||||
if (queried === 0 && !awaiting) return;
|
||||
@@ -162,6 +177,10 @@ export function ActionNeededSection({
|
||||
case "clearance":
|
||||
setClearanceId(item.targetId);
|
||||
break;
|
||||
case "duty":
|
||||
// Duty advice + payment-slip upload live on the contract detail page.
|
||||
navigate(`/contracts/${item.targetId}`);
|
||||
break;
|
||||
case "pay":
|
||||
setPayItem(item);
|
||||
break;
|
||||
@@ -249,6 +268,8 @@ export function ActionNeededSection({
|
||||
leftSection={
|
||||
item.kind === "clearance" ? (
|
||||
<Upload size={14} />
|
||||
) : item.kind === "duty" ? (
|
||||
<Receipt size={14} />
|
||||
) : (
|
||||
<FilePlus2 size={14} />
|
||||
)
|
||||
@@ -256,13 +277,15 @@ export function ActionNeededSection({
|
||||
>
|
||||
{item.kind === "pay"
|
||||
? "Pay now"
|
||||
: item.kind === "sign"
|
||||
? "Sign"
|
||||
: item.kind === "book"
|
||||
? "Book"
|
||||
: item.urgent
|
||||
? "Upload documents"
|
||||
: "Upload"}
|
||||
: item.kind === "duty"
|
||||
? "Pay duty & upload slip"
|
||||
: item.kind === "sign"
|
||||
? "Sign"
|
||||
: item.kind === "book"
|
||||
? "Book"
|
||||
: item.urgent
|
||||
? "Upload documents"
|
||||
: "Upload"}
|
||||
</Button>
|
||||
</Group>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Box, Group, Skeleton, Stack, Text } from "@mantine/core";
|
||||
import { Box, Button, Group, Skeleton, Stack, Text } from "@mantine/core";
|
||||
import { memo } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { ArrowRight, CalendarClock } from "lucide-react";
|
||||
import { ArrowRight, CalendarClock, PackagePlus } from "lucide-react";
|
||||
import { CountdownTimer } from "@edr/ui-common";
|
||||
import type { MyBookingWindow } from "@/services/bookings.service";
|
||||
import { Card } from "./Card";
|
||||
|
||||
@@ -43,6 +44,29 @@ function windowLabel(w: MyBookingWindow): string {
|
||||
return (w.windowPhase ?? w.bookingWindowStatus).replace(/_/g, " ");
|
||||
}
|
||||
|
||||
/**
|
||||
* The deadline + label for whichever phase the window is currently in. Phases
|
||||
* run: window open (closes at windowClosesAt) → document review (docReviewEndsAt)
|
||||
* → payment (paymentPhaseEndsAt). Returns null when no phase is timing down.
|
||||
*/
|
||||
function phaseCountdown(
|
||||
w: MyBookingWindow,
|
||||
): { label: string; deadline: string } | null {
|
||||
switch (w.windowPhase) {
|
||||
case "OPEN":
|
||||
if (w.windowClosesAt) return { label: "Window closes in", deadline: w.windowClosesAt };
|
||||
return null;
|
||||
case "DOC_REVIEW":
|
||||
if (w.docReviewEndsAt) return { label: "Document review ends in", deadline: w.docReviewEndsAt };
|
||||
return null;
|
||||
case "PAYMENT":
|
||||
if (w.paymentPhaseEndsAt) return { label: "Payment due in", deadline: w.paymentPhaseEndsAt };
|
||||
return null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function Pill({
|
||||
children,
|
||||
bg,
|
||||
@@ -162,11 +186,7 @@ export const UpcomingWindowsSection = memo(function UpcomingWindowsSection({
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${w.isOpenNow ? "#CDEBDD" : BORDER}`,
|
||||
backgroundColor: w.isOpenNow ? "#F4FBF7" : undefined,
|
||||
cursor: w.isOpenNow ? "pointer" : "default",
|
||||
}}
|
||||
onClick={
|
||||
w.isOpenNow ? () => navigate("/contracts") : undefined
|
||||
}
|
||||
>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
@@ -184,11 +204,45 @@ export const UpcomingWindowsSection = memo(function UpcomingWindowsSection({
|
||||
{windowLabel(w)} · Departs {fmtDay(w.departureDate)}
|
||||
</Text>
|
||||
</Group>
|
||||
{(() => {
|
||||
const cd = phaseCountdown(w);
|
||||
return cd ? (
|
||||
<Box mt={4}>
|
||||
<CountdownTimer
|
||||
deadline={cd.deadline}
|
||||
label={cd.label}
|
||||
size="xs"
|
||||
/>
|
||||
</Box>
|
||||
) : null;
|
||||
})()}
|
||||
</Box>
|
||||
|
||||
<Group gap={8} wrap="nowrap" style={{ flexShrink: 0 }}>
|
||||
<DirectionBadge direction={w.direction} />
|
||||
<StatusBadge window={w} />
|
||||
{/* ONE_TIME contracts book via their own single-shipment flow,
|
||||
not window drawdown — show the window + countdown but no
|
||||
"Book now" entry. */}
|
||||
{w.isOpenNow && w.contractKind !== "ONE_TIME" && (
|
||||
<Button
|
||||
size="xs"
|
||||
radius="md"
|
||||
color="edr-green"
|
||||
leftSection={<PackagePlus size={14} />}
|
||||
// Book straight against the row's contract when it carries
|
||||
// one; otherwise fall back to the contract list to pick.
|
||||
onClick={() =>
|
||||
navigate(
|
||||
w.contractId
|
||||
? `/contracts/${w.contractId}/bookings/new`
|
||||
: "/contracts",
|
||||
)
|
||||
}
|
||||
>
|
||||
Book now
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
))}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { Box, Group, Text } from "@mantine/core";
|
||||
import { Group } from "@mantine/core";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { CreditCard, Download, Eye } from "lucide-react";
|
||||
import { CreditCard } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { isViewable } from "@edr/ui-common";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import { fileViewUrl } from "@/constants/apiConfig";
|
||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||
import { invoicesService } from "@/services/invoices.service";
|
||||
import { paymentsService, type PaymentMethod } from "@/services/payments.service";
|
||||
@@ -19,9 +17,8 @@ import { ClearanceCard } from "./components/ClearanceCard";
|
||||
import { ContainersCard } from "./components/ContainersCard";
|
||||
import { ContractCard } from "./components/ContractCard";
|
||||
import { CustomerTruckAssignmentCard } from "./components/CustomerTruckAssignmentCard";
|
||||
import { DocRow, IconSquare } from "./components/Documents";
|
||||
import { KeyFactsStrip } from "./components/KeyFactsStrip";
|
||||
import { BodyGrid, CardTitle, PageShell, SectionCard } from "./components/layout";
|
||||
import { BodyGrid, PageShell } from "./components/layout";
|
||||
import {
|
||||
CancelledBanner,
|
||||
ConsolidationPairedNotice,
|
||||
@@ -51,7 +48,7 @@ export function ReadonlyBookingView({
|
||||
useScrollToHash();
|
||||
const status = booking.status as string;
|
||||
const [payModalOpen, setPayModalOpen] = useState(false);
|
||||
const { view, viewer } = useFileViewer();
|
||||
const { viewer } = useFileViewer();
|
||||
|
||||
// Re-book opens the New Shipment Booking form for the same contract, not the
|
||||
// New Contract page. Fall back to /contracts/new only if the link is missing.
|
||||
@@ -160,7 +157,6 @@ export function ReadonlyBookingView({
|
||||
)
|
||||
}
|
||||
menuActions={{
|
||||
onViewContract: booking.signedByCeoAt ? () => {} : undefined,
|
||||
onRebook,
|
||||
onSupport: () => navigate("/support"),
|
||||
}}
|
||||
@@ -199,7 +195,7 @@ export function ReadonlyBookingView({
|
||||
|
||||
<KeyFactsStrip booking={booking} />
|
||||
|
||||
<ContractCard booking={booking} navigate={navigate} />
|
||||
<ContractCard booking={booking} />
|
||||
|
||||
{isClearance && <ClearanceCard booking={booking} />}
|
||||
|
||||
@@ -220,52 +216,6 @@ export function ReadonlyBookingView({
|
||||
)}
|
||||
<WarehousePaymentsSection bookingId={booking.id} />
|
||||
|
||||
{booking.files && booking.files.length > 0 && (
|
||||
<SectionCard>
|
||||
<Group justify="space-between" align="center" mb="md">
|
||||
<CardTitle>Documents</CardTitle>
|
||||
<Text fz="12.5px" fw={600} c="#9AA8B5">
|
||||
{booking.files.length} files
|
||||
</Text>
|
||||
</Group>
|
||||
<Box>
|
||||
{booking.files.map((file, i) => (
|
||||
<DocRow
|
||||
key={file.id}
|
||||
last={i === booking.files!.length - 1}
|
||||
title={file.name}
|
||||
meta={file.code.replace(/_/g, " ")}
|
||||
status="verified"
|
||||
action={
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{isViewable({
|
||||
name: file.name,
|
||||
url: fileViewUrl(file.id),
|
||||
mimeType: file.mimeType,
|
||||
}) && (
|
||||
<IconSquare
|
||||
icon={<Eye size={16} />}
|
||||
onClick={() =>
|
||||
view({
|
||||
name: file.name,
|
||||
url: fileViewUrl(file.id),
|
||||
mimeType: file.mimeType,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<IconSquare
|
||||
href={fileViewUrl(file.id, true)}
|
||||
icon={<Download size={16} />}
|
||||
/>
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
<ActivityCard booking={booking} />
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { Box, Button, Group, Paper, Text } from "@mantine/core";
|
||||
import { FileSignature } from "lucide-react";
|
||||
import type { useNavigate } from "react-router-dom";
|
||||
import { Box, Group, Paper, Text } from "@mantine/core";
|
||||
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
@@ -37,13 +35,7 @@ const CONTRACT_CONFIG: Record<
|
||||
},
|
||||
};
|
||||
|
||||
export function ContractCard({
|
||||
booking,
|
||||
navigate,
|
||||
}: {
|
||||
booking: Freight.IBooking;
|
||||
navigate: ReturnType<typeof useNavigate>;
|
||||
}) {
|
||||
export function ContractCard({ booking }: { booking: Freight.IBooking }) {
|
||||
const c = CONTRACT_CONFIG[booking.status as string];
|
||||
if (!c) return null;
|
||||
|
||||
@@ -76,20 +68,6 @@ export function ContractCard({
|
||||
{c.description}
|
||||
</Text>
|
||||
</Box>
|
||||
{c.buttonLabel && (
|
||||
<Button
|
||||
onClick={() => navigate(`/bookings/${booking.id}/contract`)}
|
||||
radius={10}
|
||||
color="edr-green"
|
||||
leftSection={<FileSignature size={18} />}
|
||||
styles={{
|
||||
root: { height: 42, paddingInline: 18 },
|
||||
label: { fontSize: 13, fontWeight: 700 },
|
||||
}}
|
||||
>
|
||||
{c.buttonLabel}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
|
||||
@@ -15,6 +15,18 @@ const PHASE_LABELS: Record<string, string> = {
|
||||
POST_TRANSIT: "Transit",
|
||||
};
|
||||
|
||||
/** One-line hint under each phase label, for the vertical layout. */
|
||||
const PHASE_HINTS: Record<string, string> = {
|
||||
CUSTOMER_INTAKE: "You upload the required clearance documents",
|
||||
GL_ET_REVIEW: "Global Logistics reviews your documents in Ethiopia",
|
||||
GL_DJ_COLLECTION: "Delivery order collected in Djibouti",
|
||||
GL_ET_OUTPUT: "Customs declaration prepared",
|
||||
CUSTOMER_DUTY: "You pay the assessed duty / tax",
|
||||
GL_ET_POST_CLEARANCE: "Transit cleared and paperwork finalised",
|
||||
GL_DJ_LOADING: "Cargo loaded for departure",
|
||||
POST_TRANSIT: "In transit",
|
||||
};
|
||||
|
||||
const IMPORT_PHASES = [
|
||||
"CUSTOMER_INTAKE",
|
||||
"GL_ET_REVIEW",
|
||||
@@ -51,62 +63,92 @@ export function ClearancePhaseStepper({
|
||||
const current = clearance?.phase ?? phases[0];
|
||||
const activeIdx = phaseIndex(phases, current);
|
||||
|
||||
const dot = compact ? 26 : 30;
|
||||
const rowGap = compact ? 18 : 24;
|
||||
|
||||
// Vertical timeline: every phase is a row, so all steps stay visible on any
|
||||
// width without horizontal scrolling. The connector runs down between dots.
|
||||
return (
|
||||
<Group gap={0} wrap="nowrap" align="flex-start" style={{ overflowX: "auto" }}>
|
||||
<Stack gap={0}>
|
||||
{phases.map((phase, index) => {
|
||||
const isComplete = index < activeIdx;
|
||||
const isActive = index === activeIdx;
|
||||
const isLast = index === phases.length - 1;
|
||||
// const doneOrActive = isComplete || isActive;
|
||||
|
||||
return (
|
||||
<Box key={phase} style={{ flex: isLast ? "0 0 auto" : 1, minWidth: compact ? 72 : 88 }}>
|
||||
<Group gap={0} wrap="nowrap" align="center">
|
||||
<Stack gap={4} align="center" style={{ flexShrink: 0 }}>
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: compact ? 28 : 34,
|
||||
height: compact ? 28 : 34,
|
||||
borderRadius: "50%",
|
||||
background: isComplete ? BRAND_GREEN : isActive ? "white" : "var(--mantine-color-gray-1)",
|
||||
border: isActive
|
||||
? `2px solid ${BRAND_GREEN}`
|
||||
: isComplete
|
||||
? "2px solid transparent"
|
||||
: "2px solid var(--mantine-color-gray-3)",
|
||||
color: isComplete ? "white" : isActive ? BRAND_GREEN : "var(--mantine-color-gray-5)",
|
||||
}}
|
||||
>
|
||||
{isComplete ? <Check size={compact ? 14 : 16} strokeWidth={3} /> : null}
|
||||
</Box>
|
||||
<Text
|
||||
size={compact ? "10px" : "xs"}
|
||||
fw={isActive ? 600 : 500}
|
||||
c={isActive ? "edr-green.7" : isComplete ? "dark" : "dimmed"}
|
||||
ta="center"
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
>
|
||||
{PHASE_LABELS[phase] ?? phase}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Group key={phase} gap={12} wrap="nowrap" align="flex-start">
|
||||
{/* Dot + connector column */}
|
||||
<Stack gap={0} align="center" style={{ flexShrink: 0, alignSelf: "stretch" }}>
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: dot,
|
||||
height: dot,
|
||||
borderRadius: "50%",
|
||||
flexShrink: 0,
|
||||
background: isComplete
|
||||
? BRAND_GREEN
|
||||
: isActive
|
||||
? "white"
|
||||
: "var(--mantine-color-gray-1)",
|
||||
border: isActive
|
||||
? `2px solid ${BRAND_GREEN}`
|
||||
: isComplete
|
||||
? "2px solid transparent"
|
||||
: "2px solid var(--mantine-color-gray-3)",
|
||||
color: isComplete
|
||||
? "white"
|
||||
: isActive
|
||||
? BRAND_GREEN
|
||||
: "var(--mantine-color-gray-5)",
|
||||
}}
|
||||
>
|
||||
{isComplete ? (
|
||||
<Check size={compact ? 14 : 16} strokeWidth={3} />
|
||||
) : (
|
||||
<Text size="xs" fw={700}>
|
||||
{index + 1}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
{!isLast && (
|
||||
<Box
|
||||
style={{
|
||||
width: 2,
|
||||
flex: 1,
|
||||
height: 2,
|
||||
marginInline: 6,
|
||||
marginBottom: compact ? 16 : 20,
|
||||
minHeight: rowGap,
|
||||
marginBlock: 4,
|
||||
borderRadius: 2,
|
||||
background: isComplete ? BRAND_GREEN : "var(--mantine-color-gray-2)",
|
||||
background: isComplete
|
||||
? BRAND_GREEN
|
||||
: "var(--mantine-color-gray-2)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
{/* Label + hint */}
|
||||
<Box pb={isLast ? 0 : rowGap} style={{ minWidth: 0, paddingTop: 3 }}>
|
||||
<Text
|
||||
size="sm"
|
||||
fw={isActive ? 700 : 500}
|
||||
c={isActive ? "edr-green.7" : isComplete ? "dark" : "dimmed"}
|
||||
lh={1.2}
|
||||
>
|
||||
{PHASE_LABELS[phase] ?? phase}
|
||||
</Text>
|
||||
{PHASE_HINTS[phase] && (
|
||||
<Text size="xs" c="dimmed" mt={2} lh={1.3}>
|
||||
{PHASE_HINTS[phase]}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { Alert, Box, Button, Group, Paper, Stack, Text } from "@mantine/core";
|
||||
import { AlertTriangle, Download, Receipt, Upload } from "lucide-react";
|
||||
import { AlertTriangle, ArrowRight, Download, PackageCheck, Receipt, Upload } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import toast from "react-hot-toast";
|
||||
import type { Freight } from "@edr/types";
|
||||
@@ -87,7 +87,36 @@ export function ContractClearanceWorkflowBanner({
|
||||
onDownload={downloadWorkflowFile}
|
||||
/>
|
||||
|
||||
{clearance.bookingReady ? (
|
||||
{clearance.linkedBookingId ? (
|
||||
<Alert color="green" variant="light" icon={<PackageCheck size={16} />}>
|
||||
<Stack gap={6}>
|
||||
<Text fz={13} fw={600} style={{ color: INK }}>
|
||||
Shipment booking created
|
||||
{clearance.linkedBookingReference
|
||||
? ` · ${clearance.linkedBookingReference}`
|
||||
: ""}
|
||||
</Text>
|
||||
<Text fz={12} c="dimmed">
|
||||
Global Logistics has created your shipment booking
|
||||
{clearance.linkedBookingStatus
|
||||
? ` (${clearance.linkedBookingStatus.replace(/_/g, " ").toLowerCase()})`
|
||||
: ""}
|
||||
. Track its progress from the booking.
|
||||
</Text>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="light"
|
||||
color="green"
|
||||
leftSection={<ArrowRight size={14} />}
|
||||
component="a"
|
||||
href={`/bookings/${clearance.linkedBookingId}`}
|
||||
style={{ alignSelf: "flex-start" }}
|
||||
>
|
||||
View shipment booking
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
) : clearance.bookingReady ? (
|
||||
<Alert color="green" variant="light">
|
||||
Clearance is complete. Global Logistics will create your shipment booking shortly.
|
||||
</Alert>
|
||||
|
||||
@@ -61,6 +61,7 @@ import { ContractClearanceWorkflowBanner } from "./ContractClearanceWorkflowBann
|
||||
import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel";
|
||||
import { formatRateUnit } from "./new-contract-form/unit-rates";
|
||||
import { getContractBookingAction } from "./contract-booking-action";
|
||||
import { closedWindowMessage, hasOpenWindow } from "./booking-window";
|
||||
import {
|
||||
BORDER,
|
||||
ContractStatusBadge,
|
||||
@@ -198,6 +199,18 @@ export default function ContractDetailPage() {
|
||||
(r) => r.status === "PENDING" || r.status === "ACCEPTED",
|
||||
);
|
||||
|
||||
// Booking windows for this contract's routes — gates the direct "New shipment
|
||||
// booking" entry so the customer only sees it while a window is open.
|
||||
// Refetched every minute so "Open now" flips without a manual reload.
|
||||
const { data: bookingWindows = [] } = useQuery({
|
||||
...api.bookings.getContractBookingWindows.queryOptions({
|
||||
input: { contractId: id! },
|
||||
refetchInterval: 60_000,
|
||||
}),
|
||||
enabled: !!id,
|
||||
});
|
||||
const bookingWindowOpen = hasOpenWindow(bookingWindows);
|
||||
|
||||
const contractBookings = useMemo(
|
||||
() =>
|
||||
(bookingsPage?.items ?? []).filter(
|
||||
@@ -370,17 +383,40 @@ export default function ContractDetailPage() {
|
||||
Request shipment
|
||||
</Button>
|
||||
)}
|
||||
{canBookShipment && (
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
size="md"
|
||||
leftSection={<PackagePlus size={16} />}
|
||||
onClick={() => navigate(`/contracts/${contract.id}/bookings/new`)}
|
||||
>
|
||||
New shipment booking
|
||||
</Button>
|
||||
)}
|
||||
{canBookShipment &&
|
||||
(bookingWindowOpen ? (
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
size="md"
|
||||
leftSection={<PackagePlus size={16} />}
|
||||
onClick={() =>
|
||||
navigate(`/contracts/${contract.id}/bookings/new`)
|
||||
}
|
||||
>
|
||||
New shipment booking
|
||||
</Button>
|
||||
) : (
|
||||
<Paper
|
||||
withBorder
|
||||
radius="md"
|
||||
px="md"
|
||||
py={10}
|
||||
maw={420}
|
||||
style={{ borderColor: BORDER, background: "#F8FAFC" }}
|
||||
>
|
||||
<Group gap={10} align="flex-start" wrap="nowrap">
|
||||
<CalendarClock
|
||||
size={16}
|
||||
color={MUTED}
|
||||
style={{ flexShrink: 0, marginTop: 2 }}
|
||||
/>
|
||||
<Text fz={13} c="dimmed">
|
||||
{closedWindowMessage(bookingWindows)}
|
||||
</Text>
|
||||
</Group>
|
||||
</Paper>
|
||||
))}
|
||||
{glPreparingBooking && (
|
||||
<Badge
|
||||
size="lg"
|
||||
@@ -1102,7 +1138,7 @@ export default function ContractDetailPage() {
|
||||
>
|
||||
<Group justify="space-between" align="center" mb="md">
|
||||
<SectionLabel>Bookings under this contract</SectionLabel>
|
||||
{canBookShipment && (
|
||||
{canBookShipment && bookingWindowOpen && (
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
@@ -1116,6 +1152,18 @@ export default function ContractDetailPage() {
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
{canBookShipment && !bookingWindowOpen && (
|
||||
<Group gap={8} align="flex-start" wrap="nowrap" mb="md">
|
||||
<CalendarClock
|
||||
size={15}
|
||||
color={MUTED}
|
||||
style={{ flexShrink: 0, marginTop: 2 }}
|
||||
/>
|
||||
<Text fz={13} c="dimmed">
|
||||
{closedWindowMessage(bookingWindows)}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
{contractBookings.length === 0 ? (
|
||||
<Stack align="center" gap={10} py="xl">
|
||||
<Inbox size={26} color={MUTED} style={{ opacity: 0.5 }} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
} from "./new-shipment-form/schema";
|
||||
import { computeShipmentTotal } from "./new-shipment-form/total";
|
||||
import { ContractCapacityNotice } from "./new-shipment-form/ContractCapacityNotice";
|
||||
import { closedWindowMessage, hasOpenWindow } from "./booking-window";
|
||||
|
||||
type ShipmentForm = ReturnType<
|
||||
typeof useForm<ShipmentFormInputValues, any, ShipmentFormValues>
|
||||
@@ -65,7 +66,19 @@ export default function NewShipmentPage() {
|
||||
api.contracts.get.queryOptions({ input: { id: id! }, enabled: !!id }),
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
// Coarse booking-window gate: block the form entirely when no window is
|
||||
// currently open for the contract's routes. The day-picker inside the form
|
||||
// still narrows to bookable days; this is the outer "is booking open at all"
|
||||
// check that mirrors the contract detail page.
|
||||
const { data: bookingWindows = [], isLoading: windowsLoading } = useQuery({
|
||||
...api.bookings.getContractBookingWindows.queryOptions({
|
||||
input: { contractId: id! },
|
||||
refetchInterval: 60_000,
|
||||
}),
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
if (isLoading || windowsLoading) {
|
||||
return (
|
||||
<Center mih={400} p="xl">
|
||||
<Loader color="edr-green" />
|
||||
@@ -113,6 +126,60 @@ export default function NewShipmentPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// Coarse gate: if the customer deep-links here while no booking window is
|
||||
// open, show the same closed-state notice as the contract page instead of the
|
||||
// form. Still allowed the moment any window isOpenNow.
|
||||
if (!hasOpenWindow(bookingWindows)) {
|
||||
return (
|
||||
<Box style={{ padding: "28px 0 0" }}>
|
||||
<Group
|
||||
justify="space-between"
|
||||
px="24px"
|
||||
align="flex-end"
|
||||
wrap="wrap"
|
||||
gap="md"
|
||||
mb="lg"
|
||||
>
|
||||
<Box>
|
||||
<Title
|
||||
order={1}
|
||||
fw={800}
|
||||
fz={26}
|
||||
style={{ letterSpacing: "-0.01em" }}
|
||||
>
|
||||
New Shipment Booking
|
||||
</Title>
|
||||
<Text size="sm" c="edr-muted" mt={4}>
|
||||
Book a shipment against contract {contract.reference}.
|
||||
</Text>
|
||||
</Box>
|
||||
<Button
|
||||
variant="default"
|
||||
radius="md"
|
||||
leftSection={<ChevronLeft size={16} />}
|
||||
onClick={() => navigate(`/contracts/${contract.id}`)}
|
||||
>
|
||||
Back to contract
|
||||
</Button>
|
||||
</Group>
|
||||
<Box px="24px">
|
||||
<Alert
|
||||
color="yellow"
|
||||
variant="light"
|
||||
radius="md"
|
||||
icon={<CalendarDays size={18} />}
|
||||
title="Booking is not open right now"
|
||||
>
|
||||
<Text size="sm">{closedWindowMessage(bookingWindows)}</Text>
|
||||
<Text size="sm" mt="xs">
|
||||
Come back when the booking window opens to book your shipment.
|
||||
</Text>
|
||||
</Alert>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return <NewShipmentBookingForm contract={contract} contractId={id!} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { MyBookingWindow } from "@/services/bookings.service";
|
||||
|
||||
/** All booking-window times are communicated in East Africa Time. */
|
||||
const TZ = "Africa/Addis_Ababa";
|
||||
|
||||
/** "Thu, 10 Jul, 08:00 EAT" — a full opening date/time in Addis Ababa time. */
|
||||
export function formatWindowOpensAt(iso: string): string {
|
||||
const day = new Date(iso).toLocaleDateString("en-GB", {
|
||||
weekday: "short",
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
timeZone: TZ,
|
||||
});
|
||||
const time = new Date(iso).toLocaleTimeString("en-GB", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
timeZone: TZ,
|
||||
});
|
||||
return `${day}, ${time}`;
|
||||
}
|
||||
|
||||
/** True when at least one of the contract's windows is bookable right now. */
|
||||
export function hasOpenWindow(windows: MyBookingWindow[]): boolean {
|
||||
return windows.some((w) => w.isOpenNow);
|
||||
}
|
||||
|
||||
/**
|
||||
* The soonest upcoming (not-yet-open) window with a known opening time, so the
|
||||
* customer can be told when to come back. Returns `null` when nothing upcoming
|
||||
* carries an opening time.
|
||||
*/
|
||||
export function soonestUpcomingWindow(
|
||||
windows: MyBookingWindow[],
|
||||
): MyBookingWindow | null {
|
||||
const upcoming = windows
|
||||
.filter((w) => !w.isOpenNow && w.windowOpensAt)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(a.windowOpensAt!).getTime() -
|
||||
new Date(b.windowOpensAt!).getTime(),
|
||||
);
|
||||
return upcoming[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The closed-state message shown when no booking window is open: the soonest
|
||||
* upcoming window's opening time + lane, or a generic notice when nothing is
|
||||
* scheduled.
|
||||
*/
|
||||
export function closedWindowMessage(windows: MyBookingWindow[]): string {
|
||||
const next = soonestUpcomingWindow(windows);
|
||||
if (!next || !next.windowOpensAt) {
|
||||
return "No upcoming booking window scheduled.";
|
||||
}
|
||||
const lane =
|
||||
next.origin && next.destination
|
||||
? ` for ${next.origin}→${next.destination}`
|
||||
: "";
|
||||
return `Booking is not open right now. Next window: ${formatWindowOpensAt(
|
||||
next.windowOpensAt,
|
||||
)} EAT${lane}.`;
|
||||
}
|
||||
@@ -15,7 +15,8 @@ import { AlertBox, AsyncComboboxField, fieldStyles } from "./shared";
|
||||
|
||||
const CONTRACT_TYPE_OPTIONS = [
|
||||
{ value: "new", label: "New Contract" },
|
||||
{ value: "renewal", label: "Contract Renewal" },
|
||||
// Renewal is disabled for now — not yet available to customers.
|
||||
{ value: "renewal", label: "Contract Renewal (coming soon)", disabled: true },
|
||||
];
|
||||
|
||||
type ContractForm = UseFormReturn<
|
||||
|
||||
@@ -376,6 +376,12 @@ export const api = {
|
||||
"myBookingWindows",
|
||||
() => bookingsService.getMyBookingWindows(),
|
||||
),
|
||||
|
||||
getContractBookingWindows: endpoint<{ contractId: string }, MyBookingWindow[]>(
|
||||
"train-scheduling",
|
||||
"contractBookingWindows",
|
||||
({ contractId }) => bookingsService.getContractBookingWindows(contractId),
|
||||
),
|
||||
},
|
||||
|
||||
contracts: {
|
||||
|
||||
@@ -53,11 +53,17 @@ export interface PriceLineItem {
|
||||
*/
|
||||
export interface MyBookingWindow {
|
||||
scheduleId: string;
|
||||
/** Contract whose route this window belongs to, when the row carries it. */
|
||||
contractId: string | null;
|
||||
/** ONE_TIME contracts can't draw down against a window — button is hidden. */
|
||||
contractKind: "ONE_TIME" | "GENERAL" | null;
|
||||
direction: "IMPORT" | "EXPORT" | null;
|
||||
windowPhase: string | null;
|
||||
isOpenNow: boolean;
|
||||
windowOpensAt: string | null;
|
||||
windowClosesAt: string | null;
|
||||
docReviewEndsAt: string | null;
|
||||
paymentPhaseEndsAt: string | null;
|
||||
bookingWindowStatus: string;
|
||||
bookingCycleNo: number;
|
||||
departureDate: string;
|
||||
@@ -375,4 +381,18 @@ export const bookingsService = {
|
||||
);
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Booking windows for a single contract's routes (same row shape as
|
||||
* `getMyBookingWindows`). Used to gate the direct "New shipment booking"
|
||||
* entry on the contract detail page and the new-shipment form.
|
||||
*/
|
||||
getContractBookingWindows: async (
|
||||
contractId: string,
|
||||
): Promise<MyBookingWindow[]> => {
|
||||
const { data } = await client.get(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.CONTRACT_BOOKING_WINDOWS(contractId),
|
||||
);
|
||||
return data.data ?? data;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -356,6 +356,9 @@ export interface ContractClearanceView {
|
||||
/** Export post-booking clearance finalized after transit permit upload. */
|
||||
exportClearanceFinalized?: boolean;
|
||||
linkedBookingId?: string | null;
|
||||
/** Reference + status of the GL-created shipment booking, once it exists. */
|
||||
linkedBookingReference?: string | null;
|
||||
linkedBookingStatus?: string | null;
|
||||
dutyAdvice?: {
|
||||
amount: number;
|
||||
currency: string;
|
||||
@@ -577,6 +580,12 @@ export interface IContract extends BaseEntity {
|
||||
status: ContractStatus;
|
||||
clearanceStatus: ContractClearanceStatus;
|
||||
clearanceCycleNumber: number;
|
||||
/**
|
||||
* Latest clearance cycle's current phase (list responses only). Lets list
|
||||
* consumers show step-accurate customer actions without fetching the full
|
||||
* clearance view per contract.
|
||||
*/
|
||||
clearancePhase?: ContractDocPhase | string | null;
|
||||
|
||||
pricingBreakdown?: ContractPricingBreakdown | null;
|
||||
pricingDisplayMode?: "UNIT_RATES";
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Group, Text } from "@mantine/core";
|
||||
import { Clock } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export interface CountdownTimerProps {
|
||||
/** ISO timestamp the countdown targets. */
|
||||
deadline: string | null | undefined;
|
||||
/** Optional label shown before the time (e.g. "Window closes in"). */
|
||||
label?: string;
|
||||
/** Text shown once the deadline has passed. */
|
||||
expiredText?: string;
|
||||
/** Visual size of the time text. */
|
||||
size?: "xs" | "sm" | "md" | "lg";
|
||||
/** Colour once under this many seconds remain (urgency). Default 300 (5 min). */
|
||||
urgentUnderSeconds?: number;
|
||||
}
|
||||
|
||||
function pad(n: number): string {
|
||||
return String(n).padStart(2, "0");
|
||||
}
|
||||
|
||||
/** Break a remaining-milliseconds figure into a human string. */
|
||||
function formatRemaining(ms: number): string {
|
||||
const total = Math.floor(ms / 1000);
|
||||
const days = Math.floor(total / 86400);
|
||||
const hours = Math.floor((total % 86400) / 3600);
|
||||
const minutes = Math.floor((total % 3600) / 60);
|
||||
const seconds = total % 60;
|
||||
|
||||
if (days > 0) return `${days}d ${pad(hours)}h ${pad(minutes)}m`;
|
||||
if (hours > 0) return `${hours}h ${pad(minutes)}m ${pad(seconds)}s`;
|
||||
return `${pad(minutes)}m ${pad(seconds)}s`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Live countdown to an ISO deadline. Ticks once a second, shows the remaining
|
||||
* time (d/h/m/s), turns red when under `urgentUnderSeconds`, and shows
|
||||
* `expiredText` once the deadline is in the past. Display only — enforcement
|
||||
* lives server-side.
|
||||
*/
|
||||
export function CountdownTimer({
|
||||
deadline,
|
||||
label,
|
||||
expiredText = "Expired",
|
||||
size = "sm",
|
||||
urgentUnderSeconds = 300,
|
||||
}: CountdownTimerProps) {
|
||||
const [remaining, setRemaining] = useState<number | null>(() =>
|
||||
deadline ? new Date(deadline).getTime() - Date.now() : null,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!deadline) {
|
||||
setRemaining(null);
|
||||
return;
|
||||
}
|
||||
const target = new Date(deadline).getTime();
|
||||
const tick = () => setRemaining(target - Date.now());
|
||||
tick();
|
||||
const id = setInterval(tick, 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [deadline]);
|
||||
|
||||
if (!deadline || remaining == null || Number.isNaN(remaining)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const expired = remaining <= 0;
|
||||
const urgent = !expired && remaining <= urgentUnderSeconds * 1000;
|
||||
const color = expired ? "red.7" : urgent ? "orange.7" : "dimmed";
|
||||
|
||||
return (
|
||||
<Group gap={6} align="center" wrap="nowrap">
|
||||
<Clock size={size === "lg" ? 18 : 14} />
|
||||
{label && (
|
||||
<Text size={size} c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
)}
|
||||
<Text size={size} fw={600} c={color}>
|
||||
{expired ? expiredText : formatRemaining(remaining)}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export default CountdownTimer;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { CountdownTimer, default } from "./CountdownTimer";
|
||||
export type { CountdownTimerProps } from "./CountdownTimer";
|
||||
@@ -25,6 +25,9 @@ export { useFileViewer } from "./hooks/useFileViewer";
|
||||
export { OperationDatePicker } from "./components/OperationDatePicker";
|
||||
export type { OperationDatePickerProps } from "./components/OperationDatePicker";
|
||||
|
||||
export { CountdownTimer } from "./components/CountdownTimer";
|
||||
export type { CountdownTimerProps } from "./components/CountdownTimer";
|
||||
|
||||
export { Badge } from "./components/badge";
|
||||
// export type { BadgeProps } from "./components/badge";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user