diff --git a/apps/edr-freight-api/src/migrations/1870000000000-RepairSynchronizeDrift.ts b/apps/edr-freight-api/src/migrations/1870000000000-RepairSynchronizeDrift.ts new file mode 100644 index 000000000..32f1e6f0c --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1870000000000-RepairSynchronizeDrift.ts @@ -0,0 +1,269 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Repairs schema drift on databases that were originally built by TypeORM + * `synchronize` (at an older entity snapshot) and never had their migration + * history recorded. Such databases have `freight.migrations` empty while most + * of the schema already exists, so a from-scratch migration run aborts on the + * first non-idempotent statement and never reaches the columns/tables added + * after synchronize was last used. + * + * The deployment procedure for those databases is: + * 1. Baseline every pre-existing migration into `freight.migrations`. + * 2. Run migrations — this file is the only pending one and back-fills the + * objects the drift scan found missing. + * + * Every statement is idempotent (IF NOT EXISTS / guarded CREATE TYPE), so it is + * also safe on a clean database where the earlier migrations already created + * these objects — it simply no-ops. + */ +export class RepairSynchronizeDrift1870000000000 + implements MigrationInterface +{ + name = 'RepairSynchronizeDrift1870000000000'; + + public async up(queryRunner: QueryRunner): Promise { + // --- enum types (derived from entities that never had a source migration) --- + await queryRunner.query(`DO $$ BEGIN + CREATE TYPE freight.consignments_cargo_type_enum AS ENUM ( + 'CONTAINER', 'BULK_LIQUID', 'BULK_DRY', 'GENERAL', 'REFRIGERATED', 'HAZARDOUS' + ); + EXCEPTION WHEN duplicate_object THEN null; END $$;`); + await queryRunner.query(`DO $$ BEGIN + CREATE TYPE freight.consignments_status_enum AS ENUM ( + 'PENDING', 'LOADED', 'IN_TRANSIT', 'AT_DESTINATION', 'DELIVERED', 'RETURNED' + ); + EXCEPTION WHEN duplicate_object THEN null; END $$;`); + await queryRunner.query(`DO $$ BEGIN + CREATE TYPE freight.tracking_events_status_enum AS ENUM ( + 'PENDING', 'LOADED', 'IN_TRANSIT', 'AT_DESTINATION', 'DELIVERED', 'RETURNED' + ); + EXCEPTION WHEN duplicate_object THEN null; END $$;`); + + // --- missing tables --- + await queryRunner.query(`CREATE TABLE IF NOT EXISTS freight.consignments ( + id uuid NOT NULL DEFAULT gen_random_uuid(), + booking_id uuid NOT NULL, + tracking_number varchar(64) NOT NULL, + cargo_type freight.consignments_cargo_type_enum NOT NULL, + weight_kg numeric(12, 2) NOT NULL, + status freight.consignments_status_enum NOT NULL DEFAULT 'PENDING', + origin_station varchar(128) NOT NULL, + destination_station varchar(128) NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + CONSTRAINT pk_consignments PRIMARY KEY (id), + CONSTRAINT uq_consignments_tracking_number UNIQUE (tracking_number) + );`); + + await queryRunner.query(`CREATE TABLE IF NOT EXISTS freight.tracking_events ( + id uuid NOT NULL DEFAULT gen_random_uuid(), + consignment_id uuid NOT NULL, + location varchar(256) NOT NULL, + status freight.tracking_events_status_enum NOT NULL, + occurred_at timestamptz NOT NULL, + description text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + CONSTRAINT pk_tracking_events PRIMARY KEY (id) + );`); + + await queryRunner.query(`CREATE TABLE IF NOT EXISTS freight.fuel_purchases ( + id uuid NOT NULL DEFAULT gen_random_uuid(), + vehicle_id uuid NOT NULL, + purchase_date timestamptz NOT NULL, + liters numeric(10, 2) NOT NULL, + cost_per_liter numeric(10, 2) NOT NULL, + total_cost numeric(14, 2) NOT NULL, + fuel_station varchar(255) NULL, + payment_method varchar(50) DEFAULT 'CASH', + odometer_reading numeric(10, 2) NULL, + driver_id uuid NULL, + receipt_number varchar(255) NULL, + notes text NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz NULL, + CONSTRAINT pk_fuel_purchases PRIMARY KEY (id), + CONSTRAINT fk_fuel_purchases_vehicle FOREIGN KEY (vehicle_id) + REFERENCES freight.vehicles (id) ON DELETE CASCADE + );`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_fuel_purchases_vehicle ON freight.fuel_purchases (vehicle_id);`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_fuel_purchases_date ON freight.fuel_purchases (purchase_date);`); + + await queryRunner.query(`CREATE TABLE IF NOT EXISTS freight.fuel_consumption ( + id uuid NOT NULL DEFAULT gen_random_uuid(), + vehicle_id uuid NOT NULL, + month date NOT NULL, + total_liters numeric(10, 2) NOT NULL, + total_cost numeric(14, 2) NOT NULL, + total_distance_km numeric(10, 2) NOT NULL, + fuel_efficiency_km_per_l numeric(10, 2) NULL, + number_of_purchases integer DEFAULT 0, + average_cost_per_liter numeric(10, 2) NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz NULL, + CONSTRAINT pk_fuel_consumption PRIMARY KEY (id), + CONSTRAINT fk_fuel_consumption_vehicle FOREIGN KEY (vehicle_id) + REFERENCES freight.vehicles (id) ON DELETE CASCADE, + CONSTRAINT uq_fuel_consumption_vehicle_month UNIQUE (vehicle_id, month) + );`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_fuel_consumption_vehicle_month ON freight.fuel_consumption (vehicle_id, month);`); + + await queryRunner.query(`CREATE TABLE IF NOT EXISTS freight.maintenance_schedules ( + id uuid NOT NULL DEFAULT gen_random_uuid(), + vehicle_id uuid NOT NULL, + maintenance_type varchar NOT NULL, + description varchar NOT NULL, + scheduled_date timestamptz NOT NULL, + completed_date timestamptz, + estimated_cost numeric(14,2), + actual_cost numeric(14,2), + status varchar NOT NULL DEFAULT 'SCHEDULED', + odometer_reading numeric, + service_provider varchar, + notes text, + next_due_km numeric, + next_due_date timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + PRIMARY KEY (id) + );`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_maintenance_schedules_vehicle_date ON freight.maintenance_schedules (vehicle_id, scheduled_date);`); + + await queryRunner.query(`CREATE TABLE IF NOT EXISTS freight.maintenance_costs ( + id uuid NOT NULL DEFAULT gen_random_uuid(), + vehicle_id uuid NOT NULL, + maintenance_schedule_id uuid, + incurred_date timestamptz NOT NULL, + cost_amount numeric(14,2) NOT NULL, + cost_type varchar NOT NULL, + description varchar NOT NULL, + service_provider varchar, + invoice_number varchar, + notes text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + PRIMARY KEY (id), + CONSTRAINT fk_maintenance_schedule FOREIGN KEY (maintenance_schedule_id) + REFERENCES freight.maintenance_schedules (id) ON DELETE SET NULL + );`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_maintenance_costs_vehicle_date ON freight.maintenance_costs (vehicle_id, incurred_date);`); + + await queryRunner.query(`CREATE TABLE IF NOT EXISTS freight.otp_verifications ( + id uuid NOT NULL DEFAULT gen_random_uuid(), + phone varchar NOT NULL, + otp varchar NOT NULL, + verified boolean NOT NULL DEFAULT false, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + CONSTRAINT pk_otp_verifications PRIMARY KEY (id), + CONSTRAINT uq_otp_verifications_phone UNIQUE (phone) + );`); + + await queryRunner.query(`CREATE TABLE IF NOT EXISTS freight.booking_batch_offers ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + booking_id uuid NOT NULL REFERENCES freight.bookings(id) ON DELETE CASCADE, + train_schedule_id uuid NOT NULL REFERENCES freight.train_schedules(id) ON DELETE CASCADE, + offered_wagons integer NOT NULL, + total_wagons integer NOT NULL, + offered_lines jsonb NULL, + offered_weight_tons numeric(12, 3) NOT NULL, + offered_amount numeric(14, 2) NOT NULL, + offered_pricing_breakdown jsonb NULL, + invoice_id uuid NULL, + payment_deadline timestamptz NOT NULL, + status varchar(10) NOT NULL DEFAULT 'OFFERED', + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz NULL + );`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_booking_batch_offers_booking ON freight.booking_batch_offers (booking_id);`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_booking_batch_offers_schedule ON freight.booking_batch_offers (train_schedule_id);`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_booking_batch_offers_status ON freight.booking_batch_offers (status);`); + + // --- missing columns on existing tables --- + await queryRunner.query(`ALTER TABLE freight.invoices + ADD COLUMN IF NOT EXISTS subtotal_amount numeric(14, 2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS tax_amount numeric(14, 2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS paid_amount numeric(14, 2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS balance_amount numeric(14, 2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS paid_at timestamptz;`); + + await queryRunner.query(`ALTER TABLE freight.bookings + ADD COLUMN IF NOT EXISTS booking_type varchar(20) NOT NULL DEFAULT 'ONE_TIME', + ADD COLUMN IF NOT EXISTS customer_truck_plate_number varchar(32), + ADD COLUMN IF NOT EXISTS customer_truck_driver_name varchar(120), + ADD COLUMN IF NOT EXISTS customer_truck_type varchar(60), + ADD COLUMN IF NOT EXISTS customer_truck_container_number varchar(16), + ADD COLUMN IF NOT EXISTS customer_truck_assigned_at timestamptz, + ADD COLUMN IF NOT EXISTS customer_truck_arrived_at timestamptz, + ADD COLUMN IF NOT EXISTS bulk_hazardous_quantity numeric(12,3) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS bulk_reefer_quantity numeric(12,3) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS clearance_current_phase varchar(40), + ADD COLUMN IF NOT EXISTS duty_required boolean, + ADD COLUMN IF NOT EXISTS vessel_departure_date date, + ADD COLUMN IF NOT EXISTS ro_amendment_requested_at timestamptz, + ADD COLUMN IF NOT EXISTS ro_hold_reason text, + ADD COLUMN IF NOT EXISTS pre_clearance_finalized_at timestamptz;`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_bookings_booking_type ON freight.bookings (booking_type);`); + + await queryRunner.query(`ALTER TABLE freight.cargoes + ADD COLUMN IF NOT EXISTS receiver_name varchar, + ADD COLUMN IF NOT EXISTS delivered_at timestamp, + ADD COLUMN IF NOT EXISTS delivery_remarks text;`); + + await queryRunner.query(`ALTER TABLE freight.contract_clearance_cycles + ADD COLUMN IF NOT EXISTS duty_required boolean, + ADD COLUMN IF NOT EXISTS vessel_departure_date date, + ADD COLUMN IF NOT EXISTS ro_amendment_requested_at timestamptz, + ADD COLUMN IF NOT EXISTS ro_hold_reason text, + ADD COLUMN IF NOT EXISTS current_phase varchar(40), + ADD COLUMN IF NOT EXISTS pre_clearance_finalized_at timestamptz;`); + + await queryRunner.query(`ALTER TABLE freight.first_mile + ADD COLUMN IF NOT EXISTS paid boolean NOT NULL DEFAULT false;`); + await queryRunner.query(`ALTER TABLE freight.last_mile + ADD COLUMN IF NOT EXISTS paid boolean NOT NULL DEFAULT false;`); + + await queryRunner.query(`ALTER TABLE freight.route_milestones + ADD COLUMN IF NOT EXISTS distance_km numeric(10,2);`); + + await queryRunner.query(`ALTER TABLE freight.routes + ADD COLUMN IF NOT EXISTS status varchar(32) NOT NULL DEFAULT 'AVAILABLE';`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_routes_status" ON freight.routes (status);`); + + await queryRunner.query(`ALTER TABLE freight.train_schedules + ADD COLUMN IF NOT EXISTS window_phase varchar(20) NULL, + ADD COLUMN IF NOT EXISTS window_opens_at timestamptz NULL, + ADD COLUMN IF NOT EXISTS window_closes_at timestamptz NULL, + ADD COLUMN IF NOT EXISTS doc_review_ends_at timestamptz NULL, + ADD COLUMN IF NOT EXISTS doc_review_completed_at timestamptz NULL, + ADD COLUMN IF NOT EXISTS payment_phase_ends_at timestamptz NULL, + ADD COLUMN IF NOT EXISTS booking_cycle_no integer NOT NULL DEFAULT 0;`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_train_schedules_window_phase + ON freight.train_schedules (window_phase) WHERE window_phase IS NOT NULL;`); + + await queryRunner.query(`ALTER TABLE freight.train_scheduling_global_rules + ADD COLUMN IF NOT EXISTS import_window_lead_days integer NOT NULL DEFAULT 3, + ADD COLUMN IF NOT EXISTS export_booking_lead_hours integer NOT NULL DEFAULT 24, + ADD COLUMN IF NOT EXISTS window_open_hour integer NOT NULL DEFAULT 8, + ADD COLUMN IF NOT EXISTS window_duration_hours numeric(4, 2) NOT NULL DEFAULT 3, + ADD COLUMN IF NOT EXISTS doc_review_minutes integer NOT NULL DEFAULT 30, + ADD COLUMN IF NOT EXISTS payment_window_minutes integer NOT NULL DEFAULT 60, + ADD COLUMN IF NOT EXISTS reopen_delay_minutes integer NOT NULL DEFAULT 90;`); + } + + public async down(): Promise { + // No-op: this migration only repairs drift by additively creating objects + // that other migrations own. Rolling it back would drop objects those + // migrations legitimately created. Revert individual feature migrations + // instead if needed. + } +} diff --git a/apps/edr-freight-api/src/migrations/1910000000000-WidenWindowDurationHoursPrecision.ts b/apps/edr-freight-api/src/migrations/1910000000000-WidenWindowDurationHoursPrecision.ts new file mode 100644 index 000000000..194c0d056 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1910000000000-WidenWindowDurationHoursPrecision.ts @@ -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 { + 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 { + await queryRunner.query(` + ALTER TABLE freight.train_scheduling_global_rules + ALTER COLUMN window_duration_hours TYPE numeric(4, 2); + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1920000000000-AddScheduleWindowRuleSnapshot.ts b/apps/edr-freight-api/src/migrations/1920000000000-AddScheduleWindowRuleSnapshot.ts new file mode 100644 index 000000000..d48e127c3 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1920000000000-AddScheduleWindowRuleSnapshot.ts @@ -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 { + 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 { + 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; + `); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index 3e1ea3cd4..8423e9606 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -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); } diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index 3f696bc6b..846fd2c9a 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -35,6 +35,7 @@ export interface BookingListFilterOptions { serviceTypeId?: string; cargoTypeId?: string; freightType?: string; + bookingType?: string; tradeDirection?: string; paymentCurrency?: string; paymentStatus?: string; @@ -737,6 +738,11 @@ export class BookingsRepository extends BaseRepository { 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, diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index ac639a4bd..e3d882baa 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index 4b033bab5..cf0ca76f6 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts index d67d58811..0cc07dacc 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts @@ -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[]; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts index 6d295c8fd..b30b3f454 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts @@ -181,6 +181,26 @@ export function computeExportWindowTimes( }; } +/** + * Earliest departure a train may be scheduled for — staff cannot schedule inside + * the lead window. IMPORT/DOMESTIC lead is in whole EAT days: with lead 3 and + * today the 11th, the 12th and 13th are blocked and the 14th is the first + * allowed departure day (00:00 EAT). EXPORT lead is in hours: earliest departure + * is `now + exportBookingLeadHours` (24h = 1 day). Mirrors the booking-window + * math so a schedulable date always has a real booking window before it. + */ +export function earliestSchedulableDeparture( + direction: string | null | undefined, + cfg: { importWindowLeadDays: number; exportBookingLeadHours: number }, + now: Date, +): Date { + if (direction === 'EXPORT') { + return new Date(now.getTime() + cfg.exportBookingLeadHours * 3_600_000); + } + const earliestDay = shiftEatDay(eatDay(now), cfg.importWindowLeadDays); + return eatDayToUtc(earliestDay, 0); +} + /** Which 3h EAT intake window a timestamp (e.g. fullyExecutedAt) belongs to. */ export function getBatchWindowForTimestamp(date: Date): BatchWindow { const { year, month, day, hour } = eatParts(date); @@ -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( departure: Date, cfg: BoardWindowConfig, pendingKey = 'pending-contract', + anchorOpensAt?: Date | null, ): Map { - const windows = listConfigBookingWindows(direction, departure, cfg); + const windows = listConfigBookingWindows(direction, departure, cfg, anchorOpensAt); const map = new Map(); for (const w of windows) { map.set(w.key, { window: w, items: [] }); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index efa6b3259..46175c7ef 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -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 = () => ({ diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts index f2fe5db07..da235c562 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts @@ -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 { @@ -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 { + 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(); + const seenEmail = new Set(); + 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< diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts index 1171b0c90..2e82feb6a 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts b/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts index 7b8d9b26a..1a67bb791 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts index d0595c0da..2d591f2e0 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts @@ -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)" }) diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 2c678ea44..8768b16b1 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -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,29 @@ const DEFAULT_TRAIN_LIMITS: Required = { max20ftPairWeightDiffTons: 10, }; +/** Raw row shape for the booking-window queries (company- and contract-scoped). */ +interface BookingWindowRow { + schedule_id: string; + contract_id: 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 +273,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 { + 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 +477,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 +3126,15 @@ 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 = await this.dataSource.query( `SELECT DISTINCT ts.id AS schedule_id, + cr.contract_id AS contract_id, 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, @@ -3037,6 +3149,7 @@ export class TrainSchedulingService { ON c.id = cr.contract_id AND c.company_id = $1 AND c.status IN ('CONTRACT_ACTIVE', 'FULLY_EXECUTED') + AND c.contract_kind = 'GENERAL' 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 @@ -3048,19 +3161,69 @@ 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 = await this.dataSource.query( + `SELECT DISTINCT ts.id AS schedule_id, + cr.contract_id AS contract_id, + 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.contract_kind = 'GENERAL' + 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, 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 +3505,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, diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 4c74f6993..e3beb67ce 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -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"; @@ -179,12 +180,13 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ FREIGHT_PERMS.contracts.clearanceEtActions, ], }, - { - label: "Shipment Requests", - href: "/dashboard/shipment-requests", - icon: , - permission: FREIGHT_PERMS.contracts.createBooking, - }, + // Hidden for now — Shipment Requests nav item disabled. + // { + // label: "Shipment Requests", + // href: "/dashboard/shipment-requests", + // icon: , + // permission: FREIGHT_PERMS.contracts.createBooking, + // }, { label: "GL Djibouti Clearance", href: "/dashboard/gl-djibouti/clearance", @@ -675,6 +677,7 @@ const App = () => { } /> + {/* Hidden for now — Shipment Requests pages disabled. { } /> + */} {/* GL (Path B) contract clearance review hub */} (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(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() { ) : null} + {!windowsLoading && !windowOpen ? ( + } + title="Booking window is closed" + mb="lg" + > + GL can create a booking only while a window is open.{" "} + {nextWindow?.windowOpensAt ? ( + <> + Next window: {fmtWindowOpensAt(nextWindow.windowOpensAt)} EAT{" "} + for{" "} + + {nextWindow.origin ?? "Origin"} → {nextWindow.destination ?? "Destination"} + + . + + ) : ( + <>No upcoming booking window scheduled. + )} + + ) : null} + + {windowsLoading || windowOpen ? ( + <> ) : null} + + ) : null} ); } diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/detail/ContractDetailTabCards.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/detail/ContractDetailTabCards.tsx index 44ece975d..de8ff1953 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/detail/ContractDetailTabCards.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/detail/ContractDetailTabCards.tsx @@ -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 ( @@ -233,7 +239,7 @@ export function ContractDocumentsCard({ > {files.length === 0 ? ( - No documents attached to this contract. + {emptyText} ) : ( diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/DurationField.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/DurationField.tsx new file mode 100644 index 000000000..06c9013d4 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/DurationField.tsx @@ -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 = { + 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(() => + 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 ( + + + + emitNative(v === "" ? "" : Number(v), unit) + } + clampBehavior="none" + allowDecimal + min={min != null ? convert(min, nativeUnit, unit) : 0} + disabled={disabled} + style={{ flex: 1 }} + /> + + + + + + + + + ); +} + +// ── 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 ( + + + + + + {title} + + + {count} + + + + {hint} + + + + {isEmpty ? ( + + + + {emptyText} + + + ) : ( + + + {loading ? ( + + Loading… + + ) : ( + children + )} + + + )} + + ); +} + +function BookingCard({ + reference, + customer, + weightTons, + status, + right, +}: { + reference: string; + customer?: string | null; + weightTons?: number | null; + status?: string | null; + right?: React.ReactNode; +}) { + return ( + { + e.currentTarget.style.borderColor = GREEN; + }} + onMouseLeave={(e) => { + e.currentTarget.style.borderColor = "var(--mantine-color-gray-2)"; + }} + > + + + + + {reference} + + {status ? : null} + + + + {customer ?? "—"} + + {weightTons != null ? ( + + + + {Number(weightTons).toFixed(1)}T + + + ) : null} + + + {right ? {right} : null} + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index 9cc2ad685..351c95e7e 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -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) => diff --git a/apps/edr-freight-web/backoffice/src/hooks/use-toast.ts b/apps/edr-freight-web/backoffice/src/hooks/use-toast.ts index 7b15a028d..48acaa38d 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/use-toast.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/use-toast.ts @@ -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 }; } diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx index d61a47139..e45d64c50 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx @@ -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("all"); - const [operationsSubTab, setOperationsSubTab] = useState("ready"); + // Booking-kind tabs (one-time vs general contract) replace the old status tabs. + const [kindTab, setKindTab] = useState("ONE_TIME"); + // Per-tab filter selects (each nullable = "all"). + const [statusFilter, setStatusFilter] = useState(null); + const [directionFilter, setDirectionFilter] = useState(null); + const [freightTypeFilter, setFreightTypeFilter] = useState(null); const [allocateOpen, setAllocateOpen] = useState(false); const [allocateIds, setAllocateIds] = useState([]); 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. { @@ -364,73 +349,97 @@ export default function BookingRequestsPage() { }} counts={tabCounts} /> + */} + + { + setKindTab((value as BookingKindTab) ?? "ONE_TIME"); + setPagination({ pageIndex: 0, pageSize: pagination.pageSize }); + }} + > + + {BOOKING_KIND_TABS.map((t) => ( + + {t.label} + + ))} + + - - } - value={query} - onChange={(e) => setQuery(e.target.value)} - rightSection={ - query && ( - setQuery("")} - > - - - ) - } - style={{ flex: 1, minWidth: "200px" }} - radius="lg" - /> - - {total} record{total !== 1 ? "s" : ""} - - + + + } + value={query} + onChange={(e) => setQuery(e.target.value)} + rightSection={ + query && ( + setQuery("")} + > + + + ) + } + style={{ flex: 1, minWidth: "200px" }} + radius="lg" + /> + + {total} record{total !== 1 ? "s" : ""} + + + + { + setDirectionFilter(v); + setPagination({ pageIndex: 0, pageSize: pagination.pageSize }); + }} + clearable + radius="lg" + style={{ minWidth: 170 }} + /> +