Merge pull request #952 from Tria-plc/intercity

Intercity
This commit is contained in:
Hagernesh Tadesse
2026-07-24 15:12:01 +03:00
committed by GitHub
61 changed files with 2322 additions and 208 deletions

View File

@@ -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,

View File

@@ -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),
);

View File

@@ -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<void> {
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<void> {
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;`,
);
}
}

View File

@@ -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<void> {
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<void> {
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.
}
}

View File

@@ -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(

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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. */

View File

@@ -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;
}

View File

@@ -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<FirstMile> {
const existing = await this.findById(id);
// Dedupe by vehicleId, keeping the container number; preserve order.
const desiredMap = new Map<string, string | null>();
// 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,
},
);
}
});

View File

@@ -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;
}

View File

@@ -0,0 +1,5 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateTruckTypeDto } from './create-truck-type.dto';
export class UpdateTruckTypeDto extends PartialType(CreateTruckTypeDto) {}

View File

@@ -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;
}

View File

@@ -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<string, string | undefined>) {
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);
}
}

View File

@@ -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 {}

View File

@@ -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<TruckType> {
constructor(
@InjectRepository(TruckType)
repository: Repository<TruckType>,
) {
super(repository);
}
findByCode(code: string): Promise<TruckType | null> {
return this.repository.findOne({ where: { code } });
}
}

View File

@@ -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<TruckType>,
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<TruckType> {
const truckType = await this.truckTypesRepository.findById(id);
if (!truckType) {
throw new NotFoundException(`Truck type ${id} not found`);
}
return truckType;
}
async findByCode(code: string): Promise<TruckType> {
const truckType = await this.truckTypesRepository.findByCode(code);
if (!truckType) {
throw new NotFoundException(`Truck type ${code} not found`);
}
return truckType;
}
async create(dto: CreateTruckTypeDto): Promise<TruckType> {
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<TruckType> {
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<void> {
await this.findById(id);
await this.truckTypesRepository.softDelete(id);
}
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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);
});

View File

@@ -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],

View File

@@ -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<Vehicle>,
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

View File

@@ -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');
});
});

View File

@@ -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 }];

View File

@@ -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) {}

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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) {}

View File

@@ -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,

View File

@@ -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<RuleEngineResourceSlug, { view: string;
'cargo-types': { view: 'b2000001-0001-4000-8000-000000000001', manage: 'b2000001-0001-4000-8000-000000000002' },
'container-types': { view: 'b2000001-0001-4000-8000-000000000003', manage: 'b2000001-0001-4000-8000-000000000004' },
'wagon-types': { view: 'b2000001-0001-4000-8000-000000000015', manage: 'b2000001-0001-4000-8000-000000000016' },
'truck-types': { view: 'b2000001-0001-4000-8000-00000000001a', manage: 'b2000001-0001-4000-8000-00000000001b' },
'service-types': { view: 'b2000001-0001-4000-8000-000000000005', manage: 'b2000001-0001-4000-8000-000000000006' },
yards: { view: 'b2000001-0001-4000-8000-000000000007', manage: 'b2000001-0001-4000-8000-000000000008' },
'shipping-lines': { view: 'b2000001-0001-4000-8000-000000000009', manage: 'b2000001-0001-4000-8000-00000000000a' },

View File

@@ -0,0 +1,96 @@
import { Button, Group, TextInput } from "@mantine/core";
import { DatePickerInput } from "@mantine/dates";
import { Search, X } from "lucide-react";
import type { ReactNode } from "react";
export interface ListControlsProps {
search: string;
onSearchChange: (value: string) => void;
searchPlaceholder?: string;
/** `YYYY-MM-DD`, matching Mantine 9's date inputs. */
dateFrom: string | null;
onDateFromChange: (value: string | null) => void;
dateTo: string | null;
onDateToChange: (value: string | null) => void;
/** Label above the range, naming the date being filtered (e.g. "Arrival date"). */
dateLabel?: string;
hasFilters?: boolean;
onReset?: () => void;
/** Page-specific selects (status, warehouse…) rendered after the date range. */
children?: ReactNode;
showSearch?: boolean;
showDateRange?: boolean;
}
/**
* Search box + inclusive date range + clear, shared by every freight list so the
* controls sit in the same place and behave the same way on all of them.
* Pair with `useListControls`, which owns the state and does the filtering.
*/
const ListControls = ({
search,
onSearchChange,
searchPlaceholder = "Search…",
dateFrom,
onDateFromChange,
dateTo,
onDateToChange,
dateLabel,
hasFilters,
onReset,
children,
showSearch = true,
showDateRange = true,
}: ListControlsProps) => (
<Group gap="sm" align="flex-end" wrap="wrap">
{showSearch && (
<TextInput
placeholder={searchPlaceholder}
value={search}
onChange={(e) => onSearchChange(e.currentTarget.value)}
leftSection={<Search size={16} />}
style={{ flex: "1 1 240px", minWidth: 200 }}
/>
)}
{showDateRange && (
<>
<DatePickerInput
label={dateLabel ? `${dateLabel} from` : "From"}
placeholder="Any"
value={dateFrom}
onChange={onDateFromChange}
// Cannot start after it ends — the picker refuses the invalid range
// instead of silently returning nothing.
maxDate={dateTo ?? undefined}
clearable
w={150}
/>
<DatePickerInput
label={dateLabel ? `${dateLabel} to` : "To"}
placeholder="Any"
value={dateTo}
onChange={onDateToChange}
minDate={dateFrom ?? undefined}
clearable
w={150}
/>
</>
)}
{children}
{hasFilters && onReset && (
<Button
variant="subtle"
color="gray"
leftSection={<X size={14} />}
onClick={onReset}
>
Clear
</Button>
)}
</Group>
);
export default ListControls;

View File

@@ -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<string, unknown> = {};
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<string, string> = {};
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

View File

@@ -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<Set<string>>(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
</Button>
</Group>
<ListControls
search={controls.search}
onSearchChange={controls.setSearch}
searchPlaceholder="GRN, container, booking, customer…"
dateFrom={controls.dateFrom}
onDateFromChange={controls.setDateFrom}
dateTo={controls.dateTo}
onDateToChange={controls.setDateTo}
dateLabel="Arrived"
hasFilters={controls.hasFilters}
onReset={controls.reset}
/>
<WarehouseInventoryTable
items={items}
items={controls.pagedRows}
busyId={busyId}
onAdvance={advance}
onMove={setMoveItem}
@@ -287,6 +314,14 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
allSelected={allSelected}
someSelected={someSelected}
/>
<RuleEngineListFooter
pagination={controls.pagination}
pageCount={controls.pageCount}
totalCount={controls.totalCount}
itemLabel="items"
onPaginationChange={controls.setPagination}
/>
</Stack>
<MoveInventoryModal opened={Boolean(moveItem)} onClose={() => setMoveItem(null)} item={moveItem} />

View File

@@ -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}`,

View File

@@ -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<T> {
/**
* 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<string, unknown>)[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 = <T,>(rows: T[], options: ListControlsOptions<T> = {}) => {
const { searchKeys = [], dateKey, pageSize = 10, searchValue } = options;
const [search, setSearch] = useState("");
const [dateFrom, setDateFrom] = useState<string | null>(null);
const [dateTo, setDateTo] = useState<string | null>(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 <DataTable /> 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,
},
},
};
};

View File

@@ -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
</Title>
<Card withBorder>
<ListControls
search={controls.search}
onSearchChange={controls.setSearch}
searchPlaceholder="Search type, status, document no…"
dateFrom={controls.dateFrom}
onDateFromChange={controls.setDateFrom}
dateTo={controls.dateTo}
onDateToChange={controls.setDateTo}
dateLabel="Expiry"
hasFilters={controls.hasFilters}
onReset={controls.reset}
/>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
@@ -239,7 +261,7 @@ export default function CompliancePage() {
</Table.Td>
</Table.Tr>
) : null}
{(records as ComplianceRecord[]).map((record) => (
{controls.pagedRows.map((record) => (
<Table.Tr key={record.id}>
<Table.Td>{vehicleLabel(record)}</Table.Td>
<Table.Td>
@@ -259,6 +281,13 @@ export default function CompliancePage() {
))}
</Table.Tbody>
</Table>
<RuleEngineListFooter
pagination={controls.pagination}
pageCount={controls.pageCount}
totalCount={controls.totalCount}
itemLabel="records"
onPaginationChange={controls.setPagination}
/>
</Card>
{/* Modal */}

View File

@@ -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<string | null>(null);
const [dateTo, setDateTo] = useState<string | null>(null);
const [listFilterValues, setListFilterValues] = useState<Record<string, string>>({});
const [formOpen, setFormOpen] = useState(false);
const [editing, setEditing] = useState<FleetRecord | null>(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<string, unknown>;
// 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 ? (
<Group gap="sm" wrap="wrap" align="center">
<DatePickerInput
aria-label="Created from"
placeholder="Created from"
value={dateFrom}
onChange={setDateFrom}
maxDate={dateTo ?? undefined}
clearable
size="sm"
radius="lg"
w={160}
/>
<DatePickerInput
aria-label="Created to"
placeholder="Created to"
value={dateTo}
onChange={setDateTo}
minDate={dateFrom ?? undefined}
clearable
size="sm"
radius="lg"
w={160}
/>
{listFilterSelects ? (
<Group gap="sm" wrap="wrap" align="center">
{listFilterSelects.map((filter) => (
<Select
@@ -487,7 +541,8 @@ const FleetResourcePage = () => {
))}
</Group>
</Group>
) : undefined
) : null}
</Group>
}
/>
</Box>

View File

@@ -19,6 +19,11 @@ import {
} from "@mantine/core";
import { Plus } 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 { api } from "@/auth/http";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
@@ -118,6 +123,11 @@ export default function FuelPurchasePage() {
const totalCost = formData.liters * formData.costPerLiter;
// Aggregate stats (guarded against divide-by-zero when there are no purchases)
const controls = useListControls(purchasesData as FuelPurchase[], {
searchKeys: ["fuelStation", "paymentMethod"],
dateKey: "purchaseDate",
});
const totalLiters = (purchasesData as FuelPurchase[]).reduce(
(sum, p) => sum + Number(p.liters),
0
@@ -185,6 +195,18 @@ export default function FuelPurchasePage() {
{/* Purchases Table */}
<Card withBorder>
<ListControls
search={controls.search}
onSearchChange={controls.setSearch}
searchPlaceholder="Search station or payment method…"
dateFrom={controls.dateFrom}
onDateFromChange={controls.setDateFrom}
dateTo={controls.dateTo}
onDateToChange={controls.setDateTo}
dateLabel="Purchased"
hasFilters={controls.hasFilters}
onReset={controls.reset}
/>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
@@ -215,7 +237,7 @@ export default function FuelPurchasePage() {
</Table.Td>
</Table.Tr>
) : null}
{(purchasesData as FuelPurchase[])?.map((purchase) => (
{controls.pagedRows.map((purchase) => (
<Table.Tr key={purchase.id}>
<Table.Td>{(purchase as any).vehicle?.registrationNumber || (purchase as any).vehicle?.plateNumber || purchase.vehicleId}</Table.Td>
<Table.Td>{new Date(purchase.purchaseDate).toLocaleDateString()}</Table.Td>
@@ -230,6 +252,13 @@ export default function FuelPurchasePage() {
))}
</Table.Tbody>
</Table>
<RuleEngineListFooter
pagination={controls.pagination}
pageCount={controls.pageCount}
totalCount={controls.totalCount}
itemLabel="purchases"
onPaginationChange={controls.setPagination}
/>
</Card>
{/* Modal */}

View File

@@ -20,6 +20,11 @@ import {
} from "@mantine/core";
import { Plus } 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 {
incidentsService,
@@ -161,6 +166,10 @@ export default function IncidentsPage() {
})) || [];
const incidents = incidentsData as Incident[];
const controls = useListControls(incidents, {
searchKeys: ["type", "severity", "status"],
dateKey: "occurredAt",
});
const totalCount = incidents.length;
const openCount = incidents.filter((i) => OPEN_STATUSES.includes(i.status)).length;
const underReviewCount = incidents.filter((i) => i.status === "UNDER_REVIEW").length;
@@ -237,6 +246,18 @@ export default function IncidentsPage() {
{/* Incidents Table */}
<Card withBorder>
<ListControls
search={controls.search}
onSearchChange={controls.setSearch}
searchPlaceholder="Search type, severity, status…"
dateFrom={controls.dateFrom}
onDateFromChange={controls.setDateFrom}
dateTo={controls.dateTo}
onDateToChange={controls.setDateTo}
dateLabel="Occurred"
hasFilters={controls.hasFilters}
onReset={controls.reset}
/>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
@@ -267,7 +288,7 @@ export default function IncidentsPage() {
</Table.Td>
</Table.Tr>
) : null}
{incidents.map((incident) => (
{controls.pagedRows.map((incident) => (
<Table.Tr key={incident.id}>
<Table.Td>{new Date(incident.occurredAt).toLocaleDateString()}</Table.Td>
<Table.Td>
@@ -294,6 +315,13 @@ export default function IncidentsPage() {
))}
</Table.Tbody>
</Table>
<RuleEngineListFooter
pagination={controls.pagination}
pageCount={controls.pageCount}
totalCount={controls.totalCount}
itemLabel="incidents"
onPaginationChange={controls.setPagination}
/>
</Card>
{/* Modal */}

View File

@@ -1,14 +1,18 @@
import { useMemo } from "react";
import { useMemo, useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ActionIcon,
Badge,
Button,
Card,
Center,
Container,
Group,
Loader,
NumberInput,
Radio,
Select,
SimpleGrid,
Stack,
Table,
@@ -20,6 +24,7 @@ import {
import {
ArrowLeft,
Fuel,
Gauge,
History,
Route,
Truck,
@@ -28,7 +33,13 @@ import {
} from "lucide-react";
import { api } from "@/auth/http";
import { vehiclesService } from "@/services/vehicles.service";
import { api as apiClient2 } from "@/services/api";
import { useToast } from "@/hooks/use-toast";
import {
vehiclesService,
type SaveVehiclePayload,
type Vehicle,
} from "@/services/vehicles.service";
import { driversService } from "@/services/drivers.service";
import { fleetHistoryService } from "@/services/fleet-history.service";
@@ -132,6 +143,7 @@ const VehicleDetailPage = () => {
<Tabs.Tab value="history" leftSection={<History size={14} />}>History</Tabs.Tab>
<Tabs.Tab value="maintenance" leftSection={<Wrench size={14} />}>Maintenance</Tabs.Tab>
<Tabs.Tab value="fuel" leftSection={<Fuel size={14} />}>Fuel</Tabs.Tab>
<Tabs.Tab value="operations" leftSection={<Gauge size={14} />}>Operations</Tabs.Tab>
<Tabs.Tab value="mile" leftSection={<Route size={14} />}>First/Last mile</Tabs.Tab>
</Tabs.List>
@@ -142,6 +154,8 @@ const VehicleDetailPage = () => {
<InfoRow label="Code" value={vehicle.code ?? "—"} />
<InfoRow label="Registration" value={vehicle.registrationNumber ?? "—"} />
<InfoRow label="Type" value={vehicle.vehicleType ?? "—"} />
<InfoRow label="VIN" value={vehicle.vin ?? "—"} />
<InfoRow label="Ownership" value={vehicle.ownership ?? "—"} />
<InfoRow label="Manufacturer" value={vehicle.manufacturer ?? "—"} />
<InfoRow label="Model" value={vehicle.model ?? "—"} />
<InfoRow label="Year" value={vehicle.year ?? "—"} />
@@ -172,6 +186,10 @@ const VehicleDetailPage = () => {
<FuelTab vehicleId={id} />
</Tabs.Panel>
<Tabs.Panel value="operations" pt="lg">
<OperationsTab vehicle={vehicle} />
</Tabs.Panel>
<Tabs.Panel value="mile" pt="lg">
<MileTab vehicleId={id} />
</Tabs.Panel>
@@ -181,6 +199,103 @@ const VehicleDetailPage = () => {
);
};
/**
* Where a truck is and what it costs to run are per-trip operational facts, not
* part of registering the vehicle — so they are edited here rather than on the
* Add Vehicle form. `pricePerKm` is live billing input: first/last-mile charges
* are `distance × pricePerKm`.
*/
const OperationsTab = ({ vehicle }: { vehicle: Vehicle }) => {
const { toast } = useToast();
const queryClient = useQueryClient();
const [form, setForm] = useState({
locationId: vehicle.locationId ?? "",
estimatedDistanceKm: vehicle.estimatedDistanceKm ?? "",
actualDistanceKm: vehicle.actualDistanceKm ?? "",
pricePerKm: vehicle.pricePerKm ?? "",
currency: vehicle.currency ?? "ETB",
});
const { data: yards = [], isLoading: yardsLoading } = useQuery(
apiClient2.routes.yards.queryOptions(),
);
const save = useMutation({
mutationFn: () =>
vehiclesService.update(vehicle.id, {
locationId: form.locationId || null,
// Empty means "not recorded" — send null so the column is unset rather
// than coerced to 0, which would read as a real measurement.
estimatedDistanceKm: form.estimatedDistanceKm === "" ? null : Number(form.estimatedDistanceKm),
actualDistanceKm: form.actualDistanceKm === "" ? null : Number(form.actualDistanceKm),
pricePerKm: form.pricePerKm === "" ? null : Number(form.pricePerKm),
currency: form.currency || null,
} as Partial<SaveVehiclePayload> & { locationId?: string | null }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["vehicle", vehicle.id] });
toast({ title: "Operational details saved" });
},
onError: () =>
toast({ title: "Could not save operational details", variant: "destructive" }),
});
return (
<Card withBorder radius="md" padding="md">
<Stack gap="md">
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
<Select
label="Location"
placeholder={yardsLoading ? "Loading yards..." : "Not set"}
data={(yards as Array<{ id: string; label?: string; code?: string }>).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
/>
<NumberInput
label="Price per KM"
description="Used for first/last-mile billing"
value={form.pricePerKm}
onChange={(v) => setForm((f) => ({ ...f, pricePerKm: v as number | "" }))}
min={0}
/>
<NumberInput
label="Estimated Distance (KM)"
value={form.estimatedDistanceKm}
onChange={(v) => setForm((f) => ({ ...f, estimatedDistanceKm: v as number | "" }))}
min={0}
/>
<NumberInput
label="Actual Distance (KM)"
value={form.actualDistanceKm}
onChange={(v) => setForm((f) => ({ ...f, actualDistanceKm: v as number | "" }))}
min={0}
/>
<Radio.Group
label="Currency"
value={form.currency}
onChange={(v) => setForm((f) => ({ ...f, currency: v }))}
>
<Group gap="lg" mt={6}>
<Radio value="ETB" label="ETB" />
<Radio value="USD" label="USD" />
</Group>
</Radio.Group>
</SimpleGrid>
<Group justify="flex-end">
<Button onClick={() => save.mutate()} loading={save.isPending}>
Save
</Button>
</Group>
</Stack>
</Card>
);
};
const DriverTab = ({
vehicleId,
driverId,

View File

@@ -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<string, unknown>;
}
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<string, unknown>,
) => Record<string, unknown>;
/**
* 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

View File

@@ -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: "",

View File

@@ -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 }) => {
<InfoRow label="Service type" value={serviceTypeName(record)} />
{hasPickupAddress && <InfoRow label="Pickup location" value={pickupLocation(record)} />}
<InfoRow label="Destination (origin yard)" value={destinationYardName(record)} />
<InfoRow label="Cargo Type" value={cargoTypeName(record)} />
<InfoRow label="Cargo" value={cargoDesc(record)} />
<InfoRow label="Train Schedule" value={trainScheduleLabel(record)} />
<InfoRow label="Advanced Payment" value={formatPrice(record.advancedPayment, currencyOf(record))} />
<InfoRow label="Post Payment" value={formatPrice(record.remainingPayment, currencyOf(record))} />
<InfoRow label="Contact" value={contactPersonName(record)} />
@@ -496,10 +513,11 @@ const FirstMilePage = () => {
const [tripSlipVehicleId, setTripSlipVehicleId] = useState<string | null>(null);
const [tripSlipSelectOpen, setTripSlipSelectOpen] = useState(false);
const [activeId, setActiveId] = useState<string | null>(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<string>();
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}
/>
<Select
style={{ flex: 1 }}
label={i === 0 ? "Container no." : undefined}
placeholder={containerOptions.length ? "Select container" : "No container numbers"}
data={[
...containerOptions.filter(
(n) =>
n === row.containerNumber ||
!vehicleRows.some((r, idx) => idx !== i && r.containerNumber === n),
),
// keep a manual/legacy value selectable even if not in the booking
...(row.containerNumber && !containerOptions.includes(row.containerNumber)
? [row.containerNumber]
: []),
]}
value={row.containerNumber || null}
onChange={(value) =>
setVehicleRows((prev) =>
prev.map((x, idx) => (idx === i ? { ...x, containerNumber: value ?? "" } : x)),
)
}
searchable
clearable
/>
{!bulkMode && activeRecord && isBulkBooking(activeRecord) ? (
<>
<NumberInput
style={{ flex: 0.8 }}
label={i === 0 ? "Tonnes" : undefined}
placeholder="t"
min={0}
value={row.tons}
onChange={(v) =>
setVehicleRows((prev) =>
prev.map((x, idx) =>
idx === i ? { ...x, tons: v === "" ? "" : Number(v) } : x,
),
)
}
/>
<NumberInput
style={{ flex: 0.8 }}
label={i === 0 ? "Items qty (pcs)" : undefined}
placeholder="optional"
min={0}
value={row.quantity}
onChange={(v) =>
setVehicleRows((prev) =>
prev.map((x, idx) =>
idx === i ? { ...x, quantity: v === "" ? "" : Number(v) } : x,
),
)
}
/>
</>
) : (
<Select
style={{ flex: 1 }}
label={i === 0 ? "Container no." : undefined}
placeholder={containerOptions.length ? "Select container" : "No container numbers"}
data={[
...containerOptions.filter(
(n) =>
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 && (
<ActionIcon
variant="subtle"
@@ -1479,6 +1562,8 @@ const FirstMilePage = () => {
vehicleId: null,
containerNumber:
(activeRecord ? bookingContainerNumbers(activeRecord)[prev.length] : "") ?? "",
tons: "" as const,
quantity: "" as const,
},
])
}
@@ -1491,6 +1576,22 @@ const FirstMilePage = () => {
>
Add vehicle
</Button>
{!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 (
<Text size="sm" c={remaining < 0 ? "red" : "dimmed"}>
Bulk drawdown: {assigning} t of {total} t assigned {" "}
<Text span fw={600} c={remaining < 0 ? "red" : undefined}>
{remaining} t remaining
</Text>
</Text>
);
})()}
</Stack>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={closeAssign}>Cancel</Button>

View File

@@ -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",

View File

@@ -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<string | null>(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() {
<Card withBorder radius="md" padding="lg">
<Group justify="space-between" mb="md">
<Text fw={600}>{documents.length} document(s)</Text>
<Text fw={600}>{controls.totalCount} document(s)</Text>
<TextInput
w={{ base: '100%', sm: 320 }}
leftSection={<Search size={16} />}
@@ -445,6 +450,19 @@ export default function InterchangeDocumentsPage() {
/>
</Group>
<ListControls
showSearch={false}
search=""
onSearchChange={() => {}}
dateFrom={controls.dateFrom}
onDateFromChange={controls.setDateFrom}
dateTo={controls.dateTo}
onDateToChange={controls.setDateTo}
dateLabel="Generated"
hasFilters={controls.hasFilters}
onReset={controls.reset}
/>
{!isLoading && documents.length === 0 ? (
<VisualEmptyState
variant="container"
@@ -454,9 +472,10 @@ export default function InterchangeDocumentsPage() {
) : (
<DataTable
columns={documentColumns}
data={documents}
data={controls.pagedRows}
status={isLoading ? 'loading' : 'success'}
containerClassName="border-0 shadow-none"
{...controls.tableProps}
/>
)}
</Card>

View File

@@ -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 (
<Alert variant="light" color="gray">
@@ -95,6 +140,7 @@ function Rows({ rows }: { rows: IntercityRideAlongRow[] }) {
<Table.Th ta="right">Weight</Table.Th>
<Table.Th>GRN</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
@@ -151,6 +197,38 @@ function Rows({ rows }: { rows: IntercityRideAlongRow[] }) {
{r.status}
</Badge>
</Table.Td>
<Table.Td>
<Group gap="xs" justify="flex-end" wrap="nowrap">
{/* Work the cargo right here while the train is at the yard. */}
{r.trainScheduleId && atOrigin(r) && isWaiting(r) && r.status === "PAID" && (
<Button
size="compact-xs"
variant="light"
leftSection={<PackageCheck size={13} />}
loading={load.isPending}
onClick={() =>
load.mutate({ scheduleId: r.trainScheduleId as string, bookingId: r.bookingId })
}
>
Load
</Button>
)}
{r.trainScheduleId && atDestination(r) && isRiding(r) && (
<Button
size="compact-xs"
variant="light"
color="orange"
leftSection={<PackageOpen size={13} />}
loading={unload.isPending}
onClick={() =>
unload.mutate({ scheduleId: r.trainScheduleId as string, bookingId: r.bookingId })
}
>
Unload
</Button>
)}
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
@@ -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 (
<PageContainer>
<PageHeader
@@ -283,19 +370,40 @@ export default function IntercityPage() {
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="waiting">
<Rows rows={waiting} />
<ListControls
search={controls.search}
onSearchChange={controls.setSearch}
searchPlaceholder="Reference, GRN, customer, train…"
dateFrom={controls.dateFrom}
onDateFromChange={controls.setDateFrom}
dateTo={controls.dateTo}
onDateToChange={controls.setDateTo}
dateLabel="Loaded"
hasFilters={controls.hasFilters}
onReset={controls.reset}
/>
<Tabs.Panel value="waiting" keepMounted={false}>
<Rows rows={controls.pagedRows} />
</Tabs.Panel>
<Tabs.Panel value="riding">
<Rows rows={riding} />
<Tabs.Panel value="riding" keepMounted={false}>
<Rows rows={controls.pagedRows} />
</Tabs.Panel>
<Tabs.Panel value="done">
<Rows rows={done} />
<Tabs.Panel value="done" keepMounted={false}>
<Rows rows={controls.pagedRows} />
</Tabs.Panel>
</Tabs>
<RuleEngineListFooter
pagination={controls.pagination}
pageCount={controls.pageCount}
totalCount={controls.totalCount}
itemLabel="bookings"
onPaginationChange={controls.setPagination}
/>
<Text size="xs" c="dimmed" mt="sm">
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.
</Text>
</Card>
</>

View File

@@ -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."
/>
) : (
<WarehouseInquiryTable results={results} onView={setViewResult} />
<>
<ListControls
search={controls.search}
onSearchChange={controls.setSearch}
searchPlaceholder="Container, booking, customer, location…"
dateFrom={controls.dateFrom}
onDateFromChange={controls.setDateFrom}
dateTo={controls.dateTo}
onDateToChange={controls.setDateTo}
dateLabel="Arrived"
hasFilters={controls.hasFilters}
onReset={controls.reset}
/>
<WarehouseInquiryTable results={controls.pagedRows} onView={setViewResult} />
<RuleEngineListFooter
pagination={controls.pagination}
pageCount={controls.pageCount}
totalCount={controls.totalCount}
itemLabel="items"
onPaginationChange={controls.setPagination}
/>
</>
)}
</Card>
</Stack>

View File

@@ -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 (
<PageContainer>
@@ -83,12 +89,27 @@ export default function LoadedInventoryPage() {
description="Once items are loaded onto a wagon, their records show here."
/>
) : (
<DataTable
columns={columns}
data={loadings}
status={isLoading ? 'loading' : 'success'}
containerClassName="border-0 shadow-none"
/>
<>
<ListControls
search={controls.search}
onSearchChange={controls.setSearch}
searchPlaceholder="Search by wagon"
dateFrom={controls.dateFrom}
onDateFromChange={controls.setDateFrom}
dateTo={controls.dateTo}
onDateToChange={controls.setDateTo}
dateLabel="Loaded"
hasFilters={controls.hasFilters}
onReset={controls.reset}
/>
<DataTable
columns={columns}
data={controls.pagedRows}
status={isLoading ? 'loading' : 'success'}
containerClassName="border-0 shadow-none"
{...controls.tableProps}
/>
</>
)}
</Card>
</PageContainer>

View File

@@ -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() {
]}
/>
</Group>
<TextInput
size="xs"
w={280}
placeholder="Plate, driver, booking, container…"
leftSection={<Search size={14} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
/>
</Group>
{isLoading ? <Text size="sm">Loading</Text> : <Rows rows={rows} />}
<ListControls
search={controls.search}
onSearchChange={controls.setSearch}
searchPlaceholder="Plate, driver, booking, container…"
dateFrom={controls.dateFrom}
onDateFromChange={controls.setDateFrom}
dateTo={controls.dateTo}
onDateToChange={controls.setDateTo}
dateLabel="Arrived"
hasFilters={controls.hasFilters}
onReset={controls.reset}
/>
{isLoading ? (
<Text size="sm">Loading</Text>
) : (
<>
<Rows rows={controls.pagedRows} />
<RuleEngineListFooter
pagination={controls.pagination}
pageCount={controls.pageCount}
totalCount={controls.totalCount}
itemLabel="trucks"
onPaginationChange={controls.setPagination}
/>
</>
)}
</PageContainer>
);
}

View File

@@ -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<WarehouseInvoiceStatus | null>(null);
const [search, setSearch] = useState('');
const [detailId, setDetailId] = useState<string | null>(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<WarehouseFeeInvoice>[] = [
{
@@ -131,30 +131,36 @@ export default function WarehouseInvoicesPage() {
</Stack>
<Card>
<Group justify="space-between" mb="md" wrap="wrap">
<TextInput
placeholder="Search invoice no / booking / customer"
leftSection={<Search size={16} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
w={320}
/>
<Select
placeholder="All statuses"
data={WAREHOUSE_INVOICE_STATUSES.map((s) => ({ value: s, label: s.replace(/_/g, ' ') }))}
value={status}
onChange={(v) => setStatus((v as WarehouseInvoiceStatus) ?? null)}
clearable
w={200}
/>
</Group>
<ListControls
search={controls.search}
onSearchChange={controls.setSearch}
searchPlaceholder="Search invoice no / booking / customer"
dateFrom={controls.dateFrom}
onDateFromChange={controls.setDateFrom}
dateTo={controls.dateTo}
onDateToChange={controls.setDateTo}
dateLabel="Issued"
hasFilters={controls.hasFilters}
onReset={controls.reset}
>
<Select
label="Status"
placeholder="All statuses"
data={WAREHOUSE_INVOICE_STATUSES.map((s) => ({ value: s, label: s.replace(/_/g, ' ') }))}
value={status}
onChange={(v) => setStatus((v as WarehouseInvoiceStatus) ?? null)}
clearable
w={200}
/>
</ListControls>
<DataTable
columns={invoiceColumns}
data={filtered}
data={controls.pagedRows}
status={isLoading ? 'loading' : 'success'}
emptyMessage="No invoices found."
containerClassName="border-0 shadow-none"
{...controls.tableProps}
/>
</Card>

View File

@@ -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() {
<Stack gap="md">
<WarehouseFilters filter={filter} onChange={setFilter} view={view} onViewChange={setView} />
<ListControls
showSearch={false}
search=""
onSearchChange={() => {}}
dateFrom={controls.dateFrom}
onDateFromChange={controls.setDateFrom}
dateTo={controls.dateTo}
onDateToChange={controls.setDateTo}
dateLabel="Created"
hasFilters={controls.hasFilters}
onReset={controls.reset}
/>
{isLoading ? (
<Center py="xl">
<Loader />
@@ -65,10 +88,21 @@ export default function WarehouseListPage() {
<Text c="red" ta="center" py="xl">
Failed to load warehouses.
</Text>
) : view === 'table' ? (
<WarehouseTable warehouses={warehouses} onView={openDetail} onEdit={openEdit} />
) : (
<WarehouseCardView warehouses={warehouses} onView={openDetail} onEdit={openEdit} />
<>
{view === 'table' ? (
<WarehouseTable warehouses={controls.pagedRows} onView={openDetail} onEdit={openEdit} />
) : (
<WarehouseCardView warehouses={controls.pagedRows} onView={openDetail} onEdit={openEdit} />
)}
<RuleEngineListFooter
pagination={controls.pagination}
pageCount={controls.pageCount}
totalCount={controls.totalCount}
itemLabel="warehouses"
onPaginationChange={controls.setPagination}
/>
</>
)}
</Stack>
</Card>

View File

@@ -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() {
<>
<Group justify="space-between" mb="sm">
<Text c="dimmed" size="sm">
{rules.length} rule(s) matched by ascending priority
{controls.totalCount} rule(s) matched by ascending priority
</Text>
<Button leftSection={<Plus size={16} />} onClick={() => { resetForm(); setOpen(true); }}>
New allocation rule
@@ -268,12 +273,26 @@ function AllocationRules() {
</Text>
</Alert>
<ListControls
search={controls.search}
onSearchChange={controls.setSearch}
searchPlaceholder="Search allocation rules…"
dateFrom={controls.dateFrom}
onDateFromChange={controls.setDateFrom}
dateTo={controls.dateTo}
onDateToChange={controls.setDateTo}
dateLabel="Created"
hasFilters={controls.hasFilters}
onReset={controls.reset}
/>
<DataTable
columns={allocationColumns}
data={rules}
data={controls.pagedRows}
status={isLoading ? 'loading' : 'success'}
emptyMessage="No allocation rules yet. Create one to route inventory to a yard automatically."
containerClassName="border-0 shadow-none"
{...controls.tableProps}
/>
<Modal opened={open} onClose={() => { setOpen(false); resetForm(); }} title={editingId ? 'Edit allocation rule' : 'New allocation rule'} centered size="lg">
@@ -393,6 +412,12 @@ function FeeRules() {
const { data: containerTypes = [], isLoading: containerTypesLoading } = useQuery(
api.containerTypes.list.queryOptions({ staleTime: Infinity }),
);
// Detention rules match on the truck-type CODE denormalised onto
// vehicles.vehicle_type, so the options come from the managed truck types
// rather than a hardcoded list that drifts the moment a type is added.
const { data: truckTypes = [], isLoading: truckTypesLoading } = useQuery(
api.truckTypes.list.queryOptions({ staleTime: Infinity }),
);
const create = useCreateFeeRule();
const update = useUpdateFeeRule();
const remove = useDeleteFeeRule();
@@ -414,6 +439,10 @@ function FeeRules() {
currency: 'USD',
});
const rules = data ?? [];
const controls = useListControls(rules, {
searchKeys: ['name', 'ruleType', 'freightType', 'tradeDirection', 'cargoTypeCode', 'containerType', 'vehicleType', 'currency'],
dateKey: 'createdAt',
});
const cargoTypeOptions = codeOptions(cargoTypes);
const containerTypeOptions = codeOptions(containerTypes);
const isBulkRule = form.freightType === 'BULK';
@@ -642,19 +671,33 @@ function FeeRules() {
<>
<Group justify="space-between" mb="sm">
<Text c="dimmed" size="sm">
{rules.length} rule(s) - most specific match applies
{controls.totalCount} rule(s) - most specific match applies
</Text>
<Button leftSection={<Plus size={16} />} onClick={() => { resetForm(); setOpen(true); }}>
New fee rule
</Button>
</Group>
<ListControls
search={controls.search}
onSearchChange={controls.setSearch}
searchPlaceholder="Search fee rules…"
dateFrom={controls.dateFrom}
onDateFromChange={controls.setDateFrom}
dateTo={controls.dateTo}
onDateToChange={controls.setDateTo}
dateLabel="Created"
hasFilters={controls.hasFilters}
onReset={controls.reset}
/>
<DataTable
columns={feeColumns}
data={rules}
data={controls.pagedRows}
status={isLoading ? 'loading' : 'success'}
emptyMessage="No storage or demurrage fee rules yet."
containerClassName="border-0 shadow-none"
{...controls.tableProps}
/>
<Modal opened={open} onClose={() => { setOpen(false); resetForm(); }} title={editingId ? 'Edit fee rule' : 'New fee rule'} centered size="lg">
@@ -712,8 +755,9 @@ function FeeRules() {
{isTruckDetention && (
<Select
label="Truck type"
placeholder="Any truck type"
data={VEHICLE_TYPES.map((v) => ({ value: v, label: v.charAt(0) + v.slice(1).toLowerCase() }))}
placeholder={truckTypesLoading ? 'Loading truck types...' : 'Any truck type'}
data={truckTypes.map((t) => ({ value: t.code, label: `${t.name} (${t.code})` }))}
disabled={truckTypesLoading}
value={form.vehicleType || null}
onChange={(value) => setForm((f) => ({ ...f, vehicleType: selectValue(value) }))}
clearable

View File

@@ -195,6 +195,7 @@ import {
type UsedTrainNumbers,
} from "./trainBuilder.service";
import { trainSchedulingService } from "./trainScheduling.service";
import { truckTypesService, type TruckType } from "./truck-types.service";
import { wagonTypesService, type WagonType } from "./wagon-types.service";
import {
wagonService,
@@ -2073,6 +2074,36 @@ export const api = {
),
},
truckTypes: {
list: endpoint<void, TruckType[]>("truck-types", "list", () =>
truckTypesService.getTruckTypes(),
),
create: endpoint<Partial<TruckType>, TruckType>(
"truck-types",
"create",
(payload) => truckTypesService.create(payload).then((r) => r.data),
undefined,
() => [["truck-types"]],
),
update: endpoint<{ id: string; data: Partial<TruckType> }, TruckType>(
"truck-types",
"update",
({ id, data }) => truckTypesService.update(id, data).then((r) => r.data),
undefined,
() => [["truck-types"]],
),
remove: endpoint<string, void>(
"truck-types",
"remove",
(id) => truckTypesService.delete(id).then(() => undefined),
undefined,
() => [["truck-types"]],
),
},
wagonTypes: {
list: endpoint<void, WagonType[]>("wagon-types", "list", () =>
wagonTypesService.getWagonTypes(),

View File

@@ -22,7 +22,10 @@ export interface FirstMileBooking {
serviceType?: { id: string; label?: string } | null;
originYard?: { id: string; label?: string } | null;
destinationYard?: { id: string; label?: string } | null;
cargoType?: { id: string; label?: string } | null;
cargoType?: { id: string; label?: string; cargoTypeName?: string; name?: string } | null;
freightType?: string | null;
/** Attached server-side: the train schedule this booking rides. */
trainSchedule?: { trainNumber: string | null; departureDate: string | null } | null;
/** Container lines — total container count drives how many trucks are needed. */
bookingContainers?: Array<{
id: string;
@@ -68,6 +71,8 @@ export interface FirstMileRecord {
vehicleId: string;
containerNumber?: string | null;
distanceKm?: number | null;
tons?: number | null;
quantity?: number | null;
vehicle?: FirstMileVehicle | null;
}>;
/** Present only when an invoice has actually been generated (not on distance). */
@@ -95,7 +100,12 @@ export const firstMileService = {
api.delete<void>(FM.BY_ID(id)),
setVehicles: (
id: string,
vehicles: Array<{ vehicleId: string; containerNumber?: string | null }>,
vehicles: Array<{
vehicleId: string;
containerNumber?: string | null;
tons?: number | null;
quantity?: number | null;
}>,
) => api.post<FirstMileRecord>(`${FM.BASE}/${id}/vehicles`, { vehicles }),
setDistances: (
id: string,

View File

@@ -85,6 +85,7 @@ const RESOURCE_BASE: Record<RuleEngineResourceSlug, string> = {
"cargo-types": URL_CONSTANTS.RULE_ENGINE.CARGO_TYPES,
"container-types": URL_CONSTANTS.RULE_ENGINE.CONTAINER_TYPES,
"wagon-types": URL_CONSTANTS.RULE_ENGINE.WAGON_TYPES,
"truck-types": URL_CONSTANTS.RULE_ENGINE.TRUCK_TYPES,
"priority-configs": URL_CONSTANTS.RULE_ENGINE.PRIORITY_CONFIGS,
"service-types": URL_CONSTANTS.RULE_ENGINE.SERVICE_TYPES,
"weight-limit-rules": URL_CONSTANTS.RULE_ENGINE.WEIGHT_LIMIT_RULES,
@@ -103,6 +104,8 @@ const byIdPath = (resource: RuleEngineResourceSlug, id: string): string => {
return URL_CONSTANTS.RULE_ENGINE.CONTAINER_TYPE_BY_ID(id);
case "wagon-types":
return URL_CONSTANTS.RULE_ENGINE.WAGON_TYPE_BY_ID(id);
case "truck-types":
return URL_CONSTANTS.RULE_ENGINE.TRUCK_TYPE_BY_ID(id);
case "priority-configs":
return URL_CONSTANTS.RULE_ENGINE.PRIORITY_CONFIG_BY_ID(id);
case "service-types":

View File

@@ -0,0 +1,30 @@
import { api } from "../auth/http";
type ListResponse<T> = T[] | { data: T[] };
export interface TruckType {
id: string;
code: string;
name: string;
/** Pre-fills a vehicle's capacity — capacity belongs to the type, not each truck. */
capacityTons: number | null;
/** False for a rigid truck (e.g. Casoni), which has no trailer plate at all. */
hasTrailer: boolean;
description?: string | null;
isActive: boolean;
}
const asList = <T>(payload: ListResponse<T>): T[] =>
Array.isArray(payload) ? payload : payload.data;
export const truckTypesService = {
async getTruckTypes() {
const response = await api.get<ListResponse<TruckType>>('/truck-types', {
params: { isActive: 'all', pageSize: 500 },
});
return asList(response.data);
},
create: (data: Partial<TruckType>) => api.post('/truck-types', data),
update: (id: string, data: Partial<TruckType>) => api.patch(`/truck-types/${id}`, data),
delete: (id: string) => api.delete(`/truck-types/${id}`),
};

View File

@@ -20,7 +20,14 @@ export interface Vehicle {
id: string;
plateNumber: string;
registrationNumber: string;
/** Denormalised truck-type code, written server-side. Register with `truckTypeId`. */
vehicleType: VehicleType;
/** Truck configuration from the managed truck types. */
truckTypeId?: string | null;
/** Vehicle Identification Number — unique across the fleet. */
vin?: string | null;
/** OWNED | OUTSOURCED. */
ownership?: string | null;
manufacturer: string;
model: string;
year: number;

View File

@@ -2,6 +2,7 @@ export type RuleEngineResourceSlug =
| "cargo-types"
| "container-types"
| "wagon-types"
| "truck-types"
| "priority-configs"
| "service-types"
| "weight-limit-rules"

View File

@@ -756,8 +756,10 @@ export interface AllocationRule {
targetZoneCode?: string | null;
storageType?: string | null;
isActive: boolean;
/** Set by the API (BaseEntity); used for the list date filter. */
createdAt?: string;
}
export type SaveAllocationRulePayload = Omit<AllocationRule, 'id'>;
export type SaveAllocationRulePayload = Omit<AllocationRule, 'id' | 'createdAt'>;
export const FEE_RULE_TYPES = [
'STORAGE_FEE',
@@ -813,8 +815,10 @@ export interface FeeRule {
tiers?: FeeRuleTier[];
currency: string;
isActive: boolean;
/** Set by the API (BaseEntity); used for the list date filter. */
createdAt?: string;
}
export type SaveFeeRulePayload = Omit<FeeRule, 'id'>;
export type SaveFeeRulePayload = Omit<FeeRule, 'id' | 'createdAt'>;
export interface FeeRuleTier {
fromDay: number;

View File

@@ -8,6 +8,7 @@ import {
Group,
Loader,
MultiSelect,
NumberInput,
Select,
SimpleGrid,
Stack,
@@ -79,6 +80,8 @@ export function CustomerTruckAssignmentCard({
const [driverName, setDriverName] = useState("");
const [truckType, setTruckType] = useState("");
const [containers, setContainers] = useState<string[]>([]);
const [plannedTons, setPlannedTons] = useState<number | "">("");
const [plannedQty, setPlannedQty] = useState<number | "">("");
const [editingId, setEditingId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [bulkModalOpen, setBulkModalOpen] = useState(false);
@@ -105,15 +108,23 @@ export function CustomerTruckAssignmentCard({
setDriverName("");
setTruckType("");
setContainers([]);
setPlannedTons("");
setPlannedQty("");
setEditingId(null);
setError(null);
};
const startEdit = (t: Freight.ICustomerTruck) => {
const planned = t as Freight.ICustomerTruck & {
plannedTons?: number | string | null;
plannedQuantity?: number | null;
};
setPlateNumber(t.plateNumber ?? "");
setDriverName(t.driverName ?? "");
setTruckType(t.truckType ?? "");
setContainers((t.containers ?? []).map((c) => c.containerNumber));
setPlannedTons(planned.plannedTons != null ? Number(planned.plannedTons) : "");
setPlannedQty(planned.plannedQuantity != null ? Number(planned.plannedQuantity) : "");
setEditingId(t.id);
setError(null);
};
@@ -129,6 +140,12 @@ export function CustomerTruckAssignmentCard({
driverName: driverName.trim(),
truckType: truckType.trim(),
containerNumbers: isBulk ? [] : containers,
...(isBulk
? {
plannedTons: plannedTons === "" ? undefined : Number(plannedTons),
plannedQuantity: plannedQty === "" ? undefined : Number(plannedQty),
}
: {}),
};
return editingId
? customerTrucksService.update(booking.id, editingId, payload)
@@ -170,6 +187,10 @@ export function CustomerTruckAssignmentCard({
setError("Select 1 or 2 container numbers for this truck.");
return;
}
if (isBulk && plannedTons === "") {
setError("Enter the tonnes this truck will haul.");
return;
}
setError(null);
addMutation.mutate();
};
@@ -324,6 +345,40 @@ export function CustomerTruckAssignmentCard({
nothingFoundMessage="No unassigned containers"
/>
)}
{isBulk && (
<NumberInput
label="Tonnes to load"
description={(() => {
const total = Number(booking.cargoTotalWeightVgm) || 0;
const assigned = trucks
.filter((t) => t.id !== editingId)
.reduce((s, t) => {
const x = t as Freight.ICustomerTruck & {
netWeightTons?: number | string | null;
plannedTons?: number | string | null;
};
return s + (Number(x.netWeightTons ?? x.plannedTons) || 0);
}, 0);
const remaining = Math.max(0, Math.round((total - assigned) * 1000) / 1000);
return total > 0
? `${assigned} t of ${total} t already on trucks · ${remaining} t remaining`
: "Tonnage this truck hauls";
})()}
required
min={0}
value={plannedTons}
onChange={(v) => setPlannedTons(v === "" ? "" : Number(v))}
/>
)}
{isBulk && (
<NumberInput
label="Items quantity (pcs)"
description="Optional piece count on this truck"
min={0}
value={plannedQty}
onChange={(v) => setPlannedQty(v === "" ? "" : Number(v))}
/>
)}
</SimpleGrid>
<Group justify="flex-end">
{editingId && (