mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'dev' into freight/feat/fixes-v1
This commit is contained in:
10
.gitignore
vendored
10
.gitignore
vendored
@@ -37,3 +37,13 @@ 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/
|
||||
|
||||
@@ -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";
|
||||
@@ -77,8 +77,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 +104,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(),
|
||||
@@ -223,7 +229,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 +264,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();
|
||||
|
||||
|
||||
@@ -29,9 +29,22 @@ export const TrainSchedulingView = () =>
|
||||
export const TrainSchedulingManage = () =>
|
||||
BookingStaff(FREIGHT_PERMS.trainScheduling.manage);
|
||||
|
||||
export const FleetView = () => BookingStaff(FREIGHT_PERMS.fleet.view);
|
||||
/**
|
||||
* Fleet guards take an optional granular per-resource key (locomotives:create,
|
||||
* wagons:delete, …). The legacy coarse fleet:view / fleet:manage keys remain
|
||||
* valid as a one-of fallback so existing role grants keep working.
|
||||
*/
|
||||
export const FleetView = (granular?: string) =>
|
||||
BookingStaff(
|
||||
granular ? [granular, FREIGHT_PERMS.fleet.view] : FREIGHT_PERMS.fleet.view,
|
||||
);
|
||||
|
||||
export const FleetManage = () => BookingStaff(FREIGHT_PERMS.fleet.manage);
|
||||
export const FleetManage = (granular?: string) =>
|
||||
BookingStaff(
|
||||
granular
|
||||
? [granular, FREIGHT_PERMS.fleet.manage]
|
||||
: FREIGHT_PERMS.fleet.manage,
|
||||
);
|
||||
|
||||
/** Requester creates a wagon-transfer request (count-only, no wagon picks). */
|
||||
export const WagonTransferRequest = () =>
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
assertCanApproveContractStep,
|
||||
canEditContractStep,
|
||||
} from './freight-permission.util';
|
||||
import { FREIGHT_PERMS } from '../seed/freight-permissions.registry';
|
||||
|
||||
// The document-edit gate (canEditContractStep) must be STRICT: only the approver
|
||||
// whose turn it is may edit. This is the fix for a previous approver keeping the
|
||||
// "Edit contract articles" button after acting, because the approve gate lets
|
||||
// through anyone holding any contract-approve permission.
|
||||
describe('canEditContractStep (strict per-step edit gate)', () => {
|
||||
const director = {
|
||||
employee: { position: { positionType: { key: '-marketing-director-' } } },
|
||||
};
|
||||
// A line staff who already approved their own step but still holds a
|
||||
// contract-approve permission — the exact actor that leaked edit rights.
|
||||
const officerWithApprovePerm = {
|
||||
employee: {
|
||||
position: {
|
||||
positionType: { key: '-marketing-officer-' },
|
||||
permissions: [{ key: FREIGHT_PERMS.contracts.approveLineStaff }],
|
||||
},
|
||||
},
|
||||
};
|
||||
const superAdmin = { roles: [{ key: 'super_admin' }] };
|
||||
|
||||
it('lets the step’s own approver edit', () => {
|
||||
expect(canEditContractStep(director, '-marketing-director-')).toBe(true);
|
||||
});
|
||||
|
||||
it('lets an approval admin edit any step', () => {
|
||||
expect(canEditContractStep(superAdmin, '-marketing-director-')).toBe(true);
|
||||
});
|
||||
|
||||
it('does NOT let a different approver edit just because they hold an approve permission', () => {
|
||||
expect(canEditContractStep(officerWithApprovePerm, '-marketing-director-')).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('stays intentionally stricter than the approve gate (which keeps the blanket fallback)', () => {
|
||||
// The approve gate passes the officer via the any-permission blanket…
|
||||
expect(() =>
|
||||
assertCanApproveContractStep(officerWithApprovePerm, '-marketing-director-'),
|
||||
).not.toThrow();
|
||||
// …but the edit gate does not — that divergence IS the fix.
|
||||
expect(canEditContractStep(officerWithApprovePerm, '-marketing-director-')).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -201,6 +201,34 @@ export function assertCanApproveContractStep(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Strict "is it exactly this caller's turn?" test — mirrors the backoffice
|
||||
* `canApproveContractStep`. Same passes as {@link assertCanApproveContractStep}
|
||||
* EXCEPT the blanket "holds any contract-approve permission" fallback is
|
||||
* dropped: a line-staff holding `approveLineStaff` must NOT read as the director
|
||||
* for a director step. Used to gate contract-document editing so approval hands
|
||||
* edit rights to the NEXT approver only — a previous approver who already acted
|
||||
* (but still holds an approve permission) loses the edit button, as required.
|
||||
*
|
||||
* (Kept separate from the approve/reject gate, which keeps the blanket fallback
|
||||
* so delegates whose token omits a position type can still action their step.)
|
||||
*/
|
||||
export function canEditContractStep(
|
||||
user: TCurrentUser | MeLikeUser | null | undefined,
|
||||
requiredRole: string,
|
||||
): boolean {
|
||||
if (isFreightApprovalAdmin(user)) return true;
|
||||
|
||||
const positionTypes = collectPositionTypeKeys(user);
|
||||
if (positionTypes.includes(requiredRole)) return true;
|
||||
|
||||
const aliases = LEGACY_ROLE_POSITION_TYPES[requiredRole] ?? [];
|
||||
if (aliases.some((alias) => positionTypes.includes(alias))) return true;
|
||||
|
||||
const legacyPermission = CONTRACT_APPROVE_ROLE_PERMISSION[requiredRole];
|
||||
return Boolean(legacyPermission && hasFreightPermission(user, legacyPermission));
|
||||
}
|
||||
|
||||
export function assertCanApproveBookingStep(
|
||||
user: TCurrentUser | MeLikeUser | null | undefined,
|
||||
requiredRole: string,
|
||||
|
||||
@@ -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,67 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Scope the customs clearance service fee to a direction + route.
|
||||
*
|
||||
* The fee was a single global flat rate; the business sells it per lane —
|
||||
* "import clearance, Djibouti → Adama, 300 USD". CUSTOMS_CLEARANCE rates now
|
||||
* carry trade_direction + the yard pair, and contract pricing matches on them
|
||||
* strictly (no route-less fallback).
|
||||
*
|
||||
* Existing route-less clearance rates cannot be backfilled (no way to know
|
||||
* which lane each was meant for) — retired exactly like the base-freight
|
||||
* retirement in AddRateYardScope: SUPERSEDED + soft-deleted, kept for
|
||||
* snapshot history.
|
||||
*/
|
||||
export class CustomsClearanceRouteScope2820000000000 implements MigrationInterface {
|
||||
name = 'CustomsClearanceRouteScope2820000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.rates
|
||||
SET status = 'SUPERSEDED',
|
||||
deleted_at = now(),
|
||||
updated_at = now()
|
||||
WHERE deleted_at IS NULL
|
||||
AND rate_type = 'CUSTOMS_CLEARANCE'
|
||||
AND (origin_yard_id IS NULL OR destination_yard_id IS NULL);
|
||||
`);
|
||||
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope";`,
|
||||
);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.rates
|
||||
ADD CONSTRAINT "CK_rates_yard_scope" CHECK (
|
||||
deleted_at IS NOT NULL
|
||||
OR status = 'SUPERSEDED'
|
||||
OR CASE
|
||||
WHEN ("trigger" = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY'))
|
||||
OR "trigger" = 'CUSTOMS_CLEARANCE'
|
||||
THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL
|
||||
ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL
|
||||
END
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
// Retired rates stay retired (their lanes were never recorded); down only
|
||||
// restores the pre-customs constraint shape.
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope";`,
|
||||
);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.rates
|
||||
ADD CONSTRAINT "CK_rates_yard_scope" CHECK (
|
||||
deleted_at IS NOT NULL
|
||||
OR status = 'SUPERSEDED'
|
||||
OR CASE
|
||||
WHEN "trigger" = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY')
|
||||
THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL
|
||||
ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL
|
||||
END
|
||||
);
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Scope the empty-container return surcharge to a direction + route +
|
||||
* container type, like base freight (import-only for now — the box only goes
|
||||
* back to the port on imports).
|
||||
*
|
||||
* Existing route-less RETURN_SURCHARGE rates cannot be backfilled — retired
|
||||
* (SUPERSEDED + soft-deleted) exactly like base freight and customs clearance
|
||||
* were, kept readable for snapshot history. Route-scoped replacements must be
|
||||
* re-entered; a booking that asks for return with no matching rate hard-blocks.
|
||||
*/
|
||||
export class ReturnSurchargeRouteScope2830000000000 implements MigrationInterface {
|
||||
name = 'ReturnSurchargeRouteScope2830000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.rates
|
||||
SET status = 'SUPERSEDED',
|
||||
deleted_at = now(),
|
||||
updated_at = now()
|
||||
WHERE deleted_at IS NULL
|
||||
AND rate_type = 'RETURN_SURCHARGE'
|
||||
AND (origin_yard_id IS NULL OR destination_yard_id IS NULL);
|
||||
`);
|
||||
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope";`,
|
||||
);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.rates
|
||||
ADD CONSTRAINT "CK_rates_yard_scope" CHECK (
|
||||
deleted_at IS NOT NULL
|
||||
OR status = 'SUPERSEDED'
|
||||
OR CASE
|
||||
WHEN ("trigger" = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY'))
|
||||
OR "trigger" IN ('CUSTOMS_CLEARANCE', 'WITH_RETURN')
|
||||
THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL
|
||||
ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL
|
||||
END
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
// Retired rates stay retired; down only restores the customs-era shape.
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope";`,
|
||||
);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.rates
|
||||
ADD CONSTRAINT "CK_rates_yard_scope" CHECK (
|
||||
deleted_at IS NOT NULL
|
||||
OR status = 'SUPERSEDED'
|
||||
OR CASE
|
||||
WHEN ("trigger" = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY'))
|
||||
OR "trigger" = 'CUSTOMS_CLEARANCE'
|
||||
THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL
|
||||
ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL
|
||||
END
|
||||
);
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -165,8 +165,16 @@ export class BookingPricingService {
|
||||
const usdAmount = mod.calculatedAmount;
|
||||
|
||||
const rate = rateById.get(mod.rateId);
|
||||
const unit = rate?.rateUnit ?? 'FLAT';
|
||||
const unitUsd = rate ? Number(rate.rateValue) : usdAmount;
|
||||
// Derived/route-matched charges (import overweight, empty-container
|
||||
// return) carry their own unit price + billing unit — bill and display
|
||||
// those, not whatever the referenced rate row says.
|
||||
const isDerived = mod.unitPriceUsd != null;
|
||||
const unit = mod.billingUnit ?? rate?.rateUnit ?? 'FLAT';
|
||||
const unitUsd = isDerived
|
||||
? Number(mod.unitPriceUsd)
|
||||
: rate
|
||||
? Number(rate.rateValue)
|
||||
: usdAmount;
|
||||
// Per-unit count: FLAT and PER_INVOICE are billed once (qty 1); an
|
||||
// explicit trigger (e.g. overweight tons) wins when present; otherwise
|
||||
// derive from total ÷ unit price (the live unit price — a count, not a
|
||||
@@ -182,11 +190,11 @@ export class BookingPricingService {
|
||||
|
||||
// H15: bill the frozen contract surcharge rate (already in the booking
|
||||
// currency) when this code has a snapshot; else keep the live amount.
|
||||
const frozen = this.frozenRateByCode(
|
||||
frozenRates,
|
||||
mod.surchargeCode,
|
||||
paymentCurrency,
|
||||
);
|
||||
// Derived charges skip the snapshot — import overweight prices off the
|
||||
// route's container freight, never a frozen OVERWEIGHT_PER_TON value.
|
||||
const frozen = isDerived
|
||||
? null
|
||||
: this.frozenRateByCode(frozenRates, mod.surchargeCode, paymentCurrency);
|
||||
const unitAmount = frozen
|
||||
? Number(frozen.unitPrice)
|
||||
: isEtbBooking
|
||||
@@ -369,6 +377,8 @@ export class BookingPricingService {
|
||||
isGovernment: booking.isGovernment,
|
||||
allowConsolidation,
|
||||
shippingLineId: booking.shippingLineId,
|
||||
originYardId: booking.originYardId,
|
||||
destinationYardId: booking.destinationYardId,
|
||||
totalWagons,
|
||||
// Bulk tonnage scales PER_TON surcharges (e.g. the bulk reefer surcharge).
|
||||
// Container freight carries 0 here — its surcharges scale by container count.
|
||||
|
||||
@@ -436,18 +436,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);
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -422,6 +442,8 @@ export class BookingsService {
|
||||
isReefer?: boolean;
|
||||
isGovernment?: boolean;
|
||||
shippingLineId?: string | null;
|
||||
originYardId?: string | null;
|
||||
destinationYardId?: string | null;
|
||||
bulkTons?: number;
|
||||
containers: CreateBookingContainerDto[];
|
||||
}): Promise<BookingEvaluationInput> {
|
||||
@@ -467,6 +489,8 @@ export class BookingsService {
|
||||
isGovernment: dto.isGovernment ?? false,
|
||||
allowConsolidation,
|
||||
shippingLineId: dto.shippingLineId,
|
||||
originYardId: dto.originYardId ?? null,
|
||||
destinationYardId: dto.destinationYardId ?? null,
|
||||
totalWagons,
|
||||
bulkTons: dto.freightType === 'BULK' ? Number(dto.bulkTons ?? 0) : 0,
|
||||
containers,
|
||||
@@ -788,6 +812,8 @@ export class BookingsService {
|
||||
isReefer: dto.isReefer,
|
||||
isGovernment,
|
||||
shippingLineId: dto.shippingLineId,
|
||||
originYardId: dto.originYardId,
|
||||
destinationYardId: dto.destinationYardId,
|
||||
bulkTons: dto.cargoTotalWeightVgm,
|
||||
containers,
|
||||
});
|
||||
@@ -998,6 +1024,8 @@ export class BookingsService {
|
||||
isHazardous: dto.isHazardous ?? existing.isHazardous,
|
||||
isReefer: dto.isReefer ?? existing.isReefer,
|
||||
shippingLineId: dto.shippingLineId ?? existing.shippingLineId ?? undefined,
|
||||
originYardId: dto.originYardId ?? existing.originYardId,
|
||||
destinationYardId: dto.destinationYardId ?? existing.destinationYardId,
|
||||
bulkTons: dto.cargoTotalWeightVgm ?? Number(existing.cargoTotalWeightVgm ?? 0),
|
||||
containers,
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { FleetManage, FleetView } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { CreateCargoDto } from './dto/create-cargo.dto';
|
||||
import { UpdateCargoDto } from './dto/update-cargo.dto';
|
||||
import { LoadCargoDto } from './dto/load-cargo.dto';
|
||||
@@ -19,12 +20,12 @@ import { CargoesService } from './cargoes.service';
|
||||
|
||||
@ApiTags('cargoes')
|
||||
@Controller('cargoes')
|
||||
@FleetView()
|
||||
@FleetView(FREIGHT_PERMS.cargoes.view)
|
||||
export class CargoesController {
|
||||
constructor(private readonly cargoesService: CargoesService) {}
|
||||
|
||||
@Post()
|
||||
@FleetManage()
|
||||
@FleetManage(FREIGHT_PERMS.cargoes.create)
|
||||
@ApiOperation({ summary: 'Create a new cargo' })
|
||||
create(@Body() dto: CreateCargoDto) {
|
||||
return this.cargoesService.create(dto);
|
||||
@@ -43,35 +44,35 @@ export class CargoesController {
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@FleetManage()
|
||||
@FleetManage(FREIGHT_PERMS.cargoes.update)
|
||||
@ApiOperation({ summary: 'Update a cargo' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateCargoDto) {
|
||||
return this.cargoesService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@FleetManage()
|
||||
@FleetManage(FREIGHT_PERMS.cargoes.delete)
|
||||
@ApiOperation({ summary: 'Delete a cargo' })
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.cargoesService.remove(id);
|
||||
}
|
||||
|
||||
@Post(':id/load')
|
||||
@FleetManage()
|
||||
@FleetManage(FREIGHT_PERMS.cargoes.update)
|
||||
@ApiOperation({ summary: 'Load cargo into a container' })
|
||||
load(@Param('id', ParseUUIDPipe) id: string, @Body() dto: LoadCargoDto) {
|
||||
return this.cargoesService.loadCargo(id, dto);
|
||||
}
|
||||
|
||||
@Post(':id/unload')
|
||||
@FleetManage()
|
||||
@FleetManage(FREIGHT_PERMS.cargoes.update)
|
||||
@ApiOperation({ summary: 'Unload cargo from container' })
|
||||
unload(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.cargoesService.unloadCargo(id);
|
||||
}
|
||||
|
||||
@Post(':id/deliver')
|
||||
@FleetManage()
|
||||
@FleetManage(FREIGHT_PERMS.cargoes.update)
|
||||
@ApiOperation({ summary: 'Mark cargo as delivered' })
|
||||
deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto?: DeliverCargoDto) {
|
||||
return this.cargoesService.deliverCargo(id, dto);
|
||||
|
||||
@@ -10,18 +10,19 @@ import {
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
|
||||
import { FleetManage, FleetView } from "../../common/booking-guards";
|
||||
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||
import { ConsignmentsService } from "./consignments.service";
|
||||
import { CreateConsignmentDto } from "./dto/create-consignment.dto";
|
||||
import { FilterConsignmentDto } from "./dto/filter-consignment.dto";
|
||||
|
||||
@ApiTags("consignments")
|
||||
@Controller("consignments")
|
||||
@FleetView()
|
||||
@FleetView(FREIGHT_PERMS.consignments.view)
|
||||
export class ConsignmentsController {
|
||||
constructor(private readonly consignmentsService: ConsignmentsService) {}
|
||||
|
||||
@Post()
|
||||
@FleetManage()
|
||||
@FleetManage(FREIGHT_PERMS.consignments.create)
|
||||
@ApiOperation({ summary: "Create a new consignment" })
|
||||
create(@Body() dto: CreateConsignmentDto) {
|
||||
return this.consignmentsService.create(dto);
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { FleetManage, FleetView } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { CreateContainerDto } from './dto/create-container.dto';
|
||||
import { UpdateContainerDto } from './dto/update-container.dto';
|
||||
import { AssignContainerToWagonDto } from './dto/assign-container-to-wagon.dto';
|
||||
@@ -18,12 +19,12 @@ import { ContainersService } from './containers.service';
|
||||
|
||||
@ApiTags('containers')
|
||||
@Controller('containers')
|
||||
@FleetView()
|
||||
@FleetView(FREIGHT_PERMS.containers.view)
|
||||
export class ContainersController {
|
||||
constructor(private readonly containersService: ContainersService) {}
|
||||
|
||||
@Post()
|
||||
@FleetManage()
|
||||
@FleetManage(FREIGHT_PERMS.containers.create)
|
||||
@ApiOperation({ summary: 'Create a new container' })
|
||||
create(@Body() dto: CreateContainerDto) {
|
||||
return this.containersService.create(dto);
|
||||
@@ -42,28 +43,28 @@ export class ContainersController {
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@FleetManage()
|
||||
@FleetManage(FREIGHT_PERMS.containers.update)
|
||||
@ApiOperation({ summary: 'Update a container' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateContainerDto) {
|
||||
return this.containersService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@FleetManage()
|
||||
@FleetManage(FREIGHT_PERMS.containers.delete)
|
||||
@ApiOperation({ summary: 'Delete a container' })
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.containersService.remove(id);
|
||||
}
|
||||
|
||||
@Post(':id/assign-wagon')
|
||||
@FleetManage()
|
||||
@FleetManage(FREIGHT_PERMS.containers.update)
|
||||
@ApiOperation({ summary: 'Assign container to a wagon' })
|
||||
assignToWagon(@Param('id', ParseUUIDPipe) id: string, @Body() dto: AssignContainerToWagonDto) {
|
||||
return this.containersService.assignToWagon(id, dto);
|
||||
}
|
||||
|
||||
@Post(':id/unassign-wagon')
|
||||
@FleetManage()
|
||||
@FleetManage(FREIGHT_PERMS.containers.update)
|
||||
@ApiOperation({ summary: 'Unassign container from wagon' })
|
||||
unassignFromWagon(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.containersService.unassignFromWagon(id);
|
||||
|
||||
@@ -289,6 +289,7 @@ export class ContractBookingService {
|
||||
tradeDirection: contract.tradeDirection,
|
||||
freightType,
|
||||
cargoTypeId: this.resolveCargoTypeId(contract, dto),
|
||||
cargoFreeText: dto.cargoFreeText?.trim() || null,
|
||||
isHazardous: this.resolveShipmentHandlingFlag(contract, dto, 'hazardousQuantity'),
|
||||
isReefer: this.resolveShipmentHandlingFlag(contract, dto, 'reeferQuantity'),
|
||||
cargoTotalWeightVgm: this.resolveBulkTons(dto),
|
||||
@@ -738,6 +739,7 @@ export class ContractBookingService {
|
||||
}
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
cargoTypeId: this.resolveCargoTypeId(contract, dto),
|
||||
cargoFreeText: dto.cargoFreeText?.trim() || null,
|
||||
cargoTotalWeightVgm: this.resolveBulkTons(dto),
|
||||
equipmentReturn: this.resolveShipmentEquipmentReturn(contract, dto),
|
||||
} as never);
|
||||
|
||||
@@ -194,17 +194,49 @@ export class ContractPricingService {
|
||||
contract.freightType === 'CONTAINER' &&
|
||||
contract.equipmentReturn === 'WITH_RETURN'
|
||||
) {
|
||||
const withReturn = liveRates.find(
|
||||
(r) => r.rateType === 'RETURN_SURCHARGE' && r.currency === 'USD',
|
||||
);
|
||||
if (withReturn && Number(withReturn.rateValue) > 0) {
|
||||
lineItems.push({
|
||||
code: 'RETURN_SURCHARGE',
|
||||
label: 'Empty container return',
|
||||
unit: toContractUnit(withReturn.rateUnit),
|
||||
unitPrice: convert(Number(withReturn.rateValue)),
|
||||
conditionalOn: 'with_return',
|
||||
// Return is sold per direction + route + container type (import-only) —
|
||||
// one display line per contract size that has a configured rate. A size
|
||||
// with no rate shows nothing here and hard-blocks at booking time.
|
||||
// ponytail: bookings bill the live route rate, not a frozen snapshot.
|
||||
const route = [...(contract.routes ?? [])].sort(
|
||||
(a, b) => a.sortOrder - b.sortOrder,
|
||||
)[0];
|
||||
const onLeg = route
|
||||
? liveRates.filter(
|
||||
(r) =>
|
||||
r.rateType === 'RETURN_SURCHARGE' &&
|
||||
r.currency === 'USD' &&
|
||||
r.tradeDirection === contract.tradeDirection &&
|
||||
r.originYardId === route.originYardId &&
|
||||
r.destinationYardId === route.destinationYardId,
|
||||
)
|
||||
: [];
|
||||
if (onLeg.length > 0) {
|
||||
const sizes = (contract.cargoScope ?? [])
|
||||
.map((c) => c.containerSize)
|
||||
.filter((s): s is string => !!s);
|
||||
const { items: containerTypes } = await this.containerTypesService.findAll({
|
||||
isActive: true,
|
||||
pageSize: 100,
|
||||
});
|
||||
for (const size of sizes) {
|
||||
const sizeFt = size === '40ft' ? 40 : 20;
|
||||
const matchedIds = new Set(
|
||||
containerTypes.filter((ct) => ct.sizeFt === sizeFt).map((ct) => ct.id),
|
||||
);
|
||||
const rate =
|
||||
onLeg.find((r) => r.containerTypeId && matchedIds.has(r.containerTypeId)) ??
|
||||
onLeg.find((r) => !r.containerTypeId);
|
||||
if (!rate || Number(rate.rateValue) <= 0) continue;
|
||||
lineItems.push({
|
||||
code: 'RETURN_SURCHARGE',
|
||||
label: `Empty container return (${size})`,
|
||||
unit: toContractUnit(rate.rateUnit),
|
||||
unitPrice: convert(Number(rate.rateValue)),
|
||||
containerSize: size,
|
||||
conditionalOn: 'with_return',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,12 +245,24 @@ export class ContractPricingService {
|
||||
// ONE_TIME, per shipment request for GENERAL. Excluded from booking totals.
|
||||
// A customs contract may not proceed without a configured live rate.
|
||||
if (contract.customsClearingEnabled) {
|
||||
const clearance = liveRates.find(
|
||||
(r) => r.rateType === 'CUSTOMS_CLEARANCE' && r.currency === 'USD',
|
||||
);
|
||||
// The fee is sold per direction + route — strict, no route-less fallback.
|
||||
// ponytail: multi-route contracts bill the first lane's fee; per-lane fees need per-route snapshots.
|
||||
const route = [...(contract.routes ?? [])].sort(
|
||||
(a, b) => a.sortOrder - b.sortOrder,
|
||||
)[0];
|
||||
const clearance = route
|
||||
? liveRates.find(
|
||||
(r) =>
|
||||
r.rateType === 'CUSTOMS_CLEARANCE' &&
|
||||
r.currency === 'USD' &&
|
||||
r.tradeDirection === contract.tradeDirection &&
|
||||
r.originYardId === route.originYardId &&
|
||||
r.destinationYardId === route.destinationYardId,
|
||||
)
|
||||
: undefined;
|
||||
if (!clearance || Number(clearance.rateValue) <= 0) {
|
||||
throw new UnprocessableEntityException(
|
||||
'No customs clearance service fee is configured. Ask the rates team to set a live CUSTOMS_CLEARANCE rate before submitting customs contracts.',
|
||||
'No customs clearance service fee is configured for this direction and route. Ask the rates team to set a live CUSTOMS_CLEARANCE rate for this origin → destination.',
|
||||
);
|
||||
}
|
||||
lineItems.push({
|
||||
|
||||
@@ -18,7 +18,15 @@ import { ContractPdfService } from '../../contracts/contract-pdf.service';
|
||||
import { ContractViewModel } from '../../contracts/contract-view-model.builder';
|
||||
import { MinioService } from '../minio/minio.service';
|
||||
import { FileRecord } from '../files/entities/file.entity';
|
||||
import { assertCanApproveContractStep } from '../../common/freight-permission.util';
|
||||
import {
|
||||
assertCanApproveContractStep,
|
||||
assertFreightPermission,
|
||||
canEditContractStep,
|
||||
} from '../../common/freight-permission.util';
|
||||
import {
|
||||
FREIGHT_PERMS,
|
||||
forFreightType,
|
||||
} from '../../seed/freight-permissions.registry';
|
||||
import { ContractDocumentHistoryService } from './contract-document-history.service';
|
||||
import { ApprovalRulesService } from '../rule-engine/services/approval-rules.service';
|
||||
import { CargoTypesService } from '../rule-engine/services/cargo-types.service';
|
||||
@@ -236,8 +244,15 @@ export class ContractTransitionService {
|
||||
actorId: string,
|
||||
validityDays: number,
|
||||
documentSnapshot?: ContractDocumentSnapshotInput | null,
|
||||
user?: TCurrentUser | null,
|
||||
): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
// The route guard passes on either arm; the contract's freight type decides
|
||||
// which one is actually required (accept bulk ≠ accept container).
|
||||
assertFreightPermission(
|
||||
user,
|
||||
forFreightType(FREIGHT_PERMS.contracts.staffAccept, contract.freightType),
|
||||
);
|
||||
assertContractStatus(contract, ['SUBMITTED']);
|
||||
|
||||
if (!Number.isInteger(validityDays) || validityDays < 1) {
|
||||
@@ -431,12 +446,11 @@ export class ContractTransitionService {
|
||||
if (!next) return false;
|
||||
if (!user) return false;
|
||||
|
||||
try {
|
||||
assertCanApproveContractStep(user, next.requiredRole);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
// Strict match: ONLY the approver whose turn it is (the next pending step's
|
||||
// role) may edit. Using the looser approve gate here let any approver who
|
||||
// held a contract-approve permission keep the edit button after acting —
|
||||
// approval must hand edit rights to the next approver, not share them.
|
||||
return canEditContractStep(user, next.requiredRole);
|
||||
}
|
||||
|
||||
/** The role that currently holds editing rights, for UI messaging. */
|
||||
@@ -533,8 +547,13 @@ export class ContractTransitionService {
|
||||
contractId: string,
|
||||
note: string,
|
||||
actorId: string,
|
||||
user?: TCurrentUser | null,
|
||||
): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
assertFreightPermission(
|
||||
user,
|
||||
forFreightType(FREIGHT_PERMS.contracts.requestChanges, contract.freightType),
|
||||
);
|
||||
assertContractStatus(contract, ['SUBMITTED']);
|
||||
|
||||
await this.contractsRepository.createReviewNote(
|
||||
@@ -552,8 +571,17 @@ export class ContractTransitionService {
|
||||
return updated;
|
||||
}
|
||||
|
||||
async reject(contractId: string, reason: string, actorId: string): Promise<Contract> {
|
||||
async reject(
|
||||
contractId: string,
|
||||
reason: string,
|
||||
actorId: string,
|
||||
user?: TCurrentUser | null,
|
||||
): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
assertFreightPermission(
|
||||
user,
|
||||
forFreightType(FREIGHT_PERMS.contracts.reject, contract.freightType),
|
||||
);
|
||||
assertContractStatus(contract, ['SUBMITTED', 'PENDING_APPROVAL']);
|
||||
|
||||
await this.contractsRepository.createReviewNote(
|
||||
|
||||
@@ -34,7 +34,11 @@ import {
|
||||
import { actorLabel } from '../warehouses/current-actor.util';
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
import { ContractDocumentHistoryService } from './contract-document-history.service';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import {
|
||||
FREIGHT_PERMS,
|
||||
bothFreightTypes,
|
||||
forFreightType,
|
||||
} from '../../seed/freight-permissions.registry';
|
||||
import {
|
||||
assertFreightPermission,
|
||||
hasFreightPermission,
|
||||
@@ -184,7 +188,10 @@ export class ContractsController {
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
if (dto.isGovernment) {
|
||||
assertFreightPermission(user, FREIGHT_PERMS.contracts.staffAccept);
|
||||
assertFreightPermission(
|
||||
user,
|
||||
forFreightType(FREIGHT_PERMS.contracts.staffAccept, dto.freightType),
|
||||
);
|
||||
}
|
||||
return this.contractsService.create(dto, files ?? [], user?.id);
|
||||
}
|
||||
@@ -337,23 +344,25 @@ export class ContractsController {
|
||||
}
|
||||
|
||||
@Post(':id/staff/accept')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.staffAccept)
|
||||
// One-of guard; the service then requires the arm matching the contract's freight type.
|
||||
@BookingStaff(bothFreightTypes(FREIGHT_PERMS.contracts.staffAccept))
|
||||
@ApiOperation({ summary: 'Staff accept → set validity window + start approval chain' })
|
||||
staffAccept(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: AcceptContractDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.transitionService.staffAccept(
|
||||
id,
|
||||
resolveAuthUserId(user),
|
||||
dto.validityDays,
|
||||
dto.documentSnapshot,
|
||||
user,
|
||||
);
|
||||
}
|
||||
|
||||
@Get(':id/document/draft')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.staffAccept)
|
||||
@BookingStaff(bothFreightTypes(FREIGHT_PERMS.contracts.staffAccept))
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Editable contract-document draft (this contract\'s snapshot, or the live template) for the accept/edit dialog',
|
||||
@@ -377,7 +386,7 @@ export class ContractsController {
|
||||
}
|
||||
|
||||
@Put(':id/document/articles')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.staffAccept)
|
||||
@BookingStaff(bothFreightTypes(FREIGHT_PERMS.contracts.staffAccept))
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Edit this contract\'s document articles only (per-contract; never touches the six shared templates)',
|
||||
@@ -396,29 +405,35 @@ export class ContractsController {
|
||||
}
|
||||
|
||||
@Post(':id/staff/request-changes')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.requestChanges)
|
||||
@BookingStaff(bothFreightTypes(FREIGHT_PERMS.contracts.requestChanges))
|
||||
@ApiOperation({ summary: 'Staff return contract for customer updates' })
|
||||
requestChanges(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: RequestChangesDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.transitionService.requestChanges(
|
||||
id,
|
||||
dto.note,
|
||||
resolveAuthUserId(user),
|
||||
user,
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':id/staff/reject')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.reject)
|
||||
@BookingStaff(bothFreightTypes(FREIGHT_PERMS.contracts.reject))
|
||||
@ApiOperation({ summary: 'Staff reject contract' })
|
||||
reject(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: RejectContractDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.transitionService.reject(id, dto.reason, resolveAuthUserId(user));
|
||||
return this.transitionService.reject(
|
||||
id,
|
||||
dto.reason,
|
||||
resolveAuthUserId(user),
|
||||
user,
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':id/approval-steps/:stepId/approve')
|
||||
|
||||
@@ -182,6 +182,13 @@ export class CreateBookingUnderContractDto {
|
||||
@Type(() => CreateBulkLineDto)
|
||||
bulkLines?: CreateBulkLineDto[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'What the containers carry — captured per booking (container freight).',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
cargoFreeText?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { FleetManage, FleetView } from '../../common/booking-guards';
|
||||
import { FleetManage, StaffReference } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { CreateLocomotiveDto } from './dto/create-locomotive.dto';
|
||||
import { FilterLocomotivesDto } from './dto/filter-locomotives.dto';
|
||||
import { UpdateLocomotiveDto } from './dto/update-locomotive.dto';
|
||||
@@ -9,39 +10,43 @@ import { LocomotivesService } from './locomotives.service';
|
||||
|
||||
@ApiTags('locomotives')
|
||||
@ApiBearerAuth()
|
||||
// No class-level guard: reads are login-only reference data (any staff can
|
||||
// fetch a locomotive for a cross-flow view without the fleet:view that drives
|
||||
// the Fleet sidebar). Every mutation carries its own @FleetManage().
|
||||
@Controller('locomotives')
|
||||
@FleetView()
|
||||
export class LocomotivesController {
|
||||
constructor(private readonly locomotivesService: LocomotivesService) {}
|
||||
|
||||
@Get()
|
||||
@StaffReference()
|
||||
@ApiOperation({ summary: 'List locomotives' })
|
||||
findAll(@Query() filter: FilterLocomotivesDto) {
|
||||
return this.locomotivesService.findAll(filter);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@StaffReference()
|
||||
@ApiOperation({ summary: 'Get a locomotive by ID' })
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.locomotivesService.findById(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@FleetManage()
|
||||
@FleetManage(FREIGHT_PERMS.locomotives.create)
|
||||
@ApiOperation({ summary: 'Create a locomotive' })
|
||||
create(@Body() dto: CreateLocomotiveDto) {
|
||||
return this.locomotivesService.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@FleetManage()
|
||||
@FleetManage(FREIGHT_PERMS.locomotives.update)
|
||||
@ApiOperation({ summary: 'Update a locomotive' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateLocomotiveDto) {
|
||||
return this.locomotivesService.update(id, dto);
|
||||
}
|
||||
|
||||
@Post(':id/decommission')
|
||||
@FleetManage()
|
||||
@FleetManage(FREIGHT_PERMS.locomotives.delete)
|
||||
@ApiOperation({ summary: 'Decommission a locomotive' })
|
||||
decommission(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.locomotivesService.decommission(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);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Body, Controller, Delete, Get, Param, ParseUUIDPipe, Patch, Post, Query
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { FleetManage, FleetView } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { CreateRouteDto } from './dto/create-route.dto';
|
||||
import { FilterRoutesDto } from './dto/filter-routes.dto';
|
||||
import { UpdateRouteDto } from './dto/update-route.dto';
|
||||
@@ -10,7 +11,7 @@ import { RoutesService } from './routes.service';
|
||||
@ApiTags('routes')
|
||||
@ApiBearerAuth()
|
||||
@Controller('routes')
|
||||
@FleetView()
|
||||
@FleetView(FREIGHT_PERMS.routes.view)
|
||||
export class RoutesController {
|
||||
constructor(private readonly routesService: RoutesService) {}
|
||||
|
||||
@@ -27,21 +28,21 @@ export class RoutesController {
|
||||
}
|
||||
|
||||
@Post()
|
||||
@FleetManage()
|
||||
@FleetManage(FREIGHT_PERMS.routes.create)
|
||||
@ApiOperation({ summary: 'Create route' })
|
||||
create(@Body() dto: CreateRouteDto) {
|
||||
return this.routesService.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@FleetManage()
|
||||
@FleetManage(FREIGHT_PERMS.routes.update)
|
||||
@ApiOperation({ summary: 'Update route' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateRouteDto) {
|
||||
return this.routesService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@FleetManage()
|
||||
@FleetManage(FREIGHT_PERMS.routes.delete)
|
||||
@ApiOperation({ summary: 'Deactivate route' })
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.routesService.deactivate(id);
|
||||
|
||||
@@ -125,6 +125,22 @@ export class ListRatesQueryDto extends PaginationQueryDto {
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
rateType?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Filter by rate category — comma-separated appliesTo values (e.g. "CONTAINER" or "FIRST_MILE,LAST_MILE").',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
appliesTo?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Filter by surcharge trigger — comma-separated trigger values (e.g. "CUSTOMS_CLEARANCE" or "HAZARDOUS,REEFER").',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
trigger?: string;
|
||||
}
|
||||
|
||||
export class ListWeightLimitRulesQueryDto extends PaginationQueryDto {
|
||||
|
||||
@@ -117,6 +117,21 @@ export class RatesRepository implements IRatesRepository {
|
||||
if (query.rateType) {
|
||||
qb.andWhere('rate.rateType = :rateType', { rateType: query.rateType });
|
||||
}
|
||||
// Category tabs on the admin page: comma-separated appliesTo / trigger
|
||||
// lists, ANDed together (e.g. appliesTo=OTHER + trigger=CUSTOMS_CLEARANCE).
|
||||
const csv = (v?: string) =>
|
||||
(v ?? '')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
const appliesTo = csv(query.appliesTo);
|
||||
if (appliesTo.length > 0) {
|
||||
qb.andWhere('rate.appliesTo IN (:...appliesTo)', { appliesTo });
|
||||
}
|
||||
const triggers = csv(query.trigger);
|
||||
if (triggers.length > 0) {
|
||||
qb.andWhere('rate.trigger IN (:...triggers)', { triggers });
|
||||
}
|
||||
if (query.search) {
|
||||
qb.andWhere(
|
||||
'(rate.rateType ILIKE :search OR rate.status ILIKE :search OR rate.rateUnit ILIKE :search OR rate.currency ILIKE :search)',
|
||||
|
||||
@@ -76,3 +76,191 @@ describe('RuleEngineService — requested service without a configured surcharge
|
||||
expect(result.hardBlocked[0]).toContain('reefer');
|
||||
});
|
||||
});
|
||||
|
||||
describe('RuleEngineService — overweight surcharge by trade direction', () => {
|
||||
const baseImportRate: Rate = {
|
||||
id: 'rate-import-20',
|
||||
rateType: 'CONTAINER_IMPORT',
|
||||
trigger: 'ALWAYS',
|
||||
rateValue: 1000,
|
||||
rateUnit: 'PER_CONTAINER',
|
||||
currency: 'USD',
|
||||
status: 'LIVE',
|
||||
containerTypeId: 'ct-20',
|
||||
cargoTypeId: null,
|
||||
originYardId: 'yard-dj',
|
||||
destinationYardId: 'yard-adama',
|
||||
} as Rate;
|
||||
|
||||
const configuredOverweight: Rate = {
|
||||
id: 'rate-ow',
|
||||
rateType: 'OVERWEIGHT_PER_TON',
|
||||
trigger: 'OVERWEIGHT',
|
||||
rateValue: 10,
|
||||
rateUnit: 'PER_TON',
|
||||
currency: 'USD',
|
||||
status: 'LIVE',
|
||||
containerTypeId: null,
|
||||
cargoTypeId: null,
|
||||
} as Rate;
|
||||
|
||||
let service: RuleEngineService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new RuleEngineService(
|
||||
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
||||
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
||||
{
|
||||
findActiveByContainerTypeId: jest
|
||||
.fn()
|
||||
.mockResolvedValue([{ id: 'wlr-20', maxVgmTons: 20, maxCapacityTons: null }]),
|
||||
} as never,
|
||||
{ findAllActive: jest.fn().mockResolvedValue([]) } as never,
|
||||
{
|
||||
findLiveRates: jest.fn().mockResolvedValue([baseImportRate, configuredOverweight]),
|
||||
} as never,
|
||||
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
||||
{} as never,
|
||||
);
|
||||
});
|
||||
|
||||
// One 20ft at 25 t against a 20 t limit → 5 t excess.
|
||||
const overweightInput = (tradeDirection: string): BookingEvaluationInput => ({
|
||||
serviceTypeId: 'svc-1',
|
||||
paymentCurrency: 'USD',
|
||||
tradeDirection,
|
||||
isHazardous: false,
|
||||
totalWagons: 1,
|
||||
originYardId: 'yard-dj',
|
||||
destinationYardId: 'yard-adama',
|
||||
containers: [
|
||||
{ containerTypeId: 'ct-20', quantity: 1, vgmPerUnitTons: 25, totalVgmTons: 25 },
|
||||
],
|
||||
});
|
||||
|
||||
it('IMPORT derives the per-ton price from base freight ÷ (2 × limit), not the configured rate', async () => {
|
||||
const result = await service.evaluate(overweightInput('IMPORT'));
|
||||
const ow = result.appliedModifiers.filter((m) => m.surchargeCode === 'OVERWEIGHT_PER_TON');
|
||||
expect(ow).toHaveLength(1);
|
||||
// 1000 / (2 × 20) = 25 USD/t on 5 excess tons.
|
||||
expect(ow[0].unitPriceUsd).toBe(25);
|
||||
expect(ow[0].calculatedAmount).toBe(125);
|
||||
expect(ow[0].triggerValue).toBe(5);
|
||||
expect(ow[0].rateId).toBe(baseImportRate.id);
|
||||
});
|
||||
|
||||
it('EXPORT keeps billing the configured OVERWEIGHT rate', async () => {
|
||||
const result = await service.evaluate(overweightInput('EXPORT'));
|
||||
const ow = result.appliedModifiers.filter((m) => m.surchargeCode === 'OVERWEIGHT_PER_TON');
|
||||
expect(ow).toHaveLength(1);
|
||||
expect(ow[0].rateId).toBe(configuredOverweight.id);
|
||||
// 5 excess tons × the configured 10 USD/t.
|
||||
expect(ow[0].calculatedAmount).toBe(50);
|
||||
expect(ow[0].unitPriceUsd).toBeUndefined();
|
||||
});
|
||||
|
||||
it('IMPORT without a route-matching base rate bills no overweight (base freight blocks anyway)', async () => {
|
||||
const result = await service.evaluate({
|
||||
...overweightInput('IMPORT'),
|
||||
destinationYardId: 'yard-elsewhere',
|
||||
});
|
||||
const ow = result.appliedModifiers.filter((m) => m.surchargeCode === 'OVERWEIGHT_PER_TON');
|
||||
expect(ow).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('RuleEngineService — empty-container return per route + container type', () => {
|
||||
const returnRate20: Rate = {
|
||||
id: 'rate-return-20',
|
||||
rateType: 'RETURN_SURCHARGE',
|
||||
trigger: 'WITH_RETURN',
|
||||
rateValue: 20,
|
||||
rateUnit: 'PER_CONTAINER',
|
||||
currency: 'USD',
|
||||
status: 'LIVE',
|
||||
containerTypeId: 'ct-20',
|
||||
cargoTypeId: null,
|
||||
tradeDirection: 'IMPORT',
|
||||
originYardId: 'yard-dj',
|
||||
destinationYardId: 'yard-adama',
|
||||
} as Rate;
|
||||
|
||||
let service: RuleEngineService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new RuleEngineService(
|
||||
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
||||
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
||||
{
|
||||
findActiveByContainerTypeId: jest
|
||||
.fn()
|
||||
.mockResolvedValue([{ id: 'wlr-20', maxVgmTons: 20, maxCapacityTons: null }]),
|
||||
} as never,
|
||||
{ findAllActive: jest.fn().mockResolvedValue([]) } as never,
|
||||
{ findLiveRates: jest.fn().mockResolvedValue([returnRate20]) } as never,
|
||||
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
||||
{} as never,
|
||||
);
|
||||
});
|
||||
|
||||
const returnInput = (overrides: Partial<BookingEvaluationInput>): BookingEvaluationInput => ({
|
||||
serviceTypeId: 'svc-1',
|
||||
paymentCurrency: 'USD',
|
||||
tradeDirection: 'IMPORT',
|
||||
isHazardous: false,
|
||||
totalWagons: 1,
|
||||
originYardId: 'yard-dj',
|
||||
destinationYardId: 'yard-adama',
|
||||
containers: [
|
||||
{
|
||||
containerTypeId: 'ct-20',
|
||||
quantity: 4,
|
||||
vgmPerUnitTons: 10,
|
||||
totalVgmTons: 40,
|
||||
returnQuantity: 2,
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('bills the route + type matched rate on the opted-in count', async () => {
|
||||
const result = await service.evaluate(returnInput({}));
|
||||
const ret = result.appliedModifiers.filter((m) => m.surchargeCode === 'RETURN_SURCHARGE');
|
||||
expect(result.hardBlocked).toHaveLength(0);
|
||||
expect(ret).toHaveLength(1);
|
||||
expect(ret[0].rateId).toBe(returnRate20.id);
|
||||
expect(ret[0].triggerValue).toBe(2);
|
||||
expect(ret[0].calculatedAmount).toBe(40);
|
||||
expect(ret[0].billingUnit).toBe('PER_CONTAINER');
|
||||
});
|
||||
|
||||
it('hard-blocks when the booking route has no matching return rate', async () => {
|
||||
const result = await service.evaluate(
|
||||
returnInput({ destinationYardId: 'yard-elsewhere' }),
|
||||
);
|
||||
expect(result.hardBlocked.some((m) => m.includes('return'))).toBe(true);
|
||||
expect(
|
||||
result.appliedModifiers.filter((m) => m.surchargeCode === 'RETURN_SURCHARGE'),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('hard-blocks an EXPORT booking asking for return (rates are import-only)', async () => {
|
||||
const result = await service.evaluate(returnInput({ tradeDirection: 'EXPORT' }));
|
||||
expect(result.hardBlocked.some((m) => m.includes('return'))).toBe(true);
|
||||
});
|
||||
|
||||
it('legacy booking-level flag bills every container at its type rate', async () => {
|
||||
const result = await service.evaluate(
|
||||
returnInput({
|
||||
withReturn: true,
|
||||
containers: [
|
||||
{ containerTypeId: 'ct-20', quantity: 4, vgmPerUnitTons: 10, totalVgmTons: 40 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
const ret = result.appliedModifiers.filter((m) => m.surchargeCode === 'RETURN_SURCHARGE');
|
||||
expect(ret).toHaveLength(1);
|
||||
expect(ret[0].triggerValue).toBe(4);
|
||||
expect(ret[0].calculatedAmount).toBe(80);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -67,6 +67,12 @@ export interface BookingEvaluationInput {
|
||||
isGovernment?: boolean;
|
||||
allowConsolidation?: boolean;
|
||||
shippingLineId?: string | null;
|
||||
/**
|
||||
* The booking's rail leg. Import overweight derives its per-ton price from
|
||||
* this route's own container freight rate, so the engine needs the yards.
|
||||
*/
|
||||
originYardId?: string | null;
|
||||
destinationYardId?: string | null;
|
||||
/**
|
||||
* Booking's cargo type needs EDR-provided lashing/securing (cargoType
|
||||
* hasLashing = true). Fires the flat LASHING surcharge. Resolved by the
|
||||
@@ -91,6 +97,15 @@ export interface AppliedCargoModifier {
|
||||
triggerValue: number | null;
|
||||
calculatedAmount: number;
|
||||
currency: string;
|
||||
/**
|
||||
* Effective per-unit USD price when it differs from the rate row's own value
|
||||
* — set by derived charges (import overweight: base freight ÷ 2×limit) so
|
||||
* the breakdown shows the real per-ton figure, not the base container price.
|
||||
* Any modifier carrying it also bypasses frozen contract snapshots.
|
||||
*/
|
||||
unitPriceUsd?: number | null;
|
||||
/** Display unit for a unitPriceUsd modifier (e.g. PER_TON for overweight). */
|
||||
billingUnit?: string;
|
||||
}
|
||||
|
||||
export interface ContainerWeightResult {
|
||||
@@ -165,12 +180,16 @@ export class RuleEngineService {
|
||||
...(await this.capacityViolations(input.containers, input.tradeDirection)),
|
||||
);
|
||||
|
||||
// Per-container-line weight limit (maxVgmTons), index-aligned with
|
||||
// containerWeightResults — the derived import overweight divides by it.
|
||||
const lineMaxVgmTons: Array<number | null> = [];
|
||||
for (const container of input.containers) {
|
||||
const rules = await this.weightLimitRulesRepo.findActiveByContainerTypeId(
|
||||
container.containerTypeId,
|
||||
input.tradeDirection,
|
||||
);
|
||||
const rule = rules[0];
|
||||
lineMaxVgmTons.push(rule ? Number(rule.maxVgmTons) : null);
|
||||
let isOverweight = container.isOverweight ?? false;
|
||||
let excess = container.overweightExcessTons ?? null;
|
||||
|
||||
@@ -273,13 +292,6 @@ export class RuleEngineService {
|
||||
input.containers.some((c) => Number(c.reeferQuantity ?? 0) > 0),
|
||||
label: 'refrigerated (reefer) cargo',
|
||||
},
|
||||
{
|
||||
trigger: 'WITH_RETURN',
|
||||
wanted:
|
||||
truthy(input.withReturn) ||
|
||||
input.containers.some((c) => Number(c.returnQuantity ?? 0) > 0),
|
||||
label: 'empty-container return',
|
||||
},
|
||||
];
|
||||
for (const svc of requestedServices) {
|
||||
if (svc.wanted && !surchargeRates.some((r) => r.trigger === svc.trigger)) {
|
||||
@@ -292,6 +304,14 @@ export class RuleEngineService {
|
||||
}
|
||||
|
||||
for (const rate of surchargeRates) {
|
||||
// Import overweight never bills the configured rate — its per-ton price
|
||||
// derives from the route's base container freight (see below).
|
||||
if (rate.trigger === 'OVERWEIGHT' && input.tradeDirection === 'IMPORT') {
|
||||
continue;
|
||||
}
|
||||
// Empty-container return is sold per route + container type — billed by
|
||||
// the route-matched block below, never by this route-agnostic loop.
|
||||
if (rate.trigger === 'WITH_RETURN') continue;
|
||||
const triggered = this.matchesTrigger(rate.trigger, {
|
||||
isHazardous: input.isHazardous,
|
||||
hasReefer,
|
||||
@@ -384,6 +404,21 @@ export class RuleEngineService {
|
||||
});
|
||||
}
|
||||
|
||||
if (input.tradeDirection === 'IMPORT') {
|
||||
appliedModifiers.push(
|
||||
...this.derivedImportOverweight(
|
||||
input,
|
||||
containerWeightResults,
|
||||
lineMaxVgmTons,
|
||||
liveRates,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const withReturn = this.withReturnCharges(input, liveRates);
|
||||
appliedModifiers.push(...withReturn.modifiers);
|
||||
hardBlocked.push(...withReturn.blocked);
|
||||
|
||||
return {
|
||||
priorityScore,
|
||||
appliedModifiers,
|
||||
@@ -394,6 +429,132 @@ export class RuleEngineService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Import overweight — derived, never configured. Each overweight container
|
||||
* line bills its excess tons at (its own base import freight on the booking's
|
||||
* route) ÷ (2 × its weight limit): 20ft at 1000 USD with a 20 t limit →
|
||||
* 25 USD per excess ton. Export keeps the configured OVERWEIGHT rate.
|
||||
* Note: derives from the LIVE route rate even for frozen-rate contract
|
||||
* bookings — the frozen snapshot has no route-scoped container price to
|
||||
* divide.
|
||||
*/
|
||||
private derivedImportOverweight(
|
||||
input: BookingEvaluationInput,
|
||||
weightResults: ContainerWeightResult[],
|
||||
lineMaxVgmTons: Array<number | null>,
|
||||
liveRates: Rate[],
|
||||
): AppliedCargoModifier[] {
|
||||
const modifiers: AppliedCargoModifier[] = [];
|
||||
if (!input.originYardId || !input.destinationYardId) return modifiers;
|
||||
|
||||
for (let i = 0; i < weightResults.length; i++) {
|
||||
const wr = weightResults[i];
|
||||
const excess = Number(wr?.overweightExcessTons ?? 0);
|
||||
const maxVgm = Number(lineMaxVgmTons[i] ?? 0);
|
||||
if (!wr?.isOverweight || !(excess > 0) || !(maxVgm > 0)) continue;
|
||||
|
||||
// Same precedence as base freight pricing: the rate scoped to this
|
||||
// container type wins over the route's catch-all rate.
|
||||
const onLeg = liveRates.filter(
|
||||
(r) =>
|
||||
r.rateType === 'CONTAINER_IMPORT' &&
|
||||
r.currency === 'USD' &&
|
||||
r.originYardId === input.originYardId &&
|
||||
r.destinationYardId === input.destinationYardId,
|
||||
);
|
||||
const base =
|
||||
onLeg.find((r) => r.containerTypeId === wr.containerTypeId) ??
|
||||
onLeg.find((r) => !r.containerTypeId);
|
||||
// No base rate → the base-freight line hard-blocks this booking anyway.
|
||||
if (!base) continue;
|
||||
|
||||
const perTon = Number(base.rateValue) / (2 * maxVgm);
|
||||
const amount = excess * perTon;
|
||||
if (!(amount > 0)) continue;
|
||||
|
||||
modifiers.push({
|
||||
rateId: base.id,
|
||||
surchargeCode: 'OVERWEIGHT_PER_TON',
|
||||
triggerValue: excess,
|
||||
calculatedAmount: amount,
|
||||
currency: base.currency,
|
||||
unitPriceUsd: perTon,
|
||||
billingUnit: 'PER_TON',
|
||||
});
|
||||
}
|
||||
return modifiers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty-container return — sold per direction + route + container type, like
|
||||
* base freight. Each container line that opted in (returnQuantity, or every
|
||||
* container when only the legacy booking-level flag is set) bills the
|
||||
* route-matched WITH_RETURN rate for its own container type; a line with no
|
||||
* matching rate hard-blocks the booking instead of shipping the service for
|
||||
* free. Rates are import-only for now, so an export booking that asks for
|
||||
* return blocks too.
|
||||
* ponytail: bills the LIVE route rate, not a frozen contract snapshot — one
|
||||
* RETURN_SURCHARGE snapshot code can't hold per-size route prices.
|
||||
*/
|
||||
private withReturnCharges(
|
||||
input: BookingEvaluationInput,
|
||||
liveRates: Rate[],
|
||||
): { modifiers: AppliedCargoModifier[]; blocked: string[] } {
|
||||
const modifiers: AppliedCargoModifier[] = [];
|
||||
const blocked: string[] = [];
|
||||
const bookingLevel = truthy(input.withReturn);
|
||||
const wanted =
|
||||
bookingLevel || input.containers.some((c) => Number(c.returnQuantity ?? 0) > 0);
|
||||
if (!wanted) return { modifiers, blocked };
|
||||
|
||||
const onLeg = liveRates.filter(
|
||||
(r) =>
|
||||
r.trigger === 'WITH_RETURN' &&
|
||||
r.currency === 'USD' &&
|
||||
r.tradeDirection === input.tradeDirection &&
|
||||
r.originYardId === input.originYardId &&
|
||||
r.destinationYardId === input.destinationYardId,
|
||||
);
|
||||
|
||||
for (const container of input.containers) {
|
||||
const qty =
|
||||
Number(container.returnQuantity ?? 0) > 0
|
||||
? Number(container.returnQuantity)
|
||||
: bookingLevel
|
||||
? Number(container.quantity || 0)
|
||||
: 0;
|
||||
if (!(qty > 0)) continue;
|
||||
|
||||
const rate =
|
||||
onLeg.find((r) => r.containerTypeId === container.containerTypeId) ??
|
||||
onLeg.find((r) => !r.containerTypeId);
|
||||
if (!rate) {
|
||||
blocked.push(
|
||||
'No empty-container return rate is configured for this container ' +
|
||||
'type on this route (return is import-only) — remove the return ' +
|
||||
'option or ask EDR to configure its rate for this origin → destination.',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const rateValue = Number(rate.rateValue);
|
||||
const amount = rate.rateUnit === 'FLAT' ? rateValue : qty * rateValue;
|
||||
if (!(amount > 0)) continue;
|
||||
modifiers.push({
|
||||
rateId: rate.id,
|
||||
surchargeCode: this.surchargeCode(rate),
|
||||
triggerValue: qty,
|
||||
calculatedAmount: amount,
|
||||
currency: rate.currency,
|
||||
unitPriceUsd: rateValue,
|
||||
billingUnit: rate.rateUnit,
|
||||
});
|
||||
}
|
||||
|
||||
// Same block deduplicated — several lines missing the rate is one problem.
|
||||
return { modifiers, blocked: [...new Set(blocked)] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Messages for container lines whose total weight exceeds the hard capacity
|
||||
* ceiling (weight_limit_rules.max_capacity_tons). Non-empty ⇒ the booking
|
||||
|
||||
@@ -93,6 +93,19 @@ export class RatesService {
|
||||
return trigger === 'ALWAYS' && BASE_FREIGHT_CATEGORIES.includes(appliesTo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rates sold per direction + route. Base freight always; customs clearance
|
||||
* and empty-container return are the surcharges that are too — their fee
|
||||
* depends on the lane (and, for returns, the container type).
|
||||
*/
|
||||
private isRouteScoped(appliesTo: Rate['appliesTo'], trigger: Rate['trigger']): boolean {
|
||||
return (
|
||||
this.isBaseFreight(appliesTo, trigger) ||
|
||||
trigger === 'CUSTOMS_CLEARANCE' ||
|
||||
trigger === 'WITH_RETURN'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Which country each end of the leg must sit in, given what the rate is for.
|
||||
* The railway only sells three shapes: import lands at the Djibouti ports and
|
||||
@@ -126,7 +139,7 @@ export class RatesService {
|
||||
destinationYardId?: string | null;
|
||||
}): Promise<YardScope> {
|
||||
const { appliesTo, trigger, tradeDirection } = input;
|
||||
if (!this.isBaseFreight(appliesTo, trigger)) {
|
||||
if (!this.isRouteScoped(appliesTo, trigger)) {
|
||||
return { originYardId: null, destinationYardId: null };
|
||||
}
|
||||
|
||||
@@ -134,7 +147,7 @@ export class RatesService {
|
||||
const destinationYardId = input.destinationYardId ?? null;
|
||||
if (!originYardId || !destinationYardId) {
|
||||
throw new BadRequestException(
|
||||
'Base freight rates are priced per leg — pick both an origin and a destination yard.',
|
||||
'This rate is priced per leg — pick both an origin and a destination yard.',
|
||||
);
|
||||
}
|
||||
if (originYardId === destinationYardId) {
|
||||
@@ -179,6 +192,25 @@ export class RatesService {
|
||||
}): void {
|
||||
const { appliesTo, trigger, tradeDirection, intercityKind } = input;
|
||||
const { containerTypeId, cargoTypeId } = input;
|
||||
if (trigger === 'CUSTOMS_CLEARANCE') {
|
||||
if (tradeDirection !== 'IMPORT' && tradeDirection !== 'EXPORT') {
|
||||
throw new BadRequestException(
|
||||
'A customs clearance rate must say whether it covers IMPORT or EXPORT.',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (trigger === 'WITH_RETURN') {
|
||||
// Returning the empty box only exists on imports (the box goes back to
|
||||
// the port) — export return rates are rejected until the business sells
|
||||
// that.
|
||||
if (tradeDirection !== 'IMPORT') {
|
||||
throw new BadRequestException(
|
||||
'An empty container return rate is import-only for now.',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!this.isBaseFreight(appliesTo, trigger)) return;
|
||||
|
||||
if (appliesTo === 'INTERCITY') {
|
||||
@@ -247,13 +279,24 @@ export class RatesService {
|
||||
const trigger = dto.trigger as Rate['trigger'];
|
||||
// Surcharges (trigger ≠ ALWAYS) carry no direction/scope — clear them so
|
||||
// the engine never accidentally narrows a surcharge by container/direction.
|
||||
// Exceptions: customs clearance keeps a direction, and empty-container
|
||||
// return keeps direction + container type — both are sold per lane.
|
||||
const isSurcharge = trigger !== 'ALWAYS';
|
||||
const containerTypeId = isSurcharge ? null : (dto.containerTypeId ?? null);
|
||||
const containerTypeId =
|
||||
trigger === 'WITH_RETURN'
|
||||
? (dto.containerTypeId ?? null)
|
||||
: isSurcharge
|
||||
? null
|
||||
: (dto.containerTypeId ?? null);
|
||||
const cargoTypeId = isSurcharge ? null : (dto.cargoTypeId ?? null);
|
||||
// Intercity never leaves Ethiopia, so it has no trade direction to store —
|
||||
// its yard pair already says where it runs.
|
||||
const tradeDirection =
|
||||
isSurcharge || appliesTo === 'INTERCITY' ? null : (dto.tradeDirection ?? null);
|
||||
trigger === 'CUSTOMS_CLEARANCE' || trigger === 'WITH_RETURN'
|
||||
? (dto.tradeDirection ?? null)
|
||||
: isSurcharge || appliesTo === 'INTERCITY'
|
||||
? null
|
||||
: (dto.tradeDirection ?? null);
|
||||
|
||||
const intercityKind = dto.intercityKind ?? null;
|
||||
this.assertScopeCoherent({
|
||||
@@ -376,7 +419,8 @@ export class RatesService {
|
||||
if (dto.appliesTo) updates.appliesTo = appliesTo;
|
||||
if (dto.trigger) updates.trigger = trigger;
|
||||
|
||||
const containerTypeId = isSurcharge
|
||||
const keepsContainerType = !isSurcharge || trigger === 'WITH_RETURN';
|
||||
const containerTypeId = !keepsContainerType
|
||||
? null
|
||||
: dto.containerTypeId !== undefined
|
||||
? dto.containerTypeId
|
||||
@@ -387,11 +431,15 @@ export class RatesService {
|
||||
? dto.cargoTypeId
|
||||
: existing.cargoTypeId;
|
||||
const tradeDirection =
|
||||
isSurcharge || appliesTo === 'INTERCITY'
|
||||
? null
|
||||
: dto.tradeDirection !== undefined
|
||||
trigger === 'CUSTOMS_CLEARANCE' || trigger === 'WITH_RETURN'
|
||||
? dto.tradeDirection !== undefined
|
||||
? dto.tradeDirection
|
||||
: existing.tradeDirection;
|
||||
: existing.tradeDirection
|
||||
: isSurcharge || appliesTo === 'INTERCITY'
|
||||
? null
|
||||
: dto.tradeDirection !== undefined
|
||||
? dto.tradeDirection
|
||||
: existing.tradeDirection;
|
||||
|
||||
updates.containerTypeId = containerTypeId ?? null;
|
||||
updates.cargoTypeId = cargoTypeId ?? null;
|
||||
|
||||
@@ -50,8 +50,11 @@ export class TrainSchedulesRepository extends BaseRepository<TrainSchedule> {
|
||||
company: true,
|
||||
originYard: true,
|
||||
destinationYard: true,
|
||||
bookingContainers: { containerType: true },
|
||||
cargoType: true,
|
||||
// wagonTypes feed grossBookingWeightTons the REAL tare of the
|
||||
// wagon type the booking rides — without them it falls back to
|
||||
// default tares and the workspace gross drifts from the validator.
|
||||
bookingContainers: { containerType: { wagonTypes: true } },
|
||||
cargoType: { wagonTypes: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1187,6 +1187,7 @@ describe('BookingBatchService — built-train wagon capacity', () => {
|
||||
reserved: Booking[];
|
||||
maxWagons?: number;
|
||||
routeStops?: string[];
|
||||
yardCountries?: Record<string, string>;
|
||||
}) => {
|
||||
const schedule = {
|
||||
id: scheduleId,
|
||||
@@ -1218,10 +1219,21 @@ describe('BookingBatchService — built-train wagon capacity', () => {
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const yardRepo = {
|
||||
find: jest
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
Object.entries(opts.yardCountries ?? {}).map(([id, country]) => ({
|
||||
id,
|
||||
country,
|
||||
})),
|
||||
),
|
||||
};
|
||||
const dataSource = {
|
||||
getRepository: jest.fn((entity: { name?: string }) => {
|
||||
if (entity?.name === 'Wagon') return wagonRepo;
|
||||
if (entity?.name === 'RouteMilestone') return milestoneRepo;
|
||||
if (entity?.name === 'Yard') return yardRepo;
|
||||
return genericRepo;
|
||||
}),
|
||||
transaction: jest.fn(),
|
||||
@@ -1264,11 +1276,11 @@ describe('BookingBatchService — built-train wagon capacity', () => {
|
||||
await expect(service.isScheduleFull(scheduleId)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('is FULL when sub-leg bookings hold every physical wagon of a milestone route', async () => {
|
||||
// Regression: 50 wagons sold Negad→Mojo on a Doraleh→…→Dire Dawa corridor
|
||||
// left the pass-through edges reading "free" in the per-edge budget, so the
|
||||
// full train's window cycled OPEN forever and the day pool never expired.
|
||||
// A wagon is committed for the whole trip — leg-free edges are not capacity.
|
||||
it('is NOT full when only a middle leg is sold and other edges run free (domestic route)', async () => {
|
||||
// Leg-aware allocation (planWagonsWithStock legs) made mid-leg wagons real
|
||||
// capacity on the edges they don't ride: a domestic corridor with cargo
|
||||
// only on m1→m2 still boards bookings on the free first/last edges, so the
|
||||
// window must stay open for them.
|
||||
const { service } = buildService({
|
||||
physicalWagons: 2,
|
||||
routeStops: ['yard-a', 'yard-m1', 'yard-m2', 'yard-b'],
|
||||
@@ -1277,9 +1289,48 @@ describe('BookingBatchService — built-train wagon capacity', () => {
|
||||
reservedBooking('b2', { origin: 'yard-m1', dest: 'yard-m2' }),
|
||||
],
|
||||
});
|
||||
await expect(service.isScheduleFull(scheduleId)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('is FULL for the trade direction once the border edge is sold out, even with home legs free', async () => {
|
||||
// Export b→c holds every wagon of the border crossing: no further export
|
||||
// can board anywhere (they all must ride that edge), so the window closes —
|
||||
// while intercity keeps booking the free a→b leg through the per-leg budget.
|
||||
const { service } = buildService({
|
||||
physicalWagons: 2,
|
||||
routeStops: ['yard-a', 'yard-b', 'yard-dj'],
|
||||
yardCountries: {
|
||||
'yard-a': 'ETHIOPIA',
|
||||
'yard-b': 'ETHIOPIA',
|
||||
'yard-dj': 'DJIBOUTI',
|
||||
},
|
||||
reserved: [
|
||||
reservedBooking('b1', { origin: 'yard-b', dest: 'yard-dj' }),
|
||||
reservedBooking('b2', { origin: 'yard-b', dest: 'yard-dj' }),
|
||||
],
|
||||
});
|
||||
await expect(service.isScheduleFull(scheduleId)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('is NOT full while the border edge still has room, even with a home leg sold out', async () => {
|
||||
// Intercity rode a→b on both wagons; the border edge b→dj is still free,
|
||||
// so exports can still board — the window stays open.
|
||||
const { service } = buildService({
|
||||
physicalWagons: 2,
|
||||
routeStops: ['yard-a', 'yard-b', 'yard-dj'],
|
||||
yardCountries: {
|
||||
'yard-a': 'ETHIOPIA',
|
||||
'yard-b': 'ETHIOPIA',
|
||||
'yard-dj': 'DJIBOUTI',
|
||||
},
|
||||
reserved: [
|
||||
reservedBooking('b1', { origin: 'yard-a', dest: 'yard-b' }),
|
||||
reservedBooking('b2', { origin: 'yard-a', dest: 'yard-b' }),
|
||||
],
|
||||
});
|
||||
await expect(service.isScheduleFull(scheduleId)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('reports over-allocation when the consist is trimmed below committed bookings', async () => {
|
||||
const { service } = buildService({
|
||||
physicalWagons: 1,
|
||||
|
||||
@@ -24,9 +24,9 @@ import {
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||
import { BookingPricingService } from '../bookings/booking-pricing.service';
|
||||
import { Locomotive } from '../locomotives/entities/locomotive.entity';
|
||||
import { formatRouteLabel } from '../routes/entities/route.entity';
|
||||
import { RouteMilestone } from '../routes/entities/route-milestone.entity';
|
||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
|
||||
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
|
||||
@@ -60,11 +60,14 @@ import {
|
||||
DEFAULT_WAGONS_PER_BOOKING,
|
||||
} from "./booking-batch.constants";
|
||||
import {
|
||||
LocomotiveLimits,
|
||||
WagonTypeDimensions,
|
||||
bookingCargoTons,
|
||||
bookingGrossWeightTons,
|
||||
deriveTrainCapacityFromLocomotive,
|
||||
sizePartialOfferWagons,
|
||||
trainHardCaps,
|
||||
trainSetLocomotiveLimits,
|
||||
wagonTypeDimensionsFromEntity,
|
||||
} from './train-capacity.util';
|
||||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||
@@ -677,7 +680,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
|
||||
candidate.id,
|
||||
);
|
||||
const locomotive = schedule?.trainSet?.locomotive;
|
||||
const locomotive = trainSetLocomotiveLimits(schedule?.trainSet);
|
||||
if (!schedule || !locomotive) continue;
|
||||
const limits = await this.capacityLimits(locomotive);
|
||||
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
||||
@@ -825,7 +828,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
|
||||
candidate.id,
|
||||
);
|
||||
const locomotive = schedule?.trainSet?.locomotive;
|
||||
const locomotive = trainSetLocomotiveLimits(schedule?.trainSet);
|
||||
if (!schedule || !locomotive) continue;
|
||||
const limits = await this.capacityLimits(locomotive);
|
||||
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
||||
@@ -895,7 +898,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
|
||||
candidate.id,
|
||||
);
|
||||
const locomotive = schedule?.trainSet?.locomotive;
|
||||
const locomotive = trainSetLocomotiveLimits(schedule?.trainSet);
|
||||
if (!schedule || !locomotive) continue;
|
||||
const limits = await this.capacityLimits(locomotive);
|
||||
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
||||
@@ -937,7 +940,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
|
||||
target.scheduleId,
|
||||
);
|
||||
const locomotive = schedule?.trainSet?.locomotive;
|
||||
const locomotive = trainSetLocomotiveLimits(schedule?.trainSet);
|
||||
if (!schedule || !locomotive) return false;
|
||||
const wagonDims = await this.loadWagonDims();
|
||||
const limits = await this.capacityLimits(locomotive);
|
||||
@@ -1034,7 +1037,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
}
|
||||
const schedule =
|
||||
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
const locomotive = schedule?.trainSet?.locomotive;
|
||||
const locomotive = trainSetLocomotiveLimits(schedule?.trainSet);
|
||||
if (!schedule || !locomotive) {
|
||||
throw new ConflictException(
|
||||
"Export train is no longer available for reservation",
|
||||
@@ -1351,7 +1354,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
};
|
||||
});
|
||||
|
||||
const loco = s.trainSet?.locomotive ?? null;
|
||||
const loco = trainSetLocomotiveLimits(s.trainSet);
|
||||
|
||||
// The board renders ONE booking window — the schedule's own frozen window
|
||||
// (windowOpensAt/windowClosesAt + phase deadlines returned below). Bookings
|
||||
@@ -1411,10 +1414,12 @@ export class BookingBatchService implements OnModuleInit {
|
||||
trainName: s.trainSet.train.trainName ?? null,
|
||||
}
|
||||
: null,
|
||||
// Identity from the primary (legacy) locomotive; limit figures from the
|
||||
// whole set's effective minimum — what the fill engine actually spends.
|
||||
locomotive: loco
|
||||
? {
|
||||
code: loco.code,
|
||||
name: loco.name ?? null,
|
||||
code: s.trainSet?.locomotive?.code ?? '',
|
||||
name: s.trainSet?.locomotive?.name ?? null,
|
||||
maxPullWeightTons: Number(loco.maxPullWeightTons),
|
||||
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
|
||||
}
|
||||
@@ -1464,7 +1469,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
weightTons: number;
|
||||
lengthMeters: number;
|
||||
}>,
|
||||
loco: Locomotive | null,
|
||||
loco: LocomotiveLimits | null,
|
||||
maxWagons: number | null,
|
||||
): BatchBoardSchedule["capacity"] {
|
||||
const allocated = items.filter((i) => i.state === "ALLOCATED");
|
||||
@@ -1499,7 +1504,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
s: TrainSchedule,
|
||||
items: BatchBoardBooking[],
|
||||
): BatchBoardSchedule {
|
||||
const loco = s.trainSet?.locomotive ?? null;
|
||||
const loco = trainSetLocomotiveLimits(s.trainSet);
|
||||
|
||||
return {
|
||||
scheduleId: s.id,
|
||||
@@ -1531,10 +1536,12 @@ export class BookingBatchService implements OnModuleInit {
|
||||
trainName: s.trainSet.train.trainName ?? null,
|
||||
}
|
||||
: null,
|
||||
// Identity from the primary (legacy) locomotive; limit figures from the
|
||||
// whole set's effective minimum — what the fill engine actually spends.
|
||||
locomotive: loco
|
||||
? {
|
||||
code: loco.code,
|
||||
name: loco.name ?? null,
|
||||
code: s.trainSet?.locomotive?.code ?? '',
|
||||
name: s.trainSet?.locomotive?.name ?? null,
|
||||
maxPullWeightTons: Number(loco.maxPullWeightTons),
|
||||
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
|
||||
}
|
||||
@@ -1600,7 +1607,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const schedule =
|
||||
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
if (!schedule || !this.isFillable(schedule)) return 0;
|
||||
const locomotive = schedule.trainSet?.locomotive;
|
||||
const locomotive = trainSetLocomotiveLimits(schedule.trainSet);
|
||||
if (!schedule.trainSetId || !locomotive) {
|
||||
this.logger.warn(
|
||||
`Schedule ${scheduleId} has no locomotive/train set — skipped.`,
|
||||
@@ -1827,7 +1834,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
for (const id of scheduleIds) {
|
||||
const schedule =
|
||||
await this.trainSchedulesRepository.findByIdWithFullGraph(id);
|
||||
const locomotive = schedule?.trainSet?.locomotive;
|
||||
const locomotive = trainSetLocomotiveLimits(schedule?.trainSet);
|
||||
if (!schedule || !schedule.trainSetId || !locomotive) {
|
||||
this.logger.warn(
|
||||
`Schedule ${id} has no locomotive/train set — skipped.`,
|
||||
@@ -2469,7 +2476,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
} | null> {
|
||||
const schedule =
|
||||
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
const locomotive = schedule?.trainSet?.locomotive;
|
||||
const locomotive = trainSetLocomotiveLimits(schedule?.trainSet);
|
||||
if (!schedule || !locomotive) return null;
|
||||
const wagonDims = await this.loadWagonDims();
|
||||
const limits = await this.capacityLimits(locomotive);
|
||||
@@ -3114,8 +3121,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const containers = (b: Booking): number =>
|
||||
(b.bookingContainers ?? []).reduce((sum, c) => sum + Number(c.quantity ?? 0), 0);
|
||||
const totalContainers = containers(primary) + containers(partner);
|
||||
const cargoTons =
|
||||
Number(primary.cargoTotalWeightVgm ?? 0) + Number(partner.cargoTotalWeightVgm ?? 0);
|
||||
const cargoTons = bookingCargoTons(primary) + bookingCargoTons(partner);
|
||||
|
||||
// Consolidation shares TEU slots, never rated payload: the pair still needs
|
||||
// enough wagons to carry its combined cargo, so the weight axis bounds the
|
||||
@@ -3222,7 +3228,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const byLength = containerWagonsForLines(booking.bookingContainers ?? []);
|
||||
|
||||
const capacityTons = this.dimsFor(booking, wagonDims).capacityTons;
|
||||
const cargoTons = Number(booking.cargoTotalWeightVgm ?? 0);
|
||||
const cargoTons = bookingCargoTons(booking);
|
||||
const byWeight =
|
||||
cargoTons > 0 && capacityTons > 0 ? Math.ceil(cargoTons / capacityTons) : 0;
|
||||
|
||||
@@ -3243,7 +3249,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
return {
|
||||
wagons,
|
||||
weightTons: bookingGrossWeightTons(
|
||||
Number(booking.cargoTotalWeightVgm ?? 0),
|
||||
bookingCargoTons(booking),
|
||||
wagons,
|
||||
dims.tareWeightTons,
|
||||
),
|
||||
@@ -3270,7 +3276,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
* caps deliberately do not apply here (a mis-set global row once capped
|
||||
* every train at 14m and no export booking could board).
|
||||
*/
|
||||
private async capacityLimits(locomotive: Locomotive): Promise<TrainLimits> {
|
||||
private async capacityLimits(locomotive: LocomotiveLimits): Promise<TrainLimits> {
|
||||
const wagonTypes = await this.loadWagonTypeDimensions();
|
||||
const derived = deriveTrainCapacityFromLocomotive(
|
||||
{
|
||||
@@ -3304,7 +3310,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
*/
|
||||
private async syncScheduleMaxWagons(
|
||||
schedule: TrainSchedule,
|
||||
locomotive: Locomotive,
|
||||
locomotive: LocomotiveLimits,
|
||||
): Promise<void> {
|
||||
const physicalWagons = await this.builtTrainWagonCount(schedule);
|
||||
const maxWagons =
|
||||
@@ -3605,14 +3611,14 @@ export class BookingBatchService implements OnModuleInit {
|
||||
}
|
||||
|
||||
/**
|
||||
* Built train: FULL when every physical wagon slot is taken — the consist is
|
||||
* the capacity, weight/length were settled at build time.
|
||||
* No built train: FULL on ANY capacity axis — out of wagon slots, or out of
|
||||
* pull weight / train length for even one more loaded wagon. The old
|
||||
* slot-only check let a weight-bound train (PW2: weight binds at 37 wagons =
|
||||
* 3522.4T of 3500+90T, slots bind at 44) cycle its booking window forever
|
||||
* instead of finalizing — 7 phantom slots kept it "not full" while nothing
|
||||
* could board.
|
||||
* FULL is DIRECTIONAL: the schedule's trade direction is full when the
|
||||
* border-crossing edge (which every export/import must ride) can't take one
|
||||
* more minimal wagon on any axis — slots for built trains (the consist is
|
||||
* the capacity, weight/length settled at build), all three axes otherwise
|
||||
* (PW2: weight binds at 37 wagons = 3522.4T of 3500+90T, slots bind at 44).
|
||||
* Home-side legs may still run empty; intercity ride-alongs keep filling
|
||||
* them via the per-leg budget and never consult this flag. Domestic routes
|
||||
* (no border) are full only when every edge is closed.
|
||||
*/
|
||||
async isScheduleFull(scheduleId: string): Promise<boolean> {
|
||||
const schedule =
|
||||
@@ -3663,48 +3669,69 @@ export class BookingBatchService implements OnModuleInit {
|
||||
|
||||
/** See {@link isScheduleFull} — same check for callers that already hold the full graph. */
|
||||
private async isTrainFull(schedule: TrainSchedule): Promise<boolean> {
|
||||
// Built train: the physical consist is the only capacity axis, and a wagon
|
||||
// is committed to its booking for the WHOLE trip — wagon allocation has no
|
||||
// leg concept, so a wagon hauling Negad→Mojo cargo can never be re-sold for
|
||||
// the Doraleh→Negad edge it merely passes through. Count commitments
|
||||
// train-wide, not per corridor edge: the per-edge budget read "free slots"
|
||||
// on pass-through legs of a sold-out consist, so the window of a full train
|
||||
// cycled OPEN forever instead of concluding DONE (and the day pool's
|
||||
// leftover bookings were never expired).
|
||||
const physicalWagons = await this.builtTrainWagonCount(schedule);
|
||||
if (physicalWagons != null) {
|
||||
return (await this.committedWagons(schedule)) >= physicalWagons;
|
||||
}
|
||||
if ((await this.remainingWagons(schedule)) <= 0) return true;
|
||||
const locomotive = schedule.trainSet?.locomotive;
|
||||
if (!locomotive) return false; // no weight/length limits to bind against
|
||||
// "Full" means full FOR THE TRAIN'S TRADE DIRECTION. Every export and
|
||||
// every import must cross the ET↔DJ border edge, so once that edge can't
|
||||
// take one more minimal wagon the booking window may close — even while
|
||||
// home-side legs still run empty. Intercity ride-alongs never consult this
|
||||
// flag; they keep booking the free legs through the per-leg budget.
|
||||
// A single-country (domestic) corridor has no mandatory edge, so it is
|
||||
// full only when EVERY edge is closed on some axis.
|
||||
const wagonDims = await this.loadWagonDims();
|
||||
const limits = await this.capacityLimits(locomotive);
|
||||
const physicalWagons = await this.builtTrainWagonCount(schedule);
|
||||
let limits: TrainLimits;
|
||||
if (physicalWagons != null) {
|
||||
// The consist is the capacity; weight/length were settled at build time.
|
||||
// remainingBudget swaps in the physical wagon count per edge itself.
|
||||
limits = {
|
||||
base: {
|
||||
wagons: physicalWagons,
|
||||
weightTons: Number.POSITIVE_INFINITY,
|
||||
lengthMeters: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
tolerance: { weightTons: 0, lengthMeters: 0 },
|
||||
};
|
||||
} else {
|
||||
const locomotive = trainSetLocomotiveLimits(schedule.trainSet);
|
||||
// No loco, no built train: only the slot axis exists to bind against.
|
||||
if (!locomotive) return (await this.remainingWagons(schedule)) <= 0;
|
||||
limits = await this.capacityLimits(locomotive);
|
||||
}
|
||||
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
||||
return budget.isExhausted(this.minPerWagonNeed(wagonDims));
|
||||
const minNeed = this.minPerWagonNeed(wagonDims);
|
||||
const border = await this.borderLeg(budget.stops);
|
||||
if (border) {
|
||||
return !budget.fits(
|
||||
{
|
||||
wagons: 1,
|
||||
weightTons: minNeed.grossWeightTons,
|
||||
lengthMeters: minNeed.lengthMeters,
|
||||
},
|
||||
border,
|
||||
);
|
||||
}
|
||||
return budget.isExhausted(minNeed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wagons the schedule's allocated + reserved bookings occupy train-wide,
|
||||
* regardless of which corridor leg each rides. Deduped by booking id — a
|
||||
* booking mid-settle can momentarily be both linked and reserved.
|
||||
* The corridor's single border-crossing edge (last home-country stop → first
|
||||
* far-country stop), or null when every stop is in one country. This is the
|
||||
* edge every EXPORT and IMPORT booking must ride, whichever sub-corridor it
|
||||
* books — which makes it the train's directional fullness gauge.
|
||||
*/
|
||||
private async committedWagons(schedule: TrainSchedule): Promise<number> {
|
||||
const wagonDims = await this.loadWagonDims();
|
||||
const allocated = (schedule.scheduleBookings ?? [])
|
||||
.map((sb) => sb.booking)
|
||||
.filter((b): b is Booking => Boolean(b));
|
||||
const reserved = await this.bookingsRepository.findReservedForSchedule(
|
||||
schedule.id,
|
||||
);
|
||||
const byId = new Map(
|
||||
[...allocated, ...reserved].map((b) => [b.id, b] as const),
|
||||
);
|
||||
let total = 0;
|
||||
for (const booking of byId.values()) {
|
||||
total += this.wagonsFor(booking, wagonDims);
|
||||
}
|
||||
return total;
|
||||
private async borderLeg(stops: string[]): Promise<CorridorLeg | null> {
|
||||
if (stops.length < 2) return null;
|
||||
const yards = await this.dataSource
|
||||
.getRepository(Yard)
|
||||
.find({ where: { id: In(stops) } });
|
||||
const countryOf = new Map(yards.map((y) => [y.id, y.country]));
|
||||
const first = countryOf.get(stops[0]);
|
||||
if (!first) return null;
|
||||
const crossIdx = stops.findIndex((id) => {
|
||||
const country = countryOf.get(id);
|
||||
return country != null && country !== first;
|
||||
});
|
||||
if (crossIdx <= 0) return null;
|
||||
return { fromEdge: crossIdx - 1, toEdge: crossIdx };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { bookingCargoTons } from './train-capacity.util';
|
||||
import type { Booking } from '../bookings/entities/booking.entity';
|
||||
import type { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||
import {
|
||||
@@ -187,5 +188,5 @@ export function summarizeFleetWarnings(
|
||||
}
|
||||
|
||||
export function totalAssignedWeight(bookings: Booking[]): number {
|
||||
return roundTons(bookings.reduce((sum, b) => sum + Number(b.cargoTotalWeightVgm ?? 0), 0));
|
||||
return roundTons(bookings.reduce((sum, b) => sum + bookingCargoTons(b), 0));
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
grossWagonWeightTons,
|
||||
minLocomotiveLimits,
|
||||
sizePartialOfferWagons,
|
||||
trainSetLocomotiveLimits,
|
||||
} from './train-capacity.util';
|
||||
|
||||
describe('train-capacity.util', () => {
|
||||
@@ -205,6 +206,37 @@ describe('train-capacity.util', () => {
|
||||
expect(limits?.overageToleranceTons).toBe(20);
|
||||
});
|
||||
|
||||
it('ignores unconfigured (null) tolerances instead of zeroing the set (S-2026-00024)', () => {
|
||||
// LOCO-019 had 90T tolerance, LOCO-020 had none configured: the set must
|
||||
// keep the 90, not collapse to 0 and reject 3547.6T on a 3500T train.
|
||||
const limits = minLocomotiveLimits([
|
||||
{ maxPullWeightTons: 3500, maxTrainLengthMeters: 760, overageToleranceTons: 90 },
|
||||
{ maxPullWeightTons: 3500, maxTrainLengthMeters: 760, overageToleranceTons: null },
|
||||
]);
|
||||
expect(limits?.overageToleranceTons).toBe(90);
|
||||
// All unconfigured → no tolerance.
|
||||
const none = minLocomotiveLimits([
|
||||
{ maxPullWeightTons: 3500, maxTrainLengthMeters: 760 },
|
||||
]);
|
||||
expect(none?.overageToleranceTons).toBe(0);
|
||||
});
|
||||
|
||||
it('trainSetLocomotiveLimits prefers link rows and falls back to the legacy single loco', () => {
|
||||
const l1 = { maxPullWeightTons: 3500, maxTrainLengthMeters: 760, overageToleranceTons: 90 };
|
||||
const l2 = { maxPullWeightTons: 3600, maxTrainLengthMeters: 700, overageToleranceTons: null };
|
||||
expect(
|
||||
trainSetLocomotiveLimits({ locomotive: null, locomotives: [{ locomotive: l1 }, { locomotive: l2 }] }),
|
||||
).toEqual({
|
||||
maxPullWeightTons: 3500,
|
||||
maxTrainLengthMeters: 700,
|
||||
overageToleranceTons: 90,
|
||||
overageToleranceMeters: 0,
|
||||
});
|
||||
expect(trainSetLocomotiveLimits({ locomotive: l1 })?.maxPullWeightTons).toBe(3500);
|
||||
expect(trainSetLocomotiveLimits(null)).toBeNull();
|
||||
expect(trainSetLocomotiveLimits({ locomotive: null, locomotives: [] })).toBeNull();
|
||||
});
|
||||
|
||||
describe('sizePartialOfferWagons', () => {
|
||||
it('sizes a bulk split by the WEIGHT axis when the pull limit binds, not wagon slots', () => {
|
||||
// The 3500T-train scenario: two 1000T bookings boarded gross (each 15 PW2
|
||||
|
||||
@@ -86,6 +86,27 @@ function num(value: unknown, fallback = 0): number {
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cargo tons of a booking: the stored VGM total when present, else the sum of
|
||||
* its container lines (quantity × VGM per unit). The portal's container flow
|
||||
* stores per-line VGM and leaves `cargoTotalWeightVgm` at 0 — reading the
|
||||
* total alone made every such booking weigh only its tare.
|
||||
*/
|
||||
export function bookingCargoTons(booking: {
|
||||
cargoTotalWeightVgm?: number | string | null;
|
||||
bookingContainers?: Array<{
|
||||
quantity?: number | null;
|
||||
vgmPerUnitTons?: number | string | null;
|
||||
}> | null;
|
||||
}): number {
|
||||
const total = num(booking.cargoTotalWeightVgm);
|
||||
if (total > 0) return total;
|
||||
return (booking.bookingContainers ?? []).reduce(
|
||||
(sum, line) => sum + num(line.quantity) * num(line.vgmPerUnitTons),
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
/** Gross weight of one loaded wagon: it hauls itself plus its cargo. */
|
||||
export function grossWagonWeightTons(slot: Pick<ConsistSlot, 'tareWeightTons' | 'cargoTons'>): number {
|
||||
return num(slot.tareWeightTons) + num(slot.cargoTons);
|
||||
@@ -260,11 +281,34 @@ export function minLocomotiveLimits(
|
||||
};
|
||||
}
|
||||
|
||||
function minConfigured(values: Array<number | string | null | undefined>): number {
|
||||
function minConfigured(values: Array<number | null | undefined>): number {
|
||||
const configured = values.filter((v) => v != null).map((v) => num(v));
|
||||
return configured.length ? Math.min(...configured) : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective limits for a whole train set: min across its linked locomotives,
|
||||
* falling back to the legacy single `locomotive` column for sets created
|
||||
* before multi-loco support. Null when the set has no locomotive at all.
|
||||
*/
|
||||
export function trainSetLocomotiveLimits(
|
||||
trainSet?: {
|
||||
locomotive?: LocomotiveLimits | null;
|
||||
locomotives?: Array<{ locomotive?: LocomotiveLimits | null }> | null;
|
||||
} | null,
|
||||
): LocomotiveLimits | null {
|
||||
if (!trainSet) return null;
|
||||
const linked = (trainSet.locomotives ?? [])
|
||||
.map((link) => link.locomotive)
|
||||
.filter((l): l is LocomotiveLimits => Boolean(l));
|
||||
const pool = linked.length
|
||||
? linked
|
||||
: trainSet.locomotive
|
||||
? [trainSet.locomotive]
|
||||
: [];
|
||||
return minLocomotiveLimits(pool);
|
||||
}
|
||||
|
||||
/** Per-booking train length from wagon count and freight-specific wagon type length. */
|
||||
export function bookingTrainLengthMeters(
|
||||
freightType: string | null | undefined,
|
||||
|
||||
@@ -122,6 +122,7 @@ import {
|
||||
roundTons,
|
||||
sumWagonsRequired,
|
||||
type TrainLimitConfig,
|
||||
maxEdgeConsistUsage,
|
||||
validateContainerPlacements,
|
||||
validateMixedTrainLimitsPerEdge,
|
||||
type ContainerPlacementInput,
|
||||
@@ -130,9 +131,12 @@ import {
|
||||
import { deriveScheduleDirection } from './derive-schedule-direction.util';
|
||||
import { pickLowestFreeNumber, pickTrainNumberPool } from './train-number.util';
|
||||
import {
|
||||
bookingCargoTons,
|
||||
deriveTrainCapacityFromLocomotive,
|
||||
minLocomotiveLimits,
|
||||
trainSetLocomotiveLimits,
|
||||
wagonTypeDimensionsFromEntity,
|
||||
LocomotiveLimits,
|
||||
WagonTypeDimensions,
|
||||
} from './train-capacity.util';
|
||||
import {
|
||||
@@ -1706,25 +1710,36 @@ export class TrainSchedulingService {
|
||||
relations: { wagonType: true },
|
||||
})
|
||||
: null;
|
||||
const planTareTons = consistWagons
|
||||
const planTareTons = roundTons(
|
||||
wagonPlan.reduce((sum, slot) => sum + Number(slot.tareWeightTons ?? 0), 0),
|
||||
);
|
||||
const consistTareTons = consistWagons
|
||||
? roundTons(
|
||||
consistWagons.reduce(
|
||||
(sum, wagon) => sum + Number(wagon.wagonType?.tareWeightTons ?? 0),
|
||||
0,
|
||||
),
|
||||
)
|
||||
: roundTons(
|
||||
wagonPlan.reduce((sum, slot) => sum + Number(slot.tareWeightTons ?? 0), 0),
|
||||
);
|
||||
const grossWeightTons = roundTons(totalWeightTons + planTareTons);
|
||||
: planTareTons;
|
||||
// The pull limit binds on the HEAVIEST LEG, not the whole-route sum —
|
||||
// disjoint legs (intercity Gelan→Adama + export Adama→Doraleh) are never
|
||||
// hauled at the same time. Coupled-but-unplanned wagons ride every edge,
|
||||
// so their tare rides on top of the binding edge.
|
||||
const emptyConsistTareTons = Math.max(0, consistTareTons - planTareTons);
|
||||
const edgeUsage = maxEdgeConsistUsage(
|
||||
wagonPlan,
|
||||
await this.stopYardsForSchedule(schedule),
|
||||
);
|
||||
const grossWeightTons = roundTons(edgeUsage.grossWeightTons + emptyConsistTareTons);
|
||||
if (!dto.forceAssign && weightCapWithOverage < grossWeightTons) {
|
||||
throw new BadRequestException(
|
||||
`Train set locomotives cannot pull ${grossWeightTons}T gross (${totalWeightTons}T cargo + ${planTareTons}T wagon tare)`,
|
||||
`Train set locomotives cannot pull ${grossWeightTons}T gross on the heaviest leg (limit ${roundTons(weightCapWithOverage)}T incl. tolerance)`,
|
||||
);
|
||||
}
|
||||
if (!dto.forceAssign && lengthCapWithOverage < totalLengthMeters) {
|
||||
const maxEdgeLengthMeters = roundTons(edgeUsage.lengthMeters);
|
||||
if (!dto.forceAssign && lengthCapWithOverage < maxEdgeLengthMeters) {
|
||||
throw new BadRequestException(
|
||||
`Train set locomotives cannot support ${totalLengthMeters}m`,
|
||||
`Train set locomotives cannot support ${maxEdgeLengthMeters}m`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4081,9 +4096,6 @@ export class TrainSchedulingService {
|
||||
}
|
||||
|
||||
const totalWeightTons = totalAssignedWeight(fittingBookings);
|
||||
// Every weight limit below (global max, loco pull) is a GROSS axis, so the
|
||||
// figure spent against it must be gross too — cargo alone under-reports the
|
||||
// train by the full consist tare and disagrees with the assign path.
|
||||
const totalTareTons = roundTons(
|
||||
wagonPlan.reduce((sum, w) => sum + (Number(w.tareWeightTons) || 0), 0),
|
||||
);
|
||||
@@ -4091,12 +4103,13 @@ export class TrainSchedulingService {
|
||||
const totalLengthMeters = roundTons(
|
||||
wagonPlan.reduce((sum, w) => sum + w.lengthMeters, 0),
|
||||
);
|
||||
if (grossWeightTons > trainLimits.maxWeightTons) {
|
||||
const message = `Total gross weight ${grossWeightTons}T (${totalWeightTons}T cargo + ${totalTareTons}T wagon tare) exceeds max train weight ${trainLimits.maxWeightTons}T`;
|
||||
if (!violations.includes(message) && !warnings.includes(message)) {
|
||||
pushLimit([message]);
|
||||
}
|
||||
}
|
||||
// Weight/length limits are enforced PER EDGE by validateMixedTrainLimitsPerEdge
|
||||
// above — the whole-route totals here are informational (summary) only. The
|
||||
// locomotive checks below also compare the heaviest single edge: a train is
|
||||
// never heavier than its heaviest leg, so disjoint legs must not be summed.
|
||||
const edgeUsage = maxEdgeConsistUsage(wagonPlan, stops);
|
||||
const maxEdgeGrossTons = roundTons(edgeUsage.grossWeightTons);
|
||||
const maxEdgeLengthMeters = roundTons(edgeUsage.lengthMeters);
|
||||
|
||||
let assignedLocomotives: Locomotive[] = [];
|
||||
if (targetScheduleId) {
|
||||
@@ -4119,9 +4132,9 @@ export class TrainSchedulingService {
|
||||
if (
|
||||
setLimits &&
|
||||
(setLimits.maxPullWeightTons + (Number(setLimits.overageToleranceTons) || 0) <
|
||||
grossWeightTons ||
|
||||
maxEdgeGrossTons ||
|
||||
setLimits.maxTrainLengthMeters + (Number(setLimits.overageToleranceMeters) || 0) <
|
||||
totalLengthMeters)
|
||||
maxEdgeLengthMeters)
|
||||
) {
|
||||
pushLimit([
|
||||
'Assigned locomotives cannot support the total train weight and length',
|
||||
@@ -4140,9 +4153,9 @@ export class TrainSchedulingService {
|
||||
!inServiceLocomotives.some(
|
||||
(l) =>
|
||||
Number(l.maxPullWeightTons) + (Number(l.overageToleranceTons) || 0) >=
|
||||
grossWeightTons &&
|
||||
maxEdgeGrossTons &&
|
||||
Number(l.maxTrainLengthMeters) + (Number(l.overageToleranceMeters) || 0) >=
|
||||
totalLengthMeters,
|
||||
maxEdgeLengthMeters,
|
||||
)
|
||||
) {
|
||||
pushLimit(['No locomotive can support the total train weight and length']);
|
||||
@@ -4201,10 +4214,7 @@ export class TrainSchedulingService {
|
||||
maxTrainLengthMeters?: number;
|
||||
maxWagonsPerTrain?: number;
|
||||
},
|
||||
locomotive?: Pick<
|
||||
Locomotive,
|
||||
'maxPullWeightTons' | 'maxTrainLengthMeters' | 'overageToleranceTons' | 'overageToleranceMeters'
|
||||
>,
|
||||
locomotive?: LocomotiveLimits | null,
|
||||
): Promise<Required<TrainLimitConfig>> {
|
||||
const row = await this.loadGlobalRulesRow();
|
||||
const configured = this.configService?.get<{
|
||||
@@ -6512,7 +6522,7 @@ export class TrainSchedulingService {
|
||||
>,
|
||||
tareDims: Awaited<ReturnType<TrainSchedulingService['loadWagonTareDims']>>,
|
||||
): number {
|
||||
const cargo = Number(booking.cargoTotalWeightVgm ?? 0);
|
||||
const cargo = bookingCargoTons(booking);
|
||||
const fallback =
|
||||
booking.freightType === 'BULK' ? tareDims.bulk : tareDims.container;
|
||||
// Same first-configured-type resolution the batch engine's dimsFor uses.
|
||||
@@ -6878,6 +6888,18 @@ export class TrainSchedulingService {
|
||||
// Ordered corridor stops (route milestones; falls back to the two
|
||||
// endpoints) — lets the UI draw per-segment occupancy and label legs.
|
||||
stops: this.mapScheduleStops(schedule),
|
||||
// Gross ceiling the validator holds each leg to: the set's weakest
|
||||
// locomotive pull limit plus its overage tolerance. Booking weightTons
|
||||
// above are gross too, so the strip can sum them per leg against this.
|
||||
maxGrossWeightTons: (() => {
|
||||
const setLimits = trainSetLocomotiveLimits(schedule.trainSet);
|
||||
return setLimits
|
||||
? roundTons(
|
||||
Number(setLimits.maxPullWeightTons) +
|
||||
(Number(setLimits.overageToleranceTons) || 0),
|
||||
)
|
||||
: null;
|
||||
})(),
|
||||
// True when the wagon plan above is served from the frozen snapshot (schedule
|
||||
// is dispatched/arrived/cancelled) rather than the live joins — the UI can badge
|
||||
// it "historical" and skip re-pin affordances.
|
||||
@@ -6973,7 +6995,10 @@ export class TrainSchedulingService {
|
||||
originStationId: schedule.originStationId,
|
||||
destinationStationId: schedule.destinationStationId,
|
||||
};
|
||||
const limits = await this.resolveTrainLimitConfig(undefined, schedule.trainSet.locomotive);
|
||||
const limits = await this.resolveTrainLimitConfig(
|
||||
undefined,
|
||||
trainSetLocomotiveLimits(schedule.trainSet),
|
||||
);
|
||||
|
||||
const validation = await this.validateBookingsForScheduling(
|
||||
previewDto,
|
||||
@@ -7099,7 +7124,7 @@ export class TrainSchedulingService {
|
||||
};
|
||||
const limits = await this.resolveTrainLimitConfig(
|
||||
undefined,
|
||||
schedule.trainSet.locomotive,
|
||||
trainSetLocomotiveLimits(schedule.trainSet),
|
||||
);
|
||||
|
||||
let validation: Awaited<ReturnType<TrainSchedulingService['validateBookingsForScheduling']>>;
|
||||
@@ -7643,7 +7668,7 @@ export class TrainSchedulingService {
|
||||
};
|
||||
const limits = await this.resolveTrainLimitConfig(
|
||||
undefined,
|
||||
schedule.trainSet.locomotive,
|
||||
trainSetLocomotiveLimits(schedule.trainSet),
|
||||
);
|
||||
|
||||
let validation: Awaited<ReturnType<TrainSchedulingService['validateBookingsForScheduling']>>;
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
containerWagonsForLines,
|
||||
expandBookingContainerUnits,
|
||||
expandContainerItems,
|
||||
maxEdgeConsistUsage,
|
||||
roundTons,
|
||||
sumWagonsRequired,
|
||||
validate20ftContainerRules,
|
||||
@@ -279,3 +280,54 @@ describe('containerWagonsForLines — TEU-aware, ceil booking total once', () =>
|
||||
expect(containerWagonsForLines([])).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('maxEdgeConsistUsage — the binding edge, not the whole-route sum', () => {
|
||||
const slot = (
|
||||
tare: number,
|
||||
cargo: number,
|
||||
length: number,
|
||||
board?: string | null,
|
||||
alight?: string | null,
|
||||
) =>
|
||||
({
|
||||
tareWeightTons: tare,
|
||||
assignedWeightTons: cargo,
|
||||
lengthMeters: length,
|
||||
boardYardId: board ?? null,
|
||||
alightYardId: alight ?? null,
|
||||
}) as never;
|
||||
|
||||
const stops = ['a', 'b', 'c'];
|
||||
|
||||
it('does not sum disjoint legs: intercity a→b + export b→c', () => {
|
||||
const plan = [
|
||||
slot(24, 65, 14, null, 'b'), // intercity, rides a→b only
|
||||
slot(24, 65, 14, 'b', null), // export, rides b→c only
|
||||
];
|
||||
// Each edge carries one slot: 89T gross / 14m — never 178T.
|
||||
expect(maxEdgeConsistUsage(plan, stops)).toEqual({
|
||||
grossWeightTons: 89,
|
||||
lengthMeters: 14,
|
||||
});
|
||||
});
|
||||
|
||||
it('sums overlapping legs on their shared edge (the S-2026-00024 shape)', () => {
|
||||
// 20 intercity a→b wagons + 20 export a→c wagons, 23.94T tare, 64.75T cargo:
|
||||
// shared edge a→b carries all 40 slots = 3547.6T gross.
|
||||
const plan = [
|
||||
...Array.from({ length: 20 }, () => slot(23.94, 64.75, 14, null, 'b')),
|
||||
...Array.from({ length: 20 }, () => slot(23.94, 64.75, 14, null, null)),
|
||||
];
|
||||
const usage = maxEdgeConsistUsage(plan, stops);
|
||||
expect(usage.grossWeightTons).toBeCloseTo(3547.6, 1);
|
||||
expect(usage.lengthMeters).toBe(560);
|
||||
});
|
||||
|
||||
it('degrades to whole-train totals on a two-stop route', () => {
|
||||
const plan = [slot(24, 65, 14), slot(24, 65, 14)];
|
||||
expect(maxEdgeConsistUsage(plan, ['a', 'b'])).toEqual({
|
||||
grossWeightTons: 178,
|
||||
lengthMeters: 28,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -539,15 +539,9 @@ export function validateMixedTrainLimitsPerEdge(
|
||||
stops: string[],
|
||||
): string[] {
|
||||
if (stops.length <= 2) return validateMixedTrainLimits(wagonPlan, wagonTypes, limits);
|
||||
const lastIdx = stops.length - 1;
|
||||
const spans = wagonPlan.map((slot) => {
|
||||
const from = slot.boardYardId ? stops.indexOf(slot.boardYardId) : 0;
|
||||
const to = slot.alightYardId ? stops.indexOf(slot.alightYardId) : lastIdx;
|
||||
// A yard missing from the stop list keeps the slot on the whole route.
|
||||
return { from: from >= 0 ? from : 0, to: to > 0 ? to : lastIdx };
|
||||
});
|
||||
const spans = slotSpans(wagonPlan, stops);
|
||||
const violations = new Set<string>();
|
||||
for (let edge = 0; edge < lastIdx; edge += 1) {
|
||||
for (let edge = 0; edge < stops.length - 1; edge += 1) {
|
||||
const active = wagonPlan.filter(
|
||||
(_, i) => spans[i].from <= edge && edge < spans[i].to,
|
||||
);
|
||||
@@ -559,6 +553,52 @@ export function validateMixedTrainLimitsPerEdge(
|
||||
return [...violations];
|
||||
}
|
||||
|
||||
/** Per-slot stop-index spans; a yard missing from the stop list keeps the slot on the whole route. */
|
||||
function slotSpans(
|
||||
wagonPlan: WagonPlanSlot[],
|
||||
stops: string[],
|
||||
): Array<{ from: number; to: number }> {
|
||||
const lastIdx = stops.length - 1;
|
||||
return wagonPlan.map((slot) => {
|
||||
const from = slot.boardYardId ? stops.indexOf(slot.boardYardId) : 0;
|
||||
const to = slot.alightYardId ? stops.indexOf(slot.alightYardId) : lastIdx;
|
||||
return { from: from >= 0 ? from : 0, to: to > 0 ? to : lastIdx };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The corridor's binding edge: gross tons (tare + assigned cargo) and length
|
||||
* summed over only the slots riding each edge, maxed across edges. This is the
|
||||
* figure a locomotive pull/length limit must be compared against — a train is
|
||||
* never heavier than its heaviest single leg, so summing disjoint legs
|
||||
* (intercity Gelan→Adama + export Adama→Doraleh) over-reports the train.
|
||||
* Two stops or fewer degrade to the whole-train totals.
|
||||
*/
|
||||
export function maxEdgeConsistUsage(
|
||||
wagonPlan: WagonPlanSlot[],
|
||||
stops: string[],
|
||||
): { grossWeightTons: number; lengthMeters: number } {
|
||||
const totals = (slots: WagonPlanSlot[]) => ({
|
||||
grossWeightTons: slots.reduce(
|
||||
(sum, w) =>
|
||||
sum + Number(w.tareWeightTons ?? 0) + Number(w.assignedWeightTons ?? 0),
|
||||
0,
|
||||
),
|
||||
lengthMeters: slots.reduce((sum, w) => sum + Number(w.lengthMeters ?? 0), 0),
|
||||
});
|
||||
if (stops.length <= 2) return totals(wagonPlan);
|
||||
const spans = slotSpans(wagonPlan, stops);
|
||||
const usage = { grossWeightTons: 0, lengthMeters: 0 };
|
||||
for (let edge = 0; edge < stops.length - 1; edge += 1) {
|
||||
const active = totals(
|
||||
wagonPlan.filter((_, i) => spans[i].from <= edge && edge < spans[i].to),
|
||||
);
|
||||
usage.grossWeightTons = Math.max(usage.grossWeightTons, active.grossWeightTons);
|
||||
usage.lengthMeters = Math.max(usage.lengthMeters, active.lengthMeters);
|
||||
}
|
||||
return usage;
|
||||
}
|
||||
|
||||
export function validate20ftContainerRules(
|
||||
units: ContainerUnitRow[],
|
||||
placements: ContainerPlacementInput[],
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { FleetManage, FleetView } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { AssignTrainWagonsDto } from './dto/assign-train-wagons.dto';
|
||||
import { BuildTrainDto } from './dto/build-train.dto';
|
||||
import { ListBuiltTrainsQueryDto } from './dto/list-built-trains-query.dto';
|
||||
@@ -27,12 +28,12 @@ import { TrainBuilderService } from './train-builder.service';
|
||||
@ApiTags('train-builder')
|
||||
@ApiBearerAuth()
|
||||
@Controller('train-builder')
|
||||
@FleetView()
|
||||
@FleetView(FREIGHT_PERMS.trains.view)
|
||||
export class TrainBuilderController {
|
||||
constructor(private readonly trainBuilderService: TrainBuilderService) {}
|
||||
|
||||
@Post()
|
||||
@FleetManage()
|
||||
@FleetManage(FREIGHT_PERMS.trains.create)
|
||||
@ApiOperation({ summary: 'Build a train: code + yard + 2+ locomotives (+ optional wagons)' })
|
||||
build(@Body() dto: BuildTrainDto) {
|
||||
return this.trainBuilderService.buildTrain(dto);
|
||||
@@ -60,7 +61,7 @@ export class TrainBuilderController {
|
||||
}
|
||||
|
||||
@Put(':id/locomotives')
|
||||
@FleetManage()
|
||||
@FleetManage(FREIGHT_PERMS.trains.update)
|
||||
@ApiOperation({ summary: 'Replace the locomotive set (minimum 2, same yard)' })
|
||||
setLocomotives(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@@ -70,7 +71,7 @@ export class TrainBuilderController {
|
||||
}
|
||||
|
||||
@Patch(':id/details')
|
||||
@FleetManage()
|
||||
@FleetManage(FREIGHT_PERMS.trains.update)
|
||||
@ApiOperation({
|
||||
summary: "Edit the train's name and fixed import/export run numbers",
|
||||
})
|
||||
@@ -82,7 +83,7 @@ export class TrainBuilderController {
|
||||
}
|
||||
|
||||
@Patch(':id/yard')
|
||||
@FleetManage()
|
||||
@FleetManage(FREIGHT_PERMS.trains.update)
|
||||
@ApiOperation({
|
||||
summary: 'Relocate the train — its locomotives and wagons move to the new yard with it',
|
||||
})
|
||||
@@ -91,14 +92,14 @@ export class TrainBuilderController {
|
||||
}
|
||||
|
||||
@Post(':id/wagons')
|
||||
@FleetManage()
|
||||
@FleetManage(FREIGHT_PERMS.trains.assignWagons)
|
||||
@ApiOperation({ summary: "Append AVAILABLE wagons from the train's yard to the consist" })
|
||||
assignWagons(@Param('id', ParseUUIDPipe) id: string, @Body() dto: AssignTrainWagonsDto) {
|
||||
return this.trainBuilderService.assignWagons(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id/wagons/:wagonId')
|
||||
@FleetManage()
|
||||
@FleetManage(FREIGHT_PERMS.trains.assignWagons)
|
||||
@ApiOperation({ summary: 'Detach one wagon from the consist' })
|
||||
removeWagon(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@@ -108,7 +109,7 @@ export class TrainBuilderController {
|
||||
}
|
||||
|
||||
@Post(':id/wagons/:wagonId/maintenance')
|
||||
@FleetManage()
|
||||
@FleetManage(FREIGHT_PERMS.trains.assignWagons)
|
||||
@ApiOperation({ summary: 'Detach one wagon and move it to MAINTENANCE status' })
|
||||
sendWagonToMaintenance(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@@ -118,14 +119,14 @@ export class TrainBuilderController {
|
||||
}
|
||||
|
||||
@Post(':id/reorder-wagons')
|
||||
@FleetManage()
|
||||
@FleetManage(FREIGHT_PERMS.trains.assignWagons)
|
||||
@ApiOperation({ summary: 'Persist a drag-reorder of the full consist' })
|
||||
reorderWagons(@Param('id', ParseUUIDPipe) id: string, @Body() dto: ReorderTrainWagonsDto) {
|
||||
return this.trainBuilderService.reorderWagons(id, dto);
|
||||
}
|
||||
|
||||
@Post(':id/deactivate')
|
||||
@FleetManage()
|
||||
@FleetManage(FREIGHT_PERMS.trains.update)
|
||||
@ApiOperation({
|
||||
summary: 'Deactivate the train (park it) — only allowed with no active schedule',
|
||||
})
|
||||
@@ -134,14 +135,14 @@ export class TrainBuilderController {
|
||||
}
|
||||
|
||||
@Post(':id/activate')
|
||||
@FleetManage()
|
||||
@FleetManage(FREIGHT_PERMS.trains.update)
|
||||
@ApiOperation({ summary: 'Reactivate a deactivated train back to AVAILABLE' })
|
||||
activate(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.trainBuilderService.activate(id);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@FleetManage()
|
||||
@FleetManage(FREIGHT_PERMS.trains.delete)
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: 'Disband the train (release wagons and locomotives)' })
|
||||
disband(@Param('id', ParseUUIDPipe) id: string) {
|
||||
|
||||
@@ -12,18 +12,19 @@ import {
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
|
||||
import { FleetManage, FleetView } from "../../common/booking-guards";
|
||||
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||
import { CreateTrainDto } from "./dto/create-train.dto";
|
||||
import { UpdateTrainDto } from "./dto/update-train.dto";
|
||||
import { TrainsService } from "./trains.service";
|
||||
|
||||
@ApiTags("trains")
|
||||
@Controller("trains")
|
||||
@FleetView()
|
||||
@FleetView(FREIGHT_PERMS.trains.view)
|
||||
export class TrainsController {
|
||||
constructor(private readonly trainsService: TrainsService) {}
|
||||
|
||||
@Post()
|
||||
@FleetManage()
|
||||
@FleetManage(FREIGHT_PERMS.trains.create)
|
||||
@ApiOperation({ summary: "Register a new train" })
|
||||
create(@Body() dto: CreateTrainDto) {
|
||||
return this.trainsService.create(dto);
|
||||
@@ -42,14 +43,14 @@ export class TrainsController {
|
||||
}
|
||||
|
||||
@Patch(":id")
|
||||
@FleetManage()
|
||||
@FleetManage(FREIGHT_PERMS.trains.update)
|
||||
@ApiOperation({ summary: "Update a train" })
|
||||
update(@Param("id", ParseUUIDPipe) id: string, @Body() dto: UpdateTrainDto) {
|
||||
return this.trainsService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(":id")
|
||||
@FleetManage()
|
||||
@FleetManage(FREIGHT_PERMS.trains.delete)
|
||||
@ApiOperation({ summary: "Delete a train" })
|
||||
remove(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.trainsService.remove(id);
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
WagonTransferHistoryAll,
|
||||
WagonTransferRequest,
|
||||
} from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { BulkFulfillTransferRequestsDto } from './dto/bulk-fulfill-transfer-requests.dto';
|
||||
import { CreateTransferRequestDto } from './dto/create-transfer-request.dto';
|
||||
import { FulfillTransferRequestDto } from './dto/fulfill-transfer-request.dto';
|
||||
@@ -31,7 +32,7 @@ import { WagonTransferRequestsService } from './wagon-transfer-requests.service'
|
||||
*/
|
||||
@ApiTags('wagon-transfer-requests')
|
||||
@Controller('wagon-transfer-requests')
|
||||
@FleetView()
|
||||
@FleetView(FREIGHT_PERMS.wagons.view)
|
||||
export class WagonTransferRequestsController {
|
||||
constructor(private readonly service: WagonTransferRequestsService) {}
|
||||
|
||||
@@ -110,7 +111,7 @@ export class WagonTransferRequestsController {
|
||||
}
|
||||
|
||||
@Post(':id/cancel')
|
||||
@FleetManage()
|
||||
@FleetManage(FREIGHT_PERMS.wagons.transferRequest)
|
||||
@ApiOperation({ summary: 'Withdraw a pending transfer request' })
|
||||
cancel(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.service.cancelRequest(id);
|
||||
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentUser } from '@edr/api-common';
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
import { FleetManage, FleetView } from '../../common/booking-guards';
|
||||
import { FleetManage, FleetView, StaffReference } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { CreateWagonDto } from './dto/create-wagon.dto';
|
||||
import { ListWagonsQueryDto } from './dto/list-wagons-query.dto';
|
||||
import { UpdateWagonDto } from './dto/update-wagon.dto';
|
||||
@@ -23,31 +24,36 @@ import { BulkSetWagonStatusDto } from './dto/bulk-set-wagon-status.dto';
|
||||
import { WagonsService } from './wagons.service';
|
||||
|
||||
@ApiTags('wagons')
|
||||
// No class-level guard: reads (list, by-id, movements) are login-only reference
|
||||
// data — any staff can fetch wagon data for a cross-flow view without the
|
||||
// fleet:view that drives the Fleet sidebar. Every mutation has its @FleetManage().
|
||||
@Controller('wagons')
|
||||
@FleetView()
|
||||
export class WagonsController {
|
||||
constructor(private readonly wagonsService: WagonsService) {}
|
||||
|
||||
@Post()
|
||||
@FleetManage()
|
||||
@FleetManage(FREIGHT_PERMS.wagons.create)
|
||||
@ApiOperation({ summary: 'Create a new wagon' })
|
||||
create(@Body() dto: CreateWagonDto) {
|
||||
return this.wagonsService.create(dto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@StaffReference()
|
||||
@ApiOperation({ summary: 'List all wagons' })
|
||||
findAll(@Query() query: ListWagonsQueryDto) {
|
||||
return this.wagonsService.findAll(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@StaffReference()
|
||||
@ApiOperation({ summary: 'Get a wagon by ID' })
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.wagonsService.findById(id);
|
||||
}
|
||||
|
||||
@Get(':id/movements')
|
||||
@StaffReference()
|
||||
@ApiOperation({
|
||||
summary: "Wagon movement ledger (loaded legs, empty repositions, manual moves), newest first",
|
||||
})
|
||||
@@ -56,42 +62,42 @@ export class WagonsController {
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@FleetManage()
|
||||
@FleetManage(FREIGHT_PERMS.wagons.update)
|
||||
@ApiOperation({ summary: 'Update a wagon' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWagonDto) {
|
||||
return this.wagonsService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@FleetManage()
|
||||
@FleetManage(FREIGHT_PERMS.wagons.delete)
|
||||
@ApiOperation({ summary: 'Delete a wagon' })
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.wagonsService.remove(id);
|
||||
}
|
||||
|
||||
@Post(':id/assign-train')
|
||||
@FleetManage()
|
||||
@FleetManage(FREIGHT_PERMS.trains.assignWagons)
|
||||
@ApiOperation({ summary: 'Assign wagon to a train' })
|
||||
assignToTrain(@Param('id', ParseUUIDPipe) id: string, @Body() dto: AssignWagonToTrainDto) {
|
||||
return this.wagonsService.assignToTrain(id, dto);
|
||||
}
|
||||
|
||||
@Post(':id/unassign-train')
|
||||
@FleetManage()
|
||||
@FleetManage(FREIGHT_PERMS.trains.assignWagons)
|
||||
@ApiOperation({ summary: 'Unassign wagon from train' })
|
||||
unassignFromTrain(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.wagonsService.unassignFromTrain(id);
|
||||
}
|
||||
|
||||
@Post('bulk-transfer')
|
||||
@FleetManage()
|
||||
@FleetManage(FREIGHT_PERMS.wagons.update)
|
||||
@ApiOperation({ summary: 'Transfer multiple wagons to a destination yard' })
|
||||
bulkTransfer(@Body() dto: BulkTransferWagonsDto, @CurrentUser() user: TCurrentUser) {
|
||||
return this.wagonsService.bulkTransfer(dto, user?.id);
|
||||
}
|
||||
|
||||
@Post('bulk-status')
|
||||
@FleetManage()
|
||||
@FleetManage(FREIGHT_PERMS.wagons.update)
|
||||
@ApiOperation({ summary: 'Set the status of multiple wagons' })
|
||||
bulkSetStatus(@Body() dto: BulkSetWagonStatusDto) {
|
||||
return this.wagonsService.bulkSetStatus(dto);
|
||||
@@ -100,12 +106,12 @@ export class WagonsController {
|
||||
|
||||
// Separate controller for train‑specific reorder (registered in module)
|
||||
@Controller('trains/:trainId/reorder-wagons')
|
||||
@FleetView()
|
||||
@FleetView(FREIGHT_PERMS.trains.view)
|
||||
export class TrainWagonsReorderController {
|
||||
constructor(private readonly wagonsService: WagonsService) {}
|
||||
|
||||
@Post()
|
||||
@FleetManage()
|
||||
@FleetManage(FREIGHT_PERMS.trains.assignWagons)
|
||||
@ApiOperation({ summary: 'Reorder wagons of a train' })
|
||||
reorder(@Param('trainId', ParseUUIDPipe) trainId: string, @Body() dto: ReorderWagonsDto) {
|
||||
return this.wagonsService.reorderWagons(trainId, dto);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2504,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;
|
||||
@@ -4087,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);
|
||||
@@ -4114,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,
|
||||
@@ -4149,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;
|
||||
@@ -4181,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) {
|
||||
|
||||
@@ -70,9 +70,15 @@ export const BOOKING_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
*/
|
||||
export const CONTRACT_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
perm('a3000001-0001-4000-8000-000000000001', 'edr_freight_app:contracts:view', 'View contracts'),
|
||||
perm('a3000001-0001-4000-8000-000000000002', 'edr_freight_app:contracts:staff_accept', 'Accept contract intake'),
|
||||
perm('a3000001-0001-4000-8000-000000000003', 'edr_freight_app:contracts:request_changes', 'Request contract changes'),
|
||||
perm('a3000001-0001-4000-8000-000000000004', 'edr_freight_app:contracts:reject', 'Reject contract'),
|
||||
// Intake actions are split per freight type (bulk vs container) — fresh ids
|
||||
// because the seeder upserts ON CONFLICT (key); reusing the old ids with new
|
||||
// keys would PK-collide with the legacy staff_accept/request_changes/reject rows.
|
||||
perm('a3000001-0001-4000-8000-000000000011', 'edr_freight_app:contracts:staff_accept:bulk', 'Accept bulk contract intake'),
|
||||
perm('a3000001-0001-4000-8000-000000000012', 'edr_freight_app:contracts:staff_accept:container', 'Accept container contract intake'),
|
||||
perm('a3000001-0001-4000-8000-000000000013', 'edr_freight_app:contracts:request_changes:bulk', 'Request bulk contract changes'),
|
||||
perm('a3000001-0001-4000-8000-000000000014', 'edr_freight_app:contracts:request_changes:container', 'Request container contract changes'),
|
||||
perm('a3000001-0001-4000-8000-000000000015', 'edr_freight_app:contracts:reject:bulk', 'Reject bulk contract'),
|
||||
perm('a3000001-0001-4000-8000-000000000016', 'edr_freight_app:contracts:reject:container', 'Reject container contract'),
|
||||
perm('a3000001-0001-4000-8000-000000000005', 'edr_freight_app:contracts:approve_line_staff', 'Approve contract as line staff'),
|
||||
perm('a3000001-0001-4000-8000-000000000006', 'edr_freight_app:contracts:approve_director', 'Approve contract as director'),
|
||||
perm('a3000001-0001-4000-8000-000000000007', 'edr_freight_app:contracts:approve_ceo', 'Approve contract as CEO'),
|
||||
@@ -212,6 +218,10 @@ export const FLEET_RAIL_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
perm('e1f00001-0001-4000-8000-000000000002', 'edr_freight_app:cargoes:create', 'Create cargo'),
|
||||
perm('e1f00001-0001-4000-8000-000000000003', 'edr_freight_app:cargoes:update', 'Update cargo'),
|
||||
perm('e1f00001-0001-4000-8000-000000000004', 'edr_freight_app:cargoes:delete', 'Delete cargo'),
|
||||
// NB: id prefixes must stay hex — 'e1g…' once crashed the boot seeder
|
||||
// (postgres: invalid input syntax for type uuid).
|
||||
perm('e1900001-0001-4000-8000-000000000001', 'edr_freight_app:consignments:view', 'View consignments'),
|
||||
perm('e1900001-0001-4000-8000-000000000002', 'edr_freight_app:consignments:create', 'Create consignment'),
|
||||
];
|
||||
|
||||
// G. Fleet — road & telemetry
|
||||
@@ -302,8 +312,6 @@ export const SCHEDULING_EXTRA_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
|
||||
// L. Administration & settings (split from the coarse admin umbrella)
|
||||
export const CONFIG_SETTINGS_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
perm('b3a00001-0001-4000-8000-000000000001', 'edr_freight_app:config:contract_validity:view', 'View contract validity periods'),
|
||||
perm('b3a00001-0001-4000-8000-000000000002', 'edr_freight_app:config:contract_validity:manage', 'Manage contract validity periods'),
|
||||
perm('b4a00001-0001-4000-8000-000000000001', 'edr_freight_app:settings:file_upload:view', 'View file-upload settings'),
|
||||
perm('b4a00001-0001-4000-8000-000000000002', 'edr_freight_app:settings:file_upload:manage', 'Manage file-upload settings'),
|
||||
perm('b4b00001-0001-4000-8000-000000000001', 'edr_freight_app:settings:dropdown:view', 'View dropdown settings'),
|
||||
@@ -371,9 +379,18 @@ export const FREIGHT_PERMS = {
|
||||
},
|
||||
contracts: {
|
||||
view: 'edr_freight_app:contracts:view',
|
||||
staffAccept: 'edr_freight_app:contracts:staff_accept',
|
||||
requestChanges: 'edr_freight_app:contracts:request_changes',
|
||||
reject: 'edr_freight_app:contracts:reject',
|
||||
staffAccept: {
|
||||
bulk: 'edr_freight_app:contracts:staff_accept:bulk',
|
||||
container: 'edr_freight_app:contracts:staff_accept:container',
|
||||
},
|
||||
requestChanges: {
|
||||
bulk: 'edr_freight_app:contracts:request_changes:bulk',
|
||||
container: 'edr_freight_app:contracts:request_changes:container',
|
||||
},
|
||||
reject: {
|
||||
bulk: 'edr_freight_app:contracts:reject:bulk',
|
||||
container: 'edr_freight_app:contracts:reject:container',
|
||||
},
|
||||
approveLineStaff: 'edr_freight_app:contracts:approve_line_staff',
|
||||
approveDirector: 'edr_freight_app:contracts:approve_director',
|
||||
approveCeo: 'edr_freight_app:contracts:approve_ceo',
|
||||
@@ -495,6 +512,10 @@ export const FREIGHT_PERMS = {
|
||||
update: 'edr_freight_app:cargoes:update',
|
||||
delete: 'edr_freight_app:cargoes:delete',
|
||||
},
|
||||
consignments: {
|
||||
view: 'edr_freight_app:consignments:view',
|
||||
create: 'edr_freight_app:consignments:create',
|
||||
},
|
||||
vehicles: {
|
||||
view: 'edr_freight_app:vehicles:view',
|
||||
create: 'edr_freight_app:vehicles:create',
|
||||
@@ -594,12 +615,6 @@ export const FREIGHT_PERMS = {
|
||||
cancel: 'edr_freight_app:warehouse_fee_invoices:cancel',
|
||||
pay: 'edr_freight_app:warehouse_fee_invoices:pay',
|
||||
},
|
||||
config: {
|
||||
contractValidity: {
|
||||
view: 'edr_freight_app:config:contract_validity:view',
|
||||
manage: 'edr_freight_app:config:contract_validity:manage',
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
fileUpload: {
|
||||
view: 'edr_freight_app:settings:file_upload:view',
|
||||
@@ -661,9 +676,56 @@ export const FREIGHT_PERMS = {
|
||||
},
|
||||
} as const;
|
||||
|
||||
/** Both arms of a freight-type-split permission (for one-of route guards). */
|
||||
export const bothFreightTypes = (p: { bulk: string; container: string }): string[] => [
|
||||
p.bulk,
|
||||
p.container,
|
||||
];
|
||||
|
||||
/** The arm of a freight-type-split permission matching a contract's freightType. */
|
||||
export const forFreightType = (
|
||||
p: { bulk: string; container: string },
|
||||
freightType: string,
|
||||
): string => (freightType === 'BULK' ? p.bulk : p.container);
|
||||
|
||||
const allRuleEngineViewKeys = () =>
|
||||
RULE_ENGINE_RESOURCE_SLUGS.map((s) => FREIGHT_PERMS.ruleEngine.view(s));
|
||||
|
||||
/**
|
||||
* Granular equivalents of the legacy fleet:view + fleet:manage pair.
|
||||
* Deliberately excludes the wagon-transfer keys — those were always separate
|
||||
* grants (requester vs OCC vs admin history), not part of fleet:manage.
|
||||
*/
|
||||
const FLEET_GRANULAR_KEYS: string[] = [
|
||||
FREIGHT_PERMS.locomotives.view,
|
||||
FREIGHT_PERMS.locomotives.create,
|
||||
FREIGHT_PERMS.locomotives.update,
|
||||
FREIGHT_PERMS.locomotives.delete,
|
||||
FREIGHT_PERMS.wagons.view,
|
||||
FREIGHT_PERMS.wagons.create,
|
||||
FREIGHT_PERMS.wagons.update,
|
||||
FREIGHT_PERMS.wagons.delete,
|
||||
FREIGHT_PERMS.trains.view,
|
||||
FREIGHT_PERMS.trains.create,
|
||||
FREIGHT_PERMS.trains.update,
|
||||
FREIGHT_PERMS.trains.delete,
|
||||
FREIGHT_PERMS.trains.assignWagons,
|
||||
FREIGHT_PERMS.routes.view,
|
||||
FREIGHT_PERMS.routes.create,
|
||||
FREIGHT_PERMS.routes.update,
|
||||
FREIGHT_PERMS.routes.delete,
|
||||
FREIGHT_PERMS.containers.view,
|
||||
FREIGHT_PERMS.containers.create,
|
||||
FREIGHT_PERMS.containers.update,
|
||||
FREIGHT_PERMS.containers.delete,
|
||||
FREIGHT_PERMS.cargoes.view,
|
||||
FREIGHT_PERMS.cargoes.create,
|
||||
FREIGHT_PERMS.cargoes.update,
|
||||
FREIGHT_PERMS.cargoes.delete,
|
||||
FREIGHT_PERMS.consignments.view,
|
||||
FREIGHT_PERMS.consignments.create,
|
||||
];
|
||||
|
||||
export const ROLE_PERMISSION_PRESETS = {
|
||||
// Marketing / line staff: drives a booking from intake through line-staff
|
||||
// approval and contract generation/signing — i.e. until the contract is ready
|
||||
@@ -677,9 +739,9 @@ export const ROLE_PERMISSION_PRESETS = {
|
||||
FREIGHT_PERMS.bookings.rejectApproval,
|
||||
FREIGHT_PERMS.bookings.cancel,
|
||||
FREIGHT_PERMS.contracts.view,
|
||||
FREIGHT_PERMS.contracts.staffAccept,
|
||||
FREIGHT_PERMS.contracts.requestChanges,
|
||||
FREIGHT_PERMS.contracts.reject,
|
||||
...bothFreightTypes(FREIGHT_PERMS.contracts.staffAccept),
|
||||
...bothFreightTypes(FREIGHT_PERMS.contracts.requestChanges),
|
||||
...bothFreightTypes(FREIGHT_PERMS.contracts.reject),
|
||||
FREIGHT_PERMS.contracts.approveLineStaff,
|
||||
...allRuleEngineViewKeys(),
|
||||
],
|
||||
@@ -692,6 +754,7 @@ export const ROLE_PERMISSION_PRESETS = {
|
||||
FREIGHT_PERMS.trainScheduling.manage,
|
||||
FREIGHT_PERMS.fleet.view,
|
||||
FREIGHT_PERMS.fleet.manage,
|
||||
...FLEET_GRANULAR_KEYS,
|
||||
// Path A (no customs): Operations reviews the customer's self-clearance docs
|
||||
// — on the contract for ONE_TIME contracts, and PER BOOKING for GENERAL
|
||||
// contracts (booking-level document review → finalize → CLEARANCE_READY).
|
||||
@@ -768,9 +831,9 @@ export const ROLE_PERMISSION_PRESETS = {
|
||||
FREIGHT_PERMS.bookings.reviewDocuments,
|
||||
FREIGHT_PERMS.bookings.finalizeClearance,
|
||||
FREIGHT_PERMS.contracts.view,
|
||||
FREIGHT_PERMS.contracts.staffAccept,
|
||||
FREIGHT_PERMS.contracts.requestChanges,
|
||||
FREIGHT_PERMS.contracts.reject,
|
||||
...bothFreightTypes(FREIGHT_PERMS.contracts.staffAccept),
|
||||
...bothFreightTypes(FREIGHT_PERMS.contracts.requestChanges),
|
||||
...bothFreightTypes(FREIGHT_PERMS.contracts.reject),
|
||||
FREIGHT_PERMS.contracts.approveLineStaff,
|
||||
FREIGHT_PERMS.contracts.generateContract,
|
||||
FREIGHT_PERMS.contracts.signStaff,
|
||||
|
||||
@@ -271,19 +271,19 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
label: "Routes",
|
||||
href: "/dashboard/routes",
|
||||
icon: <Network />,
|
||||
permission: FREIGHT_PERMS.fleet.view,
|
||||
permission: [FREIGHT_PERMS.routes.view, FREIGHT_PERMS.fleet.view],
|
||||
},
|
||||
{
|
||||
label: "Locomotives",
|
||||
href: "/dashboard/locomotives",
|
||||
icon: <Train />,
|
||||
permission: FREIGHT_PERMS.fleet.view,
|
||||
permission: [FREIGHT_PERMS.locomotives.view, FREIGHT_PERMS.fleet.view],
|
||||
},
|
||||
{
|
||||
label: "Train Builder",
|
||||
href: "/dashboard/train-builder",
|
||||
icon: <Hammer />,
|
||||
permission: FREIGHT_PERMS.fleet.view,
|
||||
permission: [FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view],
|
||||
},
|
||||
|
||||
// {
|
||||
@@ -295,7 +295,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
label: "Wagons",
|
||||
href: "/dashboard/wagons",
|
||||
icon: <Truck />,
|
||||
permission: FREIGHT_PERMS.fleet.view,
|
||||
permission: [FREIGHT_PERMS.wagons.view, FREIGHT_PERMS.fleet.view],
|
||||
},
|
||||
{
|
||||
label: "Vehicles",
|
||||
@@ -548,11 +548,6 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
icon: <Boxes />,
|
||||
children: [
|
||||
...getCategorySidebarChildren("configuration"),
|
||||
{
|
||||
label: "Contract validity",
|
||||
href: "/dashboard/configuration/contract-validity-periods",
|
||||
permission: FREIGHT_PERMS.config.contractValidity.view,
|
||||
},
|
||||
{
|
||||
label: "Train scheduling rules",
|
||||
href: "/dashboard/configuration/train-scheduling-rules",
|
||||
@@ -586,6 +581,16 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
const ET_CLEARANCE_HREF = "/dashboard/contracts/clearance";
|
||||
const DJ_CLEARANCE_HREF = "/dashboard/gl-djibouti/clearance";
|
||||
|
||||
// Routes a GL officer may reach beyond their clearance hub. Path B booking is
|
||||
// part of their job (create/rebook under a cleared contract, then view that
|
||||
// booking's clearance), but those routes live outside the clearance prefix —
|
||||
// without this allowlist the single-prefix lock bounces them out of their own
|
||||
// workflow. Matched against location.pathname (no query string).
|
||||
const GL_WORKFLOW_PATH_PATTERNS: RegExp[] = [
|
||||
/^\/dashboard\/contracts\/[^/]+\/create-booking(\/|$)/,
|
||||
/^\/dashboard\/bookings\/[^/]+\/clearance(\/|$)/,
|
||||
];
|
||||
|
||||
const isEtClearanceItem = (item: SidebarItem): boolean =>
|
||||
item.href === ET_CLEARANCE_HREF;
|
||||
const isDjClearanceItem = (item: SidebarItem): boolean =>
|
||||
@@ -721,7 +726,11 @@ const DashboardShell = () => {
|
||||
document.title = activeLabel ? `${activeLabel} | ${APP_TITLE}` : APP_TITLE;
|
||||
}, [location.pathname, sidebarSections]);
|
||||
|
||||
if (glClearanceHome && !location.pathname.startsWith(glClearanceHome)) {
|
||||
if (
|
||||
glClearanceHome &&
|
||||
!location.pathname.startsWith(glClearanceHome) &&
|
||||
!GL_WORKFLOW_PATH_PATTERNS.some((re) => re.test(location.pathname))
|
||||
) {
|
||||
return <Navigate to={glClearanceHome} replace />;
|
||||
}
|
||||
|
||||
@@ -1104,7 +1113,7 @@ const App = () => {
|
||||
<Route
|
||||
path="routes"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<RequirePermission permission={[FREIGHT_PERMS.routes.view, FREIGHT_PERMS.fleet.view]}>
|
||||
<RoutesPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -1112,7 +1121,7 @@ const App = () => {
|
||||
<Route
|
||||
path="locomotives"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<RequirePermission permission={[FREIGHT_PERMS.locomotives.view, FREIGHT_PERMS.fleet.view]}>
|
||||
<FleetResourcePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -1120,7 +1129,7 @@ const App = () => {
|
||||
<Route
|
||||
path="trains"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<RequirePermission permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}>
|
||||
<FleetResourcePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -1128,7 +1137,7 @@ const App = () => {
|
||||
<Route
|
||||
path="trains/:id"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<RequirePermission permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}>
|
||||
<TrainDetailPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -1136,7 +1145,7 @@ const App = () => {
|
||||
<Route
|
||||
path="train-builder"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<RequirePermission permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}>
|
||||
<TrainBuilderListPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -1144,7 +1153,7 @@ const App = () => {
|
||||
<Route
|
||||
path="train-builder/:id"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<RequirePermission permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}>
|
||||
<TrainBuilderDetailPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -1152,7 +1161,7 @@ const App = () => {
|
||||
<Route
|
||||
path="wagons"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<RequirePermission permission={[FREIGHT_PERMS.wagons.view, FREIGHT_PERMS.fleet.view]}>
|
||||
<FleetResourcePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -1160,7 +1169,7 @@ const App = () => {
|
||||
<Route
|
||||
path="containers"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<RequirePermission permission={[FREIGHT_PERMS.containers.view, FREIGHT_PERMS.fleet.view]}>
|
||||
<FleetResourcePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -1168,7 +1177,7 @@ const App = () => {
|
||||
<Route
|
||||
path="cargoes"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<RequirePermission permission={[FREIGHT_PERMS.cargoes.view, FREIGHT_PERMS.fleet.view]}>
|
||||
<FleetResourcePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -1254,7 +1263,7 @@ const App = () => {
|
||||
<Route
|
||||
path="routes"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<RequirePermission permission={[FREIGHT_PERMS.routes.view, FREIGHT_PERMS.fleet.view]}>
|
||||
<RoutesPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -1342,7 +1351,7 @@ const App = () => {
|
||||
<Route
|
||||
path="locomotives"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<RequirePermission permission={[FREIGHT_PERMS.locomotives.view, FREIGHT_PERMS.fleet.view]}>
|
||||
<FleetResourcePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -1350,7 +1359,7 @@ const App = () => {
|
||||
<Route
|
||||
path="trains"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<RequirePermission permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}>
|
||||
<FleetResourcePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -1358,7 +1367,7 @@ const App = () => {
|
||||
<Route
|
||||
path="trains/:id"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<RequirePermission permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}>
|
||||
<TrainDetailPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -1366,7 +1375,7 @@ const App = () => {
|
||||
<Route
|
||||
path="train-builder"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<RequirePermission permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}>
|
||||
<TrainBuilderListPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -1374,7 +1383,7 @@ const App = () => {
|
||||
<Route
|
||||
path="train-builder/:id"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<RequirePermission permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}>
|
||||
<TrainBuilderDetailPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -1382,7 +1391,7 @@ const App = () => {
|
||||
<Route
|
||||
path="wagons"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<RequirePermission permission={[FREIGHT_PERMS.wagons.view, FREIGHT_PERMS.fleet.view]}>
|
||||
<FleetResourcePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -1390,7 +1399,7 @@ const App = () => {
|
||||
<Route
|
||||
path="containers"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<RequirePermission permission={[FREIGHT_PERMS.containers.view, FREIGHT_PERMS.fleet.view]}>
|
||||
<FleetResourcePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -1398,7 +1407,7 @@ const App = () => {
|
||||
<Route
|
||||
path="cargoes"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<RequirePermission permission={[FREIGHT_PERMS.cargoes.view, FREIGHT_PERMS.fleet.view]}>
|
||||
<FleetResourcePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -1465,14 +1474,14 @@ const App = () => {
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
{/* <Route
|
||||
path="configuration/contract-validity-periods"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.admin}>
|
||||
<ContractValidityPeriodsPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
/> */}
|
||||
<Route path="configuration/cargo-types" element={<CargoTypesPage />} />
|
||||
<Route
|
||||
path="configuration/cargo-types/:id"
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
} from "lucide-react";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import { api } from "@/services/api";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
@@ -48,8 +50,19 @@ export function ContractActionsToolbar({
|
||||
onReviewClearance,
|
||||
}: ContractActionsToolbarProps) {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const { status } = contract;
|
||||
|
||||
// Intake permissions are split per freight type: an accept:bulk holder must
|
||||
// not see the accept button on a container contract (API enforces the same).
|
||||
const arm = contract.freightType === "BULK" ? "bulk" : "container";
|
||||
const mayAccept = hasPermission(user, FREIGHT_PERMS.contracts.staffAccept[arm]);
|
||||
const mayRequestChanges = hasPermission(
|
||||
user,
|
||||
FREIGHT_PERMS.contracts.requestChanges[arm],
|
||||
);
|
||||
const mayReject = hasPermission(user, FREIGHT_PERMS.contracts.reject[arm]);
|
||||
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [editorMode, setEditorMode] = useState<"accept" | "edit">("accept");
|
||||
const [previewOpen, setPreviewOpen] = useState(false);
|
||||
@@ -97,7 +110,8 @@ export function ContractActionsToolbar({
|
||||
);
|
||||
}
|
||||
|
||||
const canAccept = status === "SUBMITTED";
|
||||
const canAccept =
|
||||
status === "SUBMITTED" && (mayAccept || mayRequestChanges || mayReject);
|
||||
// The document stays editable for the whole approval chain, but only by the
|
||||
// approver whose turn it is. The server resolves that against the caller's
|
||||
// position type; the client cannot derive it.
|
||||
@@ -126,35 +140,41 @@ export function ContractActionsToolbar({
|
||||
|
||||
{canAccept && (
|
||||
<>
|
||||
<Button
|
||||
fullWidth
|
||||
color="edr-green"
|
||||
leftSection={<Check size={16} />}
|
||||
onClick={() => {
|
||||
setEditorMode("accept");
|
||||
setEditorOpen(true);
|
||||
}}
|
||||
>
|
||||
Accept for approval
|
||||
</Button>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="light"
|
||||
color="orange"
|
||||
leftSection={<MessageSquareWarning size={16} />}
|
||||
onClick={() => setChangesOpen(true)}
|
||||
>
|
||||
Request changes
|
||||
</Button>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="light"
|
||||
color="red"
|
||||
leftSection={<XCircle size={16} />}
|
||||
onClick={() => setRejectOpen(true)}
|
||||
>
|
||||
Reject contract
|
||||
</Button>
|
||||
{mayAccept && (
|
||||
<Button
|
||||
fullWidth
|
||||
color="edr-green"
|
||||
leftSection={<Check size={16} />}
|
||||
onClick={() => {
|
||||
setEditorMode("accept");
|
||||
setEditorOpen(true);
|
||||
}}
|
||||
>
|
||||
Accept for approval
|
||||
</Button>
|
||||
)}
|
||||
{mayRequestChanges && (
|
||||
<Button
|
||||
fullWidth
|
||||
variant="light"
|
||||
color="orange"
|
||||
leftSection={<MessageSquareWarning size={16} />}
|
||||
onClick={() => setChangesOpen(true)}
|
||||
>
|
||||
Request changes
|
||||
</Button>
|
||||
)}
|
||||
{mayReject && (
|
||||
<Button
|
||||
fullWidth
|
||||
variant="light"
|
||||
color="red"
|
||||
leftSection={<XCircle size={16} />}
|
||||
onClick={() => setRejectOpen(true)}
|
||||
>
|
||||
Reject contract
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ import type { Freight } from "@edr/types";
|
||||
import { formatContractApprovalProgress } from "@/features/contracts/contract-approval-progress";
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
import type { useContractMutations } from "@/hooks/contracts/useContracts";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { canApproveContractStep } from "@/lib/permissions";
|
||||
|
||||
type Mutations = ReturnType<typeof useContractMutations>;
|
||||
|
||||
@@ -29,6 +31,7 @@ export function ContractApprovalStepsCard({
|
||||
contract,
|
||||
mutations,
|
||||
}: ContractApprovalStepsCardProps) {
|
||||
const { user } = useAuth();
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [pendingStep, setPendingStep] =
|
||||
useState<Freight.IContractApprovalStep | null>(null);
|
||||
@@ -166,6 +169,10 @@ export function ContractApprovalStepsCard({
|
||||
key={step.id}
|
||||
step={step}
|
||||
isNext={actionable && nextPending?.id === step.id}
|
||||
// Buttons show only to the step's actual approver (matching
|
||||
// position type): a chief step never offers Approve/Reject to a
|
||||
// marketing officer. Everyone still sees the "next" highlight.
|
||||
canAct={canApproveContractStep(user, step.requiredRole)}
|
||||
isPending={
|
||||
mutations.approveStep.isPending ||
|
||||
mutations.rejectStep.isPending
|
||||
@@ -306,12 +313,14 @@ export function ContractApprovalStepsCard({
|
||||
function StepRow({
|
||||
step,
|
||||
isNext,
|
||||
canAct,
|
||||
isPending,
|
||||
onApprove,
|
||||
onReject,
|
||||
}: {
|
||||
step: Freight.IContractApprovalStep;
|
||||
isNext: boolean;
|
||||
canAct: boolean;
|
||||
isPending: boolean;
|
||||
onApprove: () => void;
|
||||
onReject: () => void;
|
||||
@@ -372,8 +381,11 @@ function StepRow({
|
||||
)}
|
||||
</Box>
|
||||
</Group>
|
||||
<Group gap="xs" wrap="nowrap" style={{ flexShrink: 0 }}>
|
||||
{isNext && step.status === "PENDING" && (
|
||||
{/* One element type per row: action buttons on the active step (they
|
||||
already imply "pending & actionable"), a status badge otherwise.
|
||||
Mixing compact buttons + a badge here made them read as misaligned. */}
|
||||
<Group gap="xs" wrap="nowrap" align="center" style={{ flexShrink: 0 }}>
|
||||
{isNext && canAct && step.status === "PENDING" ? (
|
||||
<>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
@@ -395,16 +407,17 @@ function StepRow({
|
||||
Reject
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Badge
|
||||
variant="light"
|
||||
color={statusColor}
|
||||
size="sm"
|
||||
radius="sm"
|
||||
tt="uppercase"
|
||||
>
|
||||
{step.status}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge
|
||||
variant="light"
|
||||
color={statusColor}
|
||||
size="sm"
|
||||
radius="sm"
|
||||
tt="uppercase"
|
||||
>
|
||||
{step.status}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
|
||||
@@ -269,6 +269,8 @@ export default function GlCreateBookingForm() {
|
||||
const [scheduledDate, setScheduledDate] = useState("");
|
||||
const [contractRouteId, setContractRouteId] = useState<string | null>(null);
|
||||
const [notes, setNotes] = useState("");
|
||||
// What the containers carry — captured per booking (moved off the contract).
|
||||
const [cargoDescription, setCargoDescription] = useState("");
|
||||
const [containerLines, setContainerLines] = useState<ContainerLineDraft[]>([]);
|
||||
const [bulk, setBulk] = useState<BulkDraft>({
|
||||
cargoWeightTons: "",
|
||||
@@ -459,6 +461,10 @@ export default function GlCreateBookingForm() {
|
||||
);
|
||||
};
|
||||
setPrefilled(true);
|
||||
// Rebook carries the expired booking's cargo description forward.
|
||||
if (copyFromBooking.cargoFreeText) {
|
||||
setCargoDescription(copyFromBooking.cargoFreeText);
|
||||
}
|
||||
setContainerLines(
|
||||
lines.map((c) => {
|
||||
const qty = Math.max(1, c.quantity);
|
||||
@@ -705,14 +711,23 @@ export default function GlCreateBookingForm() {
|
||||
|
||||
const lineErrors = useMemo<LineErrors[]>(() => {
|
||||
if (!isContainer || !contract) return [];
|
||||
return containerLines.map((line) => {
|
||||
// A line can be 0 (the contract covers both sizes; a booking may only need
|
||||
// one) but the booking as a whole needs at least one container — anchor
|
||||
// that error on the first line's quantity so it renders in the field.
|
||||
const totalQty = containerLines.reduce(
|
||||
(sum, l) => sum + Math.max(0, Number(l.quantity) || 0),
|
||||
0,
|
||||
);
|
||||
return containerLines.map((line, idx) => {
|
||||
const errs: LineErrors = {};
|
||||
const qty = Number(line.quantity || 0);
|
||||
if (line.quantity.trim() === "") {
|
||||
errs.quantity = "Quantity is required.";
|
||||
} else if (Number.isNaN(qty) || qty < 1) {
|
||||
errs.quantity = "At least 1.";
|
||||
} else if (line.units.length < qty) {
|
||||
} else if (Number.isNaN(qty) || qty < 0) {
|
||||
errs.quantity = "Enter 0 or more.";
|
||||
} else if (idx === 0 && totalQty < 1) {
|
||||
errs.quantity = "Book at least one container (either size).";
|
||||
} else if (qty >= 1 && line.units.length < qty) {
|
||||
errs.units = `Enter details for all ${qty} container(s).`;
|
||||
}
|
||||
if (contract.isHazardous) {
|
||||
@@ -789,6 +804,11 @@ export default function GlCreateBookingForm() {
|
||||
const routeError =
|
||||
multiRoute && !contractRouteId ? "Select a route." : undefined;
|
||||
|
||||
const cargoDescriptionError =
|
||||
isContainer && !cargoDescription.trim()
|
||||
? "Describe the cargo carried in the containers."
|
||||
: undefined;
|
||||
|
||||
const cargoValid = isContainer
|
||||
? lineErrors.every(
|
||||
(e) =>
|
||||
@@ -800,7 +820,8 @@ export default function GlCreateBookingForm() {
|
||||
) &&
|
||||
unitErrors.every((line) =>
|
||||
line.every((e) => !e.containerNumber && !e.vgmTons),
|
||||
)
|
||||
) &&
|
||||
!cargoDescriptionError
|
||||
: !bulkErrors.quantity && !bulkErrors.hazardous && !bulkErrors.reefer;
|
||||
|
||||
const formValid = cargoValid && !hasOdd20ft && !dateError && !routeError;
|
||||
@@ -827,6 +848,8 @@ export default function GlCreateBookingForm() {
|
||||
};
|
||||
|
||||
if (isContainer) {
|
||||
// What the containers carry — captured per booking, not on the contract.
|
||||
if (cargoDescription.trim()) payload.cargoFreeText = cargoDescription.trim();
|
||||
payload.containers = containerLines
|
||||
.filter((l) => Number(l.quantity) >= 1)
|
||||
.map((l) => ({
|
||||
@@ -1245,6 +1268,19 @@ export default function GlCreateBookingForm() {
|
||||
)}
|
||||
{remainderNotice}
|
||||
<ContractCapacityNotice contractId={contract.id} isContainer />
|
||||
<Textarea
|
||||
label="Cargo description *"
|
||||
description="What do the containers carry on this shipment?"
|
||||
placeholder="e.g. Electronics, garments, machinery spare parts…"
|
||||
value={cargoDescription}
|
||||
onChange={(e) => setCargoDescription(e.currentTarget.value)}
|
||||
error={showErrors ? cargoDescriptionError : undefined}
|
||||
radius={10}
|
||||
autosize
|
||||
minRows={2}
|
||||
maxRows={4}
|
||||
styles={fieldStyles}
|
||||
/>
|
||||
{containerLines.length === 0 ? (
|
||||
<Text fz="sm" c="dimmed">
|
||||
This contract has no container sizes in scope.
|
||||
@@ -1264,7 +1300,7 @@ export default function GlCreateBookingForm() {
|
||||
type="number"
|
||||
onKeyDown={blockNegative}
|
||||
label="Quantity *"
|
||||
min={1}
|
||||
min={0}
|
||||
value={line.quantity}
|
||||
error={
|
||||
showErrors
|
||||
|
||||
@@ -19,8 +19,9 @@ export interface FleetCardGridProps {
|
||||
pageCount: number;
|
||||
totalCount: number;
|
||||
onPaginationChange: OnChangeFn<PaginationState>;
|
||||
onEdit: (record: FleetRecord) => void;
|
||||
onRemove: (record: FleetRecord) => void;
|
||||
/** Omit to hide the action (caller lacks the update/delete permission). */
|
||||
onEdit?: (record: FleetRecord) => void;
|
||||
onRemove?: (record: FleetRecord) => void;
|
||||
}
|
||||
|
||||
const FleetCardGrid = ({
|
||||
|
||||
@@ -8,8 +8,9 @@ import type { FleetRecord } from "@/services/fleet/fleet.service";
|
||||
export interface FleetRecordActionsProps {
|
||||
record: FleetRecord;
|
||||
config: FleetResourceConfig;
|
||||
onEdit: (record: FleetRecord) => void;
|
||||
onRemove: (record: FleetRecord) => void;
|
||||
/** Omit to hide the action (caller lacks the update/delete permission). */
|
||||
onEdit?: (record: FleetRecord) => void;
|
||||
onRemove?: (record: FleetRecord) => void;
|
||||
onAssignDriver?: (record: FleetRecord) => void;
|
||||
onHistory?: (record: FleetRecord) => void;
|
||||
onViewDetail?: (record: FleetRecord) => void;
|
||||
@@ -42,6 +43,17 @@ const FleetRecordActions = ({
|
||||
navigate(config.detailPath.replace(":id", String(record.id)));
|
||||
};
|
||||
|
||||
if (
|
||||
!onEdit &&
|
||||
!onRemove &&
|
||||
!showDetail &&
|
||||
!showViewDetail &&
|
||||
!showHistory &&
|
||||
!(isVehicle && onAssignDriver)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (layout === "compact") {
|
||||
return (
|
||||
<Menu position="bottom-end" withinPortal shadow="md">
|
||||
@@ -61,12 +73,14 @@ const FleetRecordActions = ({
|
||||
Assign Driver
|
||||
</MenuItem>
|
||||
) : null}
|
||||
<MenuItem
|
||||
onClick={() => onEdit(record)}
|
||||
leftSection={<Edit2 size={14} strokeWidth={2} />}
|
||||
>
|
||||
Edit
|
||||
</MenuItem>
|
||||
{onEdit ? (
|
||||
<MenuItem
|
||||
onClick={() => onEdit(record)}
|
||||
leftSection={<Edit2 size={14} strokeWidth={2} />}
|
||||
>
|
||||
Edit
|
||||
</MenuItem>
|
||||
) : null}
|
||||
{showViewDetail ? (
|
||||
<MenuItem
|
||||
onClick={() => onViewDetail?.(record)}
|
||||
@@ -91,13 +105,15 @@ const FleetRecordActions = ({
|
||||
View details
|
||||
</MenuItem>
|
||||
) : null}
|
||||
<MenuItem
|
||||
color="red"
|
||||
onClick={() => onRemove(record)}
|
||||
leftSection={<Trash2 size={14} strokeWidth={2} />}
|
||||
>
|
||||
{removeLabel}
|
||||
</MenuItem>
|
||||
{onRemove ? (
|
||||
<MenuItem
|
||||
color="red"
|
||||
onClick={() => onRemove(record)}
|
||||
leftSection={<Trash2 size={14} strokeWidth={2} />}
|
||||
>
|
||||
{removeLabel}
|
||||
</MenuItem>
|
||||
) : null}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
@@ -121,12 +137,14 @@ const FleetRecordActions = ({
|
||||
Assign Driver
|
||||
</MenuItem>
|
||||
) : null}
|
||||
<MenuItem
|
||||
onClick={() => onEdit(record)}
|
||||
leftSection={<Edit2 size={14} strokeWidth={2} />}
|
||||
>
|
||||
Edit
|
||||
</MenuItem>
|
||||
{onEdit ? (
|
||||
<MenuItem
|
||||
onClick={() => onEdit(record)}
|
||||
leftSection={<Edit2 size={14} strokeWidth={2} />}
|
||||
>
|
||||
Edit
|
||||
</MenuItem>
|
||||
) : null}
|
||||
{showViewDetail ? (
|
||||
<MenuItem
|
||||
onClick={() => onViewDetail?.(record)}
|
||||
@@ -143,13 +161,15 @@ const FleetRecordActions = ({
|
||||
View details
|
||||
</MenuItem>
|
||||
) : null}
|
||||
<MenuItem
|
||||
color="red"
|
||||
onClick={() => onRemove(record)}
|
||||
leftSection={<Trash2 size={14} strokeWidth={2} />}
|
||||
>
|
||||
{removeLabel}
|
||||
</MenuItem>
|
||||
{onRemove ? (
|
||||
<MenuItem
|
||||
color="red"
|
||||
onClick={() => onRemove(record)}
|
||||
leftSection={<Trash2 size={14} strokeWidth={2} />}
|
||||
>
|
||||
{removeLabel}
|
||||
</MenuItem>
|
||||
) : null}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
|
||||
@@ -287,6 +287,8 @@ export type SegmentStripBooking = {
|
||||
destinationYardId?: string | null;
|
||||
tradeDirection?: string | null;
|
||||
wagonsRequired?: number | null;
|
||||
/** GROSS tons (cargo + tare of the booking's wagons), as the API sends it. */
|
||||
weightTons?: number | null;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -300,10 +302,13 @@ export function SegmentOccupancyStrip({
|
||||
stops,
|
||||
bookings,
|
||||
maxWagons,
|
||||
maxGrossTons,
|
||||
}: {
|
||||
stops: Array<{ yardId: string; label: string }>;
|
||||
bookings: SegmentStripBooking[];
|
||||
maxWagons?: number | null;
|
||||
/** Loco pull ceiling incl. tolerance — per-leg gross is measured against it. */
|
||||
maxGrossTons?: number | null;
|
||||
}) {
|
||||
if (stops.length < 2) return null;
|
||||
const lastIdx = stops.length - 1;
|
||||
@@ -312,6 +317,7 @@ export function SegmentOccupancyStrip({
|
||||
const segments = stops.slice(0, -1).map((stop, edge) => {
|
||||
let cargo = 0;
|
||||
let intercity = 0;
|
||||
let grossTons = 0;
|
||||
for (const b of bookings) {
|
||||
const from = (b.originYardId ? indexOf.get(b.originYardId) : undefined) ?? 0;
|
||||
const to =
|
||||
@@ -322,8 +328,15 @@ export function SegmentOccupancyStrip({
|
||||
const wagons = Number(b.wagonsRequired) || 1;
|
||||
if (b.tradeDirection === "DOMESTIC") intercity += wagons;
|
||||
else cargo += wagons;
|
||||
grossTons += Number(b.weightTons) || 0;
|
||||
}
|
||||
return { from: stop, to: stops[edge + 1], cargo, intercity };
|
||||
return {
|
||||
from: stop,
|
||||
to: stops[edge + 1],
|
||||
cargo,
|
||||
intercity,
|
||||
grossTons: Math.round(grossTons * 10) / 10,
|
||||
};
|
||||
});
|
||||
|
||||
const cap = Number(maxWagons) || null;
|
||||
@@ -393,6 +406,22 @@ export function SegmentOccupancyStrip({
|
||||
</Text>
|
||||
) : null}
|
||||
</Text>
|
||||
{seg.grossTons > 0 ? (
|
||||
<Text
|
||||
size="xs"
|
||||
ta="center"
|
||||
fw={600}
|
||||
c={
|
||||
maxGrossTons != null && seg.grossTons > maxGrossTons
|
||||
? "red.7"
|
||||
: "dimmed"
|
||||
}
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
>
|
||||
{seg.grossTons}
|
||||
{maxGrossTons != null ? ` / ${maxGrossTons}` : ""} T gross
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
{i === segments.length - 1 ? (
|
||||
<Stack gap={2} align="center" justify="flex-end" style={{ minWidth: 0 }}>
|
||||
|
||||
@@ -205,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),
|
||||
@@ -218,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),
|
||||
@@ -231,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),
|
||||
@@ -281,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. */
|
||||
@@ -293,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
|
||||
@@ -611,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">
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { AuthUser } from "@/auth/types";
|
||||
import { canApproveContractStep } from "./permissions";
|
||||
|
||||
const withPositionType = (typeKey: string): AuthUser => ({
|
||||
employee: [{ positions: [{ positionType: { key: typeKey } }] }],
|
||||
});
|
||||
|
||||
const withRole = (roleKey: string): AuthUser => ({ roles: [{ key: roleKey }] });
|
||||
|
||||
const withPermission = (permKey: string): AuthUser => ({
|
||||
permissionKeys: [permKey],
|
||||
});
|
||||
|
||||
describe("canApproveContractStep", () => {
|
||||
it("shows to the matching position type only", () => {
|
||||
const chief = withPositionType("-marketing-chief");
|
||||
expect(canApproveContractStep(chief, "-marketing-chief")).toBe(true);
|
||||
// a marketing officer must NOT see the chief step's buttons
|
||||
expect(canApproveContractStep(chief, "-marketing-director-")).toBe(false);
|
||||
});
|
||||
|
||||
it("lets super/org admins action any step", () => {
|
||||
expect(canApproveContractStep(withRole("super_admin"), "anything")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
canApproveContractStep(withRole("organization_admin"), "-marketing-chief"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("resolves legacy chain roles via their position-type aliases", () => {
|
||||
const director = withPositionType("operation-director");
|
||||
expect(canApproveContractStep(director, "DIRECTOR")).toBe(true);
|
||||
expect(canApproveContractStep(director, "CEO")).toBe(false);
|
||||
});
|
||||
|
||||
it("honours the role's own legacy approve permission", () => {
|
||||
const staff = withPermission(
|
||||
"edr_freight_app:contracts:approve_director",
|
||||
);
|
||||
expect(canApproveContractStep(staff, "DIRECTOR")).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT show to holders of an unrelated approve permission", () => {
|
||||
// the dropped blanket fallback: a line-staff approver is not a chief
|
||||
const lineStaff = withPermission(
|
||||
"edr_freight_app:contracts:approve_line_staff",
|
||||
);
|
||||
expect(canApproveContractStep(lineStaff, "-marketing-chief")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false without a user or role", () => {
|
||||
expect(canApproveContractStep(null, "-marketing-chief")).toBe(false);
|
||||
expect(canApproveContractStep(withPositionType("x"), null)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -29,9 +29,19 @@ export const FREIGHT_PERMS = {
|
||||
},
|
||||
contracts: {
|
||||
view: "edr_freight_app:contracts:view",
|
||||
staffAccept: "edr_freight_app:contracts:staff_accept",
|
||||
requestChanges: "edr_freight_app:contracts:request_changes",
|
||||
reject: "edr_freight_app:contracts:reject",
|
||||
// Intake actions are split per freight type — mirror of the API registry.
|
||||
staffAccept: {
|
||||
bulk: "edr_freight_app:contracts:staff_accept:bulk",
|
||||
container: "edr_freight_app:contracts:staff_accept:container",
|
||||
},
|
||||
requestChanges: {
|
||||
bulk: "edr_freight_app:contracts:request_changes:bulk",
|
||||
container: "edr_freight_app:contracts:request_changes:container",
|
||||
},
|
||||
reject: {
|
||||
bulk: "edr_freight_app:contracts:reject:bulk",
|
||||
container: "edr_freight_app:contracts:reject:container",
|
||||
},
|
||||
approveLineStaff: "edr_freight_app:contracts:approve_line_staff",
|
||||
approveDirector: "edr_freight_app:contracts:approve_director",
|
||||
approveCeo: "edr_freight_app:contracts:approve_ceo",
|
||||
@@ -240,12 +250,6 @@ export const FREIGHT_PERMS = {
|
||||
cancel: "edr_freight_app:warehouse_fee_invoices:cancel",
|
||||
pay: "edr_freight_app:warehouse_fee_invoices:pay",
|
||||
},
|
||||
config: {
|
||||
contractValidity: {
|
||||
view: "edr_freight_app:config:contract_validity:view",
|
||||
manage: "edr_freight_app:config:contract_validity:manage",
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
fileUpload: {
|
||||
view: "edr_freight_app:settings:file_upload:view",
|
||||
@@ -420,6 +424,53 @@ export function hasPermission(
|
||||
return getPermissionKeys(user).includes(key);
|
||||
}
|
||||
|
||||
// Legacy chain roles predate position types; map each to the position types
|
||||
// that stand in for it. Mirror of the API's LEGACY_ROLE_POSITION_TYPES so the
|
||||
// button visibility matches what the approve/reject endpoint will accept.
|
||||
const LEGACY_ROLE_POSITION_TYPES: Record<string, string[]> = {
|
||||
LINE_STAFF: ["employee", "teamLeader", "officeHead", "recordOfficer"],
|
||||
DIRECTOR: ["director", "operation-director"],
|
||||
CEO: ["chief", "deputy"],
|
||||
};
|
||||
|
||||
const CONTRACT_APPROVE_ROLE_PERMISSION: Record<string, string> = {
|
||||
LINE_STAFF: FREIGHT_PERMS.contracts.approveLineStaff,
|
||||
DIRECTOR: FREIGHT_PERMS.contracts.approveDirector,
|
||||
CEO: FREIGHT_PERMS.contracts.approveCeo,
|
||||
};
|
||||
|
||||
/**
|
||||
* Can this user action a contract approval step requiring `requiredRole`?
|
||||
*
|
||||
* `requiredRole` is an `iam.position_types.key` (the role vocabulary approval
|
||||
* chains are configured in), or a legacy LINE_STAFF/DIRECTOR/CEO string. Used
|
||||
* to show Approve/Reject only to the step's actual approver — a chief step
|
||||
* shows only to a chief, a marketing-officer step only to that officer.
|
||||
*
|
||||
* Deliberately STRICTER than the API's `assertCanApproveContractStep`, which
|
||||
* also lets through anyone holding any contract-approve permission (a fallback
|
||||
* for delegates whose token omits the position type). That blanket is what made
|
||||
* every approver see the button, so it is dropped here: the visibility rule is
|
||||
* admin OR the matching position type (direct / legacy alias) OR the role's own
|
||||
* legacy approve permission. The server still guards the mutation.
|
||||
*/
|
||||
export function canApproveContractStep(
|
||||
user: AuthUser | null | undefined,
|
||||
requiredRole: string | null | undefined,
|
||||
): boolean {
|
||||
if (!user || !requiredRole) return false;
|
||||
if (isFreightApprovalAdmin(user)) return true;
|
||||
|
||||
const positionTypes = getPositionTypeKeys(user);
|
||||
if (positionTypes.includes(requiredRole)) return true;
|
||||
|
||||
const aliases = LEGACY_ROLE_POSITION_TYPES[requiredRole] ?? [];
|
||||
if (aliases.some((alias) => positionTypes.includes(alias))) return true;
|
||||
|
||||
const legacyPermission = CONTRACT_APPROVE_ROLE_PERMISSION[requiredRole];
|
||||
return Boolean(legacyPermission && hasPermission(user, legacyPermission));
|
||||
}
|
||||
|
||||
export function canAccessBookings(user: AuthUser | null | undefined): boolean {
|
||||
return hasPermission(user, FREIGHT_PERMS.bookings.view);
|
||||
}
|
||||
@@ -466,6 +517,31 @@ export function canViewFleet(user: AuthUser | null | undefined): boolean {
|
||||
return hasPermission(user, FREIGHT_PERMS.fleet.view);
|
||||
}
|
||||
|
||||
export type FleetCrudResource =
|
||||
| "locomotives"
|
||||
| "wagons"
|
||||
| "trains"
|
||||
| "routes"
|
||||
| "containers"
|
||||
| "cargoes"
|
||||
| "vehicles"
|
||||
| "drivers";
|
||||
|
||||
/**
|
||||
* Per-resource fleet CRUD check. The legacy coarse fleet:manage key still
|
||||
* grants every action (mirrors the API's one-of guard fallback).
|
||||
*/
|
||||
export function canFleetAction(
|
||||
user: AuthUser | null | undefined,
|
||||
resource: FleetCrudResource,
|
||||
action: "create" | "update" | "delete",
|
||||
): boolean {
|
||||
return (
|
||||
hasPermission(user, FREIGHT_PERMS[resource][action]) ||
|
||||
hasPermission(user, FREIGHT_PERMS.fleet.manage)
|
||||
);
|
||||
}
|
||||
|
||||
export function isFreightAdmin(user: AuthUser | null | undefined): boolean {
|
||||
return hasPermission(user, FREIGHT_PERMS.admin);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
LayoutGrid,
|
||||
Milestone,
|
||||
Package,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Container,
|
||||
@@ -36,7 +35,6 @@ import {
|
||||
BookingCompanyCard,
|
||||
BookingContractSummaryCard,
|
||||
BookingContainerUnitsCard,
|
||||
ClearanceReviewSection,
|
||||
BookingDocumentsPanel,
|
||||
ContractOrdersPanel,
|
||||
} from "@/components/bookings/detail";
|
||||
@@ -129,13 +127,9 @@ export default function BookingRequestDetailPage() {
|
||||
|
||||
const row = toBookingListRow(booking);
|
||||
const statusMeta = getStatusMeta(booking.status);
|
||||
// Non-customs clearance is reviewed here by Marketing in its own tab; customs
|
||||
// bookings are handled in the Global Logistics clearance queue instead.
|
||||
const showClearanceTab =
|
||||
!booking.customsClearingEnabled &&
|
||||
["AWAITING_DOCUMENTS", "DOCUMENTS_UNDER_REVIEW", "CLEARANCE_READY"].includes(
|
||||
booking.status,
|
||||
);
|
||||
// Clearance review + finalize now lives solely on the Operations "Clearance
|
||||
// Documents" hub (/dashboard/contracts/clearance-documents → detail page), so
|
||||
// no clearance tab is embedded here anymore.
|
||||
// A general contract drives an "Orders" tab: each drawdown order spawns a
|
||||
// child booking that staff manage (clearance/approval) independently.
|
||||
const isGeneralContract = booking.bookingType === "GENERAL_CONTRACT";
|
||||
@@ -143,13 +137,11 @@ export default function BookingRequestDetailPage() {
|
||||
// customs-workflow, invoice or notice files — so the tab bar always renders.
|
||||
const requestedTab = searchParams.get("tab");
|
||||
const activeTab =
|
||||
requestedTab === "clearance" && showClearanceTab
|
||||
? "clearance"
|
||||
: requestedTab === "orders" && isGeneralContract
|
||||
? "orders"
|
||||
: requestedTab === "documents"
|
||||
? "documents"
|
||||
: "overview";
|
||||
requestedTab === "orders" && isGeneralContract
|
||||
? "orders"
|
||||
: requestedTab === "documents"
|
||||
? "documents"
|
||||
: "overview";
|
||||
const setActiveTab = (tab: string | null) => {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
if (tab && tab !== "overview") next.set("tab", tab);
|
||||
@@ -209,14 +201,6 @@ export default function BookingRequestDetailPage() {
|
||||
Orders
|
||||
</Tabs.Tab>
|
||||
)}
|
||||
{showClearanceTab && (
|
||||
<Tabs.Tab
|
||||
value="clearance"
|
||||
leftSection={<ShieldCheck size={16} />}
|
||||
>
|
||||
Customer clearance
|
||||
</Tabs.Tab>
|
||||
)}
|
||||
<Tabs.Tab
|
||||
value="documents"
|
||||
leftSection={<FolderOpen size={16} />}
|
||||
@@ -236,14 +220,6 @@ export default function BookingRequestDetailPage() {
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
)}
|
||||
{showClearanceTab && (
|
||||
<Tabs.Panel value="clearance">
|
||||
<ClearanceReviewSection
|
||||
bookingId={booking.id}
|
||||
onChanged={() => refetch()}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
)}
|
||||
<Tabs.Panel value="documents">
|
||||
<BookingDocumentsPanel bookingId={booking.id} />
|
||||
</Tabs.Panel>
|
||||
|
||||
@@ -138,9 +138,14 @@ export default function ClearanceDocumentsPage() {
|
||||
const generalQuery = useQuery({
|
||||
queryKey: ["clearance-documents", "general", bookingStatuses, page, search],
|
||||
queryFn: () =>
|
||||
// Per-booking self-clearance instances are drawdowns under GENERAL
|
||||
// non-customs contracts: they carry bookingType=ONE_TIME (each shipment
|
||||
// is one-time) with contractKind=GENERAL, so filtering on
|
||||
// bookingType=GENERAL_CONTRACT returned nothing. customsClearingEnabled
|
||||
// =false + the three per-booking clearance statuses already isolate
|
||||
// exactly this worklist — the same set the old booking-request tab showed.
|
||||
bookingsService.list({
|
||||
statuses: bookingStatuses,
|
||||
bookingType: "GENERAL_CONTRACT",
|
||||
customsClearingEnabled: "false",
|
||||
page,
|
||||
pageSize: PAGE_SIZE,
|
||||
|
||||
@@ -93,6 +93,11 @@ export default function ContractClearanceDetailPage() {
|
||||
}, [clearance]);
|
||||
|
||||
const reference = contract?.reference ?? "Clearance";
|
||||
// Path A (non-customs) → Operations reviews & finalizes; Path B (customs) → GL.
|
||||
// This page serves BOTH hubs (Ops "Clearance Documents" + GL "Document
|
||||
// Clearance"), so the reviewer is decided by the contract, not the hub — a
|
||||
// hardcoded value routes non-customs finalize to the GL endpoint and 409s.
|
||||
const selfClear = !contract?.customsClearingEnabled;
|
||||
const phasedCustoms =
|
||||
contract?.contractKind === "ONE_TIME" && Boolean(contract.customsClearingEnabled);
|
||||
const docsPhaseComplete =
|
||||
@@ -338,7 +343,7 @@ export default function ContractClearanceDetailPage() {
|
||||
<ContractClearanceReviewSection
|
||||
contractId={id!}
|
||||
hideSummary
|
||||
selfClear={false}
|
||||
selfClear={selfClear}
|
||||
readOnly={reviewReadOnly}
|
||||
approvalsLocked={phasedCustoms && docReviewLocked}
|
||||
queriesLocked={queriesLocked}
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
Receipt,
|
||||
RefreshCw,
|
||||
Route as RouteIcon,
|
||||
ShieldCheck,
|
||||
Snowflake,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
@@ -51,7 +50,6 @@ import { ContractStatusBadge } from "@/components/contracts/ContractStatusBadge"
|
||||
import { ContractWorkflowStepper } from "@/components/contracts/ContractWorkflowStepper";
|
||||
import { ContractActionsToolbar } from "@/components/contracts/ContractActionsToolbar";
|
||||
import { ContractApprovalStepsCard } from "@/components/contracts/ContractApprovalStepsCard";
|
||||
import { ContractClearanceReviewSection } from "@/components/contracts/ContractClearanceReviewSection";
|
||||
import { ClearanceWorkflowFilesPanel } from "@/components/contracts/ClearanceWorkflowFilesPanel";
|
||||
import { ContractRevisionTimeline } from "@/components/contracts/ContractRevisionTimeline";
|
||||
import {
|
||||
@@ -74,15 +72,14 @@ import {
|
||||
import type { CustomerDocument } from "@/types/customer";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
// Clearance phase — staff can still ACT (approve / query / finalize).
|
||||
// Clearance phase — actionable (docs approve / query / finalize on the hub).
|
||||
const CLEARANCE_ACTIVE_STATUSES = [
|
||||
"AWAITING_CLEARANCE_DOCUMENTS",
|
||||
"CLEARANCE_UNDER_REVIEW",
|
||||
"CLEARANCE_READY_FOR_BOOKING",
|
||||
];
|
||||
|
||||
// Clearance is done — the tab stays visible but READ-ONLY so staff/customer can
|
||||
// see which documents were approved, by whom, and when.
|
||||
// Clearance is done — its documents are still worth loading (read-only record).
|
||||
const CLEARANCE_DONE_STATUSES = [
|
||||
"ACTIVE_SHIPMENT_IN_PROGRESS",
|
||||
"FULLY_EXECUTED",
|
||||
@@ -91,7 +88,9 @@ const CLEARANCE_DONE_STATUSES = [
|
||||
"EXPIRED",
|
||||
];
|
||||
|
||||
// Show the Clearance Review tab in either phase (active or done).
|
||||
// Contract is in (or past) its clearance phase — load the clearance view so the
|
||||
// Documents tab can show customs workflow files, and surface the "Review
|
||||
// clearance" deep-link to the Operations hub.
|
||||
const CLEARANCE_REVIEW_STATUSES = [
|
||||
...CLEARANCE_ACTIVE_STATUSES,
|
||||
...CLEARANCE_DONE_STATUSES,
|
||||
@@ -151,13 +150,13 @@ export default function ContractRequestDetailPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const showClearanceTabQuery = Boolean(
|
||||
const hasClearancePhase = Boolean(
|
||||
contract && CLEARANCE_REVIEW_STATUSES.includes(contract.status),
|
||||
);
|
||||
const { data: clearanceView } = useQuery({
|
||||
queryKey: QUERY_KEYS.CONTRACTS.clearance(id ?? ""),
|
||||
queryFn: () => contractsService.getClearance(id!),
|
||||
enabled: Boolean(id) && showClearanceTabQuery,
|
||||
enabled: Boolean(id) && hasClearancePhase,
|
||||
});
|
||||
|
||||
// Customer profile documents (national ID, TIN, import/business license) for
|
||||
@@ -270,18 +269,10 @@ export default function ContractRequestDetailPage() {
|
||||
contract.status === "APPROVED_PENDING_SIGNATURE" ||
|
||||
contract.status === "REJECTED";
|
||||
|
||||
const showClearanceTab = CLEARANCE_REVIEW_STATUSES.includes(contract.status);
|
||||
const phasedCustoms =
|
||||
contract.contractKind === "ONE_TIME" && Boolean(contract.customsClearingEnabled);
|
||||
const docsPhaseComplete =
|
||||
clearanceView?.milestones?.some(
|
||||
(m) => m.milestoneCode === "DOCUMENTS_APPROVED" && m.status === "COMPLETED",
|
||||
) ?? false;
|
||||
const clearanceApprovalsLocked = phasedCustoms && docsPhaseComplete;
|
||||
// Once clearance is finalized the tab is informational only — no approve/query.
|
||||
const clearanceReadOnly = CLEARANCE_DONE_STATUSES.includes(contract.status);
|
||||
// Path A (no customs) → Operations reviews; Path B (customs) → GL reviews.
|
||||
const selfClear = !contract.customsClearingEnabled;
|
||||
// Clearance review + finalize now lives solely on the Operations "Clearance
|
||||
// Documents" hub. The Staff-actions "Review clearance" button deep-links there
|
||||
// while the contract is in a clearance-review status — no embedded tab here.
|
||||
const inClearanceReview = CLEARANCE_REVIEW_STATUSES.includes(contract.status);
|
||||
const files = contract.files ?? [];
|
||||
const contractPdf = files.find((f) => f.code === "contract");
|
||||
// Signature files (code `signature_<role>`) are baked into the contract PDF —
|
||||
@@ -303,9 +294,7 @@ export default function ContractRequestDetailPage() {
|
||||
? "documents"
|
||||
: requestedTab === "customer"
|
||||
? "customer"
|
||||
: requestedTab === "clearance" && showClearanceTab
|
||||
? "clearance"
|
||||
: "details";
|
||||
: "details";
|
||||
|
||||
const customerLabel = contract.isGovernment
|
||||
? (contract.governmentInstitution ?? "Government")
|
||||
@@ -485,39 +474,13 @@ export default function ContractRequestDetailPage() {
|
||||
<Tabs.Tab value="customer" leftSection={<Users size={16} />}>
|
||||
Customer
|
||||
</Tabs.Tab>
|
||||
{showClearanceTab && (
|
||||
<Tabs.Tab
|
||||
value="clearance"
|
||||
leftSection={<ShieldCheck size={16} />}
|
||||
>
|
||||
Clearance Review
|
||||
</Tabs.Tab>
|
||||
)}
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
|
||||
<Grid gap="lg">
|
||||
{/* LEFT — primary content */}
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
{currentTab === "clearance" ? (
|
||||
<Stack gap="lg">
|
||||
<ContractClearanceReviewSection
|
||||
contractId={id!}
|
||||
selfClear={selfClear}
|
||||
readOnly={clearanceReadOnly}
|
||||
phasedCustoms={phasedCustoms}
|
||||
approvalsLocked={clearanceApprovalsLocked}
|
||||
onChanged={() => refetch()}
|
||||
/>
|
||||
{(clearanceView?.workflowFiles?.length ?? 0) > 0 ? (
|
||||
<ClearanceWorkflowFilesPanel
|
||||
files={clearanceView!.workflowFiles!}
|
||||
onView={view}
|
||||
onDownload={(f) => void handleDownloadFile({ id: f.id, name: f.name } as never)}
|
||||
/>
|
||||
) : null}
|
||||
</Stack>
|
||||
) : currentTab === "documents" ? (
|
||||
{currentTab === "documents" ? (
|
||||
<Stack gap="lg">
|
||||
<ContractDocumentsCard
|
||||
files={contractDocuments}
|
||||
@@ -727,7 +690,12 @@ export default function ContractRequestDetailPage() {
|
||||
contract={contract}
|
||||
mutations={mutations}
|
||||
onReviewClearance={
|
||||
showClearanceTab ? () => setTab("clearance") : undefined
|
||||
inClearanceReview
|
||||
? () =>
|
||||
navigate(
|
||||
`/dashboard/contracts/clearance-documents/${contract.id}`,
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{showApprovalCard && (
|
||||
|
||||
@@ -3,6 +3,8 @@ import { Box, Button, Card, Container, Group, Modal, Select, Stack, Text, Title
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { canFleetAction } from "@/lib/permissions";
|
||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
import { Inbox, Plus, Warehouse } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
@@ -37,6 +39,10 @@ const FleetResourcePage = () => {
|
||||
const slug = getFleetSlugFromPath(location.pathname) ?? DEFAULT_SLUG;
|
||||
const config = getFleetResource(slug);
|
||||
const { toast } = useToast();
|
||||
const { user } = useAuth();
|
||||
const canCreate = canFleetAction(user, slug, "create");
|
||||
const canUpdate = canFleetAction(user, slug, "update");
|
||||
const canDelete = canFleetAction(user, slug, "delete");
|
||||
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const [search, setSearch] = useState("");
|
||||
@@ -278,12 +284,16 @@ const FleetResourcePage = () => {
|
||||
record={row.original}
|
||||
config={config}
|
||||
layout="compact"
|
||||
onEdit={(record) => {
|
||||
setEditing(record);
|
||||
setFormOpen(true);
|
||||
}}
|
||||
onRemove={setRemoveTarget}
|
||||
onAssignDriver={setAssigningDriver}
|
||||
onEdit={
|
||||
canUpdate
|
||||
? (record) => {
|
||||
setEditing(record);
|
||||
setFormOpen(true);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onRemove={canDelete ? setRemoveTarget : undefined}
|
||||
onAssignDriver={canUpdate ? setAssigningDriver : undefined}
|
||||
onHistory={setHistoryTarget}
|
||||
/>
|
||||
</div>
|
||||
@@ -291,7 +301,7 @@ const FleetResourcePage = () => {
|
||||
});
|
||||
|
||||
return base;
|
||||
}, [config, dynamicOptions.yards]);
|
||||
}, [config, dynamicOptions.yards, canUpdate, canDelete]);
|
||||
|
||||
const tableStatus = isLoading ? "loading" : isError ? "error" : "success";
|
||||
|
||||
@@ -390,15 +400,17 @@ const FleetResourcePage = () => {
|
||||
<Group gap="sm">
|
||||
{slug === "wagons" ? (
|
||||
<>
|
||||
<Button
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
leftSection={<Warehouse size={16} />}
|
||||
styles={{ label: { fontWeight: 500 } }}
|
||||
onClick={() => setWagonWorkspaceOpen(true)}
|
||||
>
|
||||
Yard Workspace
|
||||
</Button>
|
||||
{canUpdate ? (
|
||||
<Button
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
leftSection={<Warehouse size={16} />}
|
||||
styles={{ label: { fontWeight: 500 } }}
|
||||
onClick={() => setWagonWorkspaceOpen(true)}
|
||||
>
|
||||
Yard Workspace
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="light"
|
||||
color="grape"
|
||||
@@ -410,12 +422,14 @@ const FleetResourcePage = () => {
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
<Button leftSection={<Plus size={16} />} styles={{ label: { fontWeight: 500 } }} onClick={() => {
|
||||
setEditing(null);
|
||||
setFormOpen(true);
|
||||
}}>
|
||||
{config.addLabel}
|
||||
</Button>
|
||||
{canCreate ? (
|
||||
<Button leftSection={<Plus size={16} />} styles={{ label: { fontWeight: 500 } }} onClick={() => {
|
||||
setEditing(null);
|
||||
setFormOpen(true);
|
||||
}}>
|
||||
{config.addLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
@@ -529,11 +543,15 @@ const FleetResourcePage = () => {
|
||||
pageCount={pageCount}
|
||||
totalCount={filteredRows.length}
|
||||
onPaginationChange={setPagination}
|
||||
onEdit={(record) => {
|
||||
setEditing(record);
|
||||
setFormOpen(true);
|
||||
}}
|
||||
onRemove={setRemoveTarget}
|
||||
onEdit={
|
||||
canUpdate
|
||||
? (record) => {
|
||||
setEditing(record);
|
||||
setFormOpen(true);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onRemove={canDelete ? setRemoveTarget : undefined}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,6 +36,8 @@ import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
|
||||
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||
import { api } from "@/services/api";
|
||||
import { ruleEngineService } from "@/services/ruleEngine/ruleEngine.service";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { canFleetAction } from "@/lib/permissions";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import {
|
||||
formatRouteLabel,
|
||||
@@ -160,6 +162,10 @@ export default function RoutesPage() {
|
||||
const { viewMode, setViewMode } = useFleetViewMode("routes");
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const { toast } = useToast();
|
||||
const { user } = useAuth();
|
||||
const canCreate = canFleetAction(user, "routes", "create");
|
||||
const canUpdate = canFleetAction(user, "routes", "update");
|
||||
const canDelete = canFleetAction(user, "routes", "delete");
|
||||
|
||||
const routesQuery = useQuery(api.routes.list.queryOptions());
|
||||
const yardsQuery = useQuery(api.routes.yards.queryOptions());
|
||||
@@ -441,26 +447,30 @@ export default function RoutesPage() {
|
||||
<Eye size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label="Edit">
|
||||
<ActionIcon variant="subtle" color="gray" onClick={() => openEdit(row.original)}>
|
||||
<Edit size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label="Mark stop working">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
disabled={row.original.status === "STOP_WORKING" || deactivateMutation.isPending}
|
||||
onClick={() => handleDeactivate(row.original)}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
{canUpdate ? (
|
||||
<Tooltip label="Edit">
|
||||
<ActionIcon variant="subtle" color="gray" onClick={() => openEdit(row.original)}>
|
||||
<Edit size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{canDelete ? (
|
||||
<Tooltip label="Mark stop working">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
disabled={row.original.status === "STOP_WORKING" || deactivateMutation.isPending}
|
||||
onClick={() => handleDeactivate(row.original)}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
];
|
||||
}, [deactivateMutation.isPending]);
|
||||
}, [deactivateMutation.isPending, canUpdate, canDelete]);
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
@@ -468,9 +478,11 @@ export default function RoutesPage() {
|
||||
title="Routes"
|
||||
subtitle="Define rail corridors, segment distances, and operational status for train scheduling."
|
||||
action={
|
||||
<Button leftSection={<Plus size={18} />} onClick={openCreate}>
|
||||
Add route
|
||||
</Button>
|
||||
canCreate ? (
|
||||
<Button leftSection={<Plus size={18} />} onClick={openCreate}>
|
||||
Add route
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -555,9 +567,11 @@ export default function RoutesPage() {
|
||||
<Button variant="light" size="compact-sm" onClick={() => setViewing(route)}>
|
||||
View
|
||||
</Button>
|
||||
<Button variant="light" size="compact-sm" onClick={() => openEdit(route)}>
|
||||
Edit
|
||||
</Button>
|
||||
{canUpdate ? (
|
||||
<Button variant="light" size="compact-sm" onClick={() => openEdit(route)}>
|
||||
Edit
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
Loader,
|
||||
Modal,
|
||||
Stack,
|
||||
Tabs,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
@@ -94,7 +95,14 @@ const yardOptionsForLegEnd = (
|
||||
let country: string | undefined;
|
||||
if (appliesTo === "INTERCITY") {
|
||||
country = "Ethiopia";
|
||||
} else if (appliesTo === "CONTAINER" || appliesTo === "BULK") {
|
||||
} else if (
|
||||
appliesTo === "CONTAINER" ||
|
||||
appliesTo === "BULK" ||
|
||||
// Customs clearance + empty-container return are sold per direction +
|
||||
// route, so their yard dropdowns narrow exactly like base freight.
|
||||
(appliesTo === "OTHER" &&
|
||||
["CUSTOMS_CLEARANCE", "WITH_RETURN"].includes(String(values.trigger ?? "")))
|
||||
) {
|
||||
const direction = String(values.tradeDirection ?? "");
|
||||
// Direction is what decides the countries, so offer nothing until it is set
|
||||
// rather than defaulting to one and letting it read as a real choice.
|
||||
@@ -124,6 +132,10 @@ const RuleEngineResourcePage = () => {
|
||||
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const [search, setSearch] = useState("");
|
||||
// Category tabs (rates page): the active tab's filters go to the backend.
|
||||
const [activeTab, setActiveTab] = useState<string>(
|
||||
config?.listTabs?.[0]?.key ?? "",
|
||||
);
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<RuleEngineRecord | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<RuleEngineRecord | null>(
|
||||
@@ -153,10 +165,13 @@ const RuleEngineResourcePage = () => {
|
||||
sortOrder: "ASC" as const,
|
||||
}
|
||||
: {}),
|
||||
...(config?.listTabs?.find((t) => t.key === activeTab)?.filters ?? {}),
|
||||
}),
|
||||
[
|
||||
config?.orderConfig,
|
||||
config?.supportsSearch,
|
||||
config?.listTabs,
|
||||
activeTab,
|
||||
search,
|
||||
pagination.pageIndex,
|
||||
pagination.pageSize,
|
||||
@@ -166,7 +181,8 @@ const RuleEngineResourcePage = () => {
|
||||
useEffect(() => {
|
||||
setPagination((prev) => ({ pageIndex: 0, pageSize: prev.pageSize }));
|
||||
setSearch("");
|
||||
}, [config?.slug, setPagination]);
|
||||
setActiveTab(config?.listTabs?.[0]?.key ?? "");
|
||||
}, [config?.slug, config?.listTabs, setPagination]);
|
||||
|
||||
const { data, isLoading, isError, error } = useRuleEngineList(
|
||||
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
|
||||
@@ -685,6 +701,25 @@ const RuleEngineResourcePage = () => {
|
||||
|
||||
<Card p={0}>
|
||||
<Stack gap={0}>
|
||||
{config.listTabs && (
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onChange={(v) => {
|
||||
setActiveTab(v ?? config.listTabs![0].key);
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
}}
|
||||
px="md"
|
||||
pt="sm"
|
||||
>
|
||||
<Tabs.List>
|
||||
{config.listTabs.map((tab) => (
|
||||
<Tabs.Tab key={tab.key} value={tab.key}>
|
||||
{tab.label}
|
||||
</Tabs.Tab>
|
||||
))}
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
)}
|
||||
<Box px="md" pt="md" pb="sm" w="100%">
|
||||
<RuleEngineToolbar
|
||||
search={search}
|
||||
|
||||
@@ -84,6 +84,17 @@ export interface RuleEngineOrderConfig {
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A category tab above a resource list. The active tab's `filters` are sent to
|
||||
* the list endpoint verbatim, so filtering happens server-side (values may be
|
||||
* comma-separated lists, e.g. appliesTo: "FIRST_MILE,LAST_MILE").
|
||||
*/
|
||||
export interface RuleEngineListTab {
|
||||
key: string;
|
||||
label: string;
|
||||
filters: { appliesTo?: string; trigger?: string };
|
||||
}
|
||||
|
||||
export interface RuleEngineResourceConfig {
|
||||
slug: RuleEngineResourceSlug;
|
||||
label: string;
|
||||
@@ -94,6 +105,8 @@ export interface RuleEngineResourceConfig {
|
||||
formFields: FormFieldDef[];
|
||||
supportsSearch?: boolean;
|
||||
orderConfig?: RuleEngineOrderConfig;
|
||||
/** Server-filtered category tabs rendered above the list (rates page). */
|
||||
listTabs?: RuleEngineListTab[];
|
||||
/** Primary line on card view (inferred from columns when omitted). */
|
||||
cardTitleKey?: string;
|
||||
/** Secondary line under title on card view (inferred when omitted). */
|
||||
@@ -148,9 +161,15 @@ const RATE_APPLIES_TO = [
|
||||
/** Surcharge triggers — only relevant when Applies to = Other. */
|
||||
const RATE_TRIGGERS = [
|
||||
{ label: "Hazardous cargo", value: "HAZARDOUS" },
|
||||
{ label: "Overweight (per excess ton)", value: "OVERWEIGHT" },
|
||||
{
|
||||
label: "Overweight (export only — import derives from container price)",
|
||||
value: "OVERWEIGHT",
|
||||
},
|
||||
{ label: "Reefer cargo", value: "REEFER" },
|
||||
{ label: "Empty container return", value: "WITH_RETURN" },
|
||||
{
|
||||
label: "Empty container return (import, per route + container type)",
|
||||
value: "WITH_RETURN",
|
||||
},
|
||||
{ label: "Shipping line mapped", value: "SHIPPING_LINE" },
|
||||
{ label: "Consolidation", value: "CONSOLIDATION" },
|
||||
{ label: "Lashing (flat, per booking)", value: "LASHING" },
|
||||
@@ -175,6 +194,15 @@ const INTERCITY_KINDS = [
|
||||
const isBaseFreightRate = (values: Record<string, unknown>) =>
|
||||
["BULK", "CONTAINER", "INTERCITY"].includes(String(values.appliesTo ?? ""));
|
||||
|
||||
/**
|
||||
* Rates priced per leg: base rail freight, plus the customs clearance fee and
|
||||
* the empty-container return surcharge (sold per route + container type).
|
||||
*/
|
||||
const isRouteScopedRate = (values: Record<string, unknown>) =>
|
||||
isBaseFreightRate(values) ||
|
||||
(String(values.appliesTo ?? "") === "OTHER" &&
|
||||
["CUSTOMS_CLEARANCE", "WITH_RETURN"].includes(String(values.trigger ?? "")));
|
||||
|
||||
const unitOption = (value: string) => ({ label: value.replace(/_/g, " "), value });
|
||||
|
||||
/**
|
||||
@@ -604,6 +632,37 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
subtitle: "Freight rates and approval workflow",
|
||||
searchPlaceholder: "Search rates by type or status...",
|
||||
supportsSearch: true,
|
||||
// Category tabs — each filters server-side by appliesTo / trigger.
|
||||
listTabs: [
|
||||
{ key: "all", label: "All", filters: {} },
|
||||
{ key: "container", label: "Container", filters: { appliesTo: "CONTAINER" } },
|
||||
{ key: "bulk", label: "Bulk", filters: { appliesTo: "BULK" } },
|
||||
{ key: "intercity", label: "Intercity", filters: { appliesTo: "INTERCITY" } },
|
||||
{
|
||||
key: "trucking",
|
||||
label: "First / Last mile",
|
||||
filters: { appliesTo: "FIRST_MILE,LAST_MILE" },
|
||||
},
|
||||
{
|
||||
key: "customs",
|
||||
label: "Customs clearance",
|
||||
filters: { trigger: "CUSTOMS_CLEARANCE" },
|
||||
},
|
||||
{
|
||||
key: "return",
|
||||
label: "Container return",
|
||||
filters: { trigger: "WITH_RETURN" },
|
||||
},
|
||||
{
|
||||
key: "surcharges",
|
||||
label: "Surcharges",
|
||||
filters: {
|
||||
appliesTo: "OTHER",
|
||||
trigger:
|
||||
"HAZARDOUS,OVERWEIGHT,REEFER,SHIPPING_LINE,CONSOLIDATION,LASHING,CANCELLATION,DEMURRAGE,PIL_EXTRA_FEE",
|
||||
},
|
||||
},
|
||||
],
|
||||
columns: [
|
||||
{ id: "appliesTo", header: "Applies to", accessorKey: "appliesTo", format: "code" },
|
||||
{ id: "trigger", header: "Trigger", accessorKey: "trigger" },
|
||||
@@ -640,14 +699,23 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
placeholder: "What makes this surcharge apply?",
|
||||
showWhen: { field: "appliesTo", equals: ["OTHER"] },
|
||||
},
|
||||
// ── Trade direction — Bulk & Container only (intercity is domestic) ───
|
||||
// ── Trade direction — Bulk & Container base freight, plus the route-
|
||||
// scoped surcharges (customs clearance; empty-container return, which is
|
||||
// import-only for now so export is not offered) ────────────────────────
|
||||
{
|
||||
name: "tradeDirection",
|
||||
label: "Trade direction",
|
||||
type: "select",
|
||||
required: true,
|
||||
options: TRADE_DIRECTIONS.filter((d) => d.value !== "BOTH"),
|
||||
showWhen: { field: "appliesTo", equals: ["BULK", "CONTAINER"] },
|
||||
optionsFromValues: (v: Record<string, unknown>) =>
|
||||
String(v.trigger ?? "") === "WITH_RETURN" &&
|
||||
String(v.appliesTo ?? "") === "OTHER"
|
||||
? TRADE_DIRECTIONS.filter((d) => d.value === "IMPORT")
|
||||
: TRADE_DIRECTIONS.filter((d) => d.value !== "BOTH"),
|
||||
showIf: (v) =>
|
||||
["BULK", "CONTAINER"].includes(String(v.appliesTo ?? "")) ||
|
||||
(String(v.appliesTo ?? "") === "OTHER" &&
|
||||
["CUSTOMS_CLEARANCE", "WITH_RETURN"].includes(String(v.trigger ?? ""))),
|
||||
},
|
||||
// ── Cargo kind — Intercity only (import/export get it from appliesTo) ─
|
||||
{
|
||||
@@ -664,7 +732,8 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
getInitialValue: (record) =>
|
||||
record.rateType === "INTERCITY_BULK" ? "BULK" : "CONTAINER",
|
||||
},
|
||||
// ── Container type — Container freight, and container-kind intercity ──
|
||||
// ── Container type — Container freight, container-kind intercity, and
|
||||
// the empty-container return surcharge (20ft vs 40ft price differently) ─
|
||||
{
|
||||
name: "containerTypeId",
|
||||
label: "Container type",
|
||||
@@ -673,7 +742,8 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
placeholder: "Select container type (optional)",
|
||||
showIf: (v) =>
|
||||
v.appliesTo === "CONTAINER" ||
|
||||
(v.appliesTo === "INTERCITY" && v.intercityKind === "CONTAINER"),
|
||||
(v.appliesTo === "INTERCITY" && v.intercityKind === "CONTAINER") ||
|
||||
(v.appliesTo === "OTHER" && v.trigger === "WITH_RETURN"),
|
||||
},
|
||||
// ── Bulk cargo (leaf commodity) — Bulk freight, and bulk-kind intercity ─
|
||||
{
|
||||
@@ -696,7 +766,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
type: "select",
|
||||
required: true,
|
||||
placeholder: "Where the leg starts",
|
||||
showIf: isBaseFreightRate,
|
||||
showIf: isRouteScopedRate,
|
||||
},
|
||||
{
|
||||
name: "destinationYardId",
|
||||
@@ -704,7 +774,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
type: "select",
|
||||
required: true,
|
||||
placeholder: "Where the leg ends",
|
||||
showIf: isBaseFreightRate,
|
||||
showIf: isRouteScopedRate,
|
||||
},
|
||||
{ name: "rateValue", label: "Rate value", type: "number", required: true, suffix: "USD" },
|
||||
// Unit choices are driven by the rate shape (appliesTo + trigger). Overweight
|
||||
|
||||
@@ -45,6 +45,8 @@ import {
|
||||
import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram";
|
||||
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
||||
import { api } from "@/services/api";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { canFleetAction, FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
const parseError = (error: unknown, fallback: string) => {
|
||||
@@ -76,6 +78,12 @@ export default function TrainBuilderDetailPage() {
|
||||
const [yardModalOpen, setYardModalOpen] = useState(false);
|
||||
const [disbandOpen, setDisbandOpen] = useState(false);
|
||||
const [deactivateOpen, setDeactivateOpen] = useState(false);
|
||||
const { user } = useAuth();
|
||||
const canUpdate = canFleetAction(user, "trains", "update");
|
||||
const canDelete = canFleetAction(user, "trains", "delete");
|
||||
const canAssign =
|
||||
hasPermission(user, FREIGHT_PERMS.trains.assignWagons) ||
|
||||
hasPermission(user, FREIGHT_PERMS.fleet.manage);
|
||||
|
||||
const compositionQuery = useQuery(
|
||||
api.trainBuilder.composition.queryOptions({ input: { id }, enabled: Boolean(id) }),
|
||||
@@ -162,59 +170,67 @@ export default function TrainBuilderDetailPage() {
|
||||
</Group>
|
||||
}
|
||||
action={
|
||||
<Menu position="bottom-end" withinPortal shadow="md" width={220}>
|
||||
<Menu.Target>
|
||||
<Button variant="default" rightSection={<MoreHorizontal size={16} />}>
|
||||
Actions
|
||||
</Button>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<Replace size={15} />}
|
||||
disabled={!composition.editable}
|
||||
onClick={() => setLocoModalOpen(true)}
|
||||
>
|
||||
Change locomotives
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<MapPin size={15} />}
|
||||
disabled={!composition.editable}
|
||||
onClick={() => setYardModalOpen(true)}
|
||||
>
|
||||
Change yard
|
||||
</Menu.Item>
|
||||
{composition.status === "DEACTIVATED" ? (
|
||||
<Menu.Item
|
||||
leftSection={<Power size={15} />}
|
||||
disabled={blockingLocomotives.length > 0}
|
||||
onClick={() =>
|
||||
void withToast(async () => {
|
||||
await activate.mutateAsync(composition.id);
|
||||
toast({ title: `Train ${composition.code} reactivated` });
|
||||
}, "Could not reactivate train")
|
||||
}
|
||||
>
|
||||
Reactivate train
|
||||
</Menu.Item>
|
||||
) : (
|
||||
<Menu.Item
|
||||
leftSection={<PowerOff size={15} />}
|
||||
disabled={composition.activeSchedules.length > 0}
|
||||
onClick={() => setDeactivateOpen(true)}
|
||||
>
|
||||
Deactivate train
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<Trash2 size={15} />}
|
||||
disabled={composition.activeSchedules.length > 0}
|
||||
onClick={() => setDisbandOpen(true)}
|
||||
>
|
||||
Disband train
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
canUpdate || canDelete ? (
|
||||
<Menu position="bottom-end" withinPortal shadow="md" width={220}>
|
||||
<Menu.Target>
|
||||
<Button variant="default" rightSection={<MoreHorizontal size={16} />}>
|
||||
Actions
|
||||
</Button>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
{canUpdate ? (
|
||||
<>
|
||||
<Menu.Item
|
||||
leftSection={<Replace size={15} />}
|
||||
disabled={!composition.editable}
|
||||
onClick={() => setLocoModalOpen(true)}
|
||||
>
|
||||
Change locomotives
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<MapPin size={15} />}
|
||||
disabled={!composition.editable}
|
||||
onClick={() => setYardModalOpen(true)}
|
||||
>
|
||||
Change yard
|
||||
</Menu.Item>
|
||||
{composition.status === "DEACTIVATED" ? (
|
||||
<Menu.Item
|
||||
leftSection={<Power size={15} />}
|
||||
disabled={blockingLocomotives.length > 0}
|
||||
onClick={() =>
|
||||
void withToast(async () => {
|
||||
await activate.mutateAsync(composition.id);
|
||||
toast({ title: `Train ${composition.code} reactivated` });
|
||||
}, "Could not reactivate train")
|
||||
}
|
||||
>
|
||||
Reactivate train
|
||||
</Menu.Item>
|
||||
) : (
|
||||
<Menu.Item
|
||||
leftSection={<PowerOff size={15} />}
|
||||
disabled={composition.activeSchedules.length > 0}
|
||||
onClick={() => setDeactivateOpen(true)}
|
||||
>
|
||||
Deactivate train
|
||||
</Menu.Item>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
{canDelete ? (
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<Trash2 size={15} />}
|
||||
disabled={composition.activeSchedules.length > 0}
|
||||
onClick={() => setDisbandOpen(true)}
|
||||
>
|
||||
Disband train
|
||||
</Menu.Item>
|
||||
) : null}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -259,16 +275,18 @@ export default function TrainBuilderDetailPage() {
|
||||
.
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="light"
|
||||
color="red"
|
||||
leftSection={<Replace size={14} />}
|
||||
disabled={!composition.editable}
|
||||
onClick={() => setLocoModalOpen(true)}
|
||||
>
|
||||
Detach & replace locomotives
|
||||
</Button>
|
||||
{canUpdate ? (
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="light"
|
||||
color="red"
|
||||
leftSection={<Replace size={14} />}
|
||||
disabled={!composition.editable}
|
||||
onClick={() => setLocoModalOpen(true)}
|
||||
>
|
||||
Detach & replace locomotives
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="subtle"
|
||||
@@ -330,7 +348,7 @@ export default function TrainBuilderDetailPage() {
|
||||
</Stack>
|
||||
|
||||
<Grid gap="lg" align="stretch">
|
||||
{composition.editable ? (
|
||||
{composition.editable && canAssign ? (
|
||||
<Grid.Col span={{ base: 12, md: 5 }}>
|
||||
<Card h="100%">
|
||||
<Stack gap="sm">
|
||||
@@ -355,7 +373,7 @@ export default function TrainBuilderDetailPage() {
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
) : null}
|
||||
<Grid.Col span={{ base: 12, md: composition.editable ? 7 : 12 }}>
|
||||
<Grid.Col span={{ base: 12, md: composition.editable && canAssign ? 7 : 12 }}>
|
||||
<Card h="100%">
|
||||
<Stack gap="sm">
|
||||
<Text fw={600}>Wagon order</Text>
|
||||
@@ -364,7 +382,7 @@ export default function TrainBuilderDetailPage() {
|
||||
</Text>
|
||||
<ConsistWagonList
|
||||
wagons={composition.wagons}
|
||||
editable={composition.editable}
|
||||
editable={composition.editable && canAssign}
|
||||
busy={busy}
|
||||
onReorder={(wagonIds) =>
|
||||
void withToast(
|
||||
|
||||
@@ -35,6 +35,8 @@ import {
|
||||
trainStatusLabel,
|
||||
} from "@/components/trainBuilder/trainStatus";
|
||||
import { api } from "@/services/api";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { canFleetAction } from "@/lib/permissions";
|
||||
import type {
|
||||
BuiltTrainListFilters,
|
||||
BuiltTrainStatus,
|
||||
@@ -54,6 +56,9 @@ export default function TrainBuilderListPage() {
|
||||
const [yardFilter, setYardFilter] = useState("ALL");
|
||||
const [buildOpen, setBuildOpen] = useState(false);
|
||||
const [editTarget, setEditTarget] = useState<BuiltTrainSummary | null>(null);
|
||||
const { user } = useAuth();
|
||||
const canCreate = canFleetAction(user, "trains", "create");
|
||||
const canUpdate = canFleetAction(user, "trains", "update");
|
||||
|
||||
const resetPage = useCallback(() => {
|
||||
setPagination((prev) =>
|
||||
@@ -240,24 +245,25 @@ export default function TrainBuilderListPage() {
|
||||
id: "actions",
|
||||
header: "",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label={`Edit train ${row.original.code}`}
|
||||
title="Edit name & train numbers"
|
||||
onClick={(e) => {
|
||||
// Row click navigates to the detail page — keep the edit local.
|
||||
e.stopPropagation();
|
||||
setEditTarget(row.original);
|
||||
}}
|
||||
>
|
||||
<Pencil size={15} />
|
||||
</ActionIcon>
|
||||
),
|
||||
cell: ({ row }) =>
|
||||
canUpdate ? (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label={`Edit train ${row.original.code}`}
|
||||
title="Edit name & train numbers"
|
||||
onClick={(e) => {
|
||||
// Row click navigates to the detail page — keep the edit local.
|
||||
e.stopPropagation();
|
||||
setEditTarget(row.original);
|
||||
}}
|
||||
>
|
||||
<Pencil size={15} />
|
||||
</ActionIcon>
|
||||
) : null,
|
||||
},
|
||||
];
|
||||
}, []);
|
||||
}, [canUpdate]);
|
||||
|
||||
const tableStatus = trainsQuery.isLoading
|
||||
? "loading"
|
||||
@@ -271,9 +277,11 @@ export default function TrainBuilderListPage() {
|
||||
title="Train Builder"
|
||||
subtitle="Assemble coded trains from locomotives and wagons in a yard, ready to schedule as a unit."
|
||||
action={
|
||||
<Button leftSection={<Hammer size={18} />} onClick={() => setBuildOpen(true)}>
|
||||
Build train
|
||||
</Button>
|
||||
canCreate ? (
|
||||
<Button leftSection={<Hammer size={18} />} onClick={() => setBuildOpen(true)}>
|
||||
Build train
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
@@ -935,6 +935,7 @@ export default function TrainScheduleV2DetailPage() {
|
||||
stops={schedule.stops ?? []}
|
||||
bookings={schedule.bookings ?? []}
|
||||
maxWagons={schedule.maxWagons}
|
||||
maxGrossTons={schedule.maxGrossWeightTons}
|
||||
/>
|
||||
) : (
|
||||
<Box maw={340}>
|
||||
|
||||
@@ -49,7 +49,7 @@ export interface GetIframeUrlRequest {
|
||||
|
||||
// Create a dedicated axios instance for Metabase API
|
||||
const metabaseAxios = axios.create({
|
||||
baseURL: getEnvUrl("VITE_CHRONICLE_URL"),
|
||||
baseURL: getEnvUrl("VITE_CHRONICLE_URL", false),
|
||||
});
|
||||
|
||||
// Add auth token interceptor
|
||||
|
||||
@@ -17,6 +17,9 @@ export interface RuleEngineListParams {
|
||||
sortBy?: string;
|
||||
sortOrder?: "ASC" | "DESC";
|
||||
requiresDirectorApproval?: boolean;
|
||||
/** Rates category tabs — comma-separated appliesTo / trigger filters. */
|
||||
appliesTo?: string;
|
||||
trigger?: string;
|
||||
}
|
||||
|
||||
export interface RuleEngineReorderPayload {
|
||||
@@ -205,6 +208,8 @@ export const ruleEngineService = {
|
||||
sortBy: params?.sortBy,
|
||||
sortOrder: params?.sortOrder,
|
||||
requiresDirectorApproval: params?.requiresDirectorApproval,
|
||||
appliesTo: params?.appliesTo,
|
||||
trigger: params?.trigger,
|
||||
},
|
||||
});
|
||||
return normalizeList<T>(response.data, page, pageSize);
|
||||
|
||||
@@ -9,5 +9,3 @@ function normalizeBaseUrl(url: string) {
|
||||
export const AUDITLOG_API_URL: string = normalizeBaseUrl(
|
||||
getEnvUrl("VITE_AUDITLOG_API_URL", false),
|
||||
);
|
||||
|
||||
if (!AUDITLOG_API_URL) console.warn("Missing VITE_AUDITLOG_API_URL");
|
||||
|
||||
@@ -9,18 +9,12 @@ import {
|
||||
} from "../utils/authPersistence";
|
||||
import { handleSessionExpiry } from "./sessionExpiry";
|
||||
|
||||
if (!import.meta.env.VITE_CHRONICLE_URL) {
|
||||
console.warn("Missing VITE_CHRONICLE_URL — chronicle axios instance has no base URL");
|
||||
}
|
||||
|
||||
// Chronicle/audit-log backend is optional in this deployment — no warning
|
||||
// when unset; the module's screens are simply non-functional without it.
|
||||
const chronicleBaseUrl =
|
||||
getEnvUrl("VITE_CHRONICLE_URL", false) ||
|
||||
getEnvUrl("VITE_AUDITLOG_API_URL", false);
|
||||
|
||||
if (!chronicleBaseUrl) {
|
||||
console.warn("Missing VITE_CHRONICLE_URL and VITE_AUDITLOG_API_URL");
|
||||
}
|
||||
|
||||
const chronicleInstance = axios.create({
|
||||
baseURL: chronicleBaseUrl,
|
||||
});
|
||||
|
||||
@@ -8,14 +8,10 @@ import {
|
||||
} from "../utils/authPersistence";
|
||||
import { handleSessionExpiry } from "./sessionExpiry";
|
||||
|
||||
if (!import.meta.env.VITE_RECORD_API_URL) {
|
||||
console.warn(
|
||||
"Missing VITE_RECORD_API_URL — record axios instance has no base URL",
|
||||
);
|
||||
}
|
||||
|
||||
// Record backend is optional in this deployment — no warning when unset; the
|
||||
// module's screens are simply non-functional without it.
|
||||
const recordAxiosInstance = axios.create({
|
||||
baseURL: getEnvUrl("VITE_RECORD_API_URL"),
|
||||
baseURL: getEnvUrl("VITE_RECORD_API_URL", false),
|
||||
});
|
||||
|
||||
// Attach auth token and CSRF defence header to every request
|
||||
|
||||
@@ -169,6 +169,8 @@ export interface BookingDetail {
|
||||
contractType: string;
|
||||
freightType: "CONTAINER" | "BULK";
|
||||
tradeDirection: string;
|
||||
/** What the containers carry / bulk commodity label — entered at booking time. */
|
||||
cargoFreeText?: string | null;
|
||||
cargoTotalWeightVgm: number;
|
||||
isHazardous: boolean;
|
||||
consolidationPartnerId?: string | null;
|
||||
|
||||
@@ -640,6 +640,8 @@ export interface TrainScheduleDetail {
|
||||
}>;
|
||||
/** Ordered corridor stops (route milestones) — for per-segment occupancy. */
|
||||
stops?: Array<{ yardId: string; label: string }>;
|
||||
/** Loco pull ceiling incl. overage tolerance — per-leg gross is held to it. */
|
||||
maxGrossWeightTons?: number | null;
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import Cookies from "js-cookie";
|
||||
import { getEnvUrl } from "@/shared/config/env";
|
||||
|
||||
const TENANT_ID = "adf98293-41ba-4bda-bdb4-70e30a70c1b7";
|
||||
const API_URL = getEnvUrl("VITE_RECORD_API_URL");
|
||||
const API_URL = getEnvUrl("VITE_RECORD_API_URL", false);
|
||||
|
||||
const apiInstance = axios.create({
|
||||
baseURL: API_URL,
|
||||
|
||||
@@ -11,7 +11,7 @@ import Cookies from "js-cookie";
|
||||
import { getEnvUrl } from "@/shared/config/env";
|
||||
|
||||
const TENANT_ID = "adf98293-41ba-4bda-bdb4-70e30a70c1b7";
|
||||
const API_URL = getEnvUrl("VITE_RECORD_API_URL");
|
||||
const API_URL = getEnvUrl("VITE_RECORD_API_URL", false);
|
||||
|
||||
// Create axios instance for template API
|
||||
const apiInstance = axios.create({
|
||||
|
||||
@@ -113,7 +113,7 @@ export function BulkTruckUploadModal({
|
||||
<Text fw={600} mb="xs">
|
||||
Preview ({parsed.length} trucks)
|
||||
</Text>
|
||||
<Table striped highlightOnHover size="sm">
|
||||
<Table striped highlightOnHover verticalSpacing="xs">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Plate Number</Table.Th>
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Checkbox,
|
||||
Divider,
|
||||
Group,
|
||||
Loader,
|
||||
@@ -24,9 +25,23 @@ import { customerTrucksService } from "@/services/customer-trucks.service";
|
||||
|
||||
import { CardTitle, SectionCard } from "./layout";
|
||||
import { BulkTruckUploadModal } from "./BulkTruckUploadModal";
|
||||
import { generateTruckAssignmentTemplate } from "@/utils/truck-assignment-template";
|
||||
|
||||
const TRUCK_TYPES = ["Flatbed", "Container Chassis", "Lowboy", "Box Truck", "Tipper"];
|
||||
|
||||
// Waybill-style selectable copies (indexes 1-8 in the API catalog). The 2 gate
|
||||
// copies (Port Operations, Gate Security & Carrier) are always printed.
|
||||
const FREIGHT_ORDER_COPIES = [
|
||||
{ index: 1, label: "Original 1 (for Issuing Carrier)" },
|
||||
{ index: 2, label: "Original 2 (for Consignee)" },
|
||||
{ index: 3, label: "Original 3 (for Shipper)" },
|
||||
{ index: 4, label: "Copy 4 (Delivery Receipt)" },
|
||||
{ index: 5, label: "Copy 5 (Extra Copy)" },
|
||||
{ index: 6, label: "Copy 6 (Extra Copy)" },
|
||||
{ index: 7, label: "Copy 7 (Extra Copy)" },
|
||||
{ index: 8, label: "Copy 8 (for Agent)" },
|
||||
];
|
||||
|
||||
const downloadBlob = (blob: Blob, filename: string) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
@@ -138,8 +153,11 @@ export function CustomerTruckAssignmentCard({
|
||||
});
|
||||
|
||||
const downloadMutation = useMutation(api.bookings.downloadCustomerTruckFreightOrder.mutationOptions());
|
||||
const [selectedCopies, setSelectedCopies] = useState<number[]>(
|
||||
FREIGHT_ORDER_COPIES.map((c) => c.index),
|
||||
);
|
||||
const downloadFreightOrder = async () => {
|
||||
const blob = await downloadMutation.mutateAsync({ id: booking.id });
|
||||
const blob = await downloadMutation.mutateAsync({ id: booking.id, copies: selectedCopies });
|
||||
downloadBlob(blob, `freight-order-${booking.reference}.pdf`);
|
||||
};
|
||||
|
||||
@@ -165,14 +183,24 @@ export function CustomerTruckAssignmentCard({
|
||||
<CardTitle>External Truck Assignment</CardTitle>
|
||||
</Group>
|
||||
<Group gap={12}>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
leftSection={<Upload size={14} />}
|
||||
onClick={() => setBulkModalOpen(true)}
|
||||
>
|
||||
Bulk Upload
|
||||
</Button>
|
||||
<Group gap="sm">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="default"
|
||||
leftSection={<Download size={14} />}
|
||||
onClick={() => generateTruckAssignmentTemplate("truck-assignments.xlsx")}
|
||||
>
|
||||
Download Template
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
leftSection={<Upload size={14} />}
|
||||
onClick={() => setBulkModalOpen(true)}
|
||||
>
|
||||
Bulk Upload
|
||||
</Button>
|
||||
</Group>
|
||||
{pendingAssignmentCount > 0 && (
|
||||
<Text size="sm" fw={600} c="#b45309">
|
||||
{pendingAssignmentCount} container{pendingAssignmentCount !== 1 ? "s" : ""} pending assignment
|
||||
@@ -322,17 +350,52 @@ export function CustomerTruckAssignmentCard({
|
||||
)}
|
||||
|
||||
{trucks.length > 0 && (
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<Download size={16} />}
|
||||
color="edr-green"
|
||||
onClick={downloadFreightOrder}
|
||||
loading={downloadMutation.isPending}
|
||||
>
|
||||
Generate Freight Order Copies
|
||||
</Button>
|
||||
</Group>
|
||||
<Stack gap="xs">
|
||||
<Text fz="13px" fw={600}>Copies</Text>
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="default"
|
||||
onClick={() => setSelectedCopies(FREIGHT_ORDER_COPIES.map((c) => c.index))}
|
||||
>
|
||||
8 copies
|
||||
</Button>
|
||||
<Button size="compact-xs" variant="default" onClick={() => setSelectedCopies([])}>
|
||||
clear selection
|
||||
</Button>
|
||||
</Group>
|
||||
<SimpleGrid cols={2} spacing={6} verticalSpacing={6}>
|
||||
{FREIGHT_ORDER_COPIES.map((c) => (
|
||||
<Checkbox
|
||||
key={c.index}
|
||||
size="xs"
|
||||
label={c.label}
|
||||
checked={selectedCopies.includes(c.index)}
|
||||
onChange={(e) =>
|
||||
setSelectedCopies((prev) =>
|
||||
e.currentTarget.checked
|
||||
? [...prev, c.index].sort((a, b) => a - b)
|
||||
: prev.filter((i) => i !== c.index),
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
<Group justify="space-between" align="center">
|
||||
<Text fz="11.5px" c="#9AA8B5">
|
||||
Port Operations and Gate Security copies are always included.
|
||||
</Text>
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<Download size={16} />}
|
||||
color="edr-green"
|
||||
onClick={downloadFreightOrder}
|
||||
loading={downloadMutation.isPending}
|
||||
>
|
||||
Generate Freight Order Copies
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import { isViewable } from "@edr/ui-common";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import { fetchViewableFile, downloadStoredFile } from "@/services/files.service";
|
||||
import { warehouseService } from "@/services/warehouse.service";
|
||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||
import { BookingActionModal } from "@/pages/bookings/clearance/BookingActionModal";
|
||||
import { getBookingNextAction } from "@/pages/bookings/clearance/bookingNextAction";
|
||||
@@ -241,6 +242,11 @@ export function DocumentsTab({ booking }: { booking: Freight.IBooking }) {
|
||||
}),
|
||||
);
|
||||
|
||||
const { data: handovers = [] } = useQuery({
|
||||
queryKey: ["bookingHandovers", booking.id],
|
||||
queryFn: () => warehouseService.bookingHandovers(booking.id),
|
||||
});
|
||||
|
||||
const customerDocs = useMemo(
|
||||
() => (clearance?.documents ?? []).filter((d) => d.uploadedBy === "customer"),
|
||||
[clearance],
|
||||
@@ -486,6 +492,69 @@ export function DocumentsTab({ booking }: { booking: Freight.IBooking }) {
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{/* ── 4. Handover signatures ──────────────────────────────────────── */}
|
||||
{handovers.length > 0 && (
|
||||
<SectionCard>
|
||||
<CardTitle>Handover signatures</CardTitle>
|
||||
<Text fz="12.5px" c="dimmed" mt={4} mb="sm">
|
||||
Records of goods handover and customer signatures.
|
||||
</Text>
|
||||
<Stack gap={0}>
|
||||
{handovers.map((h, i) => (
|
||||
<Box
|
||||
key={h.id}
|
||||
px="md"
|
||||
py="sm"
|
||||
style={{
|
||||
borderBottom:
|
||||
i !== handovers.length - 1
|
||||
? `1px solid ${BORDER}`
|
||||
: "none",
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<Box>
|
||||
<Text fw={500} fz="14px">
|
||||
{h.reference}
|
||||
</Text>
|
||||
<Text fz="12.5px" c="dimmed" mt={2}>
|
||||
{h.mileType === "SELF_HAUL"
|
||||
? "Customer truck delivery"
|
||||
: `EDR delivery${h.truckPlate ? ` (${h.truckPlate})` : ""}`}
|
||||
</Text>
|
||||
{h.signedAt && (
|
||||
<Text fz="12.5px" c="dimmed" mt={1}>
|
||||
Signed by {h.signerName || "Unknown"} on{" "}
|
||||
{new Date(h.signedAt).toLocaleDateString()}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Pill
|
||||
tone={h.signedAt ? "green" : "blue"}
|
||||
label={h.signedAt ? "Signed" : "Pending signature"}
|
||||
/>
|
||||
</Group>
|
||||
{h.signedAt && h.signatureImageUrl && (
|
||||
<Box mt="sm">
|
||||
<img
|
||||
src={h.signatureImageUrl}
|
||||
alt="Signature"
|
||||
style={{
|
||||
maxHeight: "60px",
|
||||
maxWidth: "200px",
|
||||
border: `1px solid ${BORDER}`,
|
||||
borderRadius: "4px",
|
||||
padding: "4px",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{/* ── Warehouse documents (one-click bundle) ──────────────────────── */}
|
||||
<SectionCard>
|
||||
<CardTitle>Warehouse documents</CardTitle>
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
} from "react-router-dom";
|
||||
import useAuth from "@/hooks/useAuth";
|
||||
import {
|
||||
CONTAINER_SIZES,
|
||||
CONTRACT_STEPS,
|
||||
ContractFormInputValues,
|
||||
contractFormSchema,
|
||||
@@ -537,15 +538,15 @@ export default function NewContractPage({
|
||||
const isContainer = data.cargoType === "container";
|
||||
const isGeneral = data.contractKind === "general_contract";
|
||||
|
||||
// Cargo scope rows — no quantities (doc §5.4). Container: one row per enabled
|
||||
// size; bulk: a single commodity row. Both GENERAL and ONE_TIME are uncapped
|
||||
// (quantityCap omitted → NULL): the customer books repeatedly against a
|
||||
// GENERAL contract until its validity expires.
|
||||
// Cargo scope rows — no quantities (doc §5.4). Container: ALWAYS both sizes
|
||||
// (rates quoted for both; per-booking quantities can zero a size out) and no
|
||||
// description — that moved to booking time. Bulk: a single commodity row.
|
||||
// Both GENERAL and ONE_TIME are uncapped (quantityCap omitted → NULL): the
|
||||
// customer books repeatedly against a GENERAL contract until its validity
|
||||
// expires.
|
||||
const cargoScope: Freight.CreateContractCargoScopeDto[] = isContainer
|
||||
? data.enabledContainerSizes.map((size) => ({
|
||||
? CONTAINER_SIZES.map((size) => ({
|
||||
containerSize: size,
|
||||
// Required cargo description — what the containers carry.
|
||||
cargoFreeText: data.cargoFreeText.trim() || undefined,
|
||||
}))
|
||||
: [
|
||||
{
|
||||
|
||||
@@ -346,6 +346,10 @@ function NewShipmentBookingForm({
|
||||
// Equipment return is a container concern — bulk keeps the contract default.
|
||||
...(isContainer
|
||||
? {
|
||||
// What the containers carry — captured per booking, not on the contract.
|
||||
...(values.cargoDescription?.trim()
|
||||
? { cargoFreeText: values.cargoDescription.trim() }
|
||||
: {}),
|
||||
containers: values.containers
|
||||
.filter((l) => Number(l.quantity) >= 1)
|
||||
.map((l) => ({
|
||||
@@ -1295,6 +1299,26 @@ function CargoStep({
|
||||
)}
|
||||
{remainderNotice}
|
||||
<ContractCapacityNotice contractId={contract.id} isContainer />
|
||||
<Controller
|
||||
name="cargoDescription"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Textarea
|
||||
label="Cargo description *"
|
||||
description="What do the containers carry on this shipment?"
|
||||
placeholder="e.g. Electronics, garments, machinery spare parts…"
|
||||
value={field.value ?? ""}
|
||||
onChange={(e) => field.onChange(e.currentTarget.value)}
|
||||
onBlur={field.onBlur}
|
||||
error={fieldState.error?.message}
|
||||
radius={10}
|
||||
autosize
|
||||
minRows={2}
|
||||
maxRows={4}
|
||||
styles={fieldStyles}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{lines.map((line, index) => (
|
||||
<ContainerLineEditor
|
||||
key={line.containerSize}
|
||||
@@ -1648,7 +1672,7 @@ function ContainerLineEditor({
|
||||
type="number"
|
||||
onKeyDown={blockNegative}
|
||||
label="Quantity *"
|
||||
min={1}
|
||||
min={0}
|
||||
error={fieldState.error?.message}
|
||||
radius={10}
|
||||
styles={fieldStyles}
|
||||
|
||||
@@ -58,12 +58,9 @@ export function contractToFormValues(
|
||||
|
||||
const scope = contract.cargoScope ?? [];
|
||||
|
||||
// Container scope: one row per enabled size, with per-size caps for GENERAL.
|
||||
const enabledContainerSizes = isContainer
|
||||
? scope
|
||||
.map((s) => s.containerSize)
|
||||
.filter((s): s is string => Boolean(s))
|
||||
: [];
|
||||
// Container scope: contracts now always cover both sizes — force both even
|
||||
// for older single-size drafts so resubmitting upgrades them.
|
||||
const enabledContainerSizes = isContainer ? ["20ft", "40ft"] : [];
|
||||
const containerSizeCaps: Record<string, number> = {};
|
||||
if (isContainer && isGeneral) {
|
||||
for (const s of scope) {
|
||||
@@ -119,10 +116,9 @@ export function contractToFormValues(
|
||||
enabledContainerSizes as ContractFormInputValues["enabledContainerSizes"],
|
||||
containerSizeCaps,
|
||||
cargoTypePath,
|
||||
// Bulk: the commodity free-text; container: the required cargo
|
||||
// description (stored on every size row — read the first).
|
||||
cargoFreeText:
|
||||
(isContainer ? scope[0]?.cargoFreeText : bulkRow?.cargoFreeText) ?? "",
|
||||
// Bulk commodity free-text only — the container cargo description is
|
||||
// captured per booking now, not on the contract.
|
||||
cargoFreeText: (isContainer ? "" : bulkRow?.cargoFreeText) ?? "",
|
||||
bulkQuantityCap:
|
||||
isGeneral && bulkRow?.quantityCap != null ? bulkRow.quantityCap : 0,
|
||||
isHazardous: contract.isHazardous,
|
||||
|
||||
@@ -160,8 +160,9 @@ export const contractFormSchema = z
|
||||
|
||||
// ── Cargo SCOPE (no quantities) ──
|
||||
cargoType: z.enum(["container", "bulk"], "Select a cargo type."),
|
||||
// Container scope: the enabled sizes (min 1). Each becomes a
|
||||
// contract_cargo_scope row.
|
||||
// Container scope: ALWAYS both sizes — the contract covers 20ft and 40ft
|
||||
// (both rates shown); the customer picks quantities per booking, where a
|
||||
// size can be 0. No picker in the UI; kept for review display/prefill.
|
||||
enabledContainerSizes: z.array(z.enum(CONTAINER_SIZES)).default([]),
|
||||
// GENERAL only: per-size container quantity cap (total bookable over the
|
||||
// validity window). Keyed by size; must be > 0 for every enabled size
|
||||
@@ -222,24 +223,8 @@ export const contractFormSchema = z
|
||||
message: "Intercity contracts are priced in ETB.",
|
||||
});
|
||||
}
|
||||
if (data.cargoType === "container") {
|
||||
// Container scope: at least one enabled size.
|
||||
if (data.enabledContainerSizes.length === 0) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["enabledContainerSizes"],
|
||||
message: "Enable at least one container size.",
|
||||
});
|
||||
}
|
||||
// Containerized cargo must say WHAT is inside — required description.
|
||||
if (!data.cargoFreeText.trim()) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["cargoFreeText"],
|
||||
message: "Describe the cargo carried in the containers.",
|
||||
});
|
||||
}
|
||||
}
|
||||
// Container scope needs no validation: both sizes are always in scope and
|
||||
// the cargo description moved to booking time.
|
||||
if (data.cargoType === "bulk") {
|
||||
// Bulk scope: a commodity is required.
|
||||
if (!data.cargoTypePath[0]) {
|
||||
@@ -280,7 +265,7 @@ export const initialContractFormValues: DeepPartial<ContractFormValues> = {
|
||||
customsClearingAgent: "",
|
||||
|
||||
cargoType: "container",
|
||||
enabledContainerSizes: [],
|
||||
enabledContainerSizes: [...CONTAINER_SIZES],
|
||||
containerSizeCaps: {},
|
||||
cargoTypePath: [],
|
||||
cargoFreeText: "",
|
||||
|
||||
@@ -152,10 +152,11 @@ export function Step1ContractType({
|
||||
contract.freightType === "BULK" ? "bulk" : "container",
|
||||
);
|
||||
const scope = contract.cargoScope ?? [];
|
||||
const sizes = scope
|
||||
.map((s) => s.containerSize)
|
||||
.filter((s): s is "20ft" | "40ft" => s === "20ft" || s === "40ft");
|
||||
if (sizes.length > 0) form.setValue("enabledContainerSizes", sizes);
|
||||
// Contracts always cover both sizes now — even when renewing an older
|
||||
// single-size contract.
|
||||
if (contract.freightType !== "BULK") {
|
||||
form.setValue("enabledContainerSizes", ["20ft", "40ft"]);
|
||||
}
|
||||
const bulkScope = scope.find((s) => s.cargoTypeId);
|
||||
if (bulkScope?.cargoTypeId) {
|
||||
// Find the parent group for this commodity so the cascader prefills.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { Controller, type UseFormReturn } from "react-hook-form";
|
||||
import { Check, Container, Flame, RotateCcw, Snowflake } from "lucide-react";
|
||||
import { Container, Flame, RotateCcw, Snowflake } from "lucide-react";
|
||||
import {
|
||||
Box,
|
||||
Group,
|
||||
@@ -9,33 +9,15 @@ import {
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
Textarea,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import type { Freight } from "@edr/types";
|
||||
import {
|
||||
CONTAINER_SIZES,
|
||||
ContractFormInputValues,
|
||||
type ContractFormValues,
|
||||
} from "./schema";
|
||||
import { fieldStyles, SelectField, StepLabel } from "./shared";
|
||||
|
||||
const CONTAINER_SIZE_OPTIONS: Array<{
|
||||
value: "20ft" | "40ft";
|
||||
label: string;
|
||||
description: string;
|
||||
}> = [
|
||||
{
|
||||
value: "20ft",
|
||||
label: "20ft Container",
|
||||
description: "Standard twenty-foot unit (TEU)",
|
||||
},
|
||||
{
|
||||
value: "40ft",
|
||||
label: "40ft Container",
|
||||
description: "Standard forty-foot unit (FEU)",
|
||||
},
|
||||
];
|
||||
|
||||
const CARGO_TYPE_OPTIONS = [
|
||||
{ value: "container", label: "Containerized (20ft / 40ft)" },
|
||||
{ value: "bulk", label: "General / Bulk cargo" },
|
||||
@@ -128,11 +110,17 @@ export function Step3CargoScope({
|
||||
onChange={(v) => {
|
||||
if (!v) return;
|
||||
field.onChange(v);
|
||||
// cargoFreeText is shared (bulk commodity label / container
|
||||
// description) — clear it so text never carries across types.
|
||||
// cargoFreeText is the bulk commodity label — clear it so text
|
||||
// never carries across types (container description is captured
|
||||
// at booking time now).
|
||||
form.setValue("cargoFreeText", "", { shouldDirty: true });
|
||||
if (v === "container") {
|
||||
form.setValue("cargoTypePath", [], { shouldDirty: true });
|
||||
// Contracts always cover BOTH sizes; quantities are chosen per
|
||||
// booking (a size can be 0 there).
|
||||
form.setValue("enabledContainerSizes", [...CONTAINER_SIZES], {
|
||||
shouldDirty: true,
|
||||
});
|
||||
} else {
|
||||
form.setValue("enabledContainerSizes", [], {
|
||||
shouldDirty: true,
|
||||
@@ -152,74 +140,47 @@ export function Step3CargoScope({
|
||||
|
||||
</div>
|
||||
|
||||
{/* Container scope: enabled sizes as tick-cards — tap to toggle, one or
|
||||
both can be in scope. Clearer than a multi-select for two options. */}
|
||||
{/* Container scope: the contract always covers BOTH sizes and quotes both
|
||||
rates. Quantities (a size can be 0) and the cargo description are
|
||||
captured at booking time. */}
|
||||
{cargoType === "container" && (
|
||||
<Controller
|
||||
name="enabledContainerSizes"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => {
|
||||
const selected = (field.value ?? []) as ("20ft" | "40ft")[];
|
||||
const toggle = (size: "20ft" | "40ft") => {
|
||||
field.onChange(
|
||||
selected.includes(size)
|
||||
? selected.filter((s) => s !== size)
|
||||
: [...selected, size],
|
||||
);
|
||||
field.onBlur();
|
||||
};
|
||||
return (
|
||||
<Box>
|
||||
<StepLabel>Container sizes in scope *</StepLabel>
|
||||
<Text fz={12} c="#6B7C8E" mt={2}>
|
||||
Tick every size this contract should cover — you can select
|
||||
both.
|
||||
</Text>
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-2">
|
||||
{CONTAINER_SIZE_OPTIONS.map((opt) => (
|
||||
<SizeCard
|
||||
key={opt.value}
|
||||
label={opt.label}
|
||||
description={opt.description}
|
||||
checked={selected.includes(opt.value)}
|
||||
hasError={Boolean(fieldState.error)}
|
||||
onToggle={() => toggle(opt.value)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{fieldState.error?.message && (
|
||||
<Text fz={12} c="red.7" mt={6}>
|
||||
{fieldState.error.message}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
<Group
|
||||
gap={13}
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
px={16}
|
||||
py={13}
|
||||
style={{
|
||||
borderRadius: 14,
|
||||
border: "1.5px solid #CDEBDD",
|
||||
background: "#F6FBF8",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Container scope: required description of what the containers carry. */}
|
||||
{cargoType === "container" && (
|
||||
<Controller
|
||||
name="cargoFreeText"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Textarea
|
||||
label="Cargo description *"
|
||||
description="What will the containers carry under this contract?"
|
||||
placeholder="e.g. Electronics, garments, machinery spare parts…"
|
||||
value={field.value ?? ""}
|
||||
onChange={(e) => field.onChange(e.currentTarget.value)}
|
||||
onBlur={field.onBlur}
|
||||
error={fieldState.error?.message}
|
||||
radius={10}
|
||||
autosize
|
||||
minRows={2}
|
||||
maxRows={4}
|
||||
styles={fieldStyles}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
width: 38,
|
||||
height: 38,
|
||||
borderRadius: 11,
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "#EAF6EC",
|
||||
color: "#1E7B34",
|
||||
}}
|
||||
>
|
||||
<Container size={18} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Text fz={14} fw={700} c="#10202F">
|
||||
20ft & 40ft containers covered
|
||||
</Text>
|
||||
<Text fz={12} c="#6B7C8E" style={{ lineHeight: 1.4 }}>
|
||||
This contract quotes rates for both sizes. You choose the
|
||||
quantities on each booking — either size can be 0.
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{/* Bulk scope: a single commodity (cargo type path). No tonnage. */}
|
||||
@@ -330,85 +291,6 @@ export function Step3CargoScope({
|
||||
);
|
||||
}
|
||||
|
||||
/** Checkbox-style card for one container size. Whole card toggles. */
|
||||
function SizeCard({
|
||||
label,
|
||||
description,
|
||||
checked,
|
||||
hasError,
|
||||
onToggle,
|
||||
}: {
|
||||
label: string;
|
||||
description: string;
|
||||
checked: boolean;
|
||||
hasError: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<UnstyledButton
|
||||
role="checkbox"
|
||||
aria-checked={checked}
|
||||
aria-label={label}
|
||||
onClick={onToggle}
|
||||
px={16}
|
||||
py={13}
|
||||
style={{
|
||||
borderRadius: 14,
|
||||
border: `1.5px solid ${
|
||||
checked ? "#0A6F4D" : hasError ? "#E8B4AC" : "#E6ECF2"
|
||||
}`,
|
||||
background: checked ? "#F6FBF8" : "#fff",
|
||||
transition: "all 150ms ease",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<Group gap={13} align="center" wrap="nowrap">
|
||||
<Box
|
||||
style={{
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: 7,
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
border: `1.5px solid ${checked ? "#0A6F4D" : "#C7D2DC"}`,
|
||||
background: checked ? "#0A6F4D" : "#fff",
|
||||
color: "#fff",
|
||||
transition: "all 150ms ease",
|
||||
}}
|
||||
>
|
||||
{checked && <Check size={14} strokeWidth={3} />}
|
||||
</Box>
|
||||
<Box
|
||||
style={{
|
||||
width: 38,
|
||||
height: 38,
|
||||
borderRadius: 11,
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: checked ? "#EAF6EC" : "#F1F5F8",
|
||||
color: checked ? "#1E7B34" : "#6B7C8E",
|
||||
transition: "all 150ms ease",
|
||||
}}
|
||||
>
|
||||
<Container size={18} />
|
||||
</Box>
|
||||
<Box style={{ textAlign: "left" }}>
|
||||
<Text fz={14} fw={700} c="#10202F">
|
||||
{label}
|
||||
</Text>
|
||||
<Text fz={12} c="#6B7C8E" style={{ lineHeight: 1.4 }}>
|
||||
{description}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleRow({
|
||||
icon,
|
||||
iconBg,
|
||||
|
||||
@@ -57,10 +57,13 @@ const containerUnitSchema = z.object({
|
||||
|
||||
const containerLineSchema = z.object({
|
||||
containerSize: z.enum(["20ft", "40ft"]),
|
||||
// 0 is allowed: the contract covers both sizes, so a booking that only needs
|
||||
// one size zeroes the other line out. At least one line must be ≥ 1
|
||||
// (enforced in the superRefine).
|
||||
quantity: z
|
||||
.string()
|
||||
.refine((v) => v.trim().length > 0, "Quantity is required.")
|
||||
.refine((v) => !Number.isNaN(Number(v)) && Number(v) >= 1, "At least 1."),
|
||||
.refine((v) => !Number.isNaN(Number(v)) && Number(v) >= 0, "Enter 0 or more."),
|
||||
hazardousQuantity: z.string().default("0"),
|
||||
reeferQuantity: z.string().default("0"),
|
||||
returnQuantity: z.string().default("0"),
|
||||
@@ -74,6 +77,8 @@ const shipmentFormBase = z.object({
|
||||
// unloading. Seeded from the contract's equipment return; bulk ignores it.
|
||||
withReturn: z.boolean().default(false),
|
||||
containers: z.array(containerLineSchema).default([]),
|
||||
// What the containers carry — captured per booking (moved off the contract).
|
||||
cargoDescription: z.string().default(""),
|
||||
cargoWeightTons: z.string().default(""),
|
||||
itemCount: z.string().default(""),
|
||||
bulkHazardousQuantity: z.string().default("0"),
|
||||
@@ -92,6 +97,28 @@ export function createShipmentFormSchema(ctx: ShipmentValidationContext) {
|
||||
}
|
||||
|
||||
if (ctx.isContainer) {
|
||||
// Containerized cargo must say WHAT is inside — required per booking.
|
||||
if (!data.cargoDescription.trim()) {
|
||||
refineCtx.addIssue({
|
||||
code: "custom",
|
||||
path: ["cargoDescription"],
|
||||
message: "Describe the cargo carried in the containers.",
|
||||
});
|
||||
}
|
||||
// Both sizes are always in contract scope and a line can be 0 — but the
|
||||
// booking as a whole needs at least one container. Anchor the error on
|
||||
// the first line's quantity so it renders in the field.
|
||||
const totalQty = data.containers.reduce(
|
||||
(sum, l) => sum + Math.max(0, Number(l.quantity) || 0),
|
||||
0,
|
||||
);
|
||||
if (data.containers.length > 0 && totalQty < 1) {
|
||||
refineCtx.addIssue({
|
||||
code: "custom",
|
||||
path: ["containers", 0, "quantity"],
|
||||
message: "Book at least one container (either size).",
|
||||
});
|
||||
}
|
||||
// Container numbers must be unique within this shipment (front-end only —
|
||||
// the DB column is intentionally not unique). Duplicates block submit and
|
||||
// price generation since both run through this same schema validation.
|
||||
@@ -243,6 +270,7 @@ export const initialShipmentFormValues: DeepPartial<ShipmentFormValues> = {
|
||||
scheduledDate: "",
|
||||
withReturn: false,
|
||||
containers: [],
|
||||
cargoDescription: "",
|
||||
cargoWeightTons: "",
|
||||
itemCount: "",
|
||||
bulkHazardousQuantity: "0",
|
||||
@@ -257,6 +285,7 @@ export const shipmentStepFields: Record<
|
||||
0: ["contractRouteId"],
|
||||
1: [
|
||||
"containers",
|
||||
"cargoDescription",
|
||||
"cargoWeightTons",
|
||||
"itemCount",
|
||||
"bulkHazardousQuantity",
|
||||
|
||||
@@ -308,10 +308,10 @@ export const api = {
|
||||
bookingsService.assignCustomerTruck(id, payload),
|
||||
),
|
||||
|
||||
downloadCustomerTruckFreightOrder: endpoint<{ id: string }, Blob>(
|
||||
downloadCustomerTruckFreightOrder: endpoint<{ id: string; copies?: number[] }, Blob>(
|
||||
"bookings",
|
||||
"downloadCustomerTruckFreightOrder",
|
||||
({ id }) => bookingsService.downloadCustomerTruckFreightOrder(id),
|
||||
({ id, copies }) => bookingsService.downloadCustomerTruckFreightOrder(id, copies),
|
||||
),
|
||||
|
||||
downloadHandoverDocument: endpoint<{ inventoryId: string }, Blob>(
|
||||
|
||||
@@ -212,10 +212,10 @@ export const bookingsService = {
|
||||
);
|
||||
return data.data;
|
||||
},
|
||||
downloadCustomerTruckFreightOrder: async (id: string): Promise<Blob> => {
|
||||
downloadCustomerTruckFreightOrder: async (id: string, copies?: number[]): Promise<Blob> => {
|
||||
const { data } = await client.get(
|
||||
`/api/bookings/${id}/customer-truck-assignment/freight-order`,
|
||||
{ responseType: "blob" },
|
||||
{ responseType: "blob", params: copies?.length ? { copies: copies.join(",") } : undefined },
|
||||
);
|
||||
return data;
|
||||
},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user