mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
fix(warehouses): detention groups by canonical truck type
Join truck_types via vehicles.truck_type_id (normalized legacy vehicle_type only as fallback) so type renames can't unmatch detention rules and FK-less vehicles keep billing.
This commit is contained in:
@@ -29,6 +29,7 @@ import { ConsignmentsModule } from "./modules/consignments/consignments.module";
|
|||||||
|
|
||||||
// import { TrainsModule } from "./modules/trains/trains.module";
|
// import { TrainsModule } from "./modules/trains/trains.module";
|
||||||
import { LocomotivesModule } from "./modules/locomotives/locomotives.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 { WagonTypesModule } from "./modules/wagon-types/wagon-types.module";
|
||||||
import { TrainSetsModule } from "./modules/train-sets/train-sets.module";
|
import { TrainSetsModule } from "./modules/train-sets/train-sets.module";
|
||||||
import { TrainSchedulesModule } from "./modules/train-schedules/train-schedules.module";
|
import { TrainSchedulesModule } from "./modules/train-schedules/train-schedules.module";
|
||||||
@@ -158,6 +159,7 @@ import { LoggerMiddleware } from "./logger.middleware";
|
|||||||
FilesModule,
|
FilesModule,
|
||||||
ConsignmentsModule,
|
ConsignmentsModule,
|
||||||
LocomotivesModule,
|
LocomotivesModule,
|
||||||
|
TruckTypesModule,
|
||||||
WagonTypesModule,
|
WagonTypesModule,
|
||||||
TrainSetsModule,
|
TrainSetsModule,
|
||||||
TrainSchedulesModule,
|
TrainSchedulesModule,
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ type MileRecord = {
|
|||||||
bookingContainers?: Array<{
|
bookingContainers?: Array<{
|
||||||
units?: Array<{ vgmTons?: number | string | null }> | null;
|
units?: Array<{ vgmTons?: number | string | null }> | null;
|
||||||
}> | null;
|
}> | null;
|
||||||
|
/** Attached here: the train schedule the booking rides, for mile alignment. */
|
||||||
|
trainSchedule?: { trainNumber: string | null; departureDate: string | null } | null;
|
||||||
} | null;
|
} | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -36,6 +38,38 @@ export async function attachMileFinancials(
|
|||||||
if (unitTons > 0) b.cargoTotalWeightVgm = Number(unitTons.toFixed(3));
|
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(
|
const needAdvance = records.filter(
|
||||||
(r) => r.bookingId && !(Number(r.advancedPayment) > 0),
|
(r) => r.bookingId && !(Number(r.advancedPayment) > 0),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -85,6 +85,24 @@ export class CustomerTruckService {
|
|||||||
if (isBulk) {
|
if (isBulk) {
|
||||||
const { totalTons, remainingTons } = await remainingBulkTons(this.dataSource, bookingId);
|
const { totalTons, remainingTons } = await remainingBulkTons(this.dataSource, bookingId);
|
||||||
assertBulkTonnageRemains(totalTons, remainingTons);
|
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) {
|
if (requested.length) {
|
||||||
@@ -108,6 +126,8 @@ export class CustomerTruckService {
|
|||||||
plateNumber: dto.truckPlateNumber.trim().toUpperCase(),
|
plateNumber: dto.truckPlateNumber.trim().toUpperCase(),
|
||||||
driverName: dto.driverName.trim(),
|
driverName: dto.driverName.trim(),
|
||||||
truckType: dto.truckType.trim(),
|
truckType: dto.truckType.trim(),
|
||||||
|
plannedTons: isBulk ? (dto.plannedTons ?? null) : null,
|
||||||
|
plannedQuantity: isBulk ? (dto.plannedQuantity ?? null) : null,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
await manager.getRepository(CustomerTruckContainer).save(
|
await manager.getRepository(CustomerTruckContainer).save(
|
||||||
@@ -186,23 +206,52 @@ export class CustomerTruckService {
|
|||||||
throw new ConflictException('Cannot edit a truck that has already arrived');
|
throw new ConflictException('Cannot edit a truck that has already arrived');
|
||||||
}
|
}
|
||||||
|
|
||||||
const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
|
// Bulk trucks carry loose tonnage, not containers — planned tonnage is
|
||||||
if (requested.length < 1) {
|
// 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');
|
throw new BadRequestException('Select at least one container for this truck');
|
||||||
}
|
}
|
||||||
assertTruckLoad({
|
if (!isBulk) {
|
||||||
containers: requested,
|
assertTruckLoad({
|
||||||
bookingContainers: await this.bookingContainerNumbers(bookingId),
|
containers: requested,
|
||||||
sizes: await bookingContainerSizes(this.dataSource, bookingId, requested),
|
bookingContainers: await this.bookingContainerNumbers(bookingId),
|
||||||
// Exclude THIS truck's own containers so re-saving the same set is allowed.
|
sizes: await bookingContainerSizes(this.dataSource, bookingId, requested),
|
||||||
assignedElsewhere: await this.assignedContainerNumbersExcept(bookingId, assignmentId),
|
// 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 this.dataSource.transaction(async (manager) => {
|
||||||
await manager.getRepository(CustomerTruckAssignment).update(assignmentId, {
|
await manager.getRepository(CustomerTruckAssignment).update(assignmentId, {
|
||||||
plateNumber: dto.truckPlateNumber.trim().toUpperCase(),
|
plateNumber: dto.truckPlateNumber.trim().toUpperCase(),
|
||||||
driverName: dto.driverName.trim(),
|
driverName: dto.driverName.trim(),
|
||||||
truckType: dto.truckType.trim(),
|
truckType: dto.truckType.trim(),
|
||||||
|
...(isBulk
|
||||||
|
? {
|
||||||
|
plannedTons: dto.plannedTons ?? null,
|
||||||
|
plannedQuantity: dto.plannedQuantity ?? null,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
});
|
});
|
||||||
await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId });
|
await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId });
|
||||||
await manager.getRepository(CustomerTruckContainer).save(
|
await manager.getRepository(CustomerTruckContainer).save(
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ import {
|
|||||||
IsArray,
|
IsArray,
|
||||||
IsIn,
|
IsIn,
|
||||||
IsNotEmpty,
|
IsNotEmpty,
|
||||||
|
IsNumber,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
Matches,
|
Matches,
|
||||||
MaxLength,
|
MaxLength,
|
||||||
|
Min,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
|
||||||
import { CUSTOMER_TRUCK_TYPES } from './customer-truck-assignment.dto';
|
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',
|
message: 'each container number must match ISO container format, e.g. ABCD1234567',
|
||||||
})
|
})
|
||||||
containerNumbers?: string[];
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,14 @@ export class CustomerTruckAssignment extends BaseEntity {
|
|||||||
@Column({ name: 'net_weight_tons', type: 'numeric', precision: 14, scale: 3, nullable: true })
|
@Column({ name: 'net_weight_tons', type: 'numeric', precision: 14, scale: 3, nullable: true })
|
||||||
netWeightTons?: number | null;
|
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 })
|
@Column({ name: 'departed_at', type: 'timestamptz', nullable: true })
|
||||||
departedAt?: Date | null;
|
departedAt?: Date | null;
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
export class FirstMileVehicleInput {
|
export class FirstMileVehicleInput {
|
||||||
@@ -8,6 +8,18 @@ export class FirstMileVehicleInput {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
containerNumber?: string;
|
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. */
|
/** Replace the full set of vehicles (with their container numbers) on a pickup. */
|
||||||
|
|||||||
@@ -36,4 +36,12 @@ export class FirstMileVehicleAssignment extends BaseEntity {
|
|||||||
/** Actual distance driven by this truck (km), entered per vehicle. */
|
/** Actual distance driven by this truck (km), entered per vehicle. */
|
||||||
@Column({ name: 'distance_km', type: 'numeric', precision: 10, scale: 2, nullable: true })
|
@Column({ name: 'distance_km', type: 'numeric', precision: 10, scale: 2, nullable: true })
|
||||||
distanceKm?: number | null;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -532,17 +532,47 @@ export class FirstMileService {
|
|||||||
*/
|
*/
|
||||||
async setVehicles(
|
async setVehicles(
|
||||||
id: string,
|
id: string,
|
||||||
inputs: Array<{ vehicleId: string; containerNumber?: string | null }>,
|
inputs: Array<{
|
||||||
|
vehicleId: string;
|
||||||
|
containerNumber?: string | null;
|
||||||
|
tons?: number | null;
|
||||||
|
quantity?: number | null;
|
||||||
|
}>,
|
||||||
): Promise<FirstMile> {
|
): Promise<FirstMile> {
|
||||||
const existing = await this.findById(id);
|
const existing = await this.findById(id);
|
||||||
// Dedupe by vehicleId, keeping the container number; preserve order.
|
// Dedupe by vehicleId, keeping the load details; preserve order.
|
||||||
const desiredMap = new Map<string, string | null>();
|
const desiredMap = new Map<
|
||||||
|
string,
|
||||||
|
{ containerNumber: string | null; tons: number | null; quantity: number | null }
|
||||||
|
>();
|
||||||
for (const inp of inputs) {
|
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 desired = [...desiredMap.keys()];
|
||||||
const desiredSet = new Set(desired);
|
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 manager = this.dataSource.manager;
|
||||||
const current = await manager.find(FirstMileVehicleAssignment, {
|
const current = await manager.find(FirstMileVehicleAssignment, {
|
||||||
where: { firstMileId: id },
|
where: { firstMileId: id },
|
||||||
@@ -555,12 +585,16 @@ export class FirstMileService {
|
|||||||
)];
|
)];
|
||||||
const added = desired.filter((v) => !junctionSet.has(v));
|
const added = desired.filter((v) => !junctionSet.has(v));
|
||||||
const removed = releaseIds.filter((v) => !desiredSet.has(v));
|
const removed = releaseIds.filter((v) => !desiredSet.has(v));
|
||||||
// Vehicles that stay but whose container number changed.
|
// Vehicles that stay but whose load details changed.
|
||||||
const changed = current.filter(
|
const changed = current.filter((a) => {
|
||||||
(a) =>
|
const want = desiredMap.get(a.vehicleId);
|
||||||
desiredMap.has(a.vehicleId) &&
|
if (!want) return false;
|
||||||
(a.containerNumber ?? null) !== (desiredMap.get(a.vehicleId) ?? null),
|
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) => {
|
await this.dataSource.transaction(async (tx) => {
|
||||||
if (removed.length) {
|
if (removed.length) {
|
||||||
@@ -570,17 +604,25 @@ export class FirstMileService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
for (const vehicleId of added) {
|
for (const vehicleId of added) {
|
||||||
|
const want = desiredMap.get(vehicleId);
|
||||||
await tx.insert(FirstMileVehicleAssignment, {
|
await tx.insert(FirstMileVehicleAssignment, {
|
||||||
firstMileId: id,
|
firstMileId: id,
|
||||||
vehicleId,
|
vehicleId,
|
||||||
containerNumber: desiredMap.get(vehicleId) ?? null,
|
containerNumber: want?.containerNumber ?? null,
|
||||||
|
tons: want?.tons ?? null,
|
||||||
|
quantity: want?.quantity ?? null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
for (const row of changed) {
|
for (const row of changed) {
|
||||||
|
const want = desiredMap.get(row.vehicleId);
|
||||||
await tx.update(
|
await tx.update(
|
||||||
FirstMileVehicleAssignment,
|
FirstMileVehicleAssignment,
|
||||||
{ firstMileId: id, vehicleId: row.vehicleId },
|
{ firstMileId: id, vehicleId: row.vehicleId },
|
||||||
{ containerNumber: desiredMap.get(row.vehicleId) ?? null },
|
{
|
||||||
|
containerNumber: want?.containerNumber ?? null,
|
||||||
|
tons: want?.tons ?? null,
|
||||||
|
quantity: want?.quantity ?? null,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { PartialType } from '@nestjs/mapped-types';
|
||||||
|
|
||||||
|
import { CreateTruckTypeDto } from './create-truck-type.dto';
|
||||||
|
|
||||||
|
export class UpdateTruckTypeDto extends PartialType(CreateTruckTypeDto) {}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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 } });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
import { IsString, IsEnum, IsNumber, IsOptional, IsUUID, Matches } from 'class-validator';
|
import { IsString, IsEnum, IsNumber, IsOptional, IsUUID, Matches } from 'class-validator';
|
||||||
import { Transform } from 'class-transformer';
|
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 —
|
* A vehicle plate is two or three letters, a hyphen, then two to six digits —
|
||||||
@@ -28,8 +33,9 @@ export class CreateVehicleDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
plateNumber!: string;
|
plateNumber!: string;
|
||||||
|
|
||||||
@IsEnum(VehicleType)
|
/** Truck configuration from `freight.truck_types` — drives capacity and whether a trailer plate applies. */
|
||||||
vehicleType!: VehicleType;
|
@IsUUID()
|
||||||
|
truckTypeId!: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
manufacturer!: string;
|
manufacturer!: string;
|
||||||
@@ -43,8 +49,18 @@ export class CreateVehicleDto {
|
|||||||
@IsEnum(FuelType)
|
@IsEnum(FuelType)
|
||||||
fuelType!: FuelType;
|
fuelType!: FuelType;
|
||||||
|
|
||||||
|
/** Defaults to the truck type's capacity when omitted. */
|
||||||
|
@IsOptional()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
capacity!: number;
|
capacity?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
vin?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(VehicleOwnership)
|
||||||
|
ownership?: VehicleOwnership;
|
||||||
|
|
||||||
@IsEnum(VehicleStatus)
|
@IsEnum(VehicleStatus)
|
||||||
status!: VehicleStatus;
|
status!: VehicleStatus;
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
import { Entity, Column } from 'typeorm';
|
import { Entity, Column } from 'typeorm';
|
||||||
import { BaseEntity } from '@edr/api-common';
|
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 {
|
export enum VehicleType {
|
||||||
TRUCK = 'TRUCK',
|
TRUCK = 'TRUCK',
|
||||||
VAN = 'VAN',
|
VAN = 'VAN',
|
||||||
@@ -11,6 +22,12 @@ export enum VehicleType {
|
|||||||
FLATBED = 'FLATBED',
|
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 {
|
export enum FuelType {
|
||||||
PETROL = 'PETROL',
|
PETROL = 'PETROL',
|
||||||
DIESEL = 'DIESEL',
|
DIESEL = 'DIESEL',
|
||||||
@@ -47,8 +64,12 @@ export class Vehicle extends BaseEntity {
|
|||||||
@Column({ name: 'registration_number', unique: true, nullable: true })
|
@Column({ name: 'registration_number', unique: true, nullable: true })
|
||||||
registrationNumber?: string;
|
registrationNumber?: string;
|
||||||
|
|
||||||
|
/** Denormalised `truck_types.code` — written through by the service, never set by hand. */
|
||||||
@Column({ name: 'vehicle_type', type: 'varchar', nullable: true })
|
@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 })
|
@Column({ nullable: true })
|
||||||
manufacturer?: string;
|
manufacturer?: string;
|
||||||
@@ -101,7 +122,7 @@ export class Vehicle extends BaseEntity {
|
|||||||
@Column({ name: 'vin', type: 'varchar', nullable: true })
|
@Column({ name: 'vin', type: 'varchar', nullable: true })
|
||||||
vin?: string;
|
vin?: string;
|
||||||
|
|
||||||
/** Owned | Leased | Rented */
|
/** OWNED | OUTSOURCED — see {@link VehicleOwnership}. */
|
||||||
@Column({ name: 'ownership', type: 'varchar', nullable: true })
|
@Column({ name: 'ownership', type: 'varchar', nullable: true })
|
||||||
ownership?: string;
|
ownership?: string;
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ describe('VehiclesService driver assignment guard', () => {
|
|||||||
new VehiclesService(
|
new VehiclesService(
|
||||||
{ findOne, create: jest.fn((x) => x), save: jest.fn(async (x) => x) } as any,
|
{ findOne, create: jest.fn((x) => x), save: jest.fn(async (x) => x) } as any,
|
||||||
{ record: jest.fn() } 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 () => {
|
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 findOne = jest.fn().mockResolvedValueOnce(null).mockResolvedValueOnce(otherTruck);
|
||||||
const svc = makeService(findOne);
|
const svc = makeService(findOne);
|
||||||
await expect(
|
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);
|
).rejects.toThrow(ConflictException);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
|||||||
import { Vehicle } from './entities/vehicle.entity';
|
import { Vehicle } from './entities/vehicle.entity';
|
||||||
import { VehiclesService } from './vehicles.service';
|
import { VehiclesService } from './vehicles.service';
|
||||||
import { VehiclesController } from './vehicles.controller';
|
import { VehiclesController } from './vehicles.controller';
|
||||||
|
import { TruckTypesModule } from '../truck-types/truck-types.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([Vehicle])],
|
imports: [TypeOrmModule.forFeature([Vehicle]), TruckTypesModule],
|
||||||
providers: [VehiclesService],
|
providers: [VehiclesService],
|
||||||
controllers: [VehiclesController],
|
controllers: [VehiclesController],
|
||||||
exports: [VehiclesService],
|
exports: [VehiclesService],
|
||||||
|
|||||||
@@ -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 { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Not, Repository } from 'typeorm';
|
import { Not, Repository } from 'typeorm';
|
||||||
import { CreateVehicleDto } from './dto/create-vehicle.dto';
|
import { CreateVehicleDto } from './dto/create-vehicle.dto';
|
||||||
import { UpdateVehicleDto } from './dto/update-vehicle.dto';
|
import { UpdateVehicleDto } from './dto/update-vehicle.dto';
|
||||||
import { Vehicle, VehicleAvailability, VehicleStatus } from './entities/vehicle.entity';
|
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 { FirstMile, FirstMileStatus } from '../first-mile/entities/first-mile.entity';
|
||||||
import { FirstMileContainerAllocation } from '../first-mile/entities/first-mile-container-allocation.entity';
|
import { FirstMileContainerAllocation } from '../first-mile/entities/first-mile-container-allocation.entity';
|
||||||
import { LastMile, LastMileStatus } from '../last-mile/entities/last-mile.entity';
|
import { LastMile, LastMileStatus } from '../last-mile/entities/last-mile.entity';
|
||||||
@@ -18,8 +25,26 @@ export class VehiclesService {
|
|||||||
@InjectRepository(Vehicle)
|
@InjectRepository(Vehicle)
|
||||||
private readonly vehicleRepo: Repository<Vehicle>,
|
private readonly vehicleRepo: Repository<Vehicle>,
|
||||||
private readonly history: FleetHistoryService,
|
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
|
* A driver holds one truck at a time — reassignment requires detaching them
|
||||||
* from their current truck first.
|
* from their current truck first.
|
||||||
@@ -54,10 +79,17 @@ export class VehiclesService {
|
|||||||
await this.assertDriverUnassigned(dto.assignedDriverId);
|
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({
|
const vehicle = this.vehicleRepo.create({
|
||||||
...dto,
|
...dto,
|
||||||
registrationNumber,
|
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);
|
const saved = await this.vehicleRepo.save(vehicle);
|
||||||
@@ -148,6 +180,17 @@ export class VehiclesService {
|
|||||||
await this.assertDriverUnassigned(dto.assignedDriverId, id);
|
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 = {
|
const prev = {
|
||||||
assignedDriverId: vehicle.assignedDriverId,
|
assignedDriverId: vehicle.assignedDriverId,
|
||||||
assignedDriverName: vehicle.assignedDriverName,
|
assignedDriverName: vehicle.assignedDriverName,
|
||||||
@@ -156,6 +199,11 @@ export class VehiclesService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
Object.assign(vehicle, dto);
|
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);
|
const saved = await this.vehicleRepo.save(vehicle);
|
||||||
|
|
||||||
// Driver (re)assignment — emit an unassign for the old driver and/or an
|
// Driver (re)assignment — emit an unassign for the old driver and/or an
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -791,15 +791,22 @@ export class WarehouseFeeService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Group the leg's vehicles by type so each truck type is billed by its own
|
// Group the leg's vehicles by CANONICAL truck type so each type is billed
|
||||||
// matching rule (rates differ by truck type). Falls back to one untyped group.
|
// 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 }> =
|
const groupRows: Array<{ vehicleType: string | null; truckCount: number | string }> =
|
||||||
await this.dataSource.query(
|
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
|
FROM freight.last_mile_vehicle_assignments va
|
||||||
JOIN freight.vehicles v ON v.id = va.vehicle_id AND v.deleted_at IS NULL
|
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
|
WHERE va.last_mile_id = $1 AND va.deleted_at IS NULL
|
||||||
GROUP BY v.vehicle_type`,
|
GROUP BY 1`,
|
||||||
[lastMileId],
|
[lastMileId],
|
||||||
);
|
);
|
||||||
const groups = groupRows.length ? groupRows : [{ vehicleType: null, truckCount: 1 }];
|
const groups = groupRows.length ? groupRows : [{ vehicleType: null, truckCount: 1 }];
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export const RULE_ENGINE_RESOURCE_SLUGS = [
|
|||||||
'cargo-types',
|
'cargo-types',
|
||||||
'container-types',
|
'container-types',
|
||||||
'wagon-types',
|
'wagon-types',
|
||||||
|
'truck-types',
|
||||||
'service-types',
|
'service-types',
|
||||||
'yards',
|
'yards',
|
||||||
'shipping-lines',
|
'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' },
|
'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' },
|
'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' },
|
'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' },
|
'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' },
|
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' },
|
'shipping-lines': { view: 'b2000001-0001-4000-8000-000000000009', manage: 'b2000001-0001-4000-8000-00000000000a' },
|
||||||
|
|||||||
@@ -126,6 +126,28 @@ const FleetFormDialog = ({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [open, recordId]);
|
}, [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
|
// Receive the ?code&state relayed by the /callback popup, exchange it for
|
||||||
// the verified identity, and prefill the matching form fields.
|
// the verified identity, and prefill the matching form fields.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -202,18 +224,52 @@ const FleetFormDialog = ({
|
|||||||
|
|
||||||
const faydaVerified = values.faydaVerified === true;
|
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(
|
const shortFields = useMemo(
|
||||||
() => fields.filter((f) => f.type !== "textarea"),
|
() => visibleFields.filter((f) => f.type !== "textarea"),
|
||||||
[fields],
|
[visibleFields],
|
||||||
);
|
);
|
||||||
const longFields = useMemo(
|
const longFields = useMemo(
|
||||||
() => fields.filter((f) => f.type === "textarea"),
|
() => visibleFields.filter((f) => f.type === "textarea"),
|
||||||
[fields],
|
[visibleFields],
|
||||||
);
|
);
|
||||||
|
|
||||||
const validate = () => {
|
const validate = () => {
|
||||||
const next: Record<string, string> = {};
|
const next: Record<string, string> = {};
|
||||||
fields.forEach((field) => {
|
visibleFields.forEach((field) => {
|
||||||
const value = values[field.name];
|
const value = values[field.name];
|
||||||
const stringValue =
|
const stringValue =
|
||||||
typeof value === "string" ? value.trim() : String(value ?? "");
|
typeof value === "string" ? value.trim() : String(value ?? "");
|
||||||
@@ -295,9 +351,19 @@ const FleetFormDialog = ({
|
|||||||
fields.forEach((field) => {
|
fields.forEach((field) => {
|
||||||
if (field.derivedValue) submitted[field.name] = field.derivedValue(values);
|
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(
|
const payload = Object.fromEntries(
|
||||||
Object.entries(submitted)
|
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]) => {
|
.map(([key, value]) => {
|
||||||
|
if (hiddenFieldNames.includes(key)) return [key, null];
|
||||||
if (value === FLEET_SELECT_NONE || value === "" || value == null)
|
if (value === FLEET_SELECT_NONE || value === "" || value == null)
|
||||||
return [key, clearableByName[key] ? null : undefined];
|
return [key, clearableByName[key] ? null : undefined];
|
||||||
if (fieldTypeByName[key] === "number") {
|
if (fieldTypeByName[key] === "number") {
|
||||||
@@ -371,7 +437,15 @@ const FleetFormDialog = ({
|
|||||||
: String(value)
|
: String(value)
|
||||||
}
|
}
|
||||||
onChange={(next) =>
|
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}
|
error={error}
|
||||||
searchable
|
searchable
|
||||||
|
|||||||
@@ -417,6 +417,9 @@ export const URL_CONSTANTS = {
|
|||||||
WAGON_TYPES: "/wagon-types",
|
WAGON_TYPES: "/wagon-types",
|
||||||
WAGON_TYPE_BY_ID: (id: string) => `/wagon-types/${id}`,
|
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_CONFIGS: "/priority-configs",
|
||||||
PRIORITY_CONFIG_BY_ID: (id: string) => `/priority-configs/${id}`,
|
PRIORITY_CONFIG_BY_ID: (id: string) => `/priority-configs/${id}`,
|
||||||
|
|
||||||
|
|||||||
@@ -94,6 +94,9 @@ const FleetResourcePage = () => {
|
|||||||
const { data: wagonTypes = [], isLoading: wagonTypesLoading } = useQuery(
|
const { data: wagonTypes = [], isLoading: wagonTypesLoading } = useQuery(
|
||||||
api.wagonTypes.list.queryOptions(),
|
api.wagonTypes.list.queryOptions(),
|
||||||
);
|
);
|
||||||
|
const { data: truckTypes = [], isLoading: truckTypesLoading } = useQuery(
|
||||||
|
api.truckTypes.list.queryOptions(),
|
||||||
|
);
|
||||||
const { data: containerTypes = [], isLoading: containerTypesLoading } = useQuery(
|
const { data: containerTypes = [], isLoading: containerTypesLoading } = useQuery(
|
||||||
api.containerTypes.list.queryOptions({ staleTime: Infinity }),
|
api.containerTypes.list.queryOptions({ staleTime: Infinity }),
|
||||||
);
|
);
|
||||||
@@ -169,17 +172,34 @@ const FleetResourcePage = () => {
|
|||||||
(y) => ({ value: y.id, label: y.label ?? y.code ?? y.id }),
|
(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);
|
registerFleetOptionLabels("currentYardId", yardOpts);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
wagonTypes: wagonTypeOpts,
|
wagonTypes: wagonTypeOpts,
|
||||||
containerTypes: containerTypeOpts,
|
containerTypes: containerTypeOpts,
|
||||||
cargoTypes: [{ label: "None", value: FLEET_SELECT_NONE }, ...cargoTypeOpts],
|
cargoTypes: [{ label: "None", value: FLEET_SELECT_NONE }, ...cargoTypeOpts],
|
||||||
|
truckTypes: truckTypeOpts,
|
||||||
wagons: [{ label: "Unassigned", value: FLEET_SELECT_NONE }, ...wagonOpts],
|
wagons: [{ label: "Unassigned", value: FLEET_SELECT_NONE }, ...wagonOpts],
|
||||||
containers: containerOpts,
|
containers: containerOpts,
|
||||||
yards: yardOpts,
|
yards: yardOpts,
|
||||||
};
|
};
|
||||||
}, [wagonTypes, containerTypes, cargoTypes, wagons, containers, yards]);
|
}, [wagonTypes, containerTypes, cargoTypes, truckTypes, wagons, containers, yards]);
|
||||||
|
|
||||||
const listFilterSelects = useMemo(() => {
|
const listFilterSelects = useMemo(() => {
|
||||||
if (!config?.listFilters?.length) return null;
|
if (!config?.listFilters?.length) return null;
|
||||||
@@ -212,6 +232,7 @@ const FleetResourcePage = () => {
|
|||||||
registerFleetOptionLabels("containerId", dynamicOptions.containers);
|
registerFleetOptionLabels("containerId", dynamicOptions.containers);
|
||||||
registerFleetOptionLabels("currentYardId", dynamicOptions.yards);
|
registerFleetOptionLabels("currentYardId", dynamicOptions.yards);
|
||||||
registerFleetOptionLabels("locationId", dynamicOptions.yards);
|
registerFleetOptionLabels("locationId", dynamicOptions.yards);
|
||||||
|
registerFleetOptionLabels("truckTypeId", dynamicOptions.truckTypes);
|
||||||
}, [dynamicOptions]);
|
}, [dynamicOptions]);
|
||||||
|
|
||||||
const formFields = useMemo((): FleetFormFieldDef[] => {
|
const formFields = useMemo((): FleetFormFieldDef[] => {
|
||||||
@@ -227,6 +248,7 @@ const FleetResourcePage = () => {
|
|||||||
wagonTypesLoading ||
|
wagonTypesLoading ||
|
||||||
containerTypesLoading ||
|
containerTypesLoading ||
|
||||||
cargoTypesLoading ||
|
cargoTypesLoading ||
|
||||||
|
truckTypesLoading ||
|
||||||
wagonsLoading ||
|
wagonsLoading ||
|
||||||
containersLoading ||
|
containersLoading ||
|
||||||
yardsLoading;
|
yardsLoading;
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useParams, useNavigate } from "react-router-dom";
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
Badge,
|
Badge,
|
||||||
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Center,
|
Center,
|
||||||
Container,
|
Container,
|
||||||
Group,
|
Group,
|
||||||
Loader,
|
Loader,
|
||||||
|
NumberInput,
|
||||||
|
Radio,
|
||||||
|
Select,
|
||||||
SimpleGrid,
|
SimpleGrid,
|
||||||
Stack,
|
Stack,
|
||||||
Table,
|
Table,
|
||||||
@@ -20,6 +24,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Fuel,
|
Fuel,
|
||||||
|
Gauge,
|
||||||
History,
|
History,
|
||||||
Route,
|
Route,
|
||||||
Truck,
|
Truck,
|
||||||
@@ -28,7 +33,13 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { api } from "@/auth/http";
|
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 { driversService } from "@/services/drivers.service";
|
||||||
import { fleetHistoryService } from "@/services/fleet-history.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="history" leftSection={<History size={14} />}>History</Tabs.Tab>
|
||||||
<Tabs.Tab value="maintenance" leftSection={<Wrench size={14} />}>Maintenance</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="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.Tab value="mile" leftSection={<Route size={14} />}>First/Last mile</Tabs.Tab>
|
||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
|
|
||||||
@@ -142,6 +154,8 @@ const VehicleDetailPage = () => {
|
|||||||
<InfoRow label="Code" value={vehicle.code ?? "—"} />
|
<InfoRow label="Code" value={vehicle.code ?? "—"} />
|
||||||
<InfoRow label="Registration" value={vehicle.registrationNumber ?? "—"} />
|
<InfoRow label="Registration" value={vehicle.registrationNumber ?? "—"} />
|
||||||
<InfoRow label="Type" value={vehicle.vehicleType ?? "—"} />
|
<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="Manufacturer" value={vehicle.manufacturer ?? "—"} />
|
||||||
<InfoRow label="Model" value={vehicle.model ?? "—"} />
|
<InfoRow label="Model" value={vehicle.model ?? "—"} />
|
||||||
<InfoRow label="Year" value={vehicle.year ?? "—"} />
|
<InfoRow label="Year" value={vehicle.year ?? "—"} />
|
||||||
@@ -172,6 +186,10 @@ const VehicleDetailPage = () => {
|
|||||||
<FuelTab vehicleId={id} />
|
<FuelTab vehicleId={id} />
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
|
||||||
|
<Tabs.Panel value="operations" pt="lg">
|
||||||
|
<OperationsTab vehicle={vehicle} />
|
||||||
|
</Tabs.Panel>
|
||||||
|
|
||||||
<Tabs.Panel value="mile" pt="lg">
|
<Tabs.Panel value="mile" pt="lg">
|
||||||
<MileTab vehicleId={id} />
|
<MileTab vehicleId={id} />
|
||||||
</Tabs.Panel>
|
</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 = ({
|
const DriverTab = ({
|
||||||
vehicleId,
|
vehicleId,
|
||||||
driverId,
|
driverId,
|
||||||
|
|||||||
@@ -30,10 +30,22 @@ export type FleetDynamicOptions =
|
|||||||
| "wagonTypes"
|
| "wagonTypes"
|
||||||
| "containerTypes"
|
| "containerTypes"
|
||||||
| "cargoTypes"
|
| "cargoTypes"
|
||||||
|
| "truckTypes"
|
||||||
| "wagons"
|
| "wagons"
|
||||||
| "containers"
|
| "containers"
|
||||||
| "yards";
|
| "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 {
|
export interface FleetResourceColumn {
|
||||||
id: string;
|
id: string;
|
||||||
header: string;
|
header: string;
|
||||||
@@ -45,6 +57,17 @@ export interface FleetResourceColumn {
|
|||||||
export interface FleetFormFieldDef extends FormFieldDef {
|
export interface FleetFormFieldDef extends FormFieldDef {
|
||||||
dynamicOptions?: FleetDynamicOptions;
|
dynamicOptions?: FleetDynamicOptions;
|
||||||
noneOption?: boolean;
|
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
|
* 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
|
* typed. Rendered non-editable and recomputed on every change, so the stored
|
||||||
|
|||||||
@@ -10,6 +10,16 @@ const PLATE_PATTERN = {
|
|||||||
message: "Use letters and numbers like ET-9875 or AA-8642",
|
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 = [
|
const VEHICLE_TYPE_OPTIONS = [
|
||||||
{ label: "Truck", value: "TRUCK" },
|
{ label: "Truck", value: "TRUCK" },
|
||||||
{ label: "Van", value: "VAN" },
|
{ label: "Van", value: "VAN" },
|
||||||
@@ -20,6 +30,11 @@ const VEHICLE_TYPE_OPTIONS = [
|
|||||||
{ label: "Flatbed", value: "FLATBED" },
|
{ label: "Flatbed", value: "FLATBED" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const OWNERSHIP_OPTIONS = [
|
||||||
|
{ label: "Owned", value: "OWNED" },
|
||||||
|
{ label: "Outsourced", value: "OUTSOURCED" },
|
||||||
|
];
|
||||||
|
|
||||||
const FUEL_TYPE_OPTIONS = [
|
const FUEL_TYPE_OPTIONS = [
|
||||||
{ label: "Petrol", value: "PETROL" },
|
{ label: "Petrol", value: "PETROL" },
|
||||||
{ label: "Diesel", value: "DIESEL" },
|
{ label: "Diesel", value: "DIESEL" },
|
||||||
@@ -39,11 +54,6 @@ const VEHICLE_AVAILABILITY_OPTIONS = [
|
|||||||
{ label: "Busy", value: "BUSY" },
|
{ label: "Busy", value: "BUSY" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const CURRENCY_OPTIONS = [
|
|
||||||
{ label: "ETB", value: "ETB" },
|
|
||||||
{ label: "USD", value: "USD" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const vehiclesConfig: FleetResourceConfig = {
|
export const vehiclesConfig: FleetResourceConfig = {
|
||||||
slug: "vehicles",
|
slug: "vehicles",
|
||||||
label: "Vehicles",
|
label: "Vehicles",
|
||||||
@@ -72,35 +82,65 @@ export const vehiclesConfig: FleetResourceConfig = {
|
|||||||
options: VEHICLE_AVAILABILITY_OPTIONS,
|
options: VEHICLE_AVAILABILITY_OPTIONS,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
searchKeys: ["plateNumber", "registrationNumber", "manufacturer", "model", "vehicleType", "status"],
|
searchKeys: ["plateNumber", "registrationNumber", "manufacturer", "model", "vehicleType", "vin", "status"],
|
||||||
columns: [
|
columns: [
|
||||||
{ id: "code", header: "Code", accessorKey: "code", size: 90 },
|
{ id: "code", header: "Code", accessorKey: "code", size: 90 },
|
||||||
{ id: "plateNumber", header: "Plate Number", accessorKey: "plateNumber", size: 120 },
|
{ id: "plateNumber", header: "Plate Number", accessorKey: "plateNumber", size: 120 },
|
||||||
{ id: "trailerPlateNo", header: "Trailer Plate No", accessorKey: "trailerPlateNo", size: 130 },
|
{ id: "trailerPlateNo", header: "Trailer Plate No", accessorKey: "trailerPlateNo", size: 130 },
|
||||||
{ id: "manufacturer", header: "Manufacturer", accessorKey: "manufacturer", size: 120 },
|
{ id: "manufacturer", header: "Manufacturer", accessorKey: "manufacturer", size: 120 },
|
||||||
{ id: "model", header: "Model", accessorKey: "model", size: 100 },
|
{ 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: "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: "status", header: "Status", accessorKey: "status", format: "statusBadge", size: 100 },
|
||||||
{ id: "availability", header: "Availability", accessorKey: "availability", 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: [
|
formFields: [
|
||||||
{ name: "code", label: "Code", type: "text" },
|
{ name: "code", label: "Code", type: "text" },
|
||||||
{ name: "plateNumber", label: "Power Plate No", type: "text", required: true, pattern: PLATE_PATTERN },
|
{ name: "plateNumber", label: "Power Plate No", type: "text", required: true, pattern: PLATE_PATTERN },
|
||||||
// { name: "powerPlateNo", label: "Power Plate No", type: "text" },
|
// The truck type decides whether a trailer exists at all: a rigid truck
|
||||||
{ name: "trailerPlateNo", label: "Trailer Plate No", type: "text", pattern: PLATE_PATTERN },
|
// (Casoni) has none, so the field disappears and submits null. Mirrored
|
||||||
{ name: "vehicleType", label: "Vehicle Type", type: "select", required: true, options: VEHICLE_TYPE_OPTIONS },
|
// 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: "manufacturer", label: "Manufacturer", type: "text", required: true },
|
||||||
{ name: "model", label: "Model", type: "text", required: true },
|
{ name: "model", label: "Model", type: "text", required: true },
|
||||||
{ name: "year", label: "Year", type: "number", required: true },
|
{ name: "year", label: "Year", type: "number", required: true },
|
||||||
{ name: "fuelType", label: "Fuel Type", type: "select", required: true, options: FUEL_TYPE_OPTIONS },
|
{ 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: "capacity",
|
||||||
{ name: "estimatedDistanceKm", label: "Estimated Distance (KM)", type: "number" },
|
label: "Capacity (tons)",
|
||||||
{ name: "actualDistanceKm", label: "Actual Distance (KM)", type: "number" },
|
type: "number",
|
||||||
{ name: "pricePerKm", label: "Price per KM", type: "number" },
|
required: true,
|
||||||
{ name: "currency", label: "Currency", type: "radio", options: CURRENCY_OPTIONS },
|
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: "status", label: "Status", type: "select", required: true, options: VEHICLE_STATUS_OPTIONS },
|
||||||
{ name: "availability", label: "Availability", type: "select", required: true, options: VEHICLE_AVAILABILITY_OPTIONS },
|
{ name: "availability", label: "Availability", type: "select", required: true, options: VEHICLE_AVAILABILITY_OPTIONS },
|
||||||
{ name: "description", label: "Description", type: "textarea" },
|
{ name: "description", label: "Description", type: "textarea" },
|
||||||
@@ -108,19 +148,15 @@ export const vehiclesConfig: FleetResourceConfig = {
|
|||||||
emptyValues: {
|
emptyValues: {
|
||||||
code: "03-ET",
|
code: "03-ET",
|
||||||
plateNumber: "",
|
plateNumber: "",
|
||||||
powerPlateNo: "",
|
|
||||||
trailerPlateNo: "",
|
trailerPlateNo: "",
|
||||||
vehicleType: "TRUCK",
|
truckTypeId: "",
|
||||||
|
vin: "",
|
||||||
|
ownership: "OWNED",
|
||||||
manufacturer: "",
|
manufacturer: "",
|
||||||
model: "",
|
model: "",
|
||||||
year: new Date().getFullYear(),
|
year: new Date().getFullYear(),
|
||||||
fuelType: "DIESEL",
|
fuelType: "DIESEL",
|
||||||
capacity: 0,
|
capacity: 0,
|
||||||
locationId: null,
|
|
||||||
estimatedDistanceKm: "",
|
|
||||||
actualDistanceKm: "",
|
|
||||||
pricePerKm: "",
|
|
||||||
currency: "ETB",
|
|
||||||
status: "ACTIVE",
|
status: "ACTIVE",
|
||||||
availability: "FREE",
|
availability: "FREE",
|
||||||
description: "",
|
description: "",
|
||||||
|
|||||||
@@ -229,6 +229,21 @@ const cargoDesc = (r: FirstMileRecord) => {
|
|||||||
if (r.booking?.cargoTotalWeightVgm) parts.push(`${r.booking.cargoTotalWeightVgm} t`);
|
if (r.booking?.cargoTotalWeightVgm) parts.push(`${r.booking.cargoTotalWeightVgm} t`);
|
||||||
return parts.join(" · ") || "—";
|
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)
|
// First-mile destination is the origin yard (pickup → origin yard)
|
||||||
const destinationYardName = (r: FirstMileRecord) =>
|
const destinationYardName = (r: FirstMileRecord) =>
|
||||||
r.booking?.originYard?.label ?? "—";
|
r.booking?.originYard?.label ?? "—";
|
||||||
@@ -277,7 +292,9 @@ const BookingInfo = ({ record }: { record: FirstMileRecord }) => {
|
|||||||
<InfoRow label="Service type" value={serviceTypeName(record)} />
|
<InfoRow label="Service type" value={serviceTypeName(record)} />
|
||||||
{hasPickupAddress && <InfoRow label="Pickup location" value={pickupLocation(record)} />}
|
{hasPickupAddress && <InfoRow label="Pickup location" value={pickupLocation(record)} />}
|
||||||
<InfoRow label="Destination (origin yard)" value={destinationYardName(record)} />
|
<InfoRow label="Destination (origin yard)" value={destinationYardName(record)} />
|
||||||
|
<InfoRow label="Cargo Type" value={cargoTypeName(record)} />
|
||||||
<InfoRow label="Cargo" value={cargoDesc(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="Advanced Payment" value={formatPrice(record.advancedPayment, currencyOf(record))} />
|
||||||
<InfoRow label="Post Payment" value={formatPrice(record.remainingPayment, currencyOf(record))} />
|
<InfoRow label="Post Payment" value={formatPrice(record.remainingPayment, currencyOf(record))} />
|
||||||
<InfoRow label="Contact" value={contactPersonName(record)} />
|
<InfoRow label="Contact" value={contactPersonName(record)} />
|
||||||
@@ -496,10 +513,11 @@ const FirstMilePage = () => {
|
|||||||
const [tripSlipVehicleId, setTripSlipVehicleId] = useState<string | null>(null);
|
const [tripSlipVehicleId, setTripSlipVehicleId] = useState<string | null>(null);
|
||||||
const [tripSlipSelectOpen, setTripSlipSelectOpen] = useState(false);
|
const [tripSlipSelectOpen, setTripSlipSelectOpen] = useState(false);
|
||||||
const [activeId, setActiveId] = useState<string | null>(null);
|
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<
|
const [vehicleRows, setVehicleRows] = useState<
|
||||||
Array<{ vehicleId: string | null; containerNumber: string }>
|
Array<{ vehicleId: string | null; containerNumber: string; tons: number | ""; quantity: number | "" }>
|
||||||
>([{ vehicleId: null, containerNumber: "" }]);
|
>([{ vehicleId: null, containerNumber: "", tons: "", quantity: "" }]);
|
||||||
|
|
||||||
const [acceptOpen, setAcceptOpen] = useState(false);
|
const [acceptOpen, setAcceptOpen] = useState(false);
|
||||||
const [acceptStep, setAcceptStep] = useState<1 | 2>(1);
|
const [acceptStep, setAcceptStep] = useState<1 | 2>(1);
|
||||||
@@ -925,13 +943,19 @@ const FirstMilePage = () => {
|
|||||||
? rec.vehicleAssignments.map((a, i) => ({
|
? rec.vehicleAssignments.map((a, i) => ({
|
||||||
vehicleId: a.vehicleId,
|
vehicleId: a.vehicleId,
|
||||||
containerNumber: a.containerNumber ?? nums[i] ?? "",
|
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
|
: rec?.vehicleId
|
||||||
? [{ vehicleId: rec.vehicleId, containerNumber: nums[0] ?? "" }]
|
? [{ vehicleId: rec.vehicleId, containerNumber: nums[0] ?? "", tons: "" as const, quantity: "" as const }]
|
||||||
: [{ vehicleId: null, containerNumber: nums[0] ?? "" }];
|
: [{ vehicleId: null, containerNumber: nums[0] ?? "", tons: "" as const, quantity: "" as const }];
|
||||||
setBulkMode(false);
|
setBulkMode(false);
|
||||||
setActiveId(resolved);
|
setActiveId(resolved);
|
||||||
setVehicleRows(rows.length ? rows : [{ vehicleId: null, containerNumber: nums[0] ?? "" }]);
|
setVehicleRows(
|
||||||
|
rows.length
|
||||||
|
? rows
|
||||||
|
: [{ vehicleId: null, containerNumber: nums[0] ?? "", tons: "", quantity: "" }],
|
||||||
|
);
|
||||||
setAssignOpen(true);
|
setAssignOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -944,7 +968,7 @@ const FirstMilePage = () => {
|
|||||||
}
|
}
|
||||||
setBulkMode(true);
|
setBulkMode(true);
|
||||||
setActiveId(null);
|
setActiveId(null);
|
||||||
setVehicleRows([{ vehicleId: null, containerNumber: "" }]);
|
setVehicleRows([{ vehicleId: null, containerNumber: "", tons: "", quantity: "" }]);
|
||||||
setAssignOpen(true);
|
setAssignOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -952,15 +976,41 @@ const FirstMilePage = () => {
|
|||||||
setAssignOpen(false);
|
setAssignOpen(false);
|
||||||
setBulkMode(false);
|
setBulkMode(false);
|
||||||
setActiveId(null);
|
setActiveId(null);
|
||||||
setVehicleRows([{ vehicleId: null, containerNumber: "" }]);
|
setVehicleRows([{ vehicleId: null, containerNumber: "", tons: "", quantity: "" }]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAssign = () => {
|
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 seen = new Set<string>();
|
||||||
const vehicles = vehicleRows
|
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)))
|
.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 count = vehicles.length;
|
||||||
const targetIds = bulkMode
|
const targetIds = bulkMode
|
||||||
? selectedIds
|
? selectedIds
|
||||||
@@ -1432,30 +1482,63 @@ const FirstMilePage = () => {
|
|||||||
clearable
|
clearable
|
||||||
disabled={assignVehicleOptions.length === 0}
|
disabled={assignVehicleOptions.length === 0}
|
||||||
/>
|
/>
|
||||||
<Select
|
{!bulkMode && activeRecord && isBulkBooking(activeRecord) ? (
|
||||||
style={{ flex: 1 }}
|
<>
|
||||||
label={i === 0 ? "Container no." : undefined}
|
<NumberInput
|
||||||
placeholder={containerOptions.length ? "Select container" : "No container numbers"}
|
style={{ flex: 0.8 }}
|
||||||
data={[
|
label={i === 0 ? "Tonnes" : undefined}
|
||||||
...containerOptions.filter(
|
placeholder="t"
|
||||||
(n) =>
|
min={0}
|
||||||
n === row.containerNumber ||
|
value={row.tons}
|
||||||
!vehicleRows.some((r, idx) => idx !== i && r.containerNumber === n),
|
onChange={(v) =>
|
||||||
),
|
setVehicleRows((prev) =>
|
||||||
// keep a manual/legacy value selectable even if not in the booking
|
prev.map((x, idx) =>
|
||||||
...(row.containerNumber && !containerOptions.includes(row.containerNumber)
|
idx === i ? { ...x, tons: v === "" ? "" : Number(v) } : x,
|
||||||
? [row.containerNumber]
|
),
|
||||||
: []),
|
)
|
||||||
]}
|
}
|
||||||
value={row.containerNumber || null}
|
/>
|
||||||
onChange={(value) =>
|
<NumberInput
|
||||||
setVehicleRows((prev) =>
|
style={{ flex: 0.8 }}
|
||||||
prev.map((x, idx) => (idx === i ? { ...x, containerNumber: value ?? "" } : x)),
|
label={i === 0 ? "Items qty (pcs)" : undefined}
|
||||||
)
|
placeholder="optional"
|
||||||
}
|
min={0}
|
||||||
searchable
|
value={row.quantity}
|
||||||
clearable
|
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 && (
|
{vehicleRows.length > 1 && (
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
@@ -1479,6 +1562,8 @@ const FirstMilePage = () => {
|
|||||||
vehicleId: null,
|
vehicleId: null,
|
||||||
containerNumber:
|
containerNumber:
|
||||||
(activeRecord ? bookingContainerNumbers(activeRecord)[prev.length] : "") ?? "",
|
(activeRecord ? bookingContainerNumbers(activeRecord)[prev.length] : "") ?? "",
|
||||||
|
tons: "" as const,
|
||||||
|
quantity: "" as const,
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
@@ -1491,6 +1576,22 @@ const FirstMilePage = () => {
|
|||||||
>
|
>
|
||||||
Add vehicle
|
Add vehicle
|
||||||
</Button>
|
</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>
|
</Stack>
|
||||||
<Group justify="flex-end" gap="sm">
|
<Group justify="flex-end" gap="sm">
|
||||||
<Button variant="default" onClick={closeAssign}>Cancel</Button>
|
<Button variant="default" onClick={closeAssign}>Cancel</Button>
|
||||||
|
|||||||
@@ -409,6 +409,43 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
|||||||
{ name: "isActive", label: "Active", type: "boolean" },
|
{ 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",
|
slug: "yard-distances",
|
||||||
label: "Yard Distances",
|
label: "Yard Distances",
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ import {
|
|||||||
FEE_RULE_BASIS_LABELS,
|
FEE_RULE_BASIS_LABELS,
|
||||||
FEE_RULE_TYPES,
|
FEE_RULE_TYPES,
|
||||||
FEE_RULE_TYPE_LABELS,
|
FEE_RULE_TYPE_LABELS,
|
||||||
VEHICLE_TYPES,
|
|
||||||
type AllocationRule,
|
type AllocationRule,
|
||||||
type FeeRule,
|
type FeeRule,
|
||||||
type FeeRuleBasis,
|
type FeeRuleBasis,
|
||||||
@@ -393,6 +392,12 @@ function FeeRules() {
|
|||||||
const { data: containerTypes = [], isLoading: containerTypesLoading } = useQuery(
|
const { data: containerTypes = [], isLoading: containerTypesLoading } = useQuery(
|
||||||
api.containerTypes.list.queryOptions({ staleTime: Infinity }),
|
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 create = useCreateFeeRule();
|
||||||
const update = useUpdateFeeRule();
|
const update = useUpdateFeeRule();
|
||||||
const remove = useDeleteFeeRule();
|
const remove = useDeleteFeeRule();
|
||||||
@@ -712,8 +717,9 @@ function FeeRules() {
|
|||||||
{isTruckDetention && (
|
{isTruckDetention && (
|
||||||
<Select
|
<Select
|
||||||
label="Truck type"
|
label="Truck type"
|
||||||
placeholder="Any truck type"
|
placeholder={truckTypesLoading ? 'Loading truck types...' : 'Any truck type'}
|
||||||
data={VEHICLE_TYPES.map((v) => ({ value: v, label: v.charAt(0) + v.slice(1).toLowerCase() }))}
|
data={truckTypes.map((t) => ({ value: t.code, label: `${t.name} (${t.code})` }))}
|
||||||
|
disabled={truckTypesLoading}
|
||||||
value={form.vehicleType || null}
|
value={form.vehicleType || null}
|
||||||
onChange={(value) => setForm((f) => ({ ...f, vehicleType: selectValue(value) }))}
|
onChange={(value) => setForm((f) => ({ ...f, vehicleType: selectValue(value) }))}
|
||||||
clearable
|
clearable
|
||||||
|
|||||||
@@ -195,6 +195,7 @@ import {
|
|||||||
type UsedTrainNumbers,
|
type UsedTrainNumbers,
|
||||||
} from "./trainBuilder.service";
|
} from "./trainBuilder.service";
|
||||||
import { trainSchedulingService } from "./trainScheduling.service";
|
import { trainSchedulingService } from "./trainScheduling.service";
|
||||||
|
import { truckTypesService, type TruckType } from "./truck-types.service";
|
||||||
import { wagonTypesService, type WagonType } from "./wagon-types.service";
|
import { wagonTypesService, type WagonType } from "./wagon-types.service";
|
||||||
import {
|
import {
|
||||||
wagonService,
|
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: {
|
wagonTypes: {
|
||||||
list: endpoint<void, WagonType[]>("wagon-types", "list", () =>
|
list: endpoint<void, WagonType[]>("wagon-types", "list", () =>
|
||||||
wagonTypesService.getWagonTypes(),
|
wagonTypesService.getWagonTypes(),
|
||||||
|
|||||||
@@ -22,7 +22,10 @@ export interface FirstMileBooking {
|
|||||||
serviceType?: { id: string; label?: string } | null;
|
serviceType?: { id: string; label?: string } | null;
|
||||||
originYard?: { id: string; label?: string } | null;
|
originYard?: { id: string; label?: string } | null;
|
||||||
destinationYard?: { 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. */
|
/** Container lines — total container count drives how many trucks are needed. */
|
||||||
bookingContainers?: Array<{
|
bookingContainers?: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
@@ -68,6 +71,8 @@ export interface FirstMileRecord {
|
|||||||
vehicleId: string;
|
vehicleId: string;
|
||||||
containerNumber?: string | null;
|
containerNumber?: string | null;
|
||||||
distanceKm?: number | null;
|
distanceKm?: number | null;
|
||||||
|
tons?: number | null;
|
||||||
|
quantity?: number | null;
|
||||||
vehicle?: FirstMileVehicle | null;
|
vehicle?: FirstMileVehicle | null;
|
||||||
}>;
|
}>;
|
||||||
/** Present only when an invoice has actually been generated (not on distance). */
|
/** 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)),
|
api.delete<void>(FM.BY_ID(id)),
|
||||||
setVehicles: (
|
setVehicles: (
|
||||||
id: string,
|
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 }),
|
) => api.post<FirstMileRecord>(`${FM.BASE}/${id}/vehicles`, { vehicles }),
|
||||||
setDistances: (
|
setDistances: (
|
||||||
id: string,
|
id: string,
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ const RESOURCE_BASE: Record<RuleEngineResourceSlug, string> = {
|
|||||||
"cargo-types": URL_CONSTANTS.RULE_ENGINE.CARGO_TYPES,
|
"cargo-types": URL_CONSTANTS.RULE_ENGINE.CARGO_TYPES,
|
||||||
"container-types": URL_CONSTANTS.RULE_ENGINE.CONTAINER_TYPES,
|
"container-types": URL_CONSTANTS.RULE_ENGINE.CONTAINER_TYPES,
|
||||||
"wagon-types": URL_CONSTANTS.RULE_ENGINE.WAGON_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,
|
"priority-configs": URL_CONSTANTS.RULE_ENGINE.PRIORITY_CONFIGS,
|
||||||
"service-types": URL_CONSTANTS.RULE_ENGINE.SERVICE_TYPES,
|
"service-types": URL_CONSTANTS.RULE_ENGINE.SERVICE_TYPES,
|
||||||
"weight-limit-rules": URL_CONSTANTS.RULE_ENGINE.WEIGHT_LIMIT_RULES,
|
"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);
|
return URL_CONSTANTS.RULE_ENGINE.CONTAINER_TYPE_BY_ID(id);
|
||||||
case "wagon-types":
|
case "wagon-types":
|
||||||
return URL_CONSTANTS.RULE_ENGINE.WAGON_TYPE_BY_ID(id);
|
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":
|
case "priority-configs":
|
||||||
return URL_CONSTANTS.RULE_ENGINE.PRIORITY_CONFIG_BY_ID(id);
|
return URL_CONSTANTS.RULE_ENGINE.PRIORITY_CONFIG_BY_ID(id);
|
||||||
case "service-types":
|
case "service-types":
|
||||||
|
|||||||
@@ -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}`),
|
||||||
|
};
|
||||||
@@ -20,7 +20,14 @@ export interface Vehicle {
|
|||||||
id: string;
|
id: string;
|
||||||
plateNumber: string;
|
plateNumber: string;
|
||||||
registrationNumber: string;
|
registrationNumber: string;
|
||||||
|
/** Denormalised truck-type code, written server-side. Register with `truckTypeId`. */
|
||||||
vehicleType: VehicleType;
|
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;
|
manufacturer: string;
|
||||||
model: string;
|
model: string;
|
||||||
year: number;
|
year: number;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export type RuleEngineResourceSlug =
|
|||||||
| "cargo-types"
|
| "cargo-types"
|
||||||
| "container-types"
|
| "container-types"
|
||||||
| "wagon-types"
|
| "wagon-types"
|
||||||
|
| "truck-types"
|
||||||
| "priority-configs"
|
| "priority-configs"
|
||||||
| "service-types"
|
| "service-types"
|
||||||
| "weight-limit-rules"
|
| "weight-limit-rules"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
Group,
|
Group,
|
||||||
Loader,
|
Loader,
|
||||||
MultiSelect,
|
MultiSelect,
|
||||||
|
NumberInput,
|
||||||
Select,
|
Select,
|
||||||
SimpleGrid,
|
SimpleGrid,
|
||||||
Stack,
|
Stack,
|
||||||
@@ -79,6 +80,8 @@ export function CustomerTruckAssignmentCard({
|
|||||||
const [driverName, setDriverName] = useState("");
|
const [driverName, setDriverName] = useState("");
|
||||||
const [truckType, setTruckType] = useState("");
|
const [truckType, setTruckType] = useState("");
|
||||||
const [containers, setContainers] = useState<string[]>([]);
|
const [containers, setContainers] = useState<string[]>([]);
|
||||||
|
const [plannedTons, setPlannedTons] = useState<number | "">("");
|
||||||
|
const [plannedQty, setPlannedQty] = useState<number | "">("");
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [bulkModalOpen, setBulkModalOpen] = useState(false);
|
const [bulkModalOpen, setBulkModalOpen] = useState(false);
|
||||||
@@ -105,15 +108,23 @@ export function CustomerTruckAssignmentCard({
|
|||||||
setDriverName("");
|
setDriverName("");
|
||||||
setTruckType("");
|
setTruckType("");
|
||||||
setContainers([]);
|
setContainers([]);
|
||||||
|
setPlannedTons("");
|
||||||
|
setPlannedQty("");
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
setError(null);
|
setError(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const startEdit = (t: Freight.ICustomerTruck) => {
|
const startEdit = (t: Freight.ICustomerTruck) => {
|
||||||
|
const planned = t as Freight.ICustomerTruck & {
|
||||||
|
plannedTons?: number | string | null;
|
||||||
|
plannedQuantity?: number | null;
|
||||||
|
};
|
||||||
setPlateNumber(t.plateNumber ?? "");
|
setPlateNumber(t.plateNumber ?? "");
|
||||||
setDriverName(t.driverName ?? "");
|
setDriverName(t.driverName ?? "");
|
||||||
setTruckType(t.truckType ?? "");
|
setTruckType(t.truckType ?? "");
|
||||||
setContainers((t.containers ?? []).map((c) => c.containerNumber));
|
setContainers((t.containers ?? []).map((c) => c.containerNumber));
|
||||||
|
setPlannedTons(planned.plannedTons != null ? Number(planned.plannedTons) : "");
|
||||||
|
setPlannedQty(planned.plannedQuantity != null ? Number(planned.plannedQuantity) : "");
|
||||||
setEditingId(t.id);
|
setEditingId(t.id);
|
||||||
setError(null);
|
setError(null);
|
||||||
};
|
};
|
||||||
@@ -129,6 +140,12 @@ export function CustomerTruckAssignmentCard({
|
|||||||
driverName: driverName.trim(),
|
driverName: driverName.trim(),
|
||||||
truckType: truckType.trim(),
|
truckType: truckType.trim(),
|
||||||
containerNumbers: isBulk ? [] : containers,
|
containerNumbers: isBulk ? [] : containers,
|
||||||
|
...(isBulk
|
||||||
|
? {
|
||||||
|
plannedTons: plannedTons === "" ? undefined : Number(plannedTons),
|
||||||
|
plannedQuantity: plannedQty === "" ? undefined : Number(plannedQty),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
return editingId
|
return editingId
|
||||||
? customerTrucksService.update(booking.id, editingId, payload)
|
? customerTrucksService.update(booking.id, editingId, payload)
|
||||||
@@ -170,6 +187,10 @@ export function CustomerTruckAssignmentCard({
|
|||||||
setError("Select 1 or 2 container numbers for this truck.");
|
setError("Select 1 or 2 container numbers for this truck.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (isBulk && plannedTons === "") {
|
||||||
|
setError("Enter the tonnes this truck will haul.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
setError(null);
|
setError(null);
|
||||||
addMutation.mutate();
|
addMutation.mutate();
|
||||||
};
|
};
|
||||||
@@ -324,6 +345,40 @@ export function CustomerTruckAssignmentCard({
|
|||||||
nothingFoundMessage="No unassigned containers"
|
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>
|
</SimpleGrid>
|
||||||
<Group justify="flex-end">
|
<Group justify="flex-end">
|
||||||
{editingId && (
|
{editingId && (
|
||||||
|
|||||||
Reference in New Issue
Block a user