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/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-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 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/fleet/FleetFormDialog.tsx b/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx index 5d1247358..f0ccf0be0 100644 --- a/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx @@ -126,6 +126,28 @@ const FleetFormDialog = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, recordId]); + // Seed the `_`-prefixed scratch that `onOptionSelected` derives (e.g. + // _hasTrailer) for the value already on the record. Without this, editing a + // rigid truck would show a Trailer Plate field until the type is re-picked. + // Only scratch keys are written, so a stored one-off capacity is never + // clobbered by the type's default; re-deriving from the live value is + // idempotent, so this is safe to run again when the options finally load. + useEffect(() => { + 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/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/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/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 63a722550..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); @@ -94,6 +100,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 }), ); @@ -122,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); @@ -169,17 +178,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 +238,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,16 +254,20 @@ const FleetResourcePage = () => { wagonTypesLoading || containerTypesLoading || cargoTypesLoading || + truckTypesLoading || wagonsLoading || containersLoading || yardsLoading; 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; } @@ -247,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(() => { @@ -444,7 +475,30 @@ const FleetResourcePage = () => { viewMode={viewMode} onViewModeChange={setViewMode} filters={ - listFilterSelects ? ( + + + + {listFilterSelects ? ( {listFilterSelects.map((filter) => ( ).map((y) => ({ + value: y.id, + label: y.label ?? y.code ?? y.id, + }))} + value={form.locationId || null} + onChange={(v) => setForm((f) => ({ ...f, locationId: v ?? "" }))} + disabled={yardsLoading} + searchable + clearable + /> + setForm((f) => ({ ...f, pricePerKm: v as number | "" }))} + min={0} + /> + setForm((f) => ({ ...f, estimatedDistanceKm: v as number | "" }))} + min={0} + /> + setForm((f) => ({ ...f, actualDistanceKm: v as number | "" }))} + min={0} + /> + setForm((f) => ({ ...f, currency: v }))} + > + + + + + + + + + + + + ); +}; + const DriverTab = ({ vehicleId, driverId, diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts index 0e6633eab..94654a8eb 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts @@ -30,10 +30,22 @@ export type FleetDynamicOptions = | "wagonTypes" | "containerTypes" | "cargoTypes" + | "truckTypes" | "wagons" | "containers" | "yards"; +/** + * A dynamic select option that can carry the record it came from. Picking a + * truck type has to pull its capacity and trailer configuration into the form, + * which a bare {label, value} pair cannot express. + */ +export interface FleetSelectOption { + label: string; + value: string; + meta?: Record; +} + export interface FleetResourceColumn { id: string; header: string; @@ -45,6 +57,17 @@ export interface FleetResourceColumn { export interface FleetFormFieldDef extends FormFieldDef { dynamicOptions?: FleetDynamicOptions; noneOption?: boolean; + /** Options carrying their source record, so `onOptionSelected` can read it. */ + options?: FleetSelectOption[]; + /** + * Patch merged into the form when this select changes — for values that are a + * property of the chosen option rather than typed per record (a vehicle's + * capacity comes from its truck type). Returns the fields to overwrite. + */ + onOptionSelected?: ( + option: FleetSelectOption | undefined, + values: Record, + ) => Record; /** * Read-only field whose value is computed from the other fields rather than * typed. Rendered non-editable and recomputed on every change, so the stored diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts b/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts index f25be0ff7..c8e6667f4 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts @@ -10,6 +10,16 @@ const PLATE_PATTERN = { message: "Use letters and numbers like ET-9875 or AA-8642", }; +/** + * Legacy static list. Truck configurations now live in `freight.truck_types` + * and are edited in the back office (Rule Engine → Truck Types), so the form + * loads them through `dynamicOptions: "truckTypes"` instead. + * + * Kept only for the driver "authorized vehicle types" multiselect, which stores + * free-text categories rather than truck-type ids. + * + * @deprecated prefer the managed truck types + */ const VEHICLE_TYPE_OPTIONS = [ { label: "Truck", value: "TRUCK" }, { label: "Van", value: "VAN" }, @@ -20,6 +30,11 @@ const VEHICLE_TYPE_OPTIONS = [ { label: "Flatbed", value: "FLATBED" }, ]; +const OWNERSHIP_OPTIONS = [ + { label: "Owned", value: "OWNED" }, + { label: "Outsourced", value: "OUTSOURCED" }, +]; + const FUEL_TYPE_OPTIONS = [ { label: "Petrol", value: "PETROL" }, { label: "Diesel", value: "DIESEL" }, @@ -39,11 +54,6 @@ const VEHICLE_AVAILABILITY_OPTIONS = [ { label: "Busy", value: "BUSY" }, ]; -const CURRENCY_OPTIONS = [ - { label: "ETB", value: "ETB" }, - { label: "USD", value: "USD" }, -]; - export const vehiclesConfig: FleetResourceConfig = { slug: "vehicles", label: "Vehicles", @@ -72,35 +82,65 @@ export const vehiclesConfig: FleetResourceConfig = { options: VEHICLE_AVAILABILITY_OPTIONS, }, ], - searchKeys: ["plateNumber", "registrationNumber", "manufacturer", "model", "vehicleType", "status"], + searchKeys: ["plateNumber", "registrationNumber", "manufacturer", "model", "vehicleType", "vin", "status"], columns: [ { id: "code", header: "Code", accessorKey: "code", size: 90 }, { id: "plateNumber", header: "Plate Number", accessorKey: "plateNumber", size: 120 }, { id: "trailerPlateNo", header: "Trailer Plate No", accessorKey: "trailerPlateNo", size: 130 }, { id: "manufacturer", header: "Manufacturer", accessorKey: "manufacturer", size: 120 }, { id: "model", header: "Model", accessorKey: "model", size: 100 }, - { id: "vehicleType", header: "Type", accessorKey: "vehicleType", size: 75 }, + { id: "truckTypeId", header: "Truck Type", accessorKey: "truckTypeId", format: "entityLabel", size: 130 }, { id: "capacity", header: "Capacity (tons)", accessorKey: "capacity", format: "number", size: 100 }, - { id: "locationId", header: "Location", accessorKey: "locationId", size: 140 }, + { id: "ownership", header: "Ownership", accessorKey: "ownership", size: 100 }, { id: "status", header: "Status", accessorKey: "status", format: "statusBadge", size: 100 }, { id: "availability", header: "Availability", accessorKey: "availability", format: "statusBadge", size: 100 }, ], + // Registration captures what the vehicle IS. Location, distances and haulage + // pricing are per-trip operational data and live on the vehicle's Operations + // tab instead (VehicleDetailPage) — they are not part of registering a truck. formFields: [ { name: "code", label: "Code", type: "text" }, { name: "plateNumber", label: "Power Plate No", type: "text", required: true, pattern: PLATE_PATTERN }, - // { name: "powerPlateNo", label: "Power Plate No", type: "text" }, - { name: "trailerPlateNo", label: "Trailer Plate No", type: "text", pattern: PLATE_PATTERN }, - { name: "vehicleType", label: "Vehicle Type", type: "select", required: true, options: VEHICLE_TYPE_OPTIONS }, + // The truck type decides whether a trailer exists at all: a rigid truck + // (Casoni) has none, so the field disappears and submits null. Mirrored + // server-side in VehiclesService — hiding a field is not enforcement. + { + name: "trailerPlateNo", + label: "Trailer Plate No", + type: "text", + pattern: PLATE_PATTERN, + showIf: (values) => values._hasTrailer !== false, + }, + { + name: "truckTypeId", + label: "Truck Type", + type: "select", + required: true, + dynamicOptions: "truckTypes", + description: "Managed in Rule Engine → Truck Types", + // Capacity is a property of the type, not of each individual truck. + // `_hasTrailer` is form-local scratch (stripped before submit) that drives + // the trailer plate's visibility. + onOptionSelected: (option) => ({ + _hasTrailer: option?.meta?.hasTrailer ?? true, + ...(option?.meta?.capacityTons != null + ? { capacity: option.meta.capacityTons } + : {}), + }), + }, + { name: "vin", label: "VIN", type: "text", description: "Vehicle Identification Number" }, + { name: "ownership", label: "Ownership", type: "radio", options: OWNERSHIP_OPTIONS }, { name: "manufacturer", label: "Manufacturer", type: "text", required: true }, { name: "model", label: "Model", type: "text", required: true }, { name: "year", label: "Year", type: "number", required: true }, { name: "fuelType", label: "Fuel Type", type: "select", required: true, options: FUEL_TYPE_OPTIONS }, - { name: "capacity", label: "Capacity", type: "number", required: true }, - { name: "locationId", label: "Location", type: "select", dynamicOptions: "yards" }, - { name: "estimatedDistanceKm", label: "Estimated Distance (KM)", type: "number" }, - { name: "actualDistanceKm", label: "Actual Distance (KM)", type: "number" }, - { name: "pricePerKm", label: "Price per KM", type: "number" }, - { name: "currency", label: "Currency", type: "radio", options: CURRENCY_OPTIONS }, + { + name: "capacity", + label: "Capacity (tons)", + type: "number", + required: true, + description: "Pre-filled from the truck type — override only for a one-off", + }, { name: "status", label: "Status", type: "select", required: true, options: VEHICLE_STATUS_OPTIONS }, { name: "availability", label: "Availability", type: "select", required: true, options: VEHICLE_AVAILABILITY_OPTIONS }, { name: "description", label: "Description", type: "textarea" }, @@ -108,19 +148,15 @@ export const vehiclesConfig: FleetResourceConfig = { emptyValues: { code: "03-ET", plateNumber: "", - powerPlateNo: "", trailerPlateNo: "", - vehicleType: "TRUCK", + truckTypeId: "", + vin: "", + ownership: "OWNED", manufacturer: "", model: "", year: new Date().getFullYear(), fuelType: "DIESEL", capacity: 0, - locationId: null, - estimatedDistanceKm: "", - actualDistanceKm: "", - pricePerKm: "", - currency: "ETB", status: "ACTIVE", availability: "FREE", description: "", diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx index f30330d96..835d1a82f 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx @@ -229,6 +229,21 @@ const cargoDesc = (r: FirstMileRecord) => { if (r.booking?.cargoTotalWeightVgm) parts.push(`${r.booking.cargoTotalWeightVgm} t`); return parts.join(" · ") || "—"; }; +const cargoTypeName = (r: FirstMileRecord) => + r.booking?.cargoType?.cargoTypeName ?? + r.booking?.cargoType?.label ?? + r.booking?.cargoType?.name ?? + r.booking?.cargoFreeText ?? + "—"; +const isBulkBooking = (r: FirstMileRecord) => r.booking?.freightType === "BULK"; +const bookingTotalTons = (r: FirstMileRecord) => Number(r.booking?.cargoTotalWeightVgm) || 0; +const trainScheduleLabel = (r: FirstMileRecord) => { + const s = r.booking?.trainSchedule; + if (!s?.trainNumber && !s?.departureDate) return "—"; + return [s.trainNumber, s.departureDate ? new Date(s.departureDate).toLocaleDateString() : null] + .filter(Boolean) + .join(" · "); +}; // First-mile destination is the origin yard (pickup → origin yard) const destinationYardName = (r: FirstMileRecord) => r.booking?.originYard?.label ?? "—"; @@ -277,7 +292,9 @@ const BookingInfo = ({ record }: { record: FirstMileRecord }) => { {hasPickupAddress && } + + @@ -496,10 +513,11 @@ const FirstMilePage = () => { const [tripSlipVehicleId, setTripSlipVehicleId] = useState(null); const [tripSlipSelectOpen, setTripSlipSelectOpen] = useState(false); const [activeId, setActiveId] = useState(null); - // Multi-vehicle assign: one row per truck — vehicle + the container it carries. + // Multi-vehicle assign: one row per truck — vehicle + its load (container for + // container bookings; tonnes + optional item count for bulk). const [vehicleRows, setVehicleRows] = useState< - Array<{ vehicleId: string | null; containerNumber: string }> - >([{ vehicleId: null, containerNumber: "" }]); + Array<{ vehicleId: string | null; containerNumber: string; tons: number | ""; quantity: number | "" }> + >([{ vehicleId: null, containerNumber: "", tons: "", quantity: "" }]); const [acceptOpen, setAcceptOpen] = useState(false); const [acceptStep, setAcceptStep] = useState<1 | 2>(1); @@ -925,13 +943,19 @@ const FirstMilePage = () => { ? rec.vehicleAssignments.map((a, i) => ({ vehicleId: a.vehicleId, containerNumber: a.containerNumber ?? nums[i] ?? "", + tons: (a.tons != null ? Number(a.tons) : "") as number | "", + quantity: (a.quantity != null ? Number(a.quantity) : "") as number | "", })) : rec?.vehicleId - ? [{ vehicleId: rec.vehicleId, containerNumber: nums[0] ?? "" }] - : [{ vehicleId: null, containerNumber: nums[0] ?? "" }]; + ? [{ vehicleId: rec.vehicleId, containerNumber: nums[0] ?? "", tons: "" as const, quantity: "" as const }] + : [{ vehicleId: null, containerNumber: nums[0] ?? "", tons: "" as const, quantity: "" as const }]; setBulkMode(false); setActiveId(resolved); - setVehicleRows(rows.length ? rows : [{ vehicleId: null, containerNumber: nums[0] ?? "" }]); + setVehicleRows( + rows.length + ? rows + : [{ vehicleId: null, containerNumber: nums[0] ?? "", tons: "", quantity: "" }], + ); setAssignOpen(true); }; @@ -944,7 +968,7 @@ const FirstMilePage = () => { } setBulkMode(true); setActiveId(null); - setVehicleRows([{ vehicleId: null, containerNumber: "" }]); + setVehicleRows([{ vehicleId: null, containerNumber: "", tons: "", quantity: "" }]); setAssignOpen(true); }; @@ -952,15 +976,41 @@ const FirstMilePage = () => { setAssignOpen(false); setBulkMode(false); setActiveId(null); - setVehicleRows([{ vehicleId: null, containerNumber: "" }]); + setVehicleRows([{ vehicleId: null, containerNumber: "", tons: "", quantity: "" }]); }; const handleAssign = () => { + const bulkCargo = activeRecord != null && isBulkBooking(activeRecord); + if (bulkCargo && vehicleRows.some((r) => r.vehicleId && r.tons === "")) { + toast({ + title: "Tonnes required", + description: "Enter the tonnage each truck hauls — bulk assignment draws down the booking total.", + variant: "destructive", + }); + return; + } + if (bulkCargo) { + const total = bookingTotalTons(activeRecord); + const assigning = vehicleRows.reduce((s, r) => s + (r.vehicleId ? Number(r.tons) || 0 : 0), 0); + if (total > 0 && assigning > total + 0.001) { + toast({ + title: "Over booking tonnage", + description: `Assigned ${assigning} t exceeds the booking's ${total} t.`, + variant: "destructive", + }); + return; + } + } const seen = new Set(); const vehicles = vehicleRows - .filter((r): r is { vehicleId: string; containerNumber: string } => Boolean(r.vehicleId)) + .filter((r): r is (typeof vehicleRows)[number] & { vehicleId: string } => Boolean(r.vehicleId)) .filter((r) => (seen.has(r.vehicleId) ? false : seen.add(r.vehicleId))) - .map((r) => ({ vehicleId: r.vehicleId, containerNumber: r.containerNumber.trim() || null })); + .map((r) => ({ + vehicleId: r.vehicleId, + containerNumber: r.containerNumber.trim() || null, + tons: bulkCargo && r.tons !== "" ? Number(r.tons) : null, + quantity: bulkCargo && r.quantity !== "" ? Number(r.quantity) : null, + })); const count = vehicles.length; const targetIds = bulkMode ? selectedIds @@ -1432,30 +1482,63 @@ const FirstMilePage = () => { clearable disabled={assignVehicleOptions.length === 0} /> - + 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 + /> + )} {vehicleRows.length > 1 && ( { vehicleId: null, containerNumber: (activeRecord ? bookingContainerNumbers(activeRecord)[prev.length] : "") ?? "", + tons: "" as const, + quantity: "" as const, }, ]) } @@ -1491,6 +1576,22 @@ const FirstMilePage = () => { > Add vehicle + {!bulkMode && activeRecord && isBulkBooking(activeRecord) && (() => { + const total = bookingTotalTons(activeRecord); + const assigning = vehicleRows.reduce( + (s, r) => s + (r.vehicleId ? Number(r.tons) || 0 : 0), + 0, + ); + const remaining = Math.round((total - assigning) * 1000) / 1000; + return ( + + Bulk drawdown: {assigning} t of {total} t assigned —{" "} + + {remaining} t remaining + + + ); + })()} diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts index 44c28a9e9..15d80b899 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts @@ -409,6 +409,43 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ { name: "isActive", label: "Active", type: "boolean" }, ], }, + { + slug: "truck-types", + label: "Truck Types", + category: "configuration", + subtitle: + "Configure the truck configurations vehicles are registered against — capacity and whether a trailer applies", + searchPlaceholder: "Search truck types by name or code...", + cardTitleKey: "name", + columns: [ + codeColumn("code"), + { id: "name", header: "Name", accessorKey: "name" }, + { id: "capacityTons", header: "Capacity (t)", accessorKey: "capacityTons", format: "number" }, + { id: "hasTrailer", header: "Has trailer", accessorKey: "hasTrailer" }, + activeColumn, + ], + formFields: [ + { name: "code", label: "Code", type: "text", required: true }, + { name: "name", label: "Name", type: "text", required: true }, + { + name: "capacityTons", + label: "Capacity (tons)", + type: "number", + optional: true, + description: "Pre-fills the capacity of every vehicle registered on this type", + }, + // Drives the trailer plate on vehicle registration: a rigid truck (Casoni) + // has none, so registering one with a trailer plate is rejected. + { + name: "hasTrailer", + label: "Pulls a trailer", + type: "boolean", + description: "Off for a rigid truck (e.g. Casoni) — its registration has no trailer plate", + }, + { name: "description", label: "Description", type: "textarea", optional: true }, + { name: "isActive", label: "Active", type: "boolean" }, + ], + }, { slug: "yard-distances", label: "Yard Distances", diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/InterchangeDocumentsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/InterchangeDocumentsPage.tsx index 04fe38970..3cc1f1ab6 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/InterchangeDocumentsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/InterchangeDocumentsPage.tsx @@ -16,6 +16,8 @@ import { CheckCircle2, Download, Eye, FileText, Printer, Search } from 'lucide-r import type { ReactNode } from 'react'; import { DataTable, type ColumnDef } from '@edr/ui-common'; +import ListControls from '@/components/common/ListControls'; +import { useListControls } from '@/hooks/useListControls'; import { PageContainer, PageHeader } from '@/components/page'; import { VisualEmptyState, formatDate, formatNumber } from '@/components/warehouses'; @@ -263,6 +265,9 @@ export default function InterchangeDocumentsPage() { const [viewId, setViewId] = useState(null); const filter = useMemo(() => ({ search: search.trim() || undefined }), [search]); const { data: documents = [], isLoading } = useInterchangeDocuments(filter); + // Search stays server-side (passed in `filter`); this adds the date range and + // pagination over what comes back. + const controls = useListControls(documents, { dateKey: 'generatedAt' }); const acknowledge = useAcknowledgeInterchangeDocument(); const dispute = useDisputeInterchangeDocument(); @@ -435,7 +440,7 @@ export default function InterchangeDocumentsPage() { - {documents.length} document(s) + {controls.totalCount} document(s) } @@ -445,6 +450,19 @@ export default function InterchangeDocumentsPage() { /> + {}} + dateFrom={controls.dateFrom} + onDateFromChange={controls.setDateFrom} + dateTo={controls.dateTo} + onDateToChange={controls.setDateTo} + dateLabel="Generated" + hasFilters={controls.hasFilters} + onReset={controls.reset} + /> + {!isLoading && documents.length === 0 ? ( )} diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/IntercityPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/IntercityPage.tsx index bce1f52e3..cfda31629 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/IntercityPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/IntercityPage.tsx @@ -2,6 +2,7 @@ import { useMemo, useState } from "react"; import { Alert, Badge, + Button, Card, Center, Group, @@ -12,10 +13,16 @@ import { Text, Tooltip, } from "@mantine/core"; -import { useQuery } from "@tanstack/react-query"; -import { AlertTriangle, PackageCheck, TrainFront, Warehouse } from "lucide-react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { AlertTriangle, PackageCheck, PackageOpen, TrainFront, Warehouse } from "lucide-react"; import { PageContainer, PageHeader } from "@/components/page"; +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 { api } from "@/services/api"; import type { IntercityRideAlongRow } from "@/types/trainScheduling"; @@ -74,7 +81,45 @@ function FacilityCell({ ); } +const apiErrorMessage = (error: unknown) => { + if (error && typeof error === "object" && "response" in error) { + const message = (error as { response?: { data?: { message?: unknown } } }).response?.data + ?.message; + if (Array.isArray(message)) return message.join("; "); + if (typeof message === "string") return message; + } + return error instanceof Error ? error.message : undefined; +}; + function Rows({ rows }: { rows: IntercityRideAlongRow[] }) { + const { toast } = useToast(); + const queryClient = useQueryClient(); + const refresh = () => + queryClient.invalidateQueries({ + queryKey: api.trainScheduling.intercityBookings.queryKey(undefined), + }); + // Same endpoints as the schedule page's ride-along panel — the server still + // validates the train's recorded checkpoint, payment and yard equipment. + const load = useMutation( + api.trainScheduling.loadIntercityBooking.mutationOptions({ + onSuccess: () => { + toast({ title: "Cargo loaded onto the train" }); + void refresh(); + }, + onError: (error) => + toast({ variant: "destructive", title: "Load failed", description: apiErrorMessage(error) }), + }), + ); + const unload = useMutation( + api.trainScheduling.unloadIntercityBooking.mutationOptions({ + onSuccess: () => { + toast({ title: "Cargo unloaded — booking completed" }); + void refresh(); + }, + onError: (error) => + toast({ variant: "destructive", title: "Unload failed", description: apiErrorMessage(error) }), + }), + ); if (rows.length === 0) { return ( @@ -95,6 +140,7 @@ function Rows({ rows }: { rows: IntercityRideAlongRow[] }) { Weight GRN Status + @@ -151,6 +197,38 @@ function Rows({ rows }: { rows: IntercityRideAlongRow[] }) { {r.status} + + + {/* Work the cargo right here while the train is at the yard. */} + {r.trainScheduleId && atOrigin(r) && isWaiting(r) && r.status === "PAID" && ( + + )} + {r.trainScheduleId && atDestination(r) && isRiding(r) && ( + + )} + + ))} @@ -205,6 +283,15 @@ export default function IntercityPage() { [rows], ); + // Controls follow the active tab, so search/date/paging always describe what + // is on screen. Panels unmount when hidden (keepMounted={false}) so a hidden + // tab can never render another tab's paged slice. + const active = tab === "riding" ? riding : tab === "done" ? done : waiting; + const controls = useListControls(active, { + searchKeys: ["reference", "grnNumber", "customer", "origin", "destination", "trainNumber"], + dateKey: "loadedAt", + }); + return ( - - + + + + - - + + - - + + + + - Loading and unloading happen on the train's schedule page, where the ride-along - panel confirms the train is at the yard. + Load and Unload appear on a row while its train is recorded at that yard ("train + here"); the same actions also live on the train's schedule page. diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/InventoryInquiryPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/InventoryInquiryPage.tsx index d85cb815b..72d2a9732 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/InventoryInquiryPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/InventoryInquiryPage.tsx @@ -15,6 +15,11 @@ import { useInventoryInquiry, useWarehouses, } from '@/hooks/useWarehouses'; +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 type { InventoryInquiryFilter, InventoryInquiryResult, InventoryStatus } from '@/types/warehouse'; export default function InventoryInquiryPage() { @@ -29,6 +34,11 @@ export default function InventoryInquiryPage() { const { data, isFetching } = useInventoryInquiry(applied); const results = data ?? []; + const controls = useListControls(results, { + searchKeys: ['containerNumber', 'bookingReference', 'customerName', 'cargoDescription', 'locationSummary'], + dateKey: 'arrivedAt', + }); + const warehouseOptions = useMemo( () => (warehousesQuery.data ?? []).map((w) => ({ value: w.id, label: `${w.name} (${w.code})` })), [warehousesQuery.data], @@ -173,7 +183,28 @@ export default function InventoryInquiryPage() { description="Adjust your filters and search to locate cargo, containers or goods across the warehouse network." /> ) : ( - + <> + + + + )} diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/LoadedInventoryPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/LoadedInventoryPage.tsx index 7dfc1996c..dbf7b14e3 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/LoadedInventoryPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/LoadedInventoryPage.tsx @@ -1,5 +1,7 @@ import { Badge, Card, Group, Text } from '@mantine/core'; import { DataTable, type ColumnDef } from '@edr/ui-common'; +import ListControls from '@/components/common/ListControls'; +import { useListControls } from '@/hooks/useListControls'; import { PageContainer, PageHeader } from '@/components/page'; import { useQuery } from '@tanstack/react-query'; @@ -67,6 +69,10 @@ export default function LoadedInventoryPage() { api.warehouses.loadings.queryOptions({ input: {} }), ); const loadings = data ?? []; + const controls = useListControls(loadings, { + searchKeys: ['wagonNumber'], + dateKey: 'loadedAt', + }); return ( @@ -83,12 +89,27 @@ export default function LoadedInventoryPage() { description="Once items are loaded onto a wagon, their records show here." /> ) : ( - + <> + + + )} diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/TrucksOnSitePage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/TrucksOnSitePage.tsx index 8210d7784..f095d94d4 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/TrucksOnSitePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/TrucksOnSitePage.tsx @@ -7,12 +7,15 @@ import { SegmentedControl, Table, Text, - TextInput, Tooltip, } from "@mantine/core"; -import { Search } from "lucide-react"; import { PageContainer, PageHeader } from "@/components/page"; +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 { useTrucksOnSite } from "@/hooks/useWarehouses"; import type { TruckOnSite } from "@/types/warehouse"; @@ -148,20 +151,21 @@ export default function TrucksOnSitePage() { scopeParam === "ON_SITE" || scopeParam === "INBOUND" ? scopeParam : "ALL", ); const [source, setSource] = useState<"ALL" | "CUSTOMER" | "EDR">("ALL"); - const [search, setSearch] = useState(""); - const rows = useMemo(() => { - const term = search.trim().toLowerCase(); - return trucks - .filter((t) => scope === "ALL" || t.status === scope) - .filter((t) => source === "ALL" || t.source === source) - .filter((t) => - !term - ? true - : [t.plateNumber, t.driverName, t.bookingReference, t.customerName, t.containers] - .some((field) => field?.toLowerCase().includes(term)), - ); - }, [trucks, scope, source, search]); + // Scope/source are page filters and run first; the shared control then does + // search + arrival-date range + pagination over what they leave. + const scoped = useMemo( + () => + trucks + .filter((t) => scope === "ALL" || t.status === scope) + .filter((t) => source === "ALL" || t.source === source), + [trucks, scope, source], + ); + + const controls = useListControls(scoped, { + searchKeys: ["plateNumber", "driverName", "bookingReference", "customerName", "containers"], + dateKey: "arrivedAt", + }); const onSiteCount = trucks.filter((t) => t.status === "ON_SITE").length; const inboundCount = trucks.length - onSiteCount; @@ -198,17 +202,35 @@ export default function TrucksOnSitePage() { ]} /> - } - value={search} - onChange={(e) => setSearch(e.currentTarget.value)} - /> - {isLoading ? Loading… : } + + + {isLoading ? ( + Loading… + ) : ( + <> + + + + )} ); } diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInvoicesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInvoicesPage.tsx index 1f632c1e7..92f807363 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInvoicesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInvoicesPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useState } from 'react'; import { ActionIcon, Badge, @@ -15,9 +15,11 @@ import { Text, TextInput, } from '@mantine/core'; -import { Ban, CreditCard, DoorOpen, Download, ExternalLink, Eye, Receipt, Search } from 'lucide-react'; +import { Ban, CreditCard, DoorOpen, Download, ExternalLink, Eye, Receipt } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; import { DataTable, type ColumnDef } from '@edr/ui-common'; +import ListControls from '@/components/common/ListControls'; +import { useListControls } from '@/hooks/useListControls'; import { PageContainer, PageHeader } from '@/components/page'; import { AccrualDashboard } from '@/components/warehouses'; @@ -50,7 +52,6 @@ const fmtDate = (d?: string | null) => (d ? new Date(d).toLocaleDateString() : ' export default function WarehouseInvoicesPage() { const [status, setStatus] = useState(null); - const [search, setSearch] = useState(''); const [detailId, setDetailId] = useState(null); const { data, isLoading } = useQuery( @@ -60,11 +61,10 @@ export default function WarehouseInvoicesPage() { ); const invoices = data ?? []; - const filtered = useMemo(() => { - const q = search.trim().toLowerCase(); - if (!q) return invoices; - return invoices.filter((i) => [i.invoiceNumber, i.bookingId, i.customerId].join(' ').toLowerCase().includes(q)); - }, [invoices, search]); + const controls = useListControls(invoices, { + searchKeys: ['invoiceNumber', 'bookingReference', 'customerName', 'containerNumber'], + dateKey: 'issuedAt', + }); const invoiceColumns: ColumnDef[] = [ { @@ -131,30 +131,36 @@ export default function WarehouseInvoicesPage() { - - } - value={search} - onChange={(e) => setSearch(e.currentTarget.value)} - w={320} - /> - ({ value: s, label: s.replace(/_/g, ' ') }))} + value={status} + onChange={(v) => setStatus((v as WarehouseInvoiceStatus) ?? null)} + clearable + w={200} + /> + diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseListPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseListPage.tsx index 3f9df44f6..8b3600026 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseListPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseListPage.tsx @@ -12,6 +12,11 @@ import { WarehouseTable, type WarehouseView, } from '@/components/warehouses'; +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 { useWarehouses } from '@/hooks/useWarehouses'; import type { Warehouse, WarehouseFilter } from '@/types/warehouse'; @@ -31,6 +36,11 @@ export default function WarehouseListPage() { const { data, isLoading, isError } = useWarehouses(queryFilter); const warehouses = data ?? []; + // Search stays with WarehouseFilters — it is server-side and debounced, so + // re-doing it client-side here would be a regression. This adds only the date + // range + pagination, shared by both the table and card views. + const controls = useListControls(warehouses, { dateKey: 'createdAt' }); + const openCreate = () => { setEditing(null); setModalOpen(true); @@ -57,6 +67,19 @@ export default function WarehouseListPage() { + {}} + dateFrom={controls.dateFrom} + onDateFromChange={controls.setDateFrom} + dateTo={controls.dateTo} + onDateToChange={controls.setDateTo} + dateLabel="Created" + hasFilters={controls.hasFilters} + onReset={controls.reset} + /> + {isLoading ? (
@@ -65,10 +88,21 @@ export default function WarehouseListPage() { Failed to load warehouses. - ) : view === 'table' ? ( - ) : ( - + <> + {view === 'table' ? ( + + ) : ( + + )} + + )} diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx index 61ce58f5f..20b7efbb0 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx @@ -19,6 +19,8 @@ import { Info, Pencil, Plus, Trash2 } from 'lucide-react'; import { useQuery } from '@tanstack/react-query'; import { DataTable, type ColumnDef } from '@edr/ui-common'; +import ListControls from '@/components/common/ListControls'; +import { useListControls } from '@/hooks/useListControls'; import { PageContainer, PageHeader } from '@/components/page'; import { useToast } from '@/hooks/use-toast'; @@ -39,7 +41,6 @@ import { FEE_RULE_BASIS_LABELS, FEE_RULE_TYPES, FEE_RULE_TYPE_LABELS, - VEHICLE_TYPES, type AllocationRule, type FeeRule, type FeeRuleBasis, @@ -141,6 +142,10 @@ function AllocationRules() { }); const rules = data ?? []; + const controls = useListControls(rules, { + searchKeys: ['name', 'targetYardCode', 'targetWarehouseCode', 'targetZoneCode', 'freightType', 'tradeDirection', 'cargoTypeCode', 'storageType'], + dateKey: 'createdAt', + }); const yardOptions = yards .filter((yard) => yard.code) .map((yard) => ({ @@ -255,7 +260,7 @@ function AllocationRules() { <> - {rules.length} rule(s) matched by ascending priority + {controls.totalCount} rule(s) matched by ascending priority + + { setOpen(false); resetForm(); }} title={editingId ? 'Edit fee rule' : 'New fee rule'} centered size="lg"> @@ -712,8 +755,9 @@ function FeeRules() { {isTruckDetention && (