mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
changes
This commit is contained in:
11
.gitignore
vendored
11
.gitignore
vendored
@@ -38,3 +38,14 @@ e2e/**/cypress/downloads/
|
||||
|
||||
# e2e launcher state (ports of the running stack)
|
||||
e2e/freight/.e2e-ports.json
|
||||
|
||||
# local run scripts (contain personal DB credentials — never commit)
|
||||
run-passenger-local.sh
|
||||
run-passenger-web.sh
|
||||
|
||||
# generated test output
|
||||
e2e-ui-report/
|
||||
test-results/
|
||||
playwright-report/
|
||||
blob-report/
|
||||
RUNNING_LOCALLY.md
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
ensurePostgresSchemas,
|
||||
APPLICATION_SEARCH_PATH,
|
||||
} from "./config/ensure-postgres-schemas";
|
||||
import { IamModule, DataSeeder } from "@tria-plc/iamapi-common";
|
||||
import { IamModule } from "@tria-plc/iamapi-common";
|
||||
import { SharedAuthModule } from "@tria-plc/api-common/modules/auth/shared-auth.module";
|
||||
|
||||
import appConfig from "./config/app.config";
|
||||
@@ -29,6 +29,7 @@ import { ConsignmentsModule } from "./modules/consignments/consignments.module";
|
||||
|
||||
// import { TrainsModule } from "./modules/trains/trains.module";
|
||||
import { LocomotivesModule } from "./modules/locomotives/locomotives.module";
|
||||
import { TruckTypesModule } from "./modules/truck-types/truck-types.module";
|
||||
import { WagonTypesModule } from "./modules/wagon-types/wagon-types.module";
|
||||
import { TrainSetsModule } from "./modules/train-sets/train-sets.module";
|
||||
import { TrainSchedulesModule } from "./modules/train-schedules/train-schedules.module";
|
||||
@@ -77,8 +78,8 @@ import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-l
|
||||
import { PaidImportExportMileDemoSeeder } from "./seed/paid-import-export-mile-demo.seeder";
|
||||
//New Trains, Wagons, Container and Cargo management modules
|
||||
import { TrainsModule } from "./modules/trains/trains.module";
|
||||
import { VerifaydaModule } from './modules/verifayda/verifayda.module';
|
||||
import { FleetHistoryModule } from './modules/fleet-history/fleet-history.module';
|
||||
import { VerifaydaModule } from "./modules/verifayda/verifayda.module";
|
||||
import { FleetHistoryModule } from "./modules/fleet-history/fleet-history.module";
|
||||
import { WagonsModule } from "./modules/wagons/wagons.module";
|
||||
import { ContainersModule } from "./modules/container-management/containers.module";
|
||||
import { CargoesModule } from "./modules/cargoes/cargoes.module";
|
||||
@@ -104,7 +105,13 @@ import { LoggerMiddleware } from "./logger.middleware";
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
load: [appConfig, databaseConfig, telebirrConfig, rabbitmqConfig, faydaConfig],
|
||||
load: [
|
||||
appConfig,
|
||||
databaseConfig,
|
||||
telebirrConfig,
|
||||
rabbitmqConfig,
|
||||
faydaConfig,
|
||||
],
|
||||
}),
|
||||
ScheduleModule.forRoot(),
|
||||
EventEmitterModule.forRoot(),
|
||||
@@ -152,6 +159,7 @@ import { LoggerMiddleware } from "./logger.middleware";
|
||||
FilesModule,
|
||||
ConsignmentsModule,
|
||||
LocomotivesModule,
|
||||
TruckTypesModule,
|
||||
WagonTypesModule,
|
||||
TrainSetsModule,
|
||||
TrainSchedulesModule,
|
||||
@@ -223,7 +231,7 @@ import { LoggerMiddleware } from "./logger.middleware";
|
||||
})
|
||||
export class AppModule implements OnApplicationBootstrap {
|
||||
constructor(
|
||||
private readonly seeder: DataSeeder,
|
||||
// private readonly seeder: DataSeeder,
|
||||
private readonly edrOrgSeeder: EdrOrgSeeder,
|
||||
private readonly freightPositionsSeeder: FreightPositionsSeeder,
|
||||
private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder,
|
||||
@@ -258,7 +266,7 @@ export class AppModule implements OnApplicationBootstrap {
|
||||
// freightPositionsSeeder → seeds Position + PositionPermission rows
|
||||
// (depends on edrOrgSeeder, must run after)
|
||||
await this.freightPermissionKeyMigrationSeeder.run();
|
||||
await this.seeder.run();
|
||||
// await this.seeder.run();
|
||||
await this.edrOrgSeeder.run();
|
||||
await this.freightPositionsSeeder.run();
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ type MileRecord = {
|
||||
bookingContainers?: Array<{
|
||||
units?: Array<{ vgmTons?: number | string | null }> | null;
|
||||
}> | null;
|
||||
/** Attached here: the train schedule the booking rides, for mile alignment. */
|
||||
trainSchedule?: { trainNumber: string | null; departureDate: string | null } | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
@@ -36,6 +38,38 @@ export async function attachMileFinancials(
|
||||
if (unitTons > 0) b.cargoTotalWeightVgm = Number(unitTons.toFixed(3));
|
||||
}
|
||||
|
||||
// Train alignment: which schedule each booking rides (mile pickups/deliveries
|
||||
// are planned against the train's departure).
|
||||
const bookingIds = [...new Set(records.map((r) => r.bookingId).filter(Boolean))] as string[];
|
||||
if (bookingIds.length) {
|
||||
const schedules: Array<{
|
||||
bookingId: string;
|
||||
trainNumber: string | null;
|
||||
departureDate: string | null;
|
||||
}> = await dataSource.query(
|
||||
`SELECT DISTINCT ON (tsb.booking_id)
|
||||
tsb.booking_id AS "bookingId",
|
||||
ts.train_number AS "trainNumber",
|
||||
COALESCE(ts.actual_departure_at, ts.scheduled_departure_date)::text AS "departureDate"
|
||||
FROM freight.train_schedule_bookings tsb
|
||||
JOIN freight.train_schedules ts
|
||||
ON ts.id = tsb.train_schedule_id AND ts.deleted_at IS NULL
|
||||
WHERE tsb.booking_id = ANY($1::uuid[]) AND tsb.deleted_at IS NULL
|
||||
ORDER BY tsb.booking_id, tsb.created_at DESC`,
|
||||
[bookingIds],
|
||||
);
|
||||
const byBookingSchedule = new Map(schedules.map((s) => [s.bookingId, s]));
|
||||
for (const r of records) {
|
||||
const s = r.bookingId ? byBookingSchedule.get(r.bookingId) : undefined;
|
||||
if (r.booking && s) {
|
||||
r.booking.trainSchedule = {
|
||||
trainNumber: s.trainNumber,
|
||||
departureDate: s.departureDate,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const needAdvance = records.filter(
|
||||
(r) => r.bookingId && !(Number(r.advancedPayment) > 0),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Acquisitions describe WHAT was acquired (vehicle, parts, equipment…) — the
|
||||
* vehicle link is optional and only for acquisitions that ARE a fleet vehicle.
|
||||
*/
|
||||
export class AddAcquisitionItemName2470000000000 implements MigrationInterface {
|
||||
name = 'AddAcquisitionItemName2470000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.asset_acquisitions
|
||||
ADD COLUMN IF NOT EXISTS item_name varchar(200)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.asset_acquisitions
|
||||
DROP COLUMN IF EXISTS item_name
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Dedup stamp for the km/date-due maintenance alert — without it the daily
|
||||
* cron would re-notify every day a schedule stays due.
|
||||
*/
|
||||
export class AddMaintenanceDueNotifiedAt2480000000000 implements MigrationInterface {
|
||||
name = 'AddMaintenanceDueNotifiedAt2480000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.maintenance_schedules
|
||||
ADD COLUMN IF NOT EXISTS due_notified_at timestamptz NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.maintenance_schedules DROP COLUMN IF EXISTS due_notified_at
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* KM-based maintenance scheduling: per-vehicle service intervals (by km
|
||||
* and/or days) driving the maintenance due engine. Raw schema-qualified SQL —
|
||||
* the builder API resolved bare table names against the default schema and
|
||||
* failed on boot ("Table maintenance_intervals does not exist").
|
||||
*/
|
||||
export class AddMaintenanceIntervals2800000000000 implements MigrationInterface {
|
||||
name = 'AddMaintenanceIntervals2800000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.maintenance_intervals (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
vehicle_id uuid NOT NULL REFERENCES freight.vehicles(id) ON DELETE CASCADE,
|
||||
maintenance_type varchar NOT NULL,
|
||||
interval_km numeric(14,2),
|
||||
interval_days integer,
|
||||
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 INDEX IF NOT EXISTS "IDX_maintenance_intervals_vehicle_type"
|
||||
ON freight.maintenance_intervals (vehicle_id, maintenance_type);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_maintenance_intervals_vehicle_type"
|
||||
ON freight.maintenance_intervals (vehicle_id, maintenance_type);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.maintenance_intervals;`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Persist the signer's saved-signature image on the handover record, so the
|
||||
* signed handover document can render the actual signature (not just the
|
||||
* typed name) — parity with the booking-contract signing flow.
|
||||
*/
|
||||
export class AddSignatureToHandover2800000000001 implements MigrationInterface {
|
||||
name = 'AddSignatureToHandover2800000000001';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.booking_handovers ADD COLUMN IF NOT EXISTS signature_image_url text;`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.booking_handovers DROP COLUMN IF EXISTS signature_image_url;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Named service items for KM-based maintenance ("oil change", "tires", …).
|
||||
* The coarse maintenance_type enum (PREVENTIVE/…) allowed only one interval
|
||||
* per type per vehicle, so oil and tire intervals could not coexist. Interval
|
||||
* identity becomes (vehicle, maintenance_type, service_item); schedules carry
|
||||
* the item so completion re-finds the right interval for auto-scheduling.
|
||||
*/
|
||||
export class AddMaintenanceServiceItem2810000000000 implements MigrationInterface {
|
||||
name = 'AddMaintenanceServiceItem2810000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.maintenance_intervals ADD COLUMN IF NOT EXISTS service_item varchar(120);`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.maintenance_schedules ADD COLUMN IF NOT EXISTS service_item varchar(120);`,
|
||||
);
|
||||
// Re-key interval uniqueness on (vehicle, type, item). COALESCE folds the
|
||||
// item-less legacy rows into one slot; soft-deleted rows are ignored.
|
||||
await queryRunner.query(
|
||||
`DROP INDEX IF EXISTS freight."UQ_maintenance_intervals_vehicle_type";`,
|
||||
);
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_maintenance_intervals_vehicle_type_item"
|
||||
ON freight.maintenance_intervals (vehicle_id, maintenance_type, COALESCE(service_item, ''))
|
||||
WHERE deleted_at IS NULL;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DROP INDEX IF EXISTS freight."UQ_maintenance_intervals_vehicle_type_item";`,
|
||||
);
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_maintenance_intervals_vehicle_type"
|
||||
ON freight.maintenance_intervals (vehicle_id, maintenance_type);
|
||||
`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.maintenance_schedules DROP COLUMN IF EXISTS service_item;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.maintenance_intervals DROP COLUMN IF EXISTS service_item;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsBoolean, IsEmail, IsObject, IsOptional, IsString, MinLength } from "class-validator";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsBoolean, IsEmail, IsOptional, IsString, MinLength, ValidateNested } from "class-validator";
|
||||
|
||||
class CreateOrganizationUserNameDto {
|
||||
@ApiProperty()
|
||||
@@ -29,7 +30,8 @@ export class CreateOrganizationUserDto {
|
||||
phoneNumber?: string;
|
||||
|
||||
@ApiProperty({ type: CreateOrganizationUserNameDto })
|
||||
@IsObject()
|
||||
@ValidateNested()
|
||||
@Type(() => CreateOrganizationUserNameDto)
|
||||
name!: CreateOrganizationUserNameDto;
|
||||
|
||||
@ApiProperty({ required: false, default: false })
|
||||
|
||||
@@ -252,8 +252,11 @@ export class BookingsController {
|
||||
return this.bookingsService.findAll(filter, companyId);
|
||||
}
|
||||
|
||||
// Powers the customer-detail bookings tab, so `customers:view` reaches it too
|
||||
// — otherwise a staffer granted only the customer permission gets a page whose
|
||||
// tabs 403 individually.
|
||||
@Get("by-company/:companyId/customer-view")
|
||||
@BookingView()
|
||||
@BookingStaff([FREIGHT_PERMS.customers.view, FREIGHT_PERMS.bookings.view])
|
||||
@ApiOperation({
|
||||
summary: "List bookings for a company (customer-view shape, backoffice)",
|
||||
})
|
||||
@@ -436,18 +439,26 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Get(':id/customer-truck-assignment/freight-order')
|
||||
@ApiOperation({ summary: 'Download duplicate freight order copies for customer truck assignment' })
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Download freight order copies. The 2 gate copies always print; ?copies=1,2,8 adds waybill-style copies (catalog indexes 1-8).',
|
||||
})
|
||||
async customerTruckFreightOrder(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
@Res() res: Response,
|
||||
@Query('copies') copies?: string,
|
||||
) {
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
|
||||
}
|
||||
const extraCopyIndexes = (copies ?? '')
|
||||
.split(',')
|
||||
.map((n) => Number(n.trim()))
|
||||
.filter((n) => Number.isInteger(n) && n >= 1 && n <= 8);
|
||||
const { filename, buffer } =
|
||||
await this.bookingsService.customerTruckFreightOrderCopies(id);
|
||||
await this.bookingsService.customerTruckFreightOrderCopies(id, extraCopyIndexes);
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
res.send(buffer);
|
||||
@@ -480,6 +491,20 @@ export class BookingsController {
|
||||
return this.customerTruckService.addTruck(id, dto);
|
||||
}
|
||||
|
||||
@Post(':id/customer-trucks/bulk')
|
||||
@ApiOperation({ summary: 'Bulk add customer trucks from array payload (Excel parsed)' })
|
||||
async bulkAddCustomerTrucks(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() payload: { trucks: AddCustomerTruckDto[] },
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
|
||||
}
|
||||
return this.customerTruckService.addBulkTrucks(id, payload.trucks);
|
||||
}
|
||||
|
||||
@Patch(':id/customer-trucks/:assignmentId')
|
||||
@ApiOperation({ summary: 'Edit a not-yet-arrived customer truck (plate/driver/type + containers)' })
|
||||
async updateCustomerTruck(
|
||||
|
||||
@@ -142,8 +142,21 @@ export class BookingsService {
|
||||
return this.findById(bookingId);
|
||||
}
|
||||
|
||||
/** Selectable freight-order copies (rail-waybill style). Indexes 1-8. */
|
||||
static readonly FREIGHT_ORDER_EXTRA_COPIES = [
|
||||
'Original 1 (for Issuing Carrier)',
|
||||
'Original 2 (for Consignee)',
|
||||
'Original 3 (for Shipper)',
|
||||
'Copy 4 (Delivery Receipt)',
|
||||
'Copy 5 (Extra Copy)',
|
||||
'Copy 6 (Extra Copy)',
|
||||
'Copy 7 (Extra Copy)',
|
||||
'Copy 8 (for Agent)',
|
||||
] as const;
|
||||
|
||||
async customerTruckFreightOrderCopies(
|
||||
bookingId: string,
|
||||
extraCopyIndexes: number[] = [],
|
||||
): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const booking = await this.findById(bookingId);
|
||||
if (!booking.customerTruckAssignedAt) {
|
||||
@@ -171,7 +184,12 @@ export class BookingsService {
|
||||
[bookingId],
|
||||
);
|
||||
|
||||
const html = this.buildCustomerTruckFreightOrderHtml(booking, trucks);
|
||||
// The 2 gate copies are ALWAYS printed; the waybill-style copies are
|
||||
// whatever the customer ticked (indexes into the fixed catalog).
|
||||
const extraCopies = [...new Set(extraCopyIndexes)]
|
||||
.map((i) => BookingsService.FREIGHT_ORDER_EXTRA_COPIES[i - 1])
|
||||
.filter(Boolean);
|
||||
const html = this.buildCustomerTruckFreightOrderHtml(booking, trucks, extraCopies);
|
||||
// Chromium when available; otherwise the styled tabular fallback (never the
|
||||
// generic text dump — the freight order is an outward-facing gate document).
|
||||
const buffer = await this.pdfRender.htmlToPdfBuffer(html, {
|
||||
@@ -268,6 +286,7 @@ export class BookingsService {
|
||||
arrivedAt: string | null;
|
||||
containers: string | null;
|
||||
}>,
|
||||
extraCopies: string[] = [],
|
||||
): string {
|
||||
const esc = (v: unknown) => this.escapeHtml(String(v ?? '-'));
|
||||
const assignedAt = booking.customerTruckAssignedAt
|
||||
@@ -386,6 +405,7 @@ export class BookingsService {
|
||||
<body>
|
||||
${copy('Copy 1: Port Operations Copy')}
|
||||
${copy('Copy 2: Gate Security & Carrier Copy')}
|
||||
${extraCopies.map((label) => copy(label)).join('')}
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
@@ -85,6 +85,24 @@ export class CustomerTruckService {
|
||||
if (isBulk) {
|
||||
const { totalTons, remainingTons } = await remainingBulkTons(this.dataSource, bookingId);
|
||||
assertBulkTonnageRemains(totalTons, remainingTons);
|
||||
|
||||
// Assignment-time drawdown: planned tonnage across live trucks (weighed
|
||||
// net once departed, planned before) may not exceed the declared total.
|
||||
if (totalTons > 0) {
|
||||
const [p]: Array<{ planned: string | null }> = await this.dataSource.query(
|
||||
`SELECT SUM(COALESCE(a.net_weight_tons, a.planned_tons, 0)) AS planned
|
||||
FROM freight.customer_truck_assignments a
|
||||
WHERE a.booking_id = $1 AND a.deleted_at IS NULL`,
|
||||
[bookingId],
|
||||
);
|
||||
const alreadyPlanned = Number(p?.planned ?? 0);
|
||||
const requestedTons = Number(dto.plannedTons ?? 0);
|
||||
if (requestedTons > 0 && alreadyPlanned + requestedTons > totalTons + 0.001) {
|
||||
throw new BadRequestException(
|
||||
`Planned tonnage exceeds the booking: ${alreadyPlanned} t already assigned of ${totalTons} t — at most ${Math.max(0, totalTons - alreadyPlanned)} t left for this truck`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (requested.length) {
|
||||
@@ -108,6 +126,8 @@ export class CustomerTruckService {
|
||||
plateNumber: dto.truckPlateNumber.trim().toUpperCase(),
|
||||
driverName: dto.driverName.trim(),
|
||||
truckType: dto.truckType.trim(),
|
||||
plannedTons: isBulk ? (dto.plannedTons ?? null) : null,
|
||||
plannedQuantity: isBulk ? (dto.plannedQuantity ?? null) : null,
|
||||
}),
|
||||
);
|
||||
await manager.getRepository(CustomerTruckContainer).save(
|
||||
@@ -186,23 +206,52 @@ export class CustomerTruckService {
|
||||
throw new ConflictException('Cannot edit a truck that has already arrived');
|
||||
}
|
||||
|
||||
const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
|
||||
if (requested.length < 1) {
|
||||
// Bulk trucks carry loose tonnage, not containers — planned tonnage is
|
||||
// editable instead, capped by what the other trucks haven't claimed.
|
||||
const isBulk = booking.freightType === 'BULK';
|
||||
const requested = isBulk
|
||||
? []
|
||||
: (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
|
||||
if (!isBulk && requested.length < 1) {
|
||||
throw new BadRequestException('Select at least one container for this truck');
|
||||
}
|
||||
assertTruckLoad({
|
||||
containers: requested,
|
||||
bookingContainers: await this.bookingContainerNumbers(bookingId),
|
||||
sizes: await bookingContainerSizes(this.dataSource, bookingId, requested),
|
||||
// Exclude THIS truck's own containers so re-saving the same set is allowed.
|
||||
assignedElsewhere: await this.assignedContainerNumbersExcept(bookingId, assignmentId),
|
||||
});
|
||||
if (!isBulk) {
|
||||
assertTruckLoad({
|
||||
containers: requested,
|
||||
bookingContainers: await this.bookingContainerNumbers(bookingId),
|
||||
sizes: await bookingContainerSizes(this.dataSource, bookingId, requested),
|
||||
// Exclude THIS truck's own containers so re-saving the same set is allowed.
|
||||
assignedElsewhere: await this.assignedContainerNumbersExcept(bookingId, assignmentId),
|
||||
});
|
||||
} else if (dto.plannedTons != null) {
|
||||
const { totalTons } = await remainingBulkTons(this.dataSource, bookingId);
|
||||
if (totalTons > 0) {
|
||||
const [p]: Array<{ planned: string | null }> = await this.dataSource.query(
|
||||
`SELECT SUM(COALESCE(a.net_weight_tons, a.planned_tons, 0)) AS planned
|
||||
FROM freight.customer_truck_assignments a
|
||||
WHERE a.booking_id = $1 AND a.deleted_at IS NULL AND a.id <> $2`,
|
||||
[bookingId, assignmentId],
|
||||
);
|
||||
const others = Number(p?.planned ?? 0);
|
||||
if (others + Number(dto.plannedTons) > totalTons + 0.001) {
|
||||
throw new BadRequestException(
|
||||
`Planned tonnage exceeds the booking: ${others} t on other trucks of ${totalTons} t — at most ${Math.max(0, totalTons - others)} t left for this truck`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.getRepository(CustomerTruckAssignment).update(assignmentId, {
|
||||
plateNumber: dto.truckPlateNumber.trim().toUpperCase(),
|
||||
driverName: dto.driverName.trim(),
|
||||
truckType: dto.truckType.trim(),
|
||||
...(isBulk
|
||||
? {
|
||||
plannedTons: dto.plannedTons ?? null,
|
||||
plannedQuantity: dto.plannedQuantity ?? null,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId });
|
||||
await manager.getRepository(CustomerTruckContainer).save(
|
||||
@@ -576,4 +625,35 @@ export class CustomerTruckService {
|
||||
}
|
||||
|
||||
/** Contract container sizes (e.g. "20ft" / "40ft") for the given container numbers. */
|
||||
|
||||
async addBulkTrucks(
|
||||
bookingId: string,
|
||||
dtos: AddCustomerTruckDto[],
|
||||
): Promise<{
|
||||
success: number;
|
||||
failed: number;
|
||||
errors: Array<{ row: number; truck: string; reason: string }>;
|
||||
}> {
|
||||
const errors: Array<{ row: number; truck: string; reason: string }> = [];
|
||||
let successCount = 0;
|
||||
|
||||
for (let i = 0; i < dtos.length; i++) {
|
||||
try {
|
||||
await this.addTruck(bookingId, dtos[i]);
|
||||
successCount++;
|
||||
} catch (err: any) {
|
||||
errors.push({
|
||||
row: i + 2, // Row 1 is header
|
||||
truck: dtos[i].truckPlateNumber,
|
||||
reason: err.message || 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: successCount,
|
||||
failed: errors.length,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,12 @@ import {
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsNotEmpty,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
MaxLength,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
import { CUSTOMER_TRUCK_TYPES } from './customer-truck-assignment.dto';
|
||||
@@ -44,4 +46,16 @@ export class AddCustomerTruckDto {
|
||||
message: 'each container number must match ISO container format, e.g. ABCD1234567',
|
||||
})
|
||||
containerNumbers?: string[];
|
||||
|
||||
/** Bulk: planned tonnage this truck hauls — draws down the booking total at assignment. */
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
plannedTons?: number;
|
||||
|
||||
/** Bulk: optional item/piece count on this truck. */
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
plannedQuantity?: number;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { IsString, IsNotEmpty, IsIn, IsArray, ArrayMaxSize, ArrayUnique, Matches, IsOptional } from 'class-validator';
|
||||
import { CUSTOMER_TRUCK_TYPES } from './customer-truck-assignment.dto';
|
||||
|
||||
export class BulkCustomerTruckRow {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
truckPlateNumber!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
driverName!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@IsIn(CUSTOMER_TRUCK_TYPES)
|
||||
truckType!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(2)
|
||||
@ArrayUnique()
|
||||
@Matches(/^[A-Z]{4}\d{7}$/, {
|
||||
each: true,
|
||||
message: 'each container must be ISO format (e.g. ABCD1234567)',
|
||||
})
|
||||
containerNumbers?: (string | null)[];
|
||||
}
|
||||
|
||||
export class BulkCustomerTrucksDto {
|
||||
@IsArray()
|
||||
@ArrayMaxSize(100)
|
||||
trucks!: BulkCustomerTruckRow[];
|
||||
}
|
||||
|
||||
export interface BulkTruckUploadResult {
|
||||
success: number;
|
||||
failed: number;
|
||||
errors: Array<{
|
||||
row: number;
|
||||
truck: string;
|
||||
reason: string;
|
||||
}>;
|
||||
created: Array<{
|
||||
truckPlateNumber: string;
|
||||
driverName: string;
|
||||
containers: number;
|
||||
}>;
|
||||
}
|
||||
@@ -51,6 +51,14 @@ export class CustomerTruckAssignment extends BaseEntity {
|
||||
@Column({ name: 'net_weight_tons', type: 'numeric', precision: 14, scale: 3, nullable: true })
|
||||
netWeightTons?: number | null;
|
||||
|
||||
/** Bulk: planned tonnage at assignment — draws down the booking before weigh-out. */
|
||||
@Column({ name: 'planned_tons', type: 'numeric', precision: 14, scale: 3, nullable: true })
|
||||
plannedTons?: number | null;
|
||||
|
||||
/** Bulk: optional item/piece count planned on this truck. */
|
||||
@Column({ name: 'planned_quantity', type: 'integer', nullable: true })
|
||||
plannedQuantity?: number | null;
|
||||
|
||||
@Column({ name: 'departed_at', type: 'timestamptz', nullable: true })
|
||||
departedAt?: Date | null;
|
||||
|
||||
|
||||
@@ -11,13 +11,22 @@ import {
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
UseInterceptors,
|
||||
UseGuards,
|
||||
UploadedFiles,
|
||||
BadRequestException,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { AnyFilesInterceptor } from "@nestjs/platform-express";
|
||||
import { ApiOperation, ApiTags, ApiConsumes } from "@nestjs/swagger";
|
||||
import { CurrentUser } from "@edr/api-common";
|
||||
import { FreightAdmin } from "../../common/booking-guards";
|
||||
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
|
||||
import { JwtGuard } from "@tria-plc/api-common/modules/auth/services/jwt.guard";
|
||||
import { BookingStaff } from "../../common/booking-guards";
|
||||
import {
|
||||
assertFreightPermission,
|
||||
hasFreightPermission,
|
||||
} from "../../common/freight-permission.util";
|
||||
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||
import { FilesService } from "../files/files.service";
|
||||
import { CompaniesService } from "./companies.service";
|
||||
import { CreateCompanyDto } from "./dto/create-company.dto";
|
||||
@@ -59,6 +68,23 @@ interface CurrentIamUser {
|
||||
phoneNumber?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which permission a status write needs. Approving/reactivating is a different
|
||||
* authority from suspending, but both arrive on the same route with the target
|
||||
* in the BODY — a route-level guard can't tell them apart, so the handlers
|
||||
* assert against this map instead.
|
||||
*
|
||||
* Keyed by string so it serves both `CompanyStatus` and `ProfileStatus`
|
||||
* (a superset: it adds `rejected`).
|
||||
*/
|
||||
const STATUS_PERM: Record<string, string> = {
|
||||
active: FREIGHT_PERMS.customers.verify,
|
||||
pending: FREIGHT_PERMS.customers.verify,
|
||||
rejected: FREIGHT_PERMS.customers.verify,
|
||||
suspended: FREIGHT_PERMS.customers.deactivate,
|
||||
blacklisted: FREIGHT_PERMS.customers.deactivate,
|
||||
};
|
||||
|
||||
@ApiTags("Companies")
|
||||
@Controller("companies")
|
||||
export class CompaniesController {
|
||||
@@ -410,7 +436,7 @@ export class CompaniesController {
|
||||
|
||||
// Used by backoffice
|
||||
@Post()
|
||||
@FreightAdmin()
|
||||
@BookingStaff(FREIGHT_PERMS.customers.create)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Create a new company (customer, freight_forwarder, dj_freight_forwarder, transporter)",
|
||||
@@ -421,12 +447,14 @@ export class CompaniesController {
|
||||
}
|
||||
|
||||
@Get("stats")
|
||||
@BookingStaff(FREIGHT_PERMS.customers.view)
|
||||
@ApiOperation({ summary: "Company counts by status (KPI strip)" })
|
||||
async getStats(): Promise<CompanyStatsResponseDto> {
|
||||
return this.companiesService.getCompanyStats();
|
||||
}
|
||||
|
||||
@Get()
|
||||
@BookingStaff(FREIGHT_PERMS.customers.view)
|
||||
@ApiOperation({ summary: "List companies (paginated, filterable)" })
|
||||
async findAll(
|
||||
@Query() query: ListCompaniesQueryDto,
|
||||
@@ -436,6 +464,7 @@ export class CompaniesController {
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
@BookingStaff(FREIGHT_PERMS.customers.view)
|
||||
@ApiOperation({ summary: "Get company by ID" })
|
||||
async findById(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@@ -446,30 +475,77 @@ export class CompaniesController {
|
||||
return dto;
|
||||
}
|
||||
|
||||
/**
|
||||
* Edits fields AND carries `status`, so it spans two authorities. The route
|
||||
* guard is one-of (a status-only caller must get in); the asserts below are
|
||||
* what actually authorize: touching `status` needs the permission
|
||||
* {@link STATUS_PERM} maps it to, touching anything else needs
|
||||
* `customers:update`. Both checks are required — without the second, a
|
||||
* caller holding only `customers:deactivate` could rename the company.
|
||||
*/
|
||||
@Patch(":id")
|
||||
@FreightAdmin()
|
||||
@BookingStaff([
|
||||
FREIGHT_PERMS.customers.update,
|
||||
FREIGHT_PERMS.customers.verify,
|
||||
FREIGHT_PERMS.customers.deactivate,
|
||||
])
|
||||
@ApiOperation({ summary: "Update a company" })
|
||||
async update(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateCompanyDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
): Promise<ResponseCompanyDto> {
|
||||
const { status, ...fields } = dto;
|
||||
if (status) assertFreightPermission(user, STATUS_PERM[status]);
|
||||
if (Object.keys(fields).length > 0) {
|
||||
assertFreightPermission(user, FREIGHT_PERMS.customers.update);
|
||||
}
|
||||
const company = await this.companiesService.updateCompany(id, dto);
|
||||
return new ResponseCompanyDto(company);
|
||||
}
|
||||
|
||||
@Delete(":id")
|
||||
@FreightAdmin()
|
||||
@BookingStaff(FREIGHT_PERMS.customers.deactivate)
|
||||
@ApiOperation({ summary: "Soft-delete a company" })
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async remove(@Param("id", ParseUUIDPipe) id: string): Promise<void> {
|
||||
await this.companiesService.deleteCompany(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dual-audience: staff read any customer's documents, and the portal reads
|
||||
* its OWN during onboarding (`companiesService.getDocuments`). So the route
|
||||
* is authenticated-only and the split happens here — same shape as
|
||||
* `GET /contracts/:id`. Gating it on a staff permission alone would 403 every
|
||||
* customer on their own documents.
|
||||
*
|
||||
* The staff arm is one-of because two pages consume it: the customer detail
|
||||
* page (`customers:view`) and the contract-request detail page, whose route
|
||||
* is gated on `contracts:view` — a contract reviewer without the customer
|
||||
* permission still needs the applicant's documents.
|
||||
*/
|
||||
@Get(":companyId/documents")
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiOperation({ summary: "List documents uploaded for a company" })
|
||||
async listDocuments(
|
||||
@Param("companyId", ParseUUIDPipe) companyId: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const isStaff = [
|
||||
FREIGHT_PERMS.customers.view,
|
||||
FREIGHT_PERMS.contracts.view,
|
||||
FREIGHT_PERMS.bookings.view,
|
||||
].some((p) => hasFreightPermission(user, p));
|
||||
|
||||
if (!isStaff) {
|
||||
const { company } = await this.companiesService.getCompanyInfoByUserId(
|
||||
user.id,
|
||||
);
|
||||
// Hidden as NotFound rather than Forbidden so company ids can't be probed.
|
||||
if (company.id !== companyId) {
|
||||
throw new NotFoundException(`Company ${companyId} not found`);
|
||||
}
|
||||
}
|
||||
const files = await this.filesService.findByResource(companyId, "companies");
|
||||
return Promise.all(
|
||||
files.map(async (f) => ({
|
||||
@@ -490,7 +566,7 @@ export class CompaniesController {
|
||||
}
|
||||
|
||||
@Post("documents/:fileId/request-change")
|
||||
@FreightAdmin()
|
||||
@BookingStaff(FREIGHT_PERMS.customers.verify)
|
||||
@ApiOperation({
|
||||
summary: "Ask the customer to correct one uploaded document",
|
||||
description:
|
||||
@@ -532,14 +608,23 @@ export class CompaniesController {
|
||||
return this.companiesService.uploadCompanyDocuments(companyId, files, user.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve / reject / suspend / blacklist all arrive here with the target in
|
||||
* the body, so authorization is per-status via {@link STATUS_PERM} rather
|
||||
* than on the route (the guard is only the one-of gate).
|
||||
*/
|
||||
@Patch("company-profiles/:profileId/status")
|
||||
@FreightAdmin()
|
||||
@BookingStaff([
|
||||
FREIGHT_PERMS.customers.verify,
|
||||
FREIGHT_PERMS.customers.deactivate,
|
||||
])
|
||||
@ApiOperation({ summary: "Update a company profile's approval status" })
|
||||
async updateCompanyProfileStatus(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
@Param("profileId", ParseUUIDPipe) profileId: string,
|
||||
@Body() dto: UpdateCompanyProfileStatusDto,
|
||||
): Promise<ResponseCompanyProfileDto> {
|
||||
assertFreightPermission(user, STATUS_PERM[dto.status]);
|
||||
const profile = await this.companiesService.setCompanyProfileStatus(
|
||||
profileId,
|
||||
dto.status,
|
||||
@@ -550,7 +635,7 @@ export class CompaniesController {
|
||||
}
|
||||
|
||||
@Get(":companyId/change-requests")
|
||||
@FreightAdmin()
|
||||
@BookingStaff(FREIGHT_PERMS.customers.view)
|
||||
@ApiOperation({ summary: "List a company's profile change requests" })
|
||||
async listChangeRequests(
|
||||
@Param("companyId", ParseUUIDPipe) companyId: string,
|
||||
@@ -560,7 +645,7 @@ export class CompaniesController {
|
||||
}
|
||||
|
||||
@Post("change-requests/:id/approve")
|
||||
@FreightAdmin()
|
||||
@BookingStaff(FREIGHT_PERMS.customers.verify)
|
||||
@ApiOperation({
|
||||
summary: "Approve a pending profile change request (applies the changes)",
|
||||
})
|
||||
@@ -576,7 +661,7 @@ export class CompaniesController {
|
||||
}
|
||||
|
||||
@Post("change-requests/:id/reject")
|
||||
@FreightAdmin()
|
||||
@BookingStaff(FREIGHT_PERMS.customers.verify)
|
||||
@ApiOperation({
|
||||
summary: "Reject a pending profile change request with a note",
|
||||
})
|
||||
@@ -594,7 +679,7 @@ export class CompaniesController {
|
||||
}
|
||||
|
||||
@Post(":companyId/profiles")
|
||||
@FreightAdmin()
|
||||
@BookingStaff(FREIGHT_PERMS.customers.update)
|
||||
@ApiOperation({ summary: "Add a profile (employee) to a company" })
|
||||
async createProfile(
|
||||
@Param("companyId", ParseUUIDPipe) companyId: string,
|
||||
@@ -608,6 +693,7 @@ export class CompaniesController {
|
||||
}
|
||||
|
||||
@Get(":companyId/profiles")
|
||||
@BookingStaff(FREIGHT_PERMS.customers.view)
|
||||
@ApiOperation({ summary: "List profiles for a company" })
|
||||
async listProfiles(
|
||||
@Param("companyId", ParseUUIDPipe) companyId: string,
|
||||
@@ -618,6 +704,7 @@ export class CompaniesController {
|
||||
}
|
||||
|
||||
@Get("profile/user/:userId")
|
||||
@BookingStaff(FREIGHT_PERMS.customers.view)
|
||||
@ApiOperation({ summary: "Get profile by IAM user ID" })
|
||||
async findProfileByUser(
|
||||
@Param("userId", ParseUUIDPipe) userId: string,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IsArray, IsOptional, IsString, IsUUID, ValidateNested } from 'class-validator';
|
||||
import { IsArray, IsNumber, IsOptional, IsString, IsUUID, Min, ValidateNested } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class FirstMileVehicleInput {
|
||||
@@ -8,6 +8,18 @@ export class FirstMileVehicleInput {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
containerNumber?: string;
|
||||
|
||||
/** Bulk: tonnage this truck hauls. */
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
tons?: number;
|
||||
|
||||
/** Bulk: optional item/piece count. */
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
quantity?: number;
|
||||
}
|
||||
|
||||
/** Replace the full set of vehicles (with their container numbers) on a pickup. */
|
||||
|
||||
@@ -36,4 +36,12 @@ export class FirstMileVehicleAssignment extends BaseEntity {
|
||||
/** Actual distance driven by this truck (km), entered per vehicle. */
|
||||
@Column({ name: 'distance_km', type: 'numeric', precision: 10, scale: 2, nullable: true })
|
||||
distanceKm?: number | null;
|
||||
|
||||
/** Bulk: tonnage this truck hauls — assigned tonnage draws down the booking total. */
|
||||
@Column({ name: 'tons', type: 'numeric', precision: 14, scale: 3, nullable: true })
|
||||
tons?: number | null;
|
||||
|
||||
/** Bulk: optional item/piece count on this truck. */
|
||||
@Column({ name: 'quantity', type: 'integer', nullable: true })
|
||||
quantity?: number | null;
|
||||
}
|
||||
|
||||
@@ -532,17 +532,47 @@ export class FirstMileService {
|
||||
*/
|
||||
async setVehicles(
|
||||
id: string,
|
||||
inputs: Array<{ vehicleId: string; containerNumber?: string | null }>,
|
||||
inputs: Array<{
|
||||
vehicleId: string;
|
||||
containerNumber?: string | null;
|
||||
tons?: number | null;
|
||||
quantity?: number | null;
|
||||
}>,
|
||||
): Promise<FirstMile> {
|
||||
const existing = await this.findById(id);
|
||||
// Dedupe by vehicleId, keeping the container number; preserve order.
|
||||
const desiredMap = new Map<string, string | null>();
|
||||
// Dedupe by vehicleId, keeping the load details; preserve order.
|
||||
const desiredMap = new Map<
|
||||
string,
|
||||
{ containerNumber: string | null; tons: number | null; quantity: number | null }
|
||||
>();
|
||||
for (const inp of inputs) {
|
||||
if (inp.vehicleId) desiredMap.set(inp.vehicleId, inp.containerNumber ?? null);
|
||||
if (inp.vehicleId) {
|
||||
desiredMap.set(inp.vehicleId, {
|
||||
containerNumber: inp.containerNumber ?? null,
|
||||
tons: inp.tons ?? null,
|
||||
quantity: inp.quantity ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
const desired = [...desiredMap.keys()];
|
||||
const desiredSet = new Set(desired);
|
||||
|
||||
// Bulk drawdown: assigned tonnage may not exceed what the booking declares.
|
||||
const totalTons = [...desiredMap.values()].reduce((s, v) => s + (Number(v.tons) || 0), 0);
|
||||
if (totalTons > 0 && existing.bookingId) {
|
||||
const [b]: Array<{ vgm: string | null }> = await this.dataSource.query(
|
||||
`SELECT cargo_total_weight_vgm AS vgm FROM freight.bookings
|
||||
WHERE id = $1 AND deleted_at IS NULL`,
|
||||
[existing.bookingId],
|
||||
);
|
||||
const declared = Number(b?.vgm ?? 0);
|
||||
if (declared > 0 && totalTons > declared + 0.001) {
|
||||
throw new BadRequestException(
|
||||
`Assigned tonnage (${totalTons} t) exceeds the booking's declared ${declared} t`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const manager = this.dataSource.manager;
|
||||
const current = await manager.find(FirstMileVehicleAssignment, {
|
||||
where: { firstMileId: id },
|
||||
@@ -555,12 +585,16 @@ export class FirstMileService {
|
||||
)];
|
||||
const added = desired.filter((v) => !junctionSet.has(v));
|
||||
const removed = releaseIds.filter((v) => !desiredSet.has(v));
|
||||
// Vehicles that stay but whose container number changed.
|
||||
const changed = current.filter(
|
||||
(a) =>
|
||||
desiredMap.has(a.vehicleId) &&
|
||||
(a.containerNumber ?? null) !== (desiredMap.get(a.vehicleId) ?? null),
|
||||
);
|
||||
// Vehicles that stay but whose load details changed.
|
||||
const changed = current.filter((a) => {
|
||||
const want = desiredMap.get(a.vehicleId);
|
||||
if (!want) return false;
|
||||
return (
|
||||
(a.containerNumber ?? null) !== want.containerNumber ||
|
||||
(a.tons == null ? null : Number(a.tons)) !== want.tons ||
|
||||
(a.quantity ?? null) !== want.quantity
|
||||
);
|
||||
});
|
||||
|
||||
await this.dataSource.transaction(async (tx) => {
|
||||
if (removed.length) {
|
||||
@@ -570,17 +604,25 @@ export class FirstMileService {
|
||||
});
|
||||
}
|
||||
for (const vehicleId of added) {
|
||||
const want = desiredMap.get(vehicleId);
|
||||
await tx.insert(FirstMileVehicleAssignment, {
|
||||
firstMileId: id,
|
||||
vehicleId,
|
||||
containerNumber: desiredMap.get(vehicleId) ?? null,
|
||||
containerNumber: want?.containerNumber ?? null,
|
||||
tons: want?.tons ?? null,
|
||||
quantity: want?.quantity ?? null,
|
||||
});
|
||||
}
|
||||
for (const row of changed) {
|
||||
const want = desiredMap.get(row.vehicleId);
|
||||
await tx.update(
|
||||
FirstMileVehicleAssignment,
|
||||
{ firstMileId: id, vehicleId: row.vehicleId },
|
||||
{ containerNumber: desiredMap.get(row.vehicleId) ?? null },
|
||||
{
|
||||
containerNumber: want?.containerNumber ?? null,
|
||||
tons: want?.tons ?? null,
|
||||
quantity: want?.quantity ?? null,
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -54,11 +54,4 @@ export class InterchangeDocumentsController {
|
||||
dispute(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DisputeInterchangeDocumentDto) {
|
||||
return this.service.dispute(id, dto);
|
||||
}
|
||||
|
||||
@Patch(':id/cancel')
|
||||
@BookingStaff(FREIGHT_PERMS.interchangeDocuments.cancel)
|
||||
@ApiOperation({ summary: 'Cancel a draft/generated interchange document' })
|
||||
cancel(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.service.cancel(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,11 +203,12 @@ export class InterchangeDocumentsService {
|
||||
|
||||
async dispute(id: string, dto: DisputeInterchangeDocumentDto): Promise<InterchangeDocument> {
|
||||
const document = await this.findOne(id);
|
||||
// A dispute can only be raised on a live handover — a GENERATED or already
|
||||
// ACKNOWLEDGED document. CANCELLED and already-DISPUTED are terminal here.
|
||||
if (!['GENERATED', 'ACKNOWLEDGED'].includes(document.status)) {
|
||||
// A dispute can only be raised BEFORE the handover is acknowledged — an
|
||||
// acknowledged document is settled. DISPUTED itself is terminal and
|
||||
// read-only: the registered dispute cannot be re-raised or overwritten.
|
||||
if (document.status !== 'GENERATED') {
|
||||
throw new BadRequestException(
|
||||
`Interchange document in ${document.status} status cannot be disputed (must be GENERATED or ACKNOWLEDGED)`,
|
||||
`Interchange document in ${document.status} status cannot be disputed (must be GENERATED — an acknowledged handover is settled, a registered dispute is read-only)`,
|
||||
);
|
||||
}
|
||||
await this.dataSource.getRepository(InterchangeDocument).update(id, {
|
||||
@@ -217,15 +218,6 @@ export class InterchangeDocumentsService {
|
||||
return this.findOne(id);
|
||||
}
|
||||
|
||||
async cancel(id: string): Promise<InterchangeDocument> {
|
||||
const document = await this.findOne(id);
|
||||
if (!['DRAFT', 'GENERATED'].includes(document.status)) {
|
||||
throw new BadRequestException(`Interchange document ${document.status} cannot be cancelled`);
|
||||
}
|
||||
await this.dataSource.getRepository(InterchangeDocument).update(id, { status: 'CANCELLED' });
|
||||
return this.findOne(id);
|
||||
}
|
||||
|
||||
private async getScheduleSnapshot(scheduleId: string): Promise<ScheduleSnapshot> {
|
||||
const [schedule] = await this.dataSource.query(
|
||||
`SELECT ts.id,
|
||||
|
||||
@@ -8,6 +8,11 @@ export class CreateMaintenanceScheduleDto {
|
||||
@IsEnum(MaintenanceType)
|
||||
maintenanceType!: MaintenanceType;
|
||||
|
||||
/** What is serviced — matched against the interval for auto-scheduling. */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
serviceItem?: string;
|
||||
|
||||
@IsString()
|
||||
description!: string;
|
||||
|
||||
@@ -81,7 +86,37 @@ export class UpdateMaintenanceScheduleDto {
|
||||
@IsNumber()
|
||||
actualCost?: number;
|
||||
|
||||
/** Odometer at completion — drives KM-based auto-scheduling of the next service. */
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
odometerReading?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export class UpsertMaintenanceIntervalDto {
|
||||
@IsUUID()
|
||||
vehicleId!: string;
|
||||
|
||||
@IsEnum(MaintenanceType)
|
||||
maintenanceType!: MaintenanceType;
|
||||
|
||||
/** What is serviced — "oil change", "tires", … Distinguishes intervals of the same type. */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
serviceItem?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
intervalKm?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
intervalDays?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Entity, Column, ManyToOne, JoinColumn, Index } from 'typeorm';
|
||||
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
|
||||
import { MaintenanceType } from './maintenance-schedule.entity';
|
||||
|
||||
/**
|
||||
* Maintenance interval configuration. Defines how often a vehicle needs a
|
||||
* given service. Identity is (vehicle, maintenanceType, serviceItem) — a
|
||||
* vehicle carries several intervals of the same coarse type with different
|
||||
* items (oil every 10k km, tires every 50k km, both PREVENTIVE). Uniqueness
|
||||
* is enforced by a COALESCE expression index in the migration (nullable
|
||||
* service_item), not a TypeORM @Unique.
|
||||
*/
|
||||
@Entity({ name: 'maintenance_intervals', schema: 'freight' })
|
||||
@Index(['vehicleId', 'maintenanceType'])
|
||||
export class MaintenanceInterval extends BaseEntity {
|
||||
@Column({ name: 'vehicle_id', type: 'uuid' })
|
||||
vehicleId!: string;
|
||||
|
||||
@ManyToOne(() => Vehicle, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'vehicle_id' })
|
||||
vehicle!: Vehicle;
|
||||
|
||||
@Column({ name: 'maintenance_type', type: 'varchar' })
|
||||
maintenanceType!: MaintenanceType;
|
||||
|
||||
/** What is serviced — "oil change", "tires", … Null = generic for the type. */
|
||||
@Column({ name: 'service_item', type: 'varchar', length: 120, nullable: true })
|
||||
serviceItem?: string | null;
|
||||
|
||||
/** Maintenance interval in kilometers. E.g., 10000 for oil changes every 10k km. */
|
||||
@Column({ name: 'interval_km', type: 'numeric', precision: 14, scale: 2, nullable: true })
|
||||
intervalKm?: number | null;
|
||||
|
||||
/** Maintenance interval in days. E.g., 365 for annual inspection. */
|
||||
@Column({ name: 'interval_days', type: 'integer', nullable: true })
|
||||
intervalDays?: number | null;
|
||||
|
||||
/** Human-readable description. E.g., "Oil and filter change". */
|
||||
@Column({ name: 'description', type: 'text', nullable: true })
|
||||
description?: string | null;
|
||||
|
||||
/** Is this interval active? Can be disabled without deleting historical data. */
|
||||
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||
isActive!: boolean;
|
||||
}
|
||||
@@ -30,6 +30,10 @@ export class MaintenanceSchedule extends BaseEntity {
|
||||
@Column({ name: 'maintenance_type', type: 'varchar' })
|
||||
maintenanceType!: MaintenanceType;
|
||||
|
||||
/** What is serviced — matches the interval's service_item for auto-scheduling. */
|
||||
@Column({ name: 'service_item', type: 'varchar', length: 120, nullable: true })
|
||||
serviceItem?: string | null;
|
||||
|
||||
@Column({ name: 'description' })
|
||||
description!: string;
|
||||
|
||||
@@ -62,4 +66,8 @@ export class MaintenanceSchedule extends BaseEntity {
|
||||
|
||||
@Column({ name: 'next_due_date', type: 'timestamptz', nullable: true })
|
||||
nextDueDate?: Date;
|
||||
|
||||
/** Stamped once the km/date-due alert has fired, so the daily check doesn't repeat it. */
|
||||
@Column({ name: 'due_notified_at', type: 'timestamptz', nullable: true })
|
||||
dueNotifiedAt?: Date;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { MaintenanceService } from './maintenance.service';
|
||||
import { MaintenanceStatus } from './entities/maintenance-schedule.entity';
|
||||
|
||||
/**
|
||||
* KM-based auto-scheduling: completing a maintenance with an odometer reading
|
||||
* creates the next SCHEDULED item at completedKm + intervalKm, matched on the
|
||||
* schedule's (type, serviceItem) interval. Re-completing must not duplicate.
|
||||
*/
|
||||
function makeService(opts: {
|
||||
before: Record<string, unknown> | null;
|
||||
after: Record<string, unknown> | null;
|
||||
interval: Record<string, unknown> | null;
|
||||
}) {
|
||||
const saved: Array<Record<string, unknown>> = [];
|
||||
const service = Object.create(MaintenanceService.prototype) as Record<string, unknown>;
|
||||
service.scheduleRepository = {
|
||||
findOneBy: jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(opts.before)
|
||||
.mockResolvedValueOnce(opts.after),
|
||||
update: jest.fn(),
|
||||
create: jest.fn((v: Record<string, unknown>) => v),
|
||||
save: jest.fn(async (v: Record<string, unknown>) => {
|
||||
saved.push(v);
|
||||
return v;
|
||||
}),
|
||||
};
|
||||
service.intervalRepository = {
|
||||
getByVehicleAndType: jest.fn().mockResolvedValue(opts.interval),
|
||||
};
|
||||
service.dataSource = {
|
||||
getRepository: jest.fn().mockReturnValue({ update: jest.fn() }),
|
||||
};
|
||||
service.logger = { error: jest.fn() };
|
||||
return { service: service as unknown as MaintenanceService, saved };
|
||||
}
|
||||
|
||||
const base = {
|
||||
id: 's-1',
|
||||
vehicleId: 'v-1',
|
||||
maintenanceType: 'PREVENTIVE',
|
||||
serviceItem: 'oil change',
|
||||
description: 'Oil and filter',
|
||||
};
|
||||
|
||||
describe('MaintenanceService auto-next scheduling', () => {
|
||||
it('completing at 50,000 km with a 10,000 km interval schedules the next at 60,000', async () => {
|
||||
const { service, saved } = makeService({
|
||||
before: { ...base, status: MaintenanceStatus.SCHEDULED },
|
||||
after: { ...base, status: MaintenanceStatus.COMPLETED, odometerReading: 50000 },
|
||||
interval: { serviceItem: 'oil change', intervalKm: '10000.00', intervalDays: null, description: 'Oil and filter' },
|
||||
});
|
||||
|
||||
await service.updateMaintenanceSchedule('s-1', {
|
||||
status: MaintenanceStatus.COMPLETED,
|
||||
odometerReading: 50000,
|
||||
});
|
||||
|
||||
expect(saved).toHaveLength(1);
|
||||
expect(saved[0]).toMatchObject({
|
||||
vehicleId: 'v-1',
|
||||
serviceItem: 'oil change',
|
||||
nextDueKm: 60000,
|
||||
status: MaintenanceStatus.SCHEDULED,
|
||||
});
|
||||
});
|
||||
|
||||
it('re-completing an already COMPLETED schedule does not duplicate the next one', async () => {
|
||||
const { service, saved } = makeService({
|
||||
before: { ...base, status: MaintenanceStatus.COMPLETED, odometerReading: 50000 },
|
||||
after: { ...base, status: MaintenanceStatus.COMPLETED, odometerReading: 50000 },
|
||||
interval: { serviceItem: 'oil change', intervalKm: '10000.00', intervalDays: null },
|
||||
});
|
||||
|
||||
await service.updateMaintenanceSchedule('s-1', {
|
||||
status: MaintenanceStatus.COMPLETED,
|
||||
odometerReading: 50000,
|
||||
});
|
||||
|
||||
expect(saved).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('a km + days interval produces ONE next schedule carrying both thresholds', async () => {
|
||||
const { service, saved } = makeService({
|
||||
before: { ...base, status: MaintenanceStatus.IN_PROGRESS },
|
||||
after: { ...base, status: MaintenanceStatus.COMPLETED, odometerReading: 20000 },
|
||||
interval: { serviceItem: 'oil change', intervalKm: '10000.00', intervalDays: 180 },
|
||||
});
|
||||
|
||||
await service.updateMaintenanceSchedule('s-1', {
|
||||
status: MaintenanceStatus.COMPLETED,
|
||||
odometerReading: 20000,
|
||||
});
|
||||
|
||||
expect(saved).toHaveLength(1);
|
||||
expect(saved[0].nextDueKm).toBe(30000);
|
||||
expect(saved[0].nextDueDate).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { NotificationAudience } from '@edr/types';
|
||||
|
||||
import { MaintenanceService } from './maintenance.service';
|
||||
|
||||
/**
|
||||
* The daily due-alert: a SCHEDULED item that crossed its km or date threshold
|
||||
* gets one BACKOFFICE notification, then is stamped so it isn't repeated.
|
||||
*/
|
||||
function makeService(due: Array<Record<string, unknown>>) {
|
||||
const update = jest.fn();
|
||||
const notify = jest.fn();
|
||||
const service = Object.create(MaintenanceService.prototype) as Record<string, unknown>;
|
||||
service.maintenanceRepository = { getUnnotifiedDue: jest.fn().mockResolvedValue(due) };
|
||||
service.scheduleRepository = { update };
|
||||
service.inbox = { notify };
|
||||
service.logger = { error: jest.fn() };
|
||||
return { service: service as unknown as MaintenanceService, update, notify };
|
||||
}
|
||||
|
||||
describe('MaintenanceService.sendDueAlerts', () => {
|
||||
it('reports the km reason when the km threshold was crossed', async () => {
|
||||
const { service, notify, update } = makeService([
|
||||
{
|
||||
id: 'sched-1',
|
||||
vehicleId: 'v-1',
|
||||
plateNumber: 'ET-9875',
|
||||
maintenanceType: 'PREVENTIVE',
|
||||
description: 'Oil change',
|
||||
nextDueKm: 50000,
|
||||
nextDueDate: null,
|
||||
currentKm: 50200,
|
||||
},
|
||||
]);
|
||||
|
||||
await service.sendDueAlerts();
|
||||
|
||||
expect(notify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
audience: NotificationAudience.BACKOFFICE,
|
||||
title: 'Maintenance due — ET-9875',
|
||||
body: expect.stringContaining('driven 50200 km (due at 50000 km)'),
|
||||
}),
|
||||
);
|
||||
expect(update).toHaveBeenCalledWith('sched-1', { dueNotifiedAt: expect.any(Date) });
|
||||
});
|
||||
|
||||
it('reports the date reason when only the due date has passed', async () => {
|
||||
const { service, notify } = makeService([
|
||||
{
|
||||
id: 'sched-2',
|
||||
vehicleId: 'v-2',
|
||||
plateNumber: 'AA-8642',
|
||||
maintenanceType: 'INSPECTION',
|
||||
description: 'Annual inspection',
|
||||
nextDueKm: null,
|
||||
nextDueDate: new Date('2026-01-01'),
|
||||
currentKm: 1000,
|
||||
},
|
||||
]);
|
||||
|
||||
await service.sendDueAlerts();
|
||||
|
||||
expect(notify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ body: expect.stringContaining('due 1/1/2026') }),
|
||||
);
|
||||
});
|
||||
|
||||
it('does nothing when nothing is due', async () => {
|
||||
const { service, notify, update } = makeService([]);
|
||||
|
||||
await service.sendDueAlerts();
|
||||
|
||||
expect(notify).not.toHaveBeenCalled();
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { IsNull, Repository } from 'typeorm';
|
||||
import { MaintenanceInterval } from './entities/maintenance-interval.entity';
|
||||
import { MaintenanceType } from './entities/maintenance-schedule.entity';
|
||||
|
||||
@Injectable()
|
||||
export class MaintenanceIntervalRepository extends BaseRepository<MaintenanceInterval> {
|
||||
constructor(
|
||||
@InjectRepository(MaintenanceInterval)
|
||||
private readonly intervalRepository: Repository<MaintenanceInterval>,
|
||||
) {
|
||||
super(intervalRepository);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the interval for a completed service. Prefers the exact
|
||||
* (type, serviceItem) match; a completion without an item falls back to the
|
||||
* type's item-less interval only, so "oil" completions never consume the
|
||||
* "tires" interval.
|
||||
*/
|
||||
async getByVehicleAndType(
|
||||
vehicleId: string,
|
||||
maintenanceType: MaintenanceType,
|
||||
serviceItem?: string | null,
|
||||
): Promise<MaintenanceInterval | null> {
|
||||
return this.intervalRepository.findOne({
|
||||
where: {
|
||||
vehicleId,
|
||||
maintenanceType,
|
||||
isActive: true,
|
||||
serviceItem: serviceItem?.trim() ? serviceItem.trim() : IsNull(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getActiveIntervals(vehicleId: string): Promise<MaintenanceInterval[]> {
|
||||
return this.intervalRepository.find({
|
||||
where: { vehicleId, isActive: true },
|
||||
order: { maintenanceType: 'ASC', serviceItem: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async upsertInterval(
|
||||
vehicleId: string,
|
||||
maintenanceType: MaintenanceType,
|
||||
serviceItem?: string | null,
|
||||
intervalKm?: number | null,
|
||||
intervalDays?: number | null,
|
||||
description?: string | null,
|
||||
): Promise<MaintenanceInterval> {
|
||||
const item = serviceItem?.trim() || null;
|
||||
const existing = await this.getByVehicleAndType(vehicleId, maintenanceType, item);
|
||||
|
||||
if (existing) {
|
||||
await this.intervalRepository.update(existing.id, {
|
||||
intervalKm: intervalKm ?? existing.intervalKm,
|
||||
intervalDays: intervalDays ?? existing.intervalDays,
|
||||
description: description ?? existing.description,
|
||||
});
|
||||
const updated = await this.intervalRepository.findOneBy({ id: existing.id });
|
||||
return updated!;
|
||||
}
|
||||
|
||||
return this.intervalRepository.save(
|
||||
this.intervalRepository.create({
|
||||
vehicleId,
|
||||
maintenanceType,
|
||||
serviceItem: item,
|
||||
intervalKm,
|
||||
intervalDays,
|
||||
description,
|
||||
isActive: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** Soft-disable: history keeps pointing at it, auto-scheduling stops. */
|
||||
async deactivate(id: string): Promise<void> {
|
||||
await this.intervalRepository.update(id, { isActive: false });
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,12 @@ import { BookingStaff } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { MaintenanceService } from './maintenance.service';
|
||||
import { MaintenanceDepthService } from './maintenance-depth.service';
|
||||
import { CreateMaintenanceScheduleDto, CreateMaintenanceCostDto, UpdateMaintenanceScheduleDto } from './dto/create-maintenance.dto';
|
||||
import {
|
||||
CreateMaintenanceScheduleDto,
|
||||
CreateMaintenanceCostDto,
|
||||
UpdateMaintenanceScheduleDto,
|
||||
UpsertMaintenanceIntervalDto,
|
||||
} from './dto/create-maintenance.dto';
|
||||
import {
|
||||
CreateWorkOrderDto,
|
||||
UpdateWorkOrderDto,
|
||||
@@ -44,6 +49,34 @@ export class MaintenanceController {
|
||||
return this.maintenanceService.updateMaintenanceSchedule(id, dto);
|
||||
}
|
||||
|
||||
@Get('due-board')
|
||||
@BookingStaff([FREIGHT_PERMS.maintenance.view, FREIGHT_PERMS.fleetDashboard.view])
|
||||
@ApiOperation({ summary: 'Fleet-wide next-due maintenance board (by date and km)' })
|
||||
async getDueBoard() {
|
||||
return this.maintenanceService.getDueBoard();
|
||||
}
|
||||
|
||||
@Post('intervals')
|
||||
@BookingStaff(FREIGHT_PERMS.maintenance.create)
|
||||
@ApiOperation({ summary: 'Define/adjust a service interval (e.g. oil change every 10,000 km)' })
|
||||
async upsertInterval(@Body() dto: UpsertMaintenanceIntervalDto) {
|
||||
return this.maintenanceService.upsertInterval(dto);
|
||||
}
|
||||
|
||||
@Get('intervals/:vehicleId')
|
||||
@BookingStaff(FREIGHT_PERMS.maintenance.view)
|
||||
@ApiOperation({ summary: "A vehicle's active service intervals" })
|
||||
async getIntervals(@Param('vehicleId') vehicleId: string) {
|
||||
return this.maintenanceService.getIntervals(vehicleId);
|
||||
}
|
||||
|
||||
@Delete('intervals/:id')
|
||||
@BookingStaff(FREIGHT_PERMS.maintenance.delete)
|
||||
@ApiOperation({ summary: 'Deactivate a service interval (stops auto-scheduling)' })
|
||||
async deactivateInterval(@Param('id') id: string) {
|
||||
return this.maintenanceService.deactivateInterval(id);
|
||||
}
|
||||
|
||||
@Get('upcoming/:vehicleId')
|
||||
@BookingStaff(FREIGHT_PERMS.maintenance.view)
|
||||
@ApiOperation({ summary: 'Get upcoming maintenance' })
|
||||
|
||||
@@ -2,25 +2,37 @@ import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { MaintenanceSchedule } from './entities/maintenance-schedule.entity';
|
||||
import { MaintenanceCost } from './entities/maintenance-cost.entity';
|
||||
import { MaintenanceInterval } from './entities/maintenance-interval.entity';
|
||||
import { WorkOrder } from './entities/work-order.entity';
|
||||
import { Part } from './entities/part.entity';
|
||||
import { Warranty } from './entities/warranty.entity';
|
||||
import { MaintenanceService } from './maintenance.service';
|
||||
import { MaintenanceDepthService } from './maintenance-depth.service';
|
||||
import { MaintenanceRepository } from './maintenance.repository';
|
||||
import { MaintenanceIntervalRepository } from './maintenance-interval.repository';
|
||||
import { WorkOrderRepository } from './work-order.repository';
|
||||
import { PartRepository } from './part.repository';
|
||||
import { WarrantyRepository } from './warranty.repository';
|
||||
import { MaintenanceController } from './maintenance.controller';
|
||||
import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([MaintenanceSchedule, MaintenanceCost, WorkOrder, Part, Warranty]),
|
||||
TypeOrmModule.forFeature([
|
||||
MaintenanceSchedule,
|
||||
MaintenanceCost,
|
||||
MaintenanceInterval,
|
||||
WorkOrder,
|
||||
Part,
|
||||
Warranty,
|
||||
]),
|
||||
NotificationInboxModule,
|
||||
],
|
||||
providers: [
|
||||
MaintenanceService,
|
||||
MaintenanceDepthService,
|
||||
MaintenanceRepository,
|
||||
MaintenanceIntervalRepository,
|
||||
WorkOrderRepository,
|
||||
PartRepository,
|
||||
WarrantyRepository,
|
||||
|
||||
@@ -47,4 +47,109 @@ export class MaintenanceRepository extends BaseRepository<MaintenanceSchedule> {
|
||||
.getRawOne();
|
||||
return result?.total || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fleet-wide "next due" board: one row per vehicle with a SCHEDULED
|
||||
* maintenance item, driven by time AND km — whichever is soonest. Current km
|
||||
* is the vehicle's latest fuel-up odometer reading (how mileage is actually
|
||||
* captured today), falling back to vehicle.actual_distance_km when the
|
||||
* vehicle has no fuel purchase on file yet.
|
||||
*/
|
||||
async getDueBoard(): Promise<
|
||||
Array<{
|
||||
scheduleId: string;
|
||||
vehicleId: string;
|
||||
plateNumber: string;
|
||||
maintenanceType: string;
|
||||
serviceItem: string | null;
|
||||
description: string;
|
||||
scheduledDate: Date;
|
||||
nextDueDate: Date | null;
|
||||
nextDueKm: number | null;
|
||||
currentKm: number | null;
|
||||
kmRemaining: number | null;
|
||||
daysRemaining: number | null;
|
||||
overdue: boolean;
|
||||
}>
|
||||
> {
|
||||
// Every SCHEDULED item, not one per vehicle — a truck legitimately holds
|
||||
// several (oil vs tires intervals differ).
|
||||
return this.scheduleRepository.manager.query(`
|
||||
SELECT
|
||||
s.id AS "scheduleId",
|
||||
s.vehicle_id AS "vehicleId",
|
||||
v.plate_number AS "plateNumber",
|
||||
s.maintenance_type AS "maintenanceType",
|
||||
s.service_item AS "serviceItem",
|
||||
s.description,
|
||||
s.scheduled_date AS "scheduledDate",
|
||||
s.next_due_date AS "nextDueDate",
|
||||
s.next_due_km AS "nextDueKm",
|
||||
COALESCE(fp.max_odometer, v.actual_distance_km) AS "currentKm",
|
||||
CASE WHEN s.next_due_km IS NOT NULL
|
||||
THEN s.next_due_km - COALESCE(fp.max_odometer, v.actual_distance_km, 0)
|
||||
ELSE NULL END AS "kmRemaining",
|
||||
CASE WHEN s.next_due_date IS NOT NULL
|
||||
THEN EXTRACT(DAY FROM s.next_due_date - now())
|
||||
ELSE NULL END AS "daysRemaining",
|
||||
(
|
||||
(s.next_due_date IS NOT NULL AND s.next_due_date <= now())
|
||||
OR (s.next_due_km IS NOT NULL
|
||||
AND COALESCE(fp.max_odometer, v.actual_distance_km, 0) >= s.next_due_km)
|
||||
) AS overdue
|
||||
FROM freight.maintenance_schedules s
|
||||
JOIN freight.vehicles v ON v.id = s.vehicle_id AND v.deleted_at IS NULL
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT MAX(odometer_reading) AS max_odometer
|
||||
FROM freight.fuel_purchases fp2
|
||||
WHERE fp2.vehicle_id = s.vehicle_id
|
||||
) fp ON true
|
||||
WHERE s.status = 'SCHEDULED' AND s.deleted_at IS NULL
|
||||
ORDER BY s.vehicle_id, s.scheduled_date ASC
|
||||
`);
|
||||
}
|
||||
|
||||
/**
|
||||
* SCHEDULED items that have crossed their km or date due-point and have not
|
||||
* yet been notified. Backs the daily km/date maintenance alert.
|
||||
*/
|
||||
async getUnnotifiedDue(): Promise<
|
||||
Array<{
|
||||
id: string;
|
||||
vehicleId: string;
|
||||
plateNumber: string;
|
||||
maintenanceType: string;
|
||||
description: string;
|
||||
nextDueKm: number | null;
|
||||
nextDueDate: Date | null;
|
||||
currentKm: number | null;
|
||||
}>
|
||||
> {
|
||||
return this.scheduleRepository.manager.query(`
|
||||
SELECT
|
||||
s.id,
|
||||
s.vehicle_id AS "vehicleId",
|
||||
v.plate_number AS "plateNumber",
|
||||
s.maintenance_type AS "maintenanceType",
|
||||
s.description,
|
||||
s.next_due_km AS "nextDueKm",
|
||||
s.next_due_date AS "nextDueDate",
|
||||
COALESCE(fp.max_odometer, v.actual_distance_km) AS "currentKm"
|
||||
FROM freight.maintenance_schedules s
|
||||
JOIN freight.vehicles v ON v.id = s.vehicle_id AND v.deleted_at IS NULL
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT MAX(odometer_reading) AS max_odometer
|
||||
FROM freight.fuel_purchases fp2
|
||||
WHERE fp2.vehicle_id = s.vehicle_id
|
||||
) fp ON true
|
||||
WHERE s.status = 'SCHEDULED'
|
||||
AND s.deleted_at IS NULL
|
||||
AND s.due_notified_at IS NULL
|
||||
AND (
|
||||
(s.next_due_date IS NOT NULL AND s.next_due_date <= now())
|
||||
OR (s.next_due_km IS NOT NULL
|
||||
AND COALESCE(fp.max_odometer, v.actual_distance_km, 0) >= s.next_due_km)
|
||||
)
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,28 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { NotificationAudience, NotificationType } from '@edr/types';
|
||||
import { DataSource, Repository } from 'typeorm';
|
||||
import { MaintenanceRepository } from './maintenance.repository';
|
||||
import { MaintenanceSchedule, MaintenanceStatus } from './entities/maintenance-schedule.entity';
|
||||
import { MaintenanceIntervalRepository } from './maintenance-interval.repository';
|
||||
import { MaintenanceSchedule, MaintenanceStatus, MaintenanceType } from './entities/maintenance-schedule.entity';
|
||||
import { MaintenanceCost } from './entities/maintenance-cost.entity';
|
||||
import { Vehicle, VehicleAvailability, VehicleStatus } from '../vehicles/entities/vehicle.entity';
|
||||
import { CreateMaintenanceScheduleDto, CreateMaintenanceCostDto, UpdateMaintenanceScheduleDto } from './dto/create-maintenance.dto';
|
||||
import {
|
||||
CreateMaintenanceScheduleDto,
|
||||
CreateMaintenanceCostDto,
|
||||
UpdateMaintenanceScheduleDto,
|
||||
UpsertMaintenanceIntervalDto,
|
||||
} from './dto/create-maintenance.dto';
|
||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||
|
||||
@Injectable()
|
||||
export class MaintenanceService {
|
||||
private readonly logger = new Logger(MaintenanceService.name);
|
||||
|
||||
constructor(
|
||||
private readonly maintenanceRepository: MaintenanceRepository,
|
||||
private readonly intervalRepository: MaintenanceIntervalRepository,
|
||||
@InjectRepository(MaintenanceSchedule)
|
||||
private readonly scheduleRepository: Repository<MaintenanceSchedule>,
|
||||
@InjectRepository(MaintenanceCost)
|
||||
@@ -18,8 +30,44 @@ export class MaintenanceService {
|
||||
// Vehicle isn't registered in this module's TypeOrmModule.forFeature, so we
|
||||
// reach it through the global DataSource rather than @InjectRepository.
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly inbox: NotificationInboxService,
|
||||
) {}
|
||||
|
||||
/** Fleet-wide next-due board — see MaintenanceRepository.getDueBoard. */
|
||||
async getDueBoard() {
|
||||
return this.maintenanceRepository.getDueBoard();
|
||||
}
|
||||
|
||||
/**
|
||||
* Daily check: a vehicle's driven km (latest fuel-up odometer reading, since
|
||||
* that's the only place mileage is actually recorded) or its due date has
|
||||
* reached a SCHEDULED item's threshold → alert backoffice once.
|
||||
*/
|
||||
@Cron(CronExpression.EVERY_DAY_AT_7AM, { name: 'maintenance-due-alert' })
|
||||
async sendDueAlerts(): Promise<void> {
|
||||
try {
|
||||
const due = await this.maintenanceRepository.getUnnotifiedDue();
|
||||
for (const item of due) {
|
||||
const reason =
|
||||
item.nextDueKm != null && (item.currentKm ?? 0) >= item.nextDueKm
|
||||
? `driven ${item.currentKm} km (due at ${item.nextDueKm} km)`
|
||||
: `due ${new Date(item.nextDueDate as Date).toLocaleDateString()}`;
|
||||
await this.inbox.notify({
|
||||
recipients: { allBackoffice: true },
|
||||
audience: NotificationAudience.BACKOFFICE,
|
||||
type: NotificationType.GENERIC,
|
||||
title: `Maintenance due — ${item.plateNumber}`,
|
||||
body: `${item.plateNumber} (${item.maintenanceType}) is due for maintenance — ${reason}. ${item.description}`,
|
||||
link: `/dashboard/maintenance?vehicleId=${item.vehicleId}`,
|
||||
data: { vehicleId: item.vehicleId, scheduleId: item.id, action: 'MAINTENANCE_DUE' },
|
||||
});
|
||||
await this.scheduleRepository.update(item.id, { dueNotifiedAt: new Date() });
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error(`sendDueAlerts failed: ${(err as Error).message}`, (err as Error).stack);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reflect a maintenance schedule's lifecycle on the target vehicle. A vehicle
|
||||
* under maintenance is taken out of service (MAINTENANCE + BUSY); once the
|
||||
@@ -64,6 +112,10 @@ export class MaintenanceService {
|
||||
id: string,
|
||||
dto: UpdateMaintenanceScheduleDto,
|
||||
): Promise<MaintenanceSchedule> {
|
||||
// Status BEFORE the write: completing an already-COMPLETED schedule again
|
||||
// must not auto-create a second "next" schedule.
|
||||
const before = await this.scheduleRepository.findOneBy({ id });
|
||||
|
||||
await this.scheduleRepository.update(id, {
|
||||
...dto,
|
||||
completedDate: dto.completedDate ? new Date(dto.completedDate) : undefined,
|
||||
@@ -78,6 +130,15 @@ export class MaintenanceService {
|
||||
) {
|
||||
// Maintenance finished/aborted → vehicle back in service.
|
||||
await this.setVehicleMaintenanceState(updated.vehicleId, false);
|
||||
|
||||
// First transition into COMPLETED with an odometer → auto-schedule next.
|
||||
if (
|
||||
dto.status === MaintenanceStatus.COMPLETED &&
|
||||
before?.status !== MaintenanceStatus.COMPLETED &&
|
||||
updated.odometerReading != null
|
||||
) {
|
||||
await this.scheduleNextMaintenance(updated);
|
||||
}
|
||||
} else if (dto.status === MaintenanceStatus.IN_PROGRESS) {
|
||||
// Maintenance started → keep the vehicle out of service.
|
||||
await this.setVehicleMaintenanceState(updated.vehicleId, true);
|
||||
@@ -87,6 +148,83 @@ export class MaintenanceService {
|
||||
return updated!;
|
||||
}
|
||||
|
||||
/** Define/adjust how often a vehicle needs a service ("oil change every 10,000 km"). */
|
||||
async upsertInterval(dto: UpsertMaintenanceIntervalDto) {
|
||||
return this.intervalRepository.upsertInterval(
|
||||
dto.vehicleId,
|
||||
dto.maintenanceType,
|
||||
dto.serviceItem ?? null,
|
||||
dto.intervalKm ?? null,
|
||||
dto.intervalDays ?? null,
|
||||
dto.description ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
async getIntervals(vehicleId: string) {
|
||||
return this.intervalRepository.getActiveIntervals(vehicleId);
|
||||
}
|
||||
|
||||
async deactivateInterval(id: string): Promise<{ id: string; deactivated: boolean }> {
|
||||
await this.intervalRepository.deactivate(id);
|
||||
return { id, deactivated: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-schedule the next service after a completion: matched on the
|
||||
* completed schedule's (type, serviceItem) interval; one SCHEDULED row
|
||||
* carrying BOTH thresholds when the interval defines km and days —
|
||||
* whichever is crossed first makes it due.
|
||||
*/
|
||||
private async scheduleNextMaintenance(completed: MaintenanceSchedule): Promise<void> {
|
||||
try {
|
||||
const interval = await this.intervalRepository.getByVehicleAndType(
|
||||
completed.vehicleId,
|
||||
completed.maintenanceType as MaintenanceType,
|
||||
completed.serviceItem,
|
||||
);
|
||||
|
||||
if (!interval) return; // No interval defined, skip auto-scheduling
|
||||
|
||||
const now = new Date();
|
||||
const completedKm = Number(completed.odometerReading ?? 0);
|
||||
const intervalKm = Number(interval.intervalKm ?? 0);
|
||||
const intervalDays = Number(interval.intervalDays ?? 0);
|
||||
if (intervalKm <= 0 && intervalDays <= 0) return;
|
||||
|
||||
const nextDueKm = intervalKm > 0 ? completedKm + intervalKm : undefined;
|
||||
const nextDueDate =
|
||||
intervalDays > 0
|
||||
? new Date(now.getTime() + intervalDays * 24 * 60 * 60 * 1000)
|
||||
: undefined;
|
||||
|
||||
const label = interval.serviceItem ? `${interval.serviceItem}: ` : '';
|
||||
const due = [
|
||||
nextDueKm != null ? `${nextDueKm} km` : null,
|
||||
nextDueDate != null ? nextDueDate.toISOString().slice(0, 10) : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' / ');
|
||||
|
||||
await this.scheduleRepository.save(
|
||||
this.scheduleRepository.create({
|
||||
vehicleId: completed.vehicleId,
|
||||
maintenanceType: completed.maintenanceType,
|
||||
serviceItem: completed.serviceItem ?? interval.serviceItem ?? null,
|
||||
description: `${label}${interval.description || completed.description} (next due: ${due})`,
|
||||
scheduledDate: now,
|
||||
nextDueKm,
|
||||
nextDueDate,
|
||||
status: MaintenanceStatus.SCHEDULED,
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Failed to schedule next maintenance for vehicle ${completed.vehicleId}: ${(err as Error).message}`,
|
||||
(err as Error).stack,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async getUpcomingMaintenance(vehicleId: string) {
|
||||
return this.maintenanceRepository.getUpcomingMaintenance(vehicleId);
|
||||
}
|
||||
|
||||
@@ -16,7 +16,8 @@ import {
|
||||
} from "@nestjs/swagger";
|
||||
import { Response } from "express";
|
||||
import { Public } from "@edr/api-common";
|
||||
import { BookingView } from "../../common/booking-guards";
|
||||
import { BookingStaff, BookingView } from "../../common/booking-guards";
|
||||
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||
import { PaymentService } from "./payment.service";
|
||||
import { IntentStatusDto } from "./payments.dto";
|
||||
|
||||
@@ -25,7 +26,9 @@ import { IntentStatusDto } from "./payments.dto";
|
||||
export class PaymentController {
|
||||
constructor(private readonly paymentService: PaymentService) { }
|
||||
|
||||
// Customer-detail payments tab — same one-of rule as the bookings tab.
|
||||
@Get("by-company/:companyId/customer-view")
|
||||
@BookingStaff([FREIGHT_PERMS.customers.view, FREIGHT_PERMS.payments.view])
|
||||
@ApiOperation({ summary: "List payments for a company (customer-view shape, backoffice)" })
|
||||
findByCompanyCustomerView(
|
||||
@Param("companyId", ParseUUIDPipe) companyId: string,
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
IsOptional,
|
||||
IsEnum,
|
||||
IsBoolean,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
import { VendorType } from '../entities/vendor.entity';
|
||||
import { AcquisitionType, AcquisitionStatus } from '../entities/asset-acquisition.entity';
|
||||
@@ -72,6 +73,11 @@ export class UpdateVendorDto {
|
||||
}
|
||||
|
||||
export class CreateAcquisitionDto {
|
||||
/** WHAT was acquired — required so an acquisition can't be saved empty. */
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
itemName!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
vehicleId?: string;
|
||||
@@ -120,6 +126,11 @@ export class CreateAcquisitionDto {
|
||||
}
|
||||
|
||||
export class UpdateAcquisitionDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
itemName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
vehicleId?: string;
|
||||
|
||||
@@ -18,6 +18,12 @@ export enum AcquisitionStatus {
|
||||
@Entity({ name: 'asset_acquisitions', schema: 'freight' })
|
||||
@Index(['vehicleId', 'acquisitionDate'])
|
||||
export class AssetAcquisition extends BaseEntity {
|
||||
/** WHAT was acquired (vehicle, parts, equipment…) — the asset itself. */
|
||||
@Column({ name: 'item_name', type: 'varchar', length: 200, nullable: true })
|
||||
itemName?: string;
|
||||
|
||||
/** Optional link — only when the acquisition IS a fleet vehicle. Parts and
|
||||
* general procurement stay unlinked so reports don't misattribute them. */
|
||||
@Column({ name: 'vehicle_id', type: 'uuid', nullable: true })
|
||||
vehicleId?: string;
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
import { ProcurementService } from './procurement.service';
|
||||
import { AcquisitionType } from './entities/asset-acquisition.entity';
|
||||
|
||||
// PURCHASE acquisitions must not carry lease terms; LEASE/RENTAL may.
|
||||
describe('ProcurementService acquisition lease-field guard', () => {
|
||||
const repo = {
|
||||
createAcquisition: jest.fn(async (dto) => dto),
|
||||
findAcquisitionById: jest.fn(async () => ({ acquisitionType: AcquisitionType.PURCHASE })),
|
||||
updateAcquisition: jest.fn(async (_id, dto) => dto),
|
||||
};
|
||||
const svc = new ProcurementService(repo as never);
|
||||
|
||||
it('rejects a PURCHASE with lease dates', async () => {
|
||||
await expect(
|
||||
svc.createAcquisition({
|
||||
itemName: 'Brake pads',
|
||||
acquisitionType: AcquisitionType.PURCHASE,
|
||||
acquisitionDate: '2026-07-22',
|
||||
leaseStart: '2026-07-01',
|
||||
} as never),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('accepts a LEASE with lease dates and a plain PURCHASE', async () => {
|
||||
await expect(
|
||||
svc.createAcquisition({
|
||||
itemName: 'Rented crane',
|
||||
acquisitionType: AcquisitionType.LEASE,
|
||||
acquisitionDate: '2026-07-22',
|
||||
leaseStart: '2026-07-01',
|
||||
leaseEnd: '2027-07-01',
|
||||
} as never),
|
||||
).resolves.toBeDefined();
|
||||
await expect(
|
||||
svc.createAcquisition({
|
||||
itemName: 'Brake pads',
|
||||
acquisitionType: AcquisitionType.PURCHASE,
|
||||
acquisitionDate: '2026-07-22',
|
||||
} as never),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it('rejects adding lease terms to an acquisition that is a PURCHASE', async () => {
|
||||
await expect(
|
||||
svc.updateAcquisition('a1', { monthlyPayment: 500 } as never),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { ProcurementRepository } from './procurement.repository';
|
||||
import { Vendor } from './entities/vendor.entity';
|
||||
import { AssetAcquisition } from './entities/asset-acquisition.entity';
|
||||
import { AcquisitionType, AssetAcquisition } from './entities/asset-acquisition.entity';
|
||||
import { AssetDisposal } from './entities/asset-disposal.entity';
|
||||
import {
|
||||
CreateVendorDto,
|
||||
@@ -51,7 +51,23 @@ export class ProcurementService {
|
||||
}
|
||||
|
||||
// ---- Acquisitions ----
|
||||
/** Lease terms only make sense on LEASE / RENTAL — a PURCHASE must not carry them. */
|
||||
private assertLeaseFieldsValid(dto: {
|
||||
acquisitionType?: string;
|
||||
leaseStart?: string;
|
||||
leaseEnd?: string;
|
||||
monthlyPayment?: number;
|
||||
}): void {
|
||||
if (dto.acquisitionType !== AcquisitionType.PURCHASE) return;
|
||||
if (dto.leaseStart || dto.leaseEnd || dto.monthlyPayment != null) {
|
||||
throw new BadRequestException(
|
||||
'Lease start/end and monthly payment are not valid for a PURCHASE acquisition',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async createAcquisition(dto: CreateAcquisitionDto): Promise<AssetAcquisition> {
|
||||
this.assertLeaseFieldsValid(dto);
|
||||
return this.procurementRepository.createAcquisition(dto);
|
||||
}
|
||||
|
||||
@@ -64,6 +80,20 @@ export class ProcurementService {
|
||||
}
|
||||
|
||||
async updateAcquisition(id: string, dto: UpdateAcquisitionDto): Promise<AssetAcquisition | null> {
|
||||
// Validate against the resulting record, not just the patch — switching an
|
||||
// acquisition to PURCHASE must also shed any stored lease terms.
|
||||
const existing = await this.procurementRepository.findAcquisitionById(id);
|
||||
if (existing) {
|
||||
const next = { ...existing, ...dto };
|
||||
if (next.acquisitionType === AcquisitionType.PURCHASE) {
|
||||
this.assertLeaseFieldsValid({
|
||||
acquisitionType: next.acquisitionType,
|
||||
leaseStart: dto.leaseStart,
|
||||
leaseEnd: dto.leaseEnd,
|
||||
monthlyPayment: dto.monthlyPayment,
|
||||
});
|
||||
}
|
||||
}
|
||||
return this.procurementRepository.updateAcquisition(id, dto);
|
||||
}
|
||||
|
||||
|
||||
@@ -2831,13 +2831,12 @@ export class TrainSchedulingService {
|
||||
return [
|
||||
`<tr class="empty">
|
||||
${wagonCells}
|
||||
<td colspan="6">EMPTY — no cargo allocated</td>
|
||||
<td colspan="4">EMPTY — no cargo allocated</td>
|
||||
</tr>`,
|
||||
];
|
||||
}
|
||||
return allocations.map((allocation) => {
|
||||
const booking = allocation.booking ?? bookingById.get(allocation.bookingId);
|
||||
const company = booking?.company as Record<string, unknown> | null | undefined;
|
||||
const cargoType = (booking as unknown as { cargoType?: { name?: string; code?: string } } | undefined)?.cargoType;
|
||||
const containerItems = allocation.containerItems ?? [];
|
||||
const firstContainer = containerItems[0];
|
||||
@@ -2846,8 +2845,6 @@ export class TrainSchedulingService {
|
||||
const chassisNumbers = containerItems.map((item) => item.chassisNumber).filter(Boolean).join(', ');
|
||||
return `<tr>
|
||||
${wagonCells}
|
||||
<td>${esc(company?.name ?? company?.legalName ?? company?.tradeName ?? booking?.companyId)}</td>
|
||||
<td>${esc(booking?.companyId)}</td>
|
||||
<td>${esc(cargoType?.name ?? cargoType?.code ?? allocation.loadType)}</td>
|
||||
<td>${esc(containerNumbers || firstContainer?.containerNumber)}</td>
|
||||
<td>${esc(chassisNumbers)}</td>
|
||||
@@ -2929,8 +2926,6 @@ export class TrainSchedulingService {
|
||||
<th class="num">Equated Length</th>
|
||||
<th class="num">Tare Weight</th>
|
||||
<th class="num">Load Capacity</th>
|
||||
<th>Customer Name</th>
|
||||
<th>Customer ID</th>
|
||||
<th>Cargo Type</th>
|
||||
<th>Container No</th>
|
||||
<th>Chassis No</th>
|
||||
@@ -2938,7 +2933,7 @@ export class TrainSchedulingService {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${rows || '<tr><td colspan="12">No wagons on this train set.</td></tr>'}
|
||||
${rows || '<tr><td colspan="10">No wagons on this train set.</td></tr>'}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
@@ -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,79 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
RuleEngineCreate,
|
||||
RuleEngineDelete,
|
||||
RuleEngineUpdate,
|
||||
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()
|
||||
@RuleEngineCreate('truck-types')
|
||||
@ApiOperation({ summary: 'Create a truck type' })
|
||||
create(@Body() dto: CreateTruckTypeDto) {
|
||||
return this.truckTypesService.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RuleEngineUpdate('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')
|
||||
@RuleEngineDelete('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 { Transform } from 'class-transformer';
|
||||
import { VehicleType, FuelType, VehicleStatus, VehicleAvailability } from '../entities/vehicle.entity';
|
||||
import {
|
||||
FuelType,
|
||||
VehicleAvailability,
|
||||
VehicleOwnership,
|
||||
VehicleStatus,
|
||||
} from '../entities/vehicle.entity';
|
||||
|
||||
/**
|
||||
* A vehicle plate is two or three letters, a hyphen, then two to six digits —
|
||||
@@ -28,8 +33,9 @@ export class CreateVehicleDto {
|
||||
@IsString()
|
||||
plateNumber!: string;
|
||||
|
||||
@IsEnum(VehicleType)
|
||||
vehicleType!: VehicleType;
|
||||
/** Truck configuration from `freight.truck_types` — drives capacity and whether a trailer plate applies. */
|
||||
@IsUUID()
|
||||
truckTypeId!: string;
|
||||
|
||||
@IsString()
|
||||
manufacturer!: string;
|
||||
@@ -43,8 +49,18 @@ export class CreateVehicleDto {
|
||||
@IsEnum(FuelType)
|
||||
fuelType!: FuelType;
|
||||
|
||||
/** Defaults to the truck type's capacity when omitted. */
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
capacity!: number;
|
||||
capacity?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
vin?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(VehicleOwnership)
|
||||
ownership?: VehicleOwnership;
|
||||
|
||||
@IsEnum(VehicleStatus)
|
||||
status!: VehicleStatus;
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import { Entity, Column } from 'typeorm';
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
|
||||
/**
|
||||
* Legacy classification. Truck configurations are now back-office data in
|
||||
* `freight.truck_types` — register a vehicle with `truckTypeId`, not this.
|
||||
*
|
||||
* The `vehicle_type` COLUMN survives as a denormalised copy of the truck type's
|
||||
* code because truck-detention billing groups by it in raw SQL and matches it
|
||||
* against `warehouse_fee_rules.vehicle_type`. The service writes it through on
|
||||
* every save; nothing should set it by hand.
|
||||
*
|
||||
* @deprecated use `truckTypeId` / `freight.truck_types`
|
||||
*/
|
||||
export enum VehicleType {
|
||||
TRUCK = 'TRUCK',
|
||||
VAN = 'VAN',
|
||||
@@ -11,6 +22,12 @@ export enum VehicleType {
|
||||
FLATBED = 'FLATBED',
|
||||
}
|
||||
|
||||
/** Who supplies the truck. Supplier selection is deferred until EDR commits to outsourcing. */
|
||||
export enum VehicleOwnership {
|
||||
OWNED = 'OWNED',
|
||||
OUTSOURCED = 'OUTSOURCED',
|
||||
}
|
||||
|
||||
export enum FuelType {
|
||||
PETROL = 'PETROL',
|
||||
DIESEL = 'DIESEL',
|
||||
@@ -47,8 +64,12 @@ export class Vehicle extends BaseEntity {
|
||||
@Column({ name: 'registration_number', unique: true, nullable: true })
|
||||
registrationNumber?: string;
|
||||
|
||||
/** Denormalised `truck_types.code` — written through by the service, never set by hand. */
|
||||
@Column({ name: 'vehicle_type', type: 'varchar', nullable: true })
|
||||
vehicleType?: VehicleType;
|
||||
vehicleType?: string;
|
||||
|
||||
@Column({ name: 'truck_type_id', type: 'uuid', nullable: true })
|
||||
truckTypeId?: string | null;
|
||||
|
||||
@Column({ nullable: true })
|
||||
manufacturer?: string;
|
||||
@@ -101,7 +122,7 @@ export class Vehicle extends BaseEntity {
|
||||
@Column({ name: 'vin', type: 'varchar', nullable: true })
|
||||
vin?: string;
|
||||
|
||||
/** Owned | Leased | Rented */
|
||||
/** OWNED | OUTSOURCED — see {@link VehicleOwnership}. */
|
||||
@Column({ name: 'ownership', type: 'varchar', nullable: true })
|
||||
ownership?: string;
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ describe('VehiclesService driver assignment guard', () => {
|
||||
new VehiclesService(
|
||||
{ findOne, create: jest.fn((x) => x), save: jest.fn(async (x) => x) } as any,
|
||||
{ record: jest.fn() } as any,
|
||||
{ findById: jest.fn(async () => ({ code: 'TRUCK', name: 'Truck', hasTrailer: true })) } as any,
|
||||
);
|
||||
|
||||
it('rejects create when the driver is on another truck', async () => {
|
||||
@@ -18,7 +19,7 @@ describe('VehiclesService driver assignment guard', () => {
|
||||
const findOne = jest.fn().mockResolvedValueOnce(null).mockResolvedValueOnce(otherTruck);
|
||||
const svc = makeService(findOne);
|
||||
await expect(
|
||||
svc.create({ plateNumber: '3-22222', vehicleType: 'TRUCK', assignedDriverId: 'd1' } as any),
|
||||
svc.create({ plateNumber: '3-22222', truckTypeId: 'tt1', assignedDriverId: 'd1' } as any),
|
||||
).rejects.toThrow(ConflictException);
|
||||
});
|
||||
|
||||
|
||||
@@ -3,9 +3,10 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Vehicle } from './entities/vehicle.entity';
|
||||
import { VehiclesService } from './vehicles.service';
|
||||
import { VehiclesController } from './vehicles.controller';
|
||||
import { TruckTypesModule } from '../truck-types/truck-types.module';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Vehicle])],
|
||||
imports: [TypeOrmModule.forFeature([Vehicle]), TruckTypesModule],
|
||||
providers: [VehiclesService],
|
||||
controllers: [VehiclesController],
|
||||
exports: [VehiclesService],
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Not, Repository } from 'typeorm';
|
||||
import { CreateVehicleDto } from './dto/create-vehicle.dto';
|
||||
import { UpdateVehicleDto } from './dto/update-vehicle.dto';
|
||||
import { Vehicle, VehicleAvailability, VehicleStatus } from './entities/vehicle.entity';
|
||||
import { TruckType } from '../truck-types/entities/truck-type.entity';
|
||||
import { TruckTypesService } from '../truck-types/truck-types.service';
|
||||
import { FirstMile, FirstMileStatus } from '../first-mile/entities/first-mile.entity';
|
||||
import { FirstMileContainerAllocation } from '../first-mile/entities/first-mile-container-allocation.entity';
|
||||
import { LastMile, LastMileStatus } from '../last-mile/entities/last-mile.entity';
|
||||
@@ -18,8 +25,26 @@ export class VehiclesService {
|
||||
@InjectRepository(Vehicle)
|
||||
private readonly vehicleRepo: Repository<Vehicle>,
|
||||
private readonly history: FleetHistoryService,
|
||||
private readonly truckTypes: TruckTypesService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* A trailer plate only exists on a configuration that pulls a trailer — a
|
||||
* rigid truck (Casoni) has none. Checked against the RESULTING record, not
|
||||
* just the patch, so switching an articulated truck to a rigid type cannot
|
||||
* leave its old trailer plate stranded on the row.
|
||||
*/
|
||||
private assertTrailerPlateAllowed(
|
||||
truckType: TruckType,
|
||||
trailerPlateNo?: string | null,
|
||||
): void {
|
||||
if (!truckType.hasTrailer && trailerPlateNo) {
|
||||
throw new BadRequestException(
|
||||
`${truckType.name} has no trailer — remove the trailer plate number`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A driver holds one truck at a time — reassignment requires detaching them
|
||||
* from their current truck first.
|
||||
@@ -54,10 +79,17 @@ export class VehiclesService {
|
||||
await this.assertDriverUnassigned(dto.assignedDriverId);
|
||||
}
|
||||
|
||||
const registrationNumber = `REG-${dto.vehicleType}-${Date.now()}`;
|
||||
const truckType = await this.truckTypes.findById(dto.truckTypeId);
|
||||
this.assertTrailerPlateAllowed(truckType, dto.trailerPlateNo);
|
||||
|
||||
const registrationNumber = `REG-${truckType.code}-${Date.now()}`;
|
||||
const vehicle = this.vehicleRepo.create({
|
||||
...dto,
|
||||
registrationNumber,
|
||||
// Denormalised for truck-detention billing, which groups on this column.
|
||||
vehicleType: truckType.code,
|
||||
// Capacity belongs to the type; an explicit value still wins for one-offs.
|
||||
capacity: dto.capacity ?? truckType.capacityTons ?? undefined,
|
||||
});
|
||||
|
||||
const saved = await this.vehicleRepo.save(vehicle);
|
||||
@@ -148,6 +180,17 @@ export class VehiclesService {
|
||||
await this.assertDriverUnassigned(dto.assignedDriverId, id);
|
||||
}
|
||||
|
||||
// Re-resolve the truck type whenever the type OR the trailer plate moves —
|
||||
// either edit can produce a rigid truck holding a trailer plate.
|
||||
const nextTruckTypeId = dto.truckTypeId ?? vehicle.truckTypeId;
|
||||
let nextTruckType: TruckType | null = null;
|
||||
if (nextTruckTypeId && (dto.truckTypeId !== undefined || dto.trailerPlateNo !== undefined)) {
|
||||
nextTruckType = await this.truckTypes.findById(nextTruckTypeId);
|
||||
const nextTrailerPlate =
|
||||
dto.trailerPlateNo !== undefined ? dto.trailerPlateNo : vehicle.trailerPlateNo;
|
||||
this.assertTrailerPlateAllowed(nextTruckType, nextTrailerPlate);
|
||||
}
|
||||
|
||||
const prev = {
|
||||
assignedDriverId: vehicle.assignedDriverId,
|
||||
assignedDriverName: vehicle.assignedDriverName,
|
||||
@@ -156,6 +199,11 @@ export class VehiclesService {
|
||||
};
|
||||
|
||||
Object.assign(vehicle, dto);
|
||||
// After the patch is applied, so the denormalised billing code always
|
||||
// reflects the type the vehicle actually ends up on.
|
||||
if (nextTruckType) {
|
||||
vehicle.vehicleType = nextTruckType.code;
|
||||
}
|
||||
const saved = await this.vehicleRepo.save(vehicle);
|
||||
|
||||
// Driver (re)assignment — emit an unassign for the old driver and/or an
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -52,4 +52,8 @@ export class BookingHandover extends BaseEntity {
|
||||
/** EDR last-mile: when the goods were delivered to the customer. */
|
||||
@Column({ name: 'delivered_at', type: 'timestamptz', nullable: true })
|
||||
deliveredAt?: Date | null;
|
||||
|
||||
/** URL to the signer's saved signature image, if available at sign time. */
|
||||
@Column({ name: 'signature_image_url', type: 'text', nullable: true })
|
||||
signatureImageUrl?: string | null;
|
||||
}
|
||||
|
||||
@@ -287,6 +287,7 @@ export class HandoverService {
|
||||
handoverId: string,
|
||||
userId?: string | null,
|
||||
signerName?: string | null,
|
||||
signatureImageUrl?: string | null,
|
||||
): Promise<BookingHandover> {
|
||||
const repo = this.dataSource.getRepository(BookingHandover);
|
||||
const handover = await repo.findOne({ where: { id: handoverId } });
|
||||
@@ -297,6 +298,7 @@ export class HandoverService {
|
||||
handover.signedAt = new Date();
|
||||
handover.signedByUserId = userId ?? null;
|
||||
handover.signerName = signerName?.trim() || null;
|
||||
handover.signatureImageUrl = signatureImageUrl ?? null;
|
||||
return repo.save(handover);
|
||||
}
|
||||
|
||||
@@ -305,6 +307,7 @@ export class HandoverService {
|
||||
bookingId: string,
|
||||
userId?: string | null,
|
||||
signerName?: string | null,
|
||||
signatureImageUrl?: string | null,
|
||||
): Promise<void> {
|
||||
await this.dataSource
|
||||
.getRepository(BookingHandover)
|
||||
@@ -314,6 +317,7 @@ export class HandoverService {
|
||||
signedAt: new Date(),
|
||||
signedByUserId: userId ?? null,
|
||||
signerName: signerName?.trim() || null,
|
||||
signatureImageUrl: signatureImageUrl ?? null,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -791,15 +791,22 @@ export class WarehouseFeeService {
|
||||
};
|
||||
}
|
||||
|
||||
// Group the leg's vehicles by type so each truck type is billed by its own
|
||||
// matching rule (rates differ by truck type). Falls back to one untyped group.
|
||||
// Group the leg's vehicles by CANONICAL truck type so each type is billed
|
||||
// by its own matching rule (rates differ by truck type). The FK to
|
||||
// truck_types is the source of truth — renaming a type's label no longer
|
||||
// silently unmatches its rule; the normalized legacy vehicle_type code is
|
||||
// only a fallback for vehicles without the FK (LEFT JOIN keeps them billed
|
||||
// instead of dropping them). Falls back to one untyped group.
|
||||
const groupRows: Array<{ vehicleType: string | null; truckCount: number | string }> =
|
||||
await this.dataSource.query(
|
||||
`SELECT v.vehicle_type AS "vehicleType", count(*)::int AS "truckCount"
|
||||
`SELECT COALESCE(t.code, NULLIF(UPPER(TRIM(v.vehicle_type)), '')) AS "vehicleType",
|
||||
count(*)::int AS "truckCount"
|
||||
FROM freight.last_mile_vehicle_assignments va
|
||||
JOIN freight.vehicles v ON v.id = va.vehicle_id AND v.deleted_at IS NULL
|
||||
LEFT JOIN freight.truck_types t
|
||||
ON t.id = v.truck_type_id AND t.deleted_at IS NULL
|
||||
WHERE va.last_mile_id = $1 AND va.deleted_at IS NULL
|
||||
GROUP BY v.vehicle_type`,
|
||||
GROUP BY 1`,
|
||||
[lastMileId],
|
||||
);
|
||||
const groups = groupRows.length ? groupRows : [{ vehicleType: null, truckCount: 1 }];
|
||||
|
||||
@@ -20,8 +20,13 @@ import { WarehouseInspectionService } from './warehouse-inspection.service';
|
||||
|
||||
@ApiTags('warehouse-inspection')
|
||||
@ApiBearerAuth()
|
||||
// Baseline read: inspection reports are opened from inventory screens too —
|
||||
// either view permission grants reads; writes stack their own per route.
|
||||
@Controller()
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInspectionReports.view)
|
||||
@BookingStaff([
|
||||
FREIGHT_PERMS.warehouseInspectionReports.view,
|
||||
FREIGHT_PERMS.warehouseInventory.view,
|
||||
])
|
||||
export class WarehouseInspectionController {
|
||||
constructor(private readonly inspectionService: WarehouseInspectionService) {}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { CurrentUser } from '@edr/api-common';
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
|
||||
import { actorLabel } from './current-actor.util';
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
import { BookingStaff, StaffReference } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { BulkReceiveDto } from './dto/bulk-receive.dto';
|
||||
import { BulkInspectDto } from './dto/bulk-inspect.dto';
|
||||
@@ -456,6 +456,7 @@ export class WarehouseInventoryController {
|
||||
}
|
||||
|
||||
@Get(':id/handover-document')
|
||||
@StaffReference()
|
||||
@ApiOperation({ summary: 'View import goods handover document PDF' })
|
||||
async handoverDocument(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) {
|
||||
const { filename, buffer } = await this.inventoryService.handoverDocument(id);
|
||||
@@ -466,6 +467,7 @@ export class WarehouseInventoryController {
|
||||
}
|
||||
|
||||
@Post('bookings/:bookingId/approve-delivery')
|
||||
@StaffReference()
|
||||
@ApiOperation({ summary: "Approve delivery — customer records their full name (signature optional)" })
|
||||
approveDeliveryForBooking(
|
||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||
@@ -481,12 +483,14 @@ export class WarehouseInventoryController {
|
||||
}
|
||||
|
||||
@Get('bookings/:bookingId/handovers')
|
||||
@StaffReference()
|
||||
@ApiOperation({ summary: 'Handover records for a booking (per-booking or per-truck)' })
|
||||
bookingHandovers(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
||||
return this.handoverService.list(bookingId);
|
||||
}
|
||||
|
||||
@Post('handovers/:handoverId/sign')
|
||||
@StaffReference()
|
||||
@ApiOperation({ summary: 'Customer signs one handover (EDR last-mile: one signature per truck)' })
|
||||
signHandover(
|
||||
@Param('handoverId', ParseUUIDPipe) handoverId: string,
|
||||
@@ -502,12 +506,14 @@ export class WarehouseInventoryController {
|
||||
}
|
||||
|
||||
@Post('bookings/:bookingId/request-handover-signature')
|
||||
@StaffReference()
|
||||
@ApiOperation({ summary: 'Ask the customer to sign the handover (creates one if none, then notifies)' })
|
||||
requestHandoverSignature(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
||||
return this.handoverService.requestSignature(bookingId);
|
||||
}
|
||||
|
||||
@Get('bookings/:bookingId/grn-document')
|
||||
@StaffReference()
|
||||
@ApiOperation({ summary: 'View GRN PDF for a booking (customer portal)' })
|
||||
async bookingGrnDocument(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Res() res: Response) {
|
||||
const { filename, buffer } = await this.inventoryService.grnDocumentForBooking(bookingId);
|
||||
@@ -518,6 +524,7 @@ export class WarehouseInventoryController {
|
||||
}
|
||||
|
||||
@Get('bookings/:bookingId/release-document')
|
||||
@StaffReference()
|
||||
@ApiOperation({ summary: 'View gate-clearance / release-order PDF for a booking (customer portal)' })
|
||||
async bookingReleaseDocument(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Res() res: Response) {
|
||||
const { filename, buffer } = await this.inventoryService.releaseDocumentForBooking(bookingId);
|
||||
@@ -528,6 +535,7 @@ export class WarehouseInventoryController {
|
||||
}
|
||||
|
||||
@Get('bookings/:bookingId/handover-document')
|
||||
@StaffReference()
|
||||
@ApiOperation({ summary: 'View import goods handover document PDF (resolved by booking; ?handoverId= for the per-truck variant)' })
|
||||
async bookingHandoverDocument(
|
||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||
@@ -545,18 +553,21 @@ export class WarehouseInventoryController {
|
||||
}
|
||||
|
||||
@Get('bookings/:bookingId/container-items')
|
||||
@StaffReference()
|
||||
@ApiOperation({ summary: 'Per-container/bulk items of a booking with lifecycle stage + refs' })
|
||||
containerItems(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
||||
return this.inventoryService.containerItems(bookingId);
|
||||
}
|
||||
|
||||
@Get('bookings/:bookingId/container-weights')
|
||||
@StaffReference()
|
||||
@ApiOperation({ summary: "A booking's containers + VGM cargo weight (tonnes) for exit weighing" })
|
||||
containerWeights(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
||||
return this.inventoryService.bookingContainerWeights(bookingId);
|
||||
}
|
||||
|
||||
@Get('bookings/:bookingId/location')
|
||||
@StaffReference()
|
||||
@ApiOperation({ summary: "Warehouse location of a booking's inventory (customer portal)" })
|
||||
bookingLocation(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
||||
return this.inventoryService.bookingLocation(bookingId);
|
||||
|
||||
@@ -1578,6 +1578,14 @@ export class WarehouseInventoryService {
|
||||
notes: `Bulk received (${dto.direction})`,
|
||||
truckEntrance,
|
||||
});
|
||||
|
||||
// Validate capacity before saving
|
||||
const weight = Number(booking.weight) || 0;
|
||||
const containerCount = booking.freightType === 'CONTAINER' ? containerQuantity : 0;
|
||||
this.assertCapacity('Warehouse', warehouse, weight, 0, containerCount);
|
||||
this.assertCapacity('Yard', yard, weight, 0, containerCount);
|
||||
this.assertCapacity('Zone', zone, weight, 0, containerCount);
|
||||
|
||||
const saved = await manager.getRepository(WarehouseInventory).save(
|
||||
manager.getRepository(WarehouseInventory).create({
|
||||
warehouseId: dto.warehouseId,
|
||||
@@ -1585,7 +1593,7 @@ export class WarehouseInventoryService {
|
||||
zoneId: dto.zoneId,
|
||||
bookingId,
|
||||
quantity: booking.freightType === 'CONTAINER' ? containerQuantity : 1,
|
||||
weight: Number(booking.weight) || 0,
|
||||
weight,
|
||||
grnNumber,
|
||||
status: 'RECEIVED',
|
||||
arrivedAt: now,
|
||||
@@ -1593,6 +1601,9 @@ export class WarehouseInventoryService {
|
||||
}),
|
||||
);
|
||||
|
||||
// Update warehouse/yard/zone capacity counters
|
||||
await this.applyCapacityDelta(manager, dto, weight, 0, containerCount);
|
||||
|
||||
// Receiving the booking flags every container unit as received into the
|
||||
// port (self-haul export: the delivering truck's goods are now in) so
|
||||
// staff can raise the per-container GRN over what's received.
|
||||
@@ -2493,27 +2504,37 @@ export class WarehouseInventoryService {
|
||||
});
|
||||
|
||||
if (result.unloadedCount > 0) {
|
||||
let document = await this.interchangeDocuments.generateFromSchedule({
|
||||
scheduleId,
|
||||
direction: 'EXPORT',
|
||||
handoverLocation: schedule.destinationName ?? 'Djibouti Port',
|
||||
handoverFrom: 'EDR',
|
||||
handoverTo: 'Djibouti Port Operator',
|
||||
portOperatorName: 'Doraleh Multipurpose Port',
|
||||
generatedBy: performedBy ?? 'EDR Operations',
|
||||
remarks: 'Generated after export unloading at Djibouti Port; signed by EDR and Djibouti Port Operator.',
|
||||
});
|
||||
if (document.status !== 'ACKNOWLEDGED') {
|
||||
document = await this.interchangeDocuments.acknowledge(document.id, {
|
||||
acknowledgedBy: 'Djibouti Port Operator',
|
||||
remarks: 'Auto acknowledged after Djibouti export unloading.',
|
||||
// Best-effort: the unload is already committed — a paperwork failure must
|
||||
// not fail the response (it did once: items unloaded, request 500'd, and
|
||||
// the document only appeared after a manual retry days later). The doc
|
||||
// backfills on any retry since already-unloaded items count as unloaded.
|
||||
try {
|
||||
let document = await this.interchangeDocuments.generateFromSchedule({
|
||||
scheduleId,
|
||||
direction: 'EXPORT',
|
||||
handoverLocation: schedule.destinationName ?? 'Djibouti Port',
|
||||
handoverFrom: 'EDR',
|
||||
handoverTo: 'Djibouti Port Operator',
|
||||
portOperatorName: 'Doraleh Multipurpose Port',
|
||||
generatedBy: performedBy ?? 'EDR Operations',
|
||||
remarks: 'Generated after export unloading at Djibouti Port; signed by EDR and Djibouti Port Operator.',
|
||||
});
|
||||
if (document.status !== 'ACKNOWLEDGED') {
|
||||
document = await this.interchangeDocuments.acknowledge(document.id, {
|
||||
acknowledgedBy: 'Djibouti Port Operator',
|
||||
remarks: 'Auto acknowledged after Djibouti export unloading.',
|
||||
});
|
||||
}
|
||||
result.interchangeDocument = {
|
||||
id: document.id,
|
||||
documentNo: document.documentNo,
|
||||
status: document.status,
|
||||
};
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Export interchange document generation failed for schedule ${scheduleId}: ${(err as Error).message} — rerun the Djibouti unloading to regenerate it`,
|
||||
);
|
||||
}
|
||||
result.interchangeDocument = {
|
||||
id: document.id,
|
||||
documentNo: document.documentNo,
|
||||
status: document.status,
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
@@ -4076,10 +4097,11 @@ export class WarehouseInventoryService {
|
||||
await this.invoices.assertClearanceAllowed(item.id);
|
||||
|
||||
const approvedAt = new Date();
|
||||
const signatureImageUrl = signature?.signatureImageUrl ?? null;
|
||||
const approval = {
|
||||
approvedAt: approvedAt.toISOString(),
|
||||
signerDisplayName: name,
|
||||
signatureImageUrl: signature?.signatureImageUrl ?? null,
|
||||
signatureImageUrl,
|
||||
userId,
|
||||
};
|
||||
const existingNotes = this.stripCustomerDeliveryApproval(item.notes);
|
||||
@@ -4103,7 +4125,7 @@ export class WarehouseInventoryService {
|
||||
|
||||
// Sign the structured handover record(s) for this booking (self-haul: before
|
||||
// the truck leaves). Kept alongside the legacy approval note.
|
||||
await this.handover.signForBooking(bookingId, userId, name);
|
||||
await this.handover.signForBooking(bookingId, userId, name, signatureImageUrl);
|
||||
|
||||
return {
|
||||
bookingId,
|
||||
@@ -4138,6 +4160,8 @@ export class WarehouseInventoryService {
|
||||
throw new BadRequestException('Please enter your full name to sign the handover');
|
||||
}
|
||||
|
||||
const signature = await this.signatures.getForUser(userId).catch(() => null);
|
||||
|
||||
const [h]: Array<{
|
||||
bookingId: string;
|
||||
reference: string;
|
||||
@@ -4170,7 +4194,7 @@ export class WarehouseInventoryService {
|
||||
);
|
||||
if (inv) await this.invoices.assertClearanceAllowed(inv.id);
|
||||
|
||||
const signed = await this.handover.sign(handoverId, userId, name);
|
||||
const signed = await this.handover.sign(handoverId, userId, name, signature?.signatureImageUrl ?? null);
|
||||
const allSigned = await this.handover.isFullySigned(h.bookingId);
|
||||
|
||||
if (inv) {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { CurrentUser } from '@edr/api-common';
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
|
||||
import { actorLabel } from './current-actor.util';
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
import { BookingStaff, StaffReference } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { PayInvoiceDto as GatewayPayInvoiceDto } from '../billing/dto/pay-invoice.dto';
|
||||
import { GenerateInvoiceDto, PayInvoiceBodyDto } from './dto/invoice.dto';
|
||||
@@ -43,6 +43,7 @@ export class WarehouseInvoiceController {
|
||||
}
|
||||
|
||||
@Get('bookings/:id/warehouse-fee-invoices')
|
||||
@StaffReference()
|
||||
@ApiOperation({ summary: 'List warehouse fee invoices for a booking' })
|
||||
listForBooking(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.invoiceService.listForBooking(id);
|
||||
@@ -70,12 +71,14 @@ export class WarehouseInvoiceController {
|
||||
}
|
||||
|
||||
@Get('warehouse-fee-invoices/:id')
|
||||
@StaffReference()
|
||||
@ApiOperation({ summary: 'Get a warehouse fee invoice with items + payment history' })
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.invoiceService.findById(id);
|
||||
}
|
||||
|
||||
@Get('warehouse-fee-invoices/:id/document')
|
||||
@StaffReference()
|
||||
@ApiOperation({ summary: 'Download sealed warehouse fee invoice PDF' })
|
||||
async document(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) {
|
||||
const { filename, buffer } = await this.invoiceService.document(id);
|
||||
@@ -86,6 +89,7 @@ export class WarehouseInvoiceController {
|
||||
}
|
||||
|
||||
@Get('warehouse-fee-invoices/:id/receipt')
|
||||
@StaffReference()
|
||||
@ApiOperation({ summary: 'Download sealed warehouse fee payment receipt PDF' })
|
||||
async receipt(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) {
|
||||
const { filename, buffer } = await this.invoiceService.receipt(id);
|
||||
@@ -110,6 +114,7 @@ export class WarehouseInvoiceController {
|
||||
}
|
||||
|
||||
@Post('warehouse-fee-invoices/:id/pay-online')
|
||||
@StaffReference()
|
||||
@ApiOperation({ summary: 'Initiate Telebirr/Waafi payment for a warehouse fee invoice' })
|
||||
payOnline(@Param('id', ParseUUIDPipe) id: string, @Body() dto: GatewayPayInvoiceDto) {
|
||||
return this.invoiceService.initiatePayment(id, dto);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { BookingStaff, StaffReference } from '../../common/booking-guards';
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { CreateWarehouseZoneDto } from './dto/create-warehouse-zone.dto';
|
||||
import { UpdateWarehouseYardDto } from './dto/update-warehouse-yard.dto';
|
||||
@@ -10,8 +10,8 @@ import { WarehouseZonesService } from './warehouse-zones.service';
|
||||
|
||||
@ApiTags('warehouse-yards')
|
||||
@ApiBearerAuth()
|
||||
// No class-level guard: the two reference GETs are open to any signed-in
|
||||
// staff (StaffReference), every other route carries its own permission.
|
||||
// No class-level guard: every route carries its own permission (reads accept
|
||||
// yard-view OR inventory-view so inventory flows can populate yard pickers).
|
||||
@Controller('warehouse-yards')
|
||||
export class WarehouseYardsController {
|
||||
constructor(
|
||||
@@ -20,14 +20,14 @@ export class WarehouseYardsController {
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@StaffReference()
|
||||
@BookingStaff([FREIGHT_PERMS.warehouseYards.view, FREIGHT_PERMS.warehouseInventory.view])
|
||||
@ApiOperation({ summary: 'List all warehouse yards' })
|
||||
findAll() {
|
||||
return this.yardsService.findAll();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@StaffReference()
|
||||
@BookingStaff([FREIGHT_PERMS.warehouseYards.view, FREIGHT_PERMS.warehouseInventory.view])
|
||||
@ApiOperation({ summary: 'Get warehouse yard by ID' })
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.yardsService.findById(id);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
|
||||
import { CreateWarehouseYardDto } from './dto/create-warehouse-yard.dto';
|
||||
import { UpdateWarehouseYardDto } from './dto/update-warehouse-yard.dto';
|
||||
@@ -44,6 +44,7 @@ export class WarehouseYardsService {
|
||||
// Ensure the parent warehouse exists.
|
||||
await this.warehousesService.findById(warehouseId);
|
||||
await this.assertCodeUnique(warehouseId, dto.code.trim());
|
||||
await this.assertCapacityWithinWarehouse(warehouseId, dto.capacityWeight ?? null, dto.capacityContainers ?? null);
|
||||
|
||||
return this.yardsRepository.create({
|
||||
warehouseId,
|
||||
@@ -69,14 +70,22 @@ export class WarehouseYardsService {
|
||||
await this.assertCodeUnique(existing.warehouseId, dto.code.trim(), id);
|
||||
}
|
||||
|
||||
const newCapacityWeight = dto.capacityWeight ?? existing.capacityWeight ?? null;
|
||||
const newCapacityContainers = dto.capacityContainers ?? existing.capacityContainers ?? null;
|
||||
|
||||
// Validate updated capacity doesn't exceed warehouse limits
|
||||
if (newCapacityWeight !== (existing.capacityWeight ?? null) || newCapacityContainers !== (existing.capacityContainers ?? null)) {
|
||||
await this.assertCapacityWithinWarehouse(existing.warehouseId, newCapacityWeight, newCapacityContainers, id);
|
||||
}
|
||||
|
||||
const status = dto.status ?? existing.status;
|
||||
|
||||
const updated = await this.yardsRepository.update(id, {
|
||||
name: dto.name?.trim() ?? existing.name,
|
||||
code: dto.code?.trim() ?? existing.code,
|
||||
type: dto.type ?? existing.type,
|
||||
capacityWeight: dto.capacityWeight ?? existing.capacityWeight,
|
||||
capacityContainers: dto.capacityContainers ?? existing.capacityContainers,
|
||||
capacityWeight: newCapacityWeight,
|
||||
capacityContainers: newCapacityContainers,
|
||||
maxWeight: dto.maxWeight ?? existing.maxWeight,
|
||||
maxVolume: dto.maxVolume ?? existing.maxVolume,
|
||||
status,
|
||||
@@ -97,4 +106,39 @@ export class WarehouseYardsService {
|
||||
throw new ConflictException(`Yard code ${code} already exists in this warehouse`);
|
||||
}
|
||||
}
|
||||
|
||||
private async assertCapacityWithinWarehouse(
|
||||
warehouseId: string,
|
||||
newCapacityWeight: number | null,
|
||||
newCapacityContainers: number | null,
|
||||
excludeYardId?: string,
|
||||
): Promise<void> {
|
||||
const warehouse = await this.warehousesService.findById(warehouseId);
|
||||
const yards = await this.findByWarehouse(warehouseId);
|
||||
|
||||
// Sum existing yard capacities, excluding the yard being updated if provided
|
||||
const otherYards = excludeYardId ? yards.filter((y) => y.id !== excludeYardId) : yards;
|
||||
const totalExistingWeight = otherYards.reduce((sum, y) => sum + (y.capacityWeight ?? 0), 0);
|
||||
const totalExistingContainers = otherYards.reduce((sum, y) => sum + (y.capacityContainers ?? 0), 0);
|
||||
|
||||
// Check weight capacity
|
||||
if (newCapacityWeight !== null && warehouse.capacityWeight != null) {
|
||||
const totalWeight = totalExistingWeight + newCapacityWeight;
|
||||
if (totalWeight > warehouse.capacityWeight) {
|
||||
throw new BadRequestException(
|
||||
`Total yard weight capacity (${totalWeight}t) exceeds warehouse limit (${warehouse.capacityWeight}t)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check container capacity
|
||||
if (newCapacityContainers !== null && warehouse.capacityContainers != null) {
|
||||
const totalContainers = totalExistingContainers + newCapacityContainers;
|
||||
if (totalContainers > warehouse.capacityContainers) {
|
||||
throw new BadRequestException(
|
||||
`Total yard container capacity (${totalContainers}) exceeds warehouse limit (${warehouse.capacityContainers})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,11 @@ import { WarehouseZonesService } from './warehouse-zones.service';
|
||||
|
||||
@ApiTags('warehouse-zones')
|
||||
@ApiBearerAuth()
|
||||
// Baseline read: zone reference data also serves inventory flows (allocation,
|
||||
// receive/move pickers) — either view permission grants reads; writes stack
|
||||
// their specific permission per route.
|
||||
@Controller('warehouse-zones')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseZones.view)
|
||||
@BookingStaff([FREIGHT_PERMS.warehouseZones.view, FREIGHT_PERMS.warehouseInventory.view])
|
||||
export class WarehouseZonesController {
|
||||
constructor(private readonly zonesService: WarehouseZonesService) {}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
|
||||
import { CreateWarehouseZoneDto } from './dto/create-warehouse-zone.dto';
|
||||
import { UpdateWarehouseZoneDto } from './dto/update-warehouse-zone.dto';
|
||||
@@ -43,6 +43,7 @@ export class WarehouseZonesService {
|
||||
// Ensure the parent yard exists.
|
||||
await this.yardsService.findById(yardId);
|
||||
await this.assertCodeUnique(yardId, dto.code.trim());
|
||||
await this.assertCapacityWithinYard(yardId, dto.capacityWeight ?? null, dto.capacityContainers ?? null);
|
||||
|
||||
return this.zonesRepository.create({
|
||||
yardId,
|
||||
@@ -68,14 +69,22 @@ export class WarehouseZonesService {
|
||||
await this.assertCodeUnique(existing.yardId, dto.code.trim(), id);
|
||||
}
|
||||
|
||||
const newCapacityWeight = dto.capacityWeight ?? existing.capacityWeight ?? null;
|
||||
const newCapacityContainers = dto.capacityContainers ?? existing.capacityContainers ?? null;
|
||||
|
||||
// Validate updated capacity doesn't exceed yard limits
|
||||
if (newCapacityWeight !== (existing.capacityWeight ?? null) || newCapacityContainers !== (existing.capacityContainers ?? null)) {
|
||||
await this.assertCapacityWithinYard(existing.yardId, newCapacityWeight, newCapacityContainers, id);
|
||||
}
|
||||
|
||||
const status = dto.status ?? existing.status;
|
||||
|
||||
const updated = await this.zonesRepository.update(id, {
|
||||
name: dto.name?.trim() ?? existing.name,
|
||||
code: dto.code?.trim() ?? existing.code,
|
||||
type: dto.type ?? existing.type,
|
||||
capacityWeight: dto.capacityWeight ?? existing.capacityWeight,
|
||||
capacityContainers: dto.capacityContainers ?? existing.capacityContainers,
|
||||
capacityWeight: newCapacityWeight,
|
||||
capacityContainers: newCapacityContainers,
|
||||
maxWeight: dto.maxWeight ?? existing.maxWeight,
|
||||
maxVolume: dto.maxVolume ?? existing.maxVolume,
|
||||
status,
|
||||
@@ -96,4 +105,39 @@ export class WarehouseZonesService {
|
||||
throw new ConflictException(`Zone code ${code} already exists in this yard`);
|
||||
}
|
||||
}
|
||||
|
||||
private async assertCapacityWithinYard(
|
||||
yardId: string,
|
||||
newCapacityWeight: number | null,
|
||||
newCapacityContainers: number | null,
|
||||
excludeZoneId?: string,
|
||||
): Promise<void> {
|
||||
const yard = await this.yardsService.findById(yardId);
|
||||
const zones = await this.findByYard(yardId);
|
||||
|
||||
// Sum existing zone capacities, excluding the zone being updated if provided
|
||||
const otherZones = excludeZoneId ? zones.filter((z) => z.id !== excludeZoneId) : zones;
|
||||
const totalExistingWeight = otherZones.reduce((sum, z) => sum + (z.capacityWeight ?? 0), 0);
|
||||
const totalExistingContainers = otherZones.reduce((sum, z) => sum + (z.capacityContainers ?? 0), 0);
|
||||
|
||||
// Check weight capacity
|
||||
if (newCapacityWeight !== null && yard.capacityWeight != null) {
|
||||
const totalWeight = totalExistingWeight + newCapacityWeight;
|
||||
if (totalWeight > yard.capacityWeight) {
|
||||
throw new BadRequestException(
|
||||
`Total zone weight capacity (${totalWeight}t) exceeds yard limit (${yard.capacityWeight}t)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check container capacity
|
||||
if (newCapacityContainers !== null && yard.capacityContainers != null) {
|
||||
const totalContainers = totalExistingContainers + newCapacityContainers;
|
||||
if (totalContainers > yard.capacityContainers) {
|
||||
throw new BadRequestException(
|
||||
`Total zone container capacity (${totalContainers}) exceeds yard limit (${yard.capacityContainers})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,8 +13,15 @@ import { WarehousesService } from './warehouses.service';
|
||||
|
||||
@ApiTags('warehouses')
|
||||
@ApiBearerAuth()
|
||||
// Baseline read: warehouse reference data is consumed by inventory/dashboard
|
||||
// flows too, so any of the three view permissions grants reads. Writes stack
|
||||
// their specific create/update permission per route on top.
|
||||
@Controller('warehouses')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouses.view)
|
||||
@BookingStaff([
|
||||
FREIGHT_PERMS.warehouses.view,
|
||||
FREIGHT_PERMS.warehouseInventory.view,
|
||||
FREIGHT_PERMS.warehouseDashboard.view,
|
||||
])
|
||||
export class WarehousesController {
|
||||
constructor(
|
||||
private readonly warehousesService: WarehousesService,
|
||||
|
||||
@@ -19,6 +19,9 @@ export const RULE_ENGINE_RESOURCE_SLUGS = [
|
||||
'rates',
|
||||
'approval-rules',
|
||||
'yard-distances',
|
||||
// Keep new slugs at the END: ruleEngineCrudId derives ids from list index,
|
||||
// so a mid-list insert would shift ids already seeded for later slugs.
|
||||
'truck-types',
|
||||
] as const;
|
||||
|
||||
export type RuleEngineResourceSlug = (typeof RULE_ENGINE_RESOURCE_SLUGS)[number];
|
||||
@@ -101,6 +104,7 @@ const RULE_ENGINE_VIEW_IDS: Record<RuleEngineResourceSlug, string> = {
|
||||
'cargo-types': 'b2000001-0001-4000-8000-000000000001',
|
||||
'container-types': 'b2000001-0001-4000-8000-000000000003',
|
||||
'wagon-types': 'b2000001-0001-4000-8000-000000000015',
|
||||
'truck-types': 'b2000001-0001-4000-8000-00000000001a',
|
||||
'service-types': 'b2000001-0001-4000-8000-000000000005',
|
||||
yards: 'b2000001-0001-4000-8000-000000000007',
|
||||
'shipping-lines': 'b2000001-0001-4000-8000-000000000009',
|
||||
@@ -123,7 +127,7 @@ const ruleEngineCrudId = (
|
||||
const n =
|
||||
RULE_ENGINE_RESOURCE_SLUGS.indexOf(slug) * 3 +
|
||||
RULE_ENGINE_CRUD_ACTIONS.indexOf(action) +
|
||||
1; // 1..33
|
||||
1; // 1..36
|
||||
return `b2000002-0001-4000-8000-${n.toString(16).padStart(12, '0')}`;
|
||||
};
|
||||
|
||||
@@ -902,41 +906,41 @@ export const POSITION_PERMISSION_PRESETS = {
|
||||
// permission catalog (all CRUD across bookings, contracts, scheduling,
|
||||
// fleet, warehouse, mile, finance, settings, staff).
|
||||
operationsChief: dedupe([...BOOKING_RULE_ENGINE_PERMISSION_KEYS]),
|
||||
// Dispatcher: warehouse floor operations — receive/GRN, move, load/unload,
|
||||
// inspect, dispatch, gate, release/deliver, interchange docs, fee invoices,
|
||||
// plus truck dispatch on the mile legs and read-only operational context.
|
||||
// Allocation & fee rules are VIEW-ONLY — never create/update/delete.
|
||||
// Dispatcher: full CRUD on warehouse management (incl. import/export/intercity
|
||||
// inventory flows) and fleet management, plus truck dispatch on the mile legs
|
||||
// and operational context. The ONE carve-out: allocation & fee rules stay
|
||||
// VIEW-ONLY — a dispatcher never creates/updates/deletes those rules.
|
||||
dispatcher: dedupe([
|
||||
// Warehouse management — full CRUD.
|
||||
FREIGHT_PERMS.warehouseDashboard.view,
|
||||
FREIGHT_PERMS.warehouses.view,
|
||||
FREIGHT_PERMS.warehouseYards.view,
|
||||
FREIGHT_PERMS.warehouseZones.view,
|
||||
FREIGHT_PERMS.warehouseInventory.view,
|
||||
FREIGHT_PERMS.warehouseInventory.receive,
|
||||
FREIGHT_PERMS.warehouseInventory.move,
|
||||
FREIGHT_PERMS.warehouseInventory.load,
|
||||
FREIGHT_PERMS.warehouseInventory.unload,
|
||||
FREIGHT_PERMS.warehouseInventory.dispatch,
|
||||
FREIGHT_PERMS.warehouseInventory.gatePass,
|
||||
FREIGHT_PERMS.warehouseInventory.release,
|
||||
FREIGHT_PERMS.warehouseInventory.deliver,
|
||||
FREIGHT_PERMS.warehouseInventory.inspect,
|
||||
FREIGHT_PERMS.warehouseInspectionReports.view,
|
||||
FREIGHT_PERMS.warehouseInspectionReports.create,
|
||||
FREIGHT_PERMS.warehouseInspectionReports.update,
|
||||
FREIGHT_PERMS.interchangeDocuments.view,
|
||||
FREIGHT_PERMS.interchangeDocuments.generate,
|
||||
FREIGHT_PERMS.interchangeDocuments.acknowledge,
|
||||
FREIGHT_PERMS.warehouseFeeInvoices.view,
|
||||
FREIGHT_PERMS.warehouseFeeInvoices.generate,
|
||||
...Object.values(FREIGHT_PERMS.warehouses),
|
||||
...Object.values(FREIGHT_PERMS.warehouseYards),
|
||||
...Object.values(FREIGHT_PERMS.warehouseZones),
|
||||
...Object.values(FREIGHT_PERMS.warehouseInventory),
|
||||
...Object.values(FREIGHT_PERMS.warehouseInspectionReports),
|
||||
...Object.values(FREIGHT_PERMS.interchangeDocuments),
|
||||
...Object.values(FREIGHT_PERMS.warehouseFeeInvoices),
|
||||
// View-only on the rules that govern allocation and fees.
|
||||
FREIGHT_PERMS.warehouseAllocationRules.view,
|
||||
FREIGHT_PERMS.warehouseFeeRules.view,
|
||||
// Fleet management — full CRUD.
|
||||
...Object.values(FREIGHT_PERMS.fleet),
|
||||
FREIGHT_PERMS.fleetDashboard.view,
|
||||
...Object.values(FREIGHT_PERMS.fleetReports),
|
||||
...Object.values(FREIGHT_PERMS.vehicles),
|
||||
...Object.values(FREIGHT_PERMS.drivers),
|
||||
...Object.values(FREIGHT_PERMS.tracking),
|
||||
...Object.values(FREIGHT_PERMS.fuel),
|
||||
...Object.values(FREIGHT_PERMS.maintenance),
|
||||
...Object.values(FREIGHT_PERMS.locomotives),
|
||||
...Object.values(FREIGHT_PERMS.wagons),
|
||||
...Object.values(FREIGHT_PERMS.trains),
|
||||
...Object.values(FREIGHT_PERMS.routes),
|
||||
...Object.values(FREIGHT_PERMS.containers),
|
||||
...Object.values(FREIGHT_PERMS.cargoes),
|
||||
// Truck dispatch on the EDR mile legs + operational context.
|
||||
FREIGHT_PERMS.firstMile.view,
|
||||
FREIGHT_PERMS.firstMile.assignVehicles,
|
||||
FREIGHT_PERMS.lastMile.view,
|
||||
FREIGHT_PERMS.lastMile.assignVehicles,
|
||||
...Object.values(FREIGHT_PERMS.firstMile),
|
||||
...Object.values(FREIGHT_PERMS.lastMile),
|
||||
FREIGHT_PERMS.trainScheduling.view,
|
||||
FREIGHT_PERMS.bookings.operations,
|
||||
]),
|
||||
|
||||
@@ -632,7 +632,9 @@ const filterSidebarByPermission = (
|
||||
const filterItems = (items: SidebarItem[]): SidebarItem[] =>
|
||||
items
|
||||
.map((item) =>
|
||||
item.children ? { ...item, children: filterItems(item.children) } : item,
|
||||
item.children
|
||||
? { ...item, children: filterItems(item.children) }
|
||||
: item,
|
||||
)
|
||||
.filter((item) => {
|
||||
if (etGl || djGl) {
|
||||
@@ -800,8 +802,22 @@ const App = () => {
|
||||
}
|
||||
/>
|
||||
<Route path="support" element={<SupportInboxPage />} />
|
||||
<Route path="customers" element={<CustomersPage />} />
|
||||
<Route path="customers/:id" element={<CustomerDetailPage />} />
|
||||
<Route
|
||||
path="customers"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.customers.view}>
|
||||
<CustomersPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="customers/:id"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.customers.view}>
|
||||
<CustomerDetailPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="invoices"
|
||||
element={
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Textarea } from '@/components/ui/textarea';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { api } from '@/services/api';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { isBackdated, nowLocalDateTimeInput } from '@/lib/no-backdate';
|
||||
|
||||
/**
|
||||
* Customer Pickup + Proof of Delivery capture for a LOADED cargo.
|
||||
@@ -27,6 +28,10 @@ export function DeliverCargoDialog({ cargoId, onSuccess }: { cargoId: string; on
|
||||
toast({ title: 'Receiver name is required', variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
if (isBackdated(pickupDate)) {
|
||||
toast({ title: 'Pickup date cannot be in the past', variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await deliver.mutateAsync({
|
||||
id: cargoId,
|
||||
@@ -75,6 +80,7 @@ export function DeliverCargoDialog({ cargoId, onSuccess }: { cargoId: string; on
|
||||
<Label>Pickup date</Label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
min={nowLocalDateTimeInput()}
|
||||
value={pickupDate}
|
||||
onChange={(e) => setPickupDate(e.target.value)}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { Button, Group, TextInput } from "@mantine/core";
|
||||
import { DatePickerInput } from "@mantine/dates";
|
||||
import { Search, X } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export interface ListControlsProps {
|
||||
search: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
searchPlaceholder?: string;
|
||||
/** `YYYY-MM-DD`, matching Mantine 9's date inputs. */
|
||||
dateFrom: string | null;
|
||||
onDateFromChange: (value: string | null) => void;
|
||||
dateTo: string | null;
|
||||
onDateToChange: (value: string | null) => void;
|
||||
/** Label above the range, naming the date being filtered (e.g. "Arrival date"). */
|
||||
dateLabel?: string;
|
||||
hasFilters?: boolean;
|
||||
onReset?: () => void;
|
||||
/** Page-specific selects (status, warehouse…) rendered after the date range. */
|
||||
children?: ReactNode;
|
||||
showSearch?: boolean;
|
||||
showDateRange?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search box + inclusive date range + clear, shared by every freight list so the
|
||||
* controls sit in the same place and behave the same way on all of them.
|
||||
* Pair with `useListControls`, which owns the state and does the filtering.
|
||||
*/
|
||||
const ListControls = ({
|
||||
search,
|
||||
onSearchChange,
|
||||
searchPlaceholder = "Search…",
|
||||
dateFrom,
|
||||
onDateFromChange,
|
||||
dateTo,
|
||||
onDateToChange,
|
||||
dateLabel,
|
||||
hasFilters,
|
||||
onReset,
|
||||
children,
|
||||
showSearch = true,
|
||||
showDateRange = true,
|
||||
}: ListControlsProps) => (
|
||||
<Group gap="sm" align="flex-end" wrap="wrap">
|
||||
{showSearch && (
|
||||
<TextInput
|
||||
placeholder={searchPlaceholder}
|
||||
value={search}
|
||||
onChange={(e) => onSearchChange(e.currentTarget.value)}
|
||||
leftSection={<Search size={16} />}
|
||||
style={{ flex: "1 1 240px", minWidth: 200 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showDateRange && (
|
||||
<>
|
||||
<DatePickerInput
|
||||
label={dateLabel ? `${dateLabel} from` : "From"}
|
||||
placeholder="Any"
|
||||
value={dateFrom}
|
||||
onChange={onDateFromChange}
|
||||
// Cannot start after it ends — the picker refuses the invalid range
|
||||
// instead of silently returning nothing.
|
||||
maxDate={dateTo ?? undefined}
|
||||
clearable
|
||||
w={150}
|
||||
/>
|
||||
<DatePickerInput
|
||||
label={dateLabel ? `${dateLabel} to` : "To"}
|
||||
placeholder="Any"
|
||||
value={dateTo}
|
||||
onChange={onDateToChange}
|
||||
minDate={dateFrom ?? undefined}
|
||||
clearable
|
||||
w={150}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{children}
|
||||
|
||||
{hasFilters && onReset && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
leftSection={<X size={14} />}
|
||||
onClick={onReset}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
|
||||
export default ListControls;
|
||||
@@ -23,6 +23,8 @@ import {
|
||||
import { useState } from "react";
|
||||
import { useFileViewer } from "@edr/ui-common";
|
||||
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import { fetchViewableFile } from "@/services/files.service";
|
||||
import { api } from "@/services/api";
|
||||
import type { Company, CompanyChangeRequest } from "@/types/customer";
|
||||
@@ -128,6 +130,8 @@ function DiffRow({
|
||||
* (with note) actions, plus a short history of past decisions.
|
||||
*/
|
||||
export function ChangeRequestReview({ company }: { company: Company }) {
|
||||
const { user } = useAuth();
|
||||
const canReview = hasPermission(user, FREIGHT_PERMS.customers.verify);
|
||||
const query = useQuery(
|
||||
api.customers.changeRequests.queryOptions({ input: { id: company.id } }),
|
||||
);
|
||||
@@ -323,25 +327,30 @@ export function ChangeRequestReview({ company }: { company: Company }) {
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
variant="light"
|
||||
color="red"
|
||||
onClick={() => {
|
||||
setRejectId(pending.id);
|
||||
setNote("");
|
||||
}}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
loading={approve.isPending}
|
||||
onClick={() => approve.mutate({ id: pending.id })}
|
||||
>
|
||||
Approve changes
|
||||
</Button>
|
||||
</Group>
|
||||
{/* Reviewing the diff is `customers:view`; deciding on it is
|
||||
`customers:verify`. Without it the request stays readable but
|
||||
un-actionable. */}
|
||||
{canReview && (
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
variant="light"
|
||||
color="red"
|
||||
onClick={() => {
|
||||
setRejectId(pending.id);
|
||||
setNote("");
|
||||
}}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
loading={approve.isPending}
|
||||
onClick={() => approve.mutate({ id: pending.id })}
|
||||
>
|
||||
Approve changes
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
} from "@mantine/core";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import { api } from "@/services/api";
|
||||
|
||||
import type {
|
||||
@@ -286,6 +288,19 @@ export function InvoiceStatusBadge({
|
||||
* regardless (setCompanyProfileStatus). Suspend/blacklist/reinstate stay live so
|
||||
* an already-active profile is still managable.
|
||||
*/
|
||||
/**
|
||||
* Which permission each status write needs. Mirrors `STATUS_PERM` in the API's
|
||||
* `companies.controller.ts` — approving is a different authority from
|
||||
* suspending, and both go through the same endpoint. Keep the two in step.
|
||||
*/
|
||||
const STATUS_PERM: Record<ProfileStatus, string> = {
|
||||
active: FREIGHT_PERMS.customers.verify,
|
||||
pending: FREIGHT_PERMS.customers.verify,
|
||||
rejected: FREIGHT_PERMS.customers.verify,
|
||||
suspended: FREIGHT_PERMS.customers.deactivate,
|
||||
blacklisted: FREIGHT_PERMS.customers.deactivate,
|
||||
};
|
||||
|
||||
export function ProfileApprovalActions({
|
||||
profileId,
|
||||
status,
|
||||
@@ -295,6 +310,10 @@ export function ProfileApprovalActions({
|
||||
status: ProfileStatus;
|
||||
locked?: boolean;
|
||||
}) {
|
||||
const { user } = useAuth();
|
||||
/** The API rejects these anyway — hide rather than offer a button that 403s. */
|
||||
const canSet = (next: ProfileStatus) =>
|
||||
hasPermission(user, STATUS_PERM[next]);
|
||||
const { mutate, isPending } = useMutation(
|
||||
api.customers.setProfileStatus.mutationOptions(),
|
||||
);
|
||||
@@ -414,35 +433,41 @@ export function ProfileApprovalActions({
|
||||
}
|
||||
|
||||
if (status === "pending") {
|
||||
if (!canSet("active") && !canSet("rejected")) return null;
|
||||
return (
|
||||
<>
|
||||
{decisionModal}
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
loading={isPending}
|
||||
onClick={() => act("active")}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="red"
|
||||
radius="md"
|
||||
onClick={() => openDecision("reject")}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
{canSet("active") && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
loading={isPending}
|
||||
onClick={() => act("active")}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
)}
|
||||
{canSet("rejected") && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="red"
|
||||
radius="md"
|
||||
onClick={() => openDecision("reject")}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "rejected") {
|
||||
if (!canSet("active")) return null;
|
||||
return (
|
||||
<Button
|
||||
size="xs"
|
||||
@@ -458,6 +483,7 @@ export function ProfileApprovalActions({
|
||||
}
|
||||
|
||||
if (status === "active") {
|
||||
if (!canSet("suspended")) return null;
|
||||
return (
|
||||
<>
|
||||
{decisionModal}
|
||||
@@ -476,34 +502,40 @@ export function ProfileApprovalActions({
|
||||
}
|
||||
|
||||
if (status === "suspended") {
|
||||
if (!canSet("active") && !canSet("blacklisted")) return null;
|
||||
return (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{decisionModal}
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
loading={isPending}
|
||||
onClick={() => openDecision("reactivate")}
|
||||
>
|
||||
Reactivate
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="red"
|
||||
radius="md"
|
||||
loading={isPending}
|
||||
onClick={() => act("blacklisted")}
|
||||
>
|
||||
Blacklist
|
||||
</Button>
|
||||
{canSet("active") && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
loading={isPending}
|
||||
onClick={() => openDecision("reactivate")}
|
||||
>
|
||||
Reactivate
|
||||
</Button>
|
||||
)}
|
||||
{canSet("blacklisted") && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="red"
|
||||
radius="md"
|
||||
loading={isPending}
|
||||
onClick={() => act("blacklisted")}
|
||||
>
|
||||
Blacklist
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "blacklisted") {
|
||||
if (!canSet("pending")) return null;
|
||||
return (
|
||||
<Button
|
||||
size="xs"
|
||||
|
||||
@@ -126,6 +126,28 @@ const FleetFormDialog = ({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, recordId]);
|
||||
|
||||
// Seed the `_`-prefixed scratch that `onOptionSelected` derives (e.g.
|
||||
// _hasTrailer) for the value already on the record. Without this, editing a
|
||||
// rigid truck would show a Trailer Plate field until the type is re-picked.
|
||||
// Only scratch keys are written, so a stored one-off capacity is never
|
||||
// clobbered by the type's default; re-deriving from the live value is
|
||||
// idempotent, so this is safe to run again when the options finally load.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setValues((current) => {
|
||||
const scratch: Record<string, unknown> = {};
|
||||
fields.forEach((field) => {
|
||||
if (!field.onOptionSelected) return;
|
||||
const selected = field.options?.find((o) => o.value === current[field.name]);
|
||||
if (!selected) return;
|
||||
Object.entries(field.onOptionSelected(selected, current)).forEach(([key, value]) => {
|
||||
if (key.startsWith("_")) scratch[key] = value;
|
||||
});
|
||||
});
|
||||
return Object.keys(scratch).length ? { ...current, ...scratch } : current;
|
||||
});
|
||||
}, [open, fields]);
|
||||
|
||||
// Receive the ?code&state relayed by the /callback popup, exchange it for
|
||||
// the verified identity, and prefill the matching form fields.
|
||||
useEffect(() => {
|
||||
@@ -202,18 +224,52 @@ const FleetFormDialog = ({
|
||||
|
||||
const faydaVerified = values.faydaVerified === true;
|
||||
|
||||
/**
|
||||
* Fields the current answers actually apply to — a rigid truck type (Casoni)
|
||||
* has no trailer, so its plate field disappears. Honoured in three places, not
|
||||
* just here: a hidden field must also skip validation (an invisible "required"
|
||||
* error blocks submit with nothing to fix) and must submit an explicit null
|
||||
* (so switching to a rigid type CLEARS the stored trailer plate rather than
|
||||
* stranding it on the row).
|
||||
*/
|
||||
const visibleFields = useMemo(
|
||||
() =>
|
||||
fields.filter((field) => {
|
||||
if (
|
||||
field.hideWhen &&
|
||||
field.hideWhen.equals.includes(String(values[field.hideWhen.field] ?? ""))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
field.showWhen &&
|
||||
!field.showWhen.equals.includes(String(values[field.showWhen.field] ?? ""))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (field.showIf && !field.showIf(values)) return false;
|
||||
return true;
|
||||
}),
|
||||
[fields, values],
|
||||
);
|
||||
|
||||
const hiddenFieldNames = useMemo(() => {
|
||||
const visible = new Set(visibleFields.map((f) => f.name));
|
||||
return fields.filter((f) => !visible.has(f.name)).map((f) => f.name);
|
||||
}, [fields, visibleFields]);
|
||||
|
||||
const shortFields = useMemo(
|
||||
() => fields.filter((f) => f.type !== "textarea"),
|
||||
[fields],
|
||||
() => visibleFields.filter((f) => f.type !== "textarea"),
|
||||
[visibleFields],
|
||||
);
|
||||
const longFields = useMemo(
|
||||
() => fields.filter((f) => f.type === "textarea"),
|
||||
[fields],
|
||||
() => visibleFields.filter((f) => f.type === "textarea"),
|
||||
[visibleFields],
|
||||
);
|
||||
|
||||
const validate = () => {
|
||||
const next: Record<string, string> = {};
|
||||
fields.forEach((field) => {
|
||||
visibleFields.forEach((field) => {
|
||||
const value = values[field.name];
|
||||
const stringValue =
|
||||
typeof value === "string" ? value.trim() : String(value ?? "");
|
||||
@@ -295,9 +351,19 @@ const FleetFormDialog = ({
|
||||
fields.forEach((field) => {
|
||||
if (field.derivedValue) submitted[field.name] = field.derivedValue(values);
|
||||
});
|
||||
// A field the answers hid no longer applies to this record — send an explicit
|
||||
// null so the column is unset, instead of leaving a stale value behind.
|
||||
hiddenFieldNames.forEach((name) => {
|
||||
submitted[name] = null;
|
||||
});
|
||||
const payload = Object.fromEntries(
|
||||
Object.entries(submitted)
|
||||
// `_`-prefixed keys are form-local scratch written by `onOptionSelected`
|
||||
// (e.g. _hasTrailer, which drives visibility). The API validates with
|
||||
// forbidNonWhitelisted, so an undeclared key would 400 the whole save.
|
||||
.filter(([key]) => !key.startsWith("_"))
|
||||
.map(([key, value]) => {
|
||||
if (hiddenFieldNames.includes(key)) return [key, null];
|
||||
if (value === FLEET_SELECT_NONE || value === "" || value == null)
|
||||
return [key, clearableByName[key] ? null : undefined];
|
||||
if (fieldTypeByName[key] === "number") {
|
||||
@@ -371,7 +437,15 @@ const FleetFormDialog = ({
|
||||
: String(value)
|
||||
}
|
||||
onChange={(next) =>
|
||||
setValues((current) => ({ ...current, [field.name]: next ?? "" }))
|
||||
setValues((current) => {
|
||||
const patch = field.onOptionSelected
|
||||
? field.onOptionSelected(
|
||||
field.options?.find((o) => o.value === next),
|
||||
current,
|
||||
)
|
||||
: {};
|
||||
return { ...current, [field.name]: next ?? "", ...patch };
|
||||
})
|
||||
}
|
||||
error={error}
|
||||
searchable
|
||||
|
||||
@@ -18,6 +18,7 @@ import { useEffect, useState } from 'react';
|
||||
|
||||
import { QUERY_KEYS } from '@/constants/QUERY_KEYS';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { isBackdated } from '@/lib/no-backdate';
|
||||
import { lastMileService, type LastMileRecord } from '@/services/last-mile.service';
|
||||
|
||||
interface TruckDetentionModalProps {
|
||||
@@ -111,6 +112,7 @@ export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionM
|
||||
description="Detention clock start"
|
||||
value={arrived}
|
||||
onChange={(v) => setArrived(v ? new Date(v) : null)}
|
||||
minDate={new Date()}
|
||||
clearable
|
||||
/>
|
||||
<DateTimePicker
|
||||
@@ -118,11 +120,26 @@ export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionM
|
||||
description="Clock end (blank = still out)"
|
||||
value={delivered}
|
||||
onChange={(v) => setDelivered(v ? new Date(v) : null)}
|
||||
minDate={new Date()}
|
||||
clearable
|
||||
/>
|
||||
</Group>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="light" loading={saveTimes.isPending} onClick={() => saveTimes.mutate()}>
|
||||
<Button
|
||||
variant="light"
|
||||
loading={saveTimes.isPending}
|
||||
onClick={() => {
|
||||
// No backdating: detention times are recorded as they happen.
|
||||
if (isBackdated(arrived) || isBackdated(delivered)) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Detention times cannot be in the past',
|
||||
});
|
||||
return;
|
||||
}
|
||||
saveTimes.mutate();
|
||||
}}
|
||||
>
|
||||
Save times
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
@@ -18,6 +18,11 @@ import { LoadInventoryModal } from './LoadInventoryModal';
|
||||
import { MoveInventoryModal } from './MoveInventoryModal';
|
||||
import { ReleaseOrderModal } from './ReleaseOrderModal';
|
||||
import { WarehouseInventoryTable } from './WarehouseInventoryTable';
|
||||
import ListControls from '@/components/common/ListControls';
|
||||
// Generic list footer — already shared by the fleet and train-scheduling lists
|
||||
// despite the ruleEngine path; reused here rather than adding a second one.
|
||||
import RuleEngineListFooter from '@/components/ruleEngine/RuleEngineListFooter';
|
||||
import { useListControls } from '@/hooks/useListControls';
|
||||
import { extractDownloadErrorMessage, extractErrorMessage } from './options';
|
||||
import { openPdfBlob, saveBlob } from './pdf';
|
||||
|
||||
@@ -56,8 +61,17 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
|
||||
api.warehouses.bulkMarkInspected.mutationOptions(),
|
||||
);
|
||||
|
||||
const controls = useListControls(items, {
|
||||
searchKeys: ['grnNumber', 'bookingReference', 'customerName', 'status', 'releaseOrderReference', 'notes'],
|
||||
dateKey: 'arrivedAt',
|
||||
});
|
||||
const visible = controls.filteredRows;
|
||||
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const allSelected = items.length > 0 && selected.size === items.length;
|
||||
// Select-all spans everything matching the current filters, not just the rows
|
||||
// on screen — bulk "mark inspected" over one page of a filtered set would be a
|
||||
// surprise. Counts compare against the filtered set for the same reason.
|
||||
const allSelected = visible.length > 0 && selected.size === visible.length;
|
||||
const someSelected = selected.size > 0 && !allSelected;
|
||||
const toggleSelect = (id: string) =>
|
||||
setSelected((prev) => {
|
||||
@@ -66,7 +80,7 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
|
||||
return next;
|
||||
});
|
||||
const toggleSelectAll = () =>
|
||||
setSelected(allSelected ? new Set() : new Set(items.map((i) => i.id)));
|
||||
setSelected(allSelected ? new Set() : new Set(visible.map((i) => i.id)));
|
||||
|
||||
const markInspected = async () => {
|
||||
if (selected.size === 0) {
|
||||
@@ -268,8 +282,21 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<ListControls
|
||||
search={controls.search}
|
||||
onSearchChange={controls.setSearch}
|
||||
searchPlaceholder="GRN, container, booking, customer…"
|
||||
dateFrom={controls.dateFrom}
|
||||
onDateFromChange={controls.setDateFrom}
|
||||
dateTo={controls.dateTo}
|
||||
onDateToChange={controls.setDateTo}
|
||||
dateLabel="Arrived"
|
||||
hasFilters={controls.hasFilters}
|
||||
onReset={controls.reset}
|
||||
/>
|
||||
|
||||
<WarehouseInventoryTable
|
||||
items={items}
|
||||
items={controls.pagedRows}
|
||||
busyId={busyId}
|
||||
onAdvance={advance}
|
||||
onMove={setMoveItem}
|
||||
@@ -287,6 +314,14 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
|
||||
allSelected={allSelected}
|
||||
someSelected={someSelected}
|
||||
/>
|
||||
|
||||
<RuleEngineListFooter
|
||||
pagination={controls.pagination}
|
||||
pageCount={controls.pageCount}
|
||||
totalCount={controls.totalCount}
|
||||
itemLabel="items"
|
||||
onPaginationChange={controls.setPagination}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<MoveInventoryModal opened={Boolean(moveItem)} onClose={() => setMoveItem(null)} item={moveItem} />
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { api } from '@/services/api';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { isBackdated, nowLocalDateTimeInput } from '@/lib/no-backdate';
|
||||
import { warehouseService } from '@/services/warehouse.service';
|
||||
import type { WarehouseInventoryItem } from '@/types/warehouse';
|
||||
import { extractErrorMessage } from './options';
|
||||
@@ -204,6 +205,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
label: `Last-mile · ${truckPrefill.truckPlateNumber}`,
|
||||
trailerPlate: truckPrefill.trailerPlateNumber ?? '',
|
||||
driverName: truckPrefill.driverName ?? '',
|
||||
driverLicense: truckPrefill.driverLicense ?? '',
|
||||
driverPhone: truckPrefill.driverPhone ?? '',
|
||||
truckType: truckPrefill.truckType ?? '',
|
||||
containerNumbers: splitContainerNumbers(truckPrefill.containerNumber),
|
||||
@@ -217,6 +219,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
label: `Customer · ${t.plateNumber} — ${t.driverName}`,
|
||||
trailerPlate: '',
|
||||
driverName: t.driverName,
|
||||
driverLicense: '',
|
||||
driverPhone: '',
|
||||
truckType: t.truckType,
|
||||
containerNumbers: (t.containers ?? []).map((c) => c.containerNumber).filter(Boolean),
|
||||
@@ -230,6 +233,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
label: `Last-mile · ${t.truckPlateNumber ?? ''}${t.driverName ? ` — ${t.driverName}` : ''}`,
|
||||
trailerPlate: t.trailerPlateNumber ?? '',
|
||||
driverName: t.driverName ?? '',
|
||||
driverLicense: t.driverLicense ?? '',
|
||||
driverPhone: t.driverPhone ?? '',
|
||||
truckType: t.truckType ?? '',
|
||||
containerNumbers: splitContainerNumbers(t.containerNumber),
|
||||
@@ -280,6 +284,11 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
// way. A walk-in truck (typed plate, no assignment) stays editable at arrival.
|
||||
const isTruckIdentityLocked = isEntranceLocked || Boolean(selectedOption);
|
||||
const isDriverNameLocked = isEntranceLocked || Boolean(selectedOption?.driverName);
|
||||
// The freight order's truck details are the customer's / fleet's record — the
|
||||
// gate may FILL blanks (walk-in license, phone) but never edit shown values.
|
||||
const isTrailerLocked = isEntranceLocked || Boolean(selectedOption?.trailerPlate);
|
||||
const isDriverLicenseLocked = isEntranceLocked || Boolean(selectedOption?.driverLicense);
|
||||
const isDriverPhoneLocked = isEntranceLocked || Boolean(selectedOption?.driverPhone);
|
||||
const referenceLocked = Boolean(item?.releaseOrderReference) || savedBlocks.length > 0;
|
||||
|
||||
/** Load a truck into the form: its saved block if any, else its assignment. */
|
||||
@@ -292,7 +301,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
setTruckPlateNumber(plate);
|
||||
setTrailerPlateNumber(block?.trailerPlateNumber || option?.trailerPlate || '');
|
||||
setDriverName(block?.driverName || option?.driverName || '');
|
||||
setDriverLicense(block?.driverLicense || '');
|
||||
setDriverLicense(block?.driverLicense || option?.driverLicense || '');
|
||||
setDriverPhone(block?.driverPhone || option?.driverPhone || '');
|
||||
setTruckType(block?.truckType || option?.truckType || '');
|
||||
const loaded = block
|
||||
@@ -440,6 +449,12 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
});
|
||||
return;
|
||||
}
|
||||
// No backdating: gate times are recorded as they happen. The locked
|
||||
// entrance (exit step) keeps its original past gate-in untouched.
|
||||
if (!isEntranceLocked && isBackdated(gateInTime)) {
|
||||
toast({ variant: 'destructive', title: 'Gate in time cannot be in the past' });
|
||||
return;
|
||||
}
|
||||
if (isExitStep && (!gateOutTime || (!skipWeighing && grossWeight === ''))) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
@@ -447,6 +462,10 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (isExitStep && isBackdated(gateOutTime)) {
|
||||
toast({ variant: 'destructive', title: 'Gate out time cannot be in the past' });
|
||||
return;
|
||||
}
|
||||
if (isExitStep && hasContainerWeights && selectedContainerNumbers.length === 0) {
|
||||
toast({ variant: 'destructive', title: 'Select the containers loaded on this truck' });
|
||||
return;
|
||||
@@ -600,15 +619,15 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
label="Trailer plate number"
|
||||
value={trailerPlateNumber}
|
||||
onChange={(e) => setTrailerPlateNumber(e.currentTarget.value)}
|
||||
readOnly={isEntranceLocked}
|
||||
readOnly={isTrailerLocked}
|
||||
/>
|
||||
</Group>
|
||||
<Group grow>
|
||||
<TextInput label="Driver name" required value={driverName} onChange={(e) => setDriverName(e.currentTarget.value)} readOnly={isDriverNameLocked} />
|
||||
<TextInput label="Driver license" value={driverLicense} onChange={(e) => setDriverLicense(e.currentTarget.value)} readOnly={isEntranceLocked} />
|
||||
<TextInput label="Driver license" value={driverLicense} onChange={(e) => setDriverLicense(e.currentTarget.value)} readOnly={isDriverLicenseLocked} />
|
||||
</Group>
|
||||
<Group grow>
|
||||
<TextInput label="Driver phone" value={driverPhone} onChange={(e) => setDriverPhone(e.currentTarget.value)} readOnly={isEntranceLocked} />
|
||||
<TextInput label="Driver phone" value={driverPhone} onChange={(e) => setDriverPhone(e.currentTarget.value)} readOnly={isDriverPhoneLocked} />
|
||||
<TextInput label="Truck type" value={truckType} onChange={(e) => setTruckType(e.currentTarget.value)} readOnly={isTruckIdentityLocked} />
|
||||
</Group>
|
||||
<Group grow align="flex-start">
|
||||
@@ -646,7 +665,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
)}
|
||||
<TextInput label="Gate in time" type="datetime-local" value={gateInTime} onChange={(e) => setGateInTime(e.currentTarget.value)} readOnly={isEntranceLocked} />
|
||||
<TextInput label="Gate in time" type="datetime-local" min={isEntranceLocked ? undefined : nowLocalDateTimeInput()} value={gateInTime} onChange={(e) => setGateInTime(e.currentTarget.value)} readOnly={isEntranceLocked} />
|
||||
</Group>
|
||||
{hasContainerWeights && (
|
||||
<Group gap="md" align="center">
|
||||
@@ -679,7 +698,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
<Text size="sm" c={weightMismatch ? 'red' : 'dimmed'}>
|
||||
Computed net: <b>{computedNetWeight == null ? '-' : `${computedNetWeight.toLocaleString()} t`}</b>
|
||||
</Text>
|
||||
<TextInput label="Gate out time" type="datetime-local" value={gateOutTime} onChange={(e) => setGateOutTime(e.currentTarget.value)} disabled={!isExitStep || hasTruckLeft} />
|
||||
<TextInput label="Gate out time" type="datetime-local" min={nowLocalDateTimeInput()} value={gateOutTime} onChange={(e) => setGateOutTime(e.currentTarget.value)} disabled={!isExitStep || hasTruckLeft} />
|
||||
</Group>
|
||||
{weightMismatch && (
|
||||
<Alert icon={<Scale size={16} />} color="red" variant="light">
|
||||
|
||||
@@ -199,6 +199,7 @@ export const QUERY_KEYS = {
|
||||
|
||||
MAINTENANCE: {
|
||||
ROOT: ["maintenance"] as const,
|
||||
dueBoard: () => ["maintenance", "due-board"] as const,
|
||||
schedules: (vehicleId?: string) =>
|
||||
["maintenance", "schedules", vehicleId ?? "all"] as const,
|
||||
upcoming: (vehicleId?: string) =>
|
||||
@@ -207,6 +208,8 @@ export const QUERY_KEYS = {
|
||||
["maintenance", "history", vehicleId ?? "all"] as const,
|
||||
stats: (vehicleId?: string) =>
|
||||
["maintenance", "stats", vehicleId ?? "all"] as const,
|
||||
intervals: (vehicleId?: string) =>
|
||||
["maintenance", "intervals", vehicleId ?? "all"] as const,
|
||||
},
|
||||
|
||||
FINANCIAL_REPORTS: {
|
||||
|
||||
@@ -417,6 +417,9 @@ export const URL_CONSTANTS = {
|
||||
WAGON_TYPES: "/wagon-types",
|
||||
WAGON_TYPE_BY_ID: (id: string) => `/wagon-types/${id}`,
|
||||
|
||||
TRUCK_TYPES: "/truck-types",
|
||||
TRUCK_TYPE_BY_ID: (id: string) => `/truck-types/${id}`,
|
||||
|
||||
PRIORITY_CONFIGS: "/priority-configs",
|
||||
PRIORITY_CONFIG_BY_ID: (id: string) => `/priority-configs/${id}`,
|
||||
|
||||
|
||||
@@ -67,10 +67,3 @@ export function useDisputeInterchangeDocument() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useCancelInterchangeDocument() {
|
||||
const onSuccess = useInterchangeInvalidation();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => interchangeDocumentsService.cancel(id),
|
||||
onSuccess,
|
||||
});
|
||||
}
|
||||
|
||||
164
apps/edr-freight-web/backoffice/src/hooks/useListControls.ts
Normal file
164
apps/edr-freight-web/backoffice/src/hooks/useListControls.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { usePagination } from "@edr/ui-common";
|
||||
|
||||
/**
|
||||
* Search + date-range + pagination over an already-fetched array.
|
||||
*
|
||||
* Client-side on purpose: the freight lists are hundreds of rows (largest table
|
||||
* is ~1.1k), so filtering in the browser avoids paginating ~20 API endpoints —
|
||||
* several of which sit on billing paths. If a list ever outgrows this (roughly
|
||||
* 5k rows, where the per-keystroke filter starts to feel slow), move that ONE
|
||||
* page to a server-side query; the component API here stays the same.
|
||||
*
|
||||
* Dates are `YYYY-MM-DD` strings, matching Mantine 9's date inputs. Comparing
|
||||
* them lexically keeps the range on calendar days and sidesteps timezone drift
|
||||
* entirely — a UTC timestamp is truncated to its date before the comparison.
|
||||
*
|
||||
* ponytail: linear scan per keystroke, no debounce — fine at this size; add
|
||||
* a debounce (or server-side filtering) if a list gets big enough to stutter.
|
||||
*/
|
||||
export interface ListControlsOptions<T> {
|
||||
/**
|
||||
* Fields matched against the search box. Constrained to real keys of the row
|
||||
* so a typo is a compile error rather than a filter that silently matches
|
||||
* nothing. For nested or derived values, pass `searchValue` instead.
|
||||
*/
|
||||
searchKeys?: (keyof T)[];
|
||||
/**
|
||||
* Row's meaningful business date (arrival, invoice, dispatch…), which is what
|
||||
* staff actually filter by. Falls back to `createdAt` when the row has no
|
||||
* value for it, so a record is never silently invisible to a date range.
|
||||
*/
|
||||
dateKey?: keyof T;
|
||||
/** Rows per page. */
|
||||
pageSize?: number;
|
||||
/** Custom search extractor when the value isn't a top-level field. */
|
||||
searchValue?: (row: T) => string;
|
||||
}
|
||||
|
||||
const readField = (row: unknown, key: string): unknown =>
|
||||
row && typeof row === "object" ? (row as Record<string, unknown>)[key] : undefined;
|
||||
|
||||
/**
|
||||
* Reduce any stored date to its `YYYY-MM-DD` calendar day. ISO strings are cut
|
||||
* directly rather than parsed, so a timestamp is never shifted into the
|
||||
* previous/next day by the viewer's timezone.
|
||||
*/
|
||||
export const toDayString = (raw: unknown): string | null => {
|
||||
if (!raw) return null;
|
||||
if (raw instanceof Date) {
|
||||
return Number.isNaN(raw.getTime()) ? null : raw.toISOString().slice(0, 10);
|
||||
}
|
||||
const text = String(raw);
|
||||
if (/^\d{4}-\d{2}-\d{2}/.test(text)) return text.slice(0, 10);
|
||||
const parsed = new Date(text);
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString().slice(0, 10);
|
||||
};
|
||||
|
||||
/**
|
||||
* Does a stored date fall inside an inclusive `YYYY-MM-DD` range? Exported for
|
||||
* lists that already own their filtering (e.g. FleetResourcePage, which folds
|
||||
* server-side filters and search together) so the range semantics — inclusive
|
||||
* ends, undated rows excluded — stay defined in exactly one place.
|
||||
*/
|
||||
export const matchesDayRange = (
|
||||
raw: unknown,
|
||||
dateFrom: string | null,
|
||||
dateTo: string | null,
|
||||
): boolean => {
|
||||
if (!dateFrom && !dateTo) return true;
|
||||
const day = toDayString(raw);
|
||||
if (!day) return false;
|
||||
if (dateFrom && day < dateFrom) return false;
|
||||
if (dateTo && day > dateTo) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
export const useListControls = <T,>(rows: T[], options: ListControlsOptions<T> = {}) => {
|
||||
const { searchKeys = [], dateKey, pageSize = 10, searchValue } = options;
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const [dateFrom, setDateFrom] = useState<string | null>(null);
|
||||
const [dateTo, setDateTo] = useState<string | null>(null);
|
||||
const { pagination, setPagination } = usePagination({ pageSize });
|
||||
|
||||
const keys = searchKeys.map(String);
|
||||
const keySignature = keys.join("|");
|
||||
const dateKeyStr = dateKey ? String(dateKey) : undefined;
|
||||
|
||||
const filteredRows = useMemo(() => {
|
||||
const term = search.trim().toLowerCase();
|
||||
if (!term && !dateFrom && !dateTo) return rows;
|
||||
|
||||
return rows.filter((row) => {
|
||||
if (term) {
|
||||
const haystack = searchValue
|
||||
? searchValue(row)
|
||||
: keys.map((key) => String(readField(row, key) ?? "")).join(" ");
|
||||
if (!haystack.toLowerCase().includes(term)) return false;
|
||||
}
|
||||
if (dateFrom || dateTo) {
|
||||
const raw = dateKeyStr
|
||||
? (readField(row, dateKeyStr) ?? readField(row, "createdAt"))
|
||||
: null;
|
||||
if (!matchesDayRange(raw, dateFrom, dateTo)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [rows, search, dateFrom, dateTo, keySignature, dateKeyStr, searchValue]);
|
||||
|
||||
// Narrowing the result set can strand the user on a page that no longer
|
||||
// exists (filter to 3 rows while on page 5 → empty table). Snap back to the
|
||||
// first page whenever the filters change.
|
||||
useEffect(() => {
|
||||
setPagination((prev) => (prev.pageIndex === 0 ? prev : { ...prev, pageIndex: 0 }));
|
||||
}, [search, dateFrom, dateTo, setPagination]);
|
||||
|
||||
const pageCount = Math.max(1, Math.ceil(filteredRows.length / pagination.pageSize));
|
||||
|
||||
const pagedRows = useMemo(() => {
|
||||
const start = pagination.pageIndex * pagination.pageSize;
|
||||
return filteredRows.slice(start, start + pagination.pageSize);
|
||||
}, [filteredRows, pagination.pageIndex, pagination.pageSize]);
|
||||
|
||||
const hasFilters = Boolean(search || dateFrom || dateTo);
|
||||
|
||||
const reset = () => {
|
||||
setSearch("");
|
||||
setDateFrom(null);
|
||||
setDateTo(null);
|
||||
};
|
||||
|
||||
return {
|
||||
search,
|
||||
setSearch,
|
||||
dateFrom,
|
||||
setDateFrom,
|
||||
dateTo,
|
||||
setDateTo,
|
||||
hasFilters,
|
||||
reset,
|
||||
filteredRows,
|
||||
pagedRows,
|
||||
pageCount,
|
||||
pagination,
|
||||
setPagination,
|
||||
totalCount: filteredRows.length,
|
||||
/** Spread straight onto <DataTable /> so every list paginates identically. */
|
||||
tableProps: {
|
||||
pagination: {
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
pageCount,
|
||||
totalCount: filteredRows.length,
|
||||
},
|
||||
tableOptions: {
|
||||
manualPagination: true as const,
|
||||
pageCount,
|
||||
state: { pagination },
|
||||
onPaginationChange: setPagination,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -88,8 +88,8 @@ export const resolveModuleConfig = (config: TenantConfig): ModuleConfig => ({
|
||||
});
|
||||
|
||||
const defaultConfig: TenantConfig = {
|
||||
appName: "Smart Office",
|
||||
organizationName: "Smart Office",
|
||||
appName: "EDR Freight",
|
||||
organizationName: "Ethio-Djibouti Railways",
|
||||
canUseAttachmentFromDMS: false,
|
||||
logo: "/assets/TriaTradinglogo.png",
|
||||
primaryColor: "#1b354d",
|
||||
@@ -116,8 +116,8 @@ const defaultConfig: TenantConfig = {
|
||||
|
||||
const tenantConfigs: Record<string, TenantConfig> = {
|
||||
localhost: {
|
||||
appName: "Smart Office",
|
||||
organizationName: "Addis Ababa City Administration",
|
||||
appName: "EDR Freight",
|
||||
organizationName: "Ethio-Djibouti Railways",
|
||||
logo: "",
|
||||
primaryColor: "#0EA371",
|
||||
moduleConfig: {
|
||||
|
||||
20
apps/edr-freight-web/backoffice/src/lib/no-backdate.ts
Normal file
20
apps/edr-freight-web/backoffice/src/lib/no-backdate.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Backdating guard for operational time entries (gate in/out, mile truck
|
||||
* times, delivery pickups): times must be recorded as they happen, never
|
||||
* dated back. A one-hour grace covers real-world lag (weighbridge queue,
|
||||
* operator finishing the form after the event).
|
||||
*/
|
||||
export const BACKDATE_GRACE_MS = 60 * 60 * 1000;
|
||||
|
||||
/** Local-time "YYYY-MM-DDTHH:mm" for a datetime-local input's `min`. */
|
||||
export const nowLocalDateTimeInput = (): string =>
|
||||
new Date(Date.now() - new Date().getTimezoneOffset() * 60_000)
|
||||
.toISOString()
|
||||
.slice(0, 16);
|
||||
|
||||
/** True when the value is more than the grace period in the past. */
|
||||
export const isBackdated = (value: string | Date | null | undefined): boolean => {
|
||||
if (!value) return false;
|
||||
const t = value instanceof Date ? value.getTime() : new Date(value).getTime();
|
||||
return Number.isFinite(t) && t < Date.now() - BACKDATE_GRACE_MS;
|
||||
};
|
||||
@@ -2039,6 +2039,7 @@
|
||||
"setting": "ቅንብሮች",
|
||||
"loadingAdmins": "አስተዳዳሪዎችን በመጫን ላይ...",
|
||||
"errorLoadingAdmins": "የአስተዳዳሪ መረጃን ማጫን ላይ ስህተት ተፈጥሯል",
|
||||
"errorLoadingUnits": "ክፍሎችን ማጫን ላይ ስህተት ተፈጥሯል",
|
||||
"retry": "ደግመው ይሞክሩ",
|
||||
"assignAdmin": "አስተዳዳሪ መመደብ",
|
||||
"addAdmin": "አስተዳዳሪ ያክሉ",
|
||||
@@ -2585,7 +2586,6 @@
|
||||
"archiveDepartment": "የስራ መደብ መጠርያ አርክብ አድርግ",
|
||||
"deleteDepartment": "የስራ መደብ መጠርያ ሰርዝ",
|
||||
"deleteConfirm": "የስራ መደብ መጠርያ ይሰርዝ?",
|
||||
"delete": "ሰርዝ",
|
||||
"deleteFailed": "የስራ መደብ መጠርያ ሰረዝ ወደ ተሳክቶ",
|
||||
"cannotDeleteWithEmployees": "ተመድቦ ካለበት ሰራተኞች ጋር የስራ መደብ መጠርያ መሰረዝ አይቻልም",
|
||||
"reassignEmployeesFirst": "እባክዎ ሁሉንም ሰራተኞች በዚህ ክፍል ውስጥ ዳግም ይሰጧቸው ወይም ያስወግዱ።",
|
||||
@@ -2652,6 +2652,18 @@
|
||||
"selectApplicationToLoadPermissions": "ፍቃዶቹን ለማስገንዘብ አፕሊኬሽኑን ይምረጡ",
|
||||
"copyPermissionsHint": "የነበረ የቦታ አይነት ይምረጡ፤ ፍቃዶቹ አስቀድመው ይሞላሉ፣ ከታች ማስተካከል ይችላሉ።",
|
||||
"copyPermissionsFailed": "ፍቃዶችን መቅዳት አልተቻለም",
|
||||
"selectOrganizationToCopy": "መቅዳት የሚችሏቸውን የቦታ ዓይነቶች ለማየት መጀመሪያ ድርጅት ይምረጡ",
|
||||
"cannotClearAllPermissions": "ተቀምጧል። ፍቃዶቹ አልተቀየሩም — ይህ የቦታ ዓይነት ቢያንስ አንድ ፍቃድ ሊኖረው ይገባል።",
|
||||
"permissionsSelected": "{{count}} ተመርጠዋል",
|
||||
"positionTypeCreated": "የቦታ ዓይነት ተፈጥሯል",
|
||||
"positionTypeUpdated": "የቦታ ዓይነት ተሻሽሏል",
|
||||
"positionTypeDeleted": "የቦታ ዓይነት ተሰርዟል",
|
||||
"positionTypeMigrated": "የቦታ ዓይነት ዝውውር ተሻሽሏል",
|
||||
"positionTypeNotFound": "የቦታ ዓይነት አልተገኘም",
|
||||
"permissionsAssignFailed": "የቦታ ዓይነቱ ተቀምጧል፣ ነገር ግን ፍቃዶቹን መመደብ አልተቻለም። እንደገና ለመሞከር ደግመው ይክፈቱት።",
|
||||
"failedToLoadPermissions": "ፍቃዶችን መጫን አልተቻለም",
|
||||
"failedToLoadPositionTypes": "የቦታ ዓይነቶችን መጫን አልተቻለም",
|
||||
"exportFailed": "የቦታ ዓይነት ቁልፎችን መላክ አልተቻለም",
|
||||
"perFailed": "ፍቃድ መፍጠር አልተቻለም",
|
||||
"perSuccess": "የፍቃድ አይነት ተፈጠረና ፍቃዶች ተመደቡ",
|
||||
"updatePerSuccess": "ፍቃድ በትክክል ተዘምኗል",
|
||||
@@ -3117,7 +3129,7 @@
|
||||
"referenceNumberStyles": "የማጣቀሻ ቁጥር ስታይሎች",
|
||||
"other": "ሌላ",
|
||||
"styleCategories": "የስታይል ምድቦች",
|
||||
"styleEditor": "ስታይል አርታኢ",
|
||||
"styleEditor": "የስታይል አርታዒ",
|
||||
"livePreview": "የቀጥታ ቅድመ-እይታ",
|
||||
"fontsHint": "አማራጭ የቅርጸ-ቁምፊ ሪሶርስ ይምረጡ።",
|
||||
"fontResource": "የቅርጸ-ቁምፊ ፋይል",
|
||||
@@ -3209,11 +3221,9 @@
|
||||
"previewLanguage": "የቅድመ-እይታ ቋንቋ",
|
||||
"amharic": "አማርኛ",
|
||||
"english": "እንግሊዝኛ",
|
||||
"styleCategories": "የስታይል ምድቦች",
|
||||
"categoryHint": "ለማርትዕ ክፍል ይምረጡ",
|
||||
"expandSidebar": "የጎን ማውጫን ዘርጋ",
|
||||
"collapseSidebar": "የጎን ማውጫን ጠቅልል",
|
||||
"styleEditor": "የስታይል አርታዒ",
|
||||
"headerFooterSelection": "ራስጌ እና ግርጌ",
|
||||
"noSettings": "ምንም የስታይል ቅንብሮች የሉም",
|
||||
"visible": "የሚታይ",
|
||||
@@ -7393,5 +7403,100 @@
|
||||
"department": "ዲፓርትመንት",
|
||||
"unit": "ክፍል",
|
||||
"notAvailable": "ያልዋቀረ"
|
||||
},
|
||||
"orgAdmins": {
|
||||
"title": "የድርጅት አስተዳዳሪዎች",
|
||||
"subtitle": "አስተዳዳሪዎቹን ለማየት እና ለማስተዳደር ድርጅት ይምረጡ።",
|
||||
"tableName": "የድርጅት አስተዳዳሪዎች",
|
||||
"selectOrg": "ድርጅት ይምረጡ",
|
||||
"searchOrgs": "ድርጅቶችን ይፈልጉ...",
|
||||
"noOrgsFound": "ምንም ድርጅት አልተገኘም።",
|
||||
"adminsCount": "{{count}} አስተዳዳሪ",
|
||||
"adminsCount_other": "{{count}} አስተዳዳሪዎች",
|
||||
"noAdmins": "አስተዳዳሪ የለም",
|
||||
"activeEmployees": "{{count}} ንቁ ሰራተኞች",
|
||||
"selectOrgPrompt": "አስተዳዳሪዎቹን ለማስተዳደር ድርጅት ይምረጡ",
|
||||
"selectOrgPromptHint": "ከላይ ያለውን መምረጫ ተጠቅመው ድርጅት ይፈልጉ እና ይምረጡ።",
|
||||
"noAdminsHint": "{{name}} እስካሁን አስተዳዳሪ የለውም። አዲስ አስተዳዳሪ ይጋብዙ ወይም ነባር ሰራተኛ ይመድቡ።",
|
||||
"assignExisting": "ነባር ሰራተኛ ይመድቡ",
|
||||
"roleOrgAdmin": "የድርጅት አስተዳዳሪ",
|
||||
"roleUnitAdmin": "የክፍል አስተዳዳሪ",
|
||||
"statusInvited": "የተጋበዘ",
|
||||
"statusActive": "ንቁ",
|
||||
"statusInactive": "ንቁ ያልሆነ",
|
||||
"columns": {
|
||||
"name": "ስም",
|
||||
"email": "ኢሜይል",
|
||||
"phone": "ስልክ",
|
||||
"role": "ሚና",
|
||||
"status": "ሁኔታ",
|
||||
"addedOn": "የተጨመረበት ቀን",
|
||||
"actions": "እርምጃዎች"
|
||||
},
|
||||
"actions": {
|
||||
"edit": "መገለጫ ያስተካክሉ",
|
||||
"resend": "ግብዣ እንደገና ይላኩ",
|
||||
"activate": "መለያ ያንቁ",
|
||||
"deactivate": "መለያ ያቦዝኑ",
|
||||
"remove": "አስተዳዳሪ ያስወግዱ"
|
||||
},
|
||||
"form": {
|
||||
"nameEn": "ስም (እንግሊዝኛ)",
|
||||
"nameAm": "ስም (አማርኛ)",
|
||||
"username": "የተጠቃሚ ስም",
|
||||
"email": "ኢሜይል",
|
||||
"phoneNumber": "ስልክ ቁጥር",
|
||||
"unit": "ክፍል",
|
||||
"selectUnit": "ክፍል ይምረጡ",
|
||||
"loadingUnits": "ክፍሎች በመጫን ላይ...",
|
||||
"noUnit": "ምንም — የድርጅት አስተዳዳሪ",
|
||||
"unitRequired": "ክፍል ያስፈልጋል"
|
||||
},
|
||||
"edit": {
|
||||
"title": "የአስተዳዳሪ መገለጫ ያስተካክሉ",
|
||||
"description": "የዚህን አስተዳዳሪ የመገለጫ ዝርዝሮች ያዘምኑ።",
|
||||
"submit": "ለውጦችን ያስቀምጡ"
|
||||
},
|
||||
"assign": {
|
||||
"title": "ነባር ሰራተኛ ይመድቡ",
|
||||
"description": "የዚህን ድርጅት ሰራተኛ ወደ አስተዳዳሪነት ያሳድጉ።",
|
||||
"searchUsers": "ሰራተኞችን በስም ወይም በኢሜይል ይፈልጉ...",
|
||||
"noUsersFound": "ምንም ሰራተኛ አልተገኘም።",
|
||||
"alreadyAdmin": "አስቀድሞ አስተዳዳሪ ነው",
|
||||
"submit": "እንደ አስተዳዳሪ ይመድቡ",
|
||||
"loadError": "ሰራተኞችን መጫን አልተሳካም።",
|
||||
"users": "ተጠቃሚዎች"
|
||||
},
|
||||
"confirmRemove": {
|
||||
"title": "አስተዳዳሪ ይወገድ?",
|
||||
"description": "ይህ የ{{name}}ን የአስተዳዳሪነት ሚና ከ{{org}} ያስወግዳል። የተጠቃሚው መለያ ራሱ ይቀራል።",
|
||||
"removing": "በማስወገድ ላይ..."
|
||||
},
|
||||
"confirmToggle": {
|
||||
"activateTitle": "መለያ ይንቃ?",
|
||||
"deactivateTitle": "መለያ ይቦዝን?",
|
||||
"description": "ይህ የ{{name}}ን የመለያ ሁኔታ በመላው ስርዓቱ ላይ ይቀይራል፣ ለዚህ ድርጅት ብቻ አይደለም።"
|
||||
},
|
||||
"toasts": {
|
||||
"assigned": "አስተዳዳሪ በተሳካ ሁኔታ ተመድቧል!",
|
||||
"removed": "አስተዳዳሪ በተሳካ ሁኔታ ተወግዷል!",
|
||||
"resent": "ግብዣው እንደገና ተልኳል!",
|
||||
"activated": "መለያው ነቅቷል!",
|
||||
"deactivated": "መለያው ቦዝኗል!",
|
||||
"profileUpdated": "መገለጫው ተዘምኗል!",
|
||||
"resending": "ግብዣ በመላክ ላይ...",
|
||||
"missingContact": "ይህ አስተዳዳሪ ኢሜይል ወይም ስልክ ቁጥር የለውም።",
|
||||
"added": "አስተዳዳሪ በተሳካ ሁኔታ ተጨምሯል!"
|
||||
},
|
||||
"loadError": "አስተዳዳሪዎችን መጫን አልተሳካም።",
|
||||
"pickerError": "ድርጅቶችን መጫን አልተሳካም።",
|
||||
"addAdmin": "አስተዳዳሪ ጨምር",
|
||||
"add": {
|
||||
"title": "አስተዳዳሪ ጨምር",
|
||||
"description": "የተጠቃሚ መለያ ይፍጠሩ እና በዚህ ድርጅት ውስጥ የአስተዳዳሪ መዳረሻ ይስጡ።",
|
||||
"submit": "አስተዳዳሪ ጨምር",
|
||||
"inviteNote": "ተጠቃሚው ይፈጠራል እና የይለፍ ቃሉን እንዲያዘጋጅ የኤስኤምኤስ ግብዣ ይደርሰዋል።",
|
||||
"noUnitsOrgAdmin": "ይህ ድርጅት ክፍሎች የሉትም — አስተዳዳሪው እንደ የድርጅት አስተዳዳሪ ይጨመራል።"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2057,6 +2057,7 @@
|
||||
"setting": "Setting",
|
||||
"loadingAdmins": "Loading admins...",
|
||||
"errorLoadingAdmins": "Error loading admin data",
|
||||
"errorLoadingUnits": "Error loading units",
|
||||
"retry": "Retry",
|
||||
"assignAdmin": "Assign Admin",
|
||||
"addAdmin": "Add Admin",
|
||||
@@ -2760,6 +2761,18 @@
|
||||
"selectApplicationToLoadPermissions": "Select an application to load its permissions",
|
||||
"copyPermissionsHint": "Pick an existing position type to pre-fill its permissions, then edit below.",
|
||||
"copyPermissionsFailed": "Failed to copy permissions",
|
||||
"selectOrganizationToCopy": "Select an organization to see the position types you can copy from",
|
||||
"cannotClearAllPermissions": "Saved. Permissions were left unchanged — this position type must keep at least one permission.",
|
||||
"permissionsSelected": "{{count}} selected",
|
||||
"positionTypeCreated": "Position type created",
|
||||
"positionTypeUpdated": "Position type updated",
|
||||
"positionTypeDeleted": "Position type deleted",
|
||||
"positionTypeMigrated": "Position type migration updated",
|
||||
"positionTypeNotFound": "Position type not found",
|
||||
"permissionsAssignFailed": "Position type saved, but assigning its permissions failed. Reopen it to try again.",
|
||||
"failedToLoadPermissions": "Failed to load permissions",
|
||||
"failedToLoadPositionTypes": "Failed to load position types",
|
||||
"exportFailed": "Failed to export position type keys",
|
||||
"perFailed": "Failed To Create Permission",
|
||||
"perSuccess": "Permission type created and permissions assigned",
|
||||
"updatePerSuccess": "Permission updated successfully",
|
||||
@@ -7391,5 +7404,100 @@
|
||||
"department": "Department",
|
||||
"unit": "Unit",
|
||||
"notAvailable": "Not Available"
|
||||
},
|
||||
"orgAdmins": {
|
||||
"title": "Organization Admins",
|
||||
"subtitle": "Pick an organization to view and manage its administrators.",
|
||||
"tableName": "Organization Admins",
|
||||
"selectOrg": "Select an organization",
|
||||
"searchOrgs": "Search organizations...",
|
||||
"noOrgsFound": "No organizations found.",
|
||||
"adminsCount": "{{count}} admin",
|
||||
"adminsCount_other": "{{count}} admins",
|
||||
"noAdmins": "No admins",
|
||||
"activeEmployees": "{{count}} active employees",
|
||||
"selectOrgPrompt": "Select an organization to manage its admins",
|
||||
"selectOrgPromptHint": "Use the selector above to search and pick an organization.",
|
||||
"noAdminsHint": "{{name}} has no administrators yet. Invite a new admin or assign an existing employee.",
|
||||
"assignExisting": "Assign Existing",
|
||||
"roleOrgAdmin": "Org Admin",
|
||||
"roleUnitAdmin": "Unit Admin",
|
||||
"statusInvited": "Invited",
|
||||
"statusActive": "Active",
|
||||
"statusInactive": "Inactive",
|
||||
"columns": {
|
||||
"name": "Name",
|
||||
"email": "Email",
|
||||
"phone": "Phone",
|
||||
"role": "Role",
|
||||
"status": "Status",
|
||||
"addedOn": "Added On",
|
||||
"actions": "Actions"
|
||||
},
|
||||
"actions": {
|
||||
"edit": "Edit Profile",
|
||||
"resend": "Resend Invite",
|
||||
"activate": "Activate Account",
|
||||
"deactivate": "Deactivate Account",
|
||||
"remove": "Remove Admin"
|
||||
},
|
||||
"form": {
|
||||
"nameEn": "Name (English)",
|
||||
"nameAm": "Name (Amharic)",
|
||||
"username": "Username",
|
||||
"email": "Email",
|
||||
"phoneNumber": "Phone Number",
|
||||
"unit": "Unit",
|
||||
"selectUnit": "Select a unit",
|
||||
"loadingUnits": "Loading units...",
|
||||
"noUnit": "None — organization admin",
|
||||
"unitRequired": "Unit is required"
|
||||
},
|
||||
"edit": {
|
||||
"title": "Edit Admin Profile",
|
||||
"description": "Update this administrator's profile details.",
|
||||
"submit": "Save Changes"
|
||||
},
|
||||
"assign": {
|
||||
"title": "Assign Existing Employee",
|
||||
"description": "Promote an employee of this organization to administrator.",
|
||||
"searchUsers": "Search employees by name or email...",
|
||||
"noUsersFound": "No employees found.",
|
||||
"alreadyAdmin": "Already admin",
|
||||
"submit": "Assign as Admin",
|
||||
"loadError": "Failed to load employees.",
|
||||
"users": "Users"
|
||||
},
|
||||
"confirmRemove": {
|
||||
"title": "Remove Admin?",
|
||||
"description": "This removes the admin role of {{name}} for {{org}}. The user account itself is kept.",
|
||||
"removing": "Removing..."
|
||||
},
|
||||
"confirmToggle": {
|
||||
"activateTitle": "Activate Account?",
|
||||
"deactivateTitle": "Deactivate Account?",
|
||||
"description": "This changes the account status of {{name}} across the whole platform, not just for this organization."
|
||||
},
|
||||
"toasts": {
|
||||
"assigned": "Admin assigned successfully!",
|
||||
"removed": "Admin removed successfully!",
|
||||
"resent": "Invitation re-sent successfully!",
|
||||
"activated": "Account activated successfully!",
|
||||
"deactivated": "Account deactivated successfully!",
|
||||
"profileUpdated": "Profile updated successfully!",
|
||||
"resending": "Sending invite...",
|
||||
"missingContact": "This admin has no email or phone number on file.",
|
||||
"added": "Admin added successfully!"
|
||||
},
|
||||
"loadError": "Failed to load admins.",
|
||||
"pickerError": "Failed to load organizations.",
|
||||
"addAdmin": "Add Admin",
|
||||
"add": {
|
||||
"title": "Add Admin",
|
||||
"description": "Create a user account and grant admin access in this organization.",
|
||||
"submit": "Add Admin",
|
||||
"inviteNote": "The user is created and receives an SMS invitation to set their password.",
|
||||
"noUnitsOrgAdmin": "This organization has no units — the admin will be added as an organization admin."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1525,6 +1525,7 @@
|
||||
"setting": "Paramètre",
|
||||
"loadingAdmins": "Chargement des administrateurs...",
|
||||
"errorLoadingAdmins": "Erreur lors du chargement des données administrateur",
|
||||
"errorLoadingUnits": "Erreur lors du chargement des unités",
|
||||
"retry": "Réessayer",
|
||||
"assignAdmin": "Assigner un administrateur",
|
||||
"addAdmin": "Ajouter un administrateur",
|
||||
@@ -1886,6 +1887,18 @@
|
||||
"selectApplicationToLoadPermissions": "Sélectionner une application pour charger ses autorisations",
|
||||
"copyPermissionsHint": "Choisissez un type de poste existant pour préremplir ses autorisations, puis modifiez ci-dessous.",
|
||||
"copyPermissionsFailed": "Échec de la copie des autorisations",
|
||||
"selectOrganizationToCopy": "Sélectionnez une organisation pour voir les types de poste que vous pouvez copier",
|
||||
"cannotClearAllPermissions": "Enregistré. Les autorisations n'ont pas été modifiées — ce type de poste doit conserver au moins une autorisation.",
|
||||
"permissionsSelected": "{{count}} sélectionné(s)",
|
||||
"positionTypeCreated": "Type de poste créé",
|
||||
"positionTypeUpdated": "Type de poste mis à jour",
|
||||
"positionTypeDeleted": "Type de poste supprimé",
|
||||
"positionTypeMigrated": "Migration du type de poste mise à jour",
|
||||
"positionTypeNotFound": "Type de poste introuvable",
|
||||
"permissionsAssignFailed": "Type de poste enregistré, mais l'attribution de ses autorisations a échoué. Rouvrez-le pour réessayer.",
|
||||
"failedToLoadPermissions": "Échec du chargement des autorisations",
|
||||
"failedToLoadPositionTypes": "Échec du chargement des types de poste",
|
||||
"exportFailed": "Échec de l'exportation des clés de type de poste",
|
||||
"perFailed": "Échec de la création de l’autorisation",
|
||||
"perSuccess": "Type d’autorisation créé et autorisations assignées",
|
||||
"updatePerSuccess": "Autorisation mise à jour avec succès",
|
||||
|
||||
@@ -11,6 +11,7 @@ import "../index.css";
|
||||
import "@edr/ui-common/theme.css";
|
||||
|
||||
import { Toaster } from "react-hot-toast";
|
||||
import { Toaster as SonnerToaster } from "./shared/common/ui/sonner";
|
||||
|
||||
// Initialize i18next before first paint so the detected/persisted language
|
||||
// applies immediately (the vendored IAM UI also imports this via @/i18n).
|
||||
@@ -70,6 +71,9 @@ createRoot(rootElement).render(
|
||||
message (suppressed on warehouse / mile / onboarding pages). */}
|
||||
<ApiErrorModal />
|
||||
<Toaster position="top-right" />
|
||||
{/* sonner toasts (used across super-admin & user-management)
|
||||
rendered nowhere without this mount */}
|
||||
<SonnerToaster position="top-right" richColors />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</StrictMode>
|
||||
|
||||
@@ -67,7 +67,8 @@ const DashboardPage = () => {
|
||||
refetch();
|
||||
refetchAdmins();
|
||||
}}
|
||||
className="bg-primary hover:bg-primary/90 text-primary-foreground">
|
||||
className="bg-primary hover:bg-primary/90 text-primary-foreground"
|
||||
>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t("organization.retry")}
|
||||
</Button>
|
||||
@@ -140,7 +141,8 @@ const DashboardPage = () => {
|
||||
<Link to="/organizations">
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-primary dark:text-primary-400 text-sm px-0">
|
||||
className="text-primary dark:text-primary-400 text-sm px-0"
|
||||
>
|
||||
{t("organization.viewMore")}
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import OrganizationsAdmins from "@/super-admin/components/organizationAdmins/OrganizationAdmins";
|
||||
import OrgAdminsPage from "@/super-admin/components/org-admins/OrgAdminsPage";
|
||||
|
||||
const OrganizationAdminsPage = () => {
|
||||
return <OrganizationsAdmins />;
|
||||
return <OrgAdminsPage />;
|
||||
};
|
||||
|
||||
export default OrganizationAdminsPage;
|
||||
|
||||
@@ -56,6 +56,8 @@ import {
|
||||
humanize,
|
||||
} from "@/components/customers";
|
||||
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import {
|
||||
downloadBookingFile,
|
||||
fetchViewableFile,
|
||||
@@ -108,6 +110,7 @@ export default function CustomerDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { view, viewer } = useFileViewer();
|
||||
const { user } = useAuth();
|
||||
|
||||
const { data: company, isLoading } = useQuery(
|
||||
api.customers.getById.queryOptions({
|
||||
@@ -180,6 +183,11 @@ export default function CustomerDetailPage() {
|
||||
// API's rule exactly, so no button is offered that the server would reject.
|
||||
const stillOnboarding = company ? isOnboardingDraft(company) : false;
|
||||
const canReview = company ? hasSubmittedOnboarding(company) : true;
|
||||
// Workflow gate (above) AND authority: asking the customer to correct a
|
||||
// document is a `customers:verify` action, so a view-only reviewer reads the
|
||||
// documents but is not offered the request-change control.
|
||||
const canRequestDocChange =
|
||||
canReview && hasPermission(user, FREIGHT_PERMS.customers.verify);
|
||||
|
||||
/** Document the reviewer is asking the customer to correct; null = closed. */
|
||||
const [changeRequestDoc, setChangeRequestDoc] =
|
||||
@@ -446,7 +454,7 @@ export default function CustomerDetailPage() {
|
||||
>
|
||||
<Download size={16} />
|
||||
</ActionIcon>
|
||||
{canReview && (
|
||||
{canRequestDocChange && (
|
||||
<ActionIcon
|
||||
component="button"
|
||||
type="button"
|
||||
@@ -468,7 +476,7 @@ export default function CustomerDetailPage() {
|
||||
),
|
||||
},
|
||||
],
|
||||
[view, canReview],
|
||||
[view, canRequestDocChange],
|
||||
);
|
||||
|
||||
const paymentColumns: ColumnDef<CustomerPayment>[] = useMemo(
|
||||
|
||||
@@ -18,6 +18,11 @@ import {
|
||||
} from "@mantine/core";
|
||||
import { Plus, AlertTriangle } from "lucide-react";
|
||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
import ListControls from "@/components/common/ListControls";
|
||||
// Generic list footer — already shared by the fleet and train-scheduling lists
|
||||
// despite the ruleEngine path.
|
||||
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
|
||||
import { useListControls } from "@/hooks/useListControls";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import {
|
||||
complianceService,
|
||||
@@ -86,6 +91,11 @@ export default function CompliancePage() {
|
||||
},
|
||||
});
|
||||
|
||||
const controls = useListControls(records as ComplianceRecord[], {
|
||||
searchKeys: ["type", "status", "documentNumber"],
|
||||
dateKey: "expiryDate",
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async (data: typeof formData) => {
|
||||
const res = await complianceService.create({
|
||||
@@ -210,6 +220,18 @@ export default function CompliancePage() {
|
||||
Compliance Records
|
||||
</Title>
|
||||
<Card withBorder>
|
||||
<ListControls
|
||||
search={controls.search}
|
||||
onSearchChange={controls.setSearch}
|
||||
searchPlaceholder="Search type, status, document no…"
|
||||
dateFrom={controls.dateFrom}
|
||||
onDateFromChange={controls.setDateFrom}
|
||||
dateTo={controls.dateTo}
|
||||
onDateToChange={controls.setDateTo}
|
||||
dateLabel="Expiry"
|
||||
hasFilters={controls.hasFilters}
|
||||
onReset={controls.reset}
|
||||
/>
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
@@ -239,7 +261,7 @@ export default function CompliancePage() {
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
) : null}
|
||||
{(records as ComplianceRecord[]).map((record) => (
|
||||
{controls.pagedRows.map((record) => (
|
||||
<Table.Tr key={record.id}>
|
||||
<Table.Td>{vehicleLabel(record)}</Table.Td>
|
||||
<Table.Td>
|
||||
@@ -259,6 +281,13 @@ export default function CompliancePage() {
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
<RuleEngineListFooter
|
||||
pagination={controls.pagination}
|
||||
pageCount={controls.pageCount}
|
||||
totalCount={controls.totalCount}
|
||||
itemLabel="records"
|
||||
onPaginationChange={controls.setPagination}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Modal */}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ColumnDef } from "@edr/ui-common";
|
||||
import { Box, Button, Card, Container, Group, Modal, Select, Stack, Text, Title } from "@mantine/core";
|
||||
import { DatePickerInput } from "@mantine/dates";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
@@ -15,6 +16,7 @@ import FleetFormDialog from "@/components/fleet/FleetFormDialog";
|
||||
import FleetHistoryModal from "@/components/fleet/FleetHistoryModal";
|
||||
import FleetRecordActions from "@/components/fleet/FleetRecordActions";
|
||||
import FleetToolbar from "@/components/fleet/FleetToolbar";
|
||||
import { matchesDayRange } from "@/hooks/useListControls";
|
||||
import WagonMovementHistoryModal from "@/components/fleet/WagonMovementHistoryModal";
|
||||
import WagonYardWorkspaceModal from "@/components/wagons/WagonYardWorkspaceModal";
|
||||
import WagonTransferRequestsModal from "@/components/wagons/WagonTransferRequestsModal";
|
||||
@@ -53,6 +55,10 @@ const FleetResourcePage = () => {
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const [search, setSearch] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("ALL");
|
||||
// Registration date range. Server-side list filters (status/yard/train) are
|
||||
// applied by the API; this narrows what comes back, alongside search.
|
||||
const [dateFrom, setDateFrom] = useState<string | null>(null);
|
||||
const [dateTo, setDateTo] = useState<string | null>(null);
|
||||
const [listFilterValues, setListFilterValues] = useState<Record<string, string>>({});
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<FleetRecord | null>(null);
|
||||
@@ -100,6 +106,9 @@ const FleetResourcePage = () => {
|
||||
const { data: wagonTypes = [], isLoading: wagonTypesLoading } = useQuery(
|
||||
api.wagonTypes.list.queryOptions(),
|
||||
);
|
||||
const { data: truckTypes = [], isLoading: truckTypesLoading } = useQuery(
|
||||
api.truckTypes.list.queryOptions(),
|
||||
);
|
||||
const { data: containerTypes = [], isLoading: containerTypesLoading } = useQuery(
|
||||
api.containerTypes.list.queryOptions({ staleTime: Infinity }),
|
||||
);
|
||||
@@ -128,7 +137,7 @@ const FleetResourcePage = () => {
|
||||
|
||||
useEffect(() => {
|
||||
setPagination((prev) => ({ pageIndex: 0, pageSize: prev.pageSize }));
|
||||
}, [search, listFilterValues, setPagination]);
|
||||
}, [search, listFilterValues, dateFrom, dateTo, setPagination]);
|
||||
|
||||
const hasStatusColumn = Boolean(config?.columns.some((col) => col.accessorKey === "status"));
|
||||
const usesServerListFilters = Boolean(config?.listFilters?.length);
|
||||
@@ -175,17 +184,34 @@ const FleetResourcePage = () => {
|
||||
(y) => ({ value: y.id, label: y.label ?? y.code ?? y.id }),
|
||||
);
|
||||
|
||||
// Carries capacity + trailer configuration so picking a truck type can
|
||||
// pre-fill the vehicle's capacity and drop the trailer plate on a rigid type.
|
||||
const truckTypeOpts = (
|
||||
truckTypes as Array<{
|
||||
id: string;
|
||||
code: string;
|
||||
name?: string;
|
||||
capacityTons?: number | null;
|
||||
hasTrailer?: boolean;
|
||||
}>
|
||||
).map((t) => ({
|
||||
value: t.id,
|
||||
label: t.name ? `${t.name} (${t.code})` : t.code,
|
||||
meta: { capacityTons: t.capacityTons, hasTrailer: t.hasTrailer },
|
||||
}));
|
||||
|
||||
registerFleetOptionLabels("currentYardId", yardOpts);
|
||||
|
||||
return {
|
||||
wagonTypes: wagonTypeOpts,
|
||||
containerTypes: containerTypeOpts,
|
||||
cargoTypes: [{ label: "None", value: FLEET_SELECT_NONE }, ...cargoTypeOpts],
|
||||
truckTypes: truckTypeOpts,
|
||||
wagons: [{ label: "Unassigned", value: FLEET_SELECT_NONE }, ...wagonOpts],
|
||||
containers: containerOpts,
|
||||
yards: yardOpts,
|
||||
};
|
||||
}, [wagonTypes, containerTypes, cargoTypes, wagons, containers, yards]);
|
||||
}, [wagonTypes, containerTypes, cargoTypes, truckTypes, wagons, containers, yards]);
|
||||
|
||||
const listFilterSelects = useMemo(() => {
|
||||
if (!config?.listFilters?.length) return null;
|
||||
@@ -218,6 +244,7 @@ const FleetResourcePage = () => {
|
||||
registerFleetOptionLabels("containerId", dynamicOptions.containers);
|
||||
registerFleetOptionLabels("currentYardId", dynamicOptions.yards);
|
||||
registerFleetOptionLabels("locationId", dynamicOptions.yards);
|
||||
registerFleetOptionLabels("truckTypeId", dynamicOptions.truckTypes);
|
||||
}, [dynamicOptions]);
|
||||
|
||||
const formFields = useMemo((): FleetFormFieldDef[] => {
|
||||
@@ -233,16 +260,20 @@ const FleetResourcePage = () => {
|
||||
wagonTypesLoading ||
|
||||
containerTypesLoading ||
|
||||
cargoTypesLoading ||
|
||||
truckTypesLoading ||
|
||||
wagonsLoading ||
|
||||
containersLoading ||
|
||||
yardsLoading;
|
||||
|
||||
const filteredRows = useMemo(() => {
|
||||
if (!config) return allRows;
|
||||
if (usesServerListFilters) return allRows;
|
||||
const term = search.trim().toLowerCase();
|
||||
return allRows.filter((row) => {
|
||||
const record = row as unknown as Record<string, unknown>;
|
||||
// The date range applies even when the API already filtered the list —
|
||||
// it is not one of the server-side filters.
|
||||
if (!matchesDayRange(record.createdAt, dateFrom, dateTo)) return false;
|
||||
if (usesServerListFilters) return true;
|
||||
if (statusFilter !== "ALL" && String(record.status ?? "") !== statusFilter) {
|
||||
return false;
|
||||
}
|
||||
@@ -253,7 +284,7 @@ const FleetResourcePage = () => {
|
||||
.includes(term),
|
||||
);
|
||||
});
|
||||
}, [allRows, search, statusFilter, config, usesServerListFilters]);
|
||||
}, [allRows, search, statusFilter, config, usesServerListFilters, dateFrom, dateTo]);
|
||||
|
||||
const pageCount = Math.max(1, Math.ceil(filteredRows.length / pagination.pageSize));
|
||||
const pagedRows = useMemo(() => {
|
||||
@@ -452,7 +483,30 @@ const FleetResourcePage = () => {
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
filters={
|
||||
listFilterSelects ? (
|
||||
<Group gap="sm" wrap="wrap" align="center">
|
||||
<DatePickerInput
|
||||
aria-label="Created from"
|
||||
placeholder="Created from"
|
||||
value={dateFrom}
|
||||
onChange={setDateFrom}
|
||||
maxDate={dateTo ?? undefined}
|
||||
clearable
|
||||
size="sm"
|
||||
radius="lg"
|
||||
w={160}
|
||||
/>
|
||||
<DatePickerInput
|
||||
aria-label="Created to"
|
||||
placeholder="Created to"
|
||||
value={dateTo}
|
||||
onChange={setDateTo}
|
||||
minDate={dateFrom ?? undefined}
|
||||
clearable
|
||||
size="sm"
|
||||
radius="lg"
|
||||
w={160}
|
||||
/>
|
||||
{listFilterSelects ? (
|
||||
<Group gap="sm" wrap="wrap" align="center">
|
||||
{listFilterSelects.map((filter) => (
|
||||
<Select
|
||||
@@ -495,7 +549,8 @@ const FleetResourcePage = () => {
|
||||
))}
|
||||
</Group>
|
||||
</Group>
|
||||
) : undefined
|
||||
) : null}
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -19,6 +19,11 @@ import {
|
||||
} from "@mantine/core";
|
||||
import { Plus } from "lucide-react";
|
||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
import ListControls from "@/components/common/ListControls";
|
||||
// Generic list footer — already shared by the fleet and train-scheduling lists
|
||||
// despite the ruleEngine path.
|
||||
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
|
||||
import { useListControls } from "@/hooks/useListControls";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { api } from "@/auth/http";
|
||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||
@@ -118,6 +123,11 @@ export default function FuelPurchasePage() {
|
||||
const totalCost = formData.liters * formData.costPerLiter;
|
||||
|
||||
// Aggregate stats (guarded against divide-by-zero when there are no purchases)
|
||||
const controls = useListControls(purchasesData as FuelPurchase[], {
|
||||
searchKeys: ["fuelStation", "paymentMethod"],
|
||||
dateKey: "purchaseDate",
|
||||
});
|
||||
|
||||
const totalLiters = (purchasesData as FuelPurchase[]).reduce(
|
||||
(sum, p) => sum + Number(p.liters),
|
||||
0
|
||||
@@ -185,6 +195,18 @@ export default function FuelPurchasePage() {
|
||||
|
||||
{/* Purchases Table */}
|
||||
<Card withBorder>
|
||||
<ListControls
|
||||
search={controls.search}
|
||||
onSearchChange={controls.setSearch}
|
||||
searchPlaceholder="Search station or payment method…"
|
||||
dateFrom={controls.dateFrom}
|
||||
onDateFromChange={controls.setDateFrom}
|
||||
dateTo={controls.dateTo}
|
||||
onDateToChange={controls.setDateTo}
|
||||
dateLabel="Purchased"
|
||||
hasFilters={controls.hasFilters}
|
||||
onReset={controls.reset}
|
||||
/>
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
@@ -215,7 +237,7 @@ export default function FuelPurchasePage() {
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
) : null}
|
||||
{(purchasesData as FuelPurchase[])?.map((purchase) => (
|
||||
{controls.pagedRows.map((purchase) => (
|
||||
<Table.Tr key={purchase.id}>
|
||||
<Table.Td>{(purchase as any).vehicle?.registrationNumber || (purchase as any).vehicle?.plateNumber || purchase.vehicleId}</Table.Td>
|
||||
<Table.Td>{new Date(purchase.purchaseDate).toLocaleDateString()}</Table.Td>
|
||||
@@ -230,6 +252,13 @@ export default function FuelPurchasePage() {
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
<RuleEngineListFooter
|
||||
pagination={controls.pagination}
|
||||
pageCount={controls.pageCount}
|
||||
totalCount={controls.totalCount}
|
||||
itemLabel="purchases"
|
||||
onPaginationChange={controls.setPagination}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Modal */}
|
||||
|
||||
@@ -20,6 +20,11 @@ import {
|
||||
} from "@mantine/core";
|
||||
import { Plus } from "lucide-react";
|
||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
import ListControls from "@/components/common/ListControls";
|
||||
// Generic list footer — already shared by the fleet and train-scheduling lists
|
||||
// despite the ruleEngine path.
|
||||
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
|
||||
import { useListControls } from "@/hooks/useListControls";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import {
|
||||
incidentsService,
|
||||
@@ -161,6 +166,10 @@ export default function IncidentsPage() {
|
||||
})) || [];
|
||||
|
||||
const incidents = incidentsData as Incident[];
|
||||
const controls = useListControls(incidents, {
|
||||
searchKeys: ["type", "severity", "status"],
|
||||
dateKey: "occurredAt",
|
||||
});
|
||||
const totalCount = incidents.length;
|
||||
const openCount = incidents.filter((i) => OPEN_STATUSES.includes(i.status)).length;
|
||||
const underReviewCount = incidents.filter((i) => i.status === "UNDER_REVIEW").length;
|
||||
@@ -237,6 +246,18 @@ export default function IncidentsPage() {
|
||||
|
||||
{/* Incidents Table */}
|
||||
<Card withBorder>
|
||||
<ListControls
|
||||
search={controls.search}
|
||||
onSearchChange={controls.setSearch}
|
||||
searchPlaceholder="Search type, severity, status…"
|
||||
dateFrom={controls.dateFrom}
|
||||
onDateFromChange={controls.setDateFrom}
|
||||
dateTo={controls.dateTo}
|
||||
onDateToChange={controls.setDateTo}
|
||||
dateLabel="Occurred"
|
||||
hasFilters={controls.hasFilters}
|
||||
onReset={controls.reset}
|
||||
/>
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
@@ -267,7 +288,7 @@ export default function IncidentsPage() {
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
) : null}
|
||||
{incidents.map((incident) => (
|
||||
{controls.pagedRows.map((incident) => (
|
||||
<Table.Tr key={incident.id}>
|
||||
<Table.Td>{new Date(incident.occurredAt).toLocaleDateString()}</Table.Td>
|
||||
<Table.Td>
|
||||
@@ -294,6 +315,13 @@ export default function IncidentsPage() {
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
<RuleEngineListFooter
|
||||
pagination={controls.pagination}
|
||||
pageCount={controls.pageCount}
|
||||
totalCount={controls.totalCount}
|
||||
itemLabel="incidents"
|
||||
onPaginationChange={controls.setPagination}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Modal */}
|
||||
|
||||
@@ -14,8 +14,10 @@ import {
|
||||
Text,
|
||||
Title,
|
||||
Container,
|
||||
ActionIcon,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { CheckCircle2, Plus, Trash2 } from 'lucide-react';
|
||||
import Breadcrumbs from '@/components/ui/Breadcrumbs';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { QUERY_KEYS } from '@/constants/QUERY_KEYS';
|
||||
@@ -26,6 +28,7 @@ interface MaintenanceSchedule {
|
||||
id: string;
|
||||
vehicleId: string;
|
||||
maintenanceType: string;
|
||||
serviceItem?: string | null;
|
||||
description: string;
|
||||
scheduledDate: string;
|
||||
completedDate?: string;
|
||||
@@ -35,8 +38,36 @@ interface MaintenanceSchedule {
|
||||
serviceProvider?: string;
|
||||
}
|
||||
|
||||
interface DueBoardRow {
|
||||
scheduleId: string;
|
||||
vehicleId: string;
|
||||
plateNumber: string;
|
||||
maintenanceType: string;
|
||||
serviceItem: string | null;
|
||||
description: string;
|
||||
scheduledDate: string;
|
||||
nextDueDate: string | null;
|
||||
nextDueKm: number | null;
|
||||
currentKm: number | null;
|
||||
kmRemaining: number | null;
|
||||
daysRemaining: number | null;
|
||||
overdue: boolean;
|
||||
}
|
||||
|
||||
interface MaintenanceInterval {
|
||||
id: string;
|
||||
vehicleId: string;
|
||||
maintenanceType: string;
|
||||
serviceItem: string | null;
|
||||
intervalKm: number | null;
|
||||
intervalDays: number | null;
|
||||
description: string | null;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
const emptyForm = {
|
||||
maintenanceType: 'PREVENTIVE',
|
||||
serviceItem: '',
|
||||
description: '',
|
||||
scheduledDate: new Date().toISOString().split('T')[0],
|
||||
estimatedCost: 0,
|
||||
@@ -44,12 +75,34 @@ const emptyForm = {
|
||||
notes: '',
|
||||
};
|
||||
|
||||
const emptyIntervalForm = {
|
||||
maintenanceType: 'PREVENTIVE',
|
||||
serviceItem: '',
|
||||
intervalKm: '' as number | '',
|
||||
intervalDays: '' as number | '',
|
||||
description: '',
|
||||
};
|
||||
|
||||
export function MaintenancePage() {
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedVehicle, setSelectedVehicle] = useState<string | null>(null);
|
||||
const [openScheduleModal, setOpenScheduleModal] = useState(false);
|
||||
const [formData, setFormData] = useState(emptyForm);
|
||||
const [intervalForm, setIntervalForm] = useState(emptyIntervalForm);
|
||||
const [completeTarget, setCompleteTarget] = useState<MaintenanceSchedule | null>(null);
|
||||
const [completeOdometer, setCompleteOdometer] = useState<number | ''>('');
|
||||
const [completeCost, setCompleteCost] = useState<number | ''>('');
|
||||
|
||||
// Maintenance is driven by time AND km, not a picked-then-scheduled action —
|
||||
// this is the fleet-wide board of what's actually due, by date or mileage.
|
||||
const { data: dueBoard, isLoading: dueLoading } = useQuery({
|
||||
queryKey: QUERY_KEYS.MAINTENANCE.dueBoard(),
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/maintenance/due-board');
|
||||
return (res.data || []) as DueBoardRow[];
|
||||
},
|
||||
});
|
||||
|
||||
const { data: vehiclesData } = useQuery({
|
||||
queryKey: QUERY_KEYS.VEHICLES.list(),
|
||||
@@ -69,7 +122,32 @@ export function MaintenancePage() {
|
||||
enabled: !!selectedVehicle,
|
||||
});
|
||||
|
||||
const { data: intervals } = useQuery({
|
||||
queryKey: QUERY_KEYS.MAINTENANCE.intervals(selectedVehicle || ''),
|
||||
queryFn: async () => {
|
||||
if (!selectedVehicle) return [];
|
||||
const res = await api.get(`/maintenance/intervals/${selectedVehicle}`);
|
||||
return (res.data || []) as MaintenanceInterval[];
|
||||
},
|
||||
enabled: !!selectedVehicle,
|
||||
});
|
||||
|
||||
const upcomingList: MaintenanceSchedule[] = Array.isArray(upcoming) ? upcoming : [];
|
||||
const intervalList: MaintenanceInterval[] = Array.isArray(intervals) ? intervals : [];
|
||||
|
||||
const invalidateVehicle = () => {
|
||||
void queryClient.invalidateQueries({ queryKey: QUERY_KEYS.MAINTENANCE.ROOT });
|
||||
};
|
||||
|
||||
const onError = (err: unknown) => {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description:
|
||||
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ??
|
||||
'Failed',
|
||||
variant: 'destructive',
|
||||
});
|
||||
};
|
||||
|
||||
const scheduleMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
@@ -77,24 +155,79 @@ export function MaintenancePage() {
|
||||
const res = await api.post('/maintenance/schedules', {
|
||||
vehicleId: selectedVehicle,
|
||||
...formData,
|
||||
serviceItem: formData.serviceItem.trim() || undefined,
|
||||
});
|
||||
return res.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({ title: 'Maintenance scheduled' });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: QUERY_KEYS.MAINTENANCE.upcoming(selectedVehicle || ''),
|
||||
});
|
||||
invalidateVehicle();
|
||||
setOpenScheduleModal(false);
|
||||
setFormData(emptyForm);
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: err?.response?.data?.message ?? 'Failed',
|
||||
variant: 'destructive',
|
||||
onError,
|
||||
});
|
||||
|
||||
// Interval upsert: "oil change every 10,000 km" — drives the auto-scheduling
|
||||
// of the next service when a maintenance completes with an odometer reading.
|
||||
const intervalMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!selectedVehicle) return;
|
||||
const res = await api.post('/maintenance/intervals', {
|
||||
vehicleId: selectedVehicle,
|
||||
maintenanceType: intervalForm.maintenanceType,
|
||||
serviceItem: intervalForm.serviceItem.trim() || undefined,
|
||||
intervalKm: intervalForm.intervalKm === '' ? undefined : Number(intervalForm.intervalKm),
|
||||
intervalDays:
|
||||
intervalForm.intervalDays === '' ? undefined : Number(intervalForm.intervalDays),
|
||||
description: intervalForm.description.trim() || undefined,
|
||||
});
|
||||
return res.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({ title: 'Interval saved' });
|
||||
invalidateVehicle();
|
||||
setIntervalForm(emptyIntervalForm);
|
||||
},
|
||||
onError,
|
||||
});
|
||||
|
||||
const deactivateIntervalMutation = useMutation({
|
||||
mutationFn: async (id: string) => api.delete(`/maintenance/intervals/${id}`),
|
||||
onSuccess: () => {
|
||||
toast({ title: 'Interval deactivated' });
|
||||
invalidateVehicle();
|
||||
},
|
||||
onError,
|
||||
});
|
||||
|
||||
// Completion with odometer: the reading is what advances KM-based
|
||||
// scheduling — the API auto-creates the next SCHEDULED item from it.
|
||||
const completeMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!completeTarget) return;
|
||||
const res = await api.patch(`/maintenance/schedules/${completeTarget.id}`, {
|
||||
status: 'COMPLETED',
|
||||
completedDate: new Date().toISOString(),
|
||||
odometerReading: completeOdometer === '' ? undefined : Number(completeOdometer),
|
||||
actualCost: completeCost === '' ? undefined : Number(completeCost),
|
||||
});
|
||||
return res.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: 'Maintenance completed',
|
||||
description:
|
||||
completeOdometer === ''
|
||||
? 'No odometer recorded — next service was NOT auto-scheduled.'
|
||||
: 'Next service auto-scheduled from the recorded odometer.',
|
||||
});
|
||||
invalidateVehicle();
|
||||
setCompleteTarget(null);
|
||||
setCompleteOdometer('');
|
||||
setCompleteCost('');
|
||||
},
|
||||
onError,
|
||||
});
|
||||
|
||||
const vehicleOptions =
|
||||
@@ -132,6 +265,64 @@ export function MaintenancePage() {
|
||||
</Group>
|
||||
|
||||
<Stack gap="md">
|
||||
<Card withBorder>
|
||||
<Card.Section p="md" withBorder>
|
||||
<Text fw={500}>Due Board — by date and driven km</Text>
|
||||
</Card.Section>
|
||||
<Card.Section p="md">
|
||||
{dueLoading ? (
|
||||
<Text>Loading…</Text>
|
||||
) : dueBoard && dueBoard.length > 0 ? (
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Vehicle</Table.Th>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Service Item</Table.Th>
|
||||
<Table.Th>Next Due Date</Table.Th>
|
||||
<Table.Th>Next Due Km</Table.Th>
|
||||
<Table.Th>Current Km</Table.Th>
|
||||
<Table.Th>Remaining</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{dueBoard.map((row) => (
|
||||
<Table.Tr
|
||||
key={row.scheduleId}
|
||||
onClick={() => setSelectedVehicle(row.vehicleId)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<Table.Td>{row.plateNumber}</Table.Td>
|
||||
<Table.Td>{row.maintenanceType}</Table.Td>
|
||||
<Table.Td>{row.serviceItem ?? '—'}</Table.Td>
|
||||
<Table.Td>
|
||||
{row.nextDueDate ? new Date(row.nextDueDate).toLocaleDateString() : '—'}
|
||||
</Table.Td>
|
||||
<Table.Td>{row.nextDueKm ?? '—'}</Table.Td>
|
||||
<Table.Td>{row.currentKm ?? '—'}</Table.Td>
|
||||
<Table.Td>
|
||||
{row.kmRemaining != null
|
||||
? `${row.kmRemaining} km`
|
||||
: row.daysRemaining != null
|
||||
? `${row.daysRemaining} d`
|
||||
: '—'}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={row.overdue ? 'edr-red' : 'edr-blue'}>
|
||||
{row.overdue ? 'OVERDUE' : 'SCHEDULED'}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
) : (
|
||||
<Text c="dimmed">Nothing scheduled fleet-wide</Text>
|
||||
)}
|
||||
</Card.Section>
|
||||
</Card>
|
||||
|
||||
<Card withBorder padding="md">
|
||||
<Select
|
||||
label="Select Vehicle"
|
||||
@@ -150,50 +341,179 @@ export function MaintenancePage() {
|
||||
</Text>
|
||||
</Card>
|
||||
) : (
|
||||
<Card withBorder>
|
||||
<Card.Section p="md" withBorder>
|
||||
<Text fw={500}>Upcoming Maintenance</Text>
|
||||
</Card.Section>
|
||||
<Card.Section p="md">
|
||||
{isLoading ? (
|
||||
<Text>Loading...</Text>
|
||||
) : upcomingList.length > 0 ? (
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Description</Table.Th>
|
||||
<Table.Th>Scheduled</Table.Th>
|
||||
<Table.Th>Est. Cost</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{upcomingList.map((m) => (
|
||||
<Table.Tr key={m.id}>
|
||||
<Table.Td>{m.maintenanceType}</Table.Td>
|
||||
<Table.Td>{m.description}</Table.Td>
|
||||
<Table.Td>{new Date(m.scheduledDate).toLocaleDateString()}</Table.Td>
|
||||
<Table.Td>
|
||||
{m.estimatedCost != null
|
||||
? `ETB ${Number(m.estimatedCost).toLocaleString('en-US', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}`
|
||||
: '—'}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={statusColor(m.status)}>{m.status}</Badge>
|
||||
</Table.Td>
|
||||
<>
|
||||
<Card withBorder>
|
||||
<Card.Section p="md" withBorder>
|
||||
<Text fw={500}>Service Intervals — drives auto-scheduling</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
e.g. oil change every 10,000 km. On completion with an odometer reading, the
|
||||
next service is scheduled automatically at reading + interval.
|
||||
</Text>
|
||||
</Card.Section>
|
||||
<Card.Section p="md">
|
||||
<Stack gap="sm">
|
||||
{intervalList.length > 0 && (
|
||||
<Table striped>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Service Item</Table.Th>
|
||||
<Table.Th>Every (km)</Table.Th>
|
||||
<Table.Th>Every (days)</Table.Th>
|
||||
<Table.Th>Description</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{intervalList.map((i) => (
|
||||
<Table.Tr key={i.id}>
|
||||
<Table.Td>{i.maintenanceType}</Table.Td>
|
||||
<Table.Td>{i.serviceItem ?? '—'}</Table.Td>
|
||||
<Table.Td>{i.intervalKm ?? '—'}</Table.Td>
|
||||
<Table.Td>{i.intervalDays ?? '—'}</Table.Td>
|
||||
<Table.Td>{i.description ?? '—'}</Table.Td>
|
||||
<Table.Td>
|
||||
<Tooltip label="Deactivate — stops auto-scheduling">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => deactivateIntervalMutation.mutate(i.id)}
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
<Group align="flex-end" gap="sm" wrap="wrap">
|
||||
<Select
|
||||
label="Type"
|
||||
w={150}
|
||||
data={['PREVENTIVE', 'CORRECTIVE', 'INSPECTION', 'REPAIR']}
|
||||
value={intervalForm.maintenanceType}
|
||||
onChange={(v) =>
|
||||
setIntervalForm({ ...intervalForm, maintenanceType: v || 'PREVENTIVE' })
|
||||
}
|
||||
/>
|
||||
<TextInput
|
||||
label="Service item"
|
||||
placeholder="e.g. oil change"
|
||||
w={170}
|
||||
value={intervalForm.serviceItem}
|
||||
onChange={(e) =>
|
||||
setIntervalForm({ ...intervalForm, serviceItem: e.currentTarget.value })
|
||||
}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Every (km)"
|
||||
min={0}
|
||||
w={130}
|
||||
value={intervalForm.intervalKm}
|
||||
onChange={(v) =>
|
||||
setIntervalForm({ ...intervalForm, intervalKm: v === '' ? '' : Number(v) })
|
||||
}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Every (days)"
|
||||
min={0}
|
||||
w={130}
|
||||
value={intervalForm.intervalDays}
|
||||
onChange={(v) =>
|
||||
setIntervalForm({
|
||||
...intervalForm,
|
||||
intervalDays: v === '' ? '' : Number(v),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<TextInput
|
||||
label="Description"
|
||||
placeholder="Oil and filter change"
|
||||
style={{ flex: 1, minWidth: 160 }}
|
||||
value={intervalForm.description}
|
||||
onChange={(e) =>
|
||||
setIntervalForm({ ...intervalForm, description: e.currentTarget.value })
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
color="edr-green"
|
||||
leftSection={<Plus size={14} />}
|
||||
loading={intervalMutation.isPending}
|
||||
disabled={
|
||||
intervalForm.intervalKm === '' && intervalForm.intervalDays === ''
|
||||
}
|
||||
onClick={() => intervalMutation.mutate()}
|
||||
>
|
||||
Save interval
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card.Section>
|
||||
</Card>
|
||||
|
||||
<Card withBorder>
|
||||
<Card.Section p="md" withBorder>
|
||||
<Text fw={500}>Upcoming Maintenance</Text>
|
||||
</Card.Section>
|
||||
<Card.Section p="md">
|
||||
{isLoading ? (
|
||||
<Text>Loading...</Text>
|
||||
) : upcomingList.length > 0 ? (
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Service Item</Table.Th>
|
||||
<Table.Th>Description</Table.Th>
|
||||
<Table.Th>Scheduled</Table.Th>
|
||||
<Table.Th>Est. Cost</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
) : (
|
||||
<Text c="dimmed">No upcoming maintenance</Text>
|
||||
)}
|
||||
</Card.Section>
|
||||
</Card>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{upcomingList.map((m) => (
|
||||
<Table.Tr key={m.id}>
|
||||
<Table.Td>{m.maintenanceType}</Table.Td>
|
||||
<Table.Td>{m.serviceItem ?? '—'}</Table.Td>
|
||||
<Table.Td>{m.description}</Table.Td>
|
||||
<Table.Td>{new Date(m.scheduledDate).toLocaleDateString()}</Table.Td>
|
||||
<Table.Td>
|
||||
{m.estimatedCost != null
|
||||
? `ETB ${Number(m.estimatedCost).toLocaleString('en-US', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}`
|
||||
: '—'}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={statusColor(m.status)}>{m.status}</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{(m.status === 'SCHEDULED' || m.status === 'IN_PROGRESS') && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
leftSection={<CheckCircle2 size={13} />}
|
||||
onClick={() => setCompleteTarget(m)}
|
||||
>
|
||||
Complete
|
||||
</Button>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
) : (
|
||||
<Text c="dimmed">No upcoming maintenance</Text>
|
||||
)}
|
||||
</Card.Section>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
@@ -210,6 +530,12 @@ export function MaintenancePage() {
|
||||
value={formData.maintenanceType}
|
||||
onChange={(v) => setFormData({ ...formData, maintenanceType: v || 'PREVENTIVE' })}
|
||||
/>
|
||||
<TextInput
|
||||
label="Service item"
|
||||
placeholder="e.g. oil change — links this schedule to its interval"
|
||||
value={formData.serviceItem}
|
||||
onChange={(e) => setFormData({ ...formData, serviceItem: e.currentTarget.value })}
|
||||
/>
|
||||
<TextInput
|
||||
label="Description"
|
||||
placeholder="What needs to be done?"
|
||||
@@ -254,6 +580,49 @@ export function MaintenancePage() {
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
opened={completeTarget != null}
|
||||
onClose={() => setCompleteTarget(null)}
|
||||
title={`Complete maintenance${completeTarget?.serviceItem ? ` — ${completeTarget.serviceItem}` : ''}`}
|
||||
size="md"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
Record the odometer at completion — the next service is auto-scheduled at reading +
|
||||
interval (e.g. completed at 50,000 km with a 10,000 km interval ⇒ next due at 60,000
|
||||
km).
|
||||
</Text>
|
||||
<NumberInput
|
||||
label="Odometer reading (km)"
|
||||
placeholder="e.g. 50000"
|
||||
min={0}
|
||||
required
|
||||
value={completeOdometer}
|
||||
onChange={(v) => setCompleteOdometer(v === '' ? '' : Number(v))}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Actual cost (ETB)"
|
||||
min={0}
|
||||
value={completeCost}
|
||||
onChange={(v) => setCompleteCost(v === '' ? '' : Number(v))}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="light" onClick={() => setCompleteTarget(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
leftSection={<CheckCircle2 size={15} />}
|
||||
loading={completeMutation.isPending}
|
||||
disabled={completeOdometer === ''}
|
||||
onClick={() => completeMutation.mutate()}
|
||||
>
|
||||
Complete & schedule next
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ const clean = <T extends Record<string, unknown>>(obj: T): Partial<T> =>
|
||||
) as Partial<T>;
|
||||
|
||||
const emptyAcquisition = {
|
||||
itemName: "",
|
||||
vehicleId: "",
|
||||
vendorId: "",
|
||||
acquisitionType: "PURCHASE" as AcquisitionType,
|
||||
@@ -239,6 +240,7 @@ export default function ProcurementPage() {
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Item / Asset</Table.Th>
|
||||
<Table.Th>Vehicle</Table.Th>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Date</Table.Th>
|
||||
@@ -249,7 +251,7 @@ export default function ProcurementPage() {
|
||||
<Table.Tbody>
|
||||
{loadingAcquisitions ? (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={5}>
|
||||
<Table.Td colSpan={6}>
|
||||
<Group justify="center" py="md">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
@@ -257,7 +259,7 @@ export default function ProcurementPage() {
|
||||
</Table.Tr>
|
||||
) : acquisitions.length === 0 ? (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={5}>
|
||||
<Table.Td colSpan={6}>
|
||||
<Text c="dimmed" ta="center" py="md">
|
||||
No acquisitions recorded yet.
|
||||
</Text>
|
||||
@@ -266,6 +268,7 @@ export default function ProcurementPage() {
|
||||
) : null}
|
||||
{acquisitions.map((a: AssetAcquisition) => (
|
||||
<Table.Tr key={a.id}>
|
||||
<Table.Td>{a.itemName || "—"}</Table.Td>
|
||||
<Table.Td>{vehicleLabel(a.vehicle, a.vehicleId)}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="sm" color={typeBadgeColor(a.acquisitionType)}>
|
||||
@@ -411,31 +414,51 @@ export default function ProcurementPage() {
|
||||
size="lg"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label="Item / Asset"
|
||||
placeholder="What was acquired — e.g. brake pads, tyres, truck 3-15288"
|
||||
value={acqForm.itemName}
|
||||
onChange={(e) => setAcqForm({ ...acqForm, itemName: e.currentTarget.value })}
|
||||
required
|
||||
/>
|
||||
<Select
|
||||
label="Vehicle"
|
||||
placeholder="Select vehicle"
|
||||
label="Related vehicle (optional)"
|
||||
description="Only when the acquisition is a fleet vehicle itself — parts and general procurement stay unlinked."
|
||||
placeholder="Not tied to a vehicle"
|
||||
data={vehicleOptions}
|
||||
value={acqForm.vehicleId}
|
||||
onChange={(val) => setAcqForm({ ...acqForm, vehicleId: val || "" })}
|
||||
searchable
|
||||
clearable
|
||||
/>
|
||||
<Select
|
||||
label="Vendor"
|
||||
placeholder="Select vendor"
|
||||
data={vendorOptions}
|
||||
value={acqForm.vendorId}
|
||||
onChange={(val) => setAcqForm({ ...acqForm, vendorId: val || "" })}
|
||||
searchable
|
||||
clearable
|
||||
/>
|
||||
<Group gap="xs" align="flex-end" wrap="nowrap">
|
||||
<Select
|
||||
style={{ flex: 1 }}
|
||||
label="Vendor"
|
||||
placeholder="Select vendor"
|
||||
data={vendorOptions}
|
||||
value={acqForm.vendorId}
|
||||
onChange={(val) => setAcqForm({ ...acqForm, vendorId: val || "" })}
|
||||
searchable
|
||||
clearable
|
||||
/>
|
||||
<Button variant="light" size="sm" onClick={() => setVendorModalOpen(true)}>
|
||||
Register vendor
|
||||
</Button>
|
||||
</Group>
|
||||
<Select
|
||||
label="Acquisition Type"
|
||||
data={ACQUISITION_TYPES}
|
||||
value={acqForm.acquisitionType}
|
||||
onChange={(val) =>
|
||||
setAcqForm({ ...acqForm, acquisitionType: (val as AcquisitionType) || "PURCHASE" })
|
||||
}
|
||||
onChange={(val) => {
|
||||
const acquisitionType = (val as AcquisitionType) || "PURCHASE";
|
||||
// Lease terms are invalid on a purchase — drop them on switch.
|
||||
setAcqForm(
|
||||
acquisitionType === "PURCHASE"
|
||||
? { ...acqForm, acquisitionType, leaseStart: "", leaseEnd: "", monthlyPayment: undefined }
|
||||
: { ...acqForm, acquisitionType },
|
||||
);
|
||||
}}
|
||||
required
|
||||
/>
|
||||
<TextInput
|
||||
@@ -471,28 +494,32 @@ export default function ProcurementPage() {
|
||||
decimalScale={2}
|
||||
min={0}
|
||||
/>
|
||||
<TextInput
|
||||
label="Lease Start"
|
||||
type="date"
|
||||
value={acqForm.leaseStart}
|
||||
onChange={(e) => setAcqForm({ ...acqForm, leaseStart: e.currentTarget.value })}
|
||||
/>
|
||||
<TextInput
|
||||
label="Lease End"
|
||||
type="date"
|
||||
value={acqForm.leaseEnd}
|
||||
onChange={(e) => setAcqForm({ ...acqForm, leaseEnd: e.currentTarget.value })}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Monthly Payment"
|
||||
placeholder="0.00"
|
||||
value={acqForm.monthlyPayment}
|
||||
onChange={(val) =>
|
||||
setAcqForm({ ...acqForm, monthlyPayment: val as number | undefined })
|
||||
}
|
||||
decimalScale={2}
|
||||
min={0}
|
||||
/>
|
||||
{acqForm.acquisitionType !== "PURCHASE" && (
|
||||
<>
|
||||
<TextInput
|
||||
label="Lease Start"
|
||||
type="date"
|
||||
value={acqForm.leaseStart}
|
||||
onChange={(e) => setAcqForm({ ...acqForm, leaseStart: e.currentTarget.value })}
|
||||
/>
|
||||
<TextInput
|
||||
label="Lease End"
|
||||
type="date"
|
||||
value={acqForm.leaseEnd}
|
||||
onChange={(e) => setAcqForm({ ...acqForm, leaseEnd: e.currentTarget.value })}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Monthly Payment"
|
||||
placeholder="0.00"
|
||||
value={acqForm.monthlyPayment}
|
||||
onChange={(val) =>
|
||||
setAcqForm({ ...acqForm, monthlyPayment: val as number | undefined })
|
||||
}
|
||||
decimalScale={2}
|
||||
min={0}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Select
|
||||
label="Status"
|
||||
data={ACQUISITION_STATUSES}
|
||||
@@ -514,7 +541,7 @@ export default function ProcurementPage() {
|
||||
<Button
|
||||
onClick={() => createAcquisition.mutate()}
|
||||
loading={createAcquisition.isPending}
|
||||
disabled={!acqForm.acquisitionDate}
|
||||
disabled={!acqForm.acquisitionDate || acqForm.itemName.trim().length < 2}
|
||||
>
|
||||
Save Acquisition
|
||||
</Button>
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Container,
|
||||
Group,
|
||||
Loader,
|
||||
NumberInput,
|
||||
Radio,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
@@ -20,6 +24,7 @@ import {
|
||||
import {
|
||||
ArrowLeft,
|
||||
Fuel,
|
||||
Gauge,
|
||||
History,
|
||||
Route,
|
||||
Truck,
|
||||
@@ -28,7 +33,13 @@ import {
|
||||
} from "lucide-react";
|
||||
|
||||
import { api } from "@/auth/http";
|
||||
import { vehiclesService } from "@/services/vehicles.service";
|
||||
import { api as apiClient2 } from "@/services/api";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import {
|
||||
vehiclesService,
|
||||
type SaveVehiclePayload,
|
||||
type Vehicle,
|
||||
} from "@/services/vehicles.service";
|
||||
import { driversService } from "@/services/drivers.service";
|
||||
import { fleetHistoryService } from "@/services/fleet-history.service";
|
||||
|
||||
@@ -132,6 +143,7 @@ const VehicleDetailPage = () => {
|
||||
<Tabs.Tab value="history" leftSection={<History size={14} />}>History</Tabs.Tab>
|
||||
<Tabs.Tab value="maintenance" leftSection={<Wrench size={14} />}>Maintenance</Tabs.Tab>
|
||||
<Tabs.Tab value="fuel" leftSection={<Fuel size={14} />}>Fuel</Tabs.Tab>
|
||||
<Tabs.Tab value="operations" leftSection={<Gauge size={14} />}>Operations</Tabs.Tab>
|
||||
<Tabs.Tab value="mile" leftSection={<Route size={14} />}>First/Last mile</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
@@ -142,6 +154,8 @@ const VehicleDetailPage = () => {
|
||||
<InfoRow label="Code" value={vehicle.code ?? "—"} />
|
||||
<InfoRow label="Registration" value={vehicle.registrationNumber ?? "—"} />
|
||||
<InfoRow label="Type" value={vehicle.vehicleType ?? "—"} />
|
||||
<InfoRow label="VIN" value={vehicle.vin ?? "—"} />
|
||||
<InfoRow label="Ownership" value={vehicle.ownership ?? "—"} />
|
||||
<InfoRow label="Manufacturer" value={vehicle.manufacturer ?? "—"} />
|
||||
<InfoRow label="Model" value={vehicle.model ?? "—"} />
|
||||
<InfoRow label="Year" value={vehicle.year ?? "—"} />
|
||||
@@ -172,6 +186,10 @@ const VehicleDetailPage = () => {
|
||||
<FuelTab vehicleId={id} />
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="operations" pt="lg">
|
||||
<OperationsTab vehicle={vehicle} />
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="mile" pt="lg">
|
||||
<MileTab vehicleId={id} />
|
||||
</Tabs.Panel>
|
||||
@@ -181,6 +199,103 @@ const VehicleDetailPage = () => {
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Where a truck is and what it costs to run are per-trip operational facts, not
|
||||
* part of registering the vehicle — so they are edited here rather than on the
|
||||
* Add Vehicle form. `pricePerKm` is live billing input: first/last-mile charges
|
||||
* are `distance × pricePerKm`.
|
||||
*/
|
||||
const OperationsTab = ({ vehicle }: { vehicle: Vehicle }) => {
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [form, setForm] = useState({
|
||||
locationId: vehicle.locationId ?? "",
|
||||
estimatedDistanceKm: vehicle.estimatedDistanceKm ?? "",
|
||||
actualDistanceKm: vehicle.actualDistanceKm ?? "",
|
||||
pricePerKm: vehicle.pricePerKm ?? "",
|
||||
currency: vehicle.currency ?? "ETB",
|
||||
});
|
||||
|
||||
const { data: yards = [], isLoading: yardsLoading } = useQuery(
|
||||
apiClient2.routes.yards.queryOptions(),
|
||||
);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () =>
|
||||
vehiclesService.update(vehicle.id, {
|
||||
locationId: form.locationId || null,
|
||||
// Empty means "not recorded" — send null so the column is unset rather
|
||||
// than coerced to 0, which would read as a real measurement.
|
||||
estimatedDistanceKm: form.estimatedDistanceKm === "" ? null : Number(form.estimatedDistanceKm),
|
||||
actualDistanceKm: form.actualDistanceKm === "" ? null : Number(form.actualDistanceKm),
|
||||
pricePerKm: form.pricePerKm === "" ? null : Number(form.pricePerKm),
|
||||
currency: form.currency || null,
|
||||
} as Partial<SaveVehiclePayload> & { locationId?: string | null }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["vehicle", vehicle.id] });
|
||||
toast({ title: "Operational details saved" });
|
||||
},
|
||||
onError: () =>
|
||||
toast({ title: "Could not save operational details", variant: "destructive" }),
|
||||
});
|
||||
|
||||
return (
|
||||
<Card withBorder radius="md" padding="md">
|
||||
<Stack gap="md">
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
|
||||
<Select
|
||||
label="Location"
|
||||
placeholder={yardsLoading ? "Loading yards..." : "Not set"}
|
||||
data={(yards as Array<{ id: string; label?: string; code?: string }>).map((y) => ({
|
||||
value: y.id,
|
||||
label: y.label ?? y.code ?? y.id,
|
||||
}))}
|
||||
value={form.locationId || null}
|
||||
onChange={(v) => setForm((f) => ({ ...f, locationId: v ?? "" }))}
|
||||
disabled={yardsLoading}
|
||||
searchable
|
||||
clearable
|
||||
/>
|
||||
<NumberInput
|
||||
label="Price per KM"
|
||||
description="Used for first/last-mile billing"
|
||||
value={form.pricePerKm}
|
||||
onChange={(v) => setForm((f) => ({ ...f, pricePerKm: v as number | "" }))}
|
||||
min={0}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Estimated Distance (KM)"
|
||||
value={form.estimatedDistanceKm}
|
||||
onChange={(v) => setForm((f) => ({ ...f, estimatedDistanceKm: v as number | "" }))}
|
||||
min={0}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Actual Distance (KM)"
|
||||
value={form.actualDistanceKm}
|
||||
onChange={(v) => setForm((f) => ({ ...f, actualDistanceKm: v as number | "" }))}
|
||||
min={0}
|
||||
/>
|
||||
<Radio.Group
|
||||
label="Currency"
|
||||
value={form.currency}
|
||||
onChange={(v) => setForm((f) => ({ ...f, currency: v }))}
|
||||
>
|
||||
<Group gap="lg" mt={6}>
|
||||
<Radio value="ETB" label="ETB" />
|
||||
<Radio value="USD" label="USD" />
|
||||
</Group>
|
||||
</Radio.Group>
|
||||
</SimpleGrid>
|
||||
<Group justify="flex-end">
|
||||
<Button onClick={() => save.mutate()} loading={save.isPending}>
|
||||
Save
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const DriverTab = ({
|
||||
vehicleId,
|
||||
driverId,
|
||||
|
||||
@@ -30,10 +30,22 @@ export type FleetDynamicOptions =
|
||||
| "wagonTypes"
|
||||
| "containerTypes"
|
||||
| "cargoTypes"
|
||||
| "truckTypes"
|
||||
| "wagons"
|
||||
| "containers"
|
||||
| "yards";
|
||||
|
||||
/**
|
||||
* A dynamic select option that can carry the record it came from. Picking a
|
||||
* truck type has to pull its capacity and trailer configuration into the form,
|
||||
* which a bare {label, value} pair cannot express.
|
||||
*/
|
||||
export interface FleetSelectOption {
|
||||
label: string;
|
||||
value: string;
|
||||
meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface FleetResourceColumn {
|
||||
id: string;
|
||||
header: string;
|
||||
@@ -45,6 +57,17 @@ export interface FleetResourceColumn {
|
||||
export interface FleetFormFieldDef extends FormFieldDef {
|
||||
dynamicOptions?: FleetDynamicOptions;
|
||||
noneOption?: boolean;
|
||||
/** Options carrying their source record, so `onOptionSelected` can read it. */
|
||||
options?: FleetSelectOption[];
|
||||
/**
|
||||
* Patch merged into the form when this select changes — for values that are a
|
||||
* property of the chosen option rather than typed per record (a vehicle's
|
||||
* capacity comes from its truck type). Returns the fields to overwrite.
|
||||
*/
|
||||
onOptionSelected?: (
|
||||
option: FleetSelectOption | undefined,
|
||||
values: Record<string, unknown>,
|
||||
) => Record<string, unknown>;
|
||||
/**
|
||||
* Read-only field whose value is computed from the other fields rather than
|
||||
* typed. Rendered non-editable and recomputed on every change, so the stored
|
||||
|
||||
@@ -10,6 +10,16 @@ const PLATE_PATTERN = {
|
||||
message: "Use letters and numbers like ET-9875 or AA-8642",
|
||||
};
|
||||
|
||||
/**
|
||||
* Legacy static list. Truck configurations now live in `freight.truck_types`
|
||||
* and are edited in the back office (Rule Engine → Truck Types), so the form
|
||||
* loads them through `dynamicOptions: "truckTypes"` instead.
|
||||
*
|
||||
* Kept only for the driver "authorized vehicle types" multiselect, which stores
|
||||
* free-text categories rather than truck-type ids.
|
||||
*
|
||||
* @deprecated prefer the managed truck types
|
||||
*/
|
||||
const VEHICLE_TYPE_OPTIONS = [
|
||||
{ label: "Truck", value: "TRUCK" },
|
||||
{ label: "Van", value: "VAN" },
|
||||
@@ -20,6 +30,11 @@ const VEHICLE_TYPE_OPTIONS = [
|
||||
{ label: "Flatbed", value: "FLATBED" },
|
||||
];
|
||||
|
||||
const OWNERSHIP_OPTIONS = [
|
||||
{ label: "Owned", value: "OWNED" },
|
||||
{ label: "Outsourced", value: "OUTSOURCED" },
|
||||
];
|
||||
|
||||
const FUEL_TYPE_OPTIONS = [
|
||||
{ label: "Petrol", value: "PETROL" },
|
||||
{ label: "Diesel", value: "DIESEL" },
|
||||
@@ -39,11 +54,6 @@ const VEHICLE_AVAILABILITY_OPTIONS = [
|
||||
{ label: "Busy", value: "BUSY" },
|
||||
];
|
||||
|
||||
const CURRENCY_OPTIONS = [
|
||||
{ label: "ETB", value: "ETB" },
|
||||
{ label: "USD", value: "USD" },
|
||||
];
|
||||
|
||||
export const vehiclesConfig: FleetResourceConfig = {
|
||||
slug: "vehicles",
|
||||
label: "Vehicles",
|
||||
@@ -72,35 +82,65 @@ export const vehiclesConfig: FleetResourceConfig = {
|
||||
options: VEHICLE_AVAILABILITY_OPTIONS,
|
||||
},
|
||||
],
|
||||
searchKeys: ["plateNumber", "registrationNumber", "manufacturer", "model", "vehicleType", "status"],
|
||||
searchKeys: ["plateNumber", "registrationNumber", "manufacturer", "model", "vehicleType", "vin", "status"],
|
||||
columns: [
|
||||
{ id: "code", header: "Code", accessorKey: "code", size: 90 },
|
||||
{ id: "plateNumber", header: "Plate Number", accessorKey: "plateNumber", size: 120 },
|
||||
{ id: "trailerPlateNo", header: "Trailer Plate No", accessorKey: "trailerPlateNo", size: 130 },
|
||||
{ id: "manufacturer", header: "Manufacturer", accessorKey: "manufacturer", size: 120 },
|
||||
{ id: "model", header: "Model", accessorKey: "model", size: 100 },
|
||||
{ id: "vehicleType", header: "Type", accessorKey: "vehicleType", size: 75 },
|
||||
{ id: "truckTypeId", header: "Truck Type", accessorKey: "truckTypeId", format: "entityLabel", size: 130 },
|
||||
{ id: "capacity", header: "Capacity (tons)", accessorKey: "capacity", format: "number", size: 100 },
|
||||
{ id: "locationId", header: "Location", accessorKey: "locationId", size: 140 },
|
||||
{ id: "ownership", header: "Ownership", accessorKey: "ownership", size: 100 },
|
||||
{ id: "status", header: "Status", accessorKey: "status", format: "statusBadge", size: 100 },
|
||||
{ id: "availability", header: "Availability", accessorKey: "availability", format: "statusBadge", size: 100 },
|
||||
],
|
||||
// Registration captures what the vehicle IS. Location, distances and haulage
|
||||
// pricing are per-trip operational data and live on the vehicle's Operations
|
||||
// tab instead (VehicleDetailPage) — they are not part of registering a truck.
|
||||
formFields: [
|
||||
{ name: "code", label: "Code", type: "text" },
|
||||
{ name: "plateNumber", label: "Power Plate No", type: "text", required: true, pattern: PLATE_PATTERN },
|
||||
// { name: "powerPlateNo", label: "Power Plate No", type: "text" },
|
||||
{ name: "trailerPlateNo", label: "Trailer Plate No", type: "text", pattern: PLATE_PATTERN },
|
||||
{ name: "vehicleType", label: "Vehicle Type", type: "select", required: true, options: VEHICLE_TYPE_OPTIONS },
|
||||
// The truck type decides whether a trailer exists at all: a rigid truck
|
||||
// (Casoni) has none, so the field disappears and submits null. Mirrored
|
||||
// server-side in VehiclesService — hiding a field is not enforcement.
|
||||
{
|
||||
name: "trailerPlateNo",
|
||||
label: "Trailer Plate No",
|
||||
type: "text",
|
||||
pattern: PLATE_PATTERN,
|
||||
showIf: (values) => values._hasTrailer !== false,
|
||||
},
|
||||
{
|
||||
name: "truckTypeId",
|
||||
label: "Truck Type",
|
||||
type: "select",
|
||||
required: true,
|
||||
dynamicOptions: "truckTypes",
|
||||
description: "Managed in Rule Engine → Truck Types",
|
||||
// Capacity is a property of the type, not of each individual truck.
|
||||
// `_hasTrailer` is form-local scratch (stripped before submit) that drives
|
||||
// the trailer plate's visibility.
|
||||
onOptionSelected: (option) => ({
|
||||
_hasTrailer: option?.meta?.hasTrailer ?? true,
|
||||
...(option?.meta?.capacityTons != null
|
||||
? { capacity: option.meta.capacityTons }
|
||||
: {}),
|
||||
}),
|
||||
},
|
||||
{ name: "vin", label: "VIN", type: "text", description: "Vehicle Identification Number" },
|
||||
{ name: "ownership", label: "Ownership", type: "radio", options: OWNERSHIP_OPTIONS },
|
||||
{ name: "manufacturer", label: "Manufacturer", type: "text", required: true },
|
||||
{ name: "model", label: "Model", type: "text", required: true },
|
||||
{ name: "year", label: "Year", type: "number", required: true },
|
||||
{ name: "fuelType", label: "Fuel Type", type: "select", required: true, options: FUEL_TYPE_OPTIONS },
|
||||
{ name: "capacity", label: "Capacity", type: "number", required: true },
|
||||
{ name: "locationId", label: "Location", type: "select", dynamicOptions: "yards" },
|
||||
{ name: "estimatedDistanceKm", label: "Estimated Distance (KM)", type: "number" },
|
||||
{ name: "actualDistanceKm", label: "Actual Distance (KM)", type: "number" },
|
||||
{ name: "pricePerKm", label: "Price per KM", type: "number" },
|
||||
{ name: "currency", label: "Currency", type: "radio", options: CURRENCY_OPTIONS },
|
||||
{
|
||||
name: "capacity",
|
||||
label: "Capacity (tons)",
|
||||
type: "number",
|
||||
required: true,
|
||||
description: "Pre-filled from the truck type — override only for a one-off",
|
||||
},
|
||||
{ name: "status", label: "Status", type: "select", required: true, options: VEHICLE_STATUS_OPTIONS },
|
||||
{ name: "availability", label: "Availability", type: "select", required: true, options: VEHICLE_AVAILABILITY_OPTIONS },
|
||||
{ name: "description", label: "Description", type: "textarea" },
|
||||
@@ -108,19 +148,15 @@ export const vehiclesConfig: FleetResourceConfig = {
|
||||
emptyValues: {
|
||||
code: "03-ET",
|
||||
plateNumber: "",
|
||||
powerPlateNo: "",
|
||||
trailerPlateNo: "",
|
||||
vehicleType: "TRUCK",
|
||||
truckTypeId: "",
|
||||
vin: "",
|
||||
ownership: "OWNED",
|
||||
manufacturer: "",
|
||||
model: "",
|
||||
year: new Date().getFullYear(),
|
||||
fuelType: "DIESEL",
|
||||
capacity: 0,
|
||||
locationId: null,
|
||||
estimatedDistanceKm: "",
|
||||
actualDistanceKm: "",
|
||||
pricePerKm: "",
|
||||
currency: "ETB",
|
||||
status: "ACTIVE",
|
||||
availability: "FREE",
|
||||
description: "",
|
||||
|
||||
@@ -229,6 +229,21 @@ const cargoDesc = (r: FirstMileRecord) => {
|
||||
if (r.booking?.cargoTotalWeightVgm) parts.push(`${r.booking.cargoTotalWeightVgm} t`);
|
||||
return parts.join(" · ") || "—";
|
||||
};
|
||||
const cargoTypeName = (r: FirstMileRecord) =>
|
||||
r.booking?.cargoType?.cargoTypeName ??
|
||||
r.booking?.cargoType?.label ??
|
||||
r.booking?.cargoType?.name ??
|
||||
r.booking?.cargoFreeText ??
|
||||
"—";
|
||||
const isBulkBooking = (r: FirstMileRecord) => r.booking?.freightType === "BULK";
|
||||
const bookingTotalTons = (r: FirstMileRecord) => Number(r.booking?.cargoTotalWeightVgm) || 0;
|
||||
const trainScheduleLabel = (r: FirstMileRecord) => {
|
||||
const s = r.booking?.trainSchedule;
|
||||
if (!s?.trainNumber && !s?.departureDate) return "—";
|
||||
return [s.trainNumber, s.departureDate ? new Date(s.departureDate).toLocaleDateString() : null]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
};
|
||||
// First-mile destination is the origin yard (pickup → origin yard)
|
||||
const destinationYardName = (r: FirstMileRecord) =>
|
||||
r.booking?.originYard?.label ?? "—";
|
||||
@@ -277,7 +292,9 @@ const BookingInfo = ({ record }: { record: FirstMileRecord }) => {
|
||||
<InfoRow label="Service type" value={serviceTypeName(record)} />
|
||||
{hasPickupAddress && <InfoRow label="Pickup location" value={pickupLocation(record)} />}
|
||||
<InfoRow label="Destination (origin yard)" value={destinationYardName(record)} />
|
||||
<InfoRow label="Cargo Type" value={cargoTypeName(record)} />
|
||||
<InfoRow label="Cargo" value={cargoDesc(record)} />
|
||||
<InfoRow label="Train Schedule" value={trainScheduleLabel(record)} />
|
||||
<InfoRow label="Advanced Payment" value={formatPrice(record.advancedPayment, currencyOf(record))} />
|
||||
<InfoRow label="Post Payment" value={formatPrice(record.remainingPayment, currencyOf(record))} />
|
||||
<InfoRow label="Contact" value={contactPersonName(record)} />
|
||||
@@ -496,10 +513,11 @@ const FirstMilePage = () => {
|
||||
const [tripSlipVehicleId, setTripSlipVehicleId] = useState<string | null>(null);
|
||||
const [tripSlipSelectOpen, setTripSlipSelectOpen] = useState(false);
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
// Multi-vehicle assign: one row per truck — vehicle + the container it carries.
|
||||
// Multi-vehicle assign: one row per truck — vehicle + its load (container for
|
||||
// container bookings; tonnes + optional item count for bulk).
|
||||
const [vehicleRows, setVehicleRows] = useState<
|
||||
Array<{ vehicleId: string | null; containerNumber: string }>
|
||||
>([{ vehicleId: null, containerNumber: "" }]);
|
||||
Array<{ vehicleId: string | null; containerNumber: string; tons: number | ""; quantity: number | "" }>
|
||||
>([{ vehicleId: null, containerNumber: "", tons: "", quantity: "" }]);
|
||||
|
||||
const [acceptOpen, setAcceptOpen] = useState(false);
|
||||
const [acceptStep, setAcceptStep] = useState<1 | 2>(1);
|
||||
@@ -925,13 +943,19 @@ const FirstMilePage = () => {
|
||||
? rec.vehicleAssignments.map((a, i) => ({
|
||||
vehicleId: a.vehicleId,
|
||||
containerNumber: a.containerNumber ?? nums[i] ?? "",
|
||||
tons: (a.tons != null ? Number(a.tons) : "") as number | "",
|
||||
quantity: (a.quantity != null ? Number(a.quantity) : "") as number | "",
|
||||
}))
|
||||
: rec?.vehicleId
|
||||
? [{ vehicleId: rec.vehicleId, containerNumber: nums[0] ?? "" }]
|
||||
: [{ vehicleId: null, containerNumber: nums[0] ?? "" }];
|
||||
? [{ vehicleId: rec.vehicleId, containerNumber: nums[0] ?? "", tons: "" as const, quantity: "" as const }]
|
||||
: [{ vehicleId: null, containerNumber: nums[0] ?? "", tons: "" as const, quantity: "" as const }];
|
||||
setBulkMode(false);
|
||||
setActiveId(resolved);
|
||||
setVehicleRows(rows.length ? rows : [{ vehicleId: null, containerNumber: nums[0] ?? "" }]);
|
||||
setVehicleRows(
|
||||
rows.length
|
||||
? rows
|
||||
: [{ vehicleId: null, containerNumber: nums[0] ?? "", tons: "", quantity: "" }],
|
||||
);
|
||||
setAssignOpen(true);
|
||||
};
|
||||
|
||||
@@ -944,7 +968,7 @@ const FirstMilePage = () => {
|
||||
}
|
||||
setBulkMode(true);
|
||||
setActiveId(null);
|
||||
setVehicleRows([{ vehicleId: null, containerNumber: "" }]);
|
||||
setVehicleRows([{ vehicleId: null, containerNumber: "", tons: "", quantity: "" }]);
|
||||
setAssignOpen(true);
|
||||
};
|
||||
|
||||
@@ -952,15 +976,41 @@ const FirstMilePage = () => {
|
||||
setAssignOpen(false);
|
||||
setBulkMode(false);
|
||||
setActiveId(null);
|
||||
setVehicleRows([{ vehicleId: null, containerNumber: "" }]);
|
||||
setVehicleRows([{ vehicleId: null, containerNumber: "", tons: "", quantity: "" }]);
|
||||
};
|
||||
|
||||
const handleAssign = () => {
|
||||
const bulkCargo = activeRecord != null && isBulkBooking(activeRecord);
|
||||
if (bulkCargo && vehicleRows.some((r) => r.vehicleId && r.tons === "")) {
|
||||
toast({
|
||||
title: "Tonnes required",
|
||||
description: "Enter the tonnage each truck hauls — bulk assignment draws down the booking total.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (bulkCargo) {
|
||||
const total = bookingTotalTons(activeRecord);
|
||||
const assigning = vehicleRows.reduce((s, r) => s + (r.vehicleId ? Number(r.tons) || 0 : 0), 0);
|
||||
if (total > 0 && assigning > total + 0.001) {
|
||||
toast({
|
||||
title: "Over booking tonnage",
|
||||
description: `Assigned ${assigning} t exceeds the booking's ${total} t.`,
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
const vehicles = vehicleRows
|
||||
.filter((r): r is { vehicleId: string; containerNumber: string } => Boolean(r.vehicleId))
|
||||
.filter((r): r is (typeof vehicleRows)[number] & { vehicleId: string } => Boolean(r.vehicleId))
|
||||
.filter((r) => (seen.has(r.vehicleId) ? false : seen.add(r.vehicleId)))
|
||||
.map((r) => ({ vehicleId: r.vehicleId, containerNumber: r.containerNumber.trim() || null }));
|
||||
.map((r) => ({
|
||||
vehicleId: r.vehicleId,
|
||||
containerNumber: r.containerNumber.trim() || null,
|
||||
tons: bulkCargo && r.tons !== "" ? Number(r.tons) : null,
|
||||
quantity: bulkCargo && r.quantity !== "" ? Number(r.quantity) : null,
|
||||
}));
|
||||
const count = vehicles.length;
|
||||
const targetIds = bulkMode
|
||||
? selectedIds
|
||||
@@ -1432,30 +1482,63 @@ const FirstMilePage = () => {
|
||||
clearable
|
||||
disabled={assignVehicleOptions.length === 0}
|
||||
/>
|
||||
<Select
|
||||
style={{ flex: 1 }}
|
||||
label={i === 0 ? "Container no." : undefined}
|
||||
placeholder={containerOptions.length ? "Select container" : "No container numbers"}
|
||||
data={[
|
||||
...containerOptions.filter(
|
||||
(n) =>
|
||||
n === row.containerNumber ||
|
||||
!vehicleRows.some((r, idx) => idx !== i && r.containerNumber === n),
|
||||
),
|
||||
// keep a manual/legacy value selectable even if not in the booking
|
||||
...(row.containerNumber && !containerOptions.includes(row.containerNumber)
|
||||
? [row.containerNumber]
|
||||
: []),
|
||||
]}
|
||||
value={row.containerNumber || null}
|
||||
onChange={(value) =>
|
||||
setVehicleRows((prev) =>
|
||||
prev.map((x, idx) => (idx === i ? { ...x, containerNumber: value ?? "" } : x)),
|
||||
)
|
||||
}
|
||||
searchable
|
||||
clearable
|
||||
/>
|
||||
{!bulkMode && activeRecord && isBulkBooking(activeRecord) ? (
|
||||
<>
|
||||
<NumberInput
|
||||
style={{ flex: 0.8 }}
|
||||
label={i === 0 ? "Tonnes" : undefined}
|
||||
placeholder="t"
|
||||
min={0}
|
||||
value={row.tons}
|
||||
onChange={(v) =>
|
||||
setVehicleRows((prev) =>
|
||||
prev.map((x, idx) =>
|
||||
idx === i ? { ...x, tons: v === "" ? "" : Number(v) } : x,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<NumberInput
|
||||
style={{ flex: 0.8 }}
|
||||
label={i === 0 ? "Items qty (pcs)" : undefined}
|
||||
placeholder="optional"
|
||||
min={0}
|
||||
value={row.quantity}
|
||||
onChange={(v) =>
|
||||
setVehicleRows((prev) =>
|
||||
prev.map((x, idx) =>
|
||||
idx === i ? { ...x, quantity: v === "" ? "" : Number(v) } : x,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Select
|
||||
style={{ flex: 1 }}
|
||||
label={i === 0 ? "Container no." : undefined}
|
||||
placeholder={containerOptions.length ? "Select container" : "No container numbers"}
|
||||
data={[
|
||||
...containerOptions.filter(
|
||||
(n) =>
|
||||
n === row.containerNumber ||
|
||||
!vehicleRows.some((r, idx) => idx !== i && r.containerNumber === n),
|
||||
),
|
||||
// keep a manual/legacy value selectable even if not in the booking
|
||||
...(row.containerNumber && !containerOptions.includes(row.containerNumber)
|
||||
? [row.containerNumber]
|
||||
: []),
|
||||
]}
|
||||
value={row.containerNumber || null}
|
||||
onChange={(value) =>
|
||||
setVehicleRows((prev) =>
|
||||
prev.map((x, idx) => (idx === i ? { ...x, containerNumber: value ?? "" } : x)),
|
||||
)
|
||||
}
|
||||
searchable
|
||||
clearable
|
||||
/>
|
||||
)}
|
||||
{vehicleRows.length > 1 && (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
@@ -1479,6 +1562,8 @@ const FirstMilePage = () => {
|
||||
vehicleId: null,
|
||||
containerNumber:
|
||||
(activeRecord ? bookingContainerNumbers(activeRecord)[prev.length] : "") ?? "",
|
||||
tons: "" as const,
|
||||
quantity: "" as const,
|
||||
},
|
||||
])
|
||||
}
|
||||
@@ -1491,6 +1576,22 @@ const FirstMilePage = () => {
|
||||
>
|
||||
Add vehicle
|
||||
</Button>
|
||||
{!bulkMode && activeRecord && isBulkBooking(activeRecord) && (() => {
|
||||
const total = bookingTotalTons(activeRecord);
|
||||
const assigning = vehicleRows.reduce(
|
||||
(s, r) => s + (r.vehicleId ? Number(r.tons) || 0 : 0),
|
||||
0,
|
||||
);
|
||||
const remaining = Math.round((total - assigning) * 1000) / 1000;
|
||||
return (
|
||||
<Text size="sm" c={remaining < 0 ? "red" : "dimmed"}>
|
||||
Bulk drawdown: {assigning} t of {total} t assigned —{" "}
|
||||
<Text span fw={600} c={remaining < 0 ? "red" : undefined}>
|
||||
{remaining} t remaining
|
||||
</Text>
|
||||
</Text>
|
||||
);
|
||||
})()}
|
||||
</Stack>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={closeAssign}>Cancel</Button>
|
||||
|
||||
@@ -409,6 +409,43 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
{ name: "isActive", label: "Active", type: "boolean" },
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "truck-types",
|
||||
label: "Truck Types",
|
||||
category: "configuration",
|
||||
subtitle:
|
||||
"Configure the truck configurations vehicles are registered against — capacity and whether a trailer applies",
|
||||
searchPlaceholder: "Search truck types by name or code...",
|
||||
cardTitleKey: "name",
|
||||
columns: [
|
||||
codeColumn("code"),
|
||||
{ id: "name", header: "Name", accessorKey: "name" },
|
||||
{ id: "capacityTons", header: "Capacity (t)", accessorKey: "capacityTons", format: "number" },
|
||||
{ id: "hasTrailer", header: "Has trailer", accessorKey: "hasTrailer" },
|
||||
activeColumn,
|
||||
],
|
||||
formFields: [
|
||||
{ name: "code", label: "Code", type: "text", required: true },
|
||||
{ name: "name", label: "Name", type: "text", required: true },
|
||||
{
|
||||
name: "capacityTons",
|
||||
label: "Capacity (tons)",
|
||||
type: "number",
|
||||
optional: true,
|
||||
description: "Pre-fills the capacity of every vehicle registered on this type",
|
||||
},
|
||||
// Drives the trailer plate on vehicle registration: a rigid truck (Casoni)
|
||||
// has none, so registering one with a trailer plate is rejected.
|
||||
{
|
||||
name: "hasTrailer",
|
||||
label: "Pulls a trailer",
|
||||
type: "boolean",
|
||||
description: "Off for a rigid truck (e.g. Casoni) — its registration has no trailer plate",
|
||||
},
|
||||
{ name: "description", label: "Description", type: "textarea", optional: true },
|
||||
{ name: "isActive", label: "Active", type: "boolean" },
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "yard-distances",
|
||||
label: "Yard Distances",
|
||||
|
||||
@@ -12,16 +12,17 @@ import {
|
||||
Text,
|
||||
TextInput,
|
||||
} from '@mantine/core';
|
||||
import { CheckCircle2, Download, Eye, FileText, Printer, Search, XCircle } from 'lucide-react';
|
||||
import { CheckCircle2, Download, Eye, FileText, Printer, Search } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { DataTable, type ColumnDef } from '@edr/ui-common';
|
||||
import ListControls from '@/components/common/ListControls';
|
||||
import { useListControls } from '@/hooks/useListControls';
|
||||
|
||||
import { PageContainer, PageHeader } from '@/components/page';
|
||||
import { VisualEmptyState, formatDate, formatNumber } from '@/components/warehouses';
|
||||
import {
|
||||
useAcknowledgeInterchangeDocument,
|
||||
useCancelInterchangeDocument,
|
||||
useDisputeInterchangeDocument,
|
||||
useInterchangeDocument,
|
||||
useInterchangeDocuments,
|
||||
@@ -66,7 +67,7 @@ const buildPrintableInterchangeHtml = (document: InterchangeDocument) => {
|
||||
(item, index) => `
|
||||
<tr>
|
||||
<td>${index + 1}</td>
|
||||
<td>${escapeHtml(item.bookingReference ?? item.bookingId?.slice(0, 8))}</td>
|
||||
<td>${escapeHtml(item.bookingReference)}</td>
|
||||
<td>${escapeHtml(item.itemType)}</td>
|
||||
<td>${escapeHtml(item.containerNumber)}</td>
|
||||
<td>${escapeHtml(item.sealNumber)}</td>
|
||||
@@ -118,7 +119,6 @@ const buildPrintableInterchangeHtml = (document: InterchangeDocument) => {
|
||||
<div class="grid">
|
||||
<div class="field"><div class="label">Direction</div><div class="value">${escapeHtml(document.direction)}</div></div>
|
||||
<div class="field"><div class="label">Train No</div><div class="value">${escapeHtml(document.trainNo)}</div></div>
|
||||
<div class="field"><div class="label">Schedule</div><div class="value">${escapeHtml(document.scheduleId)}</div></div>
|
||||
<div class="field"><div class="label">Handover Location</div><div class="value">${escapeHtml(document.handoverLocation)}</div></div>
|
||||
<div class="field"><div class="label">Handover From</div><div class="value">${escapeHtml(document.handoverFrom)}</div></div>
|
||||
<div class="field"><div class="label">Handover To</div><div class="value">${escapeHtml(document.handoverTo)}</div></div>
|
||||
@@ -191,7 +191,6 @@ function InterchangeDocumentDetail({ id }: { id: string }) {
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>
|
||||
<DetailField label="Document No" value={document.documentNo} />
|
||||
<DetailField label="Direction" value={document.direction} />
|
||||
<DetailField label="Schedule" value={document.scheduleId?.slice(0, 8)} />
|
||||
<DetailField label="Train No" value={document.trainNo} />
|
||||
<DetailField label="Handover Location" value={document.handoverLocation} />
|
||||
<DetailField label="Handover From" value={document.handoverFrom} />
|
||||
@@ -232,7 +231,7 @@ function InterchangeDocumentDetail({ id }: { id: string }) {
|
||||
<Table.Tbody>
|
||||
{items.map((item) => (
|
||||
<Table.Tr key={item.id}>
|
||||
<Table.Td>{item.bookingReference ?? item.bookingId?.slice(0, 8) ?? '-'}</Table.Td>
|
||||
<Table.Td>{item.bookingReference ?? '-'}</Table.Td>
|
||||
<Table.Td>{item.itemType}</Table.Td>
|
||||
<Table.Td>{item.containerNumber ?? '-'}</Table.Td>
|
||||
<Table.Td>{item.sealNumber ?? '-'}</Table.Td>
|
||||
@@ -266,9 +265,11 @@ export default function InterchangeDocumentsPage() {
|
||||
const [viewId, setViewId] = useState<string | null>(null);
|
||||
const filter = useMemo(() => ({ search: search.trim() || undefined }), [search]);
|
||||
const { data: documents = [], isLoading } = useInterchangeDocuments(filter);
|
||||
// Search stays server-side (passed in `filter`); this adds the date range and
|
||||
// pagination over what comes back.
|
||||
const controls = useListControls(documents, { dateKey: 'generatedAt' });
|
||||
const acknowledge = useAcknowledgeInterchangeDocument();
|
||||
const dispute = useDisputeInterchangeDocument();
|
||||
const cancel = useCancelInterchangeDocument();
|
||||
|
||||
const getPrintableDocument = async (interchangeDocument: InterchangeDocument) => {
|
||||
if (interchangeDocument.items?.length) return interchangeDocument;
|
||||
@@ -336,19 +337,7 @@ export default function InterchangeDocumentsPage() {
|
||||
),
|
||||
},
|
||||
{ id: 'direction', header: 'Direction', cell: ({ row }) => row.original.direction },
|
||||
{
|
||||
id: 'train',
|
||||
header: 'Train No / Schedule',
|
||||
cell: ({ row }) => (
|
||||
<Stack gap={0}>
|
||||
<Text size="sm">{row.original.trainNo ?? '-'}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{row.original.scheduleId?.slice(0, 8) ?? '-'}
|
||||
</Text>
|
||||
</Stack>
|
||||
),
|
||||
},
|
||||
{ id: 'route', header: 'Route', cell: ({ row }) => row.original.routeId?.slice(0, 8) ?? '-' },
|
||||
{ id: 'train', header: 'Train No', cell: ({ row }) => row.original.trainNo ?? '-' },
|
||||
{ id: 'handoverLocation', header: 'Handover Location', cell: ({ row }) => row.original.handoverLocation },
|
||||
{ id: 'handoverFrom', header: 'Handover From', cell: ({ row }) => row.original.handoverFrom },
|
||||
{ id: 'handoverTo', header: 'Handover To', cell: ({ row }) => row.original.handoverTo },
|
||||
@@ -390,7 +379,7 @@ export default function InterchangeDocumentsPage() {
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
{doc.status !== 'ACKNOWLEDGED' && doc.status !== 'CANCELLED' ? (
|
||||
{doc.status === 'GENERATED' ? (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="green"
|
||||
@@ -423,7 +412,9 @@ export default function InterchangeDocumentsPage() {
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
{doc.status !== 'CANCELLED' ? (
|
||||
{/* Disputes are raised BEFORE acknowledgement; a registered dispute
|
||||
(DISPUTED) is read-only — its row offers View only. */}
|
||||
{doc.status === 'GENERATED' ? (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="orange"
|
||||
@@ -434,17 +425,6 @@ export default function InterchangeDocumentsPage() {
|
||||
Dispute
|
||||
</Button>
|
||||
) : null}
|
||||
{doc.status === 'DRAFT' || doc.status === 'GENERATED' ? (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="red"
|
||||
variant="light"
|
||||
leftSection={<XCircle size={14} />}
|
||||
onClick={() => run(() => cancel.mutateAsync(doc.id), 'Interchange document cancelled')}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
@@ -460,7 +440,7 @@ export default function InterchangeDocumentsPage() {
|
||||
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
<Group justify="space-between" mb="md">
|
||||
<Text fw={600}>{documents.length} document(s)</Text>
|
||||
<Text fw={600}>{controls.totalCount} document(s)</Text>
|
||||
<TextInput
|
||||
w={{ base: '100%', sm: 320 }}
|
||||
leftSection={<Search size={16} />}
|
||||
@@ -470,6 +450,19 @@ export default function InterchangeDocumentsPage() {
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<ListControls
|
||||
showSearch={false}
|
||||
search=""
|
||||
onSearchChange={() => {}}
|
||||
dateFrom={controls.dateFrom}
|
||||
onDateFromChange={controls.setDateFrom}
|
||||
dateTo={controls.dateTo}
|
||||
onDateToChange={controls.setDateTo}
|
||||
dateLabel="Generated"
|
||||
hasFilters={controls.hasFilters}
|
||||
onReset={controls.reset}
|
||||
/>
|
||||
|
||||
{!isLoading && documents.length === 0 ? (
|
||||
<VisualEmptyState
|
||||
variant="container"
|
||||
@@ -479,9 +472,10 @@ export default function InterchangeDocumentsPage() {
|
||||
) : (
|
||||
<DataTable
|
||||
columns={documentColumns}
|
||||
data={documents}
|
||||
data={controls.pagedRows}
|
||||
status={isLoading ? 'loading' : 'success'}
|
||||
containerClassName="border-0 shadow-none"
|
||||
{...controls.tableProps}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useMemo, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Group,
|
||||
@@ -12,10 +13,16 @@ import {
|
||||
Text,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { AlertTriangle, PackageCheck, TrainFront, Warehouse } from "lucide-react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AlertTriangle, PackageCheck, PackageOpen, TrainFront, Warehouse } from "lucide-react";
|
||||
|
||||
import { PageContainer, PageHeader } from "@/components/page";
|
||||
import ListControls from "@/components/common/ListControls";
|
||||
// Generic list footer — already shared by the fleet and train-scheduling lists
|
||||
// despite the ruleEngine path.
|
||||
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
|
||||
import { useListControls } from "@/hooks/useListControls";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { api } from "@/services/api";
|
||||
import type { IntercityRideAlongRow } from "@/types/trainScheduling";
|
||||
|
||||
@@ -74,7 +81,45 @@ function FacilityCell({
|
||||
);
|
||||
}
|
||||
|
||||
const apiErrorMessage = (error: unknown) => {
|
||||
if (error && typeof error === "object" && "response" in error) {
|
||||
const message = (error as { response?: { data?: { message?: unknown } } }).response?.data
|
||||
?.message;
|
||||
if (Array.isArray(message)) return message.join("; ");
|
||||
if (typeof message === "string") return message;
|
||||
}
|
||||
return error instanceof Error ? error.message : undefined;
|
||||
};
|
||||
|
||||
function Rows({ rows }: { rows: IntercityRideAlongRow[] }) {
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const refresh = () =>
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: api.trainScheduling.intercityBookings.queryKey(undefined),
|
||||
});
|
||||
// Same endpoints as the schedule page's ride-along panel — the server still
|
||||
// validates the train's recorded checkpoint, payment and yard equipment.
|
||||
const load = useMutation(
|
||||
api.trainScheduling.loadIntercityBooking.mutationOptions({
|
||||
onSuccess: () => {
|
||||
toast({ title: "Cargo loaded onto the train" });
|
||||
void refresh();
|
||||
},
|
||||
onError: (error) =>
|
||||
toast({ variant: "destructive", title: "Load failed", description: apiErrorMessage(error) }),
|
||||
}),
|
||||
);
|
||||
const unload = useMutation(
|
||||
api.trainScheduling.unloadIntercityBooking.mutationOptions({
|
||||
onSuccess: () => {
|
||||
toast({ title: "Cargo unloaded — booking completed" });
|
||||
void refresh();
|
||||
},
|
||||
onError: (error) =>
|
||||
toast({ variant: "destructive", title: "Unload failed", description: apiErrorMessage(error) }),
|
||||
}),
|
||||
);
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<Alert variant="light" color="gray">
|
||||
@@ -95,6 +140,7 @@ function Rows({ rows }: { rows: IntercityRideAlongRow[] }) {
|
||||
<Table.Th ta="right">Weight</Table.Th>
|
||||
<Table.Th>GRN</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
@@ -151,6 +197,38 @@ function Rows({ rows }: { rows: IntercityRideAlongRow[] }) {
|
||||
{r.status}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||
{/* Work the cargo right here while the train is at the yard. */}
|
||||
{r.trainScheduleId && atOrigin(r) && isWaiting(r) && r.status === "PAID" && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
leftSection={<PackageCheck size={13} />}
|
||||
loading={load.isPending}
|
||||
onClick={() =>
|
||||
load.mutate({ scheduleId: r.trainScheduleId as string, bookingId: r.bookingId })
|
||||
}
|
||||
>
|
||||
Load
|
||||
</Button>
|
||||
)}
|
||||
{r.trainScheduleId && atDestination(r) && isRiding(r) && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="orange"
|
||||
leftSection={<PackageOpen size={13} />}
|
||||
loading={unload.isPending}
|
||||
onClick={() =>
|
||||
unload.mutate({ scheduleId: r.trainScheduleId as string, bookingId: r.bookingId })
|
||||
}
|
||||
>
|
||||
Unload
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
@@ -205,6 +283,15 @@ export default function IntercityPage() {
|
||||
[rows],
|
||||
);
|
||||
|
||||
// Controls follow the active tab, so search/date/paging always describe what
|
||||
// is on screen. Panels unmount when hidden (keepMounted={false}) so a hidden
|
||||
// tab can never render another tab's paged slice.
|
||||
const active = tab === "riding" ? riding : tab === "done" ? done : waiting;
|
||||
const controls = useListControls(active, {
|
||||
searchKeys: ["reference", "grnNumber", "customer", "origin", "destination", "trainNumber"],
|
||||
dateKey: "loadedAt",
|
||||
});
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
@@ -283,19 +370,40 @@ export default function IntercityPage() {
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="waiting">
|
||||
<Rows rows={waiting} />
|
||||
<ListControls
|
||||
search={controls.search}
|
||||
onSearchChange={controls.setSearch}
|
||||
searchPlaceholder="Reference, GRN, customer, train…"
|
||||
dateFrom={controls.dateFrom}
|
||||
onDateFromChange={controls.setDateFrom}
|
||||
dateTo={controls.dateTo}
|
||||
onDateToChange={controls.setDateTo}
|
||||
dateLabel="Loaded"
|
||||
hasFilters={controls.hasFilters}
|
||||
onReset={controls.reset}
|
||||
/>
|
||||
|
||||
<Tabs.Panel value="waiting" keepMounted={false}>
|
||||
<Rows rows={controls.pagedRows} />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="riding">
|
||||
<Rows rows={riding} />
|
||||
<Tabs.Panel value="riding" keepMounted={false}>
|
||||
<Rows rows={controls.pagedRows} />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="done">
|
||||
<Rows rows={done} />
|
||||
<Tabs.Panel value="done" keepMounted={false}>
|
||||
<Rows rows={controls.pagedRows} />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
|
||||
<RuleEngineListFooter
|
||||
pagination={controls.pagination}
|
||||
pageCount={controls.pageCount}
|
||||
totalCount={controls.totalCount}
|
||||
itemLabel="bookings"
|
||||
onPaginationChange={controls.setPagination}
|
||||
/>
|
||||
<Text size="xs" c="dimmed" mt="sm">
|
||||
Loading and unloading happen on the train's schedule page, where the ride-along
|
||||
panel confirms the train is at the yard.
|
||||
Load and Unload appear on a row while its train is recorded at that yard ("train
|
||||
here"); the same actions also live on the train's schedule page.
|
||||
</Text>
|
||||
</Card>
|
||||
</>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user