Merge branch 'alpha' of github.com:Tria-plc/edr-platform into alpha

This commit is contained in:
Stephanos A
2026-07-14 20:58:36 +03:00
96 changed files with 3654 additions and 542 deletions

View File

@@ -53,23 +53,23 @@ import {
} from "./seed/edr-freight.seed";
import { EdrOrgSeeder } from "./seed/edr-org.seeder";
import { FreightPositionsSeeder } from "./seed/freight-positions.seeder";
import { DemoUsersSeeder } from "./seed/demo-users.seeder";
import { FreightStaffUsersSeeder } from "./seed/freight-staff-users.seeder";
// Disabled seeds — imports commented out with their provider/injection/run below.
// import { DemoUsersSeeder } from "./seed/demo-users.seeder";
// import { FreightStaffUsersSeeder } from "./seed/freight-staff-users.seeder";
import { PaymentModule } from "./modules/payment/payment.module";
import { PricingDataSeeder } from "./seed/pricing-data.seeder";
// import { PricingDataSeeder } from "./seed/pricing-data.seeder";
import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder";
import { IndodeFacilitySeeder } from "./seed/indode-facility.seeder";
import { Batch14TestDataSeeder } from "./seed/batch1-4-test-data.seeder";
import { Batch5TestDataSeeder } from "./seed/batch5-test-data.seeder";
import { Batch7TestDataSeeder } from "./seed/batch7-test-data.seeder";
import { Batch8TestDataSeeder } from "./seed/batch8-test-data.seeder";
import { WarehouseDemoSeeder } from "./seed/warehouse-demo.seeder";
import { ExportDjiboutiInterchangeDemoSeeder } from "./seed/export-djibouti-interchange-demo.seeder";
import { MarshallingDemoTrainsSeeder } from "./seed/marshalling-demo-trains.seeder";
// import { IndodeFacilitySeeder } from "./seed/indode-facility.seeder";
// import { Batch14TestDataSeeder } from "./seed/batch1-4-test-data.seeder";
// import { Batch5TestDataSeeder } from "./seed/batch5-test-data.seeder";
// import { Batch7TestDataSeeder } from "./seed/batch7-test-data.seeder";
// import { Batch8TestDataSeeder } from "./seed/batch8-test-data.seeder";
// import { WarehouseDemoSeeder } from "./seed/warehouse-demo.seeder";
// import { ExportDjiboutiInterchangeDemoSeeder } from "./seed/export-djibouti-interchange-demo.seeder";
// import { MarshallingDemoTrainsSeeder } from "./seed/marshalling-demo-trains.seeder";
import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder";
import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
import { GovCompaniesSeeder } from "./seed/gov-companies.seeder";
import { EdrTruckFleetSeeder } from "./seed/edr-truck-fleet.seeder";
// import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
// import { GovCompaniesSeeder } from "./seed/gov-companies.seeder";
import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder";
import { PaidImportExportMileDemoSeeder } from "./seed/paid-import-export-mile-demo.seeder";
//New Trains, Wagons, Container and Cargo management modules
@@ -195,22 +195,22 @@ import { LoggerMiddleware } from "./logger.middleware";
providers: [
EdrOrgSeeder,
FreightPositionsSeeder,
DemoUsersSeeder,
FreightStaffUsersSeeder,
PricingDataSeeder,
FileUploadSettingsSeeder,
FreightPermissionKeyMigrationSeeder,
DemoFreightDataSeeder,
GovCompaniesSeeder,
EdrTruckFleetSeeder,
IndodeFacilitySeeder,
Batch14TestDataSeeder,
Batch5TestDataSeeder,
Batch7TestDataSeeder,
Batch8TestDataSeeder,
WarehouseDemoSeeder,
ExportDjiboutiInterchangeDemoSeeder,
MarshallingDemoTrainsSeeder,
// Disabled seeds — providers commented out (imports/injection/run too):
// DemoUsersSeeder,
// FreightStaffUsersSeeder,
// PricingDataSeeder,
// DemoFreightDataSeeder,
// GovCompaniesSeeder,
// IndodeFacilitySeeder,
// Batch14TestDataSeeder,
// Batch5TestDataSeeder,
// Batch7TestDataSeeder,
// Batch8TestDataSeeder,
// WarehouseDemoSeeder,
// ExportDjiboutiInterchangeDemoSeeder,
// MarshallingDemoTrainsSeeder,
ApprovedFirstLastMileDemoBookingsSeeder,
PaidImportExportMileDemoSeeder,
],
@@ -220,53 +220,66 @@ export class AppModule implements OnApplicationBootstrap {
private readonly seeder: DataSeeder,
private readonly edrOrgSeeder: EdrOrgSeeder,
private readonly freightPositionsSeeder: FreightPositionsSeeder,
private readonly demoUsersSeeder: DemoUsersSeeder,
private readonly freightStaffUsersSeeder: FreightStaffUsersSeeder,
private readonly pricingDataSeeder: PricingDataSeeder,
private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder,
private readonly indodeFacilitySeeder: IndodeFacilitySeeder,
private readonly batch14TestDataSeeder: Batch14TestDataSeeder,
private readonly batch5TestDataSeeder: Batch5TestDataSeeder,
private readonly batch7TestDataSeeder: Batch7TestDataSeeder,
private readonly batch8TestDataSeeder: Batch8TestDataSeeder,
private readonly warehouseDemoSeeder: WarehouseDemoSeeder,
private readonly exportDjiboutiInterchangeDemoSeeder: ExportDjiboutiInterchangeDemoSeeder,
private readonly marshallingDemoTrainsSeeder: MarshallingDemoTrainsSeeder,
private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
private readonly demoFreightDataSeeder: DemoFreightDataSeeder,
private readonly govCompaniesSeeder: GovCompaniesSeeder,
private readonly edrTruckFleetSeeder: EdrTruckFleetSeeder,
// Disabled seeds — injections commented out (imports/provider/run too):
// private readonly demoUsersSeeder: DemoUsersSeeder,
// private readonly freightStaffUsersSeeder: FreightStaffUsersSeeder,
// private readonly pricingDataSeeder: PricingDataSeeder,
// private readonly indodeFacilitySeeder: IndodeFacilitySeeder,
// private readonly batch14TestDataSeeder: Batch14TestDataSeeder,
// private readonly batch5TestDataSeeder: Batch5TestDataSeeder,
// private readonly batch7TestDataSeeder: Batch7TestDataSeeder,
// private readonly batch8TestDataSeeder: Batch8TestDataSeeder,
// private readonly warehouseDemoSeeder: WarehouseDemoSeeder,
// private readonly exportDjiboutiInterchangeDemoSeeder: ExportDjiboutiInterchangeDemoSeeder,
// private readonly marshallingDemoTrainsSeeder: MarshallingDemoTrainsSeeder,
// private readonly demoFreightDataSeeder: DemoFreightDataSeeder,
// private readonly govCompaniesSeeder: GovCompaniesSeeder,
) { }
async onApplicationBootstrap() {
// ── Enabled: permissions + file-upload settings (+ dropdown settings) only ──
// Everything else below is intentionally disabled. Seeders stay registered
// as providers and injected; only their .run() calls are commented out, so
// re-enabling any of them is a one-line uncomment.
// Permissions foundation — keep enabled:
// freightPermissionKeyMigration → renames legacy permission keys
// seeder (IAM DataSeeder) → seeds the IAM app, roles, permissions
// edrOrgSeeder → seeds org/unit + the Permission catalog
// freightPositionsSeeder → seeds Position + PositionPermission rows
// (depends on edrOrgSeeder, must run after)
await this.freightPermissionKeyMigrationSeeder.run();
await this.seeder.run();
await this.edrOrgSeeder.run();
await this.freightPositionsSeeder.run();
await this.demoUsersSeeder.run();
await this.freightStaffUsersSeeder.run();
await this.pricingDataSeeder.run();
// File upload settings — keep enabled.
await this.fileUploadSettingsSeeder.run();
await this.indodeFacilitySeeder.run();
await this.batch14TestDataSeeder.run();
await this.batch5TestDataSeeder.run();
await this.batch7TestDataSeeder.run();
await this.batch8TestDataSeeder.run();
await this.warehouseDemoSeeder.run();
await this.exportDjiboutiInterchangeDemoSeeder.run();
await this.marshallingDemoTrainsSeeder.run();
// Idempotent demo data: ≥100 wagons/type, approval chains, 4 staff users.
// Each block self-guards on an empty-table check, so this is safe every boot.
// Demo data seeds (DemoBookingsSeeder, PricingDataSeeder,
// FileUploadSettingsSeeder) are intentionally disabled — they stay
// registered as providers but are not run. Re-inject + call .run() to enable.
// demoFreightDataSeeder now seeds ONLY the 4 staff users (wagons + approval
// rules are disabled inside the seeder). Kept running for the staff users.
await this.demoFreightDataSeeder.run();
// Government entities (with importer/exporter profiles) that government
// bookings bill to. Idempotent — keyed by fixed IDs.
await this.govCompaniesSeeder.run();
await this.edrTruckFleetSeeder.run();
// Dropdown settings are not seeded on boot; run them with
// `pnpm seed:dropdown-settings` (src/scripts/seed-dropdown-settings.ts).
// ── Disabled: demo / test / reference data seeds ──
// Uncomment a line to re-enable that seed.
// await this.demoUsersSeeder.run();
// await this.freightStaffUsersSeeder.run();
// await this.pricingDataSeeder.run();
// await this.indodeFacilitySeeder.run();
// await this.batch14TestDataSeeder.run();
// await this.batch5TestDataSeeder.run();
// await this.batch7TestDataSeeder.run();
// await this.batch8TestDataSeeder.run();
// await this.warehouseDemoSeeder.run();
// await this.exportDjiboutiInterchangeDemoSeeder.run();
// await this.marshallingDemoTrainsSeeder.run();
// demoFreightDataSeeder seeds ONLY the 4 staff users (wagons + approval
// rules are already disabled inside the seeder).
// await this.demoFreightDataSeeder.run();
// Government entities (importer/exporter profiles) that government bookings
// bill to. Idempotent — keyed by fixed IDs.
// await this.govCompaniesSeeder.run();
}
configure(consumer: MiddlewareConsumer) {

View File

@@ -34,6 +34,10 @@ export const WagonTransferRequest = () =>
export const WagonTransferFulfill = () =>
BookingStaff(FREIGHT_PERMS.wagons.transferFulfill);
/** Admin: read every staffer's wagon-transfer history (not just one's own). */
export const WagonTransferHistoryAll = () =>
BookingStaff(FREIGHT_PERMS.wagons.transferHistoryAll);
/** Org-administration endpoints (user mgmt, billing config, company CRUD, settings). */
export const FreightAdmin = () => BookingStaff(FREIGHT_PERMS.admin);

View File

@@ -0,0 +1,23 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* The person who signs off a handover must record their full name (a signature
* is optional, especially for self-haul). Stored per handover record.
*/
export class AddHandoverSignerName2130000000000 implements MigrationInterface {
name = "AddHandoverSignerName2130000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.booking_handovers
ADD COLUMN IF NOT EXISTS signer_name varchar(160)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.booking_handovers
DROP COLUMN IF EXISTS signer_name
`);
}
}

View File

@@ -0,0 +1,29 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Accrual alert acknowledgements: ops can mark an in-warehouse item's fee
* accrual as reviewed (optionally snoozed until a date) so it stops nudging and
* drops down the accrual dashboard. One row per inventory item.
*/
export class CreateAccrualAcks2140000000000 implements MigrationInterface {
name = "CreateAccrualAcks2140000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.warehouse_accrual_acks (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
inventory_id uuid NOT NULL UNIQUE,
acknowledged_by uuid,
acknowledged_at timestamptz NOT NULL DEFAULT now(),
snooze_until timestamptz,
note text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_accrual_acks`);
}
}

View File

@@ -0,0 +1,50 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Consist adjustments from a schedule: staff can trim free wagons off a built
* train when their tare pushes gross weight over the locomotives' pull limit
* (incl. overage tolerance), or couple extra yard wagons on while weight and
* length headroom remain. Each add/remove is logged here so the schedule keeps
* an auditable history; the built train itself is updated in place.
*
* Plain columns (no FKs) so the history survives wagon/train deletion.
*
* NOTE: the shared dev DB has no applied migration history, so this is also
* hand-applied there. IF NOT EXISTS keeps that idempotent.
*/
export class ScheduleWagonAdjustmentLogs2170000000000 implements MigrationInterface {
name = 'ScheduleWagonAdjustmentLogs2170000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.schedule_wagon_adjustment_logs (
id uuid NOT NULL DEFAULT uuid_generate_v4(),
train_schedule_id uuid NOT NULL,
train_id uuid NOT NULL,
action varchar(10) NOT NULL,
wagon_id uuid NOT NULL,
wagon_number varchar(50) NOT NULL,
adjusted_by_user_id uuid,
occurred_at timestamptz NOT NULL DEFAULT now(),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
CONSTRAINT "PK_schedule_wagon_adjustment_logs" PRIMARY KEY (id)
);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_swal_train_schedule_id"
ON freight.schedule_wagon_adjustment_logs (train_schedule_id);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_swal_train_id"
ON freight.schedule_wagon_adjustment_logs (train_id);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_swal_train_id";`);
await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_swal_train_schedule_id";`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.schedule_wagon_adjustment_logs;`);
}
}

View File

@@ -0,0 +1,54 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Link each physical wagon move back to the transfer request that drove it, so
* the history can show "Request S→K, 3× NX70 → wagons W101, W102, W103".
* Nullable — legacy moves and non-request manual corrections carry no request.
* Also indexes `moved_by_user_id` for the per-user history queries.
*/
export class LinkWagonMovementToTransferRequest2180000000000
implements MigrationInterface
{
name = 'LinkWagonMovementToTransferRequest2180000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.wagon_movements
ADD COLUMN IF NOT EXISTS transfer_request_id uuid NULL
`);
await queryRunner.query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'fk_wm_transfer_request'
) THEN
ALTER TABLE freight.wagon_movements
ADD CONSTRAINT fk_wm_transfer_request
FOREIGN KEY (transfer_request_id)
REFERENCES freight.wagon_transfer_requests (id) ON DELETE SET NULL;
END IF;
END $$;
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_wm_transfer_request
ON freight.wagon_movements (transfer_request_id)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_wm_moved_by
ON freight.wagon_movements (moved_by_user_id)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_wm_moved_by`);
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_wm_transfer_request`);
await queryRunner.query(`
ALTER TABLE freight.wagon_movements
DROP CONSTRAINT IF EXISTS fk_wm_transfer_request
`);
await queryRunner.query(`
ALTER TABLE freight.wagon_movements
DROP COLUMN IF EXISTS transfer_request_id
`);
}
}

View File

@@ -0,0 +1,38 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index } from 'typeorm';
export const WAGON_ADJUSTMENT_ACTIONS = ['ADD', 'REMOVE'] as const;
export type WagonAdjustmentAction = (typeof WAGON_ADJUSTMENT_ACTIONS)[number];
/**
* History row for a consist adjustment made from a schedule: staff coupled a
* wagon onto (ADD) or detached one from (REMOVE) the schedule's built train —
* e.g. trimming free wagons whose tare pushed gross weight over the
* locomotives' pull limit. Plain columns (no FK relations) so the history
* survives the wagon or train being deleted later.
*/
@Entity({ schema: 'freight', name: 'schedule_wagon_adjustment_logs' })
@Index(['trainScheduleId'])
@Index(['trainId'])
export class ScheduleWagonAdjustmentLog extends BaseEntity {
@Column({ name: 'train_schedule_id', type: 'uuid' })
trainScheduleId!: string;
@Column({ name: 'train_id', type: 'uuid' })
trainId!: string;
@Column({ name: 'action', type: 'varchar', length: 10 })
action!: WagonAdjustmentAction;
@Column({ name: 'wagon_id', type: 'uuid' })
wagonId!: string;
@Column({ name: 'wagon_number', type: 'varchar', length: 50 })
wagonNumber!: string;
@Column({ name: 'adjusted_by_user_id', type: 'uuid', nullable: true })
adjustedByUserId!: string | null;
@Column({ name: 'occurred_at', type: 'timestamptz', default: () => 'now()' })
occurredAt!: Date;
}

View File

@@ -208,6 +208,8 @@ export interface BatchBoardScheduleDetail {
docReviewEndsAt: string | null;
paymentPhaseEndsAt: string | null;
bookingCycleNo: number;
/** Built train (Train Builder) behind this departure, when scheduled by train. */
train: BatchBoardSchedule["train"];
locomotive: BatchBoardSchedule["locomotive"];
capacity: BatchBoardSchedule["capacity"];
counts: BatchBoardSchedule["counts"];
@@ -235,6 +237,12 @@ export interface BatchBoardSchedule {
docReviewEndsAt: string | null;
paymentPhaseEndsAt: string | null;
bookingCycleNo: number;
/** Built train (Train Builder) behind this departure, when scheduled by train. */
train: {
id: string;
code: string;
trainName: string | null;
} | null;
locomotive: {
code: string;
name: string | null;
@@ -877,7 +885,7 @@ export class BookingBatchService implements OnModuleInit {
const [schedules, total] = await this.trainSchedulesRepository.findAndCount({
where,
relations: {
trainSet: { locomotive: true },
trainSet: { locomotive: true, train: true },
originStation: true,
destinationStation: true,
// Yards supply the route's display name for `routeName` below;
@@ -1128,6 +1136,13 @@ export class BookingBatchService implements OnModuleInit {
? s.paymentPhaseEndsAt.toISOString()
: null,
bookingCycleNo: s.bookingCycleNo ?? 0,
train: s.trainSet?.train
? {
id: s.trainSet.train.id,
code: s.trainSet.train.code,
trainName: s.trainSet.train.trainName ?? null,
}
: null,
locomotive: loco
? {
code: loco.code,
@@ -1247,6 +1262,13 @@ export class BookingBatchService implements OnModuleInit {
? s.paymentPhaseEndsAt.toISOString()
: null,
bookingCycleNo: s.bookingCycleNo ?? 0,
train: s.trainSet?.train
? {
id: s.trainSet.train.id,
code: s.trainSet.train.code,
trainName: s.trainSet.train.trainName ?? null,
}
: null,
locomotive: loco
? {
code: loco.code,

View File

@@ -0,0 +1,26 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsArray, IsOptional, IsUUID } from 'class-validator';
export class AdjustScheduleConsistDto {
@ApiPropertyOptional({
type: [String],
format: 'uuid',
description:
"AVAILABLE wagons from the train's current yard to couple onto the built train (blocked when they push gross weight or length past the locomotive limits incl. tolerance).",
})
@IsOptional()
@IsArray()
@IsUUID('all', { each: true })
addWagonIds?: string[];
@ApiPropertyOptional({
type: [String],
format: 'uuid',
description:
'Free (unloaded) wagons to detach permanently from the built train — e.g. trimming tare when gross weight exceeds the pull limit.',
})
@IsOptional()
@IsArray()
@IsUUID('all', { each: true })
removeWagonIds?: string[];
}

View File

@@ -39,6 +39,7 @@ import {
UploadImportDjiboutiDocumentDto,
} from "./dto/import-djibouti-operation.dto";
import { AvailableLocomotivesQueryDto } from "./dto/available-locomotives-query.dto";
import { AdjustScheduleConsistDto } from "./dto/adjust-schedule-consist.dto";
import { AvailableTrainsQueryDto } from "./dto/available-trains-query.dto";
import { BatchBoardQueryDto } from "./dto/batch-board-query.dto";
import { BookableSchedulesQueryDto } from "./dto/bookable-schedules-query.dto";
@@ -166,6 +167,34 @@ export class TrainSchedulingController {
);
}
@Get("schedules/:id/consist")
@TrainSchedulingView()
@ApiOperation({
summary:
"Built-train consist snapshot for a schedule: gross weight/length vs locomotive limits (incl. tolerance), trimmable + addable wagons, adjustment history",
})
getScheduleConsist(@Param("id", ParseUUIDPipe) id: string) {
return this.trainSchedulingService.getScheduleConsist(id);
}
@Post("schedules/:id/adjust-consist")
@TrainSchedulingManage()
@ApiOperation({
summary:
"Permanently trim free wagons off / couple yard wagons onto the schedule's built train (weight & length limits incl. tolerance enforced, every change logged)",
})
adjustScheduleConsist(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: AdjustScheduleConsistDto,
@CurrentUser() user: AuthUserPayload,
) {
return this.trainSchedulingService.adjustScheduleConsist(
id,
dto,
resolveAuthUserId(user),
);
}
@Get("bookable-schedules")
// No staff guard: customers hit this while creating a booking to find OPEN
// same-route schedules. Do not attach train_scheduling permissions here.

View File

@@ -25,6 +25,7 @@ import {
FindOptionsWhere,
ILike,
In,
IsNull,
Not,
QueryFailedError,
Raw,
@@ -48,6 +49,7 @@ import { Train } from '../trains/entities/train.entity';
import { TrainSetLocomotive } from '../train-sets/entities/train-set-locomotive.entity';
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
import { TrainSet } from '../train-sets/entities/train-set.entity';
import { ScheduleWagonAdjustmentLog } from '../train-schedules/entities/schedule-wagon-adjustment-log.entity';
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { WagonAllocationContainerItem } from '../train-schedules/entities/wagon-allocation-container-item.entity';
@@ -61,6 +63,7 @@ import { WagonBookingAllocationsRepository } from '../train-schedules/wagon-book
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import { WagonTypesRepository } from '../wagon-types/wagon-types.repository';
import { Wagon } from '../wagons/entities/wagon.entity';
import { AdjustScheduleConsistDto } from './dto/adjust-schedule-consist.dto';
import { AssignBookingsDto } from './dto/assign-bookings.dto';
import { CreateContainerTrainScheduleDto } from './dto/create-container-train-schedule.dto';
import { GetEligibleBookingsDto } from './dto/get-eligible-bookings.dto';
@@ -282,6 +285,8 @@ interface BookingWindowRow {
origin_code: string | null;
destination_label: string | null;
destination_code: string | null;
/** Full ordered corridor (origin → milestones → destination) from the schedule's route. */
route_stations: string[] | null;
}
@Injectable()
@@ -1329,9 +1334,29 @@ export class TrainSchedulingService {
limitLoco.maxPullWeightTons + (Number(limitLoco.overageToleranceTons) || 0);
const lengthCapWithOverage =
limitLoco.maxTrainLengthMeters + (Number(limitLoco.overageToleranceMeters) || 0);
if (!dto.forceAssign && weightCapWithOverage < totalWeightTons) {
// The locomotives pull GROSS weight: the customers' cargo plus wagon tare.
// A built train hauls EVERY coupled wagon's tare — empty ones included —
// so train-bound schedules count the full consist, not just planned slots.
const consistWagons = schedule.trainSet?.trainId
? await this.dataSource.getRepository(Wagon).find({
where: { trainId: schedule.trainSet.trainId },
relations: { wagonType: true },
})
: null;
const planTareTons = 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);
if (!dto.forceAssign && weightCapWithOverage < grossWeightTons) {
throw new BadRequestException(
`Train set locomotives cannot pull ${totalWeightTons}T`,
`Train set locomotives cannot pull ${grossWeightTons}T gross (${totalWeightTons}T cargo + ${planTareTons}T wagon tare)`,
);
}
if (!dto.forceAssign && lengthCapWithOverage < totalLengthMeters) {
@@ -4605,14 +4630,8 @@ export class TrainSchedulingService {
name: link.locomotive!.name ?? null,
})),
wagonCount: wagons.length,
maxGrossTons: roundTons(
wagons.reduce(
(sum, w) =>
sum +
(Number(w.wagonType?.tareWeightTons) || 0) +
(Number(w.wagonType?.capacityTons) || 0),
0,
),
totalTareTons: roundTons(
wagons.reduce((sum, w) => sum + (Number(w.wagonType?.tareWeightTons) || 0), 0),
),
totalLengthMeters: roundTons(
wagons.reduce((sum, w) => sum + (Number(w.wagonType?.lengthMeters) || 0), 0),
@@ -4624,6 +4643,294 @@ export class TrainSchedulingService {
});
}
/**
* Consist snapshot for the adjust-consist UI: the built train's wagons with
* loaded/removable flags, gross weight (cargo + FULL consist tare) and length
* against the locomotive limits incl. overage tolerance, addable yard wagons,
* and the adjustment history.
*/
async getScheduleConsist(scheduleId: string) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) throw new NotFoundException(`Train schedule ${scheduleId} not found`);
const builtTrain = schedule.trainSet?.train;
if (!builtTrain) {
throw new BadRequestException(
'This schedule was not created from a built train — its consist cannot be adjusted here',
);
}
const wagons = await this.dataSource.getRepository(Wagon).find({
where: { trainId: builtTrain.id },
relations: { wagonType: true },
order: { sequenceNumber: 'ASC' },
});
const addableWagons = await this.dataSource.getRepository(Wagon).find({
where: {
trainId: IsNull(),
status: WagonStatus.Available,
currentYardId: builtTrain.currentYardId ?? undefined,
},
relations: { wagonType: true },
order: { wagonNumber: 'ASC' },
});
const adjustments = await this.dataSource
.getRepository(ScheduleWagonAdjustmentLog)
.find({ where: { trainScheduleId: scheduleId }, order: { occurredAt: 'DESC' }, take: 30 });
// Slots with cargo aboard — their physical wagons are "loaded" and can
// never be trimmed.
const loadedWagonIds = new Set(
(schedule.trainSet?.wagons ?? [])
.filter((slot) => slot.physicalWagonId && (slot.allocations?.length ?? 0) > 0)
.map((slot) => slot.physicalWagonId as string),
);
const limits = minLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet));
const maxPullWeightTons = roundTons(Number(limits?.maxPullWeightTons ?? 0));
const overageToleranceTons = roundTons(Number(limits?.overageToleranceTons) || 0);
const maxTrainLengthMeters = roundTons(Number(limits?.maxTrainLengthMeters ?? 0));
const overageToleranceMeters = roundTons(Number(limits?.overageToleranceMeters) || 0);
const cargoTons = roundTons(Number(schedule.trainSet?.totalWeightTons ?? 0));
const consistTareTons = roundTons(
wagons.reduce((sum, w) => sum + Number(w.wagonType?.tareWeightTons ?? 0), 0),
);
const consistLengthMeters = roundTons(
wagons.reduce((sum, w) => sum + Number(w.wagonType?.lengthMeters ?? 0), 0),
);
const mapWagon = (wagon: Wagon) => ({
id: wagon.id,
wagonNumber: wagon.wagonNumber,
sequenceNumber: wagon.sequenceNumber,
wagonType: wagon.wagonType
? {
id: wagon.wagonType.id,
code: wagon.wagonType.code,
tareWeightTons: roundTons(Number(wagon.wagonType.tareWeightTons ?? 0)),
capacityTons: roundTons(Number(wagon.wagonType.capacityTons ?? 0)),
lengthMeters: roundTons(Number(wagon.wagonType.lengthMeters ?? 0)),
}
: null,
});
return {
schedule: { id: schedule.id, reference: schedule.reference ?? null, status: schedule.status },
train: {
id: builtTrain.id,
code: builtTrain.code,
trainName: builtTrain.trainName ?? null,
currentYardId: builtTrain.currentYardId ?? null,
},
limits: {
maxPullWeightTons,
overageToleranceTons,
pullCapTons: roundTons(maxPullWeightTons + overageToleranceTons),
maxTrainLengthMeters,
overageToleranceMeters,
lengthCapMeters: roundTons(maxTrainLengthMeters + overageToleranceMeters),
},
totals: {
wagonCount: wagons.length,
cargoTons,
consistTareTons,
grossTons: roundTons(cargoTons + consistTareTons),
consistLengthMeters,
},
wagons: wagons.map((wagon) => ({
...mapWagon(wagon),
loaded: loadedWagonIds.has(wagon.id),
// Free = not pinned to any run; only free wagons can be trimmed.
removable: wagon.currentTrainScheduleId == null && !loadedWagonIds.has(wagon.id),
})),
addableWagons: addableWagons.map(mapWagon),
adjustments: adjustments.map((log) => ({
id: log.id,
action: log.action,
wagonId: log.wagonId,
wagonNumber: log.wagonNumber,
adjustedByUserId: log.adjustedByUserId,
occurredAt: log.occurredAt,
})),
editable: ['DRAFT', 'SCHEDULED'].includes(schedule.status),
};
}
/**
* Permanently adjust the built train's consist from a schedule: trim free
* wagons (their tare no longer rides — the usual fix when gross weight beats
* the pull limit) and/or couple extra AVAILABLE yard wagons while weight and
* length headroom remain (limits incl. overage tolerance). The built train
* updates in place, the schedule's wagon cap follows, and every change is
* logged for the schedule's history.
*/
async adjustScheduleConsist(
scheduleId: string,
dto: AdjustScheduleConsistDto,
userId?: string | null,
) {
const addWagonIds = [...new Set(dto.addWagonIds ?? [])];
const removeWagonIds = [...new Set(dto.removeWagonIds ?? [])];
if (!addWagonIds.length && !removeWagonIds.length) {
throw new BadRequestException('Nothing to adjust — pass wagons to add and/or remove');
}
const overlap = addWagonIds.filter((id) => removeWagonIds.includes(id));
if (overlap.length) {
throw new BadRequestException('A wagon cannot be added and removed in the same adjustment');
}
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) throw new NotFoundException(`Train schedule ${scheduleId} not found`);
if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) {
throw new BadRequestException(
'The consist is frozen once the train is dispatched — adjust before departure',
);
}
const builtTrainRef = schedule.trainSet?.train;
if (!builtTrainRef) {
throw new BadRequestException(
'This schedule was not created from a built train — its consist cannot be adjusted here',
);
}
const loadedWagonIds = new Set(
(schedule.trainSet?.wagons ?? [])
.filter((slot) => slot.physicalWagonId && (slot.allocations?.length ?? 0) > 0)
.map((slot) => slot.physicalWagonId as string),
);
const limits = minLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet));
const pullCapTons = roundTons(
Number(limits?.maxPullWeightTons ?? 0) + (Number(limits?.overageToleranceTons) || 0),
);
const lengthCapMeters = roundTons(
Number(limits?.maxTrainLengthMeters ?? 0) + (Number(limits?.overageToleranceMeters) || 0),
);
await this.dataSource.transaction(async (manager) => {
const train = await manager.getRepository(Train).findOne({
where: { id: builtTrainRef.id },
lock: { mode: 'pessimistic_write' },
});
if (!train) throw new NotFoundException(`Train ${builtTrainRef.id} not found`);
const consist = await manager.getRepository(Wagon).find({
where: { trainId: train.id },
relations: { wagonType: true },
order: { sequenceNumber: 'ASC' },
});
const consistById = new Map(consist.map((w) => [w.id, w]));
// --- validate removals: must be coupled and free (no cargo, no pin) ---
const removed: Wagon[] = [];
for (const wagonId of removeWagonIds) {
const wagon = consistById.get(wagonId);
if (!wagon) {
throw new NotFoundException(`Wagon ${wagonId} is not coupled to train ${train.code}`);
}
if (loadedWagonIds.has(wagon.id) || wagon.currentTrainScheduleId != null) {
throw new ConflictException(
`Wagon ${wagon.wagonNumber} is loaded/pinned on a schedule and cannot be trimmed`,
);
}
removed.push(wagon);
}
// --- validate additions: AVAILABLE, loose, standing in the train's yard ---
const added: Wagon[] = [];
for (const wagonId of addWagonIds) {
const wagon = await manager.getRepository(Wagon).findOne({
where: { id: wagonId },
relations: { wagonType: true },
lock: { mode: 'pessimistic_write' },
});
if (!wagon) throw new NotFoundException(`Wagon ${wagonId} not found`);
if (wagon.trainId) {
throw new ConflictException(`Wagon ${wagon.wagonNumber} is already on a train`);
}
if (wagon.status !== WagonStatus.Available) {
throw new ConflictException(
`Wagon ${wagon.wagonNumber} is not available (${wagon.status})`,
);
}
if (wagon.currentYardId !== train.currentYardId) {
throw new BadRequestException(
`Wagon ${wagon.wagonNumber} is not in the train's yard — only wagons in the same yard can be coupled`,
);
}
added.push(wagon);
}
// --- headroom check (only additions can push the train over a cap) ---
const removedIds = new Set(removed.map((w) => w.id));
const finalConsist = [...consist.filter((w) => !removedIds.has(w.id)), ...added];
const tareOf = (w: Wagon) => Number(w.wagonType?.tareWeightTons ?? 0);
const lengthOf = (w: Wagon) => Number(w.wagonType?.lengthMeters ?? 0);
const finalTareTons = roundTons(finalConsist.reduce((s, w) => s + tareOf(w), 0));
const finalLengthMeters = roundTons(finalConsist.reduce((s, w) => s + lengthOf(w), 0));
const cargoTons = roundTons(Number(schedule.trainSet?.totalWeightTons ?? 0));
const finalGrossTons = roundTons(cargoTons + finalTareTons);
if (added.length && pullCapTons > 0 && finalGrossTons > pullCapTons) {
throw new BadRequestException(
`Adding these wagons puts gross weight at ${finalGrossTons}T (${cargoTons}T cargo + ${finalTareTons}T tare), over the locomotives' ${pullCapTons}T limit incl. tolerance`,
);
}
if (added.length && lengthCapMeters > 0 && finalLengthMeters > lengthCapMeters) {
throw new BadRequestException(
`Adding these wagons puts consist length at ${finalLengthMeters}m, over the locomotives' ${lengthCapMeters}m limit incl. tolerance`,
);
}
// --- apply: detach trims, couple additions, compact the sequence ---
for (const wagon of removed) {
await manager.getRepository(Wagon).update(wagon.id, {
trainId: null,
sequenceNumber: null,
status: WagonStatus.Available,
});
}
const remaining = consist.filter((w) => !removedIds.has(w.id));
for (let i = 0; i < remaining.length; i++) {
if (remaining[i].sequenceNumber !== i + 1) {
await manager.getRepository(Wagon).update(remaining[i].id, { sequenceNumber: i + 1 });
}
}
let sequence = remaining.length;
for (const wagon of added) {
sequence += 1;
await manager.getRepository(Wagon).update(wagon.id, {
trainId: train.id,
sequenceNumber: sequence,
status: WagonStatus.Assigned,
});
}
// The schedule is full when every consist wagon is allocated.
await manager
.getRepository(TrainSchedule)
.update(scheduleId, { maxWagons: finalConsist.length });
const logRepo = manager.getRepository(ScheduleWagonAdjustmentLog);
const now = new Date();
await logRepo.save(
[
...removed.map((wagon) => ({ action: 'REMOVE' as const, wagon })),
...added.map((wagon) => ({ action: 'ADD' as const, wagon })),
].map(({ action, wagon }) =>
logRepo.create({
trainScheduleId: scheduleId,
trainId: train.id,
action,
wagonId: wagon.id,
wagonNumber: wagon.wagonNumber,
adjustedByUserId: userId ?? null,
occurredAt: now,
}),
),
);
});
return this.getScheduleConsist(scheduleId);
}
/**
* Re-derive a built train's lifecycle status from its schedules after one of
* them changes: any DISPATCHED schedule → IN_SERVICE; any DRAFT/SCHEDULED →
@@ -4725,7 +5032,11 @@ export class TrainSchedulingService {
ts.booking_cycle_no,
ts.scheduled_departure_date,
oy.label AS origin_label, oy.code AS origin_code,
dy.label AS destination_label, dy.code AS destination_code
dy.label AS destination_label, dy.code AS destination_code,
(SELECT array_agg(COALESCE(rmy.label, rmy.code) ORDER BY rm.sequence_no)
FROM freight.route_milestones rm
JOIN freight.yards rmy ON rmy.id = rm.yard_id
WHERE rm.route_id = ts.route_id) AS route_stations
FROM freight.train_schedules ts
LEFT JOIN freight.contract_routes cr
ON cr.deleted_at IS NULL
@@ -4777,7 +5088,11 @@ export class TrainSchedulingService {
ts.booking_cycle_no,
ts.scheduled_departure_date,
oy.label AS origin_label, oy.code AS origin_code,
dy.label AS destination_label, dy.code AS destination_code
dy.label AS destination_label, dy.code AS destination_code,
(SELECT array_agg(COALESCE(rmy.label, rmy.code) ORDER BY rm.sequence_no)
FROM freight.route_milestones rm
JOIN freight.yards rmy ON rmy.id = rm.yard_id
WHERE rm.route_id = ts.route_id) AS route_stations
FROM freight.train_schedules ts
JOIN freight.contract_routes cr
ON cr.contract_id = $1
@@ -4823,7 +5138,11 @@ export class TrainSchedulingService {
ts.booking_cycle_no,
ts.scheduled_departure_date,
oy.label AS origin_label, oy.code AS origin_code,
dy.label AS destination_label, dy.code AS destination_code
dy.label AS destination_label, dy.code AS destination_code,
(SELECT array_agg(COALESCE(rmy.label, rmy.code) ORDER BY rm.sequence_no)
FROM freight.route_milestones rm
JOIN freight.yards rmy ON rmy.id = rm.yard_id
WHERE rm.route_id = ts.route_id) AS route_stations
FROM freight.train_schedules ts
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
@@ -4845,6 +5164,17 @@ export class TrainSchedulingService {
}
private mapBookingWindowRow(r: BookingWindowRow) {
const origin = r.origin_label ?? r.origin_code ?? null;
const destination = r.destination_label ?? r.destination_code ?? null;
// Full corridor from the route's milestones (origin → stops → destination).
// Falls back to the schedule's origin/destination when no milestones exist.
const milestoneStops = (r.route_stations ?? []).filter(
(s): s is string => Boolean(s),
);
const routeStations =
milestoneStops.length >= 2
? milestoneStops
: [origin, destination].filter((s): s is string => Boolean(s));
return {
scheduleId: r.schedule_id,
reference: r.reference ?? null,
@@ -4860,8 +5190,9 @@ export class TrainSchedulingService {
bookingWindowStatus: r.booking_window_status,
bookingCycleNo: r.booking_cycle_no,
departureDate: r.scheduled_departure_date,
origin: r.origin_label ?? r.origin_code ?? null,
destination: r.destination_label ?? r.destination_code ?? null,
origin,
destination,
routeStations,
};
}

View File

@@ -0,0 +1,12 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsUUID } from 'class-validator';
export class UpdateTrainYardDto {
@ApiProperty({
format: 'uuid',
description:
'Yard the train now sits in. The coupled locomotives and wagons are relocated with it.',
})
@IsUUID()
currentYardId!: string;
}

View File

@@ -7,6 +7,7 @@ import {
HttpStatus,
Param,
ParseUUIDPipe,
Patch,
Post,
Put,
Query,
@@ -19,6 +20,7 @@ import { BuildTrainDto } from './dto/build-train.dto';
import { ListBuiltTrainsQueryDto } from './dto/list-built-trains-query.dto';
import { ReorderTrainWagonsDto } from './dto/reorder-train-wagons.dto';
import { UpdateTrainLocomotivesDto } from './dto/update-train-locomotives.dto';
import { UpdateTrainYardDto } from './dto/update-train-yard.dto';
import { TrainBuilderService } from './train-builder.service';
@ApiTags('train-builder')
@@ -57,6 +59,15 @@ export class TrainBuilderController {
return this.trainBuilderService.setLocomotives(id, dto);
}
@Patch(':id/yard')
@FleetManage()
@ApiOperation({
summary: 'Relocate the train — its locomotives and wagons move to the new yard with it',
})
setYard(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateTrainYardDto) {
return this.trainBuilderService.setYard(id, dto.currentYardId);
}
@Post(':id/wagons')
@FleetManage()
@ApiOperation({ summary: "Append AVAILABLE wagons from the train's yard to the consist" })

View File

@@ -1,4 +1,4 @@
import { Freight, WagonStatus } from '@edr/types';
import { Freight, WagonMovementKind, WagonStatus } from '@edr/types';
import {
BadRequestException,
ConflictException,
@@ -10,6 +10,8 @@ import { DataSource, EntityManager, ILike, In } from 'typeorm';
import { Locomotive } from '../locomotives/entities/locomotive.entity';
import { Yard } from '../rule-engine/entities/yard.entity';
import { minLocomotiveLimits } from '../train-scheduling/train-capacity.util';
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import { WagonMovement } from '../wagons/entities/wagon-movement.entity';
import { Wagon } from '../wagons/entities/wagon.entity';
import { AssignTrainWagonsDto } from './dto/assign-train-wagons.dto';
import { BuildTrainDto } from './dto/build-train.dto';
@@ -202,7 +204,6 @@ export class TrainBuilderService {
const totalLengthMeters = round(
wagons.reduce((sum, w) => sum + (w.wagonType?.lengthMeters ?? 0), 0),
);
const maxGrossTons = round(totalTareTons + totalCapacityTons);
const maxPullWeightTons = round(limits?.maxPullWeightTons ?? 0);
const maxTrainLengthMeters = round(limits?.maxTrainLengthMeters ?? 0);
@@ -221,14 +222,17 @@ export class TrainBuilderService {
totals: {
wagonCount: wagons.length,
totalTareTons,
// Informational only — building never checks against full capacity;
// the real gross check (cargo + tare vs haul limit) runs at allocation.
totalCapacityTons,
maxGrossTons,
totalLengthMeters,
maxPullWeightTons,
maxTrainLengthMeters,
// Fully loaded gross vs. what the weakest locomotive can haul.
weightUtilizationPct: maxPullWeightTons
? round((maxGrossTons / maxPullWeightTons) * 100)
// Cargo the locomotives can still haul once pulling the empty consist.
payloadCapacityTons: round(Math.max(0, maxPullWeightTons - totalTareTons)),
// Share of the haul limit consumed by the empty wagons alone.
tareUtilizationPct: maxPullWeightTons
? round((totalTareTons / maxPullWeightTons) * 100)
: null,
lengthUtilizationPct: maxTrainLengthMeters
? round((totalLengthMeters / maxTrainLengthMeters) * 100)
@@ -269,6 +273,54 @@ export class TrainBuilderService {
return this.getComposition(id);
}
/**
* Relocate the train to another yard. The consist moves as one unit: every
* coupled locomotive and wagon follows to the new yard (so their current
* yards always match the train's), and each wagon gets a movement-ledger row.
* Blocked while the train is out on a dispatched run.
*/
async setYard(id: string, currentYardId: string) {
await this.dataSource.transaction(async (manager) => {
const train = await this.getEditableTrain(manager, id);
if (train.currentYardId === currentYardId) return;
const yard = await manager.getRepository(Yard).findOne({ where: { id: currentYardId } });
if (!yard) throw new NotFoundException(`Yard ${currentYardId} not found`);
await manager.getRepository(Train).update(train.id, { currentYardId: yard.id });
const links = await manager
.getRepository(TrainLocomotive)
.find({ where: { trainId: train.id } });
if (links.length) {
await manager
.getRepository(Locomotive)
.update(
{ id: In(links.map((link) => link.locomotiveId)) },
{ currentYardId: yard.id },
);
}
const wagons = await manager.getRepository(Wagon).find({ where: { trainId: train.id } });
const now = new Date();
for (const wagon of wagons) {
if (wagon.currentYardId === yard.id) continue;
await manager.getRepository(Wagon).update(wagon.id, { currentYardId: yard.id });
// Ledger row keeps the wagon's yard history auditable (mirrors the
// manual-relocation path in the wagons service).
await manager.getRepository(WagonMovement).save(
manager.getRepository(WagonMovement).create({
wagonId: wagon.id,
fromYardId: wagon.currentYardId ?? null,
toYardId: yard.id,
kind: WagonMovementKind.Manual,
occurredAt: now,
}),
);
}
});
return this.getComposition(id);
}
/** Append AVAILABLE wagons from the train's own yard to the consist. */
async assignWagons(id: string, dto: AssignTrainWagonsDto) {
await this.dataSource.transaction(async (manager) => {
@@ -361,12 +413,8 @@ export class TrainBuilderService {
.map((link) => link.locomotive)
.filter((loco): loco is Locomotive => Boolean(loco));
const wagons = train.wagons ?? [];
const maxGrossTons = round(
wagons.reduce(
(sum, w) =>
sum + (Number(w.wagonType?.tareWeightTons) || 0) + (Number(w.wagonType?.capacityTons) || 0),
0,
),
const totalTareTons = round(
wagons.reduce((sum, w) => sum + (Number(w.wagonType?.tareWeightTons) || 0), 0),
);
return {
id: train.id,
@@ -379,7 +427,7 @@ export class TrainBuilderService {
: null,
locomotives: locomotives.map((loco) => ({ id: loco.id, code: loco.code, name: loco.name ?? null })),
wagonCount: wagons.length,
maxGrossTons,
totalTareTons,
totalLengthMeters: round(
wagons.reduce((sum, w) => sum + (Number(w.wagonType?.lengthMeters) || 0), 0),
),
@@ -464,9 +512,13 @@ export class TrainBuilderService {
startCount: number,
): Promise<void> {
const uniqueIds = [...new Set(wagonIds)];
let sequence = startCount;
const wagonRepo = manager.getRepository(Wagon);
// First pass: lock + validate every wagon so the length gate below sees
// the full incoming set before any row is written.
const toAttach: Wagon[] = [];
for (const wagonId of uniqueIds) {
const wagon = await manager.getRepository(Wagon).findOne({
const wagon = await wagonRepo.findOne({
where: { id: wagonId },
lock: { mode: 'pessimistic_write' },
});
@@ -483,8 +535,16 @@ export class TrainBuilderService {
`Wagon ${wagon.wagonNumber} is not in the train's yard; only wagons in the same yard can be attached`,
);
}
toAttach.push(wagon);
}
if (!toAttach.length) return;
await this.assertConsistLengthWithinLimit(manager, train, toAttach);
let sequence = startCount;
for (const wagon of toAttach) {
sequence += 1;
await manager.getRepository(Wagon).update(wagon.id, {
await wagonRepo.update(wagon.id, {
trainId: train.id,
sequenceNumber: sequence,
status: WagonStatus.Assigned,
@@ -492,6 +552,58 @@ export class TrainBuilderService {
}
}
/**
* The consist (already-attached wagons + the incoming ones) must fit the
* train's locomotive length limit — the weakest locomotive of the set caps
* the train, mirroring how scheduling derives capacity.
*/
private async assertConsistLengthWithinLimit(
manager: EntityManager,
train: Train,
incoming: Wagon[],
): Promise<void> {
const links = await manager.getRepository(TrainLocomotive).find({
where: { trainId: train.id },
relations: { locomotive: true },
});
const limits = minLocomotiveLimits(
links
.map((link) => link.locomotive)
.filter((loco): loco is Locomotive => Boolean(loco)),
);
const maxLengthMeters = Number(limits?.maxTrainLengthMeters ?? 0);
if (!Number.isFinite(maxLengthMeters) || maxLengthMeters <= 0) return;
const existing = await manager.getRepository(Wagon).find({
where: { trainId: train.id },
relations: { wagonType: true },
});
const currentLength = existing.reduce(
(sum, w) => sum + (Number(w.wagonType?.lengthMeters) || 0),
0,
);
const typeIds = [...new Set(incoming.map((w) => w.wagonTypeId).filter(Boolean))];
const types = typeIds.length
? await manager.getRepository(WagonType).find({ where: { id: In(typeIds) } })
: [];
const lengthByType = new Map(types.map((t) => [t.id, Number(t.lengthMeters) || 0]));
const addedLength = incoming.reduce(
(sum, w) => sum + (lengthByType.get(w.wagonTypeId) ?? 0),
0,
);
const totalLength = currentLength + addedLength;
if (totalLength > maxLengthMeters) {
throw new BadRequestException(
`Cannot attach wagons — train length would be ${round(totalLength)} m ` +
`(current ${round(currentLength)} m + ${round(addedLength)} m added), ` +
`over the locomotive limit of ${round(maxLengthMeters)} m. ` +
'Remove wagons from the consist or use locomotives with a higher length limit.',
);
}
}
/** Compact wagon sequence numbers back to 1..n after a removal. */
private async resequenceWagons(manager: EntityManager, trainId: string): Promise<void> {
const wagons = await manager.getRepository(Wagon).find({

View File

@@ -4,6 +4,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Yard } from '../../rule-engine/entities/yard.entity';
import { Wagon } from './wagon.entity';
import { WagonTransferRequest } from './wagon-transfer-request.entity';
/**
* Ledger of every physical wagon relocation between yards — one row per move.
@@ -51,6 +52,14 @@ export class WagonMovement extends BaseEntity {
@Column({ name: 'moved_by_user_id', type: 'uuid', nullable: true })
movedByUserId?: string | null;
/** The transfer request this move fulfilled, when it came from one. */
@Column({ name: 'transfer_request_id', type: 'uuid', nullable: true })
transferRequestId?: string | null;
@ManyToOne(() => WagonTransferRequest, { nullable: true })
@JoinColumn({ name: 'transfer_request_id' })
transferRequest?: WagonTransferRequest | null;
@Column({ name: 'occurred_at', type: 'timestamptz' })
occurredAt!: Date;

View File

@@ -16,6 +16,7 @@ import {
FleetManage,
FleetView,
WagonTransferFulfill,
WagonTransferHistoryAll,
WagonTransferRequest,
} from '../../common/booking-guards';
import { CreateTransferRequestDto } from './dto/create-transfer-request.dto';
@@ -50,6 +51,30 @@ export class WagonTransferRequestsController {
return this.service.listRequests(status);
}
// NOTE: the two `history` routes MUST stay above `@Get(':id')` — Express
// matches in declaration order, so `/history` would otherwise be captured by
// the `:id` param route (and rejected by ParseUUIDPipe).
@Get('history')
@ApiOperation({
summary: "Caller's own transfer history (requests filed/fulfilled + wagons moved)",
})
myHistory(@CurrentUser() user: TCurrentUser) {
// Never fall through to the all-staff view: getHistory(undefined) means
// "everyone", so a missing caller id must return empty, not leak scope.
if (!user?.id) return { requests: [], movements: [] };
return this.service.getHistory(user.id);
}
@Get('history/all')
@WagonTransferHistoryAll()
@ApiQuery({ name: 'userId', required: false })
@ApiOperation({
summary: "Admin: any/all staff's transfer history (optional ?userId filter)",
})
allHistory(@Query('userId') userId?: string) {
return this.service.getHistory(userId);
}
@Get(':id')
@ApiOperation({ summary: 'Get one transfer request' })
findOne(@Param('id', ParseUUIDPipe) id: string) {

View File

@@ -6,14 +6,24 @@ import {
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { In, Repository } from 'typeorm';
import { In, IsNull, Not, Repository } from 'typeorm';
import { CreateTransferRequestDto } from './dto/create-transfer-request.dto';
import { FulfillTransferRequestDto } from './dto/fulfill-transfer-request.dto';
import { Wagon } from './entities/wagon.entity';
import { WagonMovement } from './entities/wagon-movement.entity';
import { WagonTransferRequest } from './entities/wagon-transfer-request.entity';
import { WagonsService } from './wagons.service';
/** Bundled per-user activity: requests they touched + wagons they moved. */
export interface TransferHistory {
requests: WagonTransferRequest[];
movements: WagonMovement[];
}
/** How many ledger rows the history returns at most (newest first). */
const HISTORY_LIMIT = 500;
const REQUEST_RELATIONS = {
fromYard: true,
toYard: true,
@@ -33,6 +43,8 @@ export class WagonTransferRequestsService {
private readonly requestRepo: Repository<WagonTransferRequest>,
@InjectRepository(Wagon)
private readonly wagonRepo: Repository<Wagon>,
@InjectRepository(WagonMovement)
private readonly movementRepo: Repository<WagonMovement>,
private readonly wagonsService: WagonsService,
) {}
@@ -125,10 +137,12 @@ export class WagonTransferRequestsService {
);
}
// Reuse the audited bulk-transfer path (writes wagon_movements ledger rows).
// Reuse the audited bulk-transfer path (writes wagon_movements ledger rows,
// each stamped with this request's id so history can link them back).
await this.wagonsService.bulkTransfer(
{ wagonIds, toYardId: request.toYardId },
userId,
{ transferRequestId: request.id },
);
request.status = WagonTransferRequestStatus.Fulfilled;
@@ -138,6 +152,38 @@ export class WagonTransferRequestsService {
return this.findById(id);
}
/**
* Per-user transfer history: the requests a user filed OR fulfilled, plus the
* individual wagons they physically moved (linked back to their request when
* one drove the move). Pass a `userId` to scope to one staffer; pass
* `undefined` for the admin all-staff view. Scope is decided by the CALLER
* (the controller passes the caller's id unless they hold the history-all
* permission) — this method trusts its argument.
*/
async getHistory(userId?: string | null): Promise<TransferHistory> {
const requests = await this.requestRepo.find({
where: userId
? [{ requestedByUserId: userId }, { fulfilledByUserId: userId }]
: {},
relations: REQUEST_RELATIONS,
order: { createdAt: 'DESC' },
take: HISTORY_LIMIT,
});
const movements = await this.movementRepo.find({
// Own view: moves I made. All view: every user-attributed move (skip the
// system-written loaded/reposition legs that carry no mover).
where: userId
? { movedByUserId: userId }
: { movedByUserId: Not(IsNull()) },
relations: { wagon: true, fromYard: true, toYard: true, transferRequest: true },
order: { occurredAt: 'DESC' },
take: HISTORY_LIMIT,
});
return { requests, movements };
}
/** Withdraw a still-PENDING request. */
async cancelRequest(id: string): Promise<WagonTransferRequest> {
const request = await this.findById(id);

View File

@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Wagon } from './entities/wagon.entity';
import { WagonMovement } from './entities/wagon-movement.entity';
import { WagonTransferRequest } from './entities/wagon-transfer-request.entity';
import { Train } from '../trains/entities/train.entity';
import { Yard } from '../rule-engine/entities/yard.entity';
@@ -10,7 +11,15 @@ import { WagonsService } from './wagons.service';
import { WagonTransferRequestsService } from './wagon-transfer-requests.service';
@Module({
imports: [TypeOrmModule.forFeature([Wagon, WagonTransferRequest, Train, Yard])],
imports: [
TypeOrmModule.forFeature([
Wagon,
WagonMovement,
WagonTransferRequest,
Train,
Yard,
]),
],
controllers: [
WagonsController,
TrainWagonsReorderController,

View File

@@ -180,6 +180,7 @@ export class WagonsService {
async bulkTransfer(
dto: BulkTransferWagonsDto,
userId?: string | null,
opts?: { transferRequestId?: string | null },
): Promise<{ moved: number }> {
const { wagonIds, toYardId } = dto;
if (!wagonIds.length) return { moved: 0 };
@@ -215,6 +216,7 @@ export class WagonsService {
toYardId,
kind: WagonMovementKind.Manual,
movedByUserId: userId ?? null,
transferRequestId: opts?.transferRequestId ?? null,
occurredAt: new Date(),
}),
);

View File

@@ -0,0 +1,18 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsInt, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
/** Acknowledge (optionally snooze) an item's fee-accrual alert. */
export class AcknowledgeAccrualDto {
@ApiPropertyOptional({ minimum: 1, maximum: 90, description: 'Days to suppress alerts; omit = indefinitely.' })
@IsOptional()
@IsInt()
@Min(1)
@Max(90)
snoozeDays?: number;
@ApiPropertyOptional({ description: 'Optional reason / note.' })
@IsOptional()
@IsString()
@MaxLength(500)
note?: string;
}

View File

@@ -0,0 +1,11 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
/** The customer approving a handover must record their full name (signature optional). */
export class ApproveDeliveryDto {
@ApiProperty({ description: 'Full name of the person approving delivery.' })
@IsString()
@IsNotEmpty()
@MaxLength(160)
signerName!: string;
}

View File

@@ -37,6 +37,10 @@ export class BookingHandover extends BaseEntity {
@Column({ name: 'signed_at', type: 'timestamptz', nullable: true })
signedAt?: Date | null;
/** Full name of the person who signed off the handover (required at sign time). */
@Column({ name: 'signer_name', type: 'varchar', length: 160, nullable: true })
signerName?: string | null;
@Column({ name: 'signed_by_user_id', type: 'uuid', nullable: true })
signedByUserId?: string | null;

View File

@@ -183,12 +183,20 @@ export class HandoverService {
}
/** Sign all unsigned handovers on a booking (self-haul: before the truck leaves). */
async signForBooking(bookingId: string, userId?: string | null): Promise<void> {
async signForBooking(
bookingId: string,
userId?: string | null,
signerName?: string | null,
): Promise<void> {
await this.dataSource
.getRepository(BookingHandover)
.update(
{ bookingId, signedAt: IsNull() },
{ signedAt: new Date(), signedByUserId: userId ?? null },
{
signedAt: new Date(),
signedByUserId: userId ?? null,
signerName: signerName?.trim() || null,
},
);
}

View File

@@ -1,7 +1,10 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { ExchangeService } from '@edr/api-common';
import { NotificationAudience, NotificationType } from '@edr/types';
import { DataSource } from 'typeorm';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { CreateFeeRuleDto, UpdateFeeRuleDto } from './dto/fee-rule.dto';
import { FeeRuleBasis, FeeRuleType, WarehouseFeeRule, WarehouseFeeTier } from './entities/warehouse-fee-rule.entity';
import { WarehouseFeeRuleRepository } from './warehouse-fee-rule.repository';
@@ -26,6 +29,35 @@ interface ItemAttributes {
zoneId: string | null;
}
export type AccrualAlert = 'OK' | 'WARNING' | 'CHARGING';
export interface AccrualDashboardRow {
inventoryId: string;
status: string;
bookingId: string | null;
companyId: string | null;
bookingReference: string | null;
customerName: string | null;
warehouseCode: string | null;
zoneCode: string | null;
receivedAt: string | null;
currency: string;
accruedAmount: number;
freeDaysLeft: number | null;
charging: boolean;
alert: AccrualAlert;
/** Reviewed by ops — suppressed from alerts (snoozed until snoozeUntil, or indefinitely). */
acknowledged: boolean;
snoozeUntil: string | null;
breakdown: Array<{
type: FeeRuleType;
amount: number;
freeDays: number;
elapsedDays: number;
chargeableDays: number;
}>;
}
export interface FeePreview {
ruleType: FeeRuleType;
/** Double-handling charge basis (PER_CONTAINER | PER_TON | PER_MACHINERY); null otherwise. */
@@ -70,12 +102,82 @@ const MS_PER_DAY = 24 * 60 * 60 * 1000;
@Injectable()
export class WarehouseFeeService {
private readonly logger = new Logger(WarehouseFeeService.name);
constructor(
private readonly dataSource: DataSource,
private readonly feeRuleRepository: WarehouseFeeRuleRepository,
private readonly exchangeService: ExchangeService,
private readonly inbox: NotificationInboxService,
) {}
/**
* Daily accrual alerts: for every in-warehouse item that is charging or within
* its last free days, send the customer an in-app notification with the
* outstanding accrued amount so they can collect before (more) charges hit.
*/
@Cron(CronExpression.EVERY_DAY_AT_6AM, { name: 'warehouse-accrual-alert' })
async sendAccrualAlerts(): Promise<void> {
try {
const alerts = (await this.accrualDashboard()).filter(
(r) => r.alert !== 'OK' && !r.acknowledged,
);
if (!alerts.length) return;
this.logger.log(`Accrual alerts: ${alerts.length} item(s) charging or nearing charges`);
// Per-customer: notify each company about its own items.
for (const row of alerts.filter((r) => r.companyId)) {
const ref = row.bookingReference ?? row.inventoryId.slice(0, 8);
const amount = `${row.accruedAmount.toFixed(2)} ${row.currency}`;
const body = row.charging
? `Storage/demurrage is now charging on booking ${ref}${amount} accrued. Collect the cargo to stop further charges.`
: `Booking ${ref} has ${row.freeDaysLeft ?? 0} free day(s) left before storage/demurrage charges start (${amount} accrued so far).`;
try {
await this.inbox.notify({
recipients: { companyId: row.companyId! },
audience: NotificationAudience.PORTAL,
type: NotificationType.BOOKING_STATUS,
title: row.charging ? 'Storage charges accruing' : 'Free days ending soon',
body,
link: row.bookingId ? `/bookings/${row.bookingId}` : undefined,
data: {
inventoryId: row.inventoryId,
bookingId: row.bookingId,
alert: row.alert,
accruedAmount: row.accruedAmount,
action: 'ACCRUAL_ALERT',
},
});
} catch (err) {
this.logger.warn(
`Accrual alert failed for ${row.inventoryId}: ${(err as Error).message}`,
);
}
}
// Ops staff: one digest covering every alerting item.
const charging = alerts.filter((r) => r.charging).length;
const nearing = alerts.length - charging;
const currency = alerts[0]?.currency ?? 'USD';
const total = alerts.reduce((sum, r) => sum + r.accruedAmount, 0);
try {
await this.inbox.notify({
recipients: { allBackoffice: true },
audience: NotificationAudience.BACKOFFICE,
type: NotificationType.BOOKING_STATUS,
title: 'Warehouse fee accruals need attention',
body: `${charging} item(s) charging, ${nearing} nearing the free-day limit — ${total.toFixed(2)} ${currency} accruing. Review the accrual dashboard.`,
link: '/dashboard/warehouse-fee-invoices',
data: { charging, nearing, totalAccrued: Math.round(total * 100) / 100, action: 'ACCRUAL_ALERT_DIGEST' },
});
} catch (err) {
this.logger.warn(`Accrual staff digest failed: ${(err as Error).message}`);
}
} catch (err) {
this.logger.warn(`Accrual alert tick failed: ${(err as Error).message}`);
}
}
// ── Rule CRUD ──────────────────────────────────────────────────────────────
listRules(): Promise<WarehouseFeeRule[]> {
return this.feeRuleRepository.findAll({ order: { ruleType: 'ASC', priority: 'ASC' } });
@@ -416,6 +518,138 @@ export class WarehouseFeeService {
};
}
/**
* Live accrual dashboard: for every item still in the warehouse, the fees
* accruing right now (storage + demurrage + double-handling), how many free
* days remain, and an alert level so staff can act before charges land.
*/
async accrualDashboard(billingCurrency = 'USD'): Promise<AccrualDashboardRow[]> {
const items: Array<{
id: string;
status: string;
bookingId: string | null;
companyId: string | null;
bookingReference: string | null;
customerName: string | null;
warehouseCode: string | null;
zoneCode: string | null;
receivedAt: string | null;
}> = await this.dataSource.query(
`SELECT inv.id,
inv.status,
b.id AS "bookingId",
b.company_id AS "companyId",
b.reference AS "bookingReference",
c.name AS "customerName",
w.code AS "warehouseCode",
z.code AS "zoneCode",
inv.created_at AS "receivedAt"
FROM freight.warehouse_inventory inv
LEFT JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL
LEFT JOIN freight.companies c ON c.id = b.company_id
LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id
LEFT JOIN freight.warehouse_zones z ON z.id = inv.zone_id
WHERE inv.deleted_at IS NULL
AND inv.status IN ('RECEIVED', 'STORED', 'READY_FOR_LOADING', 'READY_FOR_PICKUP', 'RESERVED')
ORDER BY inv.created_at ASC`,
);
const ackRows: Array<{ inventoryId: string; snoozeUntil: string | null }> =
await this.dataSource.query(
`SELECT inventory_id AS "inventoryId", snooze_until AS "snoozeUntil"
FROM freight.warehouse_accrual_acks`,
);
const now = new Date();
const acks = new Map(ackRows.map((a) => [a.inventoryId, a.snoozeUntil]));
const rows = await Promise.all(
items.map(async (it): Promise<AccrualDashboardRow> => {
const previews = (await this.previewForInventory(it.id, billingCurrency)).filter(
(p) => p.ruleId,
);
const accruedAmount =
Math.round(previews.reduce((sum, p) => sum + (p.amount ?? 0), 0) * 100) / 100;
const charging = previews.some((p) => p.chargeableDays > 0);
const freeDaysLeftVals = previews
.filter((p) => p.endIsOpen)
.map((p) => Math.max(0, p.freeDays - p.elapsedDays));
const freeDaysLeft = freeDaysLeftVals.length ? Math.min(...freeDaysLeftVals) : null;
const alert: AccrualAlert = charging
? 'CHARGING'
: freeDaysLeft != null && freeDaysLeft <= 2
? 'WARNING'
: 'OK';
return {
inventoryId: it.id,
status: it.status,
bookingId: it.bookingId,
companyId: it.companyId,
bookingReference: it.bookingReference,
customerName: it.customerName,
warehouseCode: it.warehouseCode,
zoneCode: it.zoneCode,
receivedAt: it.receivedAt,
currency: billingCurrency,
accruedAmount,
freeDaysLeft,
charging,
alert,
acknowledged:
acks.has(it.id) &&
(acks.get(it.id) == null || new Date(acks.get(it.id) as string) > now),
snoozeUntil: acks.get(it.id) ?? null,
breakdown: previews.map((p) => ({
type: p.ruleType,
amount: p.amount,
freeDays: p.freeDays,
elapsedDays: p.elapsedDays,
chargeableDays: p.chargeableDays,
})),
};
}),
);
const rank = (a: AccrualAlert) => (a === 'CHARGING' ? 0 : a === 'WARNING' ? 1 : 2);
// Acknowledged items sink to the bottom; among the rest, worst alert first.
return rows.sort(
(a, b) =>
Number(a.acknowledged) - Number(b.acknowledged) ||
rank(a.alert) - rank(b.alert) ||
b.accruedAmount - a.accruedAmount,
);
}
/** Mark an item's accrual reviewed. `snoozeDays` > 0 suppresses alerts until then; omitted = indefinitely. */
async acknowledgeAccrual(
inventoryId: string,
opts: { snoozeDays?: number; note?: string; userId?: string } = {},
): Promise<void> {
const snoozeUntil =
opts.snoozeDays && opts.snoozeDays > 0
? new Date(Date.now() + opts.snoozeDays * 24 * 60 * 60 * 1000)
: null;
await this.dataSource.query(
`INSERT INTO freight.warehouse_accrual_acks
(inventory_id, acknowledged_by, acknowledged_at, snooze_until, note, updated_at)
VALUES ($1, $2, now(), $3, $4, now())
ON CONFLICT (inventory_id) DO UPDATE
SET acknowledged_by = EXCLUDED.acknowledged_by,
acknowledged_at = now(),
snooze_until = EXCLUDED.snooze_until,
note = EXCLUDED.note,
updated_at = now()`,
[inventoryId, opts.userId ?? null, snoozeUntil, opts.note?.trim() || null],
);
}
/** Remove an acknowledgement so the item re-surfaces for alerts. */
async unacknowledgeAccrual(inventoryId: string): Promise<void> {
await this.dataSource.query(
`DELETE FROM freight.warehouse_accrual_acks WHERE inventory_id = $1`,
[inventoryId],
);
}
/** Preview demurrage + storage fees for an inventory item using the most specific active rules. */
async previewForInventory(inventoryId: string, billingCurrency = 'USD'): Promise<FeePreview[]> {
const item = await this.loadItem(inventoryId);

View File

@@ -12,6 +12,8 @@ import {
import { AnyFilesInterceptor } from '@nestjs/platform-express';
import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { CreateInspectionReportDto } from './dto/create-inspection-report.dto';
import { UpdateInspectionReportDto } from './dto/update-inspection-report.dto';
import { WarehouseInspectionService } from './warehouse-inspection.service';
@@ -19,10 +21,12 @@ import { WarehouseInspectionService } from './warehouse-inspection.service';
@ApiTags('warehouse-inspection')
@ApiBearerAuth()
@Controller()
@BookingStaff(FREIGHT_PERMS.warehouseInspectionReports.view)
export class WarehouseInspectionController {
constructor(private readonly inspectionService: WarehouseInspectionService) {}
@Post('warehouse-inventory/:inventoryId/inspection-reports')
@BookingStaff(FREIGHT_PERMS.warehouseInspectionReports.create)
@ApiOperation({ summary: 'Create an inspection / damage report for an inventory item' })
create(
@Param('inventoryId', ParseUUIDPipe) inventoryId: string,
@@ -46,12 +50,14 @@ export class WarehouseInspectionController {
}
@Patch('warehouse-inspection-reports/:id')
@BookingStaff(FREIGHT_PERMS.warehouseInspectionReports.update)
@ApiOperation({ summary: 'Update an inspection report' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateInspectionReportDto) {
return this.inspectionService.update(id, dto);
}
@Post('warehouse-inspection-reports/:id/attachments')
@BookingStaff(FREIGHT_PERMS.warehouseInspectionReports.update)
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Upload inspection images / documents' })

View File

@@ -2,6 +2,8 @@ import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Reques
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import type { Response } from 'express';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { BulkReceiveDto } from './dto/bulk-receive.dto';
import { BulkInspectDto } from './dto/bulk-inspect.dto';
import { DeliverInventoryDto } from './dto/deliver-inventory.dto';
@@ -11,6 +13,7 @@ import { LoadInventoryDto } from './dto/load-inventory.dto';
import { MoveInventoryDto } from './dto/move-inventory.dto';
import { StoreInventoryDto } from './dto/store-inventory.dto';
import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto';
import { ApproveDeliveryDto } from './dto/approve-delivery.dto';
import { ReleaseOrderDto } from './dto/release-order.dto';
import { ReserveInventoryDto } from './dto/reserve-inventory.dto';
import { UnloadBookingDto } from './dto/unload-booking.dto';
@@ -29,48 +32,63 @@ export class WarehouseInventoryController {
) {}
@Get()
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'List warehouse inventory' })
findAll(@Query() filter: FilterWarehouseInventoryDto) {
return this.inventoryService.findAll(filter);
}
@Get('ready-for-loading')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'List inventory ready for loading' })
findReadyForLoading(@Query() filter: FilterWarehouseInventoryDto) {
return this.inventoryService.findReadyForLoading(filter);
}
@Get('inquiry')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'Locate any item inside the warehouse' })
inquiry(@Query() filter: InquiryWarehouseInventoryDto) {
return this.inventoryService.inquiry(filter);
}
@Get('arrival-queue')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'Arrived bookings awaiting unload / inspection' })
arrivalQueue() {
return this.inventoryService.arrivalQueue();
}
@Get('ops-stats')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'At-a-glance warehouse ops counters for the KPI strip' })
opsStats() {
return this.inventoryService.opsStats();
}
@Get('zone-occupancy')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'Live occupancy per zone (rated capacity vs held) — heatmap data' })
zoneOccupancy(@Query('yardId') yardId?: string) {
return this.inventoryService.zoneOccupancy(yardId);
}
@Post('auto-unload-arrived')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.unload)
@ApiOperation({ summary: 'Bulk auto-unload all arrived bookings into the warehouse' })
autoUnloadArrived() {
return this.inventoryService.autoUnloadArrived();
}
@Post('auto-load-ready')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.load)
@ApiOperation({ summary: 'Auto-load READY_FOR_LOADING inventory with PAID bookings' })
autoLoadReady() {
return this.inventoryService.autoLoadReady();
}
@Get('eligible-bookings')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'PAID bookings not yet received, classified IMPORT/EXPORT by route; omit direction for all' })
eligibleBookings(@Query('direction') direction?: string) {
const dir = direction === 'IMPORT' || direction === 'EXPORT' ? direction : undefined;
@@ -78,6 +96,7 @@ export class WarehouseInventoryController {
}
@Post('receive-bulk')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.receive)
@ApiOperation({ summary: 'Bulk-receive selected eligible PAID bookings into a location' })
receiveBulk(@Body() dto: BulkReceiveDto) {
return this.inventoryService.bulkReceive(dto);
@@ -85,36 +104,42 @@ export class WarehouseInventoryController {
@Get('ready-to-load-export')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'EXPORT inventory that passed inspection and is READY_FOR_LOADING' })
readyToLoadExport() {
return this.inventoryService.readyToLoadExport();
}
@Get('received-export')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'EXPORT inventory that has been received and is awaiting inspection' })
receivedExport() {
return this.inventoryService.receivedExport();
}
@Get('loaded-export')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'EXPORT inventory that is LOADED and queued for dispatch' })
loadedExport() {
return this.inventoryService.loadedExport();
}
@Get('loadable-trains')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'EXPORT trains (pre-dispatch) with inventory waiting to be loaded' })
loadableTrains() {
return this.inventoryService.loadableTrains();
}
@Get('train/:scheduleId/loadable-items')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'Container/cargo inventory assigned to a train, with allocated wagons' })
trainLoadableItems(@Param('scheduleId', ParseUUIDPipe) scheduleId: string) {
return this.inventoryService.trainLoadableItems(scheduleId);
}
@Post('train/:scheduleId/load')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.load)
@ApiOperation({ summary: 'Load selected inventory items onto their allocated wagons for a train' })
loadItemsOntoTrain(
@Param('scheduleId', ParseUUIDPipe) scheduleId: string,
@@ -124,18 +149,21 @@ export class WarehouseInventoryController {
}
@Post('bulk-dispatch-export')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.dispatch)
@ApiOperation({ summary: 'Bulk-dispatch loaded EXPORT inventory (LOADED → DISPATCHED)' })
bulkDispatchExport(@Body() dto: { inventoryIds: string[]; performedBy?: string }) {
return this.inventoryService.bulkDispatchExport(dto.inventoryIds ?? [], dto.performedBy);
}
@Post('bulk-mark-inspected')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.inspect)
@ApiOperation({ summary: 'Bulk mark received inventory inspection PASSED (EXPORT → READY_FOR_LOADING)' })
bulkMarkInspected(@Body() dto: BulkInspectDto) {
return this.inventoryService.bulkMarkInspected(dto);
}
@Post('bookings/:bookingId/unload')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.unload)
@ApiOperation({ summary: 'Unload a single arrived booking into a location' })
unloadBooking(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@@ -145,24 +173,28 @@ export class WarehouseInventoryController {
}
@Post(':id/gate-clearance')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.gatePass)
@ApiOperation({ summary: 'Final terminal release / gate clearance (blocked while fees unpaid)' })
gateClearance(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) {
return this.inventoryService.gateClearance(id, performedBy);
}
@Get('import/arrive-queue')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'Arrived IMPORT train schedules (route-derived), read-only from scheduling' })
importArriveQueue() {
return this.scheduling.importArriveQueue();
}
@Get('import/trains/:scheduleId/items')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'Assigned bookings/items for an arrived import train (read-only)' })
importTrainDetail(@Param('scheduleId', ParseUUIDPipe) scheduleId: string) {
return this.scheduling.importTrainDetail(scheduleId);
}
@Post('import/auto-unload-arrived-bookings')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.unload)
@ApiOperation({ summary: 'Unload all eligible assigned bookings of an ARRIVED import train (→ UNLOADED)' })
autoUnloadArrivedBookings(@Body() dto: {
scheduleId: string;
@@ -179,12 +211,14 @@ export class WarehouseInventoryController {
}
@Get('import/unloaded-queue')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'IMPORT inventory in the Unloaded Queue (UNLOADED / destination inspection)' })
importUnloadedQueue() {
return this.inventoryService.importUnloadedQueue();
}
@Get('export/djibouti-arrival-queue')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'Arrived EXPORT train schedules at Djibouti-side ports, ready for unloading' })
exportDjiboutiArrivalQueue(
@Query('scheduleId') scheduleId?: string,
@@ -203,102 +237,119 @@ export class WarehouseInventoryController {
}
@Get('export/djibouti-trains/:scheduleId/items')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'Assigned export bookings/items for an arrived Djibouti-side train' })
exportDjiboutiTrainDetail(@Param('scheduleId', ParseUUIDPipe) scheduleId: string) {
return this.scheduling.exportDjiboutiTrainDetail(scheduleId);
}
@Post('export/auto-unload-at-djibouti')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.unload)
@ApiOperation({ summary: 'Unload all eligible export items assigned to an arrived Djibouti-side train' })
autoUnloadExportAtDjibouti(@Body() dto: { scheduleId: string; performedBy?: string }) {
return this.inventoryService.autoUnloadExportAtDjibouti(dto.scheduleId, dto.performedBy);
}
@Get('import/pickup-ready-queue')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'IMPORT inventory that is PICKUP_READY (READY_FOR_PICKUP) awaiting pickup/dispatch' })
importPickupReadyQueue() {
return this.inventoryService.importPickupReadyQueue();
}
@Get('loadable-wagons')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'List wagons usable for loading (read-only from scheduling)' })
loadableWagons() {
return this.scheduling.listLoadableWagons();
}
@Get('booking/:bookingId/schedule')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'Read-only schedule + wagon + departure status for a booking' })
bookingSchedule(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
return this.scheduling.getBookingSchedule(bookingId);
}
@Post('receive')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.receive)
@ApiOperation({ summary: 'Receive inventory at a warehouse location' })
receive(@Body() dto: ReceiveWarehouseInventoryDto) {
return this.inventoryService.receive(dto);
}
@Post('reserve')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.move)
@ApiOperation({ summary: 'Reserve stored inventory for a PAID booking' })
reserve(@Body() dto: ReserveInventoryDto) {
return this.inventoryService.reserve(dto);
}
@Get(':id/movements')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'Inventory movement history' })
movements(@Param('id', ParseUUIDPipe) id: string) {
return this.inventoryService.findMovements(id);
}
@Get(':id/activity')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'Inventory activity log' })
activity(@Param('id', ParseUUIDPipe) id: string) {
return this.inventoryService.findActivity(id);
}
@Get(':id/loadings')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'Loading records for an inventory item' })
loadings(@Param('id', ParseUUIDPipe) id: string) {
return this.inventoryService.findLoadingsByInventory(id);
}
@Post(':id/move')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.move)
@ApiOperation({ summary: 'Move inventory to another warehouse/yard/zone' })
move(@Param('id', ParseUUIDPipe) id: string, @Body() dto: MoveInventoryDto) {
return this.inventoryService.move(id, dto);
}
@Post(':id/store')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.move)
@ApiOperation({ summary: 'Mark received inventory as STORED (optional explicit warehouse/yard/zone)' })
store(@Param('id', ParseUUIDPipe) id: string, @Body() dto: StoreInventoryDto) {
return this.inventoryService.store(id, dto.performedBy, dto);
}
@Post(':id/ready-for-loading')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.move)
@ApiOperation({ summary: 'Mark reserved inventory READY_FOR_LOADING' })
readyForLoading(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) {
return this.inventoryService.readyForLoading(id, performedBy);
}
@Post(':id/load')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.load)
@ApiOperation({ summary: 'Load READY_FOR_LOADING inventory onto a wagon' })
load(@Param('id', ParseUUIDPipe) id: string, @Body() dto: LoadInventoryDto) {
return this.inventoryService.load(id, dto);
}
@Post(':id/ready-for-pickup')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.move)
@ApiOperation({ summary: 'Mark inspected IMPORT inventory READY_FOR_PICKUP' })
readyForPickup(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) {
return this.inventoryService.readyForPickup(id, performedBy);
}
@Post(':id/release')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.release)
@ApiOperation({ summary: 'Issue a DO / release order for ready-for-pickup inventory' })
release(@Param('id', ParseUUIDPipe) id: string, @Body() dto: ReleaseOrderDto) {
return this.inventoryService.release(id, dto);
}
@Get(':id/release-document')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'View warehouse release / exit paper PDF' })
async releaseDocument(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) {
const { filename, buffer } = await this.inventoryService.releaseDocument(id);
@@ -309,6 +360,7 @@ export class WarehouseInventoryController {
}
@Get('customer-truck-exit-paper/:assignmentId')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'Per-truck exit paper PDF (containers loaded on one customer truck)' })
async truckExitPaper(
@Param('assignmentId', ParseUUIDPipe) assignmentId: string,
@@ -322,6 +374,7 @@ export class WarehouseInventoryController {
}
@Get(':id/grn-document')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'View goods received note PDF' })
async grnDocument(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) {
const { filename, buffer } = await this.inventoryService.grnDocument(id);
@@ -342,12 +395,17 @@ export class WarehouseInventoryController {
}
@Post('bookings/:bookingId/approve-delivery')
@ApiOperation({ summary: "Approve delivery using the current customer's saved signature" })
@ApiOperation({ summary: "Approve delivery — customer records their full name (signature optional)" })
approveDeliveryForBooking(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@Body() dto: ApproveDeliveryDto,
@Request() req: { user?: { id?: string; sub?: string } },
) {
return this.inventoryService.approveDeliveryForBooking(bookingId, req.user?.id ?? req.user?.sub);
return this.inventoryService.approveDeliveryForBooking(
bookingId,
req.user?.id ?? req.user?.sub,
dto.signerName,
);
}
@Get('bookings/:bookingId/handovers')
@@ -362,6 +420,26 @@ export class WarehouseInventoryController {
return this.handoverService.requestSignature(bookingId);
}
@Get('bookings/:bookingId/grn-document')
@ApiOperation({ summary: 'View GRN PDF for a booking (customer portal)' })
async bookingGrnDocument(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Res() res: Response) {
const { filename, buffer } = await this.inventoryService.grnDocumentForBooking(bookingId);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
res.setHeader('Content-Length', buffer.length);
return res.send(buffer);
}
@Get('bookings/:bookingId/release-document')
@ApiOperation({ summary: 'View gate-clearance / release-order PDF for a booking (customer portal)' })
async bookingReleaseDocument(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Res() res: Response) {
const { filename, buffer } = await this.inventoryService.releaseDocumentForBooking(bookingId);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
res.setHeader('Content-Length', buffer.length);
return res.send(buffer);
}
@Get('bookings/:bookingId/handover-document')
@ApiOperation({ summary: 'View import goods handover document PDF (resolved by booking)' })
async bookingHandoverDocument(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Res() res: Response) {
@@ -385,12 +463,14 @@ export class WarehouseInventoryController {
}
@Post(':id/deliver')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.deliver)
@ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' })
deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DeliverInventoryDto) {
return this.inventoryService.deliver(id, dto);
}
@Patch(':id/dispatch')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.dispatch)
@ApiOperation({ summary: 'Mark loaded inventory DISPATCHED (left the terminal)' })
dispatch(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) {
return this.inventoryService.dispatch(id, performedBy);

View File

@@ -397,6 +397,45 @@ export class WarehouseInventoryService {
* but has no customer truck assigned yet, nudge the customer to assign one — with
* a deep-link to the booking's truck-assignment card. Fire-and-forget.
*/
/**
* At-a-glance warehouse ops counters for the KPI strip:
* - receivedToday: items received today
* - pendingInspection: RECEIVED items not yet inspected
* - trucksOnSite: customer trucks arrived but not departed
* - itemsAging: in-warehouse items older than 7 days (demurrage risk)
*/
async opsStats(): Promise<{
receivedToday: number;
pendingInspection: number;
trucksOnSite: number;
itemsAging: number;
}> {
const [row]: Array<{
receivedToday: number;
pendingInspection: number;
trucksOnSite: number;
itemsAging: number;
}> = await this.dataSource.query(
`SELECT
(SELECT count(*)::int FROM freight.warehouse_inventory
WHERE deleted_at IS NULL AND created_at::date = CURRENT_DATE) AS "receivedToday",
(SELECT count(*)::int FROM freight.warehouse_inventory
WHERE deleted_at IS NULL AND status = 'RECEIVED' AND inspection_status IS NULL) AS "pendingInspection",
(SELECT count(*)::int FROM freight.customer_truck_assignments
WHERE deleted_at IS NULL AND arrived_at IS NOT NULL AND departed_at IS NULL) AS "trucksOnSite",
(SELECT count(*)::int FROM freight.warehouse_inventory
WHERE deleted_at IS NULL
AND status IN ('RECEIVED', 'STORED', 'READY_FOR_LOADING', 'READY_FOR_PICKUP', 'RESERVED')
AND created_at < now() - interval '7 days') AS "itemsAging"`,
);
return {
receivedToday: row?.receivedToday ?? 0,
pendingInspection: row?.pendingInspection ?? 0,
trucksOnSite: row?.trucksOnSite ?? 0,
itemsAging: row?.itemsAging ?? 0,
};
}
/**
* Live occupancy per zone: rated capacity vs the weight/items currently held
* (excludes items that have left — DELIVERED/DISPATCHED). Powers the yard
@@ -454,14 +493,17 @@ export class WarehouseInventoryService {
return rows.map((r) => {
const capWeight = r.capacityWeight != null ? Number(r.capacityWeight) : null;
// Zone capacity_weight is in TONNES; inventory weight is in KG — normalise
// used weight to tonnes before comparing so weight occupancy is correct.
const usedWeightTons = r.usedWeight / 1000;
const byWeight =
capWeight && capWeight > 0 ? (r.usedWeight / capWeight) * 100 : null;
capWeight && capWeight > 0 ? (usedWeightTons / capWeight) * 100 : null;
const byItems =
r.capacityContainers && r.capacityContainers > 0
? (r.usedItems / r.capacityContainers) * 100
: null;
// Prefer container-count occupancy (unit-consistent). Weight capacity is
// tonnes while inventory weight is kg, so weight% is only a rough fallback.
// Container zones use item-count occupancy; bulk zones (no container cap)
// fall back to the now unit-correct weight occupancy.
const pct = byItems ?? byWeight;
return {
id: r.id,
@@ -3132,16 +3174,20 @@ export class WarehouseInventoryService {
async approveDeliveryForBooking(
bookingId: string,
userId?: string,
signerName?: string,
): Promise<{ bookingId: string; inventoryId: string; approvedAt: string; signerDisplayName: string }> {
if (!userId) {
throw new BadRequestException('Authentication is required to approve delivery');
}
const signature = await this.signatures.getForUser(userId);
if (!signature?.signatureImageUrl) {
throw new BadRequestException('Please save your signature before approving delivery');
const name = signerName?.trim();
if (!name) {
throw new BadRequestException('Please enter your full name to approve delivery');
}
// A saved signature is applied when available; otherwise the typed full name
// is the record of who approved (self-haul customers may have no signature).
const signature = await this.signatures.getForUser(userId).catch(() => null);
const [item]: Array<{
id: string;
warehouseId: string | null;
@@ -3176,8 +3222,8 @@ export class WarehouseInventoryService {
const approvedAt = new Date();
const approval = {
approvedAt: approvedAt.toISOString(),
signerDisplayName: signature.signerDisplayName,
signatureImageUrl: signature.signatureImageUrl,
signerDisplayName: name,
signatureImageUrl: signature?.signatureImageUrl ?? null,
userId,
};
const existingNotes = this.stripCustomerDeliveryApproval(item.notes);
@@ -3192,8 +3238,8 @@ export class WarehouseInventoryService {
activityType: 'INVENTORY_RELEASED',
inventoryId: item.id,
warehouseId: item.warehouseId,
description: `Customer approved delivery as ${signature.signerDisplayName}`,
performedBy: signature.signerDisplayName,
description: `Customer approved delivery as ${name}`,
performedBy: name,
},
manager,
);
@@ -3201,13 +3247,13 @@ 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);
await this.handover.signForBooking(bookingId, userId, name);
return {
bookingId,
inventoryId: item.id,
approvedAt: approval.approvedAt,
signerDisplayName: signature.signerDisplayName,
signerDisplayName: name,
};
}
@@ -3226,6 +3272,31 @@ export class WarehouseInventoryService {
return this.handoverDocument(inv.id);
}
/** Resolve the primary warehouse-inventory item for a booking (most recent). */
private async primaryInventoryIdForBooking(bookingId: string): Promise<string> {
const [inv]: Array<{ id: string }> = await this.dataSource.query(
`SELECT id FROM freight.warehouse_inventory
WHERE booking_id = $1 AND deleted_at IS NULL
ORDER BY updated_at DESC NULLS LAST, created_at DESC
LIMIT 1`,
[bookingId],
);
if (!inv) {
throw new NotFoundException(`No warehouse inventory found for booking ${bookingId}`);
}
return inv.id;
}
/** Booking-scoped GRN document (customer portal). */
async grnDocumentForBooking(bookingId: string): Promise<{ filename: string; buffer: Buffer }> {
return this.grnDocument(await this.primaryInventoryIdForBooking(bookingId));
}
/** Booking-scoped gate-clearance / release document (customer portal). */
async releaseDocumentForBooking(bookingId: string): Promise<{ filename: string; buffer: Buffer }> {
return this.releaseDocument(await this.primaryInventoryIdForBooking(bookingId));
}
async handoverDocument(id: string): Promise<{ filename: string; buffer: Buffer }> {
const [row] = await this.dataSource.query(
`SELECT inv.id,

View File

@@ -2,6 +2,8 @@ import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Res }
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import type { Response } from 'express';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { PayInvoiceDto as GatewayPayInvoiceDto } from '../billing/dto/pay-invoice.dto';
import { GenerateInvoiceDto, PayInvoiceBodyDto } from './dto/invoice.dto';
import { WarehouseInvoiceService } from './warehouse-invoice.service';
@@ -13,12 +15,14 @@ export class WarehouseInvoiceController {
constructor(private readonly invoiceService: WarehouseInvoiceService) {}
@Post('warehouse-inventory/:id/generate-fee-invoice')
@BookingStaff(FREIGHT_PERMS.warehouseFeeInvoices.generate)
@ApiOperation({ summary: 'Generate a warehouse fee invoice from Batch 5 fee calculation' })
generate(@Param('id', ParseUUIDPipe) id: string, @Body() dto: GenerateInvoiceDto) {
return this.invoiceService.generateForInventory(id, dto);
}
@Post('last-mile/:id/generate-truck-detention-invoice')
@BookingStaff(FREIGHT_PERMS.warehouseFeeInvoices.generate)
@ApiOperation({ summary: 'Generate a truck-detention invoice for a last-mile leg (per truck per day)' })
generateTruckDetention(
@Param('id', ParseUUIDPipe) id: string,
@@ -28,6 +32,7 @@ export class WarehouseInvoiceController {
}
@Get('warehouse-inventory/:id/fee-invoices')
@BookingStaff(FREIGHT_PERMS.warehouseFeeInvoices.view)
@ApiOperation({ summary: 'List fee invoices for an inventory item' })
listForInventory(@Param('id', ParseUUIDPipe) id: string) {
return this.invoiceService.listForInventory(id);
@@ -40,6 +45,7 @@ export class WarehouseInvoiceController {
}
@Get('warehouse-fee-invoices')
@BookingStaff(FREIGHT_PERMS.warehouseFeeInvoices.view)
@ApiOperation({ summary: 'List / filter warehouse fee invoices' })
findAll(
@Query('status') status?: string,
@@ -86,12 +92,14 @@ export class WarehouseInvoiceController {
}
@Patch('warehouse-fee-invoices/:id/cancel')
@BookingStaff(FREIGHT_PERMS.warehouseFeeInvoices.cancel)
@ApiOperation({ summary: 'Cancel a warehouse fee invoice' })
cancel(@Param('id', ParseUUIDPipe) id: string) {
return this.invoiceService.cancel(id);
}
@Post('warehouse-fee-invoices/:id/pay')
@BookingStaff(FREIGHT_PERMS.warehouseFeeInvoices.pay)
@ApiOperation({ summary: 'Record a payment against a warehouse fee invoice' })
pay(@Param('id', ParseUUIDPipe) id: string, @Body() dto: PayInvoiceBodyDto) {
return this.invoiceService.pay(id, dto);

View File

@@ -1,11 +1,14 @@
import { Controller, Get, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { WarehouseInventoryService } from './warehouse-inventory.service';
@ApiTags('warehouse-loadings')
@ApiBearerAuth()
@Controller('warehouse-loadings')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
export class WarehouseLoadingsController {
constructor(private readonly inventoryService: WarehouseInventoryService) {}

View File

@@ -1,12 +1,15 @@
import { Body, Controller, Delete, Get, HttpCode, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import {
AllocationPreviewDto,
CreateAllocationRuleDto,
UpdateAllocationRuleDto,
} from './dto/allocation-rule.dto';
import { CreateFeeRuleDto, UpdateFeeRuleDto } from './dto/fee-rule.dto';
import { AcknowledgeAccrualDto } from './dto/acknowledge-accrual.dto';
import { WarehouseAllocationService } from './warehouse-allocation.service';
import { WarehouseFeeService } from './warehouse-fee.service';
@@ -21,18 +24,21 @@ export class WarehouseRulesController {
// ── Allocation rules ───────────────────────────────────────────────────────
@Get('warehouse-allocation-rules')
@BookingStaff(FREIGHT_PERMS.warehouseAllocationRules.view)
@ApiOperation({ summary: 'List warehouse allocation rules' })
listAllocationRules() {
return this.allocationService.listRules();
}
@Post('warehouse-allocation-rules')
@BookingStaff(FREIGHT_PERMS.warehouseAllocationRules.create)
@ApiOperation({ summary: 'Create a warehouse allocation rule' })
createAllocationRule(@Body() dto: CreateAllocationRuleDto) {
return this.allocationService.createRule(dto);
}
@Patch('warehouse-allocation-rules/:id')
@BookingStaff(FREIGHT_PERMS.warehouseAllocationRules.update)
@ApiOperation({ summary: 'Update a warehouse allocation rule' })
updateAllocationRule(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateAllocationRuleDto) {
return this.allocationService.updateRule(id, dto);
@@ -40,12 +46,14 @@ export class WarehouseRulesController {
@Delete('warehouse-allocation-rules/:id')
@HttpCode(204)
@BookingStaff(FREIGHT_PERMS.warehouseAllocationRules.delete)
@ApiOperation({ summary: 'Delete a warehouse allocation rule' })
deleteAllocationRule(@Param('id', ParseUUIDPipe) id: string) {
return this.allocationService.deleteRule(id);
}
@Post('warehouse-allocation/preview')
@BookingStaff(FREIGHT_PERMS.warehouseAllocationRules.view)
@ApiOperation({ summary: 'Preview the yard/warehouse/zone a booking would be allocated to' })
previewAllocation(@Body() dto: AllocationPreviewDto) {
return this.allocationService.resolveLocation(dto);
@@ -53,18 +61,21 @@ export class WarehouseRulesController {
// ── Fee rules ────────────────────────────────────────────────────────────────
@Get('warehouse-fee-rules')
@BookingStaff(FREIGHT_PERMS.warehouseFeeRules.view)
@ApiOperation({ summary: 'List storage / demurrage fee rules' })
listFeeRules() {
return this.feeService.listRules();
}
@Post('warehouse-fee-rules')
@BookingStaff(FREIGHT_PERMS.warehouseFeeRules.create)
@ApiOperation({ summary: 'Create a storage / demurrage fee rule' })
createFeeRule(@Body() dto: CreateFeeRuleDto) {
return this.feeService.createRule(dto);
}
@Patch('warehouse-fee-rules/:id')
@BookingStaff(FREIGHT_PERMS.warehouseFeeRules.update)
@ApiOperation({ summary: 'Update a fee rule' })
updateFeeRule(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateFeeRuleDto) {
return this.feeService.updateRule(id, dto);
@@ -72,12 +83,42 @@ export class WarehouseRulesController {
@Delete('warehouse-fee-rules/:id')
@HttpCode(204)
@BookingStaff(FREIGHT_PERMS.warehouseFeeRules.delete)
@ApiOperation({ summary: 'Delete a fee rule' })
deleteFeeRule(@Param('id', ParseUUIDPipe) id: string) {
return this.feeService.deleteRule(id);
}
@Get('warehouse-fees/accrual-dashboard')
@BookingStaff(FREIGHT_PERMS.warehouseFeeRules.view)
@ApiOperation({ summary: 'Live per-item fee accrual (storage/demurrage) with alerts' })
accrualDashboard(@Query('billingCurrency') billingCurrency?: string) {
return this.feeService.accrualDashboard(billingCurrency);
}
@Post('warehouse-fees/accrual/:inventoryId/acknowledge')
@BookingStaff(FREIGHT_PERMS.warehouseFeeRules.update)
@ApiOperation({ summary: 'Acknowledge / snooze an item fee-accrual alert' })
acknowledgeAccrual(
@Param('inventoryId', ParseUUIDPipe) inventoryId: string,
@Body() dto: AcknowledgeAccrualDto,
) {
return this.feeService.acknowledgeAccrual(inventoryId, {
snoozeDays: dto.snoozeDays,
note: dto.note,
});
}
@Delete('warehouse-fees/accrual/:inventoryId/acknowledge')
@HttpCode(204)
@BookingStaff(FREIGHT_PERMS.warehouseFeeRules.update)
@ApiOperation({ summary: 'Remove an accrual acknowledgement (re-surface for alerts)' })
unacknowledgeAccrual(@Param('inventoryId', ParseUUIDPipe) inventoryId: string) {
return this.feeService.unacknowledgeAccrual(inventoryId);
}
@Get('warehouse-inventory/:id/fee-preview')
@BookingStaff(FREIGHT_PERMS.warehouseFeeRules.view)
@ApiOperation({ summary: 'Preview demurrage + storage fees for an inventory item' })
feePreview(
@Param('id', ParseUUIDPipe) id: string,
@@ -87,6 +128,7 @@ export class WarehouseRulesController {
}
@Get('last-mile/:id/truck-detention-preview')
@BookingStaff(FREIGHT_PERMS.warehouseFeeRules.view)
@ApiOperation({ summary: 'Preview truck detention for a last-mile leg (per truck per day after grace)' })
truckDetentionPreview(
@Param('id', ParseUUIDPipe) id: string,

View File

@@ -1,6 +1,8 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { CreateWarehouseZoneDto } from './dto/create-warehouse-zone.dto';
import { UpdateWarehouseYardDto } from './dto/update-warehouse-yard.dto';
import { WarehouseYardsService } from './warehouse-yards.service';
@@ -9,6 +11,7 @@ import { WarehouseZonesService } from './warehouse-zones.service';
@ApiTags('warehouse-yards')
@ApiBearerAuth()
@Controller('warehouse-yards')
@BookingStaff(FREIGHT_PERMS.warehouseYards.view)
export class WarehouseYardsController {
constructor(
private readonly yardsService: WarehouseYardsService,
@@ -28,18 +31,21 @@ export class WarehouseYardsController {
}
@Patch(':id')
@BookingStaff(FREIGHT_PERMS.warehouseYards.update)
@ApiOperation({ summary: 'Update warehouse yard' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWarehouseYardDto) {
return this.yardsService.update(id, dto);
}
@Get(':yardId/zones')
@BookingStaff(FREIGHT_PERMS.warehouseZones.view)
@ApiOperation({ summary: 'List zones within a yard' })
listZones(@Param('yardId', ParseUUIDPipe) yardId: string) {
return this.zonesService.findByYard(yardId);
}
@Post(':yardId/zones')
@BookingStaff(FREIGHT_PERMS.warehouseZones.create)
@ApiOperation({ summary: 'Create a zone within a yard' })
createZone(
@Param('yardId', ParseUUIDPipe) yardId: string,

View File

@@ -1,12 +1,15 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { UpdateWarehouseZoneDto } from './dto/update-warehouse-zone.dto';
import { WarehouseZonesService } from './warehouse-zones.service';
@ApiTags('warehouse-zones')
@ApiBearerAuth()
@Controller('warehouse-zones')
@BookingStaff(FREIGHT_PERMS.warehouseZones.view)
export class WarehouseZonesController {
constructor(private readonly zonesService: WarehouseZonesService) {}
@@ -23,6 +26,7 @@ export class WarehouseZonesController {
}
@Patch(':id')
@BookingStaff(FREIGHT_PERMS.warehouseZones.update)
@ApiOperation({ summary: 'Update warehouse zone' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWarehouseZoneDto) {
return this.zonesService.update(id, dto);

View File

@@ -1,6 +1,8 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { CreateWarehouseDto } from './dto/create-warehouse.dto';
import { CreateWarehouseYardDto } from './dto/create-warehouse-yard.dto';
import { FilterWarehouseDto } from './dto/filter-warehouse.dto';
@@ -12,6 +14,7 @@ import { WarehousesService } from './warehouses.service';
@ApiTags('warehouses')
@ApiBearerAuth()
@Controller('warehouses')
@BookingStaff(FREIGHT_PERMS.warehouses.view)
export class WarehousesController {
constructor(
private readonly warehousesService: WarehousesService,
@@ -26,12 +29,14 @@ export class WarehousesController {
}
@Get('dashboard')
@BookingStaff(FREIGHT_PERMS.warehouseDashboard.view)
@ApiOperation({ summary: 'Warehouse dashboard metrics' })
dashboard() {
return this.dashboardService.getDashboard();
}
@Post()
@BookingStaff(FREIGHT_PERMS.warehouses.create)
@ApiOperation({ summary: 'Create warehouse' })
create(@Body() dto: CreateWarehouseDto) {
return this.warehousesService.create(dto);
@@ -44,18 +49,21 @@ export class WarehousesController {
}
@Patch(':id')
@BookingStaff(FREIGHT_PERMS.warehouses.update)
@ApiOperation({ summary: 'Update warehouse' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWarehouseDto) {
return this.warehousesService.update(id, dto);
}
@Get(':warehouseId/yards')
@BookingStaff(FREIGHT_PERMS.warehouseYards.view)
@ApiOperation({ summary: 'List yards within a warehouse' })
listYards(@Param('warehouseId', ParseUUIDPipe) warehouseId: string) {
return this.yardsService.findByWarehouse(warehouseId);
}
@Post(':warehouseId/yards')
@BookingStaff(FREIGHT_PERMS.warehouseYards.create)
@ApiOperation({ summary: 'Create a yard within a warehouse' })
createYard(
@Param('warehouseId', ParseUUIDPipe) warehouseId: string,

View File

@@ -177,6 +177,7 @@ export const FLEET_RAIL_PERMISSIONS: FreightPermissionSeed[] = [
perm('e1b00001-0001-4000-8000-000000000004', 'edr_freight_app:wagons:delete', 'Delete wagon'),
perm('e1b00001-0001-4000-8000-000000000005', 'edr_freight_app:wagons:transfer_request', 'Request wagon transfer'),
perm('e1b00001-0001-4000-8000-000000000006', 'edr_freight_app:wagons:transfer_fulfill', 'Fulfil wagon transfer (OCC)'),
perm('e1b00001-0001-4000-8000-000000000007', 'edr_freight_app:wagons:transfer_history_all', "View all staff's transfer history"),
perm('e1c00001-0001-4000-8000-000000000001', 'edr_freight_app:trains:view', 'View trains'),
perm('e1c00001-0001-4000-8000-000000000002', 'edr_freight_app:trains:create', 'Create train'),
perm('e1c00001-0001-4000-8000-000000000003', 'edr_freight_app:trains:update', 'Update train'),
@@ -446,6 +447,9 @@ export const FREIGHT_PERMS = {
// executes the move). Distinct keys so OCC can hold fulfil without request.
transferRequest: 'edr_freight_app:wagons:transfer_request',
transferFulfill: 'edr_freight_app:wagons:transfer_fulfill',
// Admin: read every staffer's transfer history. Without it, a user only sees
// their own (the /history endpoint uses the caller id, backend-enforced).
transferHistoryAll: 'edr_freight_app:wagons:transfer_history_all',
},
trains: {
view: 'edr_freight_app:trains:view',

View File

@@ -49,6 +49,7 @@ import { api } from "@/services/api";
import { PageContainer } from "@/components/page";
import { PageHeader } from "@/components/page/PageHeader";
import { contractsService } from "@/services/contracts.service";
import { bookingsService } from "@/services/bookings.service";
import {
useContractCapacity,
useContractDetail,
@@ -180,6 +181,9 @@ export default function GlCreateBookingForm() {
}>();
const [searchParams] = useSearchParams();
const requestIdParam = searchParams.get("requestId");
// Rebook: copy an EXPIRED booking's cargo into a fresh booking on the same
// contract (GL only picks a new schedule). Set by the clearance Rebook action.
const copyFromParam = searchParams.get("copyFrom");
const navigate = useNavigate();
const { data: contract, isLoading } = useContractDetail(id);
const mutations = useContractMutations(id ?? "");
@@ -205,6 +209,13 @@ export default function GlCreateBookingForm() {
enabled: Boolean(requestId),
});
// The expired booking a Rebook is copying from (its cargo seeds the form).
const { data: copyFromBooking } = useQuery({
queryKey: ["rebook-copy-from", copyFromParam],
queryFn: () => bookingsService.getById(copyFromParam!),
enabled: Boolean(copyFromParam),
});
// Same window-gating the customer sees: booking is only allowed while a
// window is OPEN for one of the contract's routes. Intercity contracts are
// never window-gated — the shipment rides a passing train staff pick later.
@@ -363,6 +374,28 @@ export default function GlCreateBookingForm() {
if (bookingRequest.notes) setNotes(bookingRequest.notes);
}, [bookingRequest, prefilled]);
// Rebook seed: copy the source booking's container lines once. (Bulk weight /
// item count isn't on the booking payload yet, so bulk rebooks fall through to
// the normal contract seed and GL re-enters the quantity.)
useEffect(() => {
if (!copyFromBooking || prefilled) return;
const lines = copyFromBooking.bookingContainers ?? [];
if (!lines.length) return;
setPrefilled(true);
setContainerLines(
lines.map((c) => {
const qty = Math.max(1, c.quantity);
return {
containerSize: String(c.containerType?.sizeFt ?? ""),
quantity: String(qty),
hazardousQuantity: "0",
reeferQuantity: "0",
units: Array.from({ length: qty }, emptyUnit),
};
}),
);
}, [copyFromBooking, prefilled]);
// Seed one shipment line per contracted size exactly once — same seeding the
// portal form does. Subsequent renders reuse the lines.
useEffect(() => {

View File

@@ -0,0 +1,103 @@
import { Alert, Button, Group, Modal, Select, Stack, Text } from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import { isAxiosError } from "axios";
import { MapPin } from "lucide-react";
import { useEffect, useState } from "react";
import { api } from "@/services/api";
import type { TrainComposition } from "@/services/trainBuilder.service";
import { useToast } from "@/hooks/use-toast";
const parseError = (error: unknown, fallback: string) => {
if (isAxiosError(error)) {
const message = error.response?.data?.message;
if (Array.isArray(message)) return message.join(", ");
if (typeof message === "string") return message;
}
return fallback;
};
/**
* Relocate the train to another yard. The consist moves as one unit — every
* coupled locomotive and wagon follows, so their current yards always match
* the train's.
*/
export default function ChangeYardModal({ composition, opened, onClose }: ChangeYardModalProps) {
const { toast } = useToast();
const [yardId, setYardId] = useState("");
const yardsQuery = useQuery(api.routes.yards.queryOptions({ staleTime: 5 * 60_000 }));
const setYard = useMutation(api.trainBuilder.setYard.mutationOptions());
useEffect(() => {
if (opened) setYardId(composition?.currentYard?.id ?? "");
}, [opened, composition]);
const handleSave = async () => {
if (!composition || !yardId) return;
try {
await setYard.mutateAsync({ id: composition.id, currentYardId: yardId });
toast({ title: "Train relocated" });
onClose();
} catch (err) {
toast({
title: "Relocation failed",
description: parseError(err, "Could not change the yard"),
variant: "destructive",
});
}
};
const memberCount =
(composition?.locomotives.length ?? 0) + (composition?.totals.wagonCount ?? 0);
return (
<Modal
opened={opened}
onClose={onClose}
title={<Text fw={600}>Change yard train {composition?.code}</Text>}
radius="lg"
centered
>
<Stack gap="md">
<Alert color="yellow" icon={<MapPin size={16} />}>
The whole consist moves with the train: {composition?.locomotives.length ?? 0}{" "}
locomotive{(composition?.locomotives.length ?? 0) === 1 ? "" : "s"} and{" "}
{composition?.totals.wagonCount ?? 0} wagon
{(composition?.totals.wagonCount ?? 0) === 1 ? "" : "s"} ({memberCount} vehicles)
are relocated so their current yard always matches the train's. Wagon moves are
recorded in the movement ledger.
</Alert>
<Select
label="New yard"
placeholder="Select yard"
data={(yardsQuery.data ?? []).map((y) => ({
value: y.id,
label: y.label ?? y.code,
}))}
value={yardId || null}
onChange={(v) => setYardId(v ?? "")}
searchable
/>
<Group justify="flex-end">
<Button variant="default" onClick={onClose}>
Cancel
</Button>
<Button
loading={setYard.isPending}
disabled={!yardId || yardId === composition?.currentYard?.id}
onClick={handleSave}
>
Relocate train
</Button>
</Group>
</Stack>
</Modal>
);
}
export interface ChangeYardModalProps {
composition: TrainComposition | null;
opened: boolean;
onClose: () => void;
}

View File

@@ -1,159 +0,0 @@
import { Box, Group, Stack, Text, Tooltip } from "@mantine/core";
import { Train as TrainIcon } from "lucide-react";
import type {
TrainCompositionLocomotive,
TrainCompositionWagon,
} from "@/services/trainBuilder.service";
/**
* Visual consist: locomotives + wagons drawn in order on a rail, the way the
* train would leave the yard. Scrolls horizontally for long consists.
*/
export default function TrainConsistStrip({
locomotives,
wagons,
emptyHint = "No wagons attached yet — add wagons from the yard below.",
}: TrainConsistStripProps) {
return (
<Box
px="md"
py="lg"
style={{
overflowX: "auto",
borderRadius: 12,
background:
"linear-gradient(180deg, var(--mantine-color-gray-0) 0%, var(--mantine-color-gray-1) 100%)",
border: "1px solid var(--mantine-color-gray-2)",
}}
>
<Box style={{ display: "inline-block", minWidth: "100%" }}>
<Group gap={0} wrap="nowrap" align="flex-end">
{locomotives.map((loco, index) => (
<Group key={loco.id} gap={0} wrap="nowrap" align="flex-end">
{index > 0 ? <Coupler /> : null}
<LocomotiveCar locomotive={loco} />
</Group>
))}
{wagons.map((wagon) => (
<Group key={wagon.id} gap={0} wrap="nowrap" align="flex-end">
<Coupler />
<WagonCar wagon={wagon} />
</Group>
))}
</Group>
{/* The rail */}
<Box
mt={6}
style={{
height: 0,
borderTop: "3px solid var(--mantine-color-gray-4)",
borderBottom: "1px solid var(--mantine-color-gray-3)",
}}
/>
{!wagons.length ? (
<Text size="xs" c="dimmed" mt="xs">
{emptyHint}
</Text>
) : null}
</Box>
</Box>
);
}
export interface TrainConsistStripProps {
locomotives: TrainCompositionLocomotive[];
wagons: TrainCompositionWagon[];
emptyHint?: string;
}
function Coupler() {
return (
<Box
style={{
width: 12,
height: 4,
marginBottom: 18,
background: "var(--mantine-color-gray-5)",
flexShrink: 0,
}}
/>
);
}
function LocomotiveCar({ locomotive }: { locomotive: TrainCompositionLocomotive }) {
return (
<Tooltip
label={`${locomotive.code}${locomotive.name ? `${locomotive.name}` : ""} · ${
locomotive.role === "LEAD" ? "Lead" : "Assist"
} · pulls ${locomotive.maxPullWeightTons}T`}
withArrow
>
<Stack
gap={2}
align="center"
px="sm"
py={6}
style={{
minWidth: 96,
borderRadius: "10px 14px 4px 4px",
background:
"linear-gradient(180deg, var(--mantine-color-edr-green-6) 0%, var(--mantine-color-edr-green-8) 100%)",
color: "white",
border: "1px solid var(--mantine-color-edr-green-9)",
flexShrink: 0,
cursor: "default",
}}
>
<Group gap={4} wrap="nowrap">
<TrainIcon size={13} />
<Text size="xs" fw={700} ff="monospace" lh={1.2}>
{locomotive.code}
</Text>
</Group>
<Text size="10px" fw={600} tt="uppercase" style={{ opacity: 0.85 }} lh={1}>
{locomotive.role === "LEAD" ? "Lead loco" : "Assist loco"}
</Text>
</Stack>
</Tooltip>
);
}
function WagonCar({ wagon }: { wagon: TrainCompositionWagon }) {
return (
<Tooltip
label={`${wagon.wagonNumber}${
wagon.wagonType
? ` · ${wagon.wagonType.name} · ${wagon.wagonType.capacityTons}T cap · ${wagon.wagonType.lengthMeters}m`
: ""
}`}
withArrow
>
<Stack
gap={2}
align="center"
px="xs"
py={6}
style={{
minWidth: 76,
borderRadius: 6,
background: "white",
border: "1px solid var(--mantine-color-gray-3)",
borderBottom: "3px solid var(--mantine-color-edr-green-3)",
flexShrink: 0,
cursor: "default",
}}
>
<Text size="10px" c="dimmed" lh={1}>
#{wagon.sequenceNumber ?? "—"}
</Text>
<Text size="xs" fw={600} ff="monospace" lh={1.2}>
{wagon.wagonNumber}
</Text>
<Text size="10px" c="dimmed" lh={1}>
{wagon.wagonType?.code ?? "—"}
</Text>
</Stack>
</Tooltip>
);
}

View File

@@ -0,0 +1,396 @@
import {
Alert,
Badge,
Button,
Checkbox,
Divider,
Grid,
Group,
Modal,
Progress,
ScrollArea,
Stack,
Text,
} from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import { isAxiosError } from "axios";
import { AlertTriangle, History, Minus, Plus } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { api } from "@/services/api";
import type { ConsistWagonRef } from "@/services/trainBuilder.service";
import { useToast } from "@/hooks/use-toast";
const parseError = (error: unknown, fallback: string) => {
if (isAxiosError(error)) {
const message = error.response?.data?.message;
if (Array.isArray(message)) return message.join(", ");
if (typeof message === "string") return message;
}
return fallback;
};
const tareOf = (w: ConsistWagonRef) => w.wagonType?.tareWeightTons ?? 0;
const lengthOf = (w: ConsistWagonRef) => w.wagonType?.lengthMeters ?? 0;
const round2 = (v: number) => Math.round(v * 100) / 100;
/**
* Adjust the built train's consist from a schedule: trim free wagons (their
* tare no longer rides — the fix when gross weight beats the pull limit) or
* couple extra yard wagons while weight/length headroom remains. Changes are
* permanent on the train and logged on the schedule.
*/
export default function AdjustConsistModal({
scheduleId,
opened,
onClose,
}: AdjustConsistModalProps) {
const { toast } = useToast();
const [removeIds, setRemoveIds] = useState<string[]>([]);
const [addIds, setAddIds] = useState<string[]>([]);
const consistQuery = useQuery(
api.trainScheduling.scheduleConsist.queryOptions({
input: { scheduleId },
enabled: opened && Boolean(scheduleId),
}),
);
const adjust = useMutation(api.trainScheduling.adjustConsist.mutationOptions());
const data = consistQuery.data;
useEffect(() => {
if (opened) {
setRemoveIds([]);
setAddIds([]);
}
}, [opened]);
// Live projection: gross = cargo + tare of (consist trims + adds).
const projection = useMemo(() => {
if (!data) return null;
const removed = new Set(removeIds);
const keptTare = data.wagons
.filter((w) => !removed.has(w.id))
.reduce((s, w) => s + tareOf(w), 0);
const keptLength = data.wagons
.filter((w) => !removed.has(w.id))
.reduce((s, w) => s + lengthOf(w), 0);
const addedWagons = data.addableWagons.filter((w) => addIds.includes(w.id));
const tare = keptTare + addedWagons.reduce((s, w) => s + tareOf(w), 0);
const length = keptLength + addedWagons.reduce((s, w) => s + lengthOf(w), 0);
const gross = round2(data.totals.cargoTons + tare);
return {
wagonCount: data.totals.wagonCount - removeIds.length + addIds.length,
tare: round2(tare),
gross,
length: round2(length),
grossPct: data.limits.pullCapTons
? Math.round((gross / data.limits.pullCapTons) * 100)
: null,
lengthPct: data.limits.lengthCapMeters
? Math.round((length / data.limits.lengthCapMeters) * 100)
: null,
overWeight: data.limits.pullCapTons > 0 && gross > data.limits.pullCapTons,
overLength:
data.limits.lengthCapMeters > 0 && length > data.limits.lengthCapMeters,
};
}, [data, removeIds, addIds]);
const toggle = (setter: typeof setRemoveIds) => (id: string, checked: boolean) =>
setter((prev) => (checked ? [...prev, id] : prev.filter((x) => x !== id)));
const handleSubmit = async () => {
if (!removeIds.length && !addIds.length) return;
try {
await adjust.mutateAsync({
scheduleId,
payload: {
...(addIds.length ? { addWagonIds: addIds } : {}),
...(removeIds.length ? { removeWagonIds: removeIds } : {}),
},
});
toast({
title: `Consist updated — ${removeIds.length ? `${removeIds.length} trimmed` : ""}${
removeIds.length && addIds.length ? ", " : ""
}${addIds.length ? `${addIds.length} added` : ""}`,
});
setRemoveIds([]);
setAddIds([]);
} catch (err) {
toast({
title: "Adjustment failed",
description: parseError(err, "Could not adjust the consist"),
variant: "destructive",
});
}
};
return (
<Modal
opened={opened}
onClose={onClose}
title={
<Text fw={600}>
Adjust consist{data ? ` — train ${data.train.code}` : ""}
</Text>
}
radius="lg"
size={860}
centered
>
{consistQuery.isLoading || !data ? (
<Text py="lg" ta="center" c="dimmed" size="sm">
{consistQuery.isError
? "This schedule has no built train to adjust."
: "Loading consist…"}
</Text>
) : (
<Stack gap="md">
{!data.editable ? (
<Alert color="yellow" icon={<AlertTriangle size={16} />}>
The consist is frozen once the train is dispatched.
</Alert>
) : null}
<Grid gap="md">
<Grid.Col span={{ base: 12, sm: 6 }}>
<LimitGauge
label="Gross weight"
detail={`${data.totals.cargoTons}T cargo + ${projection?.tare}T tare = ${projection?.gross}T of ${data.limits.pullCapTons}T (limit ${data.limits.maxPullWeightTons}T + ${data.limits.overageToleranceTons}T tolerance)`}
pct={projection?.grossPct ?? null}
over={projection?.overWeight ?? false}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6 }}>
<LimitGauge
label="Consist length"
detail={`${projection?.length}m of ${data.limits.lengthCapMeters}m (limit ${data.limits.maxTrainLengthMeters}m + ${data.limits.overageToleranceMeters}m tolerance)`}
pct={projection?.lengthPct ?? null}
over={projection?.overLength ?? false}
/>
</Grid.Col>
</Grid>
<Grid gap="md">
<Grid.Col span={{ base: 12, md: 6 }}>
<Stack gap="xs">
<Group gap={6}>
<Minus size={14} />
<Text size="sm" fw={600}>
Trim coupled wagons ({data.totals.wagonCount})
</Text>
</Group>
<Text size="xs" c="dimmed">
Only free (unloaded, unpinned) wagons can be detached. Detaching is
permanent the wagon returns to the yard as available.
</Text>
<ScrollArea.Autosize mah={260} type="auto">
<Stack gap={4}>
{data.wagons.map((wagon) => (
<WagonRow
key={wagon.id}
wagon={wagon}
checked={removeIds.includes(wagon.id)}
disabled={!data.editable || !wagon.removable}
badge={
wagon.loaded ? "Loaded" : !wagon.removable ? "Pinned" : null
}
onToggle={toggle(setRemoveIds)}
/>
))}
</Stack>
</ScrollArea.Autosize>
</Stack>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6 }}>
<Stack gap="xs">
<Group gap={6}>
<Plus size={14} />
<Text size="sm" fw={600}>
Couple yard wagons ({data.addableWagons.length} available)
</Text>
</Group>
<Text size="xs" c="dimmed">
AVAILABLE wagons standing in the train's yard. Blocked when they push
gross weight or length past the locomotive limits incl. tolerance.
</Text>
<ScrollArea.Autosize mah={260} type="auto">
<Stack gap={4}>
{data.addableWagons.length ? (
data.addableWagons.map((wagon) => (
<WagonRow
key={wagon.id}
wagon={wagon}
checked={addIds.includes(wagon.id)}
disabled={!data.editable}
badge={null}
onToggle={toggle(setAddIds)}
/>
))
) : (
<Text size="sm" c="dimmed" py="sm" ta="center">
No available wagons in this yard
</Text>
)}
</Stack>
</ScrollArea.Autosize>
</Stack>
</Grid.Col>
</Grid>
{data.adjustments.length ? (
<>
<Divider />
<Stack gap={4}>
<Group gap={6}>
<History size={14} />
<Text size="sm" fw={600}>
Adjustment history
</Text>
</Group>
<ScrollArea.Autosize mah={120} type="auto">
<Stack gap={2}>
{data.adjustments.map((log) => (
<Group key={log.id} gap="xs">
<Badge
size="xs"
variant="light"
color={log.action === "ADD" ? "edr-green" : "red"}
>
{log.action === "ADD" ? "Added" : "Trimmed"}
</Badge>
<Text size="xs" ff="monospace">
{log.wagonNumber}
</Text>
<Text size="xs" c="dimmed">
{new Date(log.occurredAt).toLocaleString()}
</Text>
</Group>
))}
</Stack>
</ScrollArea.Autosize>
</Stack>
</>
) : null}
<Group justify="space-between">
<Text size="xs" c="dimmed">
Projected consist: {projection?.wagonCount} wagons
</Text>
<Group>
<Button variant="default" onClick={onClose}>
Close
</Button>
<Button
loading={adjust.isPending}
disabled={
!data.editable ||
(!removeIds.length && !addIds.length) ||
(addIds.length > 0 && (projection?.overWeight || projection?.overLength))
}
onClick={handleSubmit}
>
Apply{" "}
{removeIds.length ? `${removeIds.length}` : ""}
{removeIds.length && addIds.length ? " / " : ""}
{addIds.length ? `+${addIds.length}` : ""}
</Button>
</Group>
</Group>
</Stack>
)}
</Modal>
);
}
export interface AdjustConsistModalProps {
scheduleId: string;
opened: boolean;
onClose: () => void;
}
function LimitGauge({
label,
detail,
pct,
over,
}: {
label: string;
detail: string;
pct: number | null;
over: boolean;
}) {
return (
<Stack gap={4}>
<Group justify="space-between">
<Text size="xs" fw={600}>
{label}
</Text>
<Text size="xs" fw={700} c={over ? "red.7" : "edr-green.7"}>
{pct != null ? `${pct}%` : "—"}
</Text>
</Group>
<Progress
value={Math.min(pct ?? 0, 100)}
size="md"
radius="xl"
color={over ? "red" : (pct ?? 0) > 85 ? "yellow" : "edr-green"}
striped={over}
animated={over}
/>
<Text size="xs" c="dimmed">
{detail}
</Text>
</Stack>
);
}
function WagonRow({
wagon,
checked,
disabled,
badge,
onToggle,
}: {
wagon: ConsistWagonRef;
checked: boolean;
disabled: boolean;
badge: string | null;
onToggle: (id: string, checked: boolean) => void;
}) {
return (
<Group
gap="sm"
wrap="nowrap"
p={6}
style={{
border: "1px solid var(--mantine-color-gray-2)",
borderRadius: "var(--mantine-radius-md)",
opacity: disabled && !badge ? 0.7 : 1,
}}
>
<Checkbox
size="sm"
checked={checked}
disabled={disabled}
onChange={(e) => onToggle(wagon.id, e.currentTarget.checked)}
aria-label={`Select wagon ${wagon.wagonNumber}`}
/>
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={600} ff="monospace" truncate>
{wagon.wagonNumber}
</Text>
<Text size="xs" c="dimmed" truncate>
{wagon.wagonType
? `${wagon.wagonType.code} · ${wagon.wagonType.tareWeightTons}T tare · ${wagon.wagonType.lengthMeters}m`
: "Unknown type"}
</Text>
</Stack>
{badge ? (
<Badge size="xs" variant="light" color={badge === "Loaded" ? "orange" : "gray"}>
{badge}
</Badge>
) : null}
</Group>
);
}

View File

@@ -506,12 +506,19 @@ function TrackBed() {
export function TrainCompositionDiagram({
locomotive,
locomotives,
wagons,
freightType,
trainNumber,
totalLengthMeters,
}: {
locomotive?: { code?: string | null; name?: string | null; maxPullWeightTons?: number | null } | null;
/** Full locomotive set (built trains, ≥2). Takes precedence over `locomotive`. */
locomotives?: Array<{
code?: string | null;
name?: string | null;
maxPullWeightTons?: number | null;
}> | null;
wagons: DiagramWagonInput[];
freightType?: string | null;
trainNumber?: string | null;
@@ -519,6 +526,11 @@ export function TrainCompositionDiagram({
}) {
const { ref, width } = useElementSize();
const locos = useMemo(
() => (locomotives?.length ? locomotives : locomotive ? [locomotive] : []),
[locomotives, locomotive],
);
const normalized = useMemo(
() => wagons.map((w) => normalizeWagon(w, freightType)),
[wagons, freightType],
@@ -533,6 +545,11 @@ export function TrainCompositionDiagram({
// ceiling the allocation engine spends from.
const totalTare = normalized.reduce((s, w) => s + w.tareWeightTons, 0);
const grossWeight = totalWeight + totalTare;
// Weakest locomotive caps the set — same rule the allocation engine applies.
const pullLimits = locos
.map((l) => Number(l.maxPullWeightTons))
.filter((v) => Number.isFinite(v) && v > 0);
const pullLimit = pullLimits.length ? Math.min(...pullLimits) : null;
return {
total: normalized.length,
assigned,
@@ -541,22 +558,25 @@ export function TrainCompositionDiagram({
totalTare: Math.round(totalTare * 100) / 100,
grossWeight: Math.round(grossWeight * 100) / 100,
totalCapacity,
pullUtil:
locomotive?.maxPullWeightTons && locomotive.maxPullWeightTons > 0
? Math.min(100, Math.round((grossWeight / locomotive.maxPullWeightTons) * 100))
: null,
pullLimit,
pullUtil: pullLimit
? Math.min(100, Math.round((grossWeight / pullLimit) * 100))
: null,
};
}, [normalized, locomotive]);
}, [normalized, locos]);
// cars-per-row from measured width; locomotive counts as one car
// cars-per-row from measured width; each locomotive counts as one car
const perRow = Math.max(1, Math.floor((width || CAR_WIDTH) / CAR_WIDTH));
const cars = useMemo(
() => [{ kind: "loco" as const }, ...normalized.map((w) => ({ kind: "wagon" as const, w }))],
[normalized],
() => [
...locos.map((l) => ({ kind: "loco" as const, l })),
...normalized.map((w) => ({ kind: "wagon" as const, w })),
],
[locos, normalized],
);
const rows = useMemo(() => chunk(cars, perRow), [cars, perRow]);
if (!locomotive && !wagons.length) return null;
if (!locos.length && !wagons.length) return null;
return (
<Paper
@@ -658,8 +678,8 @@ export function TrainCompositionDiagram({
<Text size="xs" fw={700} c="edr-green.8">
Locomotive load ·{" "}
{stats.totalTare > 0
? `${stats.grossWeight}T of ${locomotive?.maxPullWeightTons}T (${stats.totalWeight}T cargo + ${stats.totalTare}T tare)`
: `${stats.totalWeight}T of ${locomotive?.maxPullWeightTons}T`}
? `${stats.grossWeight}T of ${stats.pullLimit}T (${stats.totalWeight}T cargo + ${stats.totalTare}T tare)`
: `${stats.totalWeight}T of ${stats.pullLimit}T`}
</Text>
</Group>
<Text size="sm" fw={800} c={stats.pullUtil > 95 ? "red.7" : "edr-green.7"}>
@@ -702,13 +722,11 @@ export function TrainCompositionDiagram({
<Group key={carIndex} gap={0} wrap="nowrap" style={{ flexDirection: reversed ? "row-reverse" : "row" }}>
{carIndex > 0 ? <Coupler /> : null}
{car.kind === "loco" ? (
locomotive ? (
<LocomotiveCar
code={locomotive.code ?? "LOCO"}
name={locomotive.name}
maxPullWeightTons={locomotive.maxPullWeightTons}
/>
) : null
<LocomotiveCar
code={car.l.code ?? "LOCO"}
name={car.l.name}
maxPullWeightTons={car.l.maxPullWeightTons}
/>
) : (
<WagonCar wagon={car.w} />
)}

View File

@@ -10,6 +10,8 @@ import {
Modal,
ScrollArea,
Stack,
Switch,
Tabs,
Text,
ThemeIcon,
} from "@mantine/core";
@@ -17,6 +19,7 @@ import { useMutation, useQuery } from "@tanstack/react-query";
import {
ArrowRight,
ChevronLeft,
History,
Inbox,
PackageCheck,
Warehouse,
@@ -25,8 +28,13 @@ import {
import { useMemo, useState } from "react";
import { api } from "@/services/api";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { useToast } from "@/hooks/use-toast";
import type { WagonTransferRequest } from "@/services/wagon.service";
import type {
WagonMovementRecord,
WagonTransferRequest,
} from "@/services/wagon.service";
export interface WagonTransferRequestsModalProps {
opened: boolean;
@@ -57,16 +65,172 @@ const RequestSummary = ({ r }: { r: WagonTransferRequest }) => (
</Group>
);
const STATUS_COLOR: Record<string, string> = {
PENDING: "gray",
FULFILLED: "teal",
CANCELLED: "red",
};
const fmtDateTime = (iso: string) =>
new Date(iso).toLocaleString("en-GB", {
day: "numeric",
month: "short",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
/**
* Per-user transfer history. A staffer sees their OWN activity — the requests
* they filed or fulfilled, and the individual wagons they moved. Holders of
* `transfer_history_all` get an "All staff" toggle that widens the view; the
* backend enforces the scope regardless of the toggle.
*/
function HistoryPanel({ opened }: { opened: boolean }) {
const { user } = useAuth();
const canSeeAll = hasPermission(
user,
FREIGHT_PERMS.wagons.transferHistoryAll,
);
const myId = (user as { id?: string } | null | undefined)?.id;
const [allStaff, setAllStaff] = useState(false);
const scopeAll = canSeeAll && allStaff;
const mine = useQuery({
...api.wagonTransferRequests.history.queryOptions(),
enabled: opened && !scopeAll,
});
const all = useQuery({
...api.wagonTransferRequests.historyAll.queryOptions({ input: {} }),
enabled: opened && scopeAll,
});
const source = scopeAll ? all : mine;
const requests = source.data?.requests ?? [];
const movements: WagonMovementRecord[] = source.data?.movements ?? [];
const roleBadge = (r: WagonTransferRequest) => {
if (myId && r.fulfilledByUserId === myId)
return (
<Badge size="xs" variant="light" color="blue">
fulfilled
</Badge>
);
if (myId && r.requestedByUserId === myId)
return (
<Badge size="xs" variant="light" color="grape">
requested
</Badge>
);
return null;
};
return (
<Stack gap="lg">
{canSeeAll ? (
<Group justify="flex-end">
<Switch
checked={allStaff}
onChange={(e) => setAllStaff(e.currentTarget.checked)}
label="All staff"
color="edr-green"
/>
</Group>
) : null}
{source.isLoading ? (
<Group justify="center" p="xl">
<Loader />
</Group>
) : (
<>
<div>
<Text fw={700} size="sm" mb={8}>
Requests{scopeAll ? "" : " you touched"}
</Text>
{requests.length === 0 ? (
<Text size="sm" c="dimmed">
No requests yet.
</Text>
) : (
<Stack gap={6}>
{requests.map((r) => (
<Card key={r.id} withBorder radius="md" padding="xs">
<Group justify="space-between" wrap="nowrap">
<RequestSummary r={r} />
<Group gap={8} wrap="nowrap">
{roleBadge(r)}
<Badge
size="sm"
variant="light"
color={STATUS_COLOR[r.status] ?? "gray"}
>
{r.status.toLowerCase()}
</Badge>
</Group>
</Group>
</Card>
))}
</Stack>
)}
</div>
<Divider />
<div>
<Text fw={700} size="sm" mb={8}>
Wagons moved
</Text>
{movements.length === 0 ? (
<Text size="sm" c="dimmed">
No wagon moves yet.
</Text>
) : (
<ScrollArea.Autosize mah={260}>
<Stack gap={6}>
{movements.map((m) => (
<Card key={m.id} withBorder radius="md" padding="xs">
<Group justify="space-between" wrap="nowrap">
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
<Text fw={600} size="sm">
{m.wagon?.wagonNumber ?? "Wagon"}
</Text>
<Text size="xs" c="dimmed" truncate>
{yardLabel(m.fromYard)} {yardLabel(m.toYard)}
</Text>
{m.transferRequestId ? (
<Badge size="xs" variant="light" color="teal">
from request
</Badge>
) : null}
</Group>
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>
{fmtDateTime(m.occurredAt)}
</Text>
</Group>
</Card>
))}
</Stack>
</ScrollArea.Autosize>
)}
</div>
</>
)}
</Stack>
);
}
/**
* OCC fulfilment queue for wagon-transfer requests. Lists PENDING requests; open
* one to hand-pick exactly the requested number of wagons from the source yard
* (of the requested type) and execute the move, or cancel the request.
* A second tab shows per-user transfer history.
*/
const WagonTransferRequestsModal = ({
opened,
onClose,
}: WagonTransferRequestsModalProps) => {
const { toast } = useToast();
const [tab, setTab] = useState<string | null>("queue");
const [active, setActive] = useState<WagonTransferRequest | null>(null);
const [picked, setPicked] = useState<Set<string>>(new Set());
@@ -175,6 +339,17 @@ const WagonTransferRequestsModal = ({
</Group>
}
>
<Tabs value={tab} onChange={setTab} keepMounted={false}>
<Tabs.List mb="md">
<Tabs.Tab value="queue" leftSection={<Inbox size={14} />}>
Queue
</Tabs.Tab>
<Tabs.Tab value="history" leftSection={<History size={14} />}>
History
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="queue">
{!active ? (
// ---- Pending queue ----
isLoading ? (
@@ -337,6 +512,12 @@ const WagonTransferRequestsModal = ({
</Group>
</Stack>
)}
</Tabs.Panel>
<Tabs.Panel value="history">
<HistoryPanel opened={opened} />
</Tabs.Panel>
</Tabs>
</Modal>
);
};

View File

@@ -0,0 +1,241 @@
import { useMemo } from 'react';
import { ActionIcon, Badge, Card, Group, Loader, Menu, SimpleGrid, Stack, Table, Text, ThemeIcon } from '@mantine/core';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { AlertTriangle, Bell, BellOff, Check, Clock, DollarSign, MoreVertical } from 'lucide-react';
import { useAccrualDashboard } from '@/hooks/useWarehouses';
import { warehouseService } from '@/services/warehouse.service';
import { useToast } from '@/hooks/use-toast';
import type { AccrualAlert, AccrualDashboardRow } from '@/types/warehouse';
const ALERT_META: Record<AccrualAlert, { color: string; label: string }> = {
CHARGING: { color: 'red', label: 'Charging' },
WARNING: { color: 'orange', label: 'Free days ending' },
OK: { color: 'teal', label: 'Within free days' },
};
function money(amount: number, currency: string): string {
return `${amount.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ${currency}`;
}
function freeDaysLabel(row: AccrualDashboardRow): string {
if (row.charging) return 'charging now';
if (row.freeDaysLeft == null) return '—';
return `${row.freeDaysLeft} day${row.freeDaysLeft === 1 ? '' : 's'} left`;
}
/**
* Live accrual dashboard: storage / demurrage ticking per in-warehouse item,
* sorted so items already charging (or about to) surface first. Read-only.
*/
export function AccrualDashboard() {
const { data: rows = [], isLoading } = useAccrualDashboard();
const { toast } = useToast();
const qc = useQueryClient();
const refresh = () =>
qc.invalidateQueries({ queryKey: ['warehouse-fees', 'accrual-dashboard'] });
const ack = useMutation({
mutationFn: ({ id, snoozeDays }: { id: string; snoozeDays?: number }) =>
warehouseService.acknowledgeAccrual(id, snoozeDays ? { snoozeDays } : {}),
onSuccess: (_r, v) => {
toast({ title: v.snoozeDays ? `Snoozed ${v.snoozeDays} days` : 'Marked reviewed' });
void refresh();
},
onError: () => toast({ variant: 'destructive', title: 'Could not acknowledge' }),
});
const unack = useMutation({
mutationFn: (id: string) => warehouseService.unacknowledgeAccrual(id),
onSuccess: () => {
toast({ title: 'Acknowledgement removed' });
void refresh();
},
onError: () => toast({ variant: 'destructive', title: 'Could not un-acknowledge' }),
});
const summary = useMemo(() => {
const currency = rows[0]?.currency ?? 'USD';
return {
currency,
charging: rows.filter((r) => r.alert === 'CHARGING').length,
atRisk: rows.filter((r) => r.alert === 'WARNING').length,
totalAccruing: Math.round(rows.reduce((s, r) => s + r.accruedAmount, 0) * 100) / 100,
};
}, [rows]);
if (isLoading) {
return (
<Group justify="center" py="xl">
<Loader />
</Group>
);
}
return (
<Stack gap="md">
<SimpleGrid cols={{ base: 1, xs: 3 }} spacing="sm">
<StatCard
icon={<DollarSign size={18} />}
label="Accruing now"
value={money(summary.totalAccruing, summary.currency)}
color="edr-green"
/>
<StatCard
icon={<AlertTriangle size={18} />}
label="Charging"
value={summary.charging}
color={summary.charging > 0 ? 'red' : 'gray'}
/>
<StatCard
icon={<Clock size={18} />}
label="Free days ending (≤2d)"
value={summary.atRisk}
color={summary.atRisk > 0 ? 'orange' : 'gray'}
/>
</SimpleGrid>
<Card withBorder radius="md" padding={0}>
{rows.length === 0 ? (
<Text c="dimmed" ta="center" py="xl" size="sm">
No in-warehouse items are accruing fees.
</Text>
) : (
<Table.ScrollContainer minWidth={900}>
<Table verticalSpacing="sm" highlightOnHover striped>
<Table.Thead>
<Table.Tr>
<Table.Th>Booking</Table.Th>
<Table.Th>Customer</Table.Th>
<Table.Th>Location</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th ta="right">Accrued</Table.Th>
<Table.Th>Free days</Table.Th>
<Table.Th>Alert</Table.Th>
<Table.Th ta="right" />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows.map((row) => {
const meta = ALERT_META[row.alert];
const busy = ack.isPending || unack.isPending;
return (
<Table.Tr key={row.inventoryId} style={{ opacity: row.acknowledged ? 0.55 : 1 }}>
<Table.Td>
<Text fw={600} size="sm">
{row.bookingReference ?? row.inventoryId.slice(0, 8)}
</Text>
</Table.Td>
<Table.Td>{row.customerName ?? '—'}</Table.Td>
<Table.Td>
<Text size="sm">
{[row.warehouseCode, row.zoneCode].filter(Boolean).join(' · ') || '—'}
</Text>
</Table.Td>
<Table.Td>
<Badge variant="light" color="gray" size="sm">
{row.status}
</Badge>
</Table.Td>
<Table.Td ta="right">
<Text fw={600} size="sm" c={row.accruedAmount > 0 ? 'red' : undefined}>
{money(row.accruedAmount, row.currency)}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm" c={row.charging ? 'red' : undefined}>
{freeDaysLabel(row)}
</Text>
</Table.Td>
<Table.Td>
{row.acknowledged ? (
<Badge color="gray" variant="light" size="sm" leftSection={<Check size={11} />}>
Reviewed{row.snoozeUntil ? ' (snoozed)' : ''}
</Badge>
) : (
<Badge color={meta.color} variant={row.alert === 'OK' ? 'light' : 'filled'} size="sm">
{meta.label}
</Badge>
)}
</Table.Td>
<Table.Td ta="right">
<Menu shadow="md" position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon variant="subtle" color="gray" loading={busy} aria-label="Accrual actions">
<MoreVertical size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
{row.acknowledged ? (
<Menu.Item
leftSection={<Bell size={14} />}
onClick={() => unack.mutate(row.inventoryId)}
>
Un-acknowledge
</Menu.Item>
) : (
<>
<Menu.Item
leftSection={<Check size={14} />}
onClick={() => ack.mutate({ id: row.inventoryId })}
>
Mark reviewed
</Menu.Item>
<Menu.Item
leftSection={<BellOff size={14} />}
onClick={() => ack.mutate({ id: row.inventoryId, snoozeDays: 3 })}
>
Snooze 3 days
</Menu.Item>
<Menu.Item
leftSection={<BellOff size={14} />}
onClick={() => ack.mutate({ id: row.inventoryId, snoozeDays: 7 })}
>
Snooze 7 days
</Menu.Item>
</>
)}
</Menu.Dropdown>
</Menu>
</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
</Card>
</Stack>
);
}
function StatCard({
icon,
label,
value,
color,
}: {
icon: React.ReactNode;
label: string;
value: React.ReactNode;
color: string;
}) {
return (
<Card withBorder radius="md" padding="md">
<Group gap="sm" wrap="nowrap">
<ThemeIcon color={color} variant="light" size={40} radius="md">
{icon}
</ThemeIcon>
<Stack gap={0} style={{ minWidth: 0 }}>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
{label}
</Text>
<Text fw={800} fz={20} lh={1.1} truncate>
{value}
</Text>
</Stack>
</Group>
</Card>
);
}

View File

@@ -19,7 +19,7 @@ import { MoveInventoryModal } from './MoveInventoryModal';
import { ReleaseOrderModal } from './ReleaseOrderModal';
import { WarehouseInventoryTable } from './WarehouseInventoryTable';
import { extractDownloadErrorMessage, extractErrorMessage } from './options';
import { openPdfBlob } from './pdf';
import { openPdfBlob, saveBlob } from './pdf';
interface InventoryWorkbenchProps {
items: WarehouseInventoryItem[];
@@ -138,6 +138,42 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
}
};
// One-click bundle: download every available document for the item (GRN +
// gate clearance / release order + handover). Best-effort — docs that aren't
// generatable yet for this item are skipped.
const downloadDocumentBundle = async (item: WarehouseInventoryItem) => {
setBusyId(item.id);
const ref = item.booking?.reference ?? item.bookingId ?? item.id;
const jobs: Array<{ name: string; fn: () => Promise<{ data: Blob }> }> = [
{ name: `GRN-${ref}.pdf`, fn: () => warehouseService.downloadGrnDocument(item.id) },
{ name: `gate-clearance-${ref}.pdf`, fn: () => warehouseService.downloadReleaseDocument(item.id) },
{ name: `handover-${ref}.pdf`, fn: () => warehouseService.downloadHandoverDocument(item.id) },
];
let saved = 0;
for (const job of jobs) {
try {
const response = await job.fn();
saveBlob(response.data, job.name);
saved += 1;
} catch {
// Document not available for this item yet — skip it.
}
}
setBusyId(null);
if (saved === 0) {
toast({
variant: 'destructive',
title: 'No documents available',
description: 'This item has no GRN, gate clearance or handover document yet.',
});
} else {
toast({
title: `Downloaded ${saved} document${saved !== 1 ? 's' : ''}`,
description: `Bundle for ${ref} (available documents only).`,
});
}
};
const acceptLastMile = async (item: WarehouseInventoryItem) => {
const reference = item.booking?.reference;
if (!reference) {
@@ -243,6 +279,7 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
onFeePreview={setFeeItem}
onReleaseDocument={downloadReleaseDocument}
onHandoverDocument={openHandoverDocument}
onDownloadBundle={downloadDocumentBundle}
onLastMile={onLastMile ? acceptLastMile : undefined}
selectedIds={selected}
onToggleSelect={toggleSelect}

View File

@@ -1,6 +1,6 @@
import { useState, type MouseEvent } from 'react';
import { ActionIcon, Badge, Button, Checkbox, Group, Table, Text, Tooltip } from '@mantine/core';
import { ArrowRightLeft, ClipboardList, Coins, Eye, FileText, History, MapPin } from 'lucide-react';
import { ArrowRightLeft, ClipboardList, Coins, Download, Eye, FileText, History, MapPin } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { warehouseService } from '@/services/warehouse.service';
@@ -24,6 +24,7 @@ interface WarehouseInventoryTableProps {
onFeePreview?: (item: WarehouseInventoryItem) => void;
onReleaseDocument?: (item: WarehouseInventoryItem) => void;
onHandoverDocument?: (item: WarehouseInventoryItem) => void;
onDownloadBundle?: (item: WarehouseInventoryItem) => void;
onLastMile?: (item: WarehouseInventoryItem) => void;
selectedIds?: Set<string>;
onToggleSelect?: (id: string) => void;
@@ -110,6 +111,7 @@ export function WarehouseInventoryTable({
onFeePreview,
onReleaseDocument,
onHandoverDocument,
onDownloadBundle,
onLastMile,
selectedIds,
onToggleSelect,
@@ -285,6 +287,13 @@ export function WarehouseInventoryTable({
</ActionIcon>
</Tooltip>
)}
{onDownloadBundle && item.grnNumber && (
<Tooltip label="Download document bundle (GRN + gate clearance + handover)" withArrow>
<ActionIcon variant="subtle" color="grape" onClick={() => onDownloadBundle(item)}>
<Download size={16} />
</ActionIcon>
</Tooltip>
)}
{onLastMile && item.booking?.lastMileDeliveryAddress && (
<Tooltip label="Last mile delivery" withArrow>
<ActionIcon variant="subtle" color="blue" onClick={() => onLastMile(item)}>

View File

@@ -0,0 +1,45 @@
import { AlertTriangle, ClipboardCheck, PackageCheck, Truck } from "lucide-react";
import { KpiStrip } from "@/components/page";
import { useWarehouseOpsStats } from "@/hooks/useWarehouses";
/**
* At-a-glance warehouse ops KPIs (received today, pending inspection, trucks
* on-site, items aging). Drop-in for any warehouse ops page header.
*/
export function WarehouseOpsKpiStrip() {
const { data, isLoading } = useWarehouseOpsStats();
return (
<KpiStrip
loading={isLoading}
items={[
{
label: "Received today",
value: data?.receivedToday ?? 0,
icon: PackageCheck,
color: "edr-green",
},
{
label: "Pending inspection",
value: data?.pendingInspection ?? 0,
icon: ClipboardCheck,
color: "yellow",
},
{
label: "Trucks on-site",
value: data?.trucksOnSite ?? 0,
icon: Truck,
color: "blue",
},
{
label: "Items aging (>7d)",
value: data?.itemsAging ?? 0,
icon: AlertTriangle,
color: (data?.itemsAging ?? 0) > 0 ? "red" : "edr-green",
hint: "In warehouse over 7 days",
},
]}
/>
);
}

View File

@@ -30,3 +30,5 @@ export { WarehouseDashboardCharts } from './WarehouseDashboardCharts';
export { InspectionReportModal } from './InspectionReportModal';
export { FeePreviewModal } from './FeePreviewModal';
export { ZoneOccupancyHeatmap } from './ZoneOccupancyHeatmap';
export { WarehouseOpsKpiStrip } from './WarehouseOpsKpiStrip';
export { AccrualDashboard } from './AccrualDashboard';

View File

@@ -22,3 +22,16 @@ export function openPdfBlob(blob: Blob, filename: string, targetWindow?: Window
URL.revokeObjectURL(url);
return false;
}
/** Force a browser download of a blob under the given filename (no preview tab). */
export function saveBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
// Delay revoke so the download has time to start (esp. for rapid multi-saves).
setTimeout(() => URL.revokeObjectURL(url), 10_000);
}

View File

@@ -486,6 +486,7 @@ export const URL_CONSTANTS = {
MOVE: (id: string) => `/warehouse-inventory/${id}/move`,
RESERVE: "/warehouse-inventory/reserve",
ARRIVAL_QUEUE: "/warehouse-inventory/arrival-queue",
OPS_STATS: "/warehouse-inventory/ops-stats",
ZONE_OCCUPANCY: (yardId?: string) =>
yardId
? `/warehouse-inventory/zone-occupancy?yardId=${yardId}`
@@ -557,6 +558,9 @@ export const URL_CONSTANTS = {
FEES_BY_ID: (id: string) => `/warehouse-fee-rules/${id}`,
FEE_PREVIEW: (inventoryId: string) =>
`/warehouse-inventory/${inventoryId}/fee-preview`,
ACCRUAL_DASHBOARD: "/warehouse-fees/accrual-dashboard",
ACCRUAL_ACK: (inventoryId: string) =>
`/warehouse-fees/accrual/${inventoryId}/acknowledge`,
},
WAREHOUSE_INVOICES: {

View File

@@ -144,6 +144,22 @@ export function useZoneOccupancy(yardId?: string) {
});
}
/** At-a-glance warehouse ops counters for the KPI strip. */
export function useWarehouseOpsStats() {
return useQuery({
queryKey: ['warehouse-inventory', 'ops-stats'],
queryFn: () => warehouseService.opsStats().then((r) => r.data),
});
}
/** Live per-item fee accrual (storage/demurrage) with alerts. */
export function useAccrualDashboard(billingCurrency?: 'ETB' | 'USD') {
return useQuery({
queryKey: ['warehouse-fees', 'accrual-dashboard', billingCurrency ?? 'USD'],
queryFn: () => warehouseService.accrualDashboard(billingCurrency).then((r) => r.data),
});
}
export function useCreateZone() {
const qc = useQueryClient();
return useMutation({

View File

@@ -106,6 +106,9 @@ export const FREIGHT_PERMS = {
create: "edr_freight_app:wagons:create",
update: "edr_freight_app:wagons:update",
delete: "edr_freight_app:wagons:delete",
transferRequest: "edr_freight_app:wagons:transfer_request",
transferFulfill: "edr_freight_app:wagons:transfer_fulfill",
transferHistoryAll: "edr_freight_app:wagons:transfer_history_all",
},
trains: {
view: "edr_freight_app:trains:view",

View File

@@ -1,5 +1,6 @@
import {
ActionIcon,
Alert,
Badge,
Box,
Button,
@@ -324,6 +325,21 @@ export default function NewBookingPage() {
const containerWeight = lines.reduce((s, l) => s + (l.quantity || 0) * (l.vgmPerUnitTons || 0), 0);
const cargoTotalWeightVgm = freightType === "CONTAINER" ? containerWeight : bulkWeight;
// 20ft containers ride two per wagon, so a booking must hold an even number
// of them — odd counts would leave half a wagon waiting on a co-loader
// (cross-booking consolidation is disabled for now).
const twentyFtCount = useMemo(() => {
const sizeById = new Map<string, string>();
for (const group of refData?.containers ?? []) {
for (const type of group.types) sizeById.set(type.id, group.size);
}
return lines.reduce((sum, l) => {
const size = l.containerTypeId ? (sizeById.get(l.containerTypeId) ?? "") : "";
return String(size).includes("20") ? sum + (l.quantity || 0) : sum;
}, 0);
}, [refData?.containers, lines]);
const hasOdd20ft = freightType === "CONTAINER" && twentyFtCount % 2 === 1;
// ---- validation ----
const lineValid = (l: ContainerLine) =>
Boolean(l.containerTypeId) && l.quantity >= 1 && l.vgmPerUnitTons > 0;
@@ -344,7 +360,7 @@ export default function NewBookingPage() {
(isGovernment ? Boolean(govCompanyId && govProfileId) : Boolean(companyId)) &&
(freightType === "BULK"
? Boolean(cargoTypeId) && bulkWeight > 0
: allLinesValid);
: allLinesValid && !hasOdd20ft);
const updateLine = (key: string, patch: Partial<ContainerLine>) =>
setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l)));
@@ -842,6 +858,20 @@ export default function NewBookingPage() {
</Group>
) : null}
{hasOdd20ft ? (
<Alert color="red" icon={<AlertTriangle size={16} />} radius="md" mt="md">
<Text size="sm" fw={600}>
Odd number of 20ft containers ({twentyFtCount})
</Text>
<Text size="xs" mt={4}>
20ft containers travel two per wagon, so they must be booked in
even numbers. Add one more 20ft container or remove one e.g.
book {twentyFtCount + 1} or {twentyFtCount - 1} instead of{" "}
{twentyFtCount}.
</Text>
</Alert>
) : null}
<Stack gap="sm" mt="lg">
<Button
size="md"

View File

@@ -1,4 +1,11 @@
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
import {
Fragment,
useCallback,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { useNavigate } from "react-router-dom";
import {
ActionIcon,
@@ -75,6 +82,8 @@ interface ClearanceRow {
freightType: string;
originLabel: string;
destinationLabel: string;
/** Full ordered corridor across the contract's route legs (origin → … → destination). */
routeStops: string[];
contractKind: string;
serviceTypeName: string;
customs: boolean;
@@ -93,6 +102,25 @@ function yardLabel(
return yard.label ?? yard.name ?? yard.code ?? fallback;
}
/**
* Chain the contract's ordered route legs into one corridor of stops —
* origin of the first leg, then each leg's destination (Djibouti → Adama →
* Dire Dawa). A leg whose origin differs from the previous destination inserts
* that stop too, so gapped route lists stay readable.
*/
function contractRouteStops(routes: Freight.IContractRoute[]): string[] {
const stops: string[] = [];
for (const r of routes) {
const origin = yardLabel(r.originYard);
const destination = yardLabel(r.destinationYard);
if (stops.length === 0 || stops[stops.length - 1] !== origin) {
stops.push(origin);
}
stops.push(destination);
}
return stops;
}
function toClearanceRow(contract: Freight.IContract): ClearanceRow {
const routes = [...(contract.routes ?? [])].sort(
(a, b) => a.sortOrder - b.sortOrder,
@@ -109,6 +137,7 @@ function toClearanceRow(contract: Freight.IContract): ClearanceRow {
freightType: contract.freightType ?? "—",
originLabel: yardLabel(first?.originYard),
destinationLabel: yardLabel(last?.destinationYard),
routeStops: contractRouteStops(routes),
contractKind: contract.contractKind,
serviceTypeName: contract.serviceType?.serviceName ?? "—",
customs:
@@ -412,14 +441,23 @@ export default function ContractClearanceListPage() {
const r = row.original;
return (
<Stack gap={4} py={2}>
<Group gap={6} wrap="nowrap">
<Text size="sm" fw={500} truncate maw={120}>
{r.originLabel}
</Text>
<ArrowRight size={14} className="shrink-0 text-muted-foreground" />
<Text size="sm" fw={500} truncate maw={120}>
{r.destinationLabel}
</Text>
<Group gap={6} wrap="wrap">
{(r.routeStops.length >= 2
? r.routeStops
: [r.originLabel, r.destinationLabel]
).map((stop, i) => (
<Fragment key={i}>
{i > 0 ? (
<ArrowRight
size={14}
className="shrink-0 text-muted-foreground"
/>
) : null}
<Text size="sm" fw={500}>
{stop}
</Text>
</Fragment>
))}
</Group>
<Group gap={8} align="center">
<DirectionIcon direction={r.tradeDirection} />
@@ -638,6 +676,11 @@ export default function ContractClearanceListPage() {
`/dashboard/contracts/${row.contractId}/bookings/${row.id}/complete`,
)
}
onRebook={(row) =>
navigate(
`/dashboard/contracts/${row.contractId}/create-booking?copyFrom=${row.id}`,
)
}
onViewContract={(contractId) =>
navigate(`/dashboard/contracts/clearance/${contractId}`)
}
@@ -731,6 +774,7 @@ function ShipmentBookingsTable({
canCreateBooking,
onOpen,
onCreateBooking,
onRebook,
onViewContract,
}: {
rows: ShipmentBookingRow[];
@@ -739,6 +783,7 @@ function ShipmentBookingsTable({
canCreateBooking: boolean;
onOpen: (id: string) => void;
onCreateBooking: (row: ShipmentBookingRow) => void;
onRebook: (row: ShipmentBookingRow) => void;
onViewContract: (contractId: string) => void;
}) {
// A bare initiated instance that has cleared but not yet been created by GL.
@@ -748,6 +793,14 @@ function ShipmentBookingsTable({
!r.bookingCreated &&
r.status === "CLEARANCE_READY";
// A customs shipment whose booking lost its slot — GL rebooks it (customer
// can't self-rebook a customs booking). Copies the expired booking's cargo.
const isRebookable = (r: ShipmentBookingRow) =>
canCreateBooking &&
Boolean(r.contractId) &&
r.customs &&
r.status === "EXPIRED";
const columns = useMemo<ColumnDef<ShipmentBookingRow>[]>(
() => [
{
@@ -877,6 +930,7 @@ function ShipmentBookingsTable({
cell: ({ row }) => {
const r = row.original;
const bookable = isBookable(r);
const rebookable = isRebookable(r);
return (
<Group
justify="flex-end"
@@ -896,6 +950,17 @@ function ShipmentBookingsTable({
Create booking
</Button>
) : null}
{rebookable ? (
<Button
size="compact-sm"
color="grape"
radius="md"
leftSection={<RefreshCw size={14} />}
onClick={() => onRebook(r)}
>
Rebook
</Button>
) : null}
<Menu shadow="md" radius="md" position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon
@@ -919,6 +984,14 @@ function ShipmentBookingsTable({
Create booking
</Menu.Item>
) : null}
{rebookable ? (
<Menu.Item
leftSection={<RefreshCw size={14} />}
onClick={() => onRebook(r)}
>
Rebook (GL)
</Menu.Item>
) : null}
{r.contractId ? (
<Menu.Item
leftSection={<ExternalLink size={14} />}

View File

@@ -16,6 +16,7 @@ import { isAxiosError } from "axios";
import {
AlertTriangle,
CalendarClock,
MapPin,
MoreHorizontal,
Replace,
Ruler,
@@ -29,9 +30,10 @@ import { useNavigate, useParams } from "react-router-dom";
import AvailableWagonsPanel from "@/components/trainBuilder/AvailableWagonsPanel";
import ChangeLocomotivesModal from "@/components/trainBuilder/ChangeLocomotivesModal";
import ChangeYardModal from "@/components/trainBuilder/ChangeYardModal";
import ConsistWagonList from "@/components/trainBuilder/ConsistWagonList";
import TrainConsistStrip from "@/components/trainBuilder/TrainConsistStrip";
import { trainStatusColor, trainStatusLabel } from "@/components/trainBuilder/trainStatus";
import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import { api } from "@/services/api";
import { useToast } from "@/hooks/use-toast";
@@ -62,6 +64,7 @@ export default function TrainBuilderDetailPage() {
const navigate = useNavigate();
const { toast } = useToast();
const [locoModalOpen, setLocoModalOpen] = useState(false);
const [yardModalOpen, setYardModalOpen] = useState(false);
const [disbandOpen, setDisbandOpen] = useState(false);
const compositionQuery = useQuery(
@@ -144,6 +147,13 @@ export default function TrainBuilderDetailPage() {
>
Change locomotives
</Menu.Item>
<Menu.Item
leftSection={<MapPin size={15} />}
disabled={!composition.editable}
onClick={() => setYardModalOpen(true)}
>
Change yard
</Menu.Item>
<Menu.Item
color="red"
leftSection={<Trash2 size={15} />}
@@ -162,8 +172,8 @@ export default function TrainBuilderDetailPage() {
{ label: "Locomotives", value: composition.locomotives.length, icon: TrainFront },
{ label: "Wagons", value: totals.wagonCount, icon: TrainIcon },
{
label: "Max gross / haul limit",
value: `${totals.maxGrossTons}T / ${totals.maxPullWeightTons}T`,
label: "Tare weight / haul limit",
value: `${totals.totalTareTons}T of ${totals.maxPullWeightTons}T`,
icon: Weight,
},
{
@@ -180,33 +190,40 @@ export default function TrainBuilderDetailPage() {
</Alert>
) : null}
<Card>
<Stack gap="md">
<Group justify="space-between" align="center">
<Text fw={600}>Consist</Text>
<Text size="xs" c="dimmed">
{composition.locomotives.length} locomotive
{composition.locomotives.length === 1 ? "" : "s"} · {totals.wagonCount} wagon
{totals.wagonCount === 1 ? "" : "s"}
</Text>
</Group>
<TrainConsistStrip
locomotives={composition.locomotives}
wagons={composition.wagons}
/>
<Stack gap="sm">
<TrainCompositionDiagram
locomotives={composition.locomotives.map((loco) => ({
code: loco.code,
name: loco.name,
maxPullWeightTons: loco.maxPullWeightTons,
}))}
wagons={composition.wagons.map((wagon, index) => ({
sequenceNo: wagon.sequenceNumber ?? index + 1,
capacityTons: wagon.wagonType?.capacityTons ?? 0,
// No bookings at build time — wagons ride empty until allocation.
assignedWeightTons: 0,
tareWeightTons: wagon.wagonType?.tareWeightTons ?? 0,
wagonTypeCode: wagon.wagonType?.code ?? null,
physicalWagonNumber: wagon.wagonNumber,
allocations: [],
}))}
trainNumber={composition.code}
totalLengthMeters={totals.totalLengthMeters}
/>
<Card>
<Grid gap="lg">
<Grid.Col span={{ base: 12, sm: 6 }}>
<UtilizationBar
label="Weight utilization (fully loaded)"
pct={totals.weightUtilizationPct}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6 }}>
<UtilizationBar label="Length utilization" pct={totals.lengthUtilizationPct} />
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6 }}>
<Text size="xs" c="dimmed" mt={4}>
The real weight check happens at allocation: booked cargo weight plus
wagon tare (gross) must stay within the locomotives' haul limit.
</Text>
</Grid.Col>
</Grid>
</Stack>
</Card>
</Card>
</Stack>
<Grid gap="lg" align="stretch">
{composition.editable ? (
@@ -297,6 +314,12 @@ export default function TrainBuilderDetailPage() {
onClose={() => setLocoModalOpen(false)}
/>
<ChangeYardModal
composition={composition}
opened={yardModalOpen}
onClose={() => setYardModalOpen(false)}
/>
<Modal
opened={disbandOpen}
onClose={() => setDisbandOpen(false)}

View File

@@ -183,7 +183,7 @@ export default function TrainBuilderListPage() {
meta: { headerClassName, cellClassName },
cell: ({ row }) => (
<Text size="sm">
{row.original.wagonCount} wagons · {row.original.maxGrossTons}T ·{" "}
{row.original.wagonCount} wagons · {row.original.totalTareTons}T tare ·{" "}
{row.original.totalLengthMeters}m
</Text>
),

View File

@@ -43,6 +43,7 @@ import { CountdownTimer, DataTable, type ColumnDef } from "@edr/ui-common";
import { KpiStrip, PageContainer } from "@/components/page";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import AdjustConsistModal from "@/components/trainScheduling/AdjustConsistModal";
import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram";
import {
TrainConsistView,
@@ -684,6 +685,7 @@ export default function BatchScheduleDetailPage() {
);
const [activeTab, setActiveTab] = useState<string | null>("overview");
const [adjustConsistOpen, setAdjustConsistOpen] = useState(false);
const [selectedBookingId, setSelectedBookingId] = useState<string | null>(
null,
);
@@ -838,6 +840,12 @@ export default function BatchScheduleDetailPage() {
}).format(new Date(data.scheduleDate)) + " EAT"
: "No date"}
</HeroChip>
{data.train ? (
<HeroChip icon={<TrainFront size={12} />}>
Train {data.train.code}
{data.train.trainName ? `${data.train.trainName}` : ""}
</HeroChip>
) : null}
{data.locomotive ? (
<HeroChip icon={<TrainFront size={12} />}>
Loco {data.locomotive.code} ·{" "}
@@ -864,6 +872,17 @@ export default function BatchScheduleDetailPage() {
>
Refresh
</Button>
{data.train && ["DRAFT", "SCHEDULED"].includes(data.status) ? (
<Button
variant="light"
color="edr-green"
radius="md"
leftSection={<TrainFront size={16} />}
onClick={() => setAdjustConsistOpen(true)}
>
Adjust consist
</Button>
) : null}
{data.windowPhase === "DOC_REVIEW" ? (
<Button
color="yellow"
@@ -1175,6 +1194,12 @@ export default function BatchScheduleDetailPage() {
)}
</Tabs.Panel>
</Tabs>
<AdjustConsistModal
scheduleId={data.scheduleId}
opened={adjustConsistOpen}
onClose={() => setAdjustConsistOpen(false)}
/>
</PageContainer>
);
}

View File

@@ -52,6 +52,7 @@ import { IntercityRideAlongPanel } from "@/components/trainScheduling/IntercityR
import { YardWorkPanel } from "@/components/trainScheduling/YardWorkPanel";
// import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel";
import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog";
import AdjustConsistModal from "@/components/trainScheduling/AdjustConsistModal";
import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal";
// import { ScheduleBatchPanel } from "@/components/trainScheduling/ScheduleBatchPanel";
import { ScheduleBookingsStep } from "@/components/trainScheduling/ScheduleBookingsStep";
@@ -101,6 +102,7 @@ export default function TrainScheduleV2DetailPage() {
const [previewResult, setPreviewResult] = useState<TrainSchedulePreviewResponse | null>(null);
const [containerPlacements, setContainerPlacements] = useState<ContainerPlacement[]>([]);
const [maintenanceOpen, setMaintenanceOpen] = useState(false);
const [adjustConsistOpen, setAdjustConsistOpen] = useState(false);
const [windowSettingsOpen, setWindowSettingsOpen] = useState(false);
const [gatepassSecuredAt, setGatepassSecuredAt] = useState("");
const [gatepassReference, setGatepassReference] = useState("");
@@ -757,13 +759,14 @@ export default function TrainScheduleV2DetailPage() {
<Stack gap="md">
<TrainCompositionDiagram
locomotive={schedule.trainSet?.locomotive}
locomotives={locomotives}
wagons={
schedule.trainSet?.wagons?.length
? schedule.trainSet.wagons
: displayWagonPlan
}
freightType={freightType}
trainNumber={schedule.trainNumber}
trainNumber={schedule.train ? schedule.train.code : schedule.trainNumber}
totalLengthMeters={schedule.trainSet?.totalLengthMeters}
/>
<Paper
@@ -948,6 +951,18 @@ export default function TrainScheduleV2DetailPage() {
Reschedule train
</Button>
) : null}
{schedule.train && ["DRAFT", "SCHEDULED"].includes(schedule.status) ? (
<Button
variant="light"
color="edr-green"
radius="lg"
size="sm"
leftSection={<Train size={16} />}
onClick={() => setAdjustConsistOpen(true)}
>
Adjust consist
</Button>
) : null}
{gatepassApplies ? (
gatepassSecured ? (
<Button
@@ -1003,6 +1018,16 @@ export default function TrainScheduleV2DetailPage() {
<KpiStrip
items={[
...(schedule.train
? [
{
label: "Train",
value: schedule.train.code,
hint: schedule.train.trainName ?? "Built train (Train Builder)",
icon: Train,
},
]
: []),
{
label: locomotives.length > 1 ? "Locomotives" : "Locomotive",
value: locomotives.length
@@ -1162,6 +1187,12 @@ export default function TrainScheduleV2DetailPage() {
/>
) : null}
<AdjustConsistModal
scheduleId={schedule.id}
opened={adjustConsistOpen}
onClose={() => setAdjustConsistOpen(false)}
/>
<BookingWindowSettingsModal
scheduleId={scheduleId ?? null}
opened={windowSettingsOpen}

View File

@@ -15,6 +15,7 @@ import { ChevronDown, ChevronRight, PackageOpen, Truck } from 'lucide-react';
import { PageContainer, PageHeader } from '@/components/page';
import {
VisualEmptyState,
WarehouseOpsKpiStrip,
formatDate,
formatNumber,
} from '@/components/warehouses';
@@ -291,6 +292,8 @@ export default function ArrivalQueuePage() {
breadcrumbs={[{ label: 'Arrival queue' }]}
/>
<WarehouseOpsKpiStrip />
<Card withBorder radius="md" padding="lg">
<Group justify="space-between" mb="md">
<Stack gap={2}>

View File

@@ -29,6 +29,7 @@ import {
ActivityTimeline,
InventoryMovementHistoryTable,
VisualEmptyState,
WarehouseOpsKpiStrip,
formatDate,
formatNumber,
} from '@/components/warehouses';
@@ -292,6 +293,8 @@ export default function ExportDjiboutiUnloadingQueuePage() {
breadcrumbs={[{ label: 'Djibouti Arrival / Unloading Queue' }]}
/>
<WarehouseOpsKpiStrip />
<Card withBorder radius="md" padding="lg">
<Group justify="space-between" mb="md">
<Text fw={600}>{trains.length} arrived export train(s)</Text>

View File

@@ -8,6 +8,7 @@ import { PageContainer, PageHeader } from '@/components/page';
import {
InventoryWorkbench,
VisualEmptyState,
WarehouseOpsKpiStrip,
formatNumber,
} from '@/components/warehouses';
import { LoadToTrainPanel } from '@/components/warehouses/LoadToTrainPanel';
@@ -74,6 +75,8 @@ export default function LoadingQueuePage() {
}
/>
<WarehouseOpsKpiStrip />
<Card>
<Tabs defaultValue="ready">
<Tabs.List>

View File

@@ -20,6 +20,7 @@ import { useNavigate } from 'react-router-dom';
import { DataTable, type ColumnDef } from '@edr/ui-common';
import { PageContainer, PageHeader } from '@/components/page';
import { AccrualDashboard } from '@/components/warehouses';
import { useMutation, useQuery } from '@tanstack/react-query';
import { api } from '@/services/api';
@@ -122,6 +123,13 @@ export default function WarehouseInvoicesPage() {
subtitle="Demurrage & storage invoices generated from warehouse fee rules."
/>
<Stack gap="xs">
<Text fw={700} size="sm" tt="uppercase" c="dimmed">
Accruing now
</Text>
<AccrualDashboard />
</Stack>
<Card>
<Group justify="space-between" mb="md" wrap="wrap">
<TextInput

View File

@@ -184,10 +184,12 @@ import {
import { trainService, type Train } from "./trains.service";
import {
trainBuilderService,
type AdjustConsistPayload,
type AvailableTrain,
type BuildTrainPayload,
type BuiltTrainListFilters,
type BuiltTrainListResponse,
type ScheduleConsist,
type TrainComposition,
} from "./trainBuilder.service";
import { trainSchedulingService } from "./trainScheduling.service";
@@ -200,6 +202,7 @@ import {
type WagonMovementRecord,
type WagonTransferRequest,
type CreateTransferRequestPayload,
type TransferHistory,
} from "./wagon.service";
import { warehouseService } from "./warehouse.service";
@@ -319,6 +322,30 @@ export const api = {
({ routeId }) => QUERY_KEYS.TRAIN_SCHEDULING.availableTrains(routeId),
),
scheduleConsist: endpoint<{ scheduleId: string }, ScheduleConsist>(
"train-scheduling",
"schedule-consist",
({ scheduleId }) =>
trainBuilderService.scheduleConsist(scheduleId).then((r) => r.data),
({ scheduleId }) => [
...QUERY_KEYS.TRAIN_SCHEDULING.ROOT,
"consist",
scheduleId,
],
),
adjustConsist: endpoint<
{ scheduleId: string; payload: AdjustConsistPayload },
ScheduleConsist
>(
"train-scheduling",
"adjust-consist",
({ scheduleId, payload }) =>
trainBuilderService.adjustConsist(scheduleId, payload).then((r) => r.data),
undefined,
() => TRAIN_BUILDER_INVALIDATIONS,
),
bookableSchedules: endpoint<
{ originYardId?: string | null; destinationYardId?: string | null },
BookableSchedule[]
@@ -1701,6 +1728,21 @@ export const api = {
undefined,
() => [["wagonTransferRequests"]],
),
history: endpoint<void, TransferHistory>(
"wagonTransferRequests",
"history",
() => wagonTransferRequestService.myHistory().then((r) => r.data),
() => ["wagonTransferRequests", "history", "mine"],
),
historyAll: endpoint<{ userId?: string }, TransferHistory>(
"wagonTransferRequests",
"historyAll",
({ userId }) =>
wagonTransferRequestService.allHistory(userId).then((r) => r.data),
({ userId }) => ["wagonTransferRequests", "history", "all", userId ?? ""],
),
},
trains: {
@@ -1781,6 +1823,15 @@ export const api = {
() => TRAIN_BUILDER_INVALIDATIONS,
),
setYard: endpoint<{ id: string; currentYardId: string }, TrainComposition>(
"train-builder",
"setYard",
({ id, currentYardId }) =>
trainBuilderService.setYard(id, currentYardId).then((r) => r.data),
undefined,
() => TRAIN_BUILDER_INVALIDATIONS,
),
assignWagons: endpoint<{ id: string; wagonIds: string[] }, TrainComposition>(
"train-builder",
"assignWagons",

View File

@@ -26,7 +26,7 @@ export interface BuiltTrainSummary {
currentYard: YardRefLite | null;
locomotives: Array<{ id: string; code: string; name: string | null }>;
wagonCount: number;
maxGrossTons: number;
totalTareTons: number;
totalLengthMeters: number;
maxPullWeightTons: number;
}
@@ -63,12 +63,15 @@ export interface TrainCompositionWagon {
export interface TrainCompositionTotals {
wagonCount: number;
totalTareTons: number;
/** Informational only — building never checks against full capacity. */
totalCapacityTons: number;
maxGrossTons: number;
totalLengthMeters: number;
maxPullWeightTons: number;
maxTrainLengthMeters: number;
weightUtilizationPct: number | null;
/** Cargo the locomotives can still haul once pulling the empty consist. */
payloadCapacityTons: number;
/** Share of the haul limit consumed by the empty wagons alone. */
tareUtilizationPct: number | null;
lengthUtilizationPct: number | null;
}
@@ -126,7 +129,7 @@ export interface AvailableTrain {
currentYard: YardRefLite | null;
locomotives: Array<{ id: string; code: string; name: string | null }>;
wagonCount: number;
maxGrossTons: number;
totalTareTons: number;
totalLengthMeters: number;
maxPullWeightTons: number;
atOriginYard: boolean;
@@ -150,6 +153,64 @@ const toQuery = (filters: BuiltTrainListFilters = {}) => {
return qs ? `?${qs}` : "";
};
// ---------------------------------------------------------------------------
// Schedule consist adjustment (train-bound schedules)
// ---------------------------------------------------------------------------
export interface ConsistWagonRef {
id: string;
wagonNumber: string;
sequenceNumber: number | null;
wagonType: {
id: string;
code: string;
tareWeightTons: number;
capacityTons: number;
lengthMeters: number;
} | null;
}
export interface ScheduleConsist {
schedule: { id: string; reference: string | null; status: string };
train: {
id: string;
code: string;
trainName: string | null;
currentYardId: string | null;
};
limits: {
maxPullWeightTons: number;
overageToleranceTons: number;
pullCapTons: number;
maxTrainLengthMeters: number;
overageToleranceMeters: number;
lengthCapMeters: number;
};
totals: {
wagonCount: number;
cargoTons: number;
consistTareTons: number;
grossTons: number;
consistLengthMeters: number;
};
wagons: Array<ConsistWagonRef & { loaded: boolean; removable: boolean }>;
addableWagons: ConsistWagonRef[];
adjustments: Array<{
id: string;
action: "ADD" | "REMOVE";
wagonId: string;
wagonNumber: string;
adjustedByUserId: string | null;
occurredAt: string;
}>;
editable: boolean;
}
export interface AdjustConsistPayload {
addWagonIds?: string[];
removeWagonIds?: string[];
}
export const trainBuilderService = {
list: (filters: BuiltTrainListFilters = {}) =>
apiClient.get<BuiltTrainListResponse>(`${BASE}${toQuery(filters)}`),
@@ -157,6 +218,9 @@ export const trainBuilderService = {
build: (payload: BuildTrainPayload) => apiClient.post<TrainComposition>(BASE, payload),
setLocomotives: (id: string, locomotiveIds: string[]) =>
apiClient.put<TrainComposition>(`${BASE}/${id}/locomotives`, { locomotiveIds }),
/** Relocate the train — coupled locomotives and wagons move with it. */
setYard: (id: string, currentYardId: string) =>
apiClient.patch<TrainComposition>(`${BASE}/${id}/yard`, { currentYardId }),
assignWagons: (id: string, wagonIds: string[]) =>
apiClient.post<TrainComposition>(`${BASE}/${id}/wagons`, { wagonIds }),
removeWagon: (id: string, wagonId: string) =>
@@ -169,4 +233,13 @@ export const trainBuilderService = {
apiClient.get<AvailableTrain[]>(`/train-scheduling/available-trains`, {
params: { routeId },
}),
/** Consist snapshot for a train-bound schedule (adjust-consist UI). */
scheduleConsist: (scheduleId: string) =>
apiClient.get<ScheduleConsist>(`/train-scheduling/schedules/${scheduleId}/consist`),
/** Permanently trim/add wagons on the schedule's built train. */
adjustConsist: (scheduleId: string, payload: AdjustConsistPayload) =>
apiClient.post<ScheduleConsist>(
`/train-scheduling/schedules/${scheduleId}/adjust-consist`,
payload,
),
};

View File

@@ -55,9 +55,12 @@ export interface WagonMovementRecord {
bookingId: string | null;
kind: Freight.WagonMovementKind;
movedByUserId: string | null;
/** The transfer request this move fulfilled, when one drove it. */
transferRequestId: string | null;
occurredAt: string;
note: string | null;
createdAt: string;
wagon?: { id: string; wagonNumber?: string } | null;
}
export const wagonService = {
@@ -120,11 +123,25 @@ export interface CreateTransferRequestPayload {
note?: string;
}
/** Per-user activity: requests filed/fulfilled + the wagons physically moved. */
export interface TransferHistory {
requests: WagonTransferRequest[];
movements: WagonMovementRecord[];
}
export const wagonTransferRequestService = {
list: (status?: Freight.WagonTransferRequestStatus) =>
apiClient.get<WagonTransferRequest[]>(
`/wagon-transfer-requests${status ? `?status=${status}` : ''}`,
),
/** The caller's own history (both roles: requests they filed and fulfilled). */
myHistory: () =>
apiClient.get<TransferHistory>('/wagon-transfer-requests/history'),
/** Admin: any/all staff's history (optional userId filter). */
allHistory: (userId?: string) =>
apiClient.get<TransferHistory>(
`/wagon-transfer-requests/history/all${userId ? `?userId=${userId}` : ''}`,
),
getById: (id: string) =>
apiClient.get<WagonTransferRequest>(`/wagon-transfer-requests/${id}`),
create: (data: CreateTransferRequestPayload) =>

View File

@@ -5,6 +5,8 @@ import { api as apiClient } from '../auth/http';
import { URL_CONSTANTS } from '@/constants/URLS';
import type {
ZoneOccupancy,
WarehouseOpsStats,
AccrualDashboardRow,
AllocationCriteria,
AllocationPreviewResult,
AllocationRule,
@@ -370,6 +372,8 @@ export const warehouseService = {
apiClient.get<ZoneOccupancy[]>(
URL_CONSTANTS.WAREHOUSE_INVENTORY.ZONE_OCCUPANCY(yardId),
),
opsStats: () =>
apiClient.get<WarehouseOpsStats>(URL_CONSTANTS.WAREHOUSE_INVENTORY.OPS_STATS),
autoUnloadArrived: () =>
apiClient.post<AutoUnloadResult>(URL_CONSTANTS.WAREHOUSE_INVENTORY.AUTO_UNLOAD_ARRIVED),
autoLoadReady: () =>
@@ -422,6 +426,14 @@ export const warehouseService = {
apiClient.get<FeePreview[]>(URL_CONSTANTS.WAREHOUSE_RULES.FEE_PREVIEW(inventoryId), {
params: cleanParams({ billingCurrency }),
}),
accrualDashboard: (billingCurrency?: 'ETB' | 'USD') =>
apiClient.get<AccrualDashboardRow[]>(URL_CONSTANTS.WAREHOUSE_RULES.ACCRUAL_DASHBOARD, {
params: cleanParams({ billingCurrency }),
}),
acknowledgeAccrual: (inventoryId: string, body: { snoozeDays?: number; note?: string } = {}) =>
apiClient.post(URL_CONSTANTS.WAREHOUSE_RULES.ACCRUAL_ACK(inventoryId), body),
unacknowledgeAccrual: (inventoryId: string) =>
apiClient.delete(URL_CONSTANTS.WAREHOUSE_RULES.ACCRUAL_ACK(inventoryId)),
// ── Batch 6: Warehouse fee invoices ────────────────────────────────────────
listInvoices: (filter?: WarehouseInvoiceFilter) =>

View File

@@ -309,6 +309,12 @@ export interface BatchBoardSchedule {
docReviewEndsAt: string | null;
paymentPhaseEndsAt: string | null;
bookingCycleNo: number;
/** Built train (Train Builder) behind this departure, when scheduled by train. */
train: {
id: string;
code: string;
trainName: string | null;
} | null;
locomotive: {
code: string;
name: string | null;
@@ -417,6 +423,8 @@ export interface BatchBoardScheduleDetail {
docReviewEndsAt: string | null;
paymentPhaseEndsAt: string | null;
bookingCycleNo: number;
/** Built train (Train Builder) behind this departure, when scheduled by train. */
train: BatchBoardSchedule["train"];
locomotive: BatchBoardSchedule["locomotive"];
capacity: BatchBoardSchedule["capacity"];
counts: BatchBoardSchedule["counts"];
@@ -511,6 +519,12 @@ export interface TrainScheduleDetail {
deferredBookings?: DeferredBookingRow[];
freightType?: FreightType | null;
trainNumber?: string | null;
/** Built train (Train Builder) behind this departure, when scheduled by train. */
train?: {
id: string;
code: string;
trainName?: string | null;
} | null;
direction?: string | null;
/** True when this schedule needs loading confirmed before dispatch (import-Djibouti). */
requiresLoadingConfirmation?: boolean;

View File

@@ -1107,3 +1107,40 @@ export interface ZoneOccupancy {
/** 0100+, container-count based (weight is a rough fallback). Null if no capacity set. */
occupancyPct: number | null;
}
/** At-a-glance warehouse ops counters for the KPI strip. */
export interface WarehouseOpsStats {
receivedToday: number;
pendingInspection: number;
trucksOnSite: number;
itemsAging: number;
}
export type AccrualAlert = 'OK' | 'WARNING' | 'CHARGING';
/** One item's live fee accrual for the accrual dashboard. */
export interface AccrualDashboardRow {
inventoryId: string;
status: string;
bookingId: string | null;
companyId: string | null;
bookingReference: string | null;
customerName: string | null;
warehouseCode: string | null;
zoneCode: string | null;
receivedAt: string | null;
currency: string;
accruedAmount: number;
freeDaysLeft: number | null;
charging: boolean;
alert: AccrualAlert;
acknowledged: boolean;
snoozeUntil: string | null;
breakdown: Array<{
type: string;
amount: number;
freeDays: number;
elapsedDays: number;
chargeableDays: number;
}>;
}

View File

@@ -1,5 +1,5 @@
import { ActionIcon, Box, Group, Skeleton, Stack, Text } from "@mantine/core";
import { memo, useMemo, useState } from "react";
import { Fragment, memo, useMemo, useState } from "react";
import {
ArrowRight,
CalendarClock,
@@ -8,6 +8,7 @@ import {
} from "lucide-react";
import { CountdownTimer } from "@edr/ui-common";
import type { MyBookingWindow } from "@/services/bookings.service";
import { windowRouteStops } from "@/pages/contracts/booking-window";
import { Card } from "./Card";
const INK = "#10202F";
@@ -284,14 +285,21 @@ export const UpcomingWindowsSection = memo(function UpcomingWindowsSection({
}}
>
<Box style={{ minWidth: 0 }}>
<Group gap={6} wrap="nowrap">
<Text fz={14} fw={700} style={{ color: INK }} truncate>
{w.origin ?? "—"}
</Text>
<ArrowRight size={13} color={MUTED} style={{ flexShrink: 0 }} />
<Text fz={14} fw={700} style={{ color: INK }} truncate>
{w.destination ?? "—"}
</Text>
<Group gap={6} wrap="wrap">
{windowRouteStops(w).map((stop, i) => (
<Fragment key={i}>
{i > 0 ? (
<ArrowRight
size={13}
color={MUTED}
style={{ flexShrink: 0 }}
/>
) : null}
<Text fz={14} fw={700} style={{ color: INK }}>
{stop}
</Text>
</Fragment>
))}
{w.reference && (
<Text
fz={11}

View File

@@ -133,6 +133,11 @@ export function ReadonlyBookingView({
["PAID", "TRUCK_ASSIGNED"].includes(status));
const showCountdown = canPay && !!booking.paymentDeadline;
const isExpired = status === "EXPIRED";
// Customs (Path B) bookings are created AND rebooked by Global Logistics, not
// the customer. Hide the customer's rebook everywhere and tell them GL will
// handle it. Non-customs bookings stay self-service.
const isCustoms = Boolean(booking.customsClearingEnabled);
const canSelfRebook = !isCustoms;
const isPendingConsolidation = status === "PENDING_CONSOLIDATION";
const isClearance = [
"AWAITING_DOCUMENTS",
@@ -169,7 +174,7 @@ export function ReadonlyBookingView({
)
}
menuActions={{
onRebook,
onRebook: canSelfRebook ? onRebook : undefined,
onSupport: () => navigate("/support"),
}}
/>
@@ -186,14 +191,18 @@ export function ReadonlyBookingView({
: "This booking process has been terminated."
}
reason={booking.latestChangeRequestNote}
onRebook={onRebook}
onRebook={canSelfRebook ? onRebook : undefined}
/>
) : isExpired ? (
<CancelledBanner
pillLabel="Expired"
title={`The payment window expired on ${fmtDate(booking.updatedAt)}.`}
subtitle="Payment wasn't completed in time, so this booking lost its slot. Rebook to try another schedule."
onRebook={onRebook}
subtitle={
isCustoms
? "Payment wasn't completed in time, so this booking lost its slot. Global Logistics will rebook this shipment for you — no action is needed on your side."
: "Payment wasn't completed in time, so this booking lost its slot. Rebook to try another schedule."
}
onRebook={canSelfRebook ? onRebook : undefined}
/>
) : isPendingConsolidation ? (
<ConsolidationWaitingBanner

View File

@@ -21,6 +21,10 @@ import { BookingActionModal } from "@/pages/bookings/clearance/BookingActionModa
import { getBookingNextAction } from "@/pages/bookings/clearance/bookingNextAction";
import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel";
import toast from "react-hot-toast";
import { bookingsService } from "@/services/bookings.service";
import { saveBlob } from "@/utils/download";
import { fmtDate } from "../utils";
import { IconSquare } from "./Documents";
import { CardTitle, SectionCard } from "./layout";
@@ -361,6 +365,39 @@ export function DocumentsTab({ booking }: { booking: Freight.IBooking }) {
const company = contract?.company;
// One-click warehouse-document bundle: GRN + gate clearance + handover.
const [bundleBusy, setBundleBusy] = useState(false);
const downloadWarehouseDocuments = async () => {
setBundleBusy(true);
const ref = booking.reference ?? booking.id;
const jobs: Array<{ name: string; fn: () => Promise<Blob> }> = [
{ name: `GRN-${ref}.pdf`, fn: () => bookingsService.downloadBookingGrnDocument(booking.id) },
{
name: `gate-clearance-${ref}.pdf`,
fn: () => bookingsService.downloadBookingReleaseDocument(booking.id),
},
{
name: `handover-${ref}.pdf`,
fn: () => bookingsService.downloadBookingHandoverDocument(booking.id),
},
];
let saved = 0;
for (const job of jobs) {
try {
saveBlob(await job.fn(), job.name);
saved += 1;
} catch {
// Document not available for this booking yet — skip it.
}
}
setBundleBusy(false);
if (saved === 0) {
toast.error("No warehouse documents are available for this booking yet.");
} else {
toast.success(`Downloaded ${saved} document${saved !== 1 ? "s" : ""}.`);
}
};
return (
<Stack gap="lg">
{/* ── 1. Clearance documents ──────────────────────────────────────── */}
@@ -552,6 +589,23 @@ export function DocumentsTab({ booking }: { booking: Freight.IBooking }) {
</SectionCard>
)}
{/* ── Warehouse documents (one-click bundle) ──────────────────────── */}
<SectionCard>
<CardTitle>Warehouse documents</CardTitle>
<Text fz="12.5px" c="dimmed" mt={4} mb="sm">
Goods Received Note, gate clearance / release order and handover download all
available documents for this booking in one click.
</Text>
<Button
leftSection={<Download size={16} />}
color="edr-green"
loading={bundleBusy}
onClick={downloadWarehouseDocuments}
>
Download documents
</Button>
</SectionCard>
{!hasContract && otherBookingFiles.length === 0 && (
<SectionCard>
<Alert color="gray" radius="md" icon={<Info size={16} />}>

View File

@@ -1,4 +1,4 @@
import { Alert, Button, Group, Loader, Modal, Stack, Text } from "@mantine/core";
import { Alert, Button, Group, Loader, Modal, Stack, Text, TextInput } from "@mantine/core";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { CheckCircle2, Info } from "lucide-react";
import { useEffect, useState } from "react";
@@ -47,6 +47,7 @@ export function ApproveDeliveryModal({
const navigate = useNavigate();
const queryClient = useQueryClient();
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
const [signerName, setSignerName] = useState("");
const {
data: docBlob,
@@ -122,8 +123,9 @@ export function ApproveDeliveryModal({
<Stack gap="md">
<Alert color="blue" variant="light" icon={<Info size={16} />}>
<Text size="sm">
Review the handover document below. Approving applies your saved signature
and confirms you received the goods.
Review the handover document below, then type your full name to sign and
confirm you received the goods. Your saved signature is applied automatically
if you have one.
</Text>
</Alert>
@@ -151,6 +153,15 @@ export function ApproveDeliveryModal({
/>
)}
<TextInput
label="Your full name"
placeholder="e.g. Abebe Kebede"
required
value={signerName}
onChange={(e) => setSignerName(e.currentTarget.value)}
disabled={busy}
/>
<Group justify="flex-end">
<Button variant="default" onClick={onClose} disabled={busy}>
Cancel
@@ -159,8 +170,8 @@ export function ApproveDeliveryModal({
color="edr-green"
leftSection={<CheckCircle2 size={16} />}
loading={busy}
disabled={isLoading || isError}
onClick={() => approve.mutate({ id: bookingId })}
disabled={isLoading || isError || !signerName.trim()}
onClick={() => approve.mutate({ id: bookingId, signerName: signerName.trim() })}
>
Approve &amp; sign delivery
</Button>

View File

@@ -787,13 +787,17 @@ export function Step5CargoDetails({
const result = calcWagons(containers ?? []);
if (result.hasOddUnit) {
return (
<AlertBox tone="warning">
<p className="font-semibold">Unpaired 20ft Container</p>
<AlertBox tone="error">
<p className="font-semibold">
Odd number of 20ft containers ({result.ft20Wagons})
</p>
<p className="mt-1 text-xs">
One 20ft container occupies only half a wagon. The wagon will
depart once a co-loader is found to fill the remaining slot,
which <strong>may delay departure</strong> beyond the standard
lead time.
20ft containers travel two per wagon, so they must be booked in
even numbers. Please <strong>add one more 20ft container</strong>{" "}
or <strong>remove one</strong> (e.g. book{" "}
{result.ft20Wagons + 1} or {result.ft20Wagons - 1} instead of{" "}
{result.ft20Wagons}) the booking cannot be submitted with an
unpaired 20ft container.
</p>
</AlertBox>
);

View File

@@ -25,10 +25,11 @@ import {
} from "lucide-react";
import type { Freight } from "@/types";
import {
calcWagons,
type BookingFormInputValues,
type BookingFormValues,
} from "./schema";
import { StepHeader } from "./shared";
import { AlertBox, StepHeader } from "./shared";
export const REVIEW_STEP_TARGETS = {
contract: 1,
@@ -160,6 +161,12 @@ export function Step8Review({
.join(", ")
: "";
// 20ft containers must pair up (two per wagon) — an odd total blocks submit.
const { hasOddUnit: hasOdd20ft, ft20Wagons: twentyFtCount } =
values.cargoType === "container"
? calcWagons(values.containers ?? [])
: { hasOddUnit: false, ft20Wagons: 0 };
const isGeneralContract = values.bookingType === "general_contract";
// Both one-time and general contracts take the bulk amount from the cargo step
// (cargoWeight); general contracts no longer collect a per-route quantity.
@@ -529,10 +536,27 @@ export function Step8Review({
</Paper>
<Paper radius={20} p="lg" withBorder bg="white">
<Text size="sm" c="dimmed" mb="md">
Ready to submit. You'll review the unit rates before final
submission.
</Text>
{hasOdd20ft ? (
<Box mb="md">
<AlertBox tone="error">
<p className="font-semibold">
Odd number of 20ft containers ({twentyFtCount})
</p>
<p className="mt-1 text-xs">
20ft containers travel two per wagon, so they must be booked
in even numbers. Go back to the cargo step and{" "}
<strong>add one more 20ft container</strong> or{" "}
<strong>remove one</strong> (e.g. {twentyFtCount + 1} or{" "}
{twentyFtCount - 1} instead of {twentyFtCount}).
</p>
</AlertBox>
</Box>
) : (
<Text size="sm" c="dimmed" mb="md">
Ready to submit. You'll review the unit rates before final
submission.
</Text>
)}
<Stack gap="sm">
<Button
type="button"
@@ -543,7 +567,7 @@ export function Step8Review({
leftSection={<Send size={16} />}
onClick={onSubmit}
loading={submitPending}
disabled={submitPending}
disabled={submitPending || hasOdd20ft}
>
Submit
</Button>

View File

@@ -1,4 +1,4 @@
import { useMemo, useState } from "react";
import { Fragment, useMemo, useState } from "react";
import {
ActionIcon,
Badge,
@@ -21,7 +21,11 @@ import {
import { CountdownTimer } from "@edr/ui-common";
import type { MyBookingWindow } from "@/services/bookings.service";
import { formatWindowOpensAt, soonestUpcomingWindow } from "./booking-window";
import {
formatWindowOpensAt,
soonestUpcomingWindow,
windowRouteStops,
} from "./booking-window";
const INK = "#10202F";
const MUTED = "#6B7C8E";
@@ -189,14 +193,21 @@ function WindowCard({ w }: { w: MyBookingWindow }) {
</Badge>
</Group>
<Group gap={6} wrap="nowrap" mt={10}>
<Text fz={15} fw={700} style={{ color: INK }} truncate>
{w.origin ?? "—"}
</Text>
<ArrowRight size={14} color={MUTED} style={{ flexShrink: 0 }} />
<Text fz={15} fw={700} style={{ color: INK }} truncate>
{w.destination ?? "—"}
</Text>
<Group gap={6} wrap="wrap" mt={10}>
{windowRouteStops(w).map((stop, i) => (
<Fragment key={i}>
{i > 0 ? (
<ArrowRight
size={14}
color={MUTED}
style={{ flexShrink: 0 }}
/>
) : null}
<Text fz={15} fw={700} style={{ color: INK }}>
{stop}
</Text>
</Fragment>
))}
</Group>
<Group gap={6} wrap="nowrap" mt={8}>

View File

@@ -26,7 +26,12 @@ export default function NewShipmentRequestPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [scheduledDate, setScheduledDate] = useState("");
const [quantity, setQuantity] = useState<number | string>(1);
// Container contracts: one quantity per enabled size (e.g. 20ft + 40ft).
const [qtyBySize, setQtyBySize] = useState<Record<string, number | string>>(
{},
);
// Bulk contracts: a single amount — tons (PER_TON) or item count (PER_ITEM).
const [bulkAmount, setBulkAmount] = useState<number | string>("");
const [notes, setNotes] = useState("");
const { data: contract, isLoading } = useQuery({
@@ -76,6 +81,22 @@ export default function NewShipmentRequestPage() {
contract.contractKind === "GENERAL" &&
(contract.serviceType?.includesCustoms ?? contract.customsClearingEnabled);
// Only the container sizes the contract was scoped for (20ft, 40ft, or both).
const SIZE_ORDER = ["20ft", "40ft"];
const enabledSizes = SIZE_ORDER.filter((s) =>
contract.cargoScope?.some(
(l) => (l.containerSize ?? "").toLowerCase() === s,
),
);
// A CONTAINER contract should always carry scope lines; fall back to both.
const sizes = enabledSizes.length ? enabledSizes : SIZE_ORDER;
// Bulk: pick the bulk scope line and read how it's measured.
const bulkScope =
contract.cargoScope?.find((l) => !l.containerSize) ??
contract.cargoScope?.[0];
const isPerItem = bulkScope?.cargoType?.unitOfMeasure === "PER_ITEM";
const handleSubmit = () => {
const dto: Freight.CreateBookingRequestDto = {
contractRouteId: route?.id,
@@ -84,17 +105,24 @@ export default function NewShipmentRequestPage() {
};
if (isContainer) {
const size = contract.cargoScope?.[0]?.containerSize ?? "20FT";
dto.containers = [
{
containerSize: size,
quantity: Number(quantity) || 1,
},
];
// One line per size the user filled; 0 (or blank) sizes are dropped.
const containers = sizes
.map((size) => ({ containerSize: size, quantity: Number(qtyBySize[size]) || 0 }))
.filter((line) => line.quantity > 0);
if (containers.length === 0) {
toast.error("Enter a quantity for at least one container size");
return;
}
dto.containers = containers;
} else {
const amount = Number(bulkAmount) || 0;
if (amount <= 0) {
toast.error(isPerItem ? "Enter the number of items" : "Enter the cargo weight");
return;
}
dto.bulk = {
cargoTypeId: contract.cargoScope?.[0]?.cargoTypeId ?? null,
cargoWeightTons: Number(quantity) || undefined,
cargoTypeId: bulkScope?.cargoTypeId ?? null,
...(isPerItem ? { itemCount: amount } : { cargoWeightTons: amount }),
};
}
@@ -131,17 +159,47 @@ export default function NewShipmentRequestPage() {
/>
)}
<NumberInput
label={isContainer ? "Number of containers" : "Cargo weight (tons)"}
description={
hasCustoms
? "Global Logistics schedules the shipment date during customs clearance — you only state the quantity."
: undefined
}
value={quantity}
onChange={setQuantity}
min={1}
/>
{isContainer ? (
<Stack gap="sm">
{sizes.map((size) => (
<NumberInput
key={size}
label={`Number of ${size} containers`}
value={qtyBySize[size] ?? 0}
onChange={(v) =>
setQtyBySize((prev) => ({ ...prev, [size]: v }))
}
min={0}
allowDecimal={false}
/>
))}
{sizes.length > 1 ? (
<Text size="xs" c="dimmed">
Enter a quantity for each size you need leave a size at 0 if
you don&apos;t need it.
</Text>
) : null}
{hasCustoms ? (
<Text size="xs" c="dimmed">
Global Logistics schedules the shipment date during customs
clearance you only state the quantity.
</Text>
) : null}
</Stack>
) : (
<NumberInput
label={isPerItem ? "Number of items" : "Cargo weight (tons)"}
description={
hasCustoms
? "Global Logistics schedules the shipment date during customs clearance — you only state the quantity."
: undefined
}
value={bulkAmount}
onChange={setBulkAmount}
min={0}
allowDecimal={!isPerItem}
/>
)}
{capacity?.length ? (
<Text size="xs" c="dimmed">

View File

@@ -25,6 +25,16 @@ export function hasOpenWindow(windows: MyBookingWindow[]): boolean {
return windows.some((w) => w.isOpenNow);
}
/**
* Full ordered corridor for a window — every stop from origin through the
* intermediate milestones to the destination. Falls back to origin/destination
* when the backend sends no milestone chain (older schedules, routeless windows).
*/
export function windowRouteStops(w: MyBookingWindow): string[] {
if (w.routeStations && w.routeStations.length >= 2) return w.routeStations;
return [w.origin ?? "—", w.destination ?? "—"];
}
/**
* The next upcoming (not-yet-open) window the customer should come back for —
* the one that OPENS soonest from now. Two guards matter here:

View File

@@ -387,10 +387,10 @@ export const api = {
({ orderId }) => bookingsService.checkPayment(orderId),
),
approveDelivery: endpoint<{ id: string }, ApproveDeliveryResponse>(
approveDelivery: endpoint<{ id: string; signerName: string }, ApproveDeliveryResponse>(
"bookings",
"approveDelivery",
({ id }) => bookingsService.approveDelivery(id),
({ id, signerName }) => bookingsService.approveDelivery(id, signerName),
),
getBookableSchedules: endpoint<

View File

@@ -94,6 +94,12 @@ export interface MyBookingWindow {
departureDate: string;
origin: string | null;
destination: string | null;
/**
* Full ordered corridor for the window's route — origin, every intermediate
* milestone stop, then destination (e.g. Djibouti → Adama → Dire Dawa).
* Falls back to [origin, destination] when the route has no milestones.
*/
routeStations: string[];
}
export interface GeneratePriceResponse {
@@ -209,6 +215,20 @@ export const bookingsService = {
);
return data;
},
downloadBookingGrnDocument: async (bookingId: string): Promise<Blob> => {
const { data } = await client.get(
`/api/warehouse-inventory/bookings/${bookingId}/grn-document`,
{ responseType: "blob" },
);
return data;
},
downloadBookingReleaseDocument: async (bookingId: string): Promise<Blob> => {
const { data } = await client.get(
`/api/warehouse-inventory/bookings/${bookingId}/release-document`,
{ responseType: "blob" },
);
return data;
},
tracking: async (id: string): Promise<Freight.IBookingTracking> => {
const { data } = await client.get(`/api/bookings/${id}/tracking`);
return data.data;
@@ -359,9 +379,13 @@ export const bookingsService = {
return data.data ?? data;
},
approveDelivery: async (id: string): Promise<ApproveDeliveryResponse> => {
approveDelivery: async (
id: string,
signerName: string,
): Promise<ApproveDeliveryResponse> => {
const { data } = await client.post(
`/api/warehouse-inventory/bookings/${id}/approve-delivery`,
{ signerName },
);
return data.data ?? data;
},

View File

@@ -1745,13 +1745,40 @@ export class BookingsService {
);
}
// Resolves the passenger's actual boarding/alighting stations for one leg from
// originStationId/destinationStationId (set when the booking covers only part of a
// longer multi-stop schedule, e.g. train runs A→D but the passenger booked B→D) via
// the schedule's stopTimes, falling back to the schedule's own full-route endpoints
// when there's no segment override (older records, or a booking that covers the
// whole run). Mirrors notifications.service.ts's resolveSegmentStations — that's
// already applied to SMS/email; this brings the booking API (voucher, detail page,
// confirmation) to the same behavior instead of always showing the train's full route.
private resolveSegmentStations(
schedule: any,
originStationId: string | null | undefined,
destinationStationId: string | null | undefined,
): { origin: any; destination: any } {
const stopTimes: any[] = schedule?.stopTimes ?? [];
const findStation = (stationId: string | null | undefined, fallback: any) => {
if (stationId && stopTimes.length > 0) {
const stop = stopTimes.find((st: any) => st.stationId === stationId);
if (stop?.station) return stop.station;
}
return fallback ?? null;
};
return {
origin: findStation(originStationId, schedule?.originStation),
destination: findStation(destinationStationId, schedule?.destinationStation),
};
}
async getByRef(bookingRefOrId: string) {
const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(bookingRefOrId);
const booking = await this.prisma.booking.findUnique({
where: isUuid ? { id: bookingRefOrId } : { bookingRef: bookingRefOrId },
include: {
schedule: { include: { originStation: true, destinationStation: true, train: true } },
returnSchedule: { include: { originStation: true, destinationStation: true, train: true } },
schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
returnSchedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
seats: { include: { seat: { include: { coach: { include: { coachType: { include: { seatClasses: true } } } } } } } },
paymentIntent: true, tickets: true,
priceTier: { select: { priceMinor: true } },
@@ -1820,6 +1847,19 @@ export class BookingsService {
};
}
const outboundSegment = this.resolveSegmentStations(
(booking as any).schedule,
(booking as any).originStationId,
(booking as any).destinationStationId,
);
const returnSegment = (booking as any).returnSchedule
? this.resolveSegmentStations(
(booking as any).returnSchedule,
(booking as any).returnOriginStationId,
(booking as any).returnDestinationStationId,
)
: null;
return {
id: booking.id, bookingRef: booking.bookingRef, status: booking.status,
totalMinor: resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount), currency: 'ETB',
@@ -1836,8 +1876,8 @@ export class BookingsService {
id: (booking as any).schedule.id,
trainNumber: (booking as any).schedule.train.number,
trainName: (booking as any).schedule.train.name,
origin: { id: (booking as any).schedule.originStation.id, name: (booking as any).schedule.originStation.name, code: (booking as any).schedule.originStation.code, city: (booking as any).schedule.originStation.city },
destination: { id: (booking as any).schedule.destinationStation.id, name: (booking as any).schedule.destinationStation.name, code: (booking as any).schedule.destinationStation.code, city: (booking as any).schedule.destinationStation.city },
origin: { id: outboundSegment.origin.id, name: outboundSegment.origin.name, code: outboundSegment.origin.code, city: outboundSegment.origin.city },
destination: { id: outboundSegment.destination.id, name: outboundSegment.destination.name, code: outboundSegment.destination.code, city: outboundSegment.destination.city },
departureAt: (booking as any).schedule.departureAt, arrivalAt: (booking as any).schedule.arrivalAt,
},
returnSchedule: (booking as any).returnSchedule
@@ -1845,8 +1885,8 @@ export class BookingsService {
id: (booking as any).returnSchedule.id,
trainNumber: (booking as any).returnSchedule.train.number,
trainName: (booking as any).returnSchedule.train.name,
origin: { id: (booking as any).returnSchedule.originStation.id, name: (booking as any).returnSchedule.originStation.name, code: (booking as any).returnSchedule.originStation.code, city: (booking as any).returnSchedule.originStation.city },
destination: { id: (booking as any).returnSchedule.destinationStation.id, name: (booking as any).returnSchedule.destinationStation.name, code: (booking as any).returnSchedule.destinationStation.code, city: (booking as any).returnSchedule.destinationStation.city },
origin: { id: returnSegment!.origin.id, name: returnSegment!.origin.name, code: returnSegment!.origin.code, city: returnSegment!.origin.city },
destination: { id: returnSegment!.destination.id, name: returnSegment!.destination.name, code: returnSegment!.destination.code, city: returnSegment!.destination.city },
departureAt: (booking as any).returnSchedule.departureAt, arrivalAt: (booking as any).returnSchedule.arrivalAt,
}
: null,

View File

@@ -3,7 +3,8 @@ import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiBody, ApiR
import { FleetService } from './fleet.service';
import { CreateTrainDto, CreateCoachDto, UpdateCoachDto, AssignCoachDto, ListCoachesDto, CreateCoachTypeDto, UpdateCoachTypeDto, CreateClassDto, UpdateClassDto, GenerateSeatMapDto } from './fleet.dto';
import { JwtGuard } from '../../common/jwt.guard';
import { PassengerAdmin } from '../../common/passenger-guards';
import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards';
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
@ApiTags('Fleet')
@Controller('fleet')
@@ -21,6 +22,7 @@ export class FleetController {
}
@Post('coach-types')
@PassengerStaff([PASSENGER_PERMS.coaches.manage, PASSENGER_PERMS.admin])
@ApiOperation({ summary: 'Create a coach type' })
@ApiBody({ type: CreateCoachTypeDto })
@ApiResponse({ status: 201, description: 'Coach type created' })
@@ -29,6 +31,7 @@ export class FleetController {
}
@Patch('coach-types/:id')
@PassengerStaff([PASSENGER_PERMS.coaches.manage, PASSENGER_PERMS.admin])
@ApiOperation({ summary: 'Update a coach type' })
@ApiParam({ name: 'id', description: 'Coach Type UUID' })
@ApiBody({ type: UpdateCoachTypeDto })
@@ -59,6 +62,7 @@ export class FleetController {
}
@Post('classes')
@PassengerStaff([PASSENGER_PERMS.classes.manage, PASSENGER_PERMS.admin])
@ApiOperation({ summary: 'Create a class' })
@ApiBody({ type: CreateClassDto })
@ApiResponse({ status: 201, description: 'Class created' })
@@ -67,6 +71,7 @@ export class FleetController {
}
@Patch('classes/:id')
@PassengerStaff([PASSENGER_PERMS.classes.manage, PASSENGER_PERMS.admin])
@ApiOperation({ summary: 'Update a class' })
@ApiParam({ name: 'id', description: 'Class UUID' })
@ApiBody({ type: UpdateClassDto })
@@ -98,6 +103,7 @@ export class FleetController {
}
@Post('seat-classes')
@PassengerStaff([PASSENGER_PERMS.classes.manage, PASSENGER_PERMS.admin])
@ApiOperation({ summary: 'Create a class (DEPRECATED - use /fleet/classes)' })
@ApiBody({ type: CreateClassDto })
@ApiResponse({ status: 201, description: 'Class created' })
@@ -106,6 +112,7 @@ export class FleetController {
}
@Patch('seat-classes/:id')
@PassengerStaff([PASSENGER_PERMS.classes.manage, PASSENGER_PERMS.admin])
@ApiOperation({ summary: 'Update a class (DEPRECATED - use /fleet/classes)' })
@ApiParam({ name: 'id', description: 'Class UUID' })
@ApiBody({ type: UpdateClassDto })
@@ -136,6 +143,7 @@ export class FleetController {
}
@Post('trains')
@PassengerStaff([PASSENGER_PERMS.trains.manage, PASSENGER_PERMS.admin])
@ApiOperation({ summary: 'Create a train service' })
@ApiBody({ type: CreateTrainDto })
@ApiResponse({ status: 201, description: 'Train created' })
@@ -144,6 +152,7 @@ export class FleetController {
}
@Patch('trains/:id')
@PassengerStaff([PASSENGER_PERMS.trains.manage, PASSENGER_PERMS.admin])
@ApiOperation({ summary: 'Update a train service' })
@ApiParam({ name: 'id', description: 'Train UUID' })
@ApiBody({ type: CreateTrainDto })
@@ -166,6 +175,7 @@ export class FleetController {
}
@Patch('trains/:id/restore')
@PassengerStaff([PASSENGER_PERMS.trains.manage, PASSENGER_PERMS.admin])
@ApiOperation({ summary: 'Restore (reactivate) a deactivated train' })
@ApiParam({ name: 'id', description: 'Train UUID' })
@ApiResponse({ status: 200, description: 'Train restored' })
@@ -268,6 +278,7 @@ export class FleetController {
}
@Post('coaches')
@PassengerStaff([PASSENGER_PERMS.coaches.manage, PASSENGER_PERMS.admin])
@ApiOperation({ summary: 'Create a coach with auto-generated seat numbers' })
@ApiBody({ type: CreateCoachDto })
@ApiResponse({
@@ -293,6 +304,7 @@ export class FleetController {
}
@Patch('coaches/:id')
@PassengerStaff([PASSENGER_PERMS.coaches.manage, PASSENGER_PERMS.admin])
@ApiOperation({ summary: 'Update coach properties' })
@ApiParam({ name: 'id', description: 'Coach UUID' })
@ApiBody({ type: UpdateCoachDto })
@@ -331,6 +343,7 @@ export class FleetController {
}
@Post('assignments')
@PassengerStaff([PASSENGER_PERMS.coaches.manage, PASSENGER_PERMS.admin])
@ApiOperation({ summary: 'Assign a coach to a schedule' })
@ApiBody({ type: AssignCoachDto })
@ApiResponse({ status: 201, description: 'Coach assigned' })

View File

@@ -3,10 +3,10 @@ import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger'
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
import { PackagesService } from './packages.service';
import { CreatePackageDto, BookPackageDto, CreatePriceTierDto, UpdatePriceTierDto, CreateInquiryDto, UpdateInquiryStatusDto, PackageBookingContextDto } from './packages.dto';
import { IamGuard } from '../../common/iam-adapter';
import { JwtGuard } from '../../common/jwt.guard';
import { OptionalJwtGuard } from '../verifayda/optional-jwt.guard';
import { PassengerAdmin } from '../../common/passenger-guards';
import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards';
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
@ApiTags('Packages')
@Controller('packages')
@@ -21,7 +21,7 @@ export class PackagesController {
}
@Get('inquiries')
@UseGuards(IamGuard)
@PassengerStaff([PASSENGER_PERMS.inquiries.view, PASSENGER_PERMS.admin])
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'List all inquiries (backoffice)' })
listInquiries(
@@ -34,7 +34,7 @@ export class PackagesController {
}
@Patch('inquiries/:id/status')
@UseGuards(IamGuard)
@PassengerStaff([PASSENGER_PERMS.inquiries.manage, PASSENGER_PERMS.admin])
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Update inquiry status (backoffice)' })
updateInquiryStatus(@Param('id') id: string, @Body() dto: UpdateInquiryStatusDto) {
@@ -57,7 +57,7 @@ export class PackagesController {
}
@Get('all')
@UseGuards(IamGuard)
@PassengerStaff([PASSENGER_PERMS.packages.view, PASSENGER_PERMS.admin])
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'List all packages (backoffice)' })
listAll(@Query('page') page?: string, @Query('pageSize') pageSize?: string) {
@@ -65,7 +65,7 @@ export class PackagesController {
}
@Get('bookings')
@UseGuards(IamGuard)
@PassengerStaff([PASSENGER_PERMS.packages.view, PASSENGER_PERMS.admin])
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'List all package bookings (backoffice)' })
listBookings(
@@ -124,7 +124,7 @@ export class PackagesController {
}
@Post()
@UseGuards(IamGuard)
@PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin])
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Create package (admin)' })
create(@Body() dto: CreatePackageDto) {
@@ -132,7 +132,7 @@ export class PackagesController {
}
@Patch(':id')
@UseGuards(IamGuard)
@PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin])
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Update package (admin)' })
update(@Param('id') id: string, @Body() dto: Partial<CreatePackageDto>) {
@@ -149,7 +149,7 @@ export class PackagesController {
}
@Patch(':id/activate')
@UseGuards(IamGuard)
@PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin])
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Activate package (admin)' })
activate(@Param('id') id: string) {
@@ -157,7 +157,7 @@ export class PackagesController {
}
@Patch(':id/deactivate')
@UseGuards(IamGuard)
@PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin])
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Deactivate package (admin)' })
deactivate(@Param('id') id: string) {
@@ -165,7 +165,7 @@ export class PackagesController {
}
@Post(':id/tiers')
@UseGuards(IamGuard)
@PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin])
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Add price tier to package (admin)' })
addTier(@Param('id') id: string, @Body() dto: CreatePriceTierDto) {
@@ -173,7 +173,7 @@ export class PackagesController {
}
@Patch('tiers/:tierId')
@UseGuards(IamGuard)
@PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin])
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Update price tier (admin)' })
updateTier(@Param('tierId') tierId: string, @Body() dto: UpdatePriceTierDto) {

View File

@@ -55,7 +55,11 @@ export class PaymentsController {
}
@Get("all")
@PassengerStaff([PASSENGER_PERMS.payments.viewAll, PASSENGER_PERMS.admin])
@PassengerStaff([
PASSENGER_PERMS.payments.view,
PASSENGER_PERMS.payments.viewAll,
PASSENGER_PERMS.admin,
])
@ApiBearerAuth("IAM-auth")
@ApiOperation({ summary: "Get all payments with filters (staff/admin only)" })
@ApiQuery({ name: "search", required: false })
@@ -146,7 +150,11 @@ export class PaymentsController {
}
@Post("refund")
@PassengerStaff([PASSENGER_PERMS.payments.refund, PASSENGER_PERMS.admin])
@PassengerStaff([
PASSENGER_PERMS.payments.manage,
PASSENGER_PERMS.payments.refund,
PASSENGER_PERMS.admin,
])
@ApiBearerAuth("IAM-auth")
@ApiOperation({ summary: "Refund a confirmed booking (staff/agent only)" })
refund(@Body() dto: RefundDto) {
@@ -155,6 +163,7 @@ export class PaymentsController {
@Post(":bookingId/force-confirm")
@PassengerStaff([
PASSENGER_PERMS.payments.manage,
PASSENGER_PERMS.payments.manageMethods,
PASSENGER_PERMS.admin,
])
@@ -174,6 +183,7 @@ export class PaymentsController {
@Post("methods")
@PassengerStaff([
PASSENGER_PERMS.paymentMethods.manage,
PASSENGER_PERMS.payments.manageMethods,
PASSENGER_PERMS.admin,
])
@@ -187,6 +197,7 @@ export class PaymentsController {
@Patch("methods/:id")
@PassengerStaff([
PASSENGER_PERMS.paymentMethods.manage,
PASSENGER_PERMS.payments.manageMethods,
PASSENGER_PERMS.admin,
])

View File

@@ -1,9 +1,9 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, ParseIntPipe, UseGuards } from '@nestjs/common';
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, ParseIntPipe } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger';
import { RoutesService } from './routes.service';
import { CreateRouteDto, AddRouteStopDto, UpdateRouteDto, SetRouteCoachTemplateDto } from './routes.dto';
import { JwtGuard } from '../../common/jwt.guard';
import { PassengerAdmin } from '../../common/passenger-guards';
import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards';
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
@ApiTags('Routes')
@Controller('routes')
@@ -13,7 +13,7 @@ export class RoutesController {
// ── Routes ─────────────────────────────────────────────────────────────────
@Post()
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@PassengerStaff([PASSENGER_PERMS.routes.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@ApiOperation({
summary: 'Create a reusable route with its ordered stops',
description: `Define the physical corridor once (e.g. ADD→ADM→AWS→DDW→AYS→DJI).
@@ -41,7 +41,7 @@ Route stops carry distanceKm for fare-by-distance calculations.`,
getRoute(@Param('id') id: string) { return this.service.getRoute(id); }
@Patch(':id')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@PassengerStaff([PASSENGER_PERMS.routes.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Update route metadata (name, description, active flag, effectiveUntil)' })
@ApiParam({ name: 'id', description: 'Route UUID' })
@ApiResponse({ status: 200, description: 'Route updated' })
@@ -68,7 +68,7 @@ Route stops carry distanceKm for fare-by-distance calculations.`,
getStops(@Param('id') id: string) { return this.service.getStops(id); }
@Post(':id/stops')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@PassengerStaff([PASSENGER_PERMS.routes.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Add a stop to an existing route' })
@ApiParam({ name: 'id', description: 'Route UUID' })
@ApiResponse({ status: 201, description: 'Stop added' })
@@ -108,7 +108,7 @@ Route stops carry distanceKm for fare-by-distance calculations.`,
getCoachTemplate(@Param('id') id: string) { return this.service.getRouteCoachTemplate(id); }
@Put(':id/coaches')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@PassengerStaff([PASSENGER_PERMS.routes.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@ApiOperation({
summary: 'Set the default coach lineup for this route',
description: 'Replaces the entire coach template. Coaches are auto-assigned in this order when a new schedule is created for this route.',

View File

@@ -1,10 +1,10 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, ParseIntPipe, UseGuards } from '@nestjs/common';
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, ParseIntPipe } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger';
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
import { SchedulesService } from './schedules.service';
import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto, BulkSchedulesResponseDto, TripStatus } from './schedules.dto';
import { JwtGuard } from '../../common/jwt.guard';
import { PassengerAdmin } from '../../common/passenger-guards';
import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards';
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
@ApiTags('Schedule')
@Controller('schedules')
@@ -12,14 +12,14 @@ export class SchedulesController {
constructor(private service: SchedulesService) {}
@Post('bulk-generate')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Bulk generate repetitive schedules' })
bulkGenerateSchedules(@Body() dto: BulkCreateSchedulesDto) {
return this.service.bulkGenerateSchedules(dto);
}
@Post()
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Create a train schedule from a route template' })
createSchedule(@Body() dto: CreateScheduleDto) { return this.service.createSchedule(dto); }
@@ -42,13 +42,13 @@ export class SchedulesController {
// ===== SPECIFIC ROUTES (must come BEFORE generic :id routes) =====
@Post('fares')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Create a fare rule scoped to a schedule or route code' })
@ApiResponse({ status: 201, description: 'Fare rule created' })
createFareRule(@Body() dto: CreateFareRuleDto) { return this.service.createFareRule(dto); }
@Patch('fares/:id')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Update a fare rule' })
@ApiParam({ name: 'id', description: 'FareRule UUID' })
@ApiResponse({ status: 200, description: 'Fare rule updated' })
@@ -65,7 +65,7 @@ export class SchedulesController {
deleteFareRule(@Param('id') id: string) { return this.service.deleteFareRule(id); }
@Post('segment-fares')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Create a segment fare rule' })
createSegmentFareRule(@Body() dto: any) { return this.service.createSegmentFareRule(dto); }
@@ -80,7 +80,7 @@ export class SchedulesController {
}
@Patch('routes/fare-rules/:id')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Update a route-level fare override' })
@ApiParam({ name: 'id', description: 'RouteFareRule UUID' })
updateRouteFareRule(@Param('id') id: string, @Body() dto: any) {
@@ -96,7 +96,7 @@ export class SchedulesController {
}
@Post('routes/:routeId/fare-rules')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Create a route-level fare override' })
@ApiParam({ name: 'routeId', description: 'Route UUID' })
createRouteFareRule(@Param('routeId') routeId: string, @Body() dto: any) {
@@ -110,7 +110,7 @@ export class SchedulesController {
getSegmentFares(@Param('routeId') routeId: string) { return this.service.getSegmentFares(routeId); }
@Patch('segment-fares/:id')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Update a segment fare rule' })
@ApiParam({ name: 'id', description: 'SegmentFareRule UUID' })
updateSegmentFareRule(@Param('id') id: string, @Body() dto: any) { return this.service.updateSegmentFareRule(id, dto); }
@@ -131,7 +131,7 @@ export class SchedulesController {
getSchedule(@Param('id') id: string) { return this.service.getSchedule(id); }
@Patch(':id')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Update a schedule (partial)' })
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
updateSchedule(@Param('id') id: string, @Body() dto: UpdateScheduleDto) {
@@ -139,7 +139,7 @@ export class SchedulesController {
}
@Patch(':id/status')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Update schedule status' })
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
updateStatus(@Param('id') id: string, @Body() dto: UpdateScheduleStatusDto) {
@@ -161,7 +161,7 @@ export class SchedulesController {
getStops(@Param('id') id: string) { return this.service.getStops(id); }
@Patch(':id/stops/:sequence')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Update a stop time' })
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
@ApiParam({ name: 'sequence', description: 'Stop sequence number' })
@@ -172,7 +172,7 @@ export class SchedulesController {
) { return this.service.updateStop(id, sequence, dto); }
@Put(':scheduleId/fares/:seatClassId')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@ApiOperation({
summary: 'Override fare for a specific seat class on a schedule',
description: 'Upserts a schedule-scoped FareRule. Expires any existing active rule for the same schedule+seatClass and creates a new one.',
@@ -220,12 +220,13 @@ export class SchedulesController {
}
@Post(':id/fares/sync')
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Sync fares from fare engine' })
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
syncFares(@Param('id') id: string) { return this.service.syncFaresFromEngine(id); }
@Post(':id/coaches')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Assign coaches to a schedule' })
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
assignCoaches(

View File

@@ -1,10 +1,10 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiResponse, ApiBody } from '@nestjs/swagger';
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
import { SeatClassesService } from './seat-classes.service';
import { CreateSeatClassDto, UpdateSeatClassDto } from './seat-classes.dto';
import { JwtGuard } from '../../common/jwt.guard';
import { PassengerAdmin } from '../../common/passenger-guards';
import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards';
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
@ApiTags('Seat Classes')
@Controller('seat-classes')
@@ -26,7 +26,7 @@ export class SeatClassesController {
getSeatClass(@Param('id') id: string) { return this.service.getSeatClass(id); }
@Post()
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@PassengerStaff([PASSENGER_PERMS.tariffRates.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Create a seat class' })
@ApiBody({ type: CreateSeatClassDto })
@ApiResponse({ status: 201, description: 'Seat class created' })
@@ -34,7 +34,7 @@ export class SeatClassesController {
createSeatClass(@Body() dto: CreateSeatClassDto) { return this.service.createSeatClass(dto); }
@Patch(':id')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@PassengerStaff([PASSENGER_PERMS.tariffRates.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Update a seat class' })
@ApiParam({ name: 'id', description: 'Seat class UUID' })
@ApiBody({ type: UpdateSeatClassDto })

View File

@@ -21,7 +21,8 @@ import {
import { SeatsService } from "./seats.service";
import { HoldSeatsDto, ReleaseHoldDto } from "./seats.dto";
import { JwtGuard } from "../../common/jwt.guard";
import { IamGuard } from "../../common/iam-adapter";
import { PassengerStaff } from "../../common/passenger-guards";
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
@ApiTags("Seats")
@Controller("seats")
@@ -205,7 +206,7 @@ This makes it clear which segment of the route each seat is held for, enabling s
// ── Seat Block / Unblock ───────────────────────────────────────────────────
@Post(":seatId/block")
@UseGuards(IamGuard)
@PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.admin])
@ApiBearerAuth("IAM-auth")
@ApiOperation({ summary: "Block a seat (e.g., maintenance, damage)" })
@ApiParam({ name: "seatId", description: "Seat UUID" })
@@ -215,7 +216,7 @@ This makes it clear which segment of the route each seat is held for, enabling s
}
@Delete(":seatId/block")
@UseGuards(IamGuard)
@PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.admin])
@ApiBearerAuth("IAM-auth")
@ApiOperation({ summary: "Unblock a seat" })
@ApiParam({ name: "seatId", description: "Seat UUID" })
@@ -226,7 +227,7 @@ This makes it clear which segment of the route each seat is held for, enabling s
// ── Maintenance ───────────────────────────────────────────────────────────
@Post(":seatId/maintenance")
@UseGuards(IamGuard)
@PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.admin])
@ApiBearerAuth("IAM-auth")
@ApiOperation({ summary: "Set seat status to Under Maintenance" })
@ApiParam({ name: "seatId", description: "Seat UUID" })
@@ -236,7 +237,7 @@ This makes it clear which segment of the route each seat is held for, enabling s
}
@Delete(":seatId/maintenance")
@UseGuards(IamGuard)
@PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.admin])
@ApiBearerAuth("IAM-auth")
@ApiOperation({ summary: "Clear seat maintenance status" })
@ApiParam({ name: "seatId", description: "Seat UUID" })
@@ -247,7 +248,7 @@ This makes it clear which segment of the route each seat is held for, enabling s
// ── Remove Seat ────────────────────────────────────────────────────────────
@Patch(":seatId/remove")
@UseGuards(IamGuard)
@PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.admin])
@ApiBearerAuth("IAM-auth")
@ApiOperation({
summary: "Remove a seat by marking with negative seatNumber",
@@ -263,7 +264,7 @@ This makes it clear which segment of the route each seat is held for, enabling s
}
@Patch(":seatId/undo-remove")
@UseGuards(IamGuard)
@PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.admin])
@ApiBearerAuth("IAM-auth")
@ApiOperation({
summary: "Undo seat removal by restoring original seatNumber",

View File

@@ -1,10 +1,10 @@
import { Body, Controller, Get, Param, Post, Patch, Delete, UseGuards, Query } from '@nestjs/common';
import { Body, Controller, Get, Param, Post, Patch, Delete, Query } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery, ApiResponse } from '@nestjs/swagger';
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
import { StationsService } from './stations.service';
import { CreateStationDto } from './stations.dto';
import { JwtGuard } from '../../common/jwt.guard';
import { PassengerAdmin } from '../../common/passenger-guards';
import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards';
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
@ApiTags('Stations')
@Controller('stations')
@@ -79,8 +79,8 @@ export class StationsController {
findOne(@Param('id') id: string) { return this.service.findOne(id); }
@Post()
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@PassengerStaff([PASSENGER_PERMS.stations.manage, PASSENGER_PERMS.admin])
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Create new station' })
@ApiResponse({
status: 201,
@@ -105,8 +105,8 @@ export class StationsController {
create(@Body() dto: CreateStationDto) { return this.service.create(dto); }
@Patch(':id')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@PassengerStaff([PASSENGER_PERMS.stations.manage, PASSENGER_PERMS.admin])
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Update station' })
@ApiResponse({
status: 200,

View File

@@ -54,4 +54,19 @@ export const EDR_PASSENGER_ROLES: PassengerSeedRole[] = [
name: { en: 'EDR Passenger Finance' },
permissionKeys: [...ROLE_PERMISSION_PRESETS.finance],
},
{
key: 'edr_passenger_operations_manager',
name: { en: 'EDR Passenger Operations Manager' },
permissionKeys: [...ROLE_PERMISSION_PRESETS.operationsManager],
},
{
key: 'edr_passenger_marketing_manager',
name: { en: 'EDR Passenger Marketing Manager' },
permissionKeys: [...ROLE_PERMISSION_PRESETS.marketingManager],
},
{
key: 'edr_passenger_finance_manager',
name: { en: 'EDR Passenger Finance Manager' },
permissionKeys: [...ROLE_PERMISSION_PRESETS.financeManager],
},
];

View File

@@ -34,6 +34,38 @@ export const PASSENGER_PERMISSIONS: PassengerPermissionSeed[] = [
perm('75b5ff62-a8e4-4331-b6e6-d53e1456d10e', 'edr_passenger_app:currencies:manage', 'Manage currencies'),
perm('4a47da9b-cf6e-4240-aff8-aadf01641c54', 'edr_passenger_app:notifications:send', 'Send notifications'),
perm('bfe3428f-8b85-4a36-87c6-33063b084bf3', 'edr_passenger_app:dashboard:view', 'View dashboard'),
// ── Master Data ────────────────────────────────────────────────────────────
perm('102969bc-13a2-4f4a-aa7f-ce4b2599ce82', 'edr_passenger_app:stations:view', 'View stations'),
perm('931cf6fc-8f41-46b6-82b8-b242f75d296e', 'edr_passenger_app:stations:manage', 'Manage stations'),
perm('82a801b3-2451-409b-99e8-9f4d3515d329', 'edr_passenger_app:trains:view', 'View trains'),
perm('e2a7f842-7691-4007-bd71-fd91c467044d', 'edr_passenger_app:trains:manage', 'Manage trains'),
perm('71de593c-ae4a-4d15-9f0a-b889eeb4910c', 'edr_passenger_app:coaches:view', 'View coaches'),
perm('39b3de2e-c779-4010-8ef3-d478f00a15c6', 'edr_passenger_app:coaches:manage', 'Manage coaches'),
perm('ba0fdd0b-6580-48a8-b31f-eb5ef76a2453', 'edr_passenger_app:seats:view', 'View seats'),
perm('6fb9affb-1885-446e-a8e4-962af9fae33f', 'edr_passenger_app:seats:manage', 'Manage seats'),
perm('b7b659d9-f453-41d3-a6db-72e671446214', 'edr_passenger_app:classes:view', 'View classes'),
perm('b2d06665-33f5-46d7-a895-785b5fb1896a', 'edr_passenger_app:classes:manage', 'Manage classes'),
perm('8731ee98-24c2-4f8b-9c06-cf3fa900a95a', 'edr_passenger_app:routes:view', 'View routes'),
perm('5851233c-78de-45b9-9d3f-63816d068622', 'edr_passenger_app:routes:manage', 'Manage routes'),
perm('c453bdf9-496a-4ac8-b733-8eb5dd5d591a', 'edr_passenger_app:schedules:view', 'View schedules'),
perm('d3f3cfd0-c7ce-47ab-be7f-bf3d6b40e488', 'edr_passenger_app:schedules:manage', 'Manage schedules'),
// ── Tourism ────────────────────────────────────────────────────────────────
perm('d78d810b-3003-4d81-92d5-41c437f3cc42', 'edr_passenger_app:packages:view', 'View packages'),
perm('dcfab0d9-1f80-4822-892a-e2851b549297', 'edr_passenger_app:packages:manage', 'Manage packages'),
perm('6d7ab68c-1b88-405d-9f92-b130055eece6', 'edr_passenger_app:inquiries:view', 'View package inquiries'),
perm('dbe5a07a-d12f-4a36-b191-0bb4f980054e', 'edr_passenger_app:inquiries:manage', 'Manage package inquiries'),
// ── Finance ────────────────────────────────────────────────────────────────
perm('4b6efd87-f230-4109-abe1-593e53cb0c10', 'edr_passenger_app:tariff_rates:view', 'View tariff rates'),
perm('94f17a59-397c-4c9c-a424-38a9c66c9e50', 'edr_passenger_app:tariff_rates:manage', 'Manage tariff rates'),
perm('2dc4eb75-5b28-4ead-a2d4-82ac95cd290c', 'edr_passenger_app:payments:view', 'View payments'),
perm('418b5f64-656b-4d44-a543-930ada9ec1a7', 'edr_passenger_app:payments:manage', 'Manage payments'),
perm('3f3d5479-af33-4883-867e-aae9e2aeeeca', 'edr_passenger_app:currencies:view', 'View currencies'),
perm('b4e63290-cc3a-4df8-9f2a-9ff726e86e36', 'edr_passenger_app:payment_methods:view', 'View payment methods'),
perm('f9fb6af2-e869-4e6e-938c-259643393315', 'edr_passenger_app:payment_methods:manage', 'Manage payment methods'),
perm('49fd28cd-5b58-4403-8e53-1df4b93cbbd2', 'edr_passenger_app:admin', 'Full admin access'),
];
@@ -54,10 +86,57 @@ export const PASSENGER_PERMS = {
manage: 'edr_passenger_app:tickets:manage',
},
payments: {
view: 'edr_passenger_app:payments:view',
manage: 'edr_passenger_app:payments:manage',
// legacy keys — retained as aliases for backward compatibility
viewAll: 'edr_passenger_app:payments:view_all',
refund: 'edr_passenger_app:payments:refund',
manageMethods: 'edr_passenger_app:payments:manage_methods',
},
paymentMethods: {
view: 'edr_passenger_app:payment_methods:view',
manage: 'edr_passenger_app:payment_methods:manage',
},
stations: {
view: 'edr_passenger_app:stations:view',
manage: 'edr_passenger_app:stations:manage',
},
trains: {
view: 'edr_passenger_app:trains:view',
manage: 'edr_passenger_app:trains:manage',
},
coaches: {
view: 'edr_passenger_app:coaches:view',
manage: 'edr_passenger_app:coaches:manage',
},
seats: {
view: 'edr_passenger_app:seats:view',
manage: 'edr_passenger_app:seats:manage',
},
classes: {
view: 'edr_passenger_app:classes:view',
manage: 'edr_passenger_app:classes:manage',
},
routes: {
view: 'edr_passenger_app:routes:view',
manage: 'edr_passenger_app:routes:manage',
},
schedules: {
view: 'edr_passenger_app:schedules:view',
manage: 'edr_passenger_app:schedules:manage',
},
packages: {
view: 'edr_passenger_app:packages:view',
manage: 'edr_passenger_app:packages:manage',
},
inquiries: {
view: 'edr_passenger_app:inquiries:view',
manage: 'edr_passenger_app:inquiries:manage',
},
tariffRates: {
view: 'edr_passenger_app:tariff_rates:view',
manage: 'edr_passenger_app:tariff_rates:manage',
},
reports: {
view: 'edr_passenger_app:reports:view',
},
@@ -73,6 +152,7 @@ export const PASSENGER_PERMS = {
manage: 'edr_passenger_app:agents:manage',
},
currencies: {
view: 'edr_passenger_app:currencies:view',
manage: 'edr_passenger_app:currencies:manage',
},
notifications: {
@@ -126,9 +206,36 @@ export const ROLE_PERMISSION_PRESETS = {
],
finance: [
PASSENGER_PERMS.payments.view,
PASSENGER_PERMS.payments.viewAll,
PASSENGER_PERMS.payments.refund,
PASSENGER_PERMS.reports.view,
PASSENGER_PERMS.dashboard.view,
],
operationsManager: [
PASSENGER_PERMS.stations.view, PASSENGER_PERMS.stations.manage,
PASSENGER_PERMS.trains.view, PASSENGER_PERMS.trains.manage,
PASSENGER_PERMS.coaches.view, PASSENGER_PERMS.coaches.manage,
PASSENGER_PERMS.seats.view, PASSENGER_PERMS.seats.manage,
PASSENGER_PERMS.classes.view, PASSENGER_PERMS.classes.manage,
PASSENGER_PERMS.routes.view, PASSENGER_PERMS.routes.manage,
PASSENGER_PERMS.schedules.view, PASSENGER_PERMS.schedules.manage,
PASSENGER_PERMS.dashboard.view,
],
marketingManager: [
PASSENGER_PERMS.packages.view, PASSENGER_PERMS.packages.manage,
PASSENGER_PERMS.inquiries.view, PASSENGER_PERMS.inquiries.manage,
PASSENGER_PERMS.dashboard.view,
],
financeManager: [
PASSENGER_PERMS.tariffRates.view, PASSENGER_PERMS.tariffRates.manage,
PASSENGER_PERMS.payments.view, PASSENGER_PERMS.payments.manage,
PASSENGER_PERMS.currencies.view, PASSENGER_PERMS.currencies.manage,
PASSENGER_PERMS.paymentMethods.view, PASSENGER_PERMS.paymentMethods.manage,
PASSENGER_PERMS.reports.view,
PASSENGER_PERMS.dashboard.view,
],
} as const;

View File

@@ -9,14 +9,13 @@ import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { apiClient, paymentsApi } from '@/lib/api';
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { usePermission } from '@/lib/use-permission';
import { PERMS } from '@/lib/permissions';
export default function PaymentMethodsPage() {
const canManagePayments = usePermission(PERMS.payments.manage);
const canManageMethods = usePermission(PERMS.paymentMethods.manage);
const canManageAdmin = usePermission(PERMS.admin);
const canManage = canManagePayments || canManageAdmin;
const canManage = canManageMethods || canManageAdmin;
const [createModalOpen, setCreateModalOpen] = useState(false);
const [editModalOpen, setEditModalOpen] = useState(false);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
@@ -228,11 +227,11 @@ export default function PaymentMethodsPage() {
<h1 className="text-2xl font-bold text-foreground">Payment Methods</h1>
<p className="text-muted-foreground">Manage supported payment systems</p>
</div>
<PermissionGuard permission={PERMS.admin}>
{canManage && (
<ActionButton icon={Plus} onClick={() => setCreateModalOpen(true)}>
Add Method
</ActionButton>
</PermissionGuard>
)}
</div>
{successMessage && (

View File

@@ -70,33 +70,33 @@ const navigationSections: { title: string; items: NavItem[] }[] = [
{
title: 'Tourism',
items: [
{ name: 'Packages', href: '/packages', icon: Package, permission: PERMS.admin },
// { name: 'Bookings', href: '/package-bookings', icon: Ticket, permission: PERMS.admin },
{ name: 'Inquiries', href: '/package-inquiries', icon: MessageSquare, permission: PERMS.admin },
{ name: 'Packages', href: '/packages', icon: Package, permission: PERMS.packages.view },
// { name: 'Bookings', href: '/package-bookings', icon: Ticket, permission: PERMS.packages.view },
{ name: 'Inquiries', href: '/package-inquiries', icon: MessageSquare, permission: PERMS.inquiries.view },
]
},
{
title: 'Master Data',
items: [
{ name: 'Stations', href: '/stations', icon: MapPin, permission: PERMS.admin },
{ name: 'Trains', href: '/trains', icon: Train, permission: PERMS.admin },
{ name: 'Coaches', href: '/coaches', icon: Grid3x3, permission: PERMS.admin },
{ name: 'Seats', href: '/seats', icon: Armchair, permission: PERMS.admin },
{ name: 'Classes', href: '/classes', icon: Settings, permission: PERMS.admin },
{ name: 'Routes', href: '/routes', icon: Route, permission: PERMS.admin },
{ name: 'Schedules', href: '/schedules', icon: Calendar, permission: PERMS.admin },
{ name: 'Stations', href: '/stations', icon: MapPin, permission: PERMS.stations.view },
{ name: 'Trains', href: '/trains', icon: Train, permission: PERMS.trains.view },
{ name: 'Coaches', href: '/coaches', icon: Grid3x3, permission: PERMS.coaches.view },
{ name: 'Seats', href: '/seats', icon: Armchair, permission: PERMS.seats.view },
{ name: 'Classes', href: '/classes', icon: Settings, permission: PERMS.classes.view },
{ name: 'Routes', href: '/routes', icon: Route, permission: PERMS.routes.view },
{ name: 'Schedules', href: '/schedules', icon: Calendar, permission: PERMS.schedules.view },
]
},
{
title: 'Financial',
items: [
// { name: 'Pricing & Fares', href: '/pricing', icon: DollarSign, permission: PERMS.admin },
{ name: 'Tariff Rates', href: '/tariff-rates', icon: Banknote, permission: PERMS.admin },
{ name: 'Tariff Rates', href: '/tariff-rates', icon: Banknote, permission: PERMS.tariffRates.view },
// { name: 'Fare Rules', href: '/fare-management', icon: Settings, permission: PERMS.admin },
{ name: 'Payments', href: '/payments', icon: CreditCard, permission: PERMS.payments.view },
{ name: 'Currencies', href: '/currencies', icon: Banknote, permission: PERMS.currencies.manage },
{ name: 'Currencies', href: '/currencies', icon: Banknote, permission: PERMS.currencies.view },
// { name: 'Promo Codes', href: '/promos', icon: Gift, permission: PERMS.admin },
{ name: 'Payment Methods', href: '/payment-methods', icon: CreditCard, permission: PERMS.payments.view },
{ name: 'Payment Methods', href: '/payment-methods', icon: CreditCard, permission: PERMS.paymentMethods.view },
// { name: 'Wallet Accounts', href: '/wallet-accounts', icon: Wallet, permission: PERMS.payments.view },
]
},

View File

@@ -13,11 +13,69 @@ export const PERMS = {
view: 'edr_passenger_app:tickets:view',
manage: 'edr_passenger_app:tickets:manage',
},
payments: {
view: 'edr_passenger_app:payments:view_all',
refund: 'edr_passenger_app:payments:refund',
manage: 'edr_passenger_app:payments:manage_methods',
// ── Master Data ────────────────────────────────────────────────
stations: {
view: 'edr_passenger_app:stations:view',
manage: 'edr_passenger_app:stations:manage',
},
trains: {
view: 'edr_passenger_app:trains:view',
manage: 'edr_passenger_app:trains:manage',
},
coaches: {
view: 'edr_passenger_app:coaches:view',
manage: 'edr_passenger_app:coaches:manage',
},
seats: {
view: 'edr_passenger_app:seats:view',
manage: 'edr_passenger_app:seats:manage',
},
classes: {
view: 'edr_passenger_app:classes:view',
manage: 'edr_passenger_app:classes:manage',
},
routes: {
view: 'edr_passenger_app:routes:view',
manage: 'edr_passenger_app:routes:manage',
},
schedules: {
view: 'edr_passenger_app:schedules:view',
manage: 'edr_passenger_app:schedules:manage',
},
// ── Tourism ────────────────────────────────────────────────────
packages: {
view: 'edr_passenger_app:packages:view',
manage: 'edr_passenger_app:packages:manage',
},
inquiries: {
view: 'edr_passenger_app:inquiries:view',
manage: 'edr_passenger_app:inquiries:manage',
},
// ── Finance ────────────────────────────────────────────────────
tariffRates: {
view: 'edr_passenger_app:tariff_rates:view',
manage: 'edr_passenger_app:tariff_rates:manage',
},
payments: {
view: 'edr_passenger_app:payments:view',
manage: 'edr_passenger_app:payments:manage',
// legacy aliases — still honoured by the backend guards
viewAll: 'edr_passenger_app:payments:view_all',
refund: 'edr_passenger_app:payments:refund',
manageMethods: 'edr_passenger_app:payments:manage_methods',
},
paymentMethods: {
view: 'edr_passenger_app:payment_methods:view',
manage: 'edr_passenger_app:payment_methods:manage',
},
currencies: {
view: 'edr_passenger_app:currencies:view',
manage: 'edr_passenger_app:currencies:manage',
},
reports: {
view: 'edr_passenger_app:reports:view',
},
@@ -32,9 +90,6 @@ export const PERMS = {
view: 'edr_passenger_app:agents:view',
manage: 'edr_passenger_app:agents:manage',
},
currencies: {
manage: 'edr_passenger_app:currencies:manage',
},
notifications: {
send: 'edr_passenger_app:notifications:send',
},

View File

@@ -730,7 +730,7 @@ export default function ConfirmationPage() {
</div>
<div className="p-4 bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-800 rounded-lg">
<p className="text-sm text-green-800 dark:text-green-300">
Please arrive at the station at least 30 minutes before
Please arrive at the station at least 2 hours before
departure.
</p>
</div>

View File

@@ -1927,9 +1927,6 @@ export default function SeatsPage() {
? allCoachSeats?.find((s: any) => s.id === assignedSeatId)
: null;
const seatLabel = assignedSeat ? buildSeatLabel(assignedSeat) : "";
const seatFare = assignedSeat
? (getSeatFare(assignedSeat) ?? (isPackageBooking ? packageTierPriceMinor ?? null : null))
: isPackageBooking && assignedSeatId ? (packageTierPriceMinor ?? null) : null;
const isActive = i === activePassengerIndex;
const isClickable = i <= maxSelectableIndex;
return (

View File

@@ -174,7 +174,7 @@ export default function HowToGuidePage() {
</p>
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 p-4 rounded-lg">
<p className="text-sm text-green-800 dark:text-green-300">
📱 Show the QR code at the gate for easy check-in. Arrive at least 30 minutes before departure.
📱 Show the QR code at the gate for easy check-in. Arrive at least 2 hours before departure.
</p>
</div>
</div>
@@ -225,7 +225,7 @@ export default function HowToGuidePage() {
<div>
<h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-2">What should I bring on the day of travel?</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
Bring your ticket (digital or printed), valid ID/passport, and arrive 30 minutes before departure.
Bring your ticket (digital or printed), valid ID/passport, and arrive 2 hours before departure.
</p>
</div>
</div>

View File

@@ -374,7 +374,7 @@ function drawInstructions(doc: jsPDF, y: number, margin: number, pageWidth: numb
doc.text('BEFORE YOU TRAVEL', margin + padX, y + 6, { charSpace: 0.3 });
doc.setFont('helvetica', 'normal'); doc.setFontSize(8);
doc.text('Present this voucher (printed or on your phone) at the terminal for boarding.', margin + padX, y + 11);
doc.text('Please arrive at least 30 minutes before scheduled departure.', margin + padX, y + 15);
doc.text('Please arrive at least 2 hours before scheduled departure.', margin + padX, y + 15);
return y + cardH + 4;
}

View File

@@ -5,9 +5,6 @@
# Build: DOCKER_BUILDKIT=1 docker compose build
# Run: docker compose up -d
services:
# Message broker for payment event delivery (payment-api -> passenger/freight).
# Management UI: http://localhost:15672 (login edr / edr_secret). vhost: payment.
# In deployed envs this is a shared/managed RabbitMQ; only PAYMENT_RABBITMQ_URL changes.
freight-api:
build:
context: .
@@ -21,26 +18,19 @@ services:
extra_hosts:
- "paymentcallback.triaplc.com:10.18.7.179"
# Standalone GT06 GPS tracker ingester (@edr/gps-tracker). Raw TCP only, no
# HTTP. Writes freight.gps_devices / freight.gps_positions in the shared
# freight DB; the /gps REST API stays in freight-api. Never runs migrations.
gps-tracker:
build:
context: .
dockerfile: apps/edr-gps-tracker/Dockerfile
secrets:
- npmrc
depends_on:
- freight-api
ports:
# Raw TCP — reachable by tracker SIMs. Not HTTP; no L7 proxy can host-route it.
- "${GT06_TCP_PORT:-5023}:5023"
environment:
GT06_TCP_PORT: "5023"
GT06_TCP_HOST: "0.0.0.0"
env_file:
# Reuses the freight DB credentials (DB_HOST/DB_PORT/DB_USER/DB_PASSWORD/DB_NAME).
- apps/edr-freight-api/.env
- apps/edr-gps-tracker/.env
restart: unless-stopped
passenger-api:
@@ -54,17 +44,7 @@ services:
extra_hosts:
- "paymentcallback.triaplc.com:10.18.7.179"
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:4000/health/ready"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
deploy:
resources:
limits:
cpus: '1.0'
memory: 1G
freight-portal:
build:
context: .
@@ -80,6 +60,7 @@ services:
- npmrc
ports:
- "${FREIGHT_PORTAL_PORT:-5173}:80"
freight-backoffice:
build:
context: .
@@ -95,6 +76,7 @@ services:
- npmrc
ports:
- "${FREIGHT_BACKOFFICE_PORT:-5183}:80"
passenger-portal:
build:
context: .
@@ -110,17 +92,7 @@ services:
env_file:
- apps/edr-passenger-web/portal/.env
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:${PASSENGER_PORTAL_PORT:-5174}/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
deploy:
resources:
limits:
cpus: '0.5'
memory: 512M
passenger-backoffice:
build:
context: .
@@ -136,17 +108,6 @@ services:
env_file:
- apps/edr-passenger-web/backoffice/.env
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:${PASSENGER_BACKOFFICE_PORT:-5184}/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
deploy:
resources:
limits:
cpus: '0.5'
memory: 512M
payment-api:
build:

View File

@@ -327,6 +327,8 @@ export interface IWagonMovement extends BaseEntity {
bookingId?: string | null;
kind: WagonMovementKind;
movedByUserId?: string | null;
/** The transfer request this move fulfilled, when it came from one. */
transferRequestId?: string | null;
occurredAt: string;
note?: string | null;
}