From cf8a2e928d7ea49dfe5903a3c539fbd8f3e2f6f3 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Thu, 23 Jul 2026 13:40:14 +0000 Subject: [PATCH 1/2] fix(warehouses): detention groups by canonical truck type Join truck_types via vehicles.truck_type_id (normalized legacy vehicle_type only as fallback) so type renames can't unmatch detention rules and FK-less vehicles keep billing. --- apps/edr-freight-api/src/app.module.ts | 2 + .../src/common/mile-financials.util.ts | 34 ++++ .../2820000000000-AddMileTonsQuantity.ts | 40 +++++ .../migrations/2840000000000-AddTruckTypes.ts | 121 +++++++++++++ .../bookings/customer-truck.service.ts | 67 ++++++- .../bookings/dto/add-customer-truck.dto.ts | 14 ++ .../customer-truck-assignment.entity.ts | 8 + .../first-mile/dto/set-vehicles.dto.ts | 14 +- .../first-mile-vehicle-assignment.entity.ts | 8 + .../modules/first-mile/first-mile.service.ts | 66 +++++-- .../truck-types/dto/create-truck-type.dto.ts | 55 ++++++ .../truck-types/dto/update-truck-type.dto.ts | 5 + .../truck-types/entities/truck-type.entity.ts | 40 +++++ .../truck-types/truck-types.controller.ts | 74 ++++++++ .../modules/truck-types/truck-types.module.ts | 15 ++ .../truck-types/truck-types.repository.ts | 20 +++ .../truck-types/truck-types.service.ts | 116 ++++++++++++ .../vehicles/dto/create-vehicle.dto.ts | 24 ++- .../vehicles/entities/vehicle.entity.ts | 25 ++- .../vehicles/vehicles.driver-guard.spec.ts | 3 +- .../src/modules/vehicles/vehicles.module.ts | 3 +- .../src/modules/vehicles/vehicles.service.ts | 52 +++++- .../vehicles.trailer-plate-guard.spec.ts | 81 +++++++++ .../warehouses/warehouse-fee.service.ts | 15 +- .../src/seed/freight-permissions.registry.ts | 2 + .../src/components/fleet/FleetFormDialog.tsx | 86 ++++++++- .../backoffice/src/constants/URLS.ts | 3 + .../src/pages/fleet/FleetResourcePage.tsx | 24 ++- .../src/pages/fleet/VehicleDetailPage.tsx | 121 ++++++++++++- .../src/pages/fleet/config/resources.ts | 23 +++ .../src/pages/fleet/config/vehicles.ts | 84 ++++++--- .../src/pages/operations/FirstMilePage.tsx | 169 ++++++++++++++---- .../src/pages/ruleEngine/config/resources.ts | 37 ++++ .../pages/warehouses/WarehouseRulesPage.tsx | 12 +- .../backoffice/src/services/api.ts | 31 ++++ .../src/services/first-mile.service.ts | 14 +- .../services/ruleEngine/ruleEngine.service.ts | 3 + .../src/services/truck-types.service.ts | 30 ++++ .../src/services/vehicles.service.ts | 7 + .../backoffice/src/types/rule-engine/index.ts | 1 + .../CustomerTruckAssignmentCard.tsx | 55 ++++++ 41 files changed, 1495 insertions(+), 109 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/2820000000000-AddMileTonsQuantity.ts create mode 100644 apps/edr-freight-api/src/migrations/2840000000000-AddTruckTypes.ts create mode 100644 apps/edr-freight-api/src/modules/truck-types/dto/create-truck-type.dto.ts create mode 100644 apps/edr-freight-api/src/modules/truck-types/dto/update-truck-type.dto.ts create mode 100644 apps/edr-freight-api/src/modules/truck-types/entities/truck-type.entity.ts create mode 100644 apps/edr-freight-api/src/modules/truck-types/truck-types.controller.ts create mode 100644 apps/edr-freight-api/src/modules/truck-types/truck-types.module.ts create mode 100644 apps/edr-freight-api/src/modules/truck-types/truck-types.repository.ts create mode 100644 apps/edr-freight-api/src/modules/truck-types/truck-types.service.ts create mode 100644 apps/edr-freight-api/src/modules/vehicles/vehicles.trailer-plate-guard.spec.ts create mode 100644 apps/edr-freight-web/backoffice/src/services/truck-types.service.ts diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 9270dd0a1..26afdc2d0 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -29,6 +29,7 @@ import { ConsignmentsModule } from "./modules/consignments/consignments.module"; // import { TrainsModule } from "./modules/trains/trains.module"; import { LocomotivesModule } from "./modules/locomotives/locomotives.module"; +import { TruckTypesModule } from "./modules/truck-types/truck-types.module"; import { WagonTypesModule } from "./modules/wagon-types/wagon-types.module"; import { TrainSetsModule } from "./modules/train-sets/train-sets.module"; import { TrainSchedulesModule } from "./modules/train-schedules/train-schedules.module"; @@ -158,6 +159,7 @@ import { LoggerMiddleware } from "./logger.middleware"; FilesModule, ConsignmentsModule, LocomotivesModule, + TruckTypesModule, WagonTypesModule, TrainSetsModule, TrainSchedulesModule, diff --git a/apps/edr-freight-api/src/common/mile-financials.util.ts b/apps/edr-freight-api/src/common/mile-financials.util.ts index 2f5288048..f22e86925 100644 --- a/apps/edr-freight-api/src/common/mile-financials.util.ts +++ b/apps/edr-freight-api/src/common/mile-financials.util.ts @@ -8,6 +8,8 @@ type MileRecord = { bookingContainers?: Array<{ units?: Array<{ vgmTons?: number | string | null }> | null; }> | null; + /** Attached here: the train schedule the booking rides, for mile alignment. */ + trainSchedule?: { trainNumber: string | null; departureDate: string | null } | null; } | null; }; @@ -36,6 +38,38 @@ export async function attachMileFinancials( if (unitTons > 0) b.cargoTotalWeightVgm = Number(unitTons.toFixed(3)); } + // Train alignment: which schedule each booking rides (mile pickups/deliveries + // are planned against the train's departure). + const bookingIds = [...new Set(records.map((r) => r.bookingId).filter(Boolean))] as string[]; + if (bookingIds.length) { + const schedules: Array<{ + bookingId: string; + trainNumber: string | null; + departureDate: string | null; + }> = await dataSource.query( + `SELECT DISTINCT ON (tsb.booking_id) + tsb.booking_id AS "bookingId", + ts.train_number AS "trainNumber", + COALESCE(ts.actual_departure_at, ts.scheduled_departure_date)::text AS "departureDate" + FROM freight.train_schedule_bookings tsb + JOIN freight.train_schedules ts + ON ts.id = tsb.train_schedule_id AND ts.deleted_at IS NULL + WHERE tsb.booking_id = ANY($1::uuid[]) AND tsb.deleted_at IS NULL + ORDER BY tsb.booking_id, tsb.created_at DESC`, + [bookingIds], + ); + const byBookingSchedule = new Map(schedules.map((s) => [s.bookingId, s])); + for (const r of records) { + const s = r.bookingId ? byBookingSchedule.get(r.bookingId) : undefined; + if (r.booking && s) { + r.booking.trainSchedule = { + trainNumber: s.trainNumber, + departureDate: s.departureDate, + }; + } + } + } + const needAdvance = records.filter( (r) => r.bookingId && !(Number(r.advancedPayment) > 0), ); diff --git a/apps/edr-freight-api/src/migrations/2820000000000-AddMileTonsQuantity.ts b/apps/edr-freight-api/src/migrations/2820000000000-AddMileTonsQuantity.ts new file mode 100644 index 000000000..e1e02c418 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2820000000000-AddMileTonsQuantity.ts @@ -0,0 +1,40 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Bulk tonnage at assignment time. First-mile trucks and export self-haul + * trucks carry a planned load (tonnes + optional item count) so bulk bookings + * draw down as vehicles are assigned — not only at the weighbridge. + */ +export class AddMileTonsQuantity2820000000000 implements MigrationInterface { + name = 'AddMileTonsQuantity2820000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.first_mile_vehicle_assignments ADD COLUMN IF NOT EXISTS tons numeric(14,3);`, + ); + await queryRunner.query( + `ALTER TABLE freight.first_mile_vehicle_assignments ADD COLUMN IF NOT EXISTS quantity integer;`, + ); + await queryRunner.query( + `ALTER TABLE freight.customer_truck_assignments ADD COLUMN IF NOT EXISTS planned_tons numeric(14,3);`, + ); + await queryRunner.query( + `ALTER TABLE freight.customer_truck_assignments ADD COLUMN IF NOT EXISTS planned_quantity integer;`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.customer_truck_assignments DROP COLUMN IF EXISTS planned_quantity;`, + ); + await queryRunner.query( + `ALTER TABLE freight.customer_truck_assignments DROP COLUMN IF EXISTS planned_tons;`, + ); + await queryRunner.query( + `ALTER TABLE freight.first_mile_vehicle_assignments DROP COLUMN IF EXISTS quantity;`, + ); + await queryRunner.query( + `ALTER TABLE freight.first_mile_vehicle_assignments DROP COLUMN IF EXISTS tons;`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/2840000000000-AddTruckTypes.ts b/apps/edr-freight-api/src/migrations/2840000000000-AddTruckTypes.ts new file mode 100644 index 000000000..fb22cbee9 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2840000000000-AddTruckTypes.ts @@ -0,0 +1,121 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Truck types become back-office data instead of a hardcoded `VehicleType` enum, + * so EDR can add a configuration without a code change. + * + * `vehicles.vehicle_type` is deliberately LEFT IN PLACE as a denormalised code. + * Truck-detention billing groups trucks with raw SQL over that column + * (`SELECT v.vehicle_type ... GROUP BY`, warehouse-fee.service.ts) and matches + * the result against `warehouse_fee_rules.vehicle_type`. Swapping it for the FK + * outright would silently drop detention charges, so the FK is additive and the + * service writes the type's code through on every save. + * + * Raw SQL, `freight.`-qualified, IF NOT EXISTS throughout — the TypeORM builder + * API resolves bare names against `public` and crash-loops boot. + */ +export class AddTruckTypes2840000000000 implements MigrationInterface { + name = "AddTruckTypes2840000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.truck_types ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + code varchar(32) NOT NULL, + name varchar(100) NOT NULL, + capacity_tons numeric(10,3), + has_trailer boolean NOT NULL DEFAULT false, + description text, + is_active boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ) + `); + + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS ux_truck_types_code + ON freight.truck_types (code) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS ix_truck_types_is_active + ON freight.truck_types (is_active) + `); + + // Seed one row per legacy enum value so vehicles already carrying that code + // keep resolving, plus CASONI as the first rigid (no-trailer) configuration. + // has_trailer is true only for the articulated configurations. + await queryRunner.query(` + INSERT INTO freight.truck_types (code, name, has_trailer) + VALUES + ('TRUCK', 'Truck', true), + ('TRAILER', 'Trailer', true), + ('TANKER', 'Tanker', true), + ('FLATBED', 'Flatbed', true), + ('VAN', 'Van', false), + ('CAR', 'Car', false), + ('BUS', 'Bus', false), + ('CASONI', 'Casoni (rigid, no trailer)', false) + ON CONFLICT (code) DO NOTHING + `); + + await queryRunner.query(` + ALTER TABLE freight.vehicles + ADD COLUMN IF NOT EXISTS truck_type_id uuid + `); + + // Separate DO block: ADD CONSTRAINT has no IF NOT EXISTS in Postgres. + await queryRunner.query(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'fk_vehicles_truck_type' + ) THEN + ALTER TABLE freight.vehicles + ADD CONSTRAINT fk_vehicles_truck_type + FOREIGN KEY (truck_type_id) REFERENCES freight.truck_types (id) + ON DELETE SET NULL; + END IF; + END $$ + `); + + // Backfill the FK from the code already stored on each vehicle. + await queryRunner.query(` + UPDATE freight.vehicles v + SET truck_type_id = t.id + FROM freight.truck_types t + WHERE v.truck_type_id IS NULL + AND upper(trim(v.vehicle_type)) = t.code + `); + + // Truck-type codes are varchar(32); the fee-rule column they are matched + // against was varchar(20) and would truncate/reject longer codes. + await queryRunner.query(` + ALTER TABLE freight.warehouse_fee_rules + ALTER COLUMN vehicle_type TYPE varchar(32) + `); + + // A VIN identifies exactly one vehicle worldwide. Partial index so the many + // existing rows without a VIN do not collide. + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS ux_vehicles_vin + ON freight.vehicles (vin) + WHERE vin IS NOT NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight.ux_vehicles_vin`); + await queryRunner.query(` + ALTER TABLE freight.vehicles + DROP CONSTRAINT IF EXISTS fk_vehicles_truck_type + `); + await queryRunner.query(` + ALTER TABLE freight.vehicles + DROP COLUMN IF EXISTS truck_type_id + `); + await queryRunner.query(`DROP TABLE IF EXISTS freight.truck_types`); + // warehouse_fee_rules.vehicle_type is left widened: narrowing it back would + // fail on any row that stored a code longer than 20 characters. + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts b/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts index 4ca578f0b..3df681d9c 100644 --- a/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts @@ -85,6 +85,24 @@ export class CustomerTruckService { if (isBulk) { const { totalTons, remainingTons } = await remainingBulkTons(this.dataSource, bookingId); assertBulkTonnageRemains(totalTons, remainingTons); + + // Assignment-time drawdown: planned tonnage across live trucks (weighed + // net once departed, planned before) may not exceed the declared total. + if (totalTons > 0) { + const [p]: Array<{ planned: string | null }> = await this.dataSource.query( + `SELECT SUM(COALESCE(a.net_weight_tons, a.planned_tons, 0)) AS planned + FROM freight.customer_truck_assignments a + WHERE a.booking_id = $1 AND a.deleted_at IS NULL`, + [bookingId], + ); + const alreadyPlanned = Number(p?.planned ?? 0); + const requestedTons = Number(dto.plannedTons ?? 0); + if (requestedTons > 0 && alreadyPlanned + requestedTons > totalTons + 0.001) { + throw new BadRequestException( + `Planned tonnage exceeds the booking: ${alreadyPlanned} t already assigned of ${totalTons} t — at most ${Math.max(0, totalTons - alreadyPlanned)} t left for this truck`, + ); + } + } } if (requested.length) { @@ -108,6 +126,8 @@ export class CustomerTruckService { plateNumber: dto.truckPlateNumber.trim().toUpperCase(), driverName: dto.driverName.trim(), truckType: dto.truckType.trim(), + plannedTons: isBulk ? (dto.plannedTons ?? null) : null, + plannedQuantity: isBulk ? (dto.plannedQuantity ?? null) : null, }), ); await manager.getRepository(CustomerTruckContainer).save( @@ -186,23 +206,52 @@ export class CustomerTruckService { throw new ConflictException('Cannot edit a truck that has already arrived'); } - const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase()); - if (requested.length < 1) { + // Bulk trucks carry loose tonnage, not containers — planned tonnage is + // editable instead, capped by what the other trucks haven't claimed. + const isBulk = booking.freightType === 'BULK'; + const requested = isBulk + ? [] + : (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase()); + if (!isBulk && requested.length < 1) { throw new BadRequestException('Select at least one container for this truck'); } - assertTruckLoad({ - containers: requested, - bookingContainers: await this.bookingContainerNumbers(bookingId), - sizes: await bookingContainerSizes(this.dataSource, bookingId, requested), - // Exclude THIS truck's own containers so re-saving the same set is allowed. - assignedElsewhere: await this.assignedContainerNumbersExcept(bookingId, assignmentId), - }); + if (!isBulk) { + assertTruckLoad({ + containers: requested, + bookingContainers: await this.bookingContainerNumbers(bookingId), + sizes: await bookingContainerSizes(this.dataSource, bookingId, requested), + // Exclude THIS truck's own containers so re-saving the same set is allowed. + assignedElsewhere: await this.assignedContainerNumbersExcept(bookingId, assignmentId), + }); + } else if (dto.plannedTons != null) { + const { totalTons } = await remainingBulkTons(this.dataSource, bookingId); + if (totalTons > 0) { + const [p]: Array<{ planned: string | null }> = await this.dataSource.query( + `SELECT SUM(COALESCE(a.net_weight_tons, a.planned_tons, 0)) AS planned + FROM freight.customer_truck_assignments a + WHERE a.booking_id = $1 AND a.deleted_at IS NULL AND a.id <> $2`, + [bookingId, assignmentId], + ); + const others = Number(p?.planned ?? 0); + if (others + Number(dto.plannedTons) > totalTons + 0.001) { + throw new BadRequestException( + `Planned tonnage exceeds the booking: ${others} t on other trucks of ${totalTons} t — at most ${Math.max(0, totalTons - others)} t left for this truck`, + ); + } + } + } await this.dataSource.transaction(async (manager) => { await manager.getRepository(CustomerTruckAssignment).update(assignmentId, { plateNumber: dto.truckPlateNumber.trim().toUpperCase(), driverName: dto.driverName.trim(), truckType: dto.truckType.trim(), + ...(isBulk + ? { + plannedTons: dto.plannedTons ?? null, + plannedQuantity: dto.plannedQuantity ?? null, + } + : {}), }); await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId }); await manager.getRepository(CustomerTruckContainer).save( diff --git a/apps/edr-freight-api/src/modules/bookings/dto/add-customer-truck.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/add-customer-truck.dto.ts index 4356d66ec..9816b3405 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/add-customer-truck.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/add-customer-truck.dto.ts @@ -4,10 +4,12 @@ import { IsArray, IsIn, IsNotEmpty, + IsNumber, IsOptional, IsString, Matches, MaxLength, + Min, } from 'class-validator'; import { CUSTOMER_TRUCK_TYPES } from './customer-truck-assignment.dto'; @@ -44,4 +46,16 @@ export class AddCustomerTruckDto { message: 'each container number must match ISO container format, e.g. ABCD1234567', }) containerNumbers?: string[]; + + /** Bulk: planned tonnage this truck hauls — draws down the booking total at assignment. */ + @IsOptional() + @IsNumber() + @Min(0) + plannedTons?: number; + + /** Bulk: optional item/piece count on this truck. */ + @IsOptional() + @IsNumber() + @Min(0) + plannedQuantity?: number; } diff --git a/apps/edr-freight-api/src/modules/bookings/entities/customer-truck-assignment.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/customer-truck-assignment.entity.ts index 3892d2a97..94ab3b212 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/customer-truck-assignment.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/customer-truck-assignment.entity.ts @@ -51,6 +51,14 @@ export class CustomerTruckAssignment extends BaseEntity { @Column({ name: 'net_weight_tons', type: 'numeric', precision: 14, scale: 3, nullable: true }) netWeightTons?: number | null; + /** Bulk: planned tonnage at assignment — draws down the booking before weigh-out. */ + @Column({ name: 'planned_tons', type: 'numeric', precision: 14, scale: 3, nullable: true }) + plannedTons?: number | null; + + /** Bulk: optional item/piece count planned on this truck. */ + @Column({ name: 'planned_quantity', type: 'integer', nullable: true }) + plannedQuantity?: number | null; + @Column({ name: 'departed_at', type: 'timestamptz', nullable: true }) departedAt?: Date | null; diff --git a/apps/edr-freight-api/src/modules/first-mile/dto/set-vehicles.dto.ts b/apps/edr-freight-api/src/modules/first-mile/dto/set-vehicles.dto.ts index 8656b2109..512c22566 100644 --- a/apps/edr-freight-api/src/modules/first-mile/dto/set-vehicles.dto.ts +++ b/apps/edr-freight-api/src/modules/first-mile/dto/set-vehicles.dto.ts @@ -1,4 +1,4 @@ -import { IsArray, IsOptional, IsString, IsUUID, ValidateNested } from 'class-validator'; +import { IsArray, IsNumber, IsOptional, IsString, IsUUID, Min, ValidateNested } from 'class-validator'; import { Type } from 'class-transformer'; export class FirstMileVehicleInput { @@ -8,6 +8,18 @@ export class FirstMileVehicleInput { @IsOptional() @IsString() containerNumber?: string; + + /** Bulk: tonnage this truck hauls. */ + @IsOptional() + @IsNumber() + @Min(0) + tons?: number; + + /** Bulk: optional item/piece count. */ + @IsOptional() + @IsNumber() + @Min(0) + quantity?: number; } /** Replace the full set of vehicles (with their container numbers) on a pickup. */ diff --git a/apps/edr-freight-api/src/modules/first-mile/entities/first-mile-vehicle-assignment.entity.ts b/apps/edr-freight-api/src/modules/first-mile/entities/first-mile-vehicle-assignment.entity.ts index 39bf51a50..5c9f02c42 100644 --- a/apps/edr-freight-api/src/modules/first-mile/entities/first-mile-vehicle-assignment.entity.ts +++ b/apps/edr-freight-api/src/modules/first-mile/entities/first-mile-vehicle-assignment.entity.ts @@ -36,4 +36,12 @@ export class FirstMileVehicleAssignment extends BaseEntity { /** Actual distance driven by this truck (km), entered per vehicle. */ @Column({ name: 'distance_km', type: 'numeric', precision: 10, scale: 2, nullable: true }) distanceKm?: number | null; + + /** Bulk: tonnage this truck hauls — assigned tonnage draws down the booking total. */ + @Column({ name: 'tons', type: 'numeric', precision: 14, scale: 3, nullable: true }) + tons?: number | null; + + /** Bulk: optional item/piece count on this truck. */ + @Column({ name: 'quantity', type: 'integer', nullable: true }) + quantity?: number | null; } diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts index 948853e22..b11912b2c 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts @@ -532,17 +532,47 @@ export class FirstMileService { */ async setVehicles( id: string, - inputs: Array<{ vehicleId: string; containerNumber?: string | null }>, + inputs: Array<{ + vehicleId: string; + containerNumber?: string | null; + tons?: number | null; + quantity?: number | null; + }>, ): Promise { const existing = await this.findById(id); - // Dedupe by vehicleId, keeping the container number; preserve order. - const desiredMap = new Map(); + // Dedupe by vehicleId, keeping the load details; preserve order. + const desiredMap = new Map< + string, + { containerNumber: string | null; tons: number | null; quantity: number | null } + >(); for (const inp of inputs) { - if (inp.vehicleId) desiredMap.set(inp.vehicleId, inp.containerNumber ?? null); + if (inp.vehicleId) { + desiredMap.set(inp.vehicleId, { + containerNumber: inp.containerNumber ?? null, + tons: inp.tons ?? null, + quantity: inp.quantity ?? null, + }); + } } const desired = [...desiredMap.keys()]; const desiredSet = new Set(desired); + // Bulk drawdown: assigned tonnage may not exceed what the booking declares. + const totalTons = [...desiredMap.values()].reduce((s, v) => s + (Number(v.tons) || 0), 0); + if (totalTons > 0 && existing.bookingId) { + const [b]: Array<{ vgm: string | null }> = await this.dataSource.query( + `SELECT cargo_total_weight_vgm AS vgm FROM freight.bookings + WHERE id = $1 AND deleted_at IS NULL`, + [existing.bookingId], + ); + const declared = Number(b?.vgm ?? 0); + if (declared > 0 && totalTons > declared + 0.001) { + throw new BadRequestException( + `Assigned tonnage (${totalTons} t) exceeds the booking's declared ${declared} t`, + ); + } + } + const manager = this.dataSource.manager; const current = await manager.find(FirstMileVehicleAssignment, { where: { firstMileId: id }, @@ -555,12 +585,16 @@ export class FirstMileService { )]; const added = desired.filter((v) => !junctionSet.has(v)); const removed = releaseIds.filter((v) => !desiredSet.has(v)); - // Vehicles that stay but whose container number changed. - const changed = current.filter( - (a) => - desiredMap.has(a.vehicleId) && - (a.containerNumber ?? null) !== (desiredMap.get(a.vehicleId) ?? null), - ); + // Vehicles that stay but whose load details changed. + const changed = current.filter((a) => { + const want = desiredMap.get(a.vehicleId); + if (!want) return false; + return ( + (a.containerNumber ?? null) !== want.containerNumber || + (a.tons == null ? null : Number(a.tons)) !== want.tons || + (a.quantity ?? null) !== want.quantity + ); + }); await this.dataSource.transaction(async (tx) => { if (removed.length) { @@ -570,17 +604,25 @@ export class FirstMileService { }); } for (const vehicleId of added) { + const want = desiredMap.get(vehicleId); await tx.insert(FirstMileVehicleAssignment, { firstMileId: id, vehicleId, - containerNumber: desiredMap.get(vehicleId) ?? null, + containerNumber: want?.containerNumber ?? null, + tons: want?.tons ?? null, + quantity: want?.quantity ?? null, }); } for (const row of changed) { + const want = desiredMap.get(row.vehicleId); await tx.update( FirstMileVehicleAssignment, { firstMileId: id, vehicleId: row.vehicleId }, - { containerNumber: desiredMap.get(row.vehicleId) ?? null }, + { + containerNumber: want?.containerNumber ?? null, + tons: want?.tons ?? null, + quantity: want?.quantity ?? null, + }, ); } }); diff --git a/apps/edr-freight-api/src/modules/truck-types/dto/create-truck-type.dto.ts b/apps/edr-freight-api/src/modules/truck-types/dto/create-truck-type.dto.ts new file mode 100644 index 000000000..ec673a471 --- /dev/null +++ b/apps/edr-freight-api/src/modules/truck-types/dto/create-truck-type.dto.ts @@ -0,0 +1,55 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { IsBoolean, IsNumber, IsOptional, IsString, MaxLength, Min } from 'class-validator'; + +const toNumber = ({ value }: { value: unknown }) => + value === '' || value == null ? value : Number(value); + +const toBoolean = ({ value }: { value: unknown }) => { + if (typeof value === 'boolean') return value; + if (value === 'true') return true; + if (value === 'false') return false; + return value; +}; + +export class CreateTruckTypeDto { + @ApiProperty({ maxLength: 32, example: 'CASONI' }) + @IsString() + @MaxLength(32) + code!: string; + + @ApiProperty({ maxLength: 100, example: 'Casoni (rigid, no trailer)' }) + @IsString() + @MaxLength(100) + name!: string; + + @ApiPropertyOptional({ + description: 'Payload capacity in metric tons — pre-fills a vehicle registered against this type', + example: 30, + }) + @IsOptional() + @Transform(toNumber) + @IsNumber() + @Min(0) + capacityTons?: number; + + @ApiPropertyOptional({ + description: 'Whether this configuration pulls a trailer. False (e.g. Casoni) forbids a trailer plate.', + default: false, + }) + @IsOptional() + @Transform(toBoolean) + @IsBoolean() + hasTrailer?: boolean; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + description?: string; + + @ApiPropertyOptional({ default: true }) + @IsOptional() + @Transform(toBoolean) + @IsBoolean() + isActive?: boolean; +} diff --git a/apps/edr-freight-api/src/modules/truck-types/dto/update-truck-type.dto.ts b/apps/edr-freight-api/src/modules/truck-types/dto/update-truck-type.dto.ts new file mode 100644 index 000000000..269bce685 --- /dev/null +++ b/apps/edr-freight-api/src/modules/truck-types/dto/update-truck-type.dto.ts @@ -0,0 +1,5 @@ +import { PartialType } from '@nestjs/mapped-types'; + +import { CreateTruckTypeDto } from './create-truck-type.dto'; + +export class UpdateTruckTypeDto extends PartialType(CreateTruckTypeDto) {} diff --git a/apps/edr-freight-api/src/modules/truck-types/entities/truck-type.entity.ts b/apps/edr-freight-api/src/modules/truck-types/entities/truck-type.entity.ts new file mode 100644 index 000000000..4a09dcd40 --- /dev/null +++ b/apps/edr-freight-api/src/modules/truck-types/entities/truck-type.entity.ts @@ -0,0 +1,40 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index } from 'typeorm'; + +/** + * A truck configuration EDR registers vehicles against — back-office managed so + * new configurations arrive without a code change. + * + * Two fields drive vehicle registration: + * - `capacityTons` pre-fills a vehicle's capacity (capacity belongs to the type, + * not to each individual truck). + * - `hasTrailer` decides whether a trailer plate applies at all. A rigid truck + * (e.g. Casoni) has none, and registering one with a trailer plate is rejected. + */ +@Entity({ schema: 'freight', name: 'truck_types' }) +@Index(['code']) +@Index(['isActive']) +export class TruckType extends BaseEntity { + /** + * Matching key, upper-case. Denormalised onto `vehicles.vehicle_type`, which + * truck-detention billing groups and matches fee rules by — so a code change + * here is a billing-visible change. + */ + @Column({ name: 'code', type: 'varchar', length: 32, unique: true }) + code!: string; + + @Column({ name: 'name', type: 'varchar', length: 100 }) + name!: string; + + @Column({ name: 'capacity_tons', type: 'numeric', precision: 10, scale: 3, nullable: true }) + capacityTons?: number | null; + + @Column({ name: 'has_trailer', type: 'boolean', default: false }) + hasTrailer!: boolean; + + @Column({ name: 'description', type: 'text', nullable: true }) + description?: string | null; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive!: boolean; +} diff --git a/apps/edr-freight-api/src/modules/truck-types/truck-types.controller.ts b/apps/edr-freight-api/src/modules/truck-types/truck-types.controller.ts new file mode 100644 index 000000000..8c51b824e --- /dev/null +++ b/apps/edr-freight-api/src/modules/truck-types/truck-types.controller.ts @@ -0,0 +1,74 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { RuleEngineManage, RuleEngineView } from '../../common/rule-engine-guards'; + +import { CreateTruckTypeDto } from './dto/create-truck-type.dto'; +import { UpdateTruckTypeDto } from './dto/update-truck-type.dto'; +import { TruckTypesService } from './truck-types.service'; + +@ApiTags('truck-types') +@Controller('truck-types') +@ApiBearerAuth() +export class TruckTypesController { + constructor(private readonly truckTypesService: TruckTypesService) {} + + @Get() + @RuleEngineView('truck-types') + @ApiOperation({ summary: 'List truck types' }) + findAll(@Query() query: Record) { + return this.truckTypesService.findAll({ + isActive: + query.isActive === 'all' + ? undefined + : query.isActive !== undefined + ? query.isActive === 'true' + : true, + page: query.page ? parseInt(query.page, 10) : undefined, + pageSize: query.pageSize ? parseInt(query.pageSize, 10) : undefined, + sortBy: query.sortBy, + sortOrder: query.sortOrder, + }); + } + + @Get(':id') + @RuleEngineView('truck-types') + @ApiOperation({ summary: 'Get a truck type by ID' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.truckTypesService.findById(id); + } + + @Post() + @RuleEngineManage('truck-types') + @ApiOperation({ summary: 'Create a truck type' }) + create(@Body() dto: CreateTruckTypeDto) { + return this.truckTypesService.create(dto); + } + + @Patch(':id') + @RuleEngineManage('truck-types') + @ApiOperation({ summary: 'Update a truck type' }) + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateTruckTypeDto) { + return this.truckTypesService.update(id, dto); + } + + @Delete(':id') + @RuleEngineManage('truck-types') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Soft-delete a truck type' }) + remove(@Param('id', ParseUUIDPipe) id: string) { + return this.truckTypesService.remove(id); + } +} diff --git a/apps/edr-freight-api/src/modules/truck-types/truck-types.module.ts b/apps/edr-freight-api/src/modules/truck-types/truck-types.module.ts new file mode 100644 index 000000000..466caa91f --- /dev/null +++ b/apps/edr-freight-api/src/modules/truck-types/truck-types.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { TruckType } from './entities/truck-type.entity'; +import { TruckTypesController } from './truck-types.controller'; +import { TruckTypesRepository } from './truck-types.repository'; +import { TruckTypesService } from './truck-types.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([TruckType])], + controllers: [TruckTypesController], + providers: [TruckTypesRepository, TruckTypesService], + exports: [TruckTypesRepository, TruckTypesService], +}) +export class TruckTypesModule {} diff --git a/apps/edr-freight-api/src/modules/truck-types/truck-types.repository.ts b/apps/edr-freight-api/src/modules/truck-types/truck-types.repository.ts new file mode 100644 index 000000000..bb803bd8c --- /dev/null +++ b/apps/edr-freight-api/src/modules/truck-types/truck-types.repository.ts @@ -0,0 +1,20 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { TruckType } from './entities/truck-type.entity'; + +@Injectable() +export class TruckTypesRepository extends BaseRepository { + constructor( + @InjectRepository(TruckType) + repository: Repository, + ) { + super(repository); + } + + findByCode(code: string): Promise { + return this.repository.findOne({ where: { code } }); + } +} diff --git a/apps/edr-freight-api/src/modules/truck-types/truck-types.service.ts b/apps/edr-freight-api/src/modules/truck-types/truck-types.service.ts new file mode 100644 index 000000000..1cc6421d6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/truck-types/truck-types.service.ts @@ -0,0 +1,116 @@ +import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { FindOptionsOrder } from 'typeorm'; + +import { CreateTruckTypeDto } from './dto/create-truck-type.dto'; +import { UpdateTruckTypeDto } from './dto/update-truck-type.dto'; +import { TruckType } from './entities/truck-type.entity'; +import { TruckTypesRepository } from './truck-types.repository'; + +type TruckTypeListFilter = { + isActive?: boolean; + page?: number; + pageSize?: number; + sortBy?: string; + sortOrder?: string; +}; + +@Injectable() +export class TruckTypesService { + constructor(private readonly truckTypesRepository: TruckTypesRepository) {} + + async findAll(filter: TruckTypeListFilter = {}): Promise<{ + data: TruckType[]; + meta: { total: number; page: number; pageSize: number; totalPages: number }; + }> { + const page = filter.page ?? 1; + const pageSize = filter.pageSize ?? 500; + const sortBy = ['code', 'name', 'capacityTons', 'hasTrailer', 'isActive'].includes( + filter.sortBy ?? '', + ) + ? (filter.sortBy as keyof TruckType) + : 'code'; + const sortOrder = filter.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC'; + + const [data, total] = await this.truckTypesRepository.findAndCount({ + where: filter.isActive === undefined ? {} : { isActive: filter.isActive }, + order: { [sortBy]: sortOrder } as FindOptionsOrder, + skip: (page - 1) * pageSize, + take: pageSize, + }); + + return { + data, + meta: { + total, + page, + pageSize, + totalPages: Math.max(1, Math.ceil(total / pageSize)), + }, + }; + } + + async findById(id: string): Promise { + const truckType = await this.truckTypesRepository.findById(id); + + if (!truckType) { + throw new NotFoundException(`Truck type ${id} not found`); + } + + return truckType; + } + + async findByCode(code: string): Promise { + const truckType = await this.truckTypesRepository.findByCode(code); + if (!truckType) { + throw new NotFoundException(`Truck type ${code} not found`); + } + return truckType; + } + + async create(dto: CreateTruckTypeDto): Promise { + const code = dto.code.trim().toUpperCase(); + const existing = await this.truckTypesRepository.findByCode(code); + + if (existing) { + throw new ConflictException(`Truck type code "${code}" already exists`); + } + + return this.truckTypesRepository.create({ + code, + name: dto.name.trim(), + capacityTons: dto.capacityTons ?? null, + hasTrailer: dto.hasTrailer ?? false, + description: dto.description?.trim() ?? null, + isActive: dto.isActive ?? true, + }); + } + + async update(id: string, dto: UpdateTruckTypeDto): Promise { + const truckType = await this.findById(id); + const nextCode = dto.code?.trim().toUpperCase(); + + if (nextCode && nextCode !== truckType.code) { + const existing = await this.truckTypesRepository.findByCode(nextCode); + if (existing) { + throw new ConflictException(`Truck type code "${nextCode}" already exists`); + } + } + + const updated = await this.truckTypesRepository.update(id, { + ...dto, + ...(nextCode ? { code: nextCode } : {}), + ...(dto.name ? { name: dto.name.trim() } : {}), + }); + + if (!updated) { + throw new NotFoundException(`Truck type ${id} not found`); + } + + return updated; + } + + async remove(id: string): Promise { + await this.findById(id); + await this.truckTypesRepository.softDelete(id); + } +} diff --git a/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts b/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts index 4dda3c053..79ecc76c5 100644 --- a/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts +++ b/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts @@ -1,6 +1,11 @@ import { IsString, IsEnum, IsNumber, IsOptional, IsUUID, Matches } from 'class-validator'; import { Transform } from 'class-transformer'; -import { VehicleType, FuelType, VehicleStatus, VehicleAvailability } from '../entities/vehicle.entity'; +import { + FuelType, + VehicleAvailability, + VehicleOwnership, + VehicleStatus, +} from '../entities/vehicle.entity'; /** * A vehicle plate is two or three letters, a hyphen, then two to six digits — @@ -28,8 +33,9 @@ export class CreateVehicleDto { @IsString() plateNumber!: string; - @IsEnum(VehicleType) - vehicleType!: VehicleType; + /** Truck configuration from `freight.truck_types` — drives capacity and whether a trailer plate applies. */ + @IsUUID() + truckTypeId!: string; @IsString() manufacturer!: string; @@ -43,8 +49,18 @@ export class CreateVehicleDto { @IsEnum(FuelType) fuelType!: FuelType; + /** Defaults to the truck type's capacity when omitted. */ + @IsOptional() @IsNumber() - capacity!: number; + capacity?: number; + + @IsOptional() + @IsString() + vin?: string; + + @IsOptional() + @IsEnum(VehicleOwnership) + ownership?: VehicleOwnership; @IsEnum(VehicleStatus) status!: VehicleStatus; diff --git a/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts b/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts index 534019bc4..59725fc5e 100644 --- a/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts +++ b/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts @@ -1,6 +1,17 @@ import { Entity, Column } from 'typeorm'; import { BaseEntity } from '@edr/api-common'; +/** + * Legacy classification. Truck configurations are now back-office data in + * `freight.truck_types` — register a vehicle with `truckTypeId`, not this. + * + * The `vehicle_type` COLUMN survives as a denormalised copy of the truck type's + * code because truck-detention billing groups by it in raw SQL and matches it + * against `warehouse_fee_rules.vehicle_type`. The service writes it through on + * every save; nothing should set it by hand. + * + * @deprecated use `truckTypeId` / `freight.truck_types` + */ export enum VehicleType { TRUCK = 'TRUCK', VAN = 'VAN', @@ -11,6 +22,12 @@ export enum VehicleType { FLATBED = 'FLATBED', } +/** Who supplies the truck. Supplier selection is deferred until EDR commits to outsourcing. */ +export enum VehicleOwnership { + OWNED = 'OWNED', + OUTSOURCED = 'OUTSOURCED', +} + export enum FuelType { PETROL = 'PETROL', DIESEL = 'DIESEL', @@ -47,8 +64,12 @@ export class Vehicle extends BaseEntity { @Column({ name: 'registration_number', unique: true, nullable: true }) registrationNumber?: string; + /** Denormalised `truck_types.code` — written through by the service, never set by hand. */ @Column({ name: 'vehicle_type', type: 'varchar', nullable: true }) - vehicleType?: VehicleType; + vehicleType?: string; + + @Column({ name: 'truck_type_id', type: 'uuid', nullable: true }) + truckTypeId?: string | null; @Column({ nullable: true }) manufacturer?: string; @@ -101,7 +122,7 @@ export class Vehicle extends BaseEntity { @Column({ name: 'vin', type: 'varchar', nullable: true }) vin?: string; - /** Owned | Leased | Rented */ + /** OWNED | OUTSOURCED — see {@link VehicleOwnership}. */ @Column({ name: 'ownership', type: 'varchar', nullable: true }) ownership?: string; diff --git a/apps/edr-freight-api/src/modules/vehicles/vehicles.driver-guard.spec.ts b/apps/edr-freight-api/src/modules/vehicles/vehicles.driver-guard.spec.ts index cb810abaf..6171602d7 100644 --- a/apps/edr-freight-api/src/modules/vehicles/vehicles.driver-guard.spec.ts +++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.driver-guard.spec.ts @@ -11,6 +11,7 @@ describe('VehiclesService driver assignment guard', () => { new VehiclesService( { findOne, create: jest.fn((x) => x), save: jest.fn(async (x) => x) } as any, { record: jest.fn() } as any, + { findById: jest.fn(async () => ({ code: 'TRUCK', name: 'Truck', hasTrailer: true })) } as any, ); it('rejects create when the driver is on another truck', async () => { @@ -18,7 +19,7 @@ describe('VehiclesService driver assignment guard', () => { const findOne = jest.fn().mockResolvedValueOnce(null).mockResolvedValueOnce(otherTruck); const svc = makeService(findOne); await expect( - svc.create({ plateNumber: '3-22222', vehicleType: 'TRUCK', assignedDriverId: 'd1' } as any), + svc.create({ plateNumber: '3-22222', truckTypeId: 'tt1', assignedDriverId: 'd1' } as any), ).rejects.toThrow(ConflictException); }); diff --git a/apps/edr-freight-api/src/modules/vehicles/vehicles.module.ts b/apps/edr-freight-api/src/modules/vehicles/vehicles.module.ts index 07aa4bd2f..a3febac70 100644 --- a/apps/edr-freight-api/src/modules/vehicles/vehicles.module.ts +++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.module.ts @@ -3,9 +3,10 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { Vehicle } from './entities/vehicle.entity'; import { VehiclesService } from './vehicles.service'; import { VehiclesController } from './vehicles.controller'; +import { TruckTypesModule } from '../truck-types/truck-types.module'; @Module({ - imports: [TypeOrmModule.forFeature([Vehicle])], + imports: [TypeOrmModule.forFeature([Vehicle]), TruckTypesModule], providers: [VehiclesService], controllers: [VehiclesController], exports: [VehiclesService], diff --git a/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts b/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts index 98d38cbce..30c72e1ba 100644 --- a/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts +++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts @@ -1,9 +1,16 @@ -import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Not, Repository } from 'typeorm'; import { CreateVehicleDto } from './dto/create-vehicle.dto'; import { UpdateVehicleDto } from './dto/update-vehicle.dto'; import { Vehicle, VehicleAvailability, VehicleStatus } from './entities/vehicle.entity'; +import { TruckType } from '../truck-types/entities/truck-type.entity'; +import { TruckTypesService } from '../truck-types/truck-types.service'; import { FirstMile, FirstMileStatus } from '../first-mile/entities/first-mile.entity'; import { FirstMileContainerAllocation } from '../first-mile/entities/first-mile-container-allocation.entity'; import { LastMile, LastMileStatus } from '../last-mile/entities/last-mile.entity'; @@ -18,8 +25,26 @@ export class VehiclesService { @InjectRepository(Vehicle) private readonly vehicleRepo: Repository, private readonly history: FleetHistoryService, + private readonly truckTypes: TruckTypesService, ) {} + /** + * A trailer plate only exists on a configuration that pulls a trailer — a + * rigid truck (Casoni) has none. Checked against the RESULTING record, not + * just the patch, so switching an articulated truck to a rigid type cannot + * leave its old trailer plate stranded on the row. + */ + private assertTrailerPlateAllowed( + truckType: TruckType, + trailerPlateNo?: string | null, + ): void { + if (!truckType.hasTrailer && trailerPlateNo) { + throw new BadRequestException( + `${truckType.name} has no trailer — remove the trailer plate number`, + ); + } + } + /** * A driver holds one truck at a time — reassignment requires detaching them * from their current truck first. @@ -54,10 +79,17 @@ export class VehiclesService { await this.assertDriverUnassigned(dto.assignedDriverId); } - const registrationNumber = `REG-${dto.vehicleType}-${Date.now()}`; + const truckType = await this.truckTypes.findById(dto.truckTypeId); + this.assertTrailerPlateAllowed(truckType, dto.trailerPlateNo); + + const registrationNumber = `REG-${truckType.code}-${Date.now()}`; const vehicle = this.vehicleRepo.create({ ...dto, registrationNumber, + // Denormalised for truck-detention billing, which groups on this column. + vehicleType: truckType.code, + // Capacity belongs to the type; an explicit value still wins for one-offs. + capacity: dto.capacity ?? truckType.capacityTons ?? undefined, }); const saved = await this.vehicleRepo.save(vehicle); @@ -148,6 +180,17 @@ export class VehiclesService { await this.assertDriverUnassigned(dto.assignedDriverId, id); } + // Re-resolve the truck type whenever the type OR the trailer plate moves — + // either edit can produce a rigid truck holding a trailer plate. + const nextTruckTypeId = dto.truckTypeId ?? vehicle.truckTypeId; + let nextTruckType: TruckType | null = null; + if (nextTruckTypeId && (dto.truckTypeId !== undefined || dto.trailerPlateNo !== undefined)) { + nextTruckType = await this.truckTypes.findById(nextTruckTypeId); + const nextTrailerPlate = + dto.trailerPlateNo !== undefined ? dto.trailerPlateNo : vehicle.trailerPlateNo; + this.assertTrailerPlateAllowed(nextTruckType, nextTrailerPlate); + } + const prev = { assignedDriverId: vehicle.assignedDriverId, assignedDriverName: vehicle.assignedDriverName, @@ -156,6 +199,11 @@ export class VehiclesService { }; Object.assign(vehicle, dto); + // After the patch is applied, so the denormalised billing code always + // reflects the type the vehicle actually ends up on. + if (nextTruckType) { + vehicle.vehicleType = nextTruckType.code; + } const saved = await this.vehicleRepo.save(vehicle); // Driver (re)assignment — emit an unassign for the old driver and/or an diff --git a/apps/edr-freight-api/src/modules/vehicles/vehicles.trailer-plate-guard.spec.ts b/apps/edr-freight-api/src/modules/vehicles/vehicles.trailer-plate-guard.spec.ts new file mode 100644 index 000000000..e8f73a38f --- /dev/null +++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.trailer-plate-guard.spec.ts @@ -0,0 +1,81 @@ +import { BadRequestException } from '@nestjs/common'; + +import { VehiclesService } from './vehicles.service'; + +// A trailer plate only exists on a configuration that pulls a trailer. A rigid +// truck (Casoni) has none, so registering or editing one into a trailer plate +// must be refused server-side — the form hiding the field is not enforcement. +describe('VehiclesService trailer plate guard', () => { + const CASONI = { code: 'CASONI', name: 'Casoni (rigid, no trailer)', hasTrailer: false, capacityTons: 30 }; + const ARTIC = { code: 'TRUCK', name: 'Truck', hasTrailer: true, capacityTons: 40 }; + + const makeService = (findOne: jest.Mock, truckType: unknown) => { + const save = jest.fn(async (x) => x); + const svc = new VehiclesService( + { findOne, create: jest.fn((x) => x), save } as any, + { record: jest.fn() } as any, + { findById: jest.fn(async () => truckType) } as any, + ); + return { svc, save }; + }; + + it('rejects creating a rigid truck that carries a trailer plate', async () => { + const findOne = jest.fn().mockResolvedValueOnce(null); // plate is free + const { svc } = makeService(findOne, CASONI); + await expect( + svc.create({ plateNumber: 'ET-9875', truckTypeId: 'tt-casoni', trailerPlateNo: 'ET-1234' } as any), + ).rejects.toThrow(BadRequestException); + }); + + it('accepts a rigid truck with no trailer plate, and takes capacity from the type', async () => { + const findOne = jest.fn().mockResolvedValueOnce(null); + const { svc } = makeService(findOne, CASONI); + const saved = await svc.create({ plateNumber: 'ET-9875', truckTypeId: 'tt-casoni' } as any); + expect(saved.capacity).toBe(30); + // Denormalised code is what truck-detention billing groups on. + expect(saved.vehicleType).toBe('CASONI'); + }); + + it('keeps an explicit capacity over the type default', async () => { + const findOne = jest.fn().mockResolvedValueOnce(null); + const { svc } = makeService(findOne, CASONI); + const saved = await svc.create({ + plateNumber: 'ET-9875', + truckTypeId: 'tt-casoni', + capacity: 25, + } as any); + expect(saved.capacity).toBe(25); + }); + + it('allows a trailer plate on an articulated type', async () => { + const findOne = jest.fn().mockResolvedValueOnce(null); + const { svc } = makeService(findOne, ARTIC); + await expect( + svc.create({ plateNumber: 'ET-9875', truckTypeId: 'tt-truck', trailerPlateNo: 'ET-1234' } as any), + ).resolves.toBeDefined(); + }); + + // The regression that motivated validating the RESULT rather than the patch: + // switching type alone leaves the stored trailer plate behind. + it('rejects switching an existing truck to a rigid type while its trailer plate stands', async () => { + const findOne = jest + .fn() + .mockResolvedValueOnce({ id: 'v1', plateNumber: 'ET-9875', trailerPlateNo: 'ET-1234' }); + const { svc } = makeService(findOne, CASONI); + await expect(svc.update('v1', { truckTypeId: 'tt-casoni' } as any)).rejects.toThrow( + BadRequestException, + ); + }); + + it('allows the switch when the trailer plate is cleared in the same edit', async () => { + const findOne = jest + .fn() + .mockResolvedValueOnce({ id: 'v1', plateNumber: 'ET-9875', trailerPlateNo: 'ET-1234' }); + const { svc } = makeService(findOne, CASONI); + const saved = await svc.update('v1', { + truckTypeId: 'tt-casoni', + trailerPlateNo: null, + } as any); + expect(saved.vehicleType).toBe('CASONI'); + }); +}); diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts index 3b61d7ff0..a60261c1e 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts @@ -791,15 +791,22 @@ export class WarehouseFeeService { }; } - // Group the leg's vehicles by type so each truck type is billed by its own - // matching rule (rates differ by truck type). Falls back to one untyped group. + // Group the leg's vehicles by CANONICAL truck type so each type is billed + // by its own matching rule (rates differ by truck type). The FK to + // truck_types is the source of truth — renaming a type's label no longer + // silently unmatches its rule; the normalized legacy vehicle_type code is + // only a fallback for vehicles without the FK (LEFT JOIN keeps them billed + // instead of dropping them). Falls back to one untyped group. const groupRows: Array<{ vehicleType: string | null; truckCount: number | string }> = await this.dataSource.query( - `SELECT v.vehicle_type AS "vehicleType", count(*)::int AS "truckCount" + `SELECT COALESCE(t.code, NULLIF(UPPER(TRIM(v.vehicle_type)), '')) AS "vehicleType", + count(*)::int AS "truckCount" FROM freight.last_mile_vehicle_assignments va JOIN freight.vehicles v ON v.id = va.vehicle_id AND v.deleted_at IS NULL + LEFT JOIN freight.truck_types t + ON t.id = v.truck_type_id AND t.deleted_at IS NULL WHERE va.last_mile_id = $1 AND va.deleted_at IS NULL - GROUP BY v.vehicle_type`, + GROUP BY 1`, [lastMileId], ); const groups = groupRows.length ? groupRows : [{ vehicleType: null, truckCount: 1 }]; diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 9871f6980..fd65ddcca 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -11,6 +11,7 @@ export const RULE_ENGINE_RESOURCE_SLUGS = [ 'cargo-types', 'container-types', 'wagon-types', + 'truck-types', 'service-types', 'yards', 'shipping-lines', @@ -97,6 +98,7 @@ const RULE_ENGINE_PERMISSION_IDS: Record { + if (!open) return; + setValues((current) => { + const scratch: Record = {}; + fields.forEach((field) => { + if (!field.onOptionSelected) return; + const selected = field.options?.find((o) => o.value === current[field.name]); + if (!selected) return; + Object.entries(field.onOptionSelected(selected, current)).forEach(([key, value]) => { + if (key.startsWith("_")) scratch[key] = value; + }); + }); + return Object.keys(scratch).length ? { ...current, ...scratch } : current; + }); + }, [open, fields]); + // Receive the ?code&state relayed by the /callback popup, exchange it for // the verified identity, and prefill the matching form fields. useEffect(() => { @@ -202,18 +224,52 @@ const FleetFormDialog = ({ const faydaVerified = values.faydaVerified === true; + /** + * Fields the current answers actually apply to — a rigid truck type (Casoni) + * has no trailer, so its plate field disappears. Honoured in three places, not + * just here: a hidden field must also skip validation (an invisible "required" + * error blocks submit with nothing to fix) and must submit an explicit null + * (so switching to a rigid type CLEARS the stored trailer plate rather than + * stranding it on the row). + */ + const visibleFields = useMemo( + () => + fields.filter((field) => { + if ( + field.hideWhen && + field.hideWhen.equals.includes(String(values[field.hideWhen.field] ?? "")) + ) { + return false; + } + if ( + field.showWhen && + !field.showWhen.equals.includes(String(values[field.showWhen.field] ?? "")) + ) { + return false; + } + if (field.showIf && !field.showIf(values)) return false; + return true; + }), + [fields, values], + ); + + const hiddenFieldNames = useMemo(() => { + const visible = new Set(visibleFields.map((f) => f.name)); + return fields.filter((f) => !visible.has(f.name)).map((f) => f.name); + }, [fields, visibleFields]); + const shortFields = useMemo( - () => fields.filter((f) => f.type !== "textarea"), - [fields], + () => visibleFields.filter((f) => f.type !== "textarea"), + [visibleFields], ); const longFields = useMemo( - () => fields.filter((f) => f.type === "textarea"), - [fields], + () => visibleFields.filter((f) => f.type === "textarea"), + [visibleFields], ); const validate = () => { const next: Record = {}; - fields.forEach((field) => { + visibleFields.forEach((field) => { const value = values[field.name]; const stringValue = typeof value === "string" ? value.trim() : String(value ?? ""); @@ -295,9 +351,19 @@ const FleetFormDialog = ({ fields.forEach((field) => { if (field.derivedValue) submitted[field.name] = field.derivedValue(values); }); + // A field the answers hid no longer applies to this record — send an explicit + // null so the column is unset, instead of leaving a stale value behind. + hiddenFieldNames.forEach((name) => { + submitted[name] = null; + }); const payload = Object.fromEntries( Object.entries(submitted) + // `_`-prefixed keys are form-local scratch written by `onOptionSelected` + // (e.g. _hasTrailer, which drives visibility). The API validates with + // forbidNonWhitelisted, so an undeclared key would 400 the whole save. + .filter(([key]) => !key.startsWith("_")) .map(([key, value]) => { + if (hiddenFieldNames.includes(key)) return [key, null]; if (value === FLEET_SELECT_NONE || value === "" || value == null) return [key, clearableByName[key] ? null : undefined]; if (fieldTypeByName[key] === "number") { @@ -371,7 +437,15 @@ const FleetFormDialog = ({ : String(value) } onChange={(next) => - setValues((current) => ({ ...current, [field.name]: next ?? "" })) + setValues((current) => { + const patch = field.onOptionSelected + ? field.onOptionSelected( + field.options?.find((o) => o.value === next), + current, + ) + : {}; + return { ...current, [field.name]: next ?? "", ...patch }; + }) } error={error} searchable diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index ade491a7b..d08cac603 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -417,6 +417,9 @@ export const URL_CONSTANTS = { WAGON_TYPES: "/wagon-types", WAGON_TYPE_BY_ID: (id: string) => `/wagon-types/${id}`, + TRUCK_TYPES: "/truck-types", + TRUCK_TYPE_BY_ID: (id: string) => `/truck-types/${id}`, + PRIORITY_CONFIGS: "/priority-configs", PRIORITY_CONFIG_BY_ID: (id: string) => `/priority-configs/${id}`, diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx index 63a722550..027b16d4e 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx @@ -94,6 +94,9 @@ const FleetResourcePage = () => { const { data: wagonTypes = [], isLoading: wagonTypesLoading } = useQuery( api.wagonTypes.list.queryOptions(), ); + const { data: truckTypes = [], isLoading: truckTypesLoading } = useQuery( + api.truckTypes.list.queryOptions(), + ); const { data: containerTypes = [], isLoading: containerTypesLoading } = useQuery( api.containerTypes.list.queryOptions({ staleTime: Infinity }), ); @@ -169,17 +172,34 @@ const FleetResourcePage = () => { (y) => ({ value: y.id, label: y.label ?? y.code ?? y.id }), ); + // Carries capacity + trailer configuration so picking a truck type can + // pre-fill the vehicle's capacity and drop the trailer plate on a rigid type. + const truckTypeOpts = ( + truckTypes as Array<{ + id: string; + code: string; + name?: string; + capacityTons?: number | null; + hasTrailer?: boolean; + }> + ).map((t) => ({ + value: t.id, + label: t.name ? `${t.name} (${t.code})` : t.code, + meta: { capacityTons: t.capacityTons, hasTrailer: t.hasTrailer }, + })); + registerFleetOptionLabels("currentYardId", yardOpts); return { wagonTypes: wagonTypeOpts, containerTypes: containerTypeOpts, cargoTypes: [{ label: "None", value: FLEET_SELECT_NONE }, ...cargoTypeOpts], + truckTypes: truckTypeOpts, wagons: [{ label: "Unassigned", value: FLEET_SELECT_NONE }, ...wagonOpts], containers: containerOpts, yards: yardOpts, }; - }, [wagonTypes, containerTypes, cargoTypes, wagons, containers, yards]); + }, [wagonTypes, containerTypes, cargoTypes, truckTypes, wagons, containers, yards]); const listFilterSelects = useMemo(() => { if (!config?.listFilters?.length) return null; @@ -212,6 +232,7 @@ const FleetResourcePage = () => { registerFleetOptionLabels("containerId", dynamicOptions.containers); registerFleetOptionLabels("currentYardId", dynamicOptions.yards); registerFleetOptionLabels("locationId", dynamicOptions.yards); + registerFleetOptionLabels("truckTypeId", dynamicOptions.truckTypes); }, [dynamicOptions]); const formFields = useMemo((): FleetFormFieldDef[] => { @@ -227,6 +248,7 @@ const FleetResourcePage = () => { wagonTypesLoading || containerTypesLoading || cargoTypesLoading || + truckTypesLoading || wagonsLoading || containersLoading || yardsLoading; diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/VehicleDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/VehicleDetailPage.tsx index 6a3956685..ae785a708 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/VehicleDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/VehicleDetailPage.tsx @@ -1,14 +1,18 @@ -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import { useParams, useNavigate } from "react-router-dom"; -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ActionIcon, Badge, + Button, Card, Center, Container, Group, Loader, + NumberInput, + Radio, + Select, SimpleGrid, Stack, Table, @@ -20,6 +24,7 @@ import { import { ArrowLeft, Fuel, + Gauge, History, Route, Truck, @@ -28,7 +33,13 @@ import { } from "lucide-react"; import { api } from "@/auth/http"; -import { vehiclesService } from "@/services/vehicles.service"; +import { api as apiClient2 } from "@/services/api"; +import { useToast } from "@/hooks/use-toast"; +import { + vehiclesService, + type SaveVehiclePayload, + type Vehicle, +} from "@/services/vehicles.service"; import { driversService } from "@/services/drivers.service"; import { fleetHistoryService } from "@/services/fleet-history.service"; @@ -132,6 +143,7 @@ const VehicleDetailPage = () => { }>History }>Maintenance }>Fuel + }>Operations }>First/Last mile @@ -142,6 +154,8 @@ const VehicleDetailPage = () => { + + @@ -172,6 +186,10 @@ const VehicleDetailPage = () => { + + + + @@ -181,6 +199,103 @@ const VehicleDetailPage = () => { ); }; +/** + * Where a truck is and what it costs to run are per-trip operational facts, not + * part of registering the vehicle — so they are edited here rather than on the + * Add Vehicle form. `pricePerKm` is live billing input: first/last-mile charges + * are `distance × pricePerKm`. + */ +const OperationsTab = ({ vehicle }: { vehicle: Vehicle }) => { + const { toast } = useToast(); + const queryClient = useQueryClient(); + const [form, setForm] = useState({ + locationId: vehicle.locationId ?? "", + estimatedDistanceKm: vehicle.estimatedDistanceKm ?? "", + actualDistanceKm: vehicle.actualDistanceKm ?? "", + pricePerKm: vehicle.pricePerKm ?? "", + currency: vehicle.currency ?? "ETB", + }); + + const { data: yards = [], isLoading: yardsLoading } = useQuery( + apiClient2.routes.yards.queryOptions(), + ); + + const save = useMutation({ + mutationFn: () => + vehiclesService.update(vehicle.id, { + locationId: form.locationId || null, + // Empty means "not recorded" — send null so the column is unset rather + // than coerced to 0, which would read as a real measurement. + estimatedDistanceKm: form.estimatedDistanceKm === "" ? null : Number(form.estimatedDistanceKm), + actualDistanceKm: form.actualDistanceKm === "" ? null : Number(form.actualDistanceKm), + pricePerKm: form.pricePerKm === "" ? null : Number(form.pricePerKm), + currency: form.currency || null, + } as Partial & { locationId?: string | null }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["vehicle", vehicle.id] }); + toast({ title: "Operational details saved" }); + }, + onError: () => + toast({ title: "Could not save operational details", variant: "destructive" }), + }); + + return ( + + + + - n === row.containerNumber || - !vehicleRows.some((r, idx) => idx !== i && r.containerNumber === n), - ), - // keep a manual/legacy value selectable even if not in the booking - ...(row.containerNumber && !containerOptions.includes(row.containerNumber) - ? [row.containerNumber] - : []), - ]} - value={row.containerNumber || null} - onChange={(value) => - setVehicleRows((prev) => - prev.map((x, idx) => (idx === i ? { ...x, containerNumber: value ?? "" } : x)), - ) - } - searchable - clearable - /> + {!bulkMode && activeRecord && isBulkBooking(activeRecord) ? ( + <> + + setVehicleRows((prev) => + prev.map((x, idx) => + idx === i ? { ...x, tons: v === "" ? "" : Number(v) } : x, + ), + ) + } + /> + + setVehicleRows((prev) => + prev.map((x, idx) => + idx === i ? { ...x, quantity: v === "" ? "" : Number(v) } : x, + ), + ) + } + /> + + ) : ( + ({ value: v, label: v.charAt(0) + v.slice(1).toLowerCase() }))} + placeholder={truckTypesLoading ? 'Loading truck types...' : 'Any truck type'} + data={truckTypes.map((t) => ({ value: t.code, label: `${t.name} (${t.code})` }))} + disabled={truckTypesLoading} value={form.vehicleType || null} onChange={(value) => setForm((f) => ({ ...f, vehicleType: selectValue(value) }))} clearable diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts index 71005f6a1..c5fc7d85c 100644 --- a/apps/edr-freight-web/backoffice/src/services/api.ts +++ b/apps/edr-freight-web/backoffice/src/services/api.ts @@ -195,6 +195,7 @@ import { type UsedTrainNumbers, } from "./trainBuilder.service"; import { trainSchedulingService } from "./trainScheduling.service"; +import { truckTypesService, type TruckType } from "./truck-types.service"; import { wagonTypesService, type WagonType } from "./wagon-types.service"; import { wagonService, @@ -2073,6 +2074,36 @@ export const api = { ), }, + truckTypes: { + list: endpoint("truck-types", "list", () => + truckTypesService.getTruckTypes(), + ), + + create: endpoint, TruckType>( + "truck-types", + "create", + (payload) => truckTypesService.create(payload).then((r) => r.data), + undefined, + () => [["truck-types"]], + ), + + update: endpoint<{ id: string; data: Partial }, TruckType>( + "truck-types", + "update", + ({ id, data }) => truckTypesService.update(id, data).then((r) => r.data), + undefined, + () => [["truck-types"]], + ), + + remove: endpoint( + "truck-types", + "remove", + (id) => truckTypesService.delete(id).then(() => undefined), + undefined, + () => [["truck-types"]], + ), + }, + wagonTypes: { list: endpoint("wagon-types", "list", () => wagonTypesService.getWagonTypes(), diff --git a/apps/edr-freight-web/backoffice/src/services/first-mile.service.ts b/apps/edr-freight-web/backoffice/src/services/first-mile.service.ts index 275bf84ea..5992fa96f 100644 --- a/apps/edr-freight-web/backoffice/src/services/first-mile.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/first-mile.service.ts @@ -22,7 +22,10 @@ export interface FirstMileBooking { serviceType?: { id: string; label?: string } | null; originYard?: { id: string; label?: string } | null; destinationYard?: { id: string; label?: string } | null; - cargoType?: { id: string; label?: string } | null; + cargoType?: { id: string; label?: string; cargoTypeName?: string; name?: string } | null; + freightType?: string | null; + /** Attached server-side: the train schedule this booking rides. */ + trainSchedule?: { trainNumber: string | null; departureDate: string | null } | null; /** Container lines — total container count drives how many trucks are needed. */ bookingContainers?: Array<{ id: string; @@ -68,6 +71,8 @@ export interface FirstMileRecord { vehicleId: string; containerNumber?: string | null; distanceKm?: number | null; + tons?: number | null; + quantity?: number | null; vehicle?: FirstMileVehicle | null; }>; /** Present only when an invoice has actually been generated (not on distance). */ @@ -95,7 +100,12 @@ export const firstMileService = { api.delete(FM.BY_ID(id)), setVehicles: ( id: string, - vehicles: Array<{ vehicleId: string; containerNumber?: string | null }>, + vehicles: Array<{ + vehicleId: string; + containerNumber?: string | null; + tons?: number | null; + quantity?: number | null; + }>, ) => api.post(`${FM.BASE}/${id}/vehicles`, { vehicles }), setDistances: ( id: string, diff --git a/apps/edr-freight-web/backoffice/src/services/ruleEngine/ruleEngine.service.ts b/apps/edr-freight-web/backoffice/src/services/ruleEngine/ruleEngine.service.ts index 4e646e426..3fcd6a7e8 100644 --- a/apps/edr-freight-web/backoffice/src/services/ruleEngine/ruleEngine.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/ruleEngine/ruleEngine.service.ts @@ -85,6 +85,7 @@ const RESOURCE_BASE: Record = { "cargo-types": URL_CONSTANTS.RULE_ENGINE.CARGO_TYPES, "container-types": URL_CONSTANTS.RULE_ENGINE.CONTAINER_TYPES, "wagon-types": URL_CONSTANTS.RULE_ENGINE.WAGON_TYPES, + "truck-types": URL_CONSTANTS.RULE_ENGINE.TRUCK_TYPES, "priority-configs": URL_CONSTANTS.RULE_ENGINE.PRIORITY_CONFIGS, "service-types": URL_CONSTANTS.RULE_ENGINE.SERVICE_TYPES, "weight-limit-rules": URL_CONSTANTS.RULE_ENGINE.WEIGHT_LIMIT_RULES, @@ -103,6 +104,8 @@ const byIdPath = (resource: RuleEngineResourceSlug, id: string): string => { return URL_CONSTANTS.RULE_ENGINE.CONTAINER_TYPE_BY_ID(id); case "wagon-types": return URL_CONSTANTS.RULE_ENGINE.WAGON_TYPE_BY_ID(id); + case "truck-types": + return URL_CONSTANTS.RULE_ENGINE.TRUCK_TYPE_BY_ID(id); case "priority-configs": return URL_CONSTANTS.RULE_ENGINE.PRIORITY_CONFIG_BY_ID(id); case "service-types": diff --git a/apps/edr-freight-web/backoffice/src/services/truck-types.service.ts b/apps/edr-freight-web/backoffice/src/services/truck-types.service.ts new file mode 100644 index 000000000..a48fa569f --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/services/truck-types.service.ts @@ -0,0 +1,30 @@ +import { api } from "../auth/http"; + +type ListResponse = T[] | { data: T[] }; + +export interface TruckType { + id: string; + code: string; + name: string; + /** Pre-fills a vehicle's capacity — capacity belongs to the type, not each truck. */ + capacityTons: number | null; + /** False for a rigid truck (e.g. Casoni), which has no trailer plate at all. */ + hasTrailer: boolean; + description?: string | null; + isActive: boolean; +} + +const asList = (payload: ListResponse): T[] => + Array.isArray(payload) ? payload : payload.data; + +export const truckTypesService = { + async getTruckTypes() { + const response = await api.get>('/truck-types', { + params: { isActive: 'all', pageSize: 500 }, + }); + return asList(response.data); + }, + create: (data: Partial) => api.post('/truck-types', data), + update: (id: string, data: Partial) => api.patch(`/truck-types/${id}`, data), + delete: (id: string) => api.delete(`/truck-types/${id}`), +}; diff --git a/apps/edr-freight-web/backoffice/src/services/vehicles.service.ts b/apps/edr-freight-web/backoffice/src/services/vehicles.service.ts index 749e7656c..c72efa264 100644 --- a/apps/edr-freight-web/backoffice/src/services/vehicles.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/vehicles.service.ts @@ -20,7 +20,14 @@ export interface Vehicle { id: string; plateNumber: string; registrationNumber: string; + /** Denormalised truck-type code, written server-side. Register with `truckTypeId`. */ vehicleType: VehicleType; + /** Truck configuration from the managed truck types. */ + truckTypeId?: string | null; + /** Vehicle Identification Number — unique across the fleet. */ + vin?: string | null; + /** OWNED | OUTSOURCED. */ + ownership?: string | null; manufacturer: string; model: string; year: number; diff --git a/apps/edr-freight-web/backoffice/src/types/rule-engine/index.ts b/apps/edr-freight-web/backoffice/src/types/rule-engine/index.ts index da454e55c..bed483a90 100644 --- a/apps/edr-freight-web/backoffice/src/types/rule-engine/index.ts +++ b/apps/edr-freight-web/backoffice/src/types/rule-engine/index.ts @@ -2,6 +2,7 @@ export type RuleEngineResourceSlug = | "cargo-types" | "container-types" | "wagon-types" + | "truck-types" | "priority-configs" | "service-types" | "weight-limit-rules" diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx index 5f143b28c..7ec73c848 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx @@ -8,6 +8,7 @@ import { Group, Loader, MultiSelect, + NumberInput, Select, SimpleGrid, Stack, @@ -79,6 +80,8 @@ export function CustomerTruckAssignmentCard({ const [driverName, setDriverName] = useState(""); const [truckType, setTruckType] = useState(""); const [containers, setContainers] = useState([]); + const [plannedTons, setPlannedTons] = useState(""); + const [plannedQty, setPlannedQty] = useState(""); const [editingId, setEditingId] = useState(null); const [error, setError] = useState(null); const [bulkModalOpen, setBulkModalOpen] = useState(false); @@ -105,15 +108,23 @@ export function CustomerTruckAssignmentCard({ setDriverName(""); setTruckType(""); setContainers([]); + setPlannedTons(""); + setPlannedQty(""); setEditingId(null); setError(null); }; const startEdit = (t: Freight.ICustomerTruck) => { + const planned = t as Freight.ICustomerTruck & { + plannedTons?: number | string | null; + plannedQuantity?: number | null; + }; setPlateNumber(t.plateNumber ?? ""); setDriverName(t.driverName ?? ""); setTruckType(t.truckType ?? ""); setContainers((t.containers ?? []).map((c) => c.containerNumber)); + setPlannedTons(planned.plannedTons != null ? Number(planned.plannedTons) : ""); + setPlannedQty(planned.plannedQuantity != null ? Number(planned.plannedQuantity) : ""); setEditingId(t.id); setError(null); }; @@ -129,6 +140,12 @@ export function CustomerTruckAssignmentCard({ driverName: driverName.trim(), truckType: truckType.trim(), containerNumbers: isBulk ? [] : containers, + ...(isBulk + ? { + plannedTons: plannedTons === "" ? undefined : Number(plannedTons), + plannedQuantity: plannedQty === "" ? undefined : Number(plannedQty), + } + : {}), }; return editingId ? customerTrucksService.update(booking.id, editingId, payload) @@ -170,6 +187,10 @@ export function CustomerTruckAssignmentCard({ setError("Select 1 or 2 container numbers for this truck."); return; } + if (isBulk && plannedTons === "") { + setError("Enter the tonnes this truck will haul."); + return; + } setError(null); addMutation.mutate(); }; @@ -324,6 +345,40 @@ export function CustomerTruckAssignmentCard({ nothingFoundMessage="No unassigned containers" /> )} + {isBulk && ( + { + const total = Number(booking.cargoTotalWeightVgm) || 0; + const assigned = trucks + .filter((t) => t.id !== editingId) + .reduce((s, t) => { + const x = t as Freight.ICustomerTruck & { + netWeightTons?: number | string | null; + plannedTons?: number | string | null; + }; + return s + (Number(x.netWeightTons ?? x.plannedTons) || 0); + }, 0); + const remaining = Math.max(0, Math.round((total - assigned) * 1000) / 1000); + return total > 0 + ? `${assigned} t of ${total} t already on trucks · ${remaining} t remaining` + : "Tonnage this truck hauls"; + })()} + required + min={0} + value={plannedTons} + onChange={(v) => setPlannedTons(v === "" ? "" : Number(v))} + /> + )} + {isBulk && ( + setPlannedQty(v === "" ? "" : Number(v))} + /> + )} {editingId && ( From 2f124bc666ad26e35f79b9de4a454df48a8b9b35 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Fri, 24 Jul 2026 11:50:25 +0000 Subject: [PATCH 2/2] Intercity load unload with grn ,Warehouse , fleet , and allocation endpoints permission --- .../warehouse-inspection.controller.ts | 7 +- .../warehouse-inventory.controller.ts | 13 +- .../warehouse-invoice.controller.ts | 7 +- .../warehouses/warehouse-yards.controller.ts | 10 +- .../warehouses/warehouse-zones.controller.ts | 5 +- .../warehouses/warehouses.controller.ts | 9 +- .../src/components/common/ListControls.tsx | 96 ++++++++++ .../warehouses/InventoryWorkbench.tsx | 41 ++++- .../backoffice/src/hooks/useListControls.ts | 164 ++++++++++++++++++ .../src/pages/fleet/CompliancePage.tsx | 31 +++- .../src/pages/fleet/FleetResourcePage.tsx | 43 ++++- .../src/pages/fleet/FuelPurchasePage.tsx | 31 +++- .../src/pages/fleet/IncidentsPage.tsx | 30 +++- .../warehouses/InterchangeDocumentsPage.tsx | 23 ++- .../src/pages/warehouses/IntercityPage.tsx | 128 ++++++++++++-- .../pages/warehouses/InventoryInquiryPage.tsx | 33 +++- .../pages/warehouses/LoadedInventoryPage.tsx | 33 +++- .../src/pages/warehouses/TrucksOnSitePage.tsx | 70 +++++--- .../warehouses/WarehouseInvoicesPage.tsx | 58 ++++--- .../pages/warehouses/WarehouseListPage.tsx | 40 ++++- .../pages/warehouses/WarehouseRulesPage.tsx | 46 ++++- .../backoffice/src/types/warehouse.ts | 8 +- 22 files changed, 827 insertions(+), 99 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/components/common/ListControls.tsx create mode 100644 apps/edr-freight-web/backoffice/src/hooks/useListControls.ts diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.controller.ts index 7bd56e593..cdf34cd66 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.controller.ts @@ -20,8 +20,13 @@ import { WarehouseInspectionService } from './warehouse-inspection.service'; @ApiTags('warehouse-inspection') @ApiBearerAuth() +// Baseline read: inspection reports are opened from inventory screens too — +// either view permission grants reads; writes stack their own per route. @Controller() -@BookingStaff(FREIGHT_PERMS.warehouseInspectionReports.view) +@BookingStaff([ + FREIGHT_PERMS.warehouseInspectionReports.view, + FREIGHT_PERMS.warehouseInventory.view, +]) export class WarehouseInspectionController { constructor(private readonly inspectionService: WarehouseInspectionService) {} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts index 0174ef3e9..b3868b465 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts @@ -5,7 +5,7 @@ import { CurrentUser } from '@edr/api-common'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; import { actorLabel } from './current-actor.util'; -import { BookingStaff } from '../../common/booking-guards'; +import { BookingStaff, StaffReference } from '../../common/booking-guards'; import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { BulkReceiveDto } from './dto/bulk-receive.dto'; import { BulkInspectDto } from './dto/bulk-inspect.dto'; @@ -456,6 +456,7 @@ export class WarehouseInventoryController { } @Get(':id/handover-document') + @StaffReference() @ApiOperation({ summary: 'View import goods handover document PDF' }) async handoverDocument(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) { const { filename, buffer } = await this.inventoryService.handoverDocument(id); @@ -466,6 +467,7 @@ export class WarehouseInventoryController { } @Post('bookings/:bookingId/approve-delivery') + @StaffReference() @ApiOperation({ summary: "Approve delivery — customer records their full name (signature optional)" }) approveDeliveryForBooking( @Param('bookingId', ParseUUIDPipe) bookingId: string, @@ -481,12 +483,14 @@ export class WarehouseInventoryController { } @Get('bookings/:bookingId/handovers') + @StaffReference() @ApiOperation({ summary: 'Handover records for a booking (per-booking or per-truck)' }) bookingHandovers(@Param('bookingId', ParseUUIDPipe) bookingId: string) { return this.handoverService.list(bookingId); } @Post('handovers/:handoverId/sign') + @StaffReference() @ApiOperation({ summary: 'Customer signs one handover (EDR last-mile: one signature per truck)' }) signHandover( @Param('handoverId', ParseUUIDPipe) handoverId: string, @@ -502,12 +506,14 @@ export class WarehouseInventoryController { } @Post('bookings/:bookingId/request-handover-signature') + @StaffReference() @ApiOperation({ summary: 'Ask the customer to sign the handover (creates one if none, then notifies)' }) requestHandoverSignature(@Param('bookingId', ParseUUIDPipe) bookingId: string) { return this.handoverService.requestSignature(bookingId); } @Get('bookings/:bookingId/grn-document') + @StaffReference() @ApiOperation({ summary: 'View GRN PDF for a booking (customer portal)' }) async bookingGrnDocument(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Res() res: Response) { const { filename, buffer } = await this.inventoryService.grnDocumentForBooking(bookingId); @@ -518,6 +524,7 @@ export class WarehouseInventoryController { } @Get('bookings/:bookingId/release-document') + @StaffReference() @ApiOperation({ summary: 'View gate-clearance / release-order PDF for a booking (customer portal)' }) async bookingReleaseDocument(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Res() res: Response) { const { filename, buffer } = await this.inventoryService.releaseDocumentForBooking(bookingId); @@ -528,6 +535,7 @@ export class WarehouseInventoryController { } @Get('bookings/:bookingId/handover-document') + @StaffReference() @ApiOperation({ summary: 'View import goods handover document PDF (resolved by booking; ?handoverId= for the per-truck variant)' }) async bookingHandoverDocument( @Param('bookingId', ParseUUIDPipe) bookingId: string, @@ -545,18 +553,21 @@ export class WarehouseInventoryController { } @Get('bookings/:bookingId/container-items') + @StaffReference() @ApiOperation({ summary: 'Per-container/bulk items of a booking with lifecycle stage + refs' }) containerItems(@Param('bookingId', ParseUUIDPipe) bookingId: string) { return this.inventoryService.containerItems(bookingId); } @Get('bookings/:bookingId/container-weights') + @StaffReference() @ApiOperation({ summary: "A booking's containers + VGM cargo weight (tonnes) for exit weighing" }) containerWeights(@Param('bookingId', ParseUUIDPipe) bookingId: string) { return this.inventoryService.bookingContainerWeights(bookingId); } @Get('bookings/:bookingId/location') + @StaffReference() @ApiOperation({ summary: "Warehouse location of a booking's inventory (customer portal)" }) bookingLocation(@Param('bookingId', ParseUUIDPipe) bookingId: string) { return this.inventoryService.bookingLocation(bookingId); diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.controller.ts index 4ad469ce0..c2f5cec9c 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.controller.ts @@ -5,7 +5,7 @@ import { CurrentUser } from '@edr/api-common'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; import { actorLabel } from './current-actor.util'; -import { BookingStaff } from '../../common/booking-guards'; +import { BookingStaff, StaffReference } from '../../common/booking-guards'; import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { PayInvoiceDto as GatewayPayInvoiceDto } from '../billing/dto/pay-invoice.dto'; import { GenerateInvoiceDto, PayInvoiceBodyDto } from './dto/invoice.dto'; @@ -43,6 +43,7 @@ export class WarehouseInvoiceController { } @Get('bookings/:id/warehouse-fee-invoices') + @StaffReference() @ApiOperation({ summary: 'List warehouse fee invoices for a booking' }) listForBooking(@Param('id', ParseUUIDPipe) id: string) { return this.invoiceService.listForBooking(id); @@ -70,12 +71,14 @@ export class WarehouseInvoiceController { } @Get('warehouse-fee-invoices/:id') + @StaffReference() @ApiOperation({ summary: 'Get a warehouse fee invoice with items + payment history' }) findOne(@Param('id', ParseUUIDPipe) id: string) { return this.invoiceService.findById(id); } @Get('warehouse-fee-invoices/:id/document') + @StaffReference() @ApiOperation({ summary: 'Download sealed warehouse fee invoice PDF' }) async document(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) { const { filename, buffer } = await this.invoiceService.document(id); @@ -86,6 +89,7 @@ export class WarehouseInvoiceController { } @Get('warehouse-fee-invoices/:id/receipt') + @StaffReference() @ApiOperation({ summary: 'Download sealed warehouse fee payment receipt PDF' }) async receipt(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) { const { filename, buffer } = await this.invoiceService.receipt(id); @@ -110,6 +114,7 @@ export class WarehouseInvoiceController { } @Post('warehouse-fee-invoices/:id/pay-online') + @StaffReference() @ApiOperation({ summary: 'Initiate Telebirr/Waafi payment for a warehouse fee invoice' }) payOnline(@Param('id', ParseUUIDPipe) id: string, @Body() dto: GatewayPayInvoiceDto) { return this.invoiceService.initiatePayment(id, dto); diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.controller.ts index 5f4205815..7e658d9db 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.controller.ts @@ -1,7 +1,7 @@ import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; -import { BookingStaff, StaffReference } from '../../common/booking-guards'; +import { BookingStaff } from '../../common/booking-guards'; import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { CreateWarehouseZoneDto } from './dto/create-warehouse-zone.dto'; import { UpdateWarehouseYardDto } from './dto/update-warehouse-yard.dto'; @@ -10,8 +10,8 @@ import { WarehouseZonesService } from './warehouse-zones.service'; @ApiTags('warehouse-yards') @ApiBearerAuth() -// No class-level guard: the two reference GETs are open to any signed-in -// staff (StaffReference), every other route carries its own permission. +// No class-level guard: every route carries its own permission (reads accept +// yard-view OR inventory-view so inventory flows can populate yard pickers). @Controller('warehouse-yards') export class WarehouseYardsController { constructor( @@ -20,14 +20,14 @@ export class WarehouseYardsController { ) {} @Get() - @StaffReference() + @BookingStaff([FREIGHT_PERMS.warehouseYards.view, FREIGHT_PERMS.warehouseInventory.view]) @ApiOperation({ summary: 'List all warehouse yards' }) findAll() { return this.yardsService.findAll(); } @Get(':id') - @StaffReference() + @BookingStaff([FREIGHT_PERMS.warehouseYards.view, FREIGHT_PERMS.warehouseInventory.view]) @ApiOperation({ summary: 'Get warehouse yard by ID' }) findOne(@Param('id', ParseUUIDPipe) id: string) { return this.yardsService.findById(id); diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts index b0371cbcc..594fd7a6f 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts @@ -8,8 +8,11 @@ import { WarehouseZonesService } from './warehouse-zones.service'; @ApiTags('warehouse-zones') @ApiBearerAuth() +// Baseline read: zone reference data also serves inventory flows (allocation, +// receive/move pickers) — either view permission grants reads; writes stack +// their specific permission per route. @Controller('warehouse-zones') -@BookingStaff(FREIGHT_PERMS.warehouseZones.view) +@BookingStaff([FREIGHT_PERMS.warehouseZones.view, FREIGHT_PERMS.warehouseInventory.view]) export class WarehouseZonesController { constructor(private readonly zonesService: WarehouseZonesService) {} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouses.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouses.controller.ts index 63c40de94..3ee381a8c 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouses.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.controller.ts @@ -13,8 +13,15 @@ import { WarehousesService } from './warehouses.service'; @ApiTags('warehouses') @ApiBearerAuth() +// Baseline read: warehouse reference data is consumed by inventory/dashboard +// flows too, so any of the three view permissions grants reads. Writes stack +// their specific create/update permission per route on top. @Controller('warehouses') -@BookingStaff(FREIGHT_PERMS.warehouses.view) +@BookingStaff([ + FREIGHT_PERMS.warehouses.view, + FREIGHT_PERMS.warehouseInventory.view, + FREIGHT_PERMS.warehouseDashboard.view, +]) export class WarehousesController { constructor( private readonly warehousesService: WarehousesService, diff --git a/apps/edr-freight-web/backoffice/src/components/common/ListControls.tsx b/apps/edr-freight-web/backoffice/src/components/common/ListControls.tsx new file mode 100644 index 000000000..92ab514ad --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/common/ListControls.tsx @@ -0,0 +1,96 @@ +import { Button, Group, TextInput } from "@mantine/core"; +import { DatePickerInput } from "@mantine/dates"; +import { Search, X } from "lucide-react"; +import type { ReactNode } from "react"; + +export interface ListControlsProps { + search: string; + onSearchChange: (value: string) => void; + searchPlaceholder?: string; + /** `YYYY-MM-DD`, matching Mantine 9's date inputs. */ + dateFrom: string | null; + onDateFromChange: (value: string | null) => void; + dateTo: string | null; + onDateToChange: (value: string | null) => void; + /** Label above the range, naming the date being filtered (e.g. "Arrival date"). */ + dateLabel?: string; + hasFilters?: boolean; + onReset?: () => void; + /** Page-specific selects (status, warehouse…) rendered after the date range. */ + children?: ReactNode; + showSearch?: boolean; + showDateRange?: boolean; +} + +/** + * Search box + inclusive date range + clear, shared by every freight list so the + * controls sit in the same place and behave the same way on all of them. + * Pair with `useListControls`, which owns the state and does the filtering. + */ +const ListControls = ({ + search, + onSearchChange, + searchPlaceholder = "Search…", + dateFrom, + onDateFromChange, + dateTo, + onDateToChange, + dateLabel, + hasFilters, + onReset, + children, + showSearch = true, + showDateRange = true, +}: ListControlsProps) => ( + + {showSearch && ( + onSearchChange(e.currentTarget.value)} + leftSection={} + style={{ flex: "1 1 240px", minWidth: 200 }} + /> + )} + + {showDateRange && ( + <> + + + + )} + + {children} + + {hasFilters && onReset && ( + + )} + +); + +export default ListControls; diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx index d8b370de0..88c6de18c 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx @@ -18,6 +18,11 @@ import { LoadInventoryModal } from './LoadInventoryModal'; import { MoveInventoryModal } from './MoveInventoryModal'; import { ReleaseOrderModal } from './ReleaseOrderModal'; import { WarehouseInventoryTable } from './WarehouseInventoryTable'; +import ListControls from '@/components/common/ListControls'; +// Generic list footer — already shared by the fleet and train-scheduling lists +// despite the ruleEngine path; reused here rather than adding a second one. +import RuleEngineListFooter from '@/components/ruleEngine/RuleEngineListFooter'; +import { useListControls } from '@/hooks/useListControls'; import { extractDownloadErrorMessage, extractErrorMessage } from './options'; import { openPdfBlob, saveBlob } from './pdf'; @@ -56,8 +61,17 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo api.warehouses.bulkMarkInspected.mutationOptions(), ); + const controls = useListControls(items, { + searchKeys: ['grnNumber', 'bookingReference', 'customerName', 'status', 'releaseOrderReference', 'notes'], + dateKey: 'arrivedAt', + }); + const visible = controls.filteredRows; + const [selected, setSelected] = useState>(new Set()); - const allSelected = items.length > 0 && selected.size === items.length; + // Select-all spans everything matching the current filters, not just the rows + // on screen — bulk "mark inspected" over one page of a filtered set would be a + // surprise. Counts compare against the filtered set for the same reason. + const allSelected = visible.length > 0 && selected.size === visible.length; const someSelected = selected.size > 0 && !allSelected; const toggleSelect = (id: string) => setSelected((prev) => { @@ -66,7 +80,7 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo return next; }); const toggleSelectAll = () => - setSelected(allSelected ? new Set() : new Set(items.map((i) => i.id))); + setSelected(allSelected ? new Set() : new Set(visible.map((i) => i.id))); const markInspected = async () => { if (selected.size === 0) { @@ -268,8 +282,21 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo + + + + setMoveItem(null)} item={moveItem} /> diff --git a/apps/edr-freight-web/backoffice/src/hooks/useListControls.ts b/apps/edr-freight-web/backoffice/src/hooks/useListControls.ts new file mode 100644 index 000000000..11e0124eb --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/hooks/useListControls.ts @@ -0,0 +1,164 @@ +import { useEffect, useMemo, useState } from "react"; +import { usePagination } from "@edr/ui-common"; + +/** + * Search + date-range + pagination over an already-fetched array. + * + * Client-side on purpose: the freight lists are hundreds of rows (largest table + * is ~1.1k), so filtering in the browser avoids paginating ~20 API endpoints — + * several of which sit on billing paths. If a list ever outgrows this (roughly + * 5k rows, where the per-keystroke filter starts to feel slow), move that ONE + * page to a server-side query; the component API here stays the same. + * + * Dates are `YYYY-MM-DD` strings, matching Mantine 9's date inputs. Comparing + * them lexically keeps the range on calendar days and sidesteps timezone drift + * entirely — a UTC timestamp is truncated to its date before the comparison. + * + * ponytail: linear scan per keystroke, no debounce — fine at this size; add + * a debounce (or server-side filtering) if a list gets big enough to stutter. + */ +export interface ListControlsOptions { + /** + * Fields matched against the search box. Constrained to real keys of the row + * so a typo is a compile error rather than a filter that silently matches + * nothing. For nested or derived values, pass `searchValue` instead. + */ + searchKeys?: (keyof T)[]; + /** + * Row's meaningful business date (arrival, invoice, dispatch…), which is what + * staff actually filter by. Falls back to `createdAt` when the row has no + * value for it, so a record is never silently invisible to a date range. + */ + dateKey?: keyof T; + /** Rows per page. */ + pageSize?: number; + /** Custom search extractor when the value isn't a top-level field. */ + searchValue?: (row: T) => string; +} + +const readField = (row: unknown, key: string): unknown => + row && typeof row === "object" ? (row as Record)[key] : undefined; + +/** + * Reduce any stored date to its `YYYY-MM-DD` calendar day. ISO strings are cut + * directly rather than parsed, so a timestamp is never shifted into the + * previous/next day by the viewer's timezone. + */ +export const toDayString = (raw: unknown): string | null => { + if (!raw) return null; + if (raw instanceof Date) { + return Number.isNaN(raw.getTime()) ? null : raw.toISOString().slice(0, 10); + } + const text = String(raw); + if (/^\d{4}-\d{2}-\d{2}/.test(text)) return text.slice(0, 10); + const parsed = new Date(text); + return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString().slice(0, 10); +}; + +/** + * Does a stored date fall inside an inclusive `YYYY-MM-DD` range? Exported for + * lists that already own their filtering (e.g. FleetResourcePage, which folds + * server-side filters and search together) so the range semantics — inclusive + * ends, undated rows excluded — stay defined in exactly one place. + */ +export const matchesDayRange = ( + raw: unknown, + dateFrom: string | null, + dateTo: string | null, +): boolean => { + if (!dateFrom && !dateTo) return true; + const day = toDayString(raw); + if (!day) return false; + if (dateFrom && day < dateFrom) return false; + if (dateTo && day > dateTo) return false; + return true; +}; + +export const useListControls = (rows: T[], options: ListControlsOptions = {}) => { + const { searchKeys = [], dateKey, pageSize = 10, searchValue } = options; + + const [search, setSearch] = useState(""); + const [dateFrom, setDateFrom] = useState(null); + const [dateTo, setDateTo] = useState(null); + const { pagination, setPagination } = usePagination({ pageSize }); + + const keys = searchKeys.map(String); + const keySignature = keys.join("|"); + const dateKeyStr = dateKey ? String(dateKey) : undefined; + + const filteredRows = useMemo(() => { + const term = search.trim().toLowerCase(); + if (!term && !dateFrom && !dateTo) return rows; + + return rows.filter((row) => { + if (term) { + const haystack = searchValue + ? searchValue(row) + : keys.map((key) => String(readField(row, key) ?? "")).join(" "); + if (!haystack.toLowerCase().includes(term)) return false; + } + if (dateFrom || dateTo) { + const raw = dateKeyStr + ? (readField(row, dateKeyStr) ?? readField(row, "createdAt")) + : null; + if (!matchesDayRange(raw, dateFrom, dateTo)) return false; + } + return true; + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [rows, search, dateFrom, dateTo, keySignature, dateKeyStr, searchValue]); + + // Narrowing the result set can strand the user on a page that no longer + // exists (filter to 3 rows while on page 5 → empty table). Snap back to the + // first page whenever the filters change. + useEffect(() => { + setPagination((prev) => (prev.pageIndex === 0 ? prev : { ...prev, pageIndex: 0 })); + }, [search, dateFrom, dateTo, setPagination]); + + const pageCount = Math.max(1, Math.ceil(filteredRows.length / pagination.pageSize)); + + const pagedRows = useMemo(() => { + const start = pagination.pageIndex * pagination.pageSize; + return filteredRows.slice(start, start + pagination.pageSize); + }, [filteredRows, pagination.pageIndex, pagination.pageSize]); + + const hasFilters = Boolean(search || dateFrom || dateTo); + + const reset = () => { + setSearch(""); + setDateFrom(null); + setDateTo(null); + }; + + return { + search, + setSearch, + dateFrom, + setDateFrom, + dateTo, + setDateTo, + hasFilters, + reset, + filteredRows, + pagedRows, + pageCount, + pagination, + setPagination, + totalCount: filteredRows.length, + /** Spread straight onto so every list paginates identically. */ + tableProps: { + pagination: { + pageIndex: pagination.pageIndex, + pageSize: pagination.pageSize, + pageCount, + totalCount: filteredRows.length, + }, + tableOptions: { + manualPagination: true as const, + pageCount, + state: { pagination }, + onPaginationChange: setPagination, + }, + }, + }; +}; diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/CompliancePage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/CompliancePage.tsx index 008fee25b..f31bf5e76 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/CompliancePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/CompliancePage.tsx @@ -18,6 +18,11 @@ import { } from "@mantine/core"; import { Plus, AlertTriangle } from "lucide-react"; import Breadcrumbs from "@/components/ui/Breadcrumbs"; +import ListControls from "@/components/common/ListControls"; +// Generic list footer — already shared by the fleet and train-scheduling lists +// despite the ruleEngine path. +import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter"; +import { useListControls } from "@/hooks/useListControls"; import { useToast } from "@/hooks/use-toast"; import { complianceService, @@ -86,6 +91,11 @@ export default function CompliancePage() { }, }); + const controls = useListControls(records as ComplianceRecord[], { + searchKeys: ["type", "status", "documentNumber"], + dateKey: "expiryDate", + }); + const createMutation = useMutation({ mutationFn: async (data: typeof formData) => { const res = await complianceService.create({ @@ -210,6 +220,18 @@ export default function CompliancePage() { Compliance Records + @@ -239,7 +261,7 @@ export default function CompliancePage() { ) : null} - {(records as ComplianceRecord[]).map((record) => ( + {controls.pagedRows.map((record) => ( {vehicleLabel(record)} @@ -259,6 +281,13 @@ export default function CompliancePage() { ))}
+
{/* Modal */} diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx index 027b16d4e..47539decf 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx @@ -1,5 +1,6 @@ import type { ColumnDef } from "@edr/ui-common"; import { Box, Button, Card, Container, Group, Modal, Select, Stack, Text, Title } from "@mantine/core"; +import { DatePickerInput } from "@mantine/dates"; import { useMutation, useQuery } from "@tanstack/react-query"; import { api } from "@/services/api"; @@ -15,6 +16,7 @@ import FleetFormDialog from "@/components/fleet/FleetFormDialog"; import FleetHistoryModal from "@/components/fleet/FleetHistoryModal"; import FleetRecordActions from "@/components/fleet/FleetRecordActions"; import FleetToolbar from "@/components/fleet/FleetToolbar"; +import { matchesDayRange } from "@/hooks/useListControls"; import WagonMovementHistoryModal from "@/components/fleet/WagonMovementHistoryModal"; import WagonYardWorkspaceModal from "@/components/wagons/WagonYardWorkspaceModal"; import WagonTransferRequestsModal from "@/components/wagons/WagonTransferRequestsModal"; @@ -47,6 +49,10 @@ const FleetResourcePage = () => { const { pagination, setPagination } = usePagination({ pageSize: 10 }); const [search, setSearch] = useState(""); const [statusFilter, setStatusFilter] = useState("ALL"); + // Registration date range. Server-side list filters (status/yard/train) are + // applied by the API; this narrows what comes back, alongside search. + const [dateFrom, setDateFrom] = useState(null); + const [dateTo, setDateTo] = useState(null); const [listFilterValues, setListFilterValues] = useState>({}); const [formOpen, setFormOpen] = useState(false); const [editing, setEditing] = useState(null); @@ -125,7 +131,7 @@ const FleetResourcePage = () => { useEffect(() => { setPagination((prev) => ({ pageIndex: 0, pageSize: prev.pageSize })); - }, [search, listFilterValues, setPagination]); + }, [search, listFilterValues, dateFrom, dateTo, setPagination]); const hasStatusColumn = Boolean(config?.columns.some((col) => col.accessorKey === "status")); const usesServerListFilters = Boolean(config?.listFilters?.length); @@ -255,10 +261,13 @@ const FleetResourcePage = () => { const filteredRows = useMemo(() => { if (!config) return allRows; - if (usesServerListFilters) return allRows; const term = search.trim().toLowerCase(); return allRows.filter((row) => { const record = row as unknown as Record; + // The date range applies even when the API already filtered the list — + // it is not one of the server-side filters. + if (!matchesDayRange(record.createdAt, dateFrom, dateTo)) return false; + if (usesServerListFilters) return true; if (statusFilter !== "ALL" && String(record.status ?? "") !== statusFilter) { return false; } @@ -269,7 +278,7 @@ const FleetResourcePage = () => { .includes(term), ); }); - }, [allRows, search, statusFilter, config, usesServerListFilters]); + }, [allRows, search, statusFilter, config, usesServerListFilters, dateFrom, dateTo]); const pageCount = Math.max(1, Math.ceil(filteredRows.length / pagination.pageSize)); const pagedRows = useMemo(() => { @@ -466,7 +475,30 @@ const FleetResourcePage = () => { viewMode={viewMode} onViewModeChange={setViewMode} filters={ - listFilterSelects ? ( + + + + {listFilterSelects ? ( {listFilterSelects.map((filter) => ( ({ value: s, label: s.replace(/_/g, ' ') }))} - value={status} - onChange={(v) => setStatus((v as WarehouseInvoiceStatus) ?? null)} - clearable - w={200} - /> - + +