diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index 4ccffb6a4..e02391ccd 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -41,6 +41,7 @@ DEFAULT_PASSWORD=password@tria # Freight org + staff (bookings / rule-engine IAM) SEED_EDR_ORG=true SEED_FREIGHT_STAFF=true +SEED_EXPORT_DJIBOUTI_INTERCHANGE_DEMO=false # MinIO (used by @tria-plc/iamapi-common for file storage) MINIO_ENDPOINT=localhost diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index 290ecec69..2fd9f6f5a 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -19,6 +19,9 @@ "seed:freight-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-freight-demo.ts", "seed:warehouse-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-warehouse-demo.ts", "seed:export-djibouti-interchange-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-export-djibouti-interchange-demo.ts", + "seed:import-djibouti-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-import-djibouti-demo.ts", + "seed:approved-first-lastmile-demo-bookings": "ts-node -r tsconfig-paths/register src/scripts/seed-approved-first-lastmile-demo-bookings.ts", + "seed:negad-indode-arrived-train": "ts-node -r tsconfig-paths/register src/scripts/seed-negad-indode-arrived-train.ts", "seed:file-upload-settings": "ts-node -r tsconfig-paths/register src/scripts/seed-file-upload-settings.ts", "seed:fleet-wagons": "bash ../../../docs/new/seeds/seed-fleet-wagons.sh", "iam:typeorm:cli": "cross-env MIGRATIONS_DIR=node_modules/@tria-plc/iamapi-common/dist/db/migrations/*.{ts,js} ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js -d ./node_modules/@tria-plc/api-common/dist/modules/typeorm/typeorm.config.js", diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 3b69d2812..fd9f3af33 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -52,8 +52,10 @@ 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 { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder"; import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder"; +import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder"; //New Trains, Wagons, Container and Cargo management modules import { TrainsModule } from "./modules/trains/trains.module"; import { WagonsModule } from './modules/wagons/wagons.module'; @@ -67,6 +69,7 @@ import { DriversModule } from './modules/drivers/drivers.module'; import { FirstMileModule } from './modules/first-mile/first-mile.module'; import { LastMileModule } from './modules/last-mile/last-mile.module'; import { InterchangeDocumentsModule } from './modules/interchange-documents/interchange-documents.module'; +import { ImportOperationsModule } from './modules/import-operations/import-operations.module'; @Module({ imports: [ @@ -130,6 +133,7 @@ import { InterchangeDocumentsModule } from './modules/interchange-documents/inte FirstMileModule, LastMileModule, InterchangeDocumentsModule, + ImportOperationsModule, ], providers: [ EdrOrgSeeder, @@ -145,6 +149,8 @@ import { InterchangeDocumentsModule } from './modules/interchange-documents/inte Batch7TestDataSeeder, Batch8TestDataSeeder, WarehouseDemoSeeder, + ExportDjiboutiInterchangeDemoSeeder, + ApprovedFirstLastMileDemoBookingsSeeder, ], }) export class AppModule implements OnApplicationBootstrap { @@ -161,6 +167,7 @@ export class AppModule implements OnApplicationBootstrap { private readonly batch7TestDataSeeder: Batch7TestDataSeeder, private readonly batch8TestDataSeeder: Batch8TestDataSeeder, private readonly warehouseDemoSeeder: WarehouseDemoSeeder, + private readonly exportDjiboutiInterchangeDemoSeeder: ExportDjiboutiInterchangeDemoSeeder, private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder, private readonly demoFreightDataSeeder: DemoFreightDataSeeder, ) { } @@ -179,6 +186,7 @@ export class AppModule implements OnApplicationBootstrap { await this.batch7TestDataSeeder.run(); await this.batch8TestDataSeeder.run(); await this.warehouseDemoSeeder.run(); + await this.exportDjiboutiInterchangeDemoSeeder.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, diff --git a/apps/edr-freight-api/src/migrations/1822000000000-CreateImportDjiboutiOperations.ts b/apps/edr-freight-api/src/migrations/1822000000000-CreateImportDjiboutiOperations.ts new file mode 100644 index 000000000..981918f25 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1822000000000-CreateImportDjiboutiOperations.ts @@ -0,0 +1,53 @@ +import { MigrationInterface, QueryRunner, Table, TableForeignKey, TableIndex } from 'typeorm'; + +export class CreateImportDjiboutiOperations1822000000000 implements MigrationInterface { + name = 'CreateImportDjiboutiOperations1822000000000'; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + schema: 'freight', + name: 'import_djibouti_operations', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' }, + { name: 'train_schedule_id', type: 'uuid', isUnique: true }, + { name: 'documents', type: 'jsonb', default: "'{}'::jsonb" }, + { name: 'gatepass_granted_at', type: 'timestamptz', isNullable: true }, + { name: 'ready_for_loading_at', type: 'timestamptz', isNullable: true }, + { name: 'loaded_on_train_at', type: 'timestamptz', isNullable: true }, + { name: 'departed_from_djibouti_at', type: 'timestamptz', isNullable: true }, + { name: 'load_list_generated_at', type: 'timestamptz', isNullable: true }, + { name: 'performed_by', type: 'varchar', length: '120', isNullable: true }, + { name: 'notes', type: 'text', isNullable: true }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + }), + true, + ); + + await queryRunner.createIndex( + 'freight.import_djibouti_operations', + new TableIndex({ + name: 'idx_import_djibouti_operations_schedule', + columnNames: ['train_schedule_id'], + }), + ); + + await queryRunner.createForeignKey( + 'freight.import_djibouti_operations', + new TableForeignKey({ + columnNames: ['train_schedule_id'], + referencedTableName: 'train_schedules', + referencedSchema: 'freight', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable('freight.import_djibouti_operations', true); + } +} diff --git a/apps/edr-freight-api/src/migrations/1823000000000-CreateImportOperationsTables.ts b/apps/edr-freight-api/src/migrations/1823000000000-CreateImportOperationsTables.ts new file mode 100644 index 000000000..1a198e983 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1823000000000-CreateImportOperationsTables.ts @@ -0,0 +1,95 @@ +import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm'; + +export class CreateImportOperationsTables1823000000000 implements MigrationInterface { + name = 'CreateImportOperationsTables1823000000000'; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + schema: 'freight', + name: 'djibouti_import_incidents', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' }, + { name: 'booking_id', type: 'uuid' }, + { name: 'container_number', type: 'varchar', length: '80', isNullable: true }, + { name: 'cargo_id', type: 'uuid', isNullable: true }, + { name: 'facility', type: 'varchar', length: '120', isNullable: true }, + { name: 'station', type: 'varchar', length: '120', isNullable: true }, + { name: 'incident_type', type: 'varchar', length: '40' }, + { name: 'description', type: 'text' }, + { name: 'photos', type: 'jsonb', default: "'[]'::jsonb" }, + { name: 'reported_by', type: 'varchar', length: '120', isNullable: true }, + { name: 'reported_at', type: 'timestamptz' }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + }), + true, + ); + await queryRunner.createIndex('freight.djibouti_import_incidents', new TableIndex({ name: 'idx_djibouti_incidents_booking', columnNames: ['booking_id'] })); + await queryRunner.createIndex('freight.djibouti_import_incidents', new TableIndex({ name: 'idx_djibouti_incidents_container', columnNames: ['container_number'] })); + await queryRunner.createIndex('freight.djibouti_import_incidents', new TableIndex({ name: 'idx_djibouti_incidents_type', columnNames: ['incident_type'] })); + + await queryRunner.createTable( + new Table({ + schema: 'freight', + name: 'import_customs_finalizations', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' }, + { name: 'booking_id', type: 'uuid', isUnique: true }, + { name: 'documents', type: 'jsonb', default: "'{}'::jsonb" }, + { name: 'declaration_serial_number', type: 'varchar', length: '120', isNullable: true }, + { name: 'duties_taxes_notified_at', type: 'timestamptz', isNullable: true }, + { name: 'duties_taxes_paid_at', type: 'timestamptz', isNullable: true }, + { name: 'customs_risk', type: 'varchar', length: '12', isNullable: true }, + { name: 'import_release_permitted_at', type: 'timestamptz', isNullable: true }, + { name: 'completed_at', type: 'timestamptz', isNullable: true }, + { name: 'performed_by', type: 'varchar', length: '120', isNullable: true }, + { name: 'notes', type: 'text', isNullable: true }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + }), + true, + ); + await queryRunner.createIndex('freight.import_customs_finalizations', new TableIndex({ name: 'idx_import_customs_booking', columnNames: ['booking_id'] })); + await queryRunner.createIndex('freight.import_customs_finalizations', new TableIndex({ name: 'idx_import_customs_risk', columnNames: ['customs_risk'] })); + + await queryRunner.createTable( + new Table({ + schema: 'freight', + name: 'empty_container_returns', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' }, + { name: 'container_number', type: 'varchar', length: '80' }, + { name: 'booking_id', type: 'uuid', isNullable: true }, + { name: 'customer_id', type: 'uuid', isNullable: true }, + { name: 'return_date', type: 'timestamptz' }, + { name: 'facility', type: 'varchar', length: '120', isNullable: true }, + { name: 'yard', type: 'varchar', length: '120', isNullable: true }, + { name: 'zone', type: 'varchar', length: '120', isNullable: true }, + { name: 'condition', type: 'text', isNullable: true }, + { name: 'handover_note', type: 'text', isNullable: true }, + { name: 'status', type: 'varchar', length: '40', default: "'RETURNED'" }, + { name: 'wagon_allocation_reference', type: 'varchar', length: '120', isNullable: true }, + { name: 'performed_by', type: 'varchar', length: '120', isNullable: true }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + }), + true, + ); + await queryRunner.createIndex('freight.empty_container_returns', new TableIndex({ name: 'idx_empty_returns_container', columnNames: ['container_number'] })); + await queryRunner.createIndex('freight.empty_container_returns', new TableIndex({ name: 'idx_empty_returns_booking', columnNames: ['booking_id'] })); + await queryRunner.createIndex('freight.empty_container_returns', new TableIndex({ name: 'idx_empty_returns_status', columnNames: ['status'] })); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable('freight.empty_container_returns', true); + await queryRunner.dropTable('freight.import_customs_finalizations', true); + await queryRunner.dropTable('freight.djibouti_import_incidents', true); + } +} diff --git a/apps/edr-freight-api/src/modules/import-operations/dto/import-operations.dto.ts b/apps/edr-freight-api/src/modules/import-operations/dto/import-operations.dto.ts new file mode 100644 index 000000000..1cf4c72e5 --- /dev/null +++ b/apps/edr-freight-api/src/modules/import-operations/dto/import-operations.dto.ts @@ -0,0 +1,188 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsArray, IsDateString, IsIn, IsOptional, IsString, IsUUID } from 'class-validator'; + +import { DJIBOUTI_INCIDENT_TYPES, type DjiboutiIncidentType } from '../entities/djibouti-incident.entity'; +import { + EMPTY_CONTAINER_RETURN_STATUSES, + type EmptyContainerReturnStatus, +} from '../entities/empty-container-return.entity'; +import { + IMPORT_CUSTOMS_RISK_LEVELS, + type ImportCustomsDocumentType, + type ImportCustomsRiskLevel, +} from '../entities/import-customs-finalization.entity'; + +export const IMPORT_CUSTOMS_DOCUMENT_TYPES = [ + 'IM4', + 'IM5', + 'T1_CLOSURE_PROOF', + 'TRANSIT_PERMIT_SCREENSHOT', + 'CUSTOMER_PAYMENT_SLIP', + 'IMPORT_RELEASE_PERMIT', +] as const; + +export class CreateDjiboutiIncidentDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + bookingId!: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + containerNumber?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + cargoId?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + facility?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + station?: string; + + @ApiProperty({ enum: DJIBOUTI_INCIDENT_TYPES }) + @IsIn(DJIBOUTI_INCIDENT_TYPES) + incidentType!: DjiboutiIncidentType; + + @ApiProperty() + @IsString() + description!: string; + + @ApiPropertyOptional({ type: [String] }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + photos?: string[]; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + reportedBy?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsDateString() + reportedAt?: string; +} + +export class UploadImportCustomsDocumentDto { + @ApiProperty({ enum: IMPORT_CUSTOMS_DOCUMENT_TYPES }) + @IsIn(IMPORT_CUSTOMS_DOCUMENT_TYPES) + documentType!: ImportCustomsDocumentType; + + @ApiProperty() + @IsString() + fileId!: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + performedBy?: string; +} + +export class RecordDeclarationDto { + @ApiProperty() + @IsString() + declarationSerialNumber!: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + performedBy?: string; +} + +export class AssignCustomsRiskDto { + @ApiProperty({ enum: IMPORT_CUSTOMS_RISK_LEVELS }) + @IsIn(IMPORT_CUSTOMS_RISK_LEVELS) + risk!: ImportCustomsRiskLevel; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + performedBy?: string; +} + +export class ImportOperationActionDto { + @ApiPropertyOptional() + @IsOptional() + @IsString() + performedBy?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + notes?: string; +} + +export class CreateEmptyContainerReturnDto { + @ApiProperty() + @IsString() + containerNumber!: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + bookingId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + customerId?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsDateString() + returnDate?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + facility?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + yard?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + zone?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + condition?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + handoverNote?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + performedBy?: string; +} + +export class UpdateEmptyContainerReturnStatusDto extends ImportOperationActionDto { + @ApiProperty({ enum: EMPTY_CONTAINER_RETURN_STATUSES }) + @IsIn(EMPTY_CONTAINER_RETURN_STATUSES) + status!: EmptyContainerReturnStatus; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + wagonAllocationReference?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + handoverNote?: string; +} diff --git a/apps/edr-freight-api/src/modules/import-operations/entities/djibouti-incident.entity.ts b/apps/edr-freight-api/src/modules/import-operations/entities/djibouti-incident.entity.ts new file mode 100644 index 000000000..cda4fbef6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/import-operations/entities/djibouti-incident.entity.ts @@ -0,0 +1,50 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index } from 'typeorm'; + +export const DJIBOUTI_INCIDENT_TYPES = [ + 'SEAL_BROKEN', + 'CONTAINER_OPENED', + 'CONTAINER_DAMAGED', + 'FLUID_LEAKING', + 'QUANTITY_MISMATCH', + 'WEIGHT_MISMATCH', + 'OTHER', +] as const; + +export type DjiboutiIncidentType = (typeof DJIBOUTI_INCIDENT_TYPES)[number]; + +@Entity({ schema: 'freight', name: 'djibouti_import_incidents' }) +@Index(['bookingId']) +@Index(['containerNumber']) +@Index(['incidentType']) +export class DjiboutiIncident extends BaseEntity { + @Column({ name: 'booking_id', type: 'uuid' }) + bookingId!: string; + + @Column({ name: 'container_number', type: 'varchar', length: 80, nullable: true }) + containerNumber?: string | null; + + @Column({ name: 'cargo_id', type: 'uuid', nullable: true }) + cargoId?: string | null; + + @Column({ name: 'facility', type: 'varchar', length: 120, nullable: true }) + facility?: string | null; + + @Column({ name: 'station', type: 'varchar', length: 120, nullable: true }) + station?: string | null; + + @Column({ name: 'incident_type', type: 'varchar', length: 40 }) + incidentType!: DjiboutiIncidentType; + + @Column({ name: 'description', type: 'text' }) + description!: string; + + @Column({ name: 'photos', type: 'jsonb', default: () => "'[]'::jsonb" }) + photos!: string[]; + + @Column({ name: 'reported_by', type: 'varchar', length: 120, nullable: true }) + reportedBy?: string | null; + + @Column({ name: 'reported_at', type: 'timestamptz' }) + reportedAt!: Date; +} diff --git a/apps/edr-freight-api/src/modules/import-operations/entities/empty-container-return.entity.ts b/apps/edr-freight-api/src/modules/import-operations/entities/empty-container-return.entity.ts new file mode 100644 index 000000000..727aee322 --- /dev/null +++ b/apps/edr-freight-api/src/modules/import-operations/entities/empty-container-return.entity.ts @@ -0,0 +1,56 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index } from 'typeorm'; + +export const EMPTY_CONTAINER_RETURN_STATUSES = [ + 'RETURNED', + 'ASSIGNED_STORAGE', + 'DOCUMENTATION_CLEARED', + 'WAGON_ALLOCATED', + 'TRANSPORTED_TO_DJIBOUTI', + 'HANDOVER_ISSUED', + 'COMPLETED', +] as const; + +export type EmptyContainerReturnStatus = (typeof EMPTY_CONTAINER_RETURN_STATUSES)[number]; + +@Entity({ schema: 'freight', name: 'empty_container_returns' }) +@Index(['containerNumber']) +@Index(['bookingId']) +@Index(['status']) +export class EmptyContainerReturn extends BaseEntity { + @Column({ name: 'container_number', type: 'varchar', length: 80 }) + containerNumber!: string; + + @Column({ name: 'booking_id', type: 'uuid', nullable: true }) + bookingId?: string | null; + + @Column({ name: 'customer_id', type: 'uuid', nullable: true }) + customerId?: string | null; + + @Column({ name: 'return_date', type: 'timestamptz' }) + returnDate!: Date; + + @Column({ name: 'facility', type: 'varchar', length: 120, nullable: true }) + facility?: string | null; + + @Column({ name: 'yard', type: 'varchar', length: 120, nullable: true }) + yard?: string | null; + + @Column({ name: 'zone', type: 'varchar', length: 120, nullable: true }) + zone?: string | null; + + @Column({ name: 'condition', type: 'text', nullable: true }) + condition?: string | null; + + @Column({ name: 'handover_note', type: 'text', nullable: true }) + handoverNote?: string | null; + + @Column({ name: 'status', type: 'varchar', length: 40, default: 'RETURNED' }) + status!: EmptyContainerReturnStatus; + + @Column({ name: 'wagon_allocation_reference', type: 'varchar', length: 120, nullable: true }) + wagonAllocationReference?: string | null; + + @Column({ name: 'performed_by', type: 'varchar', length: 120, nullable: true }) + performedBy?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/import-operations/entities/import-customs-finalization.entity.ts b/apps/edr-freight-api/src/modules/import-operations/entities/import-customs-finalization.entity.ts new file mode 100644 index 000000000..9d5bcded7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/import-operations/entities/import-customs-finalization.entity.ts @@ -0,0 +1,48 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index } from 'typeorm'; + +export const IMPORT_CUSTOMS_RISK_LEVELS = ['GREEN', 'YELLOW', 'BLUE', 'RED'] as const; +export type ImportCustomsRiskLevel = (typeof IMPORT_CUSTOMS_RISK_LEVELS)[number]; + +export type ImportCustomsDocumentType = + | 'IM4' + | 'IM5' + | 'T1_CLOSURE_PROOF' + | 'TRANSIT_PERMIT_SCREENSHOT' + | 'CUSTOMER_PAYMENT_SLIP' + | 'IMPORT_RELEASE_PERMIT'; + +@Entity({ schema: 'freight', name: 'import_customs_finalizations' }) +@Index(['bookingId'], { unique: true }) +@Index(['customsRisk']) +export class ImportCustomsFinalization extends BaseEntity { + @Column({ name: 'booking_id', type: 'uuid' }) + bookingId!: string; + + @Column({ name: 'documents', type: 'jsonb', default: () => "'{}'::jsonb" }) + documents!: Partial>; + + @Column({ name: 'declaration_serial_number', type: 'varchar', length: 120, nullable: true }) + declarationSerialNumber?: string | null; + + @Column({ name: 'duties_taxes_notified_at', type: 'timestamptz', nullable: true }) + dutiesTaxesNotifiedAt?: Date | null; + + @Column({ name: 'duties_taxes_paid_at', type: 'timestamptz', nullable: true }) + dutiesTaxesPaidAt?: Date | null; + + @Column({ name: 'customs_risk', type: 'varchar', length: 12, nullable: true }) + customsRisk?: ImportCustomsRiskLevel | null; + + @Column({ name: 'import_release_permitted_at', type: 'timestamptz', nullable: true }) + importReleasePermittedAt?: Date | null; + + @Column({ name: 'completed_at', type: 'timestamptz', nullable: true }) + completedAt?: Date | null; + + @Column({ name: 'performed_by', type: 'varchar', length: 120, nullable: true }) + performedBy?: string | null; + + @Column({ name: 'notes', type: 'text', nullable: true }) + notes?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/import-operations/import-operations.controller.ts b/apps/edr-freight-api/src/modules/import-operations/import-operations.controller.ts new file mode 100644 index 000000000..d5e31adf7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/import-operations/import-operations.controller.ts @@ -0,0 +1,110 @@ +import { Body, Controller, Get, Param, ParseUUIDPipe, Post, Query } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { + AssignCustomsRiskDto, + CreateDjiboutiIncidentDto, + CreateEmptyContainerReturnDto, + ImportOperationActionDto, + RecordDeclarationDto, + UpdateEmptyContainerReturnStatusDto, + UploadImportCustomsDocumentDto, +} from './dto/import-operations.dto'; +import { ImportOperationsService } from './import-operations.service'; + +@ApiTags('import-operations') +@ApiBearerAuth() +@Controller('import-operations') +export class ImportOperationsController { + constructor(private readonly service: ImportOperationsService) {} + + @Get('djibouti-incidents') + @ApiOperation({ summary: 'Batch 8: list Djibouti import incidents' }) + listIncidents(@Query('bookingId') bookingId?: string) { + return this.service.listIncidents(bookingId); + } + + @Post('djibouti-incidents') + @ApiOperation({ summary: 'Batch 8: report a Djibouti import incident / exception' }) + createIncident(@Body() dto: CreateDjiboutiIncidentDto) { + return this.service.createIncident(dto); + } + + @Get('customs/:bookingId') + @ApiOperation({ summary: 'Batch 12: import customs finalization state' }) + getCustoms(@Param('bookingId', ParseUUIDPipe) bookingId: string) { + return this.service.getCustoms(bookingId); + } + + @Post('customs/:bookingId/documents') + @ApiOperation({ summary: 'Batch 12: upload IM4/IM5/T1/permit/payment-slip documents' }) + uploadCustomsDocument( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @Body() dto: UploadImportCustomsDocumentDto, + ) { + return this.service.uploadCustomsDocument(bookingId, dto); + } + + @Post('customs/:bookingId/declaration') + @ApiOperation({ summary: 'Batch 12: record declaration serial number' }) + recordDeclaration( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @Body() dto: RecordDeclarationDto, + ) { + return this.service.recordDeclaration(bookingId, dto); + } + + @Post('customs/:bookingId/notify-duties-taxes') + @ApiOperation({ summary: 'Batch 12: notify duties and taxes' }) + notifyDutiesTaxes( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @Body() dto: ImportOperationActionDto, + ) { + return this.service.notifyDutiesTaxes(bookingId, dto); + } + + @Post('customs/:bookingId/duties-taxes-paid') + @ApiOperation({ summary: 'Batch 12: mark duties and taxes paid' }) + markDutiesTaxesPaid( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @Body() dto: ImportOperationActionDto, + ) { + return this.service.markDutiesTaxesPaid(bookingId, dto); + } + + @Post('customs/:bookingId/risk') + @ApiOperation({ summary: 'Batch 12: assign customs risk' }) + assignRisk(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Body() dto: AssignCustomsRiskDto) { + return this.service.assignRisk(bookingId, dto); + } + + @Post('customs/:bookingId/release-permitted') + @ApiOperation({ summary: 'Batch 12: mark import release permitted' }) + markReleasePermitted( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @Body() dto: ImportOperationActionDto, + ) { + return this.service.markReleasePermitted(bookingId, dto); + } + + @Get('empty-container-returns') + @ApiOperation({ summary: 'Batch 16: list empty container returns' }) + listEmptyReturns() { + return this.service.listEmptyReturns(); + } + + @Post('empty-container-returns') + @ApiOperation({ summary: 'Batch 16: create an empty container return record' }) + createEmptyReturn(@Body() dto: CreateEmptyContainerReturnDto) { + return this.service.createEmptyReturn(dto); + } + + @Post('empty-container-returns/:id/status') + @ApiOperation({ summary: 'Batch 16: advance empty container return workflow' }) + updateEmptyReturnStatus( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateEmptyContainerReturnStatusDto, + ) { + return this.service.updateEmptyReturnStatus(id, dto); + } +} diff --git a/apps/edr-freight-api/src/modules/import-operations/import-operations.module.ts b/apps/edr-freight-api/src/modules/import-operations/import-operations.module.ts new file mode 100644 index 000000000..fb4c6e896 --- /dev/null +++ b/apps/edr-freight-api/src/modules/import-operations/import-operations.module.ts @@ -0,0 +1,22 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { DjiboutiIncident } from './entities/djibouti-incident.entity'; +import { EmptyContainerReturn } from './entities/empty-container-return.entity'; +import { ImportCustomsFinalization } from './entities/import-customs-finalization.entity'; +import { ImportOperationsController } from './import-operations.controller'; +import { ImportOperationsService } from './import-operations.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + DjiboutiIncident, + ImportCustomsFinalization, + EmptyContainerReturn, + ]), + ], + controllers: [ImportOperationsController], + providers: [ImportOperationsService], + exports: [ImportOperationsService], +}) +export class ImportOperationsModule {} diff --git a/apps/edr-freight-api/src/modules/import-operations/import-operations.service.ts b/apps/edr-freight-api/src/modules/import-operations/import-operations.service.ts new file mode 100644 index 000000000..bbc1228a5 --- /dev/null +++ b/apps/edr-freight-api/src/modules/import-operations/import-operations.service.ts @@ -0,0 +1,211 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { + CreateDjiboutiIncidentDto, + CreateEmptyContainerReturnDto, + ImportOperationActionDto, + RecordDeclarationDto, + AssignCustomsRiskDto, + UpdateEmptyContainerReturnStatusDto, + UploadImportCustomsDocumentDto, +} from './dto/import-operations.dto'; +import { + DjiboutiIncident, + type DjiboutiIncidentType, +} from './entities/djibouti-incident.entity'; +import { EmptyContainerReturn } from './entities/empty-container-return.entity'; +import { + ImportCustomsFinalization, + type ImportCustomsDocumentType, +} from './entities/import-customs-finalization.entity'; + +const DAMAGE_INCIDENTS: DjiboutiIncidentType[] = [ + 'SEAL_BROKEN', + 'CONTAINER_OPENED', + 'CONTAINER_DAMAGED', + 'FLUID_LEAKING', +]; + +@Injectable() +export class ImportOperationsService { + constructor( + @InjectRepository(DjiboutiIncident) + private readonly incidents: Repository, + @InjectRepository(ImportCustomsFinalization) + private readonly customs: Repository, + @InjectRepository(EmptyContainerReturn) + private readonly emptyReturns: Repository, + ) {} + + listIncidents(bookingId?: string) { + return this.incidents.find({ + where: bookingId ? { bookingId } : {}, + order: { reportedAt: 'DESC', createdAt: 'DESC' } as never, + }); + } + + async createIncident(dto: CreateDjiboutiIncidentDto) { + const photos = dto.photos ?? []; + if (DAMAGE_INCIDENTS.includes(dto.incidentType) && photos.length === 0) { + throw new BadRequestException('Photos are required for damage-related Djibouti incidents'); + } + + const incident = await this.incidents.save( + this.incidents.create({ + bookingId: dto.bookingId, + containerNumber: dto.containerNumber ?? null, + cargoId: dto.cargoId ?? null, + facility: dto.facility ?? null, + station: dto.station ?? null, + incidentType: dto.incidentType, + description: dto.description, + photos, + reportedBy: dto.reportedBy ?? null, + reportedAt: dto.reportedAt ? new Date(dto.reportedAt) : new Date(), + }), + ); + + console.log( + `[NOTIFY] Djibouti incident ${incident.incidentType} for booking ${incident.bookingId}; notify Global Logistics Ethiopia and customer.`, + ); + console.log( + `[MOVEMENT] Attach incident ${incident.id} to booking ${incident.bookingId} movement history.`, + ); + return incident; + } + + async getCustoms(bookingId: string) { + return this.getOrCreateCustoms(bookingId); + } + + async uploadCustomsDocument(bookingId: string, dto: UploadImportCustomsDocumentDto) { + const row = await this.getOrCreateCustoms(bookingId); + const documents = { ...(row.documents ?? {}), [dto.documentType]: dto.fileId }; + await this.customs.update(row.id, { + documents, + performedBy: dto.performedBy ?? row.performedBy ?? null, + }); + return this.getCustoms(bookingId); + } + + async recordDeclaration(bookingId: string, dto: RecordDeclarationDto) { + const row = await this.getOrCreateCustoms(bookingId); + await this.customs.update(row.id, { + declarationSerialNumber: dto.declarationSerialNumber, + performedBy: dto.performedBy ?? row.performedBy ?? null, + }); + return this.getCustoms(bookingId); + } + + async notifyDutiesTaxes(bookingId: string, dto: ImportOperationActionDto = {}) { + const row = await this.getOrCreateCustoms(bookingId); + await this.customs.update(row.id, { + dutiesTaxesNotifiedAt: row.dutiesTaxesNotifiedAt ?? new Date(), + performedBy: dto.performedBy ?? row.performedBy ?? null, + notes: dto.notes ?? row.notes ?? null, + }); + console.log(`[NOTIFY] Duties and taxes notification sent for booking ${bookingId}.`); + return this.getCustoms(bookingId); + } + + async markDutiesTaxesPaid(bookingId: string, dto: ImportOperationActionDto = {}) { + const row = await this.getOrCreateCustoms(bookingId); + this.assertDocument(row, 'CUSTOMER_PAYMENT_SLIP', 'Customer payment slip is required before marking duties and taxes paid'); + await this.customs.update(row.id, { + dutiesTaxesPaidAt: row.dutiesTaxesPaidAt ?? new Date(), + performedBy: dto.performedBy ?? row.performedBy ?? null, + notes: dto.notes ?? row.notes ?? null, + }); + return this.getCustoms(bookingId); + } + + async assignRisk(bookingId: string, dto: AssignCustomsRiskDto) { + const row = await this.getOrCreateCustoms(bookingId); + await this.customs.update(row.id, { + customsRisk: dto.risk, + performedBy: dto.performedBy ?? row.performedBy ?? null, + }); + console.log(`[NOTIFY] Customs risk ${dto.risk} assigned for booking ${bookingId}; notify customer.`); + return this.getCustoms(bookingId); + } + + async markReleasePermitted(bookingId: string, dto: ImportOperationActionDto = {}) { + const row = await this.getOrCreateCustoms(bookingId); + this.assertReleaseReady(row); + await this.customs.update(row.id, { + importReleasePermittedAt: row.importReleasePermittedAt ?? new Date(), + completedAt: row.completedAt ?? new Date(), + performedBy: dto.performedBy ?? row.performedBy ?? null, + notes: dto.notes ?? row.notes ?? null, + }); + console.log(`[NOTIFY] Import release permitted for booking ${bookingId}; notify customer.`); + return this.getCustoms(bookingId); + } + + listEmptyReturns() { + return this.emptyReturns.find({ order: { createdAt: 'DESC' } as never }); + } + + async createEmptyReturn(dto: CreateEmptyContainerReturnDto) { + return this.emptyReturns.save( + this.emptyReturns.create({ + containerNumber: dto.containerNumber, + bookingId: dto.bookingId ?? null, + customerId: dto.customerId ?? null, + returnDate: dto.returnDate ? new Date(dto.returnDate) : new Date(), + facility: dto.facility ?? null, + yard: dto.yard ?? null, + zone: dto.zone ?? null, + condition: dto.condition ?? null, + handoverNote: dto.handoverNote ?? null, + performedBy: dto.performedBy ?? null, + }), + ); + } + + async updateEmptyReturnStatus(id: string, dto: UpdateEmptyContainerReturnStatusDto) { + const row = await this.emptyReturns.findOne({ where: { id } }); + if (!row) { + throw new NotFoundException(`Empty container return ${id} not found`); + } + await this.emptyReturns.update(id, { + status: dto.status, + wagonAllocationReference: dto.wagonAllocationReference ?? row.wagonAllocationReference ?? null, + handoverNote: dto.handoverNote ?? row.handoverNote ?? null, + performedBy: dto.performedBy ?? row.performedBy ?? null, + }); + return this.emptyReturns.findOneOrFail({ where: { id } }); + } + + private async getOrCreateCustoms(bookingId: string) { + const existing = await this.customs.findOne({ where: { bookingId } }); + if (existing) return existing; + return this.customs.save(this.customs.create({ bookingId, documents: {} })); + } + + private assertDocument( + row: ImportCustomsFinalization, + type: ImportCustomsDocumentType, + message: string, + ) { + if (!row.documents?.[type]) { + throw new BadRequestException(message); + } + } + + private assertReleaseReady(row: ImportCustomsFinalization) { + this.assertDocument(row, 'T1_CLOSURE_PROOF', 'T1 closure proof is required before import release'); + this.assertDocument(row, 'IMPORT_RELEASE_PERMIT', 'Import release permit upload is required before release is permitted'); + if (!row.declarationSerialNumber?.trim()) { + throw new BadRequestException('Declaration serial number is required before import release'); + } + if (!row.customsRisk) { + throw new BadRequestException('Customs risk must be assigned before import release'); + } + if (!row.dutiesTaxesPaidAt) { + throw new BadRequestException('Duties and taxes must be paid before import release'); + } + } +} diff --git a/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.service.ts b/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.service.ts index 667f22659..fa84f2d94 100644 --- a/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.service.ts +++ b/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.service.ts @@ -265,12 +265,12 @@ export class InterchangeDocumentsService { ) SELECT a.booking_id AS "bookingId", a.reference AS "bookingReference", - 'CONTAINER' AS "itemType", + 'CONTAINER'::varchar AS "itemType", COALESCE(c.booking_container_id, bc.id) AS "bookingContainerId", - NULL AS "bookingCargoId", + NULL::uuid AS "bookingCargoId", COALESCE(c.container_number, bc.container_number) AS "containerNumber", c.seal_number AS "sealNumber", - NULL AS "cargoId", + NULL::uuid AS "cargoId", a.booking_cargo_type AS "cargoType", a.cargo_free_text AS "cargoDescription", COALESCE(bc.total_vgm_tons, c.max_gross_weight, a.cargo_total_weight_vgm) AS "weight", @@ -288,12 +288,12 @@ export class InterchangeDocumentsService { UNION ALL SELECT a.booking_id AS "bookingId", a.reference AS "bookingReference", - 'CONTAINER' AS "itemType", + 'CONTAINER'::varchar AS "itemType", bc.id AS "bookingContainerId", - NULL AS "bookingCargoId", + NULL::uuid AS "bookingCargoId", bc.container_number AS "containerNumber", - NULL AS "sealNumber", - NULL AS "cargoId", + NULL::varchar AS "sealNumber", + NULL::uuid AS "cargoId", a.booking_cargo_type AS "cargoType", a.cargo_free_text AS "cargoDescription", COALESCE(bc.total_vgm_tons, a.cargo_total_weight_vgm) AS "weight", @@ -314,11 +314,11 @@ export class InterchangeDocumentsService { UNION ALL SELECT a.booking_id AS "bookingId", a.reference AS "bookingReference", - 'CARGO' AS "itemType", - NULL AS "bookingContainerId", + 'CARGO'::varchar AS "itemType", + NULL::uuid AS "bookingContainerId", cg.id AS "bookingCargoId", - NULL AS "containerNumber", - NULL AS "sealNumber", + NULL::varchar AS "containerNumber", + NULL::varchar AS "sealNumber", cg.id AS "cargoId", COALESCE(cgt.cargo_type_name, a.booking_cargo_type) AS "cargoType", COALESCE(cg.description, a.cargo_free_text) AS "cargoDescription", @@ -337,12 +337,12 @@ export class InterchangeDocumentsService { UNION ALL SELECT a.booking_id AS "bookingId", a.reference AS "bookingReference", - CASE WHEN a.booking_cargo_type ILIKE '%container%' THEN 'CONTAINER' ELSE 'CARGO' END AS "itemType", - NULL AS "bookingContainerId", - NULL AS "bookingCargoId", - NULL AS "containerNumber", - NULL AS "sealNumber", - NULL AS "cargoId", + (CASE WHEN a.booking_cargo_type ILIKE '%container%' THEN 'CONTAINER' ELSE 'CARGO' END)::varchar AS "itemType", + NULL::uuid AS "bookingContainerId", + NULL::uuid AS "bookingCargoId", + NULL::varchar AS "containerNumber", + NULL::varchar AS "sealNumber", + NULL::uuid AS "cargoId", a.booking_cargo_type AS "cargoType", a.cargo_free_text AS "cargoDescription", a.cargo_total_weight_vgm AS "weight", diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/import-djibouti-operation.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/import-djibouti-operation.dto.ts new file mode 100644 index 000000000..9bd9f3957 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/import-djibouti-operation.dto.ts @@ -0,0 +1,55 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsIn, IsOptional, IsString } from 'class-validator'; + +export const IMPORT_DJIBOUTI_DOCUMENT_TYPES = [ + 'DELIVERY_ORDER', + 'PORT_INVOICE', + 'DJIBOUTI_T1', + 'ETHIOPIA_T1', + 'RAILWAY_BILL', +] as const; + +export type ImportDjiboutiDocumentType = (typeof IMPORT_DJIBOUTI_DOCUMENT_TYPES)[number]; + +export class UploadImportDjiboutiDocumentDto { + @ApiProperty({ enum: IMPORT_DJIBOUTI_DOCUMENT_TYPES }) + @IsIn(IMPORT_DJIBOUTI_DOCUMENT_TYPES) + documentType!: ImportDjiboutiDocumentType; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + fileId?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + fileUrl?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + reference?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + notes?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + performedBy?: string; +} + +export class ImportDjiboutiActionDto { + @ApiPropertyOptional() + @IsOptional() + @IsString() + performedBy?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + notes?: string; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/entities/import-djibouti-operation.entity.ts b/apps/edr-freight-api/src/modules/train-scheduling/entities/import-djibouti-operation.entity.ts new file mode 100644 index 000000000..792792070 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/entities/import-djibouti-operation.entity.ts @@ -0,0 +1,55 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, OneToOne } from 'typeorm'; + +import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity'; + +export type ImportDjiboutiDocumentType = + | 'DELIVERY_ORDER' + | 'PORT_INVOICE' + | 'DJIBOUTI_T1' + | 'ETHIOPIA_T1' + | 'RAILWAY_BILL'; + +export interface ImportDjiboutiDocumentRecord { + fileId?: string | null; + fileUrl?: string | null; + reference?: string | null; + uploadedAt: string; + uploadedBy?: string | null; + notes?: string | null; +} + +@Entity({ schema: 'freight', name: 'import_djibouti_operations' }) +@Index(['trainScheduleId'], { unique: true }) +export class ImportDjiboutiOperation extends BaseEntity { + @Column({ name: 'train_schedule_id', type: 'uuid' }) + trainScheduleId!: string; + + @OneToOne(() => TrainSchedule) + @JoinColumn({ name: 'train_schedule_id' }) + trainSchedule?: TrainSchedule; + + @Column({ name: 'documents', type: 'jsonb', default: () => "'{}'::jsonb" }) + documents!: Partial>; + + @Column({ name: 'gatepass_granted_at', type: 'timestamptz', nullable: true }) + gatepassGrantedAt?: Date | null; + + @Column({ name: 'ready_for_loading_at', type: 'timestamptz', nullable: true }) + readyForLoadingAt?: Date | null; + + @Column({ name: 'loaded_on_train_at', type: 'timestamptz', nullable: true }) + loadedOnTrainAt?: Date | null; + + @Column({ name: 'departed_from_djibouti_at', type: 'timestamptz', nullable: true }) + departedFromDjiboutiAt?: Date | null; + + @Column({ name: 'load_list_generated_at', type: 'timestamptz', nullable: true }) + loadListGeneratedAt?: Date | null; + + @Column({ name: 'performed_by', type: 'varchar', length: 120, nullable: true }) + performedBy?: string | null; + + @Column({ name: 'notes', type: 'text', nullable: true }) + notes?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts index 08fc74172..1272c8fea 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts @@ -8,9 +8,11 @@ import { Patch, Post, Query, + Res, } from "@nestjs/common"; import { CurrentUser } from "@edr/api-common"; import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; +import type { Response } from "express"; import type { AuthUserPayload } from "../../common/resolve-auth-user-id"; import { resolveAuthUserId } from "../../common/resolve-auth-user-id"; @@ -30,6 +32,10 @@ import { PreviewBulkTrainScheduleDto } from "./dto/preview-bulk-train-schedule.d import { PreviewContainerTrainScheduleDto } from "./dto/preview-container-train-schedule.dto"; import { PreviewTrainScheduleDto } from "./dto/preview-train-schedule.dto"; import { RecordCheckpointDto } from "./dto/record-checkpoint.dto"; +import { + ImportDjiboutiActionDto, + UploadImportDjiboutiDocumentDto, +} from "./dto/import-djibouti-operation.dto"; import { AvailableLocomotivesQueryDto } from "./dto/available-locomotives-query.dto"; import { BookableSchedulesQueryDto } from "./dto/bookable-schedules-query.dto"; import { AvailableDaysQueryDto } from "./dto/available-days-query.dto"; @@ -304,6 +310,101 @@ export class TrainSchedulingController { return this.trainSchedulingService.dispatchSchedule(id); } + @Get("schedules/:id/import-djibouti") + @TrainSchedulingView() + @ApiOperation({ summary: "Batch 7 import Djibouti gatepass/loading status" }) + getImportDjiboutiOperation(@Param("id", ParseUUIDPipe) id: string) { + return this.trainSchedulingService.getImportDjiboutiOperation(id); + } + + @Post("schedules/:id/import-djibouti/documents") + @TrainSchedulingManage() + @ApiOperation({ summary: "Upload/check an import Djibouti-side document" }) + uploadImportDjiboutiDocument( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: UploadImportDjiboutiDocumentDto, + ) { + return this.trainSchedulingService.uploadImportDjiboutiDocument(id, dto); + } + + @Post("schedules/:id/import-djibouti/gatepass-granted") + @TrainSchedulingManage() + @ApiOperation({ summary: "Mark import Djibouti gatepass permission granted" }) + grantImportDjiboutiGatepass( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: ImportDjiboutiActionDto, + ) { + return this.trainSchedulingService.grantImportDjiboutiGatepass(id, dto); + } + + @Post("schedules/:id/import-djibouti/ready-for-loading") + @TrainSchedulingManage() + @ApiOperation({ summary: "Mark import train ready for loading at Djibouti" }) + markImportReadyForLoading( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: ImportDjiboutiActionDto, + ) { + return this.trainSchedulingService.markImportReadyForLoading(id, dto); + } + + @Post("schedules/:id/import-djibouti/loaded-on-train") + @TrainSchedulingManage() + @ApiOperation({ summary: "Confirm import cargo loaded on train at Djibouti" }) + confirmImportLoadedOnTrain( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: ImportDjiboutiActionDto, + ) { + return this.trainSchedulingService.confirmImportLoadedOnTrain(id, dto); + } + + @Post("schedules/:id/import-djibouti/depart") + @TrainSchedulingManage() + @ApiOperation({ summary: "Depart loaded import train from Djibouti" }) + departImportFromDjibouti( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: ImportDjiboutiActionDto, + ) { + return this.trainSchedulingService.departImportFromDjibouti(id, dto); + } + + @Post("schedules/:id/import-djibouti/load-list") + @TrainSchedulingManage() + @ApiOperation({ summary: "Generate import load list / marshalling document summary" }) + generateImportLoadList( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: ImportDjiboutiActionDto, + ) { + return this.trainSchedulingService.generateImportLoadList(id, dto); + } + + @Get("schedules/:id/import-djibouti/load-list/document") + @TrainSchedulingView() + @ApiOperation({ summary: "Download printable import load list / marshalling PDF" }) + async importLoadListDocument( + @Param("id", ParseUUIDPipe) id: string, + @Res() res: Response, + ) { + const { filename, buffer } = await this.trainSchedulingService.importLoadListDocument(id); + res.setHeader("Content-Type", "application/pdf"); + res.setHeader("Content-Disposition", `inline; filename="${filename}"`); + res.setHeader("Content-Length", buffer.length); + return res.send(buffer); + } + + @Get("schedules/:id/export/load-list/document") + @TrainSchedulingView() + @ApiOperation({ summary: "Download printable export marshalling / load list PDF" }) + async exportLoadListDocument( + @Param("id", ParseUUIDPipe) id: string, + @Res() res: Response, + ) { + const { filename, buffer } = await this.trainSchedulingService.exportLoadListDocument(id); + res.setHeader("Content-Type", "application/pdf"); + res.setHeader("Content-Disposition", `inline; filename="${filename}"`); + res.setHeader("Content-Length", buffer.length); + return res.send(buffer); + } + // ---- batch / booking-window staff actions ---- @Post("schedules/:id/run-batch") diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts index 2ed5eaa42..9b8efd9e0 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts @@ -17,6 +17,7 @@ import { WagonTypesModule } from '../wagon-types/wagon-types.module'; import { Wagon } from '../wagons/entities/wagon.entity'; import { WarehousesModule } from '../warehouses/warehouses.module'; import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity'; +import { ImportDjiboutiOperation } from './entities/import-djibouti-operation.entity'; import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity'; import { TrainCheckpointEventsRepository } from './train-checkpoint-events.repository'; import { TrainSchedulingController } from './train-scheduling.controller'; @@ -38,6 +39,7 @@ import { NotificationsModule } from '../notifications/notifications.module'; Container, TrainSchedulingGlobalRules, TrainCheckpointEvent, + ImportDjiboutiOperation, ]), forwardRef(() => BookingsModule), NotificationsModule, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts index f59597aaa..b449669ec 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts @@ -151,6 +151,9 @@ describe('TrainSchedulingService', () => { autoUnloadArrivedBookings: jest.fn(), autoUnloadExportAtDjibouti: jest.fn(), } as never, + { + htmlToPdfBuffer: jest.fn(), + } as never, ); const defaultFleetWagons = [ diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index c56dd6dd1..784a2c6a0 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -49,6 +49,15 @@ import { PreviewBulkTrainScheduleDto } from './dto/preview-bulk-train-schedule.d import { PreviewContainerTrainScheduleDto } from './dto/preview-container-train-schedule.dto'; import { PreviewTrainScheduleDto } from './dto/preview-train-schedule.dto'; import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity'; +import { + ImportDjiboutiOperation, + type ImportDjiboutiDocumentType, +} from './entities/import-djibouti-operation.entity'; +import { + IMPORT_DJIBOUTI_DOCUMENT_TYPES, + ImportDjiboutiActionDto, + UploadImportDjiboutiDocumentDto, +} from './dto/import-djibouti-operation.dto'; import { UpdateTrainSchedulingGlobalRulesDto } from './dto/update-train-scheduling-global-rules.dto'; import { buildCappedWagonPlan, @@ -97,6 +106,7 @@ import { RecordCheckpointDto } from './dto/record-checkpoint.dto'; import { RouteMilestone } from '../routes/entities/route-milestone.entity'; import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; import { WarehouseInventoryService } from '../warehouses/warehouse-inventory.service'; +import { WarehouseReleaseDocumentService } from '../warehouses/warehouse-release-document.service'; import { autoFillPlacements, findMissingContainerNumberIssues, @@ -170,6 +180,7 @@ export class TrainSchedulingService { private readonly trainCheckpointEventsRepository: TrainCheckpointEventsRepository, private readonly trainCompositionRemovalLogRepository: TrainCompositionRemovalLogRepository, private readonly warehouseInventoryService: WarehouseInventoryService, + private readonly pdfDocuments: WarehouseReleaseDocumentService, private readonly configService?: ConfigService, ) {} @@ -767,6 +778,7 @@ export class TrainSchedulingService { if (schedule.status !== TrainScheduleStatusEnum.Scheduled) { throw new BadRequestException('Only SCHEDULED trains can be dispatched'); } + await this.assertImportDjiboutiMayDepart(schedule); const now = new Date(); await this.dataSource.transaction(async (manager) => { @@ -806,9 +818,555 @@ export class TrainSchedulingService { .execute(); }); + if (this.isImportDjiboutiSchedule(schedule)) { + const operation = await this.getOrCreateImportDjiboutiOperation(schedule.id); + await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, { + departedFromDjiboutiAt: operation.departedFromDjiboutiAt ?? now, + }); + console.log( + `[NOTIFY] Import train ${schedule.trainNumber ?? schedule.id} departed Djibouti; notify Ethiopian operations, Global Logistics Ethiopia, Marketing/BD, and customer.`, + ); + } + return this.getTrainScheduleById(scheduleId); } + async getImportDjiboutiOperation(scheduleId: string) { + const schedule = await this.getImportDjiboutiSchedule(scheduleId); + const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId); + return this.mapImportDjiboutiOperation(schedule, operation); + } + + async uploadImportDjiboutiDocument( + scheduleId: string, + dto: UploadImportDjiboutiDocumentDto, + ) { + const schedule = await this.getImportDjiboutiSchedule(scheduleId); + const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId); + const documents = { + ...(operation.documents ?? {}), + [dto.documentType]: { + fileId: dto.fileId ?? null, + fileUrl: dto.fileUrl ?? null, + reference: dto.reference ?? null, + uploadedAt: new Date().toISOString(), + uploadedBy: dto.performedBy ?? null, + notes: dto.notes ?? null, + }, + }; + + await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, { + documents, + performedBy: dto.performedBy ?? operation.performedBy ?? null, + notes: dto.notes ?? operation.notes ?? null, + }); + + return this.getImportDjiboutiOperation(schedule.id); + } + + async grantImportDjiboutiGatepass(scheduleId: string, dto: ImportDjiboutiActionDto = {}) { + const schedule = await this.getImportDjiboutiSchedule(scheduleId); + const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId); + const missing = this.missingImportDjiboutiDocuments(operation); + if (missing.length) { + throw new BadRequestException(`Gatepass cannot be granted until documents are uploaded: ${missing.join(', ')}`); + } + + await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, { + gatepassGrantedAt: operation.gatepassGrantedAt ?? new Date(), + performedBy: dto.performedBy ?? operation.performedBy ?? null, + notes: dto.notes ?? operation.notes ?? null, + }); + + console.log( + `[NOTIFY] Import gatepass granted for train ${schedule.trainNumber ?? schedule.id}; loading may proceed.`, + ); + return this.getImportDjiboutiOperation(schedule.id); + } + + async markImportReadyForLoading(scheduleId: string, dto: ImportDjiboutiActionDto = {}) { + const schedule = await this.getImportDjiboutiSchedule(scheduleId); + const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId); + this.assertImportDjiboutiGatepassGranted(operation); + + await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, { + readyForLoadingAt: operation.readyForLoadingAt ?? new Date(), + performedBy: dto.performedBy ?? operation.performedBy ?? null, + notes: dto.notes ?? operation.notes ?? null, + }); + + return this.getImportDjiboutiOperation(schedule.id); + } + + async confirmImportLoadedOnTrain(scheduleId: string, dto: ImportDjiboutiActionDto = {}) { + const schedule = await this.getImportDjiboutiSchedule(scheduleId); + const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId); + this.assertImportDjiboutiGatepassGranted(operation); + + await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, { + readyForLoadingAt: operation.readyForLoadingAt ?? new Date(), + loadedOnTrainAt: operation.loadedOnTrainAt ?? new Date(), + performedBy: dto.performedBy ?? operation.performedBy ?? null, + notes: dto.notes ?? operation.notes ?? null, + }); + + return this.getImportDjiboutiOperation(schedule.id); + } + + async departImportFromDjibouti(scheduleId: string, dto: ImportDjiboutiActionDto = {}) { + const schedule = await this.getImportDjiboutiSchedule(scheduleId); + const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId); + this.assertImportDjiboutiGatepassGranted(operation); + if (!operation.loadedOnTrainAt) { + throw new BadRequestException('Import train cannot depart Djibouti before loading is confirmed'); + } + + if (schedule.status === TrainScheduleStatusEnum.Scheduled) { + await this.dispatchSchedule(schedule.id); + } else if (schedule.status !== TrainScheduleStatusEnum.Dispatched) { + throw new BadRequestException('Only SCHEDULED or DISPATCHED import trains can be departed from Djibouti'); + } + await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, { + departedFromDjiboutiAt: operation.departedFromDjiboutiAt ?? new Date(), + performedBy: dto.performedBy ?? operation.performedBy ?? null, + notes: dto.notes ?? operation.notes ?? null, + }); + return this.getImportDjiboutiOperation(schedule.id); + } + + async generateImportLoadList(scheduleId: string, dto: ImportDjiboutiActionDto = {}) { + const schedule = await this.getImportDjiboutiSchedule(scheduleId); + const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId); + const generatedAt = operation.loadListGeneratedAt ?? new Date(); + + await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, { + loadListGeneratedAt: generatedAt, + performedBy: dto.performedBy ?? operation.performedBy ?? null, + notes: dto.notes ?? operation.notes ?? null, + }); + + return { + generatedAt: generatedAt.toISOString(), + trainScheduleId: schedule.id, + trainNumber: schedule.trainNumber ?? null, + route: schedule.route?.name ?? null, + origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null, + destination: schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null, + totalBookings: schedule.scheduleBookings?.length ?? 0, + wagons: (schedule.trainSet?.wagons ?? []).map((wagon) => ({ + sequenceNo: wagon.sequenceNo, + wagonNumber: wagon.physicalWagon?.wagonNumber ?? null, + allocations: (wagon.allocations ?? []).map((allocation) => ({ + bookingId: allocation.bookingId, + bookingReference: allocation.booking?.reference ?? null, + loadType: allocation.loadType ?? null, + allocatedWeightTons: Number(allocation.allocatedWeightTons) || 0, + containerNumbers: (allocation.containerItems ?? []) + .map((item) => item.containerNumber) + .filter(Boolean), + })), + })), + operation: await this.getImportDjiboutiOperation(schedule.id), + }; + } + + async importLoadListDocument(scheduleId: string): Promise<{ filename: string; buffer: Buffer }> { + const loadList = await this.generateImportLoadList(scheduleId, { + performedBy: 'DOCUMENT_GENERATION', + }); + const html = this.buildImportLoadListHtml(loadList); + const buffer = await this.pdfDocuments.htmlToPdfBuffer(html); + const reference = loadList.trainNumber ?? loadList.trainScheduleId; + return { + filename: `import-marshalling-${this.safeDocumentName(reference)}.pdf`, + buffer, + }; + } + + async exportLoadListDocument(scheduleId: string): Promise<{ filename: string; buffer: Buffer }> { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + if (!this.isExportSchedule(schedule)) { + throw new BadRequestException('Export marshalling document applies only to EXPORT schedules'); + } + + const html = this.buildExportLoadListHtml(schedule); + const buffer = await this.pdfDocuments.htmlToPdfBuffer(html); + const reference = schedule.trainNumber ?? schedule.id; + return { + filename: `export-marshalling-${this.safeDocumentName(reference)}.pdf`, + buffer, + }; + } + + private buildExportLoadListHtml(schedule: TrainSchedule): string { + const esc = (value: unknown) => + String(value ?? '-') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + const date = (value: unknown) => (value ? new Date(value as string | Date).toLocaleDateString('en-GB') : '-'); + const time = (value: unknown) => (value ? new Date(value as string | Date).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }) : '-'); + const bookingById = new Map((schedule.scheduleBookings ?? []).map((link) => [link.bookingId, link.booking])); + const rows = (schedule.trainSet?.wagons ?? []) + .flatMap((wagon) => + (wagon.allocations ?? []).map((allocation) => { + const booking = allocation.booking ?? bookingById.get(allocation.bookingId); + const company = booking?.company as Record | null | undefined; + const cargoType = (booking as unknown as { cargoType?: { name?: string; code?: string } } | undefined)?.cargoType; + const containerItems = allocation.containerItems ?? []; + const firstContainer = containerItems[0]; + const containerNumbers = containerItems.map((item) => item.containerNumber).filter(Boolean).join(', '); + const sealNumbers = containerItems.map((item) => item.sealNumber).filter(Boolean).join(', '); + const chassisNumbers = containerItems.map((item) => item.chassisNumber).filter(Boolean).join(', '); + return ` + ${esc(wagon.sequenceNo)} + ${esc(wagon.physicalWagon?.wagonNumber)} + ${esc(wagon.wagonType?.code ?? wagon.wagonType?.name)} + ${esc(Number(wagon.lengthMeters || 0).toFixed(3))} + ${esc(Number(wagon.physicalWagon?.tareWeight ?? 0).toFixed(2))} + ${esc(Number(wagon.capacityTons || 0).toFixed(3))} + ${esc(company?.name ?? company?.legalName ?? company?.tradeName ?? booking?.companyId)} + ${esc(booking?.companyId)} + ${esc(cargoType?.name ?? cargoType?.code ?? allocation.loadType)} + ${esc(containerNumbers || firstContainer?.containerNumber)} + ${esc(chassisNumbers)} + ${esc(sealNumbers)} + `; + }), + ) + .join(''); + const totalWeight = (schedule.trainSet?.wagons ?? []).reduce( + (sum, wagon) => + sum + (wagon.allocations ?? []).reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0), + 0, + ); + + return ` + + + + Export Marshalling Document + + + +
+
+
Ethio-Djibouti Railway S.C.
+

Export Marshalling Document / Load List

+
+
+ Train / Schedule + ${esc(schedule.trainNumber ?? schedule.id)} + Generated: ${esc(new Date().toLocaleString('en-GB'))} +
+
+ +
+
Train ID${esc(schedule.trainNumber ?? schedule.id)}
+
Departure date${esc(date(schedule.scheduledDepartureDate))}
+
Departure time${esc(time(schedule.scheduledDepartureDate))}
+
Departure station${esc(schedule.originStation?.label ?? schedule.originStation?.code)}
+
Arrival station${esc(schedule.destinationStation?.label ?? schedule.destinationStation?.code)}
+
Total loaded weight${esc(totalWeight.toFixed(3))} T
+
Prepared person${esc(schedule.preparedByUserId)}
+
Check person${esc(schedule.checkedByUserId)}
+
Wagons${esc(schedule.trainSet?.wagons?.length ?? 0)}
+
Bookings${esc(schedule.scheduleBookings?.length ?? 0)}
+
Status${esc(schedule.status)}
+
Direction${esc(schedule.direction)}
+
+ + + + + + + + + + + + + + + + + + + + ${rows || ''} + +
SeqWagon NoWagon TypeEquated LengthTare WeightLoad CapacityCustomer NameCustomer IDCargo TypeContainer NoChassis NoSeal No
No wagon allocations found for this export train.
+ +
+ Loading and dispatch staff must verify wagon identity, seal number, container number, + cargo type, and customer booking against the physical consist before departure. +
+ +
+
Prepared person / date
+
Check person / date
+
Operations authorization / date
+
+ +`; + } + + private isExportSchedule(schedule: TrainSchedule): boolean { + const direction = + (schedule.direction as 'IMPORT' | 'EXPORT' | 'DOMESTIC' | null) ?? + (schedule.originStation && schedule.destinationStation + ? deriveScheduleDirection(schedule.originStation, schedule.destinationStation) + : null); + return direction === 'EXPORT'; + } + + private buildImportLoadListHtml(loadList: Awaited>): string { + const esc = (value: unknown) => + String(value ?? '-') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + const date = (value: unknown) => (value ? new Date(value as string | Date).toLocaleString('en-GB') : '-'); + const status = loadList.operation.status; + const totalAllocations = loadList.wagons.reduce((sum, wagon) => sum + wagon.allocations.length, 0); + const totalWeight = loadList.wagons.reduce( + (sum, wagon) => + sum + wagon.allocations.reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0), + 0, + ); + const allocationRows = loadList.wagons + .flatMap((wagon) => + wagon.allocations.map( + (allocation) => ` + ${esc(wagon.sequenceNo)} + ${esc(wagon.wagonNumber)} + ${esc(allocation.bookingReference ?? allocation.bookingId)} + ${esc(allocation.loadType)} + ${esc(allocation.containerNumbers.length ? allocation.containerNumbers.join(', ') : '-')} + ${esc(Number(allocation.allocatedWeightTons || 0).toFixed(3))} + `, + ), + ) + .join(''); + + return ` + + + + Import Load List / Marshalling Document + + + +
+
+
+
Ethio-Djibouti Railway S.C.
+

Import Load List /
Marshalling Document

+
Djibouti-side gatepass, loading, and departure manifest
+
+
+ Train / Schedule + ${esc(loadList.trainNumber ?? loadList.trainScheduleId)} + Generated: ${esc(date(loadList.generatedAt))} +
+
+ +
+
Route${esc(loadList.route)}
+
Origin${esc(loadList.origin)}
+
Destination${esc(loadList.destination)}
+
Total bookings${esc(loadList.totalBookings)}
+
Wagons${esc(loadList.wagons.length)}
+
Allocations${esc(totalAllocations)}
+
Total weight${esc(totalWeight.toFixed(3))} T
+
Gatepass granted${esc(date(loadList.operation.gatepassGrantedAt))}
+
+ +
+
Documents
+
Gatepass
+
Ready
+
Loaded
+
Departed
+
Document
+
+ +

Wagon Marshalling Allocation

+ + + + + + + + + + + + + ${allocationRows || ''} + +
SeqWagonBookingLoadContainer numbersWeight T
No wagon allocations found for this train.
+ +
+ Gate and loading staff must verify this document against the granted gatepass, + railway bill, T1 documents, wagon placement, container numbers, and physical train consist before departure. +
+ +
+
Prepared by Djibouti operations
+
Train loading supervisor
+
EDR operations authorization
+
+ + +
+ +`; + } + + private safeDocumentName(value: string): string { + return value.replace(/[^a-zA-Z0-9_-]+/g, '-'); + } + + private async assertImportDjiboutiMayDepart(schedule: TrainSchedule): Promise { + if (!this.isImportDjiboutiSchedule(schedule)) return; + const operation = await this.dataSource.getRepository(ImportDjiboutiOperation).findOne({ + where: { trainScheduleId: schedule.id }, + }); + this.assertImportDjiboutiGatepassGranted(operation); + if (!operation?.loadedOnTrainAt) { + throw new BadRequestException('Import train cannot depart Djibouti before loading is confirmed'); + } + } + + private async getImportDjiboutiSchedule(scheduleId: string): Promise { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + if (!this.isImportDjiboutiSchedule(schedule)) { + throw new BadRequestException('Batch 7 actions apply only to IMPORT schedules originating from Djibouti'); + } + return schedule; + } + + private isImportDjiboutiSchedule(schedule: TrainSchedule): boolean { + const direction = + (schedule.direction as 'IMPORT' | 'EXPORT' | 'DOMESTIC' | null) ?? + (schedule.originStation && schedule.destinationStation + ? deriveScheduleDirection(schedule.originStation, schedule.destinationStation) + : null); + return ( + direction === 'IMPORT' && + this.isDjiboutiPortDestination( + `${schedule.originStation?.code ?? ''} ${schedule.originStation?.label ?? ''}`, + ) + ); + } + + private async getOrCreateImportDjiboutiOperation(scheduleId: string): Promise { + const repo = this.dataSource.getRepository(ImportDjiboutiOperation); + const existing = await repo.findOne({ where: { trainScheduleId: scheduleId } }); + if (existing) return existing; + return repo.save(repo.create({ trainScheduleId: scheduleId, documents: {} })); + } + + private missingImportDjiboutiDocuments(operation?: ImportDjiboutiOperation | null): ImportDjiboutiDocumentType[] { + const documents = operation?.documents ?? {}; + return IMPORT_DJIBOUTI_DOCUMENT_TYPES.filter((type) => !documents[type]); + } + + private assertImportDjiboutiGatepassGranted(operation?: ImportDjiboutiOperation | null): void { + if (!operation?.gatepassGrantedAt) { + throw new BadRequestException('Import loading is blocked until Djibouti gatepass is granted'); + } + } + + private mapImportDjiboutiOperation(schedule: TrainSchedule, operation: ImportDjiboutiOperation) { + const missingDocuments = this.missingImportDjiboutiDocuments(operation); + return { + trainScheduleId: schedule.id, + trainNumber: schedule.trainNumber ?? null, + direction: schedule.direction ?? null, + status: { + documentsComplete: missingDocuments.length === 0, + missingDocuments, + gatepassGranted: Boolean(operation.gatepassGrantedAt), + readyForLoading: Boolean(operation.readyForLoadingAt), + loadedOnTrain: Boolean(operation.loadedOnTrainAt), + departedFromDjibouti: Boolean(operation.departedFromDjiboutiAt), + loadListGenerated: Boolean(operation.loadListGeneratedAt), + }, + documents: operation.documents ?? {}, + gatepassGrantedAt: operation.gatepassGrantedAt ?? null, + readyForLoadingAt: operation.readyForLoadingAt ?? null, + loadedOnTrainAt: operation.loadedOnTrainAt ?? null, + departedFromDjiboutiAt: operation.departedFromDjiboutiAt ?? null, + loadListGeneratedAt: operation.loadListGeneratedAt ?? null, + performedBy: operation.performedBy ?? null, + notes: operation.notes ?? null, + }; + } + /** * Assign a fixed train number on dispatch. The number is drawn from the pool * for the train's dominant cargo type (container vs bulk) and trade direction diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/bulk-receive.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/bulk-receive.dto.ts index 2feadaccd..5e31c8593 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/bulk-receive.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/bulk-receive.dto.ts @@ -1,5 +1,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; import { ArrayNotEmpty, IsArray, IsIn, IsNumber, IsOptional, IsString, IsUUID, Min } from 'class-validator'; +import { ValidateNested } from 'class-validator'; export class TruckEntranceDto { @ApiPropertyOptional() @@ -22,6 +24,11 @@ export class TruckEntranceDto { @IsString() tin?: string; + @ApiPropertyOptional() + @IsOptional() + @IsString() + customerPhone?: string; + @ApiProperty() @IsString() truckPlateNumber!: string; @@ -174,8 +181,11 @@ export class BulkReceiveDto { @IsUUID('all', { each: true }) bookingIds!: string[]; - @ApiProperty({ type: TruckEntranceDto }) - truckEntrance!: TruckEntranceDto; + @ApiPropertyOptional({ type: TruckEntranceDto }) + @IsOptional() + @ValidateNested() + @Type(() => TruckEntranceDto) + truckEntrance?: TruckEntranceDto; @ApiPropertyOptional() @IsOptional() diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/invoice.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/invoice.dto.ts index 9c62aeae5..6d7084a96 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/invoice.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/invoice.dto.ts @@ -33,4 +33,14 @@ export class PayInvoiceBodyDto { @IsOptional() @IsString() reference?: string; + + @ApiPropertyOptional({ description: 'Pickup driver name to notify after payment' }) + @IsOptional() + @IsString() + driverName?: string; + + @ApiPropertyOptional({ description: 'Pickup driver phone to notify after payment' }) + @IsOptional() + @IsString() + driverPhone?: string; } diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/receive-inventory.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/receive-inventory.dto.ts index e8a73190c..e4df4ce5a 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/receive-inventory.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/receive-inventory.dto.ts @@ -1,5 +1,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; import { IsNumber, IsOptional, IsString, IsUUID, Min } from 'class-validator'; +import { ValidateNested } from 'class-validator'; import { TruckEntranceDto } from './bulk-receive.dto'; export class ReceiveWarehouseInventoryDto { @@ -57,6 +59,8 @@ export class ReceiveWarehouseInventoryDto { notes?: string; @ApiProperty({ type: TruckEntranceDto }) + @ValidateNested() + @Type(() => TruckEntranceDto) truckEntrance!: TruckEntranceDto; @ApiPropertyOptional() diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/release-order.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/release-order.dto.ts index 9d4e3eb4f..681b30284 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/release-order.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/release-order.dto.ts @@ -1,5 +1,5 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; -import { IsDateString, IsOptional, IsString } from 'class-validator'; +import { IsDateString, IsNumber, IsOptional, IsString, Min } from 'class-validator'; /** Records a DO / release order being sent to the customer for import pickup. */ export class ReleaseOrderDto { @@ -17,4 +17,77 @@ export class ReleaseOrderDto { @IsOptional() @IsString() performedBy?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + bookingId?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + customerId?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + truckPlateNumber?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + trailerPlateNumber?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + driverName?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + driverLicense?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + driverPhone?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + truckType?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + containerNumber?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsDateString() + gateInTime?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + @Min(0) + tareWeight?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + @Min(0) + grossWeight?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + @Min(0) + netWeight?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsDateString() + gateOutTime?: string; } diff --git a/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts b/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts index 7d73dc6ef..ca16bcaa5 100644 --- a/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts +++ b/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts @@ -329,7 +329,7 @@ export class SchedulingReadFacade { } if (filter.destination) { params.push(`%${filter.destination}%`); - where.push(`(dy.code ILIKE $${params.length} OR dy.name ILIKE $${params.length})`); + where.push(`(dy.code ILIKE $${params.length} OR dy.label ILIKE $${params.length})`); } if (filter.dateFrom) { params.push(filter.dateFrom); @@ -351,7 +351,7 @@ export class SchedulingReadFacade { ts.train_number AS "trainNumber", oy.code AS "origin", dy.code AS "destination", - dy.name AS "destinationName", + dy.label AS "destinationName", oy.country AS "originCountry", dy.country AS "destinationCountry", ts.scheduled_departure_date AS "departureTime", diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts index 230192a91..204d0f0d4 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts @@ -86,6 +86,12 @@ export class WarehouseInventoryController { return this.inventoryService.readyToLoadExport(); } + @Get('received-export') + @ApiOperation({ summary: 'EXPORT inventory that has been received and is awaiting inspection' }) + receivedExport() { + return this.inventoryService.receivedExport(); + } + @Get('loaded-export') @ApiOperation({ summary: 'EXPORT inventory that is LOADED and queued for dispatch' }) loadedExport() { diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index ab3adb64a..2bfddbe37 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -1,4 +1,4 @@ -import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { Between, DataSource, EntityManager, FindManyOptions, ILike, LessThanOrEqual, MoreThanOrEqual } from 'typeorm'; import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; @@ -6,6 +6,7 @@ import { Cargo } from '../cargoes/entities/cargoes.entity'; import { InterchangeDocumentsService } from '../interchange-documents/interchange-documents.service'; import type { InterchangeDocument } from '../interchange-documents/entities/interchange-document.entity'; import { LastMileService } from '../last-mile/last-mile.service'; +import { NotificationsService } from '../notifications/notifications.service'; import { BulkInspectDto } from './dto/bulk-inspect.dto'; import { BulkReceiveDto, TruckEntranceDto } from './dto/bulk-receive.dto'; import { DeliverInventoryDto } from './dto/deliver-inventory.dto'; @@ -191,6 +192,13 @@ export interface EligibleBookingRow { reference: string; customerId: string | null; customer: string | null; + customerTin: string | null; + customerPhone: string | null; + containerNumber: string | null; + containerQuantity: number | null; + containerPackagingType: string | null; + cargoDescription: string | null; + lastMileRequested: boolean; direction: string; origin: string | null; destination: string | null; @@ -205,6 +213,10 @@ export interface EligibleBookingRow { firstMileVehicleId: string | null; firstMileTruckPlateNumber: string | null; firstMileTrailerPlateNumber: string | null; + firstMileDriverName: string | null; + firstMileDriverPhone: string | null; + firstMileDriverLicenseNumber: string | null; + firstMileTruckType: string | null; } export interface BulkReceiveResult { @@ -289,6 +301,8 @@ export interface ImportUnloadedRow { @Injectable() export class WarehouseInventoryService { + private readonly logger = new Logger(WarehouseInventoryService.name); + constructor( private readonly dataSource: DataSource, private readonly inventoryRepository: WarehouseInventoryRepository, @@ -301,6 +315,7 @@ export class WarehouseInventoryService { private readonly releaseDocuments: WarehouseReleaseDocumentService, private readonly interchangeDocuments: InterchangeDocumentsService, private readonly lastMileService: LastMileService, + private readonly notifications: NotificationsService, ) {} /** @@ -644,12 +659,20 @@ export class WarehouseInventoryService { b.reference AS "reference", b.company_id AS "customerId", company.name AS "customer", + company.tin AS "customerTin", + COALESCE(company.contact_person_phone, company.phone, company.general_manager_phone, company.etrade_phone) AS "customerPhone", + bc.container_numbers AS "containerNumber", + bc.container_quantity AS "containerQuantity", + bc.container_packaging_type AS "containerPackagingType", + (NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL + OR COALESCE(st.includes_last_mile, false)) AS "lastMileRequested", oy.code AS "origin", dy.code AS "destination", oy.country AS "originCountry", dy.country AS "destinationCountry", b.freight_type AS "freightType", COALESCE(ct.cargo_type_name, b.cargo_free_text) AS "cargo", + COALESCE(ct.cargo_type_name, b.cargo_free_text) AS "cargoDescription", b.cargo_total_weight_vgm AS "weight", b.payment_status AS "paymentStatus", b.status AS "status", @@ -659,7 +682,14 @@ export class WarehouseInventoryService { fm.status AS "firstMileStatus", fm.vehicle_id AS "firstMileVehicleId", v.plate_number AS "firstMileTruckPlateNumber", - v.trailer_plate_no AS "firstMileTrailerPlateNumber" + v.trailer_plate_no AS "firstMileTrailerPlateNumber", + COALESCE( + NULLIF(TRIM(CONCAT(COALESCE(driver.first_name, ''), ' ', COALESCE(driver.last_name, ''))), ''), + v.assigned_driver_name + ) AS "firstMileDriverName", + driver.phone_number AS "firstMileDriverPhone", + driver.license_number AS "firstMileDriverLicenseNumber", + v.vehicle_type AS "firstMileTruckType" FROM freight.bookings b LEFT JOIN freight.companies company ON company.id = b.company_id LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id @@ -667,6 +697,22 @@ export class WarehouseInventoryService { LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id LEFT JOIN freight.service_types st ON st.id = b.service_type_id LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL + LEFT JOIN LATERAL ( + SELECT string_agg(NULLIF(booking_container.container_number, ''), ', ' ORDER BY booking_container.container_number) AS container_numbers, + SUM(booking_container.quantity)::int AS container_quantity, + CASE + WHEN COUNT(booking_container.id) = 0 THEN NULL + WHEN bool_or(container_type.is_reefer) THEN 'REEFER_CONTAINER' + WHEN bool_or(container_type.is_open_top) THEN 'OPEN_TOP_CONTAINER' + WHEN MIN(container_type.size_ft) = 20 THEN 'CONTAINER_20FT' + WHEN MIN(container_type.size_ft) = 40 THEN 'CONTAINER_40FT' + WHEN MIN(container_type.size_ft) = 45 THEN 'CONTAINER_45FT' + ELSE 'OTHER_CONTAINER' + END AS container_packaging_type + FROM freight.booking_container booking_container + LEFT JOIN freight.container_types container_type ON container_type.id = booking_container.container_type_id + WHERE booking_container.booking_id = b.id AND booking_container.deleted_at IS NULL + ) bc ON true LEFT JOIN LATERAL ( SELECT first_mile.id, first_mile.status, first_mile.vehicle_id FROM freight.first_mile first_mile @@ -675,6 +721,7 @@ export class WarehouseInventoryService { LIMIT 1 ) fm ON true LEFT JOIN freight.vehicles v ON v.id = fm.vehicle_id + LEFT JOIN freight.drivers driver ON driver.id = v.assigned_driver_id WHERE b.deleted_at IS NULL AND b.payment_status = 'PAID' AND inv.id IS NULL @@ -695,7 +742,6 @@ export class WarehouseInventoryService { /** Bulk-receive eligible PAID bookings into a location. Skips duplicates / wrong direction. */ async bulkReceive(dto: BulkReceiveDto): Promise { const result: BulkReceiveResult = { receivedCount: 0, skippedCount: 0, results: [] }; - this.assertTruckEntrance(dto.truckEntrance); await this.dataSource.transaction(async (manager) => { await this.validateLocation(manager, { @@ -711,23 +757,61 @@ export class WarehouseInventoryService { }; const [booking] = await manager.query( - `SELECT b.payment_status AS "paymentStatus", b.cargo_total_weight_vgm AS "weight", + `SELECT b.reference AS "reference", + b.payment_status AS "paymentStatus", + b.cargo_total_weight_vgm AS "weight", + company.name AS "customer", + company.tin AS "customerTin", + COALESCE(company.contact_person_phone, company.phone, company.general_manager_phone, company.etrade_phone) AS "customerPhone", + bc.container_numbers AS "containerNumber", + bc.container_quantity AS "containerQuantity", + bc.container_packaging_type AS "containerPackagingType", + COALESCE(ct.cargo_type_name, b.cargo_free_text) AS "cargoDescription", oy.country AS "originCountry", dy.country AS "destinationCountry", (NULLIF(TRIM(COALESCE(b.first_mile_pickup_address, '')), '') IS NOT NULL OR COALESCE(st.includes_first_mile, false)) AS "hasFirstMile", fm.id AS "firstMileRequestId", - fm.status AS "firstMileStatus" + fm.status AS "firstMileStatus", + v.plate_number AS "firstMileTruckPlateNumber", + v.trailer_plate_no AS "firstMileTrailerPlateNumber", + COALESCE( + NULLIF(TRIM(CONCAT(COALESCE(driver.first_name, ''), ' ', COALESCE(driver.last_name, ''))), ''), + v.assigned_driver_name + ) AS "firstMileDriverName", + driver.phone_number AS "firstMileDriverPhone", + driver.license_number AS "firstMileDriverLicenseNumber", + v.vehicle_type AS "firstMileTruckType" FROM freight.bookings b + LEFT JOIN freight.companies company ON company.id = b.company_id LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id LEFT JOIN freight.service_types st ON st.id = b.service_type_id + LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id LEFT JOIN LATERAL ( - SELECT first_mile.id, first_mile.status + SELECT string_agg(NULLIF(booking_container.container_number, ''), ', ' ORDER BY booking_container.container_number) AS container_numbers, + SUM(booking_container.quantity)::int AS container_quantity, + CASE + WHEN COUNT(booking_container.id) = 0 THEN NULL + WHEN bool_or(container_type.is_reefer) THEN 'REEFER_CONTAINER' + WHEN bool_or(container_type.is_open_top) THEN 'OPEN_TOP_CONTAINER' + WHEN MIN(container_type.size_ft) = 20 THEN 'CONTAINER_20FT' + WHEN MIN(container_type.size_ft) = 40 THEN 'CONTAINER_40FT' + WHEN MIN(container_type.size_ft) = 45 THEN 'CONTAINER_45FT' + ELSE 'OTHER_CONTAINER' + END AS container_packaging_type + FROM freight.booking_container booking_container + LEFT JOIN freight.container_types container_type ON container_type.id = booking_container.container_type_id + WHERE booking_container.booking_id = b.id AND booking_container.deleted_at IS NULL + ) bc ON true + LEFT JOIN LATERAL ( + SELECT first_mile.id, first_mile.status, first_mile.vehicle_id FROM freight.first_mile first_mile WHERE first_mile.booking_id = b.id AND first_mile.deleted_at IS NULL ORDER BY first_mile.created_at DESC LIMIT 1 ) fm ON true + LEFT JOIN freight.vehicles v ON v.id = fm.vehicle_id + LEFT JOIN freight.drivers driver ON driver.id = v.assigned_driver_id WHERE b.id = $1 AND b.deleted_at IS NULL LIMIT 1`, [bookingId], ); @@ -758,11 +842,17 @@ export class WarehouseInventoryService { const now = new Date(); const grnNumber = this.generateGrnNumber(dto.direction, bookingId, now); + const truckEntrance = dto.truckEntrance + ? this.mergeSystemTruckEntrance(dto.truckEntrance, booking) + : undefined; + if (dto.direction === 'EXPORT') { + this.assertTruckEntrance(truckEntrance); + } const receiveNote = this.buildReceiveNote({ grnNumber, direction: dto.direction, notes: `Bulk received (${dto.direction})`, - truckEntrance: dto.truckEntrance, + truckEntrance, }); const saved = await manager.getRepository(WarehouseInventory).save( manager.getRepository(WarehouseInventory).create({ @@ -770,7 +860,7 @@ export class WarehouseInventoryService { yardId: dto.yardId, zoneId: dto.zoneId, bookingId, - quantity: 1, + quantity: Number(booking.containerQuantity) || 1, weight: Number(booking.weight) || 0, status: 'RECEIVED', arrivedAt: now, @@ -783,12 +873,23 @@ export class WarehouseInventoryService { activityType: 'INVENTORY_RECEIVED', inventoryId: saved.id, warehouseId: dto.warehouseId, - description: `GRN ${grnNumber}: bulk received ${dto.direction} booking via truck ${dto.truckEntrance.truckPlateNumber}`, + description: truckEntrance?.truckPlateNumber + ? `GRN ${grnNumber}: bulk received ${dto.direction} booking via truck ${truckEntrance.truckPlateNumber}` + : `GRN ${grnNumber}: bulk received ${dto.direction} booking`, performedBy: dto.performedBy, }, manager, ); + await this.notifyOwnerInventoryReceived({ + phone: truckEntrance?.customerPhone ?? booking.customerPhone, + ownerName: truckEntrance?.ownerName ?? booking.customer, + bookingReference: truckEntrance?.edrDigitalBookingId ?? booking.reference, + grnNumber, + direction: dto.direction, + warehouseId: dto.warehouseId, + }); + result.receivedCount += 1; result.results.push({ bookingId, status: 'RECEIVED', inventoryId: saved.id, grnNumber }); } @@ -887,6 +988,11 @@ export class WarehouseInventoryService { return this.exportInventoryByStatus('READY_FOR_LOADING', true); } + /** EXPORT inventory received at the facility and awaiting inspection. */ + async receivedExport(): Promise { + return this.exportInventoryByStatus('RECEIVED'); + } + /** EXPORT inventory that has been loaded onto a wagon and is queued for dispatch (LOADED). */ async loadedExport(): Promise { return this.exportInventoryByStatus('LOADED'); @@ -1166,7 +1272,7 @@ export class WarehouseInventoryService { oy.country AS "originCountry", dy.country AS "destinationCountry", dy.code AS "destinationCode", - dy.name AS "destinationName" + dy.label AS "destinationName" 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 @@ -1307,6 +1413,20 @@ export class WarehouseInventoryService { } const currentStatus = item.inventoryStatus ?? item.bookingStatus; + if (currentStatus === 'UNLOADED_AT_DJIBOUTI_PORT') { + seenInventory.add(item.inventoryId); + result.unloadedCount += 1; + result.results.push({ + bookingId: item.bookingId, + itemType: item.itemType, + itemId: item.itemId, + inventoryId: item.inventoryId, + containerNumber: item.containerNumber, + status: 'UNLOADED_AT_DJIBOUTI_PORT', + message: 'Already unloaded at Djibouti Port', + }); + continue; + } if (!currentStatus || !this.EXPORT_DJIBOUTI_UNLOAD_ELIGIBLE_STATUSES.includes(currentStatus)) { skip(`Status ${currentStatus ?? 'UNKNOWN'} is not eligible for Djibouti export unloading`); continue; @@ -1483,18 +1603,29 @@ export class WarehouseInventoryService { } async receive(dto: ReceiveWarehouseInventoryDto): Promise { - this.assertTruckEntrance(dto.truckEntrance); - const weight = Number(dto.weight) || 0; - const volume = Number(dto.volume) || 0; - const containerCount = dto.containerId ? Math.round(Number(dto.quantity) || 0) : 0; const bookingDirection = dto.bookingId ? await this.getBookingDirection(dto.bookingId) : null; const id = await this.dataSource.transaction(async (manager) => { const { warehouse, yard, zone } = await this.validateLocation(manager, dto); - if (dto.bookingId) { - await this.assertBookingExists(manager, dto.bookingId); + const bookingSource = dto.bookingId + ? await this.getBookingTruckEntranceSource(manager, dto.bookingId) + : null; + if (dto.bookingId && !bookingSource?.reference) { + throw new NotFoundException(`Booking ${dto.bookingId} not found`); } + const quantity = bookingSource + ? Number(bookingSource.containerQuantity) || 1 + : Number(dto.quantity) || 0; + const weight = bookingSource + ? Number(bookingSource.weight) || 0 + : Number(dto.weight) || 0; + const volume = Number(dto.volume) || 0; + const containerCount = dto.containerId ? Math.round(quantity) : 0; + const truckEntrance = dto.bookingId + ? this.mergeSystemTruckEntrance(dto.truckEntrance, bookingSource ?? {}) + : dto.truckEntrance; + this.assertTruckEntrance(truckEntrance); this.assertCapacity('Warehouse', warehouse, weight, volume, containerCount); this.assertCapacity('Yard', yard, weight, volume, containerCount); @@ -1505,7 +1636,7 @@ export class WarehouseInventoryService { const receiveNote = this.buildReceiveNote({ grnNumber, notes: dto.notes?.trim() || 'Single booking received', - truckEntrance: dto.truckEntrance, + truckEntrance, }); const saved = await manager.getRepository(WarehouseInventory).save( manager.getRepository(WarehouseInventory).create({ @@ -1516,7 +1647,7 @@ export class WarehouseInventoryService { cargoId: dto.cargoId ?? null, containerId: dto.containerId ?? null, goodsId: dto.goodsId ?? null, - quantity: Number(dto.quantity) || 0, + quantity, weight, volume: dto.volume ?? null, status: 'RECEIVED', @@ -1529,14 +1660,23 @@ export class WarehouseInventoryService { await this.activityLog.record( { - activityType: 'INVENTORY_RECEIVED', - inventoryId: saved.id, - warehouseId: dto.warehouseId, - description: `GRN ${grnNumber}: received ${weight}kg via truck ${dto.truckEntrance.truckPlateNumber}`, - performedBy: dto.performedBy, - }, - manager, - ); + activityType: 'INVENTORY_RECEIVED', + inventoryId: saved.id, + warehouseId: dto.warehouseId, + description: `GRN ${grnNumber}: received ${weight}kg via truck ${truckEntrance.truckPlateNumber}`, + performedBy: dto.performedBy, + }, + manager, + ); + + await this.notifyOwnerInventoryReceived({ + phone: truckEntrance.customerPhone, + ownerName: truckEntrance.ownerName, + bookingReference: truckEntrance.edrDigitalBookingId ?? dto.bookingId, + grnNumber, + direction: bookingDirection, + warehouseId: dto.warehouseId, + }); return saved.id; }); @@ -1776,11 +1916,13 @@ export class WarehouseInventoryService { const releaseDate = dto.releaseDate ? new Date(dto.releaseDate) : new Date(); const reference = dto.reference?.trim() || null; + const exitInspectionNote = this.buildExitInspectionNote(dto); await this.dataSource.transaction(async (manager) => { await manager.getRepository(WarehouseInventory).update(id, { releaseDate, releaseOrderReference: reference, + notes: [item.notes?.trim(), exitInspectionNote].filter(Boolean).join('\n\n'), }); await this.activityLog.record( { @@ -1807,6 +1949,7 @@ export class WarehouseInventoryService { inv.quantity, inv.weight, inv.status, + inv.notes, b.id AS "bookingId", b.reference AS "bookingReference", b.status AS "bookingStatus", @@ -1867,6 +2010,7 @@ export class WarehouseInventoryService { zone: [row?.zoneName, row?.zoneCode].filter(Boolean).join(' / ') || null, inventoryStatus: row?.status ?? null, clearanceStatus: 'CLEARED FOR WAREHOUSE EXIT', + exitInspectionSummary: this.extractExitInspectionNote(row?.notes), }); return { @@ -2372,6 +2516,7 @@ export class WarehouseInventoryService { zone: string | null; inventoryStatus: string | null; clearanceStatus: string; + exitInspectionSummary?: string | null; }): string { const esc = (value: unknown) => String(value ?? '-') @@ -2402,6 +2547,7 @@ export class WarehouseInventoryService { ['Zone', data.zone], ['Inventory Status', data.inventoryStatus], ['Clearance Status', data.clearanceStatus], + ...(data.exitInspectionSummary ? [['Exit Inspection', data.exitInspectionSummary] as [string, string]] : []), ]; return ` @@ -2492,16 +2638,6 @@ export class WarehouseInventoryService { return { warehouse, yard, zone }; } - private async assertBookingExists(manager: EntityManager, bookingId: string): Promise { - const rows = await manager.query( - 'SELECT id FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL LIMIT 1', - [bookingId], - ); - if (!rows || rows.length === 0) { - throw new NotFoundException(`Booking ${bookingId} not found`); - } - } - private appendNote(existing: string | null | undefined, note: string): string { const trimmed = existing?.trim(); return trimmed ? `${trimmed}\n${note}` : note; @@ -2522,51 +2658,265 @@ export class WarehouseInventoryService { } } + private mergeSystemTruckEntrance( + submitted: TruckEntranceDto, + booking: { + reference?: string | null; + customer?: string | null; + customerTin?: string | null; + customerPhone?: string | null; + containerNumber?: string | null; + containerQuantity?: number | string | null; + containerPackagingType?: string | null; + cargoDescription?: string | null; + weight?: number | string | null; + firstMileTruckPlateNumber?: string | null; + firstMileTrailerPlateNumber?: string | null; + firstMileDriverName?: string | null; + firstMileDriverPhone?: string | null; + firstMileDriverLicenseNumber?: string | null; + firstMileTruckType?: string | null; + }, + ): TruckEntranceDto { + return { + ...submitted, + ownerName: booking.customer?.trim() || submitted.ownerName, + edrDigitalBookingId: booking.reference?.trim() || submitted.edrDigitalBookingId, + tin: booking.customerTin?.trim() || submitted.tin, + customerPhone: booking.customerPhone?.trim() || submitted.customerPhone, + assignedEquipmentNumber: booking.containerNumber?.trim() || submitted.assignedEquipmentNumber, + itemDescription: booking.cargoDescription?.trim() || submitted.itemDescription, + packagingType: booking.containerPackagingType?.trim() || submitted.packagingType, + unitCount: + booking.containerQuantity !== undefined && booking.containerQuantity !== null + ? Number(booking.containerQuantity) + : submitted.unitCount, + grossWeightKg: + booking.weight !== undefined && booking.weight !== null + ? Number(booking.weight) + : submitted.grossWeightKg, + truckPlateNumber: booking.firstMileTruckPlateNumber?.trim() || submitted.truckPlateNumber, + trailerPlateNumber: booking.firstMileTrailerPlateNumber?.trim() || submitted.trailerPlateNumber, + driverName: booking.firstMileDriverName?.trim() || submitted.driverName, + driverPhone: booking.firstMileDriverPhone?.trim() || submitted.driverPhone, + driverLicenseNumber: booking.firstMileDriverLicenseNumber?.trim() || submitted.driverLicenseNumber, + truckType: booking.firstMileTruckType?.trim() || submitted.truckType, + }; + } + + private async getBookingTruckEntranceSource( + manager: EntityManager, + bookingId: string, + ): Promise<{ + reference?: string | null; + customer?: string | null; + customerTin?: string | null; + customerPhone?: string | null; + containerNumber?: string | null; + containerQuantity?: number | string | null; + containerPackagingType?: string | null; + cargoDescription?: string | null; + weight?: number | string | null; + firstMileTruckPlateNumber?: string | null; + firstMileTrailerPlateNumber?: string | null; + firstMileDriverName?: string | null; + firstMileDriverPhone?: string | null; + firstMileDriverLicenseNumber?: string | null; + firstMileTruckType?: string | null; + }> { + const [booking] = await manager.query( + `SELECT b.reference AS "reference", + company.name AS "customer", + company.tin AS "customerTin", + COALESCE(company.contact_person_phone, company.phone, company.general_manager_phone, company.etrade_phone) AS "customerPhone", + b.cargo_total_weight_vgm AS "weight", + COALESCE(cargo_type.cargo_type_name, b.cargo_free_text) AS "cargoDescription", + bc.container_numbers AS "containerNumber", + bc.container_quantity AS "containerQuantity", + bc.container_packaging_type AS "containerPackagingType", + v.plate_number AS "firstMileTruckPlateNumber", + v.trailer_plate_no AS "firstMileTrailerPlateNumber", + COALESCE( + NULLIF(TRIM(CONCAT(COALESCE(driver.first_name, ''), ' ', COALESCE(driver.last_name, ''))), ''), + v.assigned_driver_name + ) AS "firstMileDriverName", + driver.phone_number AS "firstMileDriverPhone", + driver.license_number AS "firstMileDriverLicenseNumber", + v.vehicle_type AS "firstMileTruckType" + FROM freight.bookings b + LEFT JOIN freight.companies company ON company.id = b.company_id + LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = b.cargo_type_id + LEFT JOIN LATERAL ( + SELECT string_agg(NULLIF(booking_container.container_number, ''), ', ' ORDER BY booking_container.container_number) AS container_numbers, + SUM(booking_container.quantity)::int AS container_quantity, + CASE + WHEN COUNT(booking_container.id) = 0 THEN NULL + WHEN bool_or(container_type.is_reefer) THEN 'REEFER_CONTAINER' + WHEN bool_or(container_type.is_open_top) THEN 'OPEN_TOP_CONTAINER' + WHEN MIN(container_type.size_ft) = 20 THEN 'CONTAINER_20FT' + WHEN MIN(container_type.size_ft) = 40 THEN 'CONTAINER_40FT' + WHEN MIN(container_type.size_ft) = 45 THEN 'CONTAINER_45FT' + ELSE 'OTHER_CONTAINER' + END AS container_packaging_type + FROM freight.booking_container booking_container + LEFT JOIN freight.container_types container_type ON container_type.id = booking_container.container_type_id + WHERE booking_container.booking_id = b.id AND booking_container.deleted_at IS NULL + ) bc ON true + LEFT JOIN LATERAL ( + SELECT first_mile.vehicle_id + FROM freight.first_mile first_mile + WHERE first_mile.booking_id = b.id AND first_mile.deleted_at IS NULL + ORDER BY first_mile.created_at DESC + LIMIT 1 + ) fm ON true + LEFT JOIN freight.vehicles v ON v.id = fm.vehicle_id + LEFT JOIN freight.drivers driver ON driver.id = v.assigned_driver_id + WHERE b.id = $1 AND b.deleted_at IS NULL + LIMIT 1`, + [bookingId], + ); + return booking ?? {}; + } + + private async notifyOwnerInventoryReceived(params: { + phone?: string | null; + ownerName?: string | null; + bookingReference?: string | null; + grnNumber: string; + direction?: string | null; + warehouseId?: string | null; + }): Promise { + const phone = params.phone?.trim(); + if (!phone) return; + + const ownerName = params.ownerName?.trim() || 'Customer'; + const bookingReference = params.bookingReference?.trim(); + const message = + `Dear ${ownerName}, your cargo has been received by EDR warehouse. ` + + (bookingReference ? `Booking: ${bookingReference}. ` : '') + + `GRN: ${params.grnNumber}. ` + + (params.direction ? `Direction: ${params.direction}. ` : '') + + `Thank you.`; + + try { + await this.notifications.directSend('sms', phone, message); + } catch (error) { + // Receiving inventory must not be rolled back because an SMS provider is unavailable. + this.logger.error(`Failed to notify owner for GRN ${params.grnNumber}: ${String(error)}`); + } + } + private generateGrnNumber(direction: string, referenceId: string, date: Date): string { const stamp = date.toISOString().slice(0, 10).replace(/-/g, ''); const suffix = referenceId.replace(/-/g, '').slice(0, 8).toUpperCase(); return `GRN-${direction.toUpperCase()}-${stamp}-${suffix}`; } + private buildExitInspectionNote(dto: ReleaseOrderDto): string | null { + const hasExitInspection = + Boolean(dto.truckPlateNumber?.trim()) || + Boolean(dto.trailerPlateNumber?.trim()) || + Boolean(dto.driverName?.trim()) || + Boolean(dto.driverLicense?.trim()) || + Boolean(dto.driverPhone?.trim()) || + Boolean(dto.truckType?.trim()) || + Boolean(dto.containerNumber?.trim()) || + dto.tareWeight !== undefined || + dto.grossWeight !== undefined || + dto.netWeight !== undefined || + Boolean(dto.gateInTime) || + Boolean(dto.gateOutTime); + + if (!hasExitInspection) return null; + + if (!dto.truckPlateNumber?.trim()) { + throw new BadRequestException('Truck plate number is required for exit inspection'); + } + if (!dto.driverName?.trim()) { + throw new BadRequestException('Driver name is required for exit inspection'); + } + if (dto.tareWeight === undefined || dto.grossWeight === undefined) { + throw new BadRequestException('Tare weight and gross weight are required for exit inspection'); + } + + const tareWeight = Number(dto.tareWeight); + const grossWeight = Number(dto.grossWeight); + const computedNetWeight = Number((grossWeight - tareWeight).toFixed(3)); + const submittedNetWeight = dto.netWeight === undefined ? computedNetWeight : Number(dto.netWeight); + + if (Math.abs(submittedNetWeight - computedNetWeight) > 0.001) { + throw new BadRequestException('Weight mismatch: net weight must equal gross weight minus tare weight.'); + } + + const rows = [ + '[Exit Inspection]', + dto.bookingId?.trim() ? `Booking ID: ${dto.bookingId.trim()}` : null, + dto.customerId?.trim() ? `Customer ID: ${dto.customerId.trim()}` : null, + `Truck Plate: ${dto.truckPlateNumber.trim()}`, + dto.trailerPlateNumber?.trim() ? `Trailer Plate: ${dto.trailerPlateNumber.trim()}` : null, + `Driver: ${dto.driverName.trim()}`, + dto.driverLicense?.trim() ? `Driver License: ${dto.driverLicense.trim()}` : null, + dto.driverPhone?.trim() ? `Driver Phone: ${dto.driverPhone.trim()}` : null, + dto.truckType?.trim() ? `Truck Type: ${dto.truckType.trim()}` : null, + dto.containerNumber?.trim() ? `Container Number: ${dto.containerNumber.trim()}` : null, + dto.gateInTime ? `Gate In Time: ${dto.gateInTime}` : null, + `Tare Weight: ${tareWeight} kg`, + `Gross Weight: ${grossWeight} kg`, + `Net Weight: ${computedNetWeight} kg`, + dto.gateOutTime ? `Gate Out Time: ${dto.gateOutTime}` : null, + ]; + + return rows.filter(Boolean).join('\n'); + } + + private extractExitInspectionNote(notes?: string | null): string | null { + if (!notes) return null; + const marker = '[Exit Inspection]'; + const index = notes.lastIndexOf(marker); + if (index < 0) return null; + return notes.slice(index + marker.length).trim() || null; + } + private buildReceiveNote(input: { grnNumber: string; direction?: string | null; notes?: string | null; - truckEntrance: TruckEntranceDto; + truckEntrance?: TruckEntranceDto; }): string { const truck = input.truckEntrance; const rows = [ `GRN Number: ${input.grnNumber}`, input.direction ? `Direction: ${input.direction}` : null, - truck.ownerName ? `Owner / Shipper: ${truck.ownerName}` : null, - truck.consigneeDetails ? `Consignee: ${truck.consigneeDetails}` : null, - truck.edrDigitalBookingId ? `EDR Digital Booking ID: ${truck.edrDigitalBookingId}` : null, - truck.tin ? `TIN: ${truck.tin}` : null, - `Truck Plate: ${truck.truckPlateNumber}`, - truck.trailerPlateNumber ? `Trailer Plate: ${truck.trailerPlateNumber}` : null, - truck.assignedEquipmentNumber ? `Assigned Wagon / Container: ${truck.assignedEquipmentNumber}` : null, - truck.customsSealNumber ? `Customs Seal Number: ${truck.customsSealNumber}` : null, - truck.truckType ? `Truck Type: ${truck.truckType}` : null, - `Driver: ${truck.driverName}`, - `Driver Phone: ${truck.driverPhone}`, - truck.driverLicenseNumber ? `Driver License: ${truck.driverLicenseNumber}` : null, - `Entrance Tare Weight: ${Number(truck.entranceTareWeightKg)} kg`, - truck.exitTareWeightKg !== undefined ? `Exit Tare Weight: ${Number(truck.exitTareWeightKg)} kg` : null, - truck.declarationNumber ? `Declaration / Bill of Entry: ${truck.declarationNumber}` : null, - truck.incoterms ? `Incoterms: ${truck.incoterms}` : null, - truck.hsCodes ? `HS Codes: ${truck.hsCodes}` : null, - truck.itemCode ? `Item Code: ${truck.itemCode}` : null, - truck.itemDescription ? `Item Description: ${truck.itemDescription}` : null, - truck.packagingType ? `Packaging Type: ${truck.packagingType}` : null, - truck.unitCount !== undefined ? `Unit Count: ${Number(truck.unitCount)}` : null, - truck.grossWeightKg !== undefined ? `Gross Weight: ${Number(truck.grossWeightKg)} kg` : null, - truck.netWeightKg !== undefined ? `Net Weight: ${Number(truck.netWeightKg)} kg` : null, - truck.volumeDimensions ? `Volume / Dimensions: ${truck.volumeDimensions}` : null, - truck.conditionAtReceipt ? `Condition at Receipt: ${truck.conditionAtReceipt}` : null, - truck.damagedRejectedQuantity !== undefined ? `Damaged / Rejected Quantity: ${Number(truck.damagedRejectedQuantity)}` : null, - truck.warehouseCodeLocation ? `Warehouse Code / Location: ${truck.warehouseCodeLocation}` : null, - truck.driverSignatoryName ? `Driver Signatory: ${truck.driverSignatoryName}` : null, - truck.warehouseManagerName ? `EDR Warehouse Manager: ${truck.warehouseManagerName}` : null, + truck?.ownerName ? `Owner / Shipper: ${truck.ownerName}` : null, + truck?.consigneeDetails ? `Consignee: ${truck.consigneeDetails}` : null, + truck?.edrDigitalBookingId ? `EDR Digital Booking ID: ${truck.edrDigitalBookingId}` : null, + truck?.tin ? `TIN: ${truck.tin}` : null, + truck?.customerPhone ? `Customer Phone: ${truck.customerPhone}` : null, + truck?.truckPlateNumber ? `Truck Plate: ${truck.truckPlateNumber}` : null, + truck?.trailerPlateNumber ? `Trailer Plate: ${truck.trailerPlateNumber}` : null, + truck?.assignedEquipmentNumber ? `Assigned Wagon / Container: ${truck.assignedEquipmentNumber}` : null, + truck?.customsSealNumber ? `Customs Seal Number: ${truck.customsSealNumber}` : null, + truck?.truckType ? `Truck Type: ${truck.truckType}` : null, + truck?.driverName ? `Driver: ${truck.driverName}` : null, + truck?.driverPhone ? `Driver Phone: ${truck.driverPhone}` : null, + truck?.driverLicenseNumber ? `Driver License: ${truck.driverLicenseNumber}` : null, + truck?.entranceTareWeightKg !== undefined ? `Entrance Tare Weight: ${Number(truck.entranceTareWeightKg)} kg` : null, + truck?.exitTareWeightKg !== undefined ? `Exit Tare Weight: ${Number(truck.exitTareWeightKg)} kg` : null, + truck?.declarationNumber ? `Declaration / Bill of Entry: ${truck.declarationNumber}` : null, + truck?.incoterms ? `Incoterms: ${truck.incoterms}` : null, + truck?.hsCodes ? `HS Codes: ${truck.hsCodes}` : null, + truck?.itemCode ? `Item Code: ${truck.itemCode}` : null, + truck?.itemDescription ? `Item Description: ${truck.itemDescription}` : null, + truck?.packagingType ? `Packaging Type: ${truck.packagingType}` : null, + truck?.unitCount !== undefined ? `Unit Count: ${Number(truck.unitCount)}` : null, + truck?.grossWeightKg !== undefined ? `Gross Weight: ${Number(truck.grossWeightKg)} kg` : null, + truck?.netWeightKg !== undefined ? `Net Weight: ${Number(truck.netWeightKg)} kg` : null, + truck?.volumeDimensions ? `Volume / Dimensions: ${truck.volumeDimensions}` : null, + truck?.conditionAtReceipt ? `Condition at Receipt: ${truck.conditionAtReceipt}` : null, + truck?.damagedRejectedQuantity !== undefined ? `Damaged / Rejected Quantity: ${Number(truck.damagedRejectedQuantity)}` : null, + truck?.warehouseCodeLocation ? `Warehouse Code / Location: ${truck.warehouseCodeLocation}` : null, + truck?.driverSignatoryName ? `Driver Signatory: ${truck.driverSignatoryName}` : null, + truck?.warehouseManagerName ? `EDR Warehouse Manager: ${truck.warehouseManagerName}` : null, input.notes?.trim() ? `Remarks: ${input.notes.trim()}` : null, ]; return rows.filter(Boolean).join('\n'); diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts index 45c471db1..1fe184662 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts @@ -1,6 +1,7 @@ -import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { DataSource } from 'typeorm'; +import { NotificationsService } from '../notifications/notifications.service'; import { WarehouseFeeInvoice, WarehouseInvoiceStatus, @@ -22,6 +23,8 @@ export interface PayInvoiceDto { amount: number; method?: string; reference?: string; + driverName?: string; + driverPhone?: string; } /** Invoices that still owe money and therefore block terminal release. */ @@ -46,12 +49,15 @@ export type WarehouseFeeInvoiceWithDisplay = WarehouseFeeInvoice & Partial { + const [row] = await this.dataSource.query( + `SELECT b.reference AS "bookingReference", + company.name AS "customerName", + COALESCE(company.contact_person_phone, company.phone, company.general_manager_phone, company.etrade_phone) AS "customerPhone", + COALESCE( + NULLIF(TRIM(CONCAT(COALESCE(last_driver.first_name, ''), ' ', COALESCE(last_driver.last_name, ''))), ''), + last_vehicle.assigned_driver_name, + NULLIF(TRIM(CONCAT(COALESCE(first_driver.first_name, ''), ' ', COALESCE(first_driver.last_name, ''))), ''), + first_vehicle.assigned_driver_name + ) AS "driverName", + COALESCE(last_driver.phone_number, first_driver.phone_number) AS "driverPhone", + COALESCE(container.container_number, booking_container.container_number) AS "containerNumber", + COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description) AS "cargoDescription" + FROM freight.warehouse_fee_invoices fee + LEFT JOIN freight.warehouse_inventory inv ON inv.id = fee.inventory_id AND inv.deleted_at IS NULL + LEFT JOIN freight.bookings b ON b.id = fee.booking_id AND b.deleted_at IS NULL + LEFT JOIN freight.companies company ON company.id = COALESCE(fee.customer_id, b.company_id) + LEFT JOIN freight.containers container ON container.id = inv.container_id AND container.deleted_at IS NULL + LEFT JOIN freight.booking_container booking_container ON ( + booking_container.booking_id = b.id + AND booking_container.deleted_at IS NULL + ) + LEFT JOIN freight.cargoes cargo ON cargo.id = inv.cargo_id AND cargo.deleted_at IS NULL + LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = COALESCE(cargo.cargo_type_id, b.cargo_type_id) + LEFT JOIN LATERAL ( + SELECT lm.vehicle_id + FROM freight.last_mile lm + WHERE lm.booking_id = b.id AND lm.deleted_at IS NULL + ORDER BY lm.created_at DESC + LIMIT 1 + ) latest_last_mile ON true + LEFT JOIN freight.vehicles last_vehicle ON last_vehicle.id = latest_last_mile.vehicle_id + LEFT JOIN freight.drivers last_driver ON last_driver.id = last_vehicle.assigned_driver_id + LEFT JOIN LATERAL ( + SELECT fm.vehicle_id + FROM freight.first_mile fm + WHERE fm.booking_id = b.id AND fm.deleted_at IS NULL + ORDER BY fm.created_at DESC + LIMIT 1 + ) latest_first_mile ON true + LEFT JOIN freight.vehicles first_vehicle ON first_vehicle.id = latest_first_mile.vehicle_id + LEFT JOIN freight.drivers first_driver ON first_driver.id = first_vehicle.assigned_driver_id + WHERE fee.id = $1 + LIMIT 1`, + [invoice.id], + ); + + return { + bookingReference: row?.bookingReference ?? null, + customerName: row?.customerName ?? null, + customerPhone: row?.customerPhone ?? null, + driverName: row?.driverName ?? null, + driverPhone: row?.driverPhone ?? null, + containerNumber: row?.containerNumber ?? null, + cargoDescription: row?.cargoDescription ?? null, + }; + } + + private async sendSms(recipient: string | null | undefined, message: string, context: string): Promise { + const phone = recipient?.trim(); + if (!phone) return; + try { + await this.notifications.directSend('sms', phone, message); + } catch (error) { + this.logger.error(`Failed to send ${context} SMS to ${phone}: ${String(error)}`); + } + } + + private async notifyWarehouseFeeIssued(invoice: WarehouseFeeInvoice): Promise { + const contacts = await this.getInvoiceNotificationContacts(invoice); + const customerName = contacts.customerName?.trim() || 'Customer'; + const bookingReference = contacts.bookingReference ? ` Booking: ${contacts.bookingReference}.` : ''; + const cargo = contacts.containerNumber || contacts.cargoDescription; + const cargoText = cargo ? ` Cargo: ${cargo}.` : ''; + const message = + `Dear ${customerName}, warehouse ${invoice.invoiceType.replace(/_/g, ' ').toLowerCase()} fee ` + + `${invoice.invoiceNumber} is due.${bookingReference}${cargoText} Amount: ` + + `${Number(invoice.totalAmount).toLocaleString()} ${invoice.currency}. Please pay before cargo pickup.`; + + await this.sendSms(contacts.customerPhone, message, `warehouse fee invoice ${invoice.invoiceNumber}`); + } + + private async notifyWarehouseFeePayment(invoice: WarehouseFeeInvoice, dto: PayInvoiceDto): Promise { + const contacts = await this.getInvoiceNotificationContacts(invoice); + const customerName = contacts.customerName?.trim() || 'Customer'; + const bookingReference = contacts.bookingReference ? ` Booking: ${contacts.bookingReference}.` : ''; + const statusText = + invoice.status === 'PAID' + ? 'fully paid and ready for pickup release' + : `partially paid. Balance: ${Number(invoice.balanceAmount).toLocaleString()} ${invoice.currency}`; + const customerMessage = + `Dear ${customerName}, payment of ${Number(dto.amount).toLocaleString()} ${invoice.currency} ` + + `was recorded for warehouse fee ${invoice.invoiceNumber}.${bookingReference} Status: ${statusText}.`; + + await this.sendSms(contacts.customerPhone, customerMessage, `warehouse fee payment ${invoice.invoiceNumber}`); + + if (invoice.status !== 'PAID') return; + + const driverPhone = dto.driverPhone?.trim() || contacts.driverPhone; + const driverName = dto.driverName?.trim() || contacts.driverName || 'Driver'; + const cargo = contacts.containerNumber || contacts.cargoDescription; + const driverMessage = + `Dear ${driverName}, warehouse demurrage/storage fee ${invoice.invoiceNumber} is paid.` + + (contacts.bookingReference ? ` Booking: ${contacts.bookingReference}.` : '') + + (cargo ? ` Cargo: ${cargo}.` : '') + + ' Proceed with pickup after gate verification.'; + + await this.sendSms(driverPhone, driverMessage, `warehouse pickup driver ${invoice.invoiceNumber}`); + } + private buildInvoiceDocumentHtml( invoice: WarehouseFeeInvoiceWithDisplay & { items: unknown[] }, kind: 'INVOICE' | 'RECEIPT', diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts index 4398d943e..a77a46c29 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts @@ -99,26 +99,51 @@ export class WarehouseReleaseDocumentService { } private htmlToBasicPdfBuffer(html: string): Buffer { - const text = this.htmlToPlainText(html); - const lines = this.wrapLines(text, 86).slice(0, 52); - const body = lines - .map((line, index) => { - const y = 770 - index * 12; - const isTitle = index < 2 || /clearance|release order/i.test(line); - const size = index === 0 ? 13 : isTitle ? 11 : 9.6; - const font = isTitle ? 'F2' : 'F1'; - return this.textOp(line, 48, y, size, font); - }) - .join('\n'); + const doc = this.extractReleaseDocument(html); + const body: string[] = [ + this.lineOp(36, 810, 559, 810, '0 0 0', 2.2), + this.textOp('ETHIO-DJIBOUTI RAILWAY S.C.', 36, 787, 9, 'F2', '0.08 0.32 0.18'), + this.textOp('WAREHOUSE GATE', 36, 764, 24, 'F2', '0.02 0.08 0.16'), + this.textOp('CLEARANCE / RELEASE', 36, 742, 24, 'F2', '0.02 0.08 0.16'), + this.textOp('ORDER', 36, 720, 24, 'F2', '0.02 0.08 0.16'), + this.textOp('OFFICIAL WAREHOUSE RELEASE AND EXIT AUTHORIZATION', 36, 696, 8.5, 'F1', '0.25 0.34 0.45'), + this.textOp('Document / Release No.', 424, 781, 8.5, 'F1', '0.15 0.22 0.32'), + this.textOp(doc.reference, 504 - doc.reference.length * 2.2, 761, 15, 'F2', '0.02 0.08 0.16'), + this.textOp(`Issued: ${doc.issuedAt}`, 424, 742, 8.5, 'F1', '0.15 0.22 0.32'), + this.lineOp(36, 682, 559, 682, '0.08 0.32 0.18', 2), + this.rectOp(36, 625, 410, 52, '0.95 1 0.96', '0.38 0.85 0.55', 0.8), + this.lineOp(39, 625, 39, 677, '0.08 0.48 0.25', 2.2), + ...this.wrapLines(doc.notice, 68) + .slice(0, 4) + .map((line, index) => this.textOp(line, 52, 659 - index * 12, 9.2, 'F1')), + this.textOp('RELEASE PARTICULARS', 36, 604, 10, 'F2', '0.08 0.32 0.18'), + ]; + + let y = 586; + const rowHeight = 20; + for (const [label, value] of doc.rows.slice(0, 14)) { + body.push(this.rectOp(36, y - rowHeight + 3, 160, rowHeight, '0.97 0.98 0.99', '0.70 0.77 0.85', 0.6)); + body.push(this.rectOp(196, y - rowHeight + 3, 363, rowHeight, '1 1 1', '0.70 0.77 0.85', 0.6)); + body.push(this.textOp(label, 46, y - 10, 8.6, 'F2', '0.02 0.08 0.16')); + body.push(this.textOp(value || '-', 206, y - 10, 8.6, 'F1', '0.02 0.08 0.16')); + y -= rowHeight; + } + + body.push(this.textOp('AUTHORIZATION CLAUSE', 36, y - 10, 10, 'F2', '0.08 0.32 0.18')); + body.push(this.rectOp(36, y - 76, 523, 48, '1 1 1', '0.70 0.77 0.85', 0.7)); + body.push( + ...this.wrapLines(doc.clause, 92) + .slice(0, 4) + .map((line, index) => this.textOp(line, 48, y - 45 - index * 10, 8.2, 'F1')), + ); + const stream = [ - this.lineOp(48, 752, 548, 752), - body, - this.circularSealOps(184, 154), - this.lineOp(48, 92, 278, 92, '0 0 0'), - this.textOp('Officer in charge name / signature / date', 48, 76, 9, 'F1'), - this.lineOp(326, 92, 548, 92, '0 0 0'), - this.textOp('Customer or driver name / signature / date', 326, 76, 9, 'F1'), - this.textOp('OFFICIAL WAREHOUSE GATE CLEARANCE DOCUMENT', 145, 52, 9, 'F2', '0.08 0.32 0.18'), + ...body, + this.lineOp(36, 60, 218, 60, '0 0 0', 1), + this.textOp('Officer in charge name / signature / date', 36, 47, 7.4, 'F1'), + this.circularSealOps(286, 62, 38), + this.lineOp(341, 60, 559, 60, '0 0 0', 1), + this.textOp('Customer or driver name / signature / date', 341, 47, 7.4, 'F1'), ].join('\n'); const objects = [ @@ -149,6 +174,31 @@ export class WarehouseReleaseDocumentService { return Buffer.from(pdf, 'latin1'); } + private extractReleaseDocument(html: string): { + reference: string; + issuedAt: string; + notice: string; + clause: string; + rows: Array<[string, string]>; + } { + const textFromHtml = (value: string) => this.htmlToPlainText(value).replace(/\n/g, ' ').trim(); + const reference = textFromHtml(html.match(/([\s\S]*?)<\/strong>/i)?.[1] ?? 'DO'); + const issuedAt = textFromHtml(html.match(/Issued:\s*([^<]+)/i)?.[1] ?? '-'); + const notice = textFromHtml( + html.match(/
([\s\S]*?)<\/div>/i)?.[1] ?? + 'This clearance document confirms that the listed booking/goods are authorized for warehouse exit, subject to gate identity verification and confirmation that no blocking warehouse fees remain unpaid.', + ); + const clause = textFromHtml( + html.match(/
([\s\S]*?)<\/div>/i)?.[1] ?? + 'The warehouse officer and gate staff shall verify this document against the booking reference, customer or driver identity, cargo details, clearance status, and payment records before permitting exit from the warehouse premises.', + ); + const rows: Array<[string, string]> = []; + for (const match of html.matchAll(/([\s\S]*?)<\/th>([\s\S]*?)<\/td><\/tr>/gi)) { + rows.push([textFromHtml(match[1]), textFromHtml(match[2])]); + } + return { reference, issuedAt, notice, clause, rows }; + } + private htmlToPlainText(html: string): string { return html .replace(//gi, '') @@ -203,24 +253,36 @@ export class WarehouseReleaseDocumentService { return `BT\n${color} rg\n/${font} ${size} Tf\n${x} ${y} Td\n(${this.escapePdfText(text)}) Tj\nET`; } - private lineOp(x1: number, y1: number, x2: number, y2: number, color = '0.08 0.32 0.18'): string { - return `q\n${color} RG\n0.8 w\n${x1} ${y1} m\n${x2} ${y2} l\nS\nQ`; + private lineOp(x1: number, y1: number, x2: number, y2: number, color = '0.08 0.32 0.18', width = 0.8): string { + return `q\n${color} RG\n${width} w\n${x1} ${y1} m\n${x2} ${y2} l\nS\nQ`; } - private circularSealOps(cx: number, cy: number): string { + private rectOp( + x: number, + y: number, + width: number, + height: number, + fillColor = '1 1 1', + strokeColor = '0.08 0.32 0.18', + lineWidth = 0.8, + ): string { + return `q\n${fillColor} rg\n${strokeColor} RG\n${lineWidth} w\n${x} ${y} ${width} ${height} re\nB\nQ`; + } + + private circularSealOps(cx: number, cy: number, radius = 51): string { return [ 'q', '0.08 0.32 0.18 RG', '0.08 0.32 0.18 rg', '2.2 w', - this.circlePath(cx, cy, 51), + this.circlePath(cx, cy, radius), 'S', '0.8 w', - this.circlePath(cx, cy, 41), + this.circlePath(cx, cy, radius - 10), 'S', - this.textOp('EDR', cx - 14, cy + 18, 14, 'F2', '0.08 0.32 0.18'), - this.textOp('WAREHOUSE', cx - 31, cy + 2, 9, 'F2', '0.08 0.32 0.18'), - this.textOp('CLEARED', cx - 27, cy - 16, 11, 'F2', '0.08 0.32 0.18'), + this.textOp('EDR', cx - 11, cy + 13, 11, 'F2', '0.08 0.32 0.18'), + this.textOp('WAREHOUSE', cx - 25, cy, 7.5, 'F2', '0.08 0.32 0.18'), + this.textOp('CLEARED', cx - 21, cy - 13, 9, 'F2', '0.08 0.32 0.18'), 'Q', ].join('\n'); } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts index 116bfabf1..1671b63e8 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts @@ -6,6 +6,7 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { FilesModule } from '../files/files.module'; import { InterchangeDocumentsModule } from '../interchange-documents/interchange-documents.module'; import { LastMileModule } from '../last-mile/last-mile.module'; +import { NotificationsModule } from '../notifications/notifications.module'; import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity'; import { WarehouseAllocationRule } from './entities/warehouse-allocation-rule.entity'; import { WarehouseFeeInvoice } from './entities/warehouse-fee-invoice.entity'; @@ -71,6 +72,7 @@ import { WarehousesService } from './warehouses.service'; FilesModule, InterchangeDocumentsModule, forwardRef(() => LastMileModule), + NotificationsModule, ExchangeModule.forRootAsync({ inject: [ConfigService], useFactory: (config: ConfigService): ExchangeOptions => @@ -122,6 +124,7 @@ import { WarehousesService } from './warehouses.service'; WarehouseAllocationService, WarehouseFeeService, WarehouseSchedulingAdapterService, + WarehouseReleaseDocumentService, ], }) export class WarehousesModule {} diff --git a/apps/edr-freight-api/src/scripts/seed-approved-first-lastmile-demo-bookings.ts b/apps/edr-freight-api/src/scripts/seed-approved-first-lastmile-demo-bookings.ts new file mode 100644 index 000000000..7ff90463f --- /dev/null +++ b/apps/edr-freight-api/src/scripts/seed-approved-first-lastmile-demo-bookings.ts @@ -0,0 +1,28 @@ +import 'reflect-metadata'; +import { config } from 'dotenv'; +import { resolve } from 'path'; + +config({ path: resolve(__dirname, '../../.env') }); + +import { NestFactory } from '@nestjs/core'; +import { AppModule } from '../app.module'; +import { ApprovedFirstLastMileDemoBookingsSeeder } from '../seed/approved-first-lastmile-demo-bookings.seeder'; + +async function main() { + const app = await NestFactory.createApplicationContext(AppModule, { + logger: ['error', 'warn', 'log'], + }); + + try { + const seeder = app.get(ApprovedFirstLastMileDemoBookingsSeeder); + await seeder.run(); + console.log('Approved first/last-mile demo bookings seeded.'); + } finally { + await app.close(); + } +} + +main().catch((err) => { + console.error('Approved first/last-mile demo booking seed failed:', err); + process.exit(1); +}); diff --git a/apps/edr-freight-api/src/scripts/seed-export-djibouti-interchange-demo.ts b/apps/edr-freight-api/src/scripts/seed-export-djibouti-interchange-demo.ts index a91c0293b..c6ec67041 100644 --- a/apps/edr-freight-api/src/scripts/seed-export-djibouti-interchange-demo.ts +++ b/apps/edr-freight-api/src/scripts/seed-export-djibouti-interchange-demo.ts @@ -21,8 +21,18 @@ import { WarehouseYard } from '../modules/warehouses/entities/warehouse-yard.ent import { WarehouseZone } from '../modules/warehouses/entities/warehouse-zone.entity'; import { Warehouse } from '../modules/warehouses/entities/warehouse.entity'; -const TRAIN_NUMBER = 'ICD-DEMO-EXP-DJ-01'; -const BOOKING_REFS = ['ICD-DEMO-EXP-001', 'ICD-DEMO-EXP-002', 'ICD-DEMO-EXP-003']; +const DEMO_TRAINS = [ + { + trainNumber: 'ICD-DEMO-EXP-DJ-01', + bookingRefs: ['ICD-DEMO-EXP-001', 'ICD-DEMO-EXP-002', 'ICD-DEMO-EXP-003'], + arrivalOffsetHours: 1, + }, + { + trainNumber: 'ICD-DEMO-EXP-DJ-02', + bookingRefs: ['ICD-DEMO-EXP-004', 'ICD-DEMO-EXP-005', 'ICD-DEMO-EXP-006'], + arrivalOffsetHours: 2, + }, +]; async function main() { const app = await NestFactory.createApplicationContext(AppModule, { @@ -44,13 +54,6 @@ async function main() { const scheduleRepo = dataSource.getRepository(TrainSchedule); const scheduleBookingRepo = dataSource.getRepository(TrainScheduleBooking); - const existingSchedule = await scheduleRepo.findOne({ where: { trainNumber: TRAIN_NUMBER } }); - if (existingSchedule) { - console.log(`Export Djibouti interchange demo already seeded: ${TRAIN_NUMBER}`); - console.log(`Schedule ID: ${existingSchedule.id}`); - return; - } - const originYard = (await yardRepo.findOne({ where: { code: 'MOJO' } })) ?? (await yardRepo.findOne({ where: { country: 'Ethiopia' } })); @@ -83,10 +86,6 @@ async function main() { throw new Error(`Cannot seed export Djibouti interchange demo, missing: ${missing.join(', ')}`); } - const now = Date.now(); - const departure = new Date(now - 6 * 60 * 60 * 1000); - const arrival = new Date(now - 60 * 60 * 1000); - const locomotive = (await locomotiveRepo.findOne({ where: { code: 'ICD-DEMO-LOCO' } })) ?? (await locomotiveRepo.save( @@ -97,83 +96,106 @@ async function main() { }), )); - const trainSet = await trainSetRepo.save( - trainSetRepo.create({ - locomotiveId: locomotive.id, - totalWeightTons: 700, - totalLengthMeters: 360, - wagonCount: 12, - status: 'COMPLETED', - }), - ); + const now = Date.now(); + const seededSchedules: TrainSchedule[] = []; - const schedule = await scheduleRepo.save( - scheduleRepo.create({ - trainSetId: trainSet.id, - originStationId: originYard!.id, - destinationStationId: destinationYard!.id, - scheduledDepartureDate: departure, - scheduledArrivalDate: arrival, - actualArrivalAt: arrival, - status: 'ARRIVED' as TrainSchedule['status'], - trainNumber: TRAIN_NUMBER, - }), - ); + for (const [trainIndex, demo] of DEMO_TRAINS.entries()) { + const existingSchedule = await scheduleRepo.findOne({ where: { trainNumber: demo.trainNumber } }); + if (existingSchedule) { + console.log(`Export Djibouti interchange demo already seeded: ${demo.trainNumber}`); + console.log(`Schedule ID: ${existingSchedule.id}`); + seededSchedules.push(existingSchedule); + continue; + } - for (const [index, reference] of BOOKING_REFS.entries()) { - const weight = 5200 + index * 800; - const booking = await bookingRepo.save( - bookingRepo.create({ - reference, - originYardId: originYard!.id, - destinationYardId: destinationYard!.id, - serviceTypeId: serviceType!.id, - status: 'IN_TRANSIT', - paymentStatus: 'PAID', - scheduledDate: new Date(), - contractType: 'SPOT', - equipmentReturn: 'TERMINAL', - paymentCurrency: 'ETB', - totalAmount: 0, - isGovernment: false, - tradeDirection: 'EXPORT', - freightType: index % 2 === 0 ? 'CONTAINER' : 'BULK', - cargoTypeId: cargoType?.id ?? null, - cargoFreeText: cargoType ? null : `Export Djibouti interchange demo cargo ${index + 1}`, - cargoTotalWeightVgm: weight, + const arrival = new Date(now - demo.arrivalOffsetHours * 60 * 60 * 1000); + const departure = new Date(arrival.getTime() - 5 * 60 * 60 * 1000); + + const trainSet = await trainSetRepo.save( + trainSetRepo.create({ + locomotiveId: locomotive.id, + totalWeightTons: 700 + trainIndex * 80, + totalLengthMeters: 360 + trainIndex * 20, + wagonCount: 12 + trainIndex, + status: 'COMPLETED', }), ); - await inventoryRepo.save( - inventoryRepo.create({ - warehouseId: warehouse!.id, - yardId: warehouseYard!.id, - zoneId: warehouseZone!.id, - bookingId: booking.id, - quantity: 1, - weight, - status: 'DISPATCHED', - inspectionStatus: 'PASSED', - arrivedAt: new Date(now - 4 * 60 * 60 * 1000), - inspectedAt: new Date(now - 3 * 60 * 60 * 1000), - readyForLoadingAt: new Date(now - 2 * 60 * 60 * 1000), - loadedAt: new Date(now - 90 * 60 * 1000), - dispatchedAt: new Date(now - 70 * 60 * 1000), - notes: '[ICD-DEMO] Eligible for Djibouti export unload and interchange generation', + const schedule = await scheduleRepo.save( + scheduleRepo.create({ + trainSetId: trainSet.id, + originStationId: originYard!.id, + destinationStationId: destinationYard!.id, + scheduledDepartureDate: departure, + scheduledArrivalDate: arrival, + actualArrivalAt: arrival, + status: 'ARRIVED' as TrainSchedule['status'], + trainNumber: demo.trainNumber, + direction: 'EXPORT', }), ); - await scheduleBookingRepo.save( - scheduleBookingRepo.create({ - trainScheduleId: schedule.id, - bookingId: booking.id, - }), - ); + for (const [bookingIndex, reference] of demo.bookingRefs.entries()) { + const weight = 5200 + trainIndex * 600 + bookingIndex * 800; + const booking = await bookingRepo.save( + bookingRepo.create({ + reference, + originYardId: originYard!.id, + destinationYardId: destinationYard!.id, + serviceTypeId: serviceType!.id, + status: 'IN_TRANSIT', + paymentStatus: 'PAID', + scheduledDate: new Date(), + contractType: 'SPOT', + equipmentReturn: 'TERMINAL', + paymentCurrency: 'ETB', + totalAmount: 0, + isGovernment: false, + tradeDirection: 'EXPORT', + freightType: bookingIndex % 2 === 0 ? 'CONTAINER' : 'BULK', + cargoTypeId: cargoType?.id ?? null, + cargoFreeText: cargoType + ? null + : `Export Djibouti interchange demo cargo ${trainIndex + 1}-${bookingIndex + 1}`, + cargoTotalWeightVgm: weight, + }), + ); + + await inventoryRepo.save( + inventoryRepo.create({ + warehouseId: warehouse!.id, + yardId: warehouseYard!.id, + zoneId: warehouseZone!.id, + bookingId: booking.id, + quantity: 1, + weight, + status: 'DISPATCHED', + inspectionStatus: 'PASSED', + arrivedAt: new Date(departure.getTime() + 2 * 60 * 60 * 1000), + inspectedAt: new Date(departure.getTime() + 3 * 60 * 60 * 1000), + readyForLoadingAt: new Date(departure.getTime() + 4 * 60 * 60 * 1000), + loadedAt: new Date(departure.getTime() + 4.5 * 60 * 60 * 1000), + dispatchedAt: departure, + notes: '[ICD-DEMO] Eligible for Djibouti export unload and interchange generation', + }), + ); + + await scheduleBookingRepo.save( + scheduleBookingRepo.create({ + trainScheduleId: schedule.id, + bookingId: booking.id, + }), + ); + } + + seededSchedules.push(schedule); } console.log('Export Djibouti interchange demo seeded.'); - console.log(`Train number: ${TRAIN_NUMBER}`); - console.log(`Schedule ID: ${schedule.id}`); + for (const schedule of seededSchedules) { + console.log(`Train number: ${schedule.trainNumber}`); + console.log(`Schedule ID: ${schedule.id}`); + } console.log('Open Djibouti Unloading, click "Auto Unload Export Items", then check Interchange Documents.'); } finally { await app.close(); diff --git a/apps/edr-freight-api/src/scripts/seed-import-djibouti-demo.ts b/apps/edr-freight-api/src/scripts/seed-import-djibouti-demo.ts new file mode 100644 index 000000000..a16ea2ee4 --- /dev/null +++ b/apps/edr-freight-api/src/scripts/seed-import-djibouti-demo.ts @@ -0,0 +1,219 @@ +import 'reflect-metadata'; +import { config } from 'dotenv'; +import { resolve } from 'path'; + +config({ path: resolve(__dirname, '../../.env') }); + +import { NestFactory } from '@nestjs/core'; +import { DataSource } from 'typeorm'; + +import { AppModule } from '../app.module'; +import { Booking } from '../modules/bookings/entities/booking.entity'; +import { Locomotive } from '../modules/locomotives/entities/locomotive.entity'; +import { ImportDjiboutiOperation } from '../modules/train-scheduling/entities/import-djibouti-operation.entity'; +import { CargoType } from '../modules/rule-engine/entities/cargo-type.entity'; +import { ServiceType } from '../modules/rule-engine/entities/service-type.entity'; +import { Yard } from '../modules/rule-engine/entities/yard.entity'; +import { TrainSchedule } from '../modules/train-schedules/entities/train-schedule.entity'; +import { TrainScheduleBooking } from '../modules/train-schedules/entities/train-schedule-booking.entity'; +import { TrainSet } from '../modules/train-sets/entities/train-set.entity'; + +const DEMO_TRAINS = [ + { + trainNumber: 'IMP-DJB-NAGAD-01', + bookingRefs: ['IMP-DJB-NGD-001', 'IMP-DJB-NGD-002', 'IMP-DJB-NGD-003'], + departureOffsetHours: 4, + }, + { + trainNumber: 'IMP-DJB-NAGAD-02', + bookingRefs: ['IMP-DJB-NGD-004', 'IMP-DJB-NGD-005', 'IMP-DJB-NGD-006'], + departureOffsetHours: 8, + }, +]; + +function addHours(date: Date, hours: number): Date { + return new Date(date.getTime() + hours * 60 * 60 * 1000); +} + +async function main() { + const app = await NestFactory.createApplicationContext(AppModule, { + logger: ['error', 'warn', 'log'], + }); + + try { + const dataSource = app.get(DataSource); + const yardRepo = dataSource.getRepository(Yard); + const serviceTypeRepo = dataSource.getRepository(ServiceType); + const cargoTypeRepo = dataSource.getRepository(CargoType); + const locomotiveRepo = dataSource.getRepository(Locomotive); + const trainSetRepo = dataSource.getRepository(TrainSet); + const scheduleRepo = dataSource.getRepository(TrainSchedule); + const bookingRepo = dataSource.getRepository(Booking); + const scheduleBookingRepo = dataSource.getRepository(TrainScheduleBooking); + const importOperationRepo = dataSource.getRepository(ImportDjiboutiOperation); + + const originYard = + (await yardRepo.findOne({ where: { code: 'NAGAD' } })) ?? + (await yardRepo.findOne({ where: { country: 'Djibouti' } })); + const destinationYard = + (await yardRepo.findOne({ where: { code: 'INDODE' } })) ?? + (await yardRepo.save( + yardRepo.create({ + code: 'INDODE', + label: 'Indode Dry Port', + country: 'Ethiopia', + isActive: true, + displayOrder: 6, + }), + )); + const serviceType = + (await serviceTypeRepo.findOne({ where: { code: 'RAIL_CONTAINER' } })) ?? + (await serviceTypeRepo.findOne({ where: { isActive: true } })); + const cargoType = await cargoTypeRepo.findOne({ where: { isActive: true } }); + + const missing = [ + !originYard ? 'NAGAD/Djibouti origin yard' : '', + !destinationYard ? 'INDODE destination yard' : '', + !serviceType ? 'service type' : '', + ].filter(Boolean); + + if (missing.length) { + throw new Error(`Cannot seed Djibouti-side import demo, missing: ${missing.join(', ')}`); + } + + const locomotive = + (await locomotiveRepo.findOne({ where: { code: 'ICD-DEMO-IMP-LOCO' } })) ?? + (await locomotiveRepo.save( + locomotiveRepo.create({ + code: 'ICD-DEMO-IMP-LOCO', + name: 'Djibouti Import Demo Locomotive', + locomotiveType: 'DIESEL', + maxPullWeightTons: 4200, + maxTrainLengthMeters: 760, + status: 'IMPORT_READY', + currentYardId: originYard!.id, + }), + )); + + const now = new Date(); + const seededSchedules: TrainSchedule[] = []; + + for (const [trainIndex, demo] of DEMO_TRAINS.entries()) { + const existingSchedule = await scheduleRepo.findOne({ + where: { trainNumber: demo.trainNumber }, + }); + + if (existingSchedule) { + console.log(`Djibouti-side import demo already seeded: ${demo.trainNumber}`); + console.log(`Schedule ID: ${existingSchedule.id}`); + seededSchedules.push(existingSchedule); + continue; + } + + const departure = addHours(now, demo.departureOffsetHours); + const arrival = addHours(departure, 12); + + const trainSet = await trainSetRepo.save( + trainSetRepo.create({ + locomotiveId: locomotive.id, + totalWeightTons: 900 + trainIndex * 120, + totalLengthMeters: 430 + trainIndex * 25, + wagonCount: 18 + trainIndex * 2, + status: 'ASSIGNED', + }), + ); + + const schedule = await scheduleRepo.save( + scheduleRepo.create({ + trainSetId: trainSet.id, + originStationId: originYard!.id, + destinationStationId: destinationYard!.id, + scheduledDepartureDate: departure, + scheduledArrivalDate: arrival, + status: 'SCHEDULED' as TrainSchedule['status'], + trainNumber: demo.trainNumber, + direction: 'IMPORT', + maxWagons: 53, + bookingWindowStatus: 'CLOSED', + }), + ); + + for (const [bookingIndex, reference] of demo.bookingRefs.entries()) { + const weight = 6800 + trainIndex * 900 + bookingIndex * 750; + const booking = await bookingRepo.save( + bookingRepo.create({ + reference, + originYardId: originYard!.id, + destinationYardId: destinationYard!.id, + serviceTypeId: serviceType!.id, + status: 'PAID', + paymentStatus: 'PAID', + scheduledDate: departure, + contractType: 'SPOT', + equipmentReturn: 'TERMINAL', + paymentCurrency: 'ETB', + totalAmount: 0, + isGovernment: false, + tradeDirection: 'IMPORT', + freightType: bookingIndex % 2 === 0 ? 'CONTAINER' : 'BULK', + cargoTypeId: cargoType?.id ?? null, + cargoFreeText: cargoType + ? null + : `Djibouti import demo cargo ${trainIndex + 1}-${bookingIndex + 1}`, + cargoTotalWeightVgm: weight, + priorityScore: 80 - trainIndex * 5 - bookingIndex, + trainScheduleId: schedule.id, + schedulingStatus: 'SCHEDULED', + scheduledAt: now, + }), + ); + + await scheduleBookingRepo.save( + scheduleBookingRepo.create({ + trainScheduleId: schedule.id, + bookingId: booking.id, + }), + ); + } + + await importOperationRepo.save( + importOperationRepo.create({ + trainScheduleId: schedule.id, + documents: { + DELIVERY_ORDER: { + reference: `DO-${demo.trainNumber}`, + uploadedAt: now.toISOString(), + uploadedBy: 'Demo Seeder', + notes: 'Demo delivery order for Djibouti-side import flow', + }, + RAILWAY_BILL: { + reference: `RB-${demo.trainNumber}`, + uploadedAt: now.toISOString(), + uploadedBy: 'Demo Seeder', + notes: 'Demo railway bill for Djibouti-side import flow', + }, + }, + performedBy: 'Demo Seeder', + notes: '[ICD-DEMO] Nagad to Indode import train for Djibouti-side flow', + }), + ); + + seededSchedules.push(schedule); + } + + console.log('Djibouti-side import demo seeded.'); + console.log(`Corridor: ${originYard!.code} -> ${destinationYard!.code}`); + for (const schedule of seededSchedules) { + console.log(`Train number: ${schedule.trainNumber}`); + console.log(`Schedule ID: ${schedule.id}`); + console.log(`Backoffice URL: /dashboard/operations/train-scheduling-v2/${schedule.id}`); + } + } finally { + await app.close(); + } +} + +main().catch((error) => { + console.error('Djibouti-side import demo seed failed:', error); + process.exit(1); +}); diff --git a/apps/edr-freight-api/src/scripts/seed-negad-indode-arrived-train.ts b/apps/edr-freight-api/src/scripts/seed-negad-indode-arrived-train.ts new file mode 100644 index 000000000..732de6262 --- /dev/null +++ b/apps/edr-freight-api/src/scripts/seed-negad-indode-arrived-train.ts @@ -0,0 +1,126 @@ +import 'reflect-metadata'; +import { config } from 'dotenv'; +import { resolve } from 'path'; + +config({ path: resolve(__dirname, '../../.env') }); + +import { AppDataSource } from '../data-source'; +import { Locomotive } from '../modules/locomotives/entities/locomotive.entity'; +import { Yard } from '../modules/rule-engine/entities/yard.entity'; +import { TrainSchedule } from '../modules/train-schedules/entities/train-schedule.entity'; +import { TrainSet } from '../modules/train-sets/entities/train-set.entity'; + +const TRAIN_NUMBER = 'NEGAD-INDODE-ARR-01'; + +function addHours(date: Date, hours: number): Date { + return new Date(date.getTime() + hours * 60 * 60 * 1000); +} + +async function main() { + const dataSource = await AppDataSource.initialize(); + + try { + await dataSource.transaction(async (manager) => { + const yardRepo = manager.getRepository(Yard); + const locomotiveRepo = manager.getRepository(Locomotive); + const trainSetRepo = manager.getRepository(TrainSet); + const scheduleRepo = manager.getRepository(TrainSchedule); + + const negad = + (await yardRepo.findOne({ where: { code: 'NEGAD' } })) ?? + (await yardRepo.save( + yardRepo.create({ + code: 'NEGAD', + label: 'Negad', + country: 'Djibouti', + isActive: true, + displayOrder: 5, + }), + )); + + const indode = + (await yardRepo.findOne({ where: { code: 'INDODE' } })) ?? + (await yardRepo.save( + yardRepo.create({ + code: 'INDODE', + label: 'Indode Dry Port', + country: 'Ethiopia', + isActive: true, + displayOrder: 6, + }), + )); + + const locomotive = + (await locomotiveRepo.findOne({ where: { code: 'NEGAD-INDODE-LOCO' } })) ?? + (await locomotiveRepo.save( + locomotiveRepo.create({ + code: 'NEGAD-INDODE-LOCO', + name: 'Negad to Indode Demo Locomotive', + locomotiveType: 'DIESEL', + maxPullWeightTons: 4200, + maxTrainLengthMeters: 760, + status: 'AVAILABLE', + currentYardId: indode.id, + }), + )); + + const now = new Date(); + const departure = addHours(now, -12); + const arrival = now; + + let schedule = await scheduleRepo.findOne({ where: { trainNumber: TRAIN_NUMBER } }); + if (!schedule) { + const trainSet = await trainSetRepo.save( + trainSetRepo.create({ + locomotiveId: locomotive.id, + totalWeightTons: 960, + totalLengthMeters: 420, + wagonCount: 18, + status: 'COMPLETED', + }), + ); + + schedule = scheduleRepo.create({ + trainSetId: trainSet.id, + originStationId: negad.id, + destinationStationId: indode.id, + scheduledDepartureDate: departure, + scheduledArrivalDate: arrival, + actualDepartureAt: departure, + actualArrivalAt: arrival, + status: 'ARRIVED' as TrainSchedule['status'], + trainNumber: TRAIN_NUMBER, + direction: 'IMPORT', + maxWagons: 53, + bookingWindowStatus: 'CLOSED', + }); + } else { + schedule.originStationId = negad.id; + schedule.destinationStationId = indode.id; + schedule.scheduledDepartureDate = departure; + schedule.scheduledArrivalDate = arrival; + schedule.actualDepartureAt = departure; + schedule.actualArrivalAt = arrival; + schedule.status = 'ARRIVED' as TrainSchedule['status']; + schedule.direction = 'IMPORT'; + schedule.bookingWindowStatus = 'CLOSED'; + + if (schedule.trainSetId) { + await trainSetRepo.update(schedule.trainSetId, { status: 'COMPLETED' }); + } + } + + const saved = await scheduleRepo.save(schedule); + console.log(`Seeded ARRIVED train ${TRAIN_NUMBER}`); + console.log(`Schedule ID: ${saved.id}`); + console.log(`Route: ${negad.code} -> ${indode.code}`); + }); + } finally { + await dataSource.destroy(); + } +} + +main().catch((err) => { + console.error('Negad to Indode arrived train seed failed:', err); + process.exit(1); +}); diff --git a/apps/edr-freight-api/src/seed/approved-first-lastmile-demo-bookings.seeder.ts b/apps/edr-freight-api/src/seed/approved-first-lastmile-demo-bookings.seeder.ts new file mode 100644 index 000000000..6b0465112 --- /dev/null +++ b/apps/edr-freight-api/src/seed/approved-first-lastmile-demo-bookings.seeder.ts @@ -0,0 +1,376 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { randomUUID } from 'crypto'; +import { DataSource, In } from 'typeorm'; + +import { BookingContainer } from '../modules/bookings/entities/booking-container.entity'; +import { Booking } from '../modules/bookings/entities/booking.entity'; +import { + Company, + CompanyStatus, + CompanyType, +} from '../modules/companies/entities/company.entity'; +import { FirstMile } from '../modules/first-mile/entities/first-mile.entity'; +import { LastMile } from '../modules/last-mile/entities/last-mile.entity'; +import { ContainerType } from '../modules/rule-engine/entities/container-type.entity'; +import { ServiceType } from '../modules/rule-engine/entities/service-type.entity'; +import { Yard } from '../modules/rule-engine/entities/yard.entity'; + +const SERVICE_TYPE_CODE = 'RAIL_CONTAINER_FIRST_LAST'; +const COMPANY_TIN = 'FLMDEMO001'; +const COMPANY_EMAIL = 'first-last-mile-demo@edr.local'; + +const YARDS = [ + { code: 'DJIBOUTI', label: 'Djibouti', country: 'Djibouti', displayOrder: 1 }, + { code: 'ADDIS_ABABA', label: 'Addis Ababa', country: 'Ethiopia', displayOrder: 2 }, +]; + +const CONTAINER_TYPES = [ + { code: '20FT', label: '20FT', sizeFt: 20 }, + { code: '40FT', label: '40FT', sizeFt: 40 }, +]; + +const DEMO_BOOKINGS = [ + { + reference: 'DEMO-IMP-FLM-001', + tradeDirection: 'IMPORT', + containerCode: '40FT', + quantity: 8, + totalWeightTons: 224, + originCode: 'DJIBOUTI', + destinationCode: 'ADDIS_ABABA', + scheduledDate: '2026-07-01T08:00:00.000Z', + firstMilePickupAddress: 'Doraleh Container Terminal, Djibouti', + firstMilePickupLat: 11.5881, + firstMilePickupLng: 43.1372, + lastMileDeliveryAddress: 'Akaki Industrial Zone, Addis Ababa', + lastMileDeliveryLat: 8.8808, + lastMileDeliveryLng: 38.7876, + }, + { + reference: 'DEMO-IMP-FLM-002', + tradeDirection: 'IMPORT', + containerCode: '20FT', + quantity: 12, + totalWeightTons: 240, + originCode: 'DJIBOUTI', + destinationCode: 'ADDIS_ABABA', + scheduledDate: '2026-07-02T08:00:00.000Z', + firstMilePickupAddress: 'PK12 Dry Port, Djibouti', + firstMilePickupLat: 11.5536, + firstMilePickupLng: 43.1103, + lastMileDeliveryAddress: 'Kality Logistics Hub, Addis Ababa', + lastMileDeliveryLat: 8.9137, + lastMileDeliveryLng: 38.7815, + }, + { + reference: 'DEMO-IMP-FLM-003', + tradeDirection: 'IMPORT', + containerCode: '40FT', + quantity: 6, + totalWeightTons: 180, + originCode: 'DJIBOUTI', + destinationCode: 'ADDIS_ABABA', + scheduledDate: '2026-07-03T08:00:00.000Z', + firstMilePickupAddress: 'Djibouti Free Zone Yard', + firstMilePickupLat: 11.5947, + firstMilePickupLng: 43.1471, + lastMileDeliveryAddress: 'Bole Lemi Industrial Park, Addis Ababa', + lastMileDeliveryLat: 8.9806, + lastMileDeliveryLng: 38.8736, + }, + { + reference: 'DEMO-IMP-FLM-004', + tradeDirection: 'IMPORT', + containerCode: '20FT', + quantity: 10, + totalWeightTons: 210, + originCode: 'DJIBOUTI', + destinationCode: 'ADDIS_ABABA', + scheduledDate: '2026-07-04T08:00:00.000Z', + firstMilePickupAddress: 'Doraleh Multipurpose Port, Djibouti', + firstMilePickupLat: 11.6062, + firstMilePickupLng: 43.1219, + lastMileDeliveryAddress: 'Addis Ababa Freight Terminal', + lastMileDeliveryLat: 9.0101, + lastMileDeliveryLng: 38.7619, + }, + { + reference: 'DEMO-IMP-FLM-005', + tradeDirection: 'IMPORT', + containerCode: '40FT', + quantity: 5, + totalWeightTons: 150, + originCode: 'DJIBOUTI', + destinationCode: 'ADDIS_ABABA', + scheduledDate: '2026-07-05T08:00:00.000Z', + firstMilePickupAddress: 'Djibouti Port Gate 3', + firstMilePickupLat: 11.5999, + firstMilePickupLng: 43.1344, + lastMileDeliveryAddress: 'Sebeta Distribution Center', + lastMileDeliveryLat: 8.9169, + lastMileDeliveryLng: 38.6177, + }, + { + reference: 'DEMO-EXP-FLM-001', + tradeDirection: 'EXPORT', + containerCode: '40FT', + quantity: 7, + totalWeightTons: 196, + originCode: 'ADDIS_ABABA', + destinationCode: 'DJIBOUTI', + scheduledDate: '2026-07-01T10:00:00.000Z', + firstMilePickupAddress: 'Bole Lemi Industrial Park, Addis Ababa', + firstMilePickupLat: 8.9806, + firstMilePickupLng: 38.8736, + lastMileDeliveryAddress: 'Doraleh Container Terminal, Djibouti', + lastMileDeliveryLat: 11.5881, + lastMileDeliveryLng: 43.1372, + }, + { + reference: 'DEMO-EXP-FLM-002', + tradeDirection: 'EXPORT', + containerCode: '20FT', + quantity: 11, + totalWeightTons: 220, + originCode: 'ADDIS_ABABA', + destinationCode: 'DJIBOUTI', + scheduledDate: '2026-07-02T10:00:00.000Z', + firstMilePickupAddress: 'Akaki Industrial Zone, Addis Ababa', + firstMilePickupLat: 8.8808, + firstMilePickupLng: 38.7876, + lastMileDeliveryAddress: 'Djibouti Free Zone Yard', + lastMileDeliveryLat: 11.5947, + lastMileDeliveryLng: 43.1471, + }, + { + reference: 'DEMO-EXP-FLM-003', + tradeDirection: 'EXPORT', + containerCode: '40FT', + quantity: 4, + totalWeightTons: 128, + originCode: 'ADDIS_ABABA', + destinationCode: 'DJIBOUTI', + scheduledDate: '2026-07-03T10:00:00.000Z', + firstMilePickupAddress: 'Kality Logistics Hub, Addis Ababa', + firstMilePickupLat: 8.9137, + firstMilePickupLng: 38.7815, + lastMileDeliveryAddress: 'Doraleh Multipurpose Port, Djibouti', + lastMileDeliveryLat: 11.6062, + lastMileDeliveryLng: 43.1219, + }, + { + reference: 'DEMO-EXP-FLM-004', + tradeDirection: 'EXPORT', + containerCode: '20FT', + quantity: 9, + totalWeightTons: 180, + originCode: 'ADDIS_ABABA', + destinationCode: 'DJIBOUTI', + scheduledDate: '2026-07-04T10:00:00.000Z', + firstMilePickupAddress: 'Addis Ababa Freight Terminal', + firstMilePickupLat: 9.0101, + firstMilePickupLng: 38.7619, + lastMileDeliveryAddress: 'PK12 Dry Port, Djibouti', + lastMileDeliveryLat: 11.5536, + lastMileDeliveryLng: 43.1103, + }, + { + reference: 'DEMO-EXP-FLM-005', + tradeDirection: 'EXPORT', + containerCode: '40FT', + quantity: 6, + totalWeightTons: 168, + originCode: 'ADDIS_ABABA', + destinationCode: 'DJIBOUTI', + scheduledDate: '2026-07-05T10:00:00.000Z', + firstMilePickupAddress: 'Sebeta Distribution Center', + firstMilePickupLat: 8.9169, + firstMilePickupLng: 38.6177, + lastMileDeliveryAddress: 'Djibouti Port Gate 3', + lastMileDeliveryLat: 11.5999, + lastMileDeliveryLng: 43.1344, + }, +] as const; + +@Injectable() +export class ApprovedFirstLastMileDemoBookingsSeeder { + private readonly logger = new Logger(ApprovedFirstLastMileDemoBookingsSeeder.name); + + constructor(private readonly dataSource: DataSource) {} + + async run() { + await this.dataSource.transaction(async (manager) => { + await manager.getRepository(Yard).upsert( + YARDS.map((yard) => ({ ...yard, isActive: true })), + { conflictPaths: { code: true } }, + ); + + await manager.getRepository(ServiceType).upsert( + { + code: SERVICE_TYPE_CODE, + serviceName: 'Rail Container with First and Last Mile', + description: 'Demo service type for approved first/last-mile bookings', + canBeBookedAlone: true, + includesFirstMile: true, + includesLastMile: true, + includesCustoms: false, + priorityBonusPoints: 0, + isActive: true, + displayOrder: 10, + }, + { conflictPaths: { code: true } }, + ); + + await manager.getRepository(ContainerType).upsert( + CONTAINER_TYPES.map((containerType, index) => ({ + ...containerType, + wagonsPerUnit: 1, + isReefer: false, + isOpenTop: false, + isActive: true, + displayOrder: index + 1, + })), + { conflictPaths: { code: true } }, + ); + + await manager.getRepository(Company).upsert( + { + name: 'First and Last Mile Demo Customer', + type: CompanyType.Customer, + status: CompanyStatus.Active, + tin: COMPANY_TIN, + vatNumber: COMPANY_TIN, + fanNumber: 'FLM0000000000001', + country: 'Ethiopia', + address: 'Addis Ababa', + phone: '251900000101', + email: COMPANY_EMAIL, + website: null, + contactPersonName: 'First Last Mile Demo', + contactPersonPhone: '251900000101', + generalManagerName: 'Demo Manager', + generalManagerEmail: COMPANY_EMAIL, + generalManagerPhone: '251900000101', + }, + { conflictPaths: { tin: true } }, + ); + + const [serviceType, company, yards, containerTypes] = await Promise.all([ + manager.getRepository(ServiceType).findOneByOrFail({ code: SERVICE_TYPE_CODE }), + manager.getRepository(Company).findOneByOrFail({ tin: COMPANY_TIN }), + manager.getRepository(Yard).find(), + manager.getRepository(ContainerType).find(), + ]); + + const yardByCode = new Map(yards.map((yard) => [yard.code, yard])); + const containerTypeByCode = new Map( + containerTypes.map((containerType) => [containerType.code, containerType]), + ); + + for (const demoBooking of DEMO_BOOKINGS) { + const origin = yardByCode.get(demoBooking.originCode); + const destination = yardByCode.get(demoBooking.destinationCode); + const containerType = containerTypeByCode.get(demoBooking.containerCode); + + if (!origin || !destination || !containerType) { + throw new Error(`approved_first_last_mile_demo_dependency_missing:${demoBooking.reference}`); + } + + const wagonsRequired = + Number(demoBooking.quantity) * Number(containerType.wagonsPerUnit ?? 1); + const vgmPerUnitTons = demoBooking.totalWeightTons / demoBooking.quantity; + + await manager.getRepository(Booking).upsert( + { + reference: demoBooking.reference, + companyId: company.id, + status: 'APPROVED', + scheduledDate: new Date(demoBooking.scheduledDate), + estimatedShipmentDate: new Date(demoBooking.scheduledDate), + totalAmount: demoBooking.totalWeightTons * 25, + paymentStatus: 'PENDING', + contractType: 'NEW', + serviceTypeId: serviceType.id, + firstMilePickupAddress: demoBooking.firstMilePickupAddress, + firstMilePickupLat: demoBooking.firstMilePickupLat, + firstMilePickupLng: demoBooking.firstMilePickupLng, + lastMileDeliveryAddress: demoBooking.lastMileDeliveryAddress, + lastMileDeliveryLat: demoBooking.lastMileDeliveryLat, + lastMileDeliveryLng: demoBooking.lastMileDeliveryLng, + equipmentReturn: 'WITHOUT_RETURN', + originYardId: origin.id, + destinationYardId: destination.id, + tradeDirection: demoBooking.tradeDirection, + freightType: 'CONTAINER', + cargoTypeId: null, + cargoFreeText: 'Demo container cargo', + shippingLineId: null, + cargoTotalWeightVgm: demoBooking.totalWeightTons, + isHazardous: false, + isReefer: false, + paymentCurrency: 'ETB', + approvedByStaffAt: new Date(), + priorityScore: 20, + wagonsRequired, + schedulingStatus: 'NOT_SCHEDULED', + versionNumber: 1, + }, + { conflictPaths: { reference: true } }, + ); + + const booking = await manager.getRepository(Booking).findOneByOrFail({ + reference: demoBooking.reference, + }); + + await manager.getRepository(BookingContainer).delete({ bookingId: booking.id }); + await manager.getRepository(BookingContainer).insert({ + id: randomUUID(), + bookingId: booking.id, + containerTypeId: containerType.id, + quantity: demoBooking.quantity, + vgmPerUnitTons, + totalVgmTons: demoBooking.totalWeightTons, + wagonsRequired, + weightLimitRuleId: null, + isOverweight: vgmPerUnitTons > 35, + overweightExcessTons: vgmPerUnitTons > 35 ? vgmPerUnitTons - 35 : null, + }); + } + + const bookings = await manager.getRepository(Booking).find({ + where: { reference: In(DEMO_BOOKINGS.map((booking) => booking.reference)) }, + select: { id: true }, + }); + const bookingIds = bookings.map((booking) => booking.id); + + await manager.getRepository(FirstMile).delete({ bookingId: In(bookingIds) }); + await manager.getRepository(LastMile).delete({ bookingId: In(bookingIds) }); + + await manager.getRepository(FirstMile).insert( + bookingIds.map((bookingId) => ({ + bookingId, + status: 'READY_TO_TRANSIT', + advancedPayment: 0, + remainingPayment: 0, + estimatedKm: 18, + exactKm: null, + vehicleId: null, + })), + ); + + await manager.getRepository(LastMile).insert( + bookingIds.map((bookingId) => ({ + bookingId, + status: 'READY_TO_TRANSIT', + advancedPayment: 0, + remainingPayment: 0, + estimatedKm: 22, + exactKm: null, + vehicleId: null, + })), + ); + }); + + this.logger.log('Seeded 5 import and 5 export approved bookings with first/last-mile legs'); + } +} diff --git a/apps/edr-freight-api/src/seed/export-djibouti-interchange-demo.seeder.ts b/apps/edr-freight-api/src/seed/export-djibouti-interchange-demo.seeder.ts new file mode 100644 index 000000000..f4b258718 --- /dev/null +++ b/apps/edr-freight-api/src/seed/export-djibouti-interchange-demo.seeder.ts @@ -0,0 +1,202 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { DataSource } from 'typeorm'; + +import { Booking } from '../modules/bookings/entities/booking.entity'; +import { Locomotive } from '../modules/locomotives/entities/locomotive.entity'; +import { CargoType } from '../modules/rule-engine/entities/cargo-type.entity'; +import { ServiceType } from '../modules/rule-engine/entities/service-type.entity'; +import { Yard } from '../modules/rule-engine/entities/yard.entity'; +import { TrainSchedule } from '../modules/train-schedules/entities/train-schedule.entity'; +import { TrainScheduleBooking } from '../modules/train-schedules/entities/train-schedule-booking.entity'; +import { TrainSet } from '../modules/train-sets/entities/train-set.entity'; +import { WarehouseInventory } from '../modules/warehouses/entities/warehouse-inventory.entity'; +import { WarehouseYard } from '../modules/warehouses/entities/warehouse-yard.entity'; +import { WarehouseZone } from '../modules/warehouses/entities/warehouse-zone.entity'; +import { Warehouse } from '../modules/warehouses/entities/warehouse.entity'; + +const SEED_FLAG = 'SEED_EXPORT_DJIBOUTI_INTERCHANGE_DEMO'; + +const DEMO_TRAINS = [ + { + trainNumber: 'ICD-DEMO-EXP-DJ-01', + bookingRefs: ['ICD-DEMO-EXP-001', 'ICD-DEMO-EXP-002', 'ICD-DEMO-EXP-003'], + arrivalOffsetHours: 1, + }, + { + trainNumber: 'ICD-DEMO-EXP-DJ-02', + bookingRefs: ['ICD-DEMO-EXP-004', 'ICD-DEMO-EXP-005', 'ICD-DEMO-EXP-006'], + arrivalOffsetHours: 2, + }, +]; + +@Injectable() +export class ExportDjiboutiInterchangeDemoSeeder { + private readonly logger = new Logger(ExportDjiboutiInterchangeDemoSeeder.name); + + constructor(private readonly dataSource: DataSource) {} + + async run(): Promise { + if (process.env[SEED_FLAG]?.trim().toLowerCase() !== 'true') { + this.logger.log(`Skipping export Djibouti interchange demo seed because ${SEED_FLAG} is not enabled`); + return; + } + + try { + const yardRepo = this.dataSource.getRepository(Yard); + const serviceTypeRepo = this.dataSource.getRepository(ServiceType); + const cargoTypeRepo = this.dataSource.getRepository(CargoType); + const warehouseRepo = this.dataSource.getRepository(Warehouse); + const warehouseYardRepo = this.dataSource.getRepository(WarehouseYard); + const warehouseZoneRepo = this.dataSource.getRepository(WarehouseZone); + const bookingRepo = this.dataSource.getRepository(Booking); + const inventoryRepo = this.dataSource.getRepository(WarehouseInventory); + const locomotiveRepo = this.dataSource.getRepository(Locomotive); + const trainSetRepo = this.dataSource.getRepository(TrainSet); + const scheduleRepo = this.dataSource.getRepository(TrainSchedule); + const scheduleBookingRepo = this.dataSource.getRepository(TrainScheduleBooking); + + const originYard = + (await yardRepo.findOne({ where: { code: 'MOJO' } })) ?? + (await yardRepo.findOne({ where: { country: 'Ethiopia' } })); + const destinationYard = + (await yardRepo.findOne({ where: { code: 'DJIB_PORT' } })) ?? + (await yardRepo.findOne({ where: { code: 'DJIBOUTI' } })) ?? + (await yardRepo.findOne({ where: { country: 'Djibouti' } })); + const serviceType = + (await serviceTypeRepo.findOne({ where: { code: 'RAIL_CONTAINER' } })) ?? + (await serviceTypeRepo.findOne({ where: { isActive: true } })); + const cargoType = await cargoTypeRepo.findOne({ where: { isActive: true } }); + const warehouse = await warehouseRepo.findOne({ where: { code: 'INDODE_OPEN' } }); + const warehouseYard = warehouse + ? await warehouseYardRepo.findOne({ where: { warehouseId: warehouse.id } }) + : null; + const warehouseZone = warehouseYard + ? await warehouseZoneRepo.findOne({ where: { yardId: warehouseYard.id } }) + : null; + + const missing = [ + !originYard ? 'Ethiopian origin yard' : '', + !destinationYard ? 'Djibouti destination yard' : '', + !serviceType ? 'service type' : '', + !warehouse ? 'INDODE_OPEN warehouse' : '', + !warehouseYard ? 'warehouse yard' : '', + !warehouseZone ? 'warehouse zone' : '', + ].filter(Boolean); + + if (missing.length) { + this.logger.warn(`Cannot seed export Djibouti interchange demo, missing: ${missing.join(', ')}`); + return; + } + + const locomotive = + (await locomotiveRepo.findOne({ where: { code: 'ICD-DEMO-LOCO' } })) ?? + (await locomotiveRepo.save( + locomotiveRepo.create({ + code: 'ICD-DEMO-LOCO', + name: 'Interchange Demo Locomotive', + maxPullWeightTons: 4000, + }), + )); + + const now = Date.now(); + let seeded = 0; + let skipped = 0; + + for (const [trainIndex, demo] of DEMO_TRAINS.entries()) { + const existingSchedule = await scheduleRepo.findOne({ where: { trainNumber: demo.trainNumber } }); + if (existingSchedule) { + skipped += 1; + continue; + } + + const arrival = new Date(now - demo.arrivalOffsetHours * 60 * 60 * 1000); + const departure = new Date(arrival.getTime() - 5 * 60 * 60 * 1000); + + const trainSet = await trainSetRepo.save( + trainSetRepo.create({ + locomotiveId: locomotive.id, + totalWeightTons: 700 + trainIndex * 80, + totalLengthMeters: 360 + trainIndex * 20, + wagonCount: 12 + trainIndex, + status: 'COMPLETED', + }), + ); + + const schedule = await scheduleRepo.save( + scheduleRepo.create({ + trainSetId: trainSet.id, + originStationId: originYard!.id, + destinationStationId: destinationYard!.id, + scheduledDepartureDate: departure, + scheduledArrivalDate: arrival, + actualArrivalAt: arrival, + status: 'ARRIVED' as TrainSchedule['status'], + trainNumber: demo.trainNumber, + direction: 'EXPORT', + }), + ); + + for (const [bookingIndex, reference] of demo.bookingRefs.entries()) { + const weight = 5200 + trainIndex * 600 + bookingIndex * 800; + const booking = await bookingRepo.save( + bookingRepo.create({ + reference, + originYardId: originYard!.id, + destinationYardId: destinationYard!.id, + serviceTypeId: serviceType!.id, + status: 'IN_TRANSIT', + paymentStatus: 'PAID', + scheduledDate: new Date(), + contractType: 'SPOT', + equipmentReturn: 'TERMINAL', + paymentCurrency: 'ETB', + totalAmount: 0, + isGovernment: false, + tradeDirection: 'EXPORT', + freightType: bookingIndex % 2 === 0 ? 'CONTAINER' : 'BULK', + cargoTypeId: cargoType?.id ?? null, + cargoFreeText: cargoType + ? null + : `Export Djibouti interchange demo cargo ${trainIndex + 1}-${bookingIndex + 1}`, + cargoTotalWeightVgm: weight, + }), + ); + + await inventoryRepo.save( + inventoryRepo.create({ + warehouseId: warehouse!.id, + yardId: warehouseYard!.id, + zoneId: warehouseZone!.id, + bookingId: booking.id, + quantity: 1, + weight, + status: 'DISPATCHED', + inspectionStatus: 'PASSED', + arrivedAt: new Date(departure.getTime() + 2 * 60 * 60 * 1000), + inspectedAt: new Date(departure.getTime() + 3 * 60 * 60 * 1000), + readyForLoadingAt: new Date(departure.getTime() + 4 * 60 * 60 * 1000), + loadedAt: new Date(departure.getTime() + 4.5 * 60 * 60 * 1000), + dispatchedAt: departure, + notes: '[ICD-DEMO] Eligible for Djibouti export unload and interchange generation', + }), + ); + + await scheduleBookingRepo.save( + scheduleBookingRepo.create({ + trainScheduleId: schedule.id, + bookingId: booking.id, + }), + ); + } + + seeded += 1; + } + + this.logger.log(`Export Djibouti interchange demo seed complete: ${seeded} train(s) seeded, ${skipped} skipped`); + } catch (error) { + this.logger.error( + `ExportDjiboutiInterchangeDemoSeeder failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } +} diff --git a/apps/edr-freight-web/backoffice/src/components/cargoes/CargoFormDialog.tsx b/apps/edr-freight-web/backoffice/src/components/cargoes/CargoFormDialog.tsx index ff37edb72..625e32a3a 100644 --- a/apps/edr-freight-web/backoffice/src/components/cargoes/CargoFormDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/cargoes/CargoFormDialog.tsx @@ -14,7 +14,7 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; import { Loader2 } from 'lucide-react'; -import { API_BASE_URL } from "@/constants/apiConfig"; +import { API_BASE_URL } from '@/constants/apiConfig'; interface Cargo { id: string; diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx index a2437fe8f..e2ff9fb3a 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx @@ -58,6 +58,7 @@ interface TruckEntranceFormState { consigneeDetails: string; edrDigitalBookingId: string; tin: string; + customerPhone: string; truckPlateNumber: string; trailerPlateNumber: string; assignedEquipmentNumber: string; @@ -85,11 +86,27 @@ interface TruckEntranceFormState { warehouseManagerName: string; } +interface LockedTruckEntranceFields { + ownerName?: boolean; + consigneeDetails?: boolean; + tin?: boolean; + edrDigitalBookingId?: boolean; + customerPhone?: boolean; + assignedEquipmentNumber?: boolean; + itemDescription?: boolean; + packagingType?: boolean; + unitCount?: boolean; + grossWeightKg?: boolean; +} + +type PackagingFreightType = 'CONTAINER' | 'BULK' | 'MIXED'; + const emptyTruckEntrance = (): TruckEntranceFormState => ({ ownerName: '', consigneeDetails: '', edrDigitalBookingId: '', tin: '', + customerPhone: '', truckPlateNumber: '', trailerPlateNumber: '', assignedEquipmentNumber: '', @@ -118,22 +135,13 @@ const emptyTruckEntrance = (): TruckEntranceFormState => ({ }); const toTruckEntrancePayload = (form: TruckEntranceFormState): TruckEntrancePayload => ({ - ownerName: form.ownerName.trim() || undefined, - consigneeDetails: form.consigneeDetails.trim() || undefined, - edrDigitalBookingId: form.edrDigitalBookingId.trim() || undefined, - tin: form.tin.trim() || undefined, truckPlateNumber: form.truckPlateNumber.trim(), trailerPlateNumber: form.trailerPlateNumber.trim() || undefined, - assignedEquipmentNumber: form.assignedEquipmentNumber.trim() || undefined, customsSealNumber: form.customsSealNumber.trim() || undefined, declarationNumber: form.declarationNumber.trim() || undefined, incoterms: form.incoterms.trim() || undefined, hsCodes: form.hsCodes.trim() || undefined, itemCode: form.itemCode.trim() || undefined, - itemDescription: form.itemDescription.trim() || undefined, - packagingType: form.packagingType.trim() || undefined, - unitCount: form.unitCount === '' ? undefined : Number(form.unitCount), - grossWeightKg: form.grossWeightKg === '' ? undefined : Number(form.grossWeightKg), netWeightKg: form.netWeightKg === '' ? undefined : Number(form.netWeightKg), volumeDimensions: form.volumeDimensions.trim() || undefined, conditionAtReceipt: form.conditionAtReceipt.trim() || undefined, @@ -149,13 +157,130 @@ const toTruckEntrancePayload = (form: TruckEntranceFormState): TruckEntrancePayl warehouseManagerName: form.warehouseManagerName.trim() || undefined, }); +const commonNonEmptyValue = (values: Array) => { + const unique = [...new Set(values.map((value) => value?.trim()).filter(Boolean))] as string[]; + return unique.length === 1 ? unique[0] : ''; +}; + +const truckEntranceFromBookings = (bookings: EligibleBooking[]): { + form: TruckEntranceFormState; + lockedFields: LockedTruckEntranceFields; + packagingFreightType: PackagingFreightType; +} => { + const ownerName = commonNonEmptyValue(bookings.map((booking) => booking.customer)); + const tin = commonNonEmptyValue(bookings.map((booking) => booking.customerTin)); + const customerPhone = commonNonEmptyValue(bookings.map((booking) => booking.customerPhone)); + const consigneeDetails = commonNonEmptyValue(bookings.map((booking) => booking.customer)); + const assignedEquipmentNumber = commonNonEmptyValue(bookings.map((booking) => booking.containerNumber)); + const itemDescription = commonNonEmptyValue(bookings.map((booking) => booking.cargoDescription ?? booking.cargo)); + const packagingType = commonNonEmptyValue(bookings.map((booking) => booking.containerPackagingType)); + const edrDigitalBookingId = + bookings.length === 1 + ? bookings[0]?.reference ?? bookings[0]?.id ?? '' + : commonNonEmptyValue(bookings.map((booking) => booking.reference)); + const firstMileBooking = bookings.length === 1 ? bookings[0] : null; + const unitCount = + bookings.length === 1 && bookings[0]?.containerQuantity != null + ? Number(bookings[0].containerQuantity) + : ''; + const grossWeightKg = + bookings.length === 1 && bookings[0]?.weight != null + ? Number(bookings[0].weight) + : ''; + const freightTypes = [...new Set(bookings.map((booking) => booking.freightType).filter(Boolean))]; + const packagingFreightType = + freightTypes.length === 1 && freightTypes[0] === 'CONTAINER' + ? 'CONTAINER' + : freightTypes.length === 1 && freightTypes[0] === 'BULK' + ? 'BULK' + : 'MIXED'; + + return { + form: { + ...emptyTruckEntrance(), + ownerName, + consigneeDetails, + tin, + customerPhone, + edrDigitalBookingId, + assignedEquipmentNumber, + itemDescription, + packagingType, + unitCount, + grossWeightKg, + truckPlateNumber: firstMileBooking?.firstMileTruckPlateNumber ?? '', + trailerPlateNumber: firstMileBooking?.firstMileTrailerPlateNumber ?? '', + driverName: firstMileBooking?.firstMileDriverName ?? '', + driverPhone: firstMileBooking?.firstMileDriverPhone ?? '', + driverLicenseNumber: firstMileBooking?.firstMileDriverLicenseNumber ?? '', + truckType: firstMileBooking?.firstMileTruckType ?? '', + }, + lockedFields: { + ownerName: Boolean(ownerName), + consigneeDetails: Boolean(consigneeDetails), + tin: Boolean(tin), + edrDigitalBookingId: Boolean(edrDigitalBookingId), + customerPhone: Boolean(customerPhone), + assignedEquipmentNumber: Boolean(assignedEquipmentNumber), + itemDescription: Boolean(itemDescription), + packagingType: Boolean(packagingType), + unitCount: unitCount !== '', + grossWeightKg: grossWeightKg !== '', + }, + packagingFreightType, + }; +}; + +const BULK_PACKAGING_TYPE_OPTIONS = [ + { value: 'BAG', label: 'Bag' }, + { value: 'SACK', label: 'Sack' }, + { value: 'BALE', label: 'Bale' }, + { value: 'CARTON', label: 'Carton' }, + { value: 'CRATE', label: 'Crate' }, + { value: 'DRUM', label: 'Drum' }, + { value: 'BARREL', label: 'Barrel' }, + { value: 'PALLET', label: 'Pallet' }, + { value: 'LOOSE_BULK', label: 'Loose bulk' }, + { value: 'OTHER', label: 'Other' }, +]; + +const CONTAINER_PACKAGING_TYPE_OPTIONS = [ + { value: 'CONTAINER_20FT', label: '20 ft container' }, + { value: 'CONTAINER_40FT', label: '40 ft container' }, + { value: 'CONTAINER_45FT', label: '45 ft container' }, + { value: 'REEFER_CONTAINER', label: 'Reefer container' }, + { value: 'TANK_CONTAINER', label: 'Tank container' }, + { value: 'FLAT_RACK_CONTAINER', label: 'Flat rack container' }, + { value: 'OPEN_TOP_CONTAINER', label: 'Open top container' }, + { value: 'OTHER_CONTAINER', label: 'Other container' }, +]; + +const packagingOptionsFor = (freightType: PackagingFreightType) => + freightType === 'CONTAINER' + ? CONTAINER_PACKAGING_TYPE_OPTIONS + : freightType === 'BULK' + ? BULK_PACKAGING_TYPE_OPTIONS + : [...CONTAINER_PACKAGING_TYPE_OPTIONS, ...BULK_PACKAGING_TYPE_OPTIONS]; + function TruckEntranceFields({ value, onChange, + lockedFields, + packagingFreightType = 'MIXED', }: { value: TruckEntranceFormState; onChange: (next: TruckEntranceFormState) => void; + lockedFields?: LockedTruckEntranceFields; + packagingFreightType?: PackagingFreightType; }) { + const packagingOptions = packagingOptionsFor(packagingFreightType); + const quantityLabel = + packagingFreightType === 'CONTAINER' + ? 'Container quantity' + : packagingFreightType === 'BULK' + ? 'Unit count' + : 'Quantity'; + return ( Customer and cargo ownership @@ -163,11 +288,13 @@ function TruckEntranceFields({ onChange({ ...value, ownerName: e.currentTarget.value })} /> onChange({ ...value, consigneeDetails: e.currentTarget.value })} /> @@ -175,14 +302,22 @@ function TruckEntranceFields({ onChange({ ...value, edrDigitalBookingId: e.currentTarget.value })} /> onChange({ ...value, tin: e.currentTarget.value })} /> + onChange({ ...value, customerPhone: e.currentTarget.value })} + /> Transport and equipment tracking @@ -202,6 +337,7 @@ function TruckEntranceFields({ onChange({ ...value, assignedEquipmentNumber: e.currentTarget.value })} /> onChange({ ...value, itemDescription: e.currentTarget.value })} /> - onChange({ ...value, packagingType: e.currentTarget.value })} + onChange={(v) => onChange({ ...value, packagingType: v ?? '' })} /> onChange({ ...value, unitCount: v === '' ? '' : Number(v) })} /> @@ -302,6 +444,7 @@ function TruckEntranceFields({ label="Gross weight (kg)" min={0} value={value.grossWeightKg} + readOnly={lockedFields?.grossWeightKg} onChange={(v) => onChange({ ...value, grossWeightKg: v === '' ? '' : Number(v) })} /> ([]); const [truckForm, setTruckForm] = useState(emptyTruckEntrance()); + const [lockedTruckFields, setLockedTruckFields] = useState({}); + const [packagingFreightType, setPackagingFreightType] = useState('MIXED'); const locationReady = Boolean(location.warehouseId && location.yardId && location.zoneId); const canReceiveBooking = (row: EligibleBooking) => @@ -539,6 +684,14 @@ function EligibleTab({ const selectableRows = statusFilteredRows.filter(canReceiveBooking); const allSelected = selectableRows.length > 0 && selected.size === selectableRows.length; const someSelected = selected.size > 0 && !allSelected; + const pendingReceiveRows = useMemo( + () => + pendingReceiveIds + .map((id) => rows.find((item) => item.id === id)) + .filter(Boolean) as EligibleBooking[], + [pendingReceiveIds, rows], + ); + const pendingHasFirstMile = pendingReceiveRows.some((row) => row.hasFirstMile); const toggleAll = () => setSelected(allSelected ? new Set() : new Set(selectableRows.map((r) => r.id))); @@ -549,6 +702,29 @@ function EligibleTab({ return next; }); + const receiveBookings = async (bookingIds: string[], truckEntrance?: TruckEntrancePayload) => { + try { + const r = await bulkReceive.mutateAsync({ + direction, + ...location, + bookingIds, + ...(truckEntrance ? { truckEntrance } : {}), + }); + toast({ + title: `${r.receivedCount} received at ${direction === 'EXPORT' ? 'facility' : 'warehouse'}`, + description: r.results.find((item) => item.grnNumber)?.grnNumber ?? (r.skippedCount ? `${r.skippedCount} skipped` : undefined), + }); + setSelected(new Set()); + setTruckOpen(false); + setPendingReceiveIds([]); + setLockedTruckFields({}); + setPackagingFreightType('MIXED'); + onChanged?.(); + } catch (error) { + toast({ variant: 'destructive', title: 'Receive failed', description: extractErrorMessage(error) }); + } + }; + const openTruckReceive = (bookingIds: string[]) => { if (!locationReady) { toast({ variant: 'destructive', title: 'Select warehouse, yard and zone first' }); @@ -564,13 +740,18 @@ function EligibleTab({ toast({ variant: 'destructive', title: 'No selected booking is ready to receive' }); return; } - const row = filteredIds.length === 1 ? rows.find((item) => item.id === filteredIds[0]) : null; + const selectedRows = filteredIds + .map((id) => rows.find((item) => item.id === id)) + .filter(Boolean) as EligibleBooking[]; + if (direction === 'IMPORT') { + void receiveBookings(filteredIds); + return; + } + const { form, lockedFields, packagingFreightType: nextPackagingFreightType } = truckEntranceFromBookings(selectedRows); setPendingReceiveIds(filteredIds); - setTruckForm({ - ...emptyTruckEntrance(), - truckPlateNumber: row?.firstMileTruckPlateNumber ?? '', - trailerPlateNumber: row?.firstMileTrailerPlateNumber ?? '', - }); + setTruckForm(form); + setLockedTruckFields(lockedFields); + setPackagingFreightType(nextPackagingFreightType); setTruckOpen(true); }; @@ -579,24 +760,7 @@ function EligibleTab({ toast({ variant: 'destructive', title: 'Truck, driver and entrance tare weight are required' }); return; } - try { - const r = await bulkReceive.mutateAsync({ - direction, - ...location, - bookingIds: pendingReceiveIds, - truckEntrance: toTruckEntrancePayload(truckForm), - }); - toast({ - title: `${r.receivedCount} received at ${direction === 'EXPORT' ? 'facility' : 'warehouse'}`, - description: r.results.find((item) => item.grnNumber)?.grnNumber ?? (r.skippedCount ? `${r.skippedCount} skipped` : undefined), - }); - setSelected(new Set()); - setTruckOpen(false); - setPendingReceiveIds([]); - onChanged?.(); - } catch (error) { - toast({ variant: 'destructive', title: 'Receive failed', description: extractErrorMessage(error) }); - } + await receiveBookings(pendingReceiveIds, toTruckEntrancePayload(truckForm)); }; const loadPassedExport = async () => { @@ -800,18 +964,33 @@ function EligibleTab({ )} - setTruckOpen(false)} title="Truck Entrance Registration" centered size="lg"> + setTruckOpen(false)} + title="Export Truck Arrival / First Mile Receive Form" + centered + size="lg" + > - - Register the arriving truck and driver before receiving {pendingReceiveIds.length} booking{pendingReceiveIds.length === 1 ? '' : 's'}. - - + } color={pendingHasFirstMile ? 'green' : 'blue'} variant="light"> + + {pendingHasFirstMile + ? 'Assigned first-mile truck and driver details are prefilled. Confirm or update the actual arrival details before GRN.' + : 'Register the customer or third-party truck and driver before export receiving and GRN.'} + + + @@ -820,6 +999,161 @@ function EligibleTab({ ); } +/** Export received items awaiting inspection before loading. */ +function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged?: () => void }) { + const { toast } = useToast(); + const { data: rows = [], isLoading } = useQuery( + api.warehouses.receivedExport.queryOptions({ enabled }), + ); + const inspectMutation = useMutation( + api.warehouses.bulkMarkInspected.mutationOptions(), + ); + const [selected, setSelected] = useState>(new Set()); + const [inspectId, setInspectId] = useState(null); + + const pendingRows = rows.filter((r) => r.inspectionStatus !== 'PASSED'); + const allSelected = pendingRows.length > 0 && selected.size === pendingRows.length; + const someSelected = selected.size > 0 && !allSelected; + const toggleAll = () => + setSelected(allSelected ? new Set() : new Set(pendingRows.map((r) => r.id))); + const toggleOne = (id: string) => + setSelected((prev) => { + const next = new Set(prev); + next.has(id) ? next.delete(id) : next.add(id); + return next; + }); + + const markInspected = async () => { + if (selected.size === 0) { + toast({ variant: 'destructive', title: 'Select at least one item' }); + return; + } + try { + const r = await inspectMutation.mutateAsync({ inventoryIds: [...selected] }); + toast({ + title: `${r.inspectedCount} marked inspected`, + description: r.skippedCount ? `${r.skippedCount} skipped` : undefined, + }); + setSelected(new Set()); + onChanged?.(); + } catch (error) { + toast({ variant: 'destructive', title: 'Mark inspected failed', description: extractErrorMessage(error) }); + } + }; + + return ( + + + + Selected: {selected.size} / {pendingRows.length} received + + + + + {isLoading ? ( + + + + ) : rows.length === 0 ? ( + + No received export items awaiting inspection. + + ) : ( + + + + + + + + Booking Ref + Booking ID + Customer ID + Customer Name + Container # + Cargo Type + Weight + Route + Inspection + Status + Actions + + + + {rows.map((r: ReadyToLoadRow) => { + const selectable = r.inspectionStatus !== 'PASSED'; + return ( + + + toggleOne(r.id)} + /> + + + {r.bookingReference ?? '—'} + + + {r.bookingId ? `${r.bookingId.slice(0, 8)}…` : '—'} + + + {r.customerId ? `${r.customerId.slice(0, 8)}…` : '—'} + + {r.customerName ?? '—'} + {r.containerNumber ?? '—'} + {r.cargoType ?? '—'} + {formatNumber(Number(r.weight))} + + {r.origin || r.destination ? `${r.origin ?? '?'} → ${r.destination ?? '?'}` : '—'} + + + + {r.inspectionStatus ?? 'PENDING'} + + + + + {r.status} + + + + + + + ); + })} + +
+
+ )} + + setInspectId(null)} + /> +
+ ); +} + /** Export items that passed inspection and are queued to be loaded onto a train. */ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?: () => void }) { const { toast } = useToast(); @@ -1620,6 +1954,7 @@ function BulkReceiveModal({ opened, onClose, onReceived }: ReceiveInventoryModal Receive Queue + Received Ready To Load Loaded Dispatch Queue @@ -1628,6 +1963,9 @@ function BulkReceiveModal({ opened, onClose, onReceived }: ReceiveInventoryModal + + + @@ -1704,10 +2042,6 @@ function SingleBookingReceiveModal({ toast({ variant: 'destructive', title: 'Select warehouse, yard and zone' }); return; } - if (form.quantity === '' || form.weight === '') { - toast({ variant: 'destructive', title: 'Quantity and weight are required' }); - return; - } if (!truckForm.truckPlateNumber.trim() || !truckForm.driverName.trim() || !truckForm.driverPhone.trim() || truckForm.entranceTareWeightKg === '') { toast({ variant: 'destructive', title: 'Truck, driver and entrance tare weight are required' }); return; @@ -1717,8 +2051,8 @@ function SingleBookingReceiveModal({ warehouseId: form.warehouseId, yardId: form.yardId, zoneId: form.zoneId, - quantity: Number(form.quantity), - weight: Number(form.weight), + quantity: 0, + weight: 0, volume: form.volume === '' ? undefined : Number(form.volume), notes: form.notes.trim() || undefined, truckEntrance: toTruckEntrancePayload(truckForm), @@ -1744,21 +2078,13 @@ function SingleBookingReceiveModal({ setForm((f) => ({ ...f, ...next }))} /> + } color="blue" variant="light"> + + Customer, TIN, phone, quantity and weight are pulled from the selected booking when the GRN is generated. + + + - setForm((f) => ({ ...f, quantity: value === '' ? '' : Number(value) }))} - /> - setForm((f) => ({ ...f, weight: value === '' ? '' : Number(value) }))} - /> ({ + value: powerPlate, + label: `${index + 1}. ${powerPlate} / ${trailerPlate}`, + trailerPlate, +})); + +const toIsoDateTime = (value: string) => { + if (!value) return undefined; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? undefined : date.toISOString(); +}; + export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalProps) { const { toast } = useToast(); const releaseMutation = useMutation(api.warehouses.release.mutationOptions()); const [reference, setReference] = useState(''); + const [truckPlateNumber, setTruckPlateNumber] = useState(''); + const [trailerPlateNumber, setTrailerPlateNumber] = useState(''); + const [driverName, setDriverName] = useState(''); + const [driverLicense, setDriverLicense] = useState(''); + const [driverPhone, setDriverPhone] = useState(''); + const [truckType, setTruckType] = useState(''); + const [containerNumber, setContainerNumber] = useState(''); + const [gateInTime, setGateInTime] = useState(''); + const [tareWeight, setTareWeight] = useState(''); + const [grossWeight, setGrossWeight] = useState(''); + const [netWeight, setNetWeight] = useState(''); + const [gateOutTime, setGateOutTime] = useState(''); const [downloading, setDownloading] = useState(false); useEffect(() => { - if (opened) setReference(item?.releaseOrderReference ?? ''); + if (opened) { + setReference(item?.releaseOrderReference ?? ''); + setTruckPlateNumber(''); + setTrailerPlateNumber(''); + setDriverName(''); + setDriverLicense(''); + setDriverPhone(''); + setTruckType(''); + setContainerNumber(''); + setGateInTime(''); + setTareWeight(''); + setGrossWeight(''); + setNetWeight(item?.weight != null ? Number(item.weight) : ''); + setGateOutTime(''); + } }, [opened, item]); + const computedNetWeight = + tareWeight !== '' && grossWeight !== '' ? Number((Number(grossWeight) - Number(tareWeight)).toFixed(3)) : null; + const weightMismatch = + computedNetWeight != null && netWeight !== '' && Math.abs(Number(netWeight) - computedNetWeight) > 0.001; + const handleSubmit = async () => { if (!item) return; + if (!truckPlateNumber.trim() || !driverName.trim()) { + toast({ variant: 'destructive', title: 'Truck plate and driver name are required' }); + return; + } + if (tareWeight === '' || grossWeight === '') { + toast({ variant: 'destructive', title: 'Tare and gross weight are required' }); + return; + } + if (weightMismatch) { + toast({ + variant: 'destructive', + title: 'Weight mismatch', + description: 'Gate clearance is blocked. Reassign the item to warehouse if it cannot exit.', + }); + return; + } const pdfWindow = window.open('', '_blank'); try { const released = await releaseMutation.mutateAsync({ id: item.id, - payload: { reference: reference.trim() || undefined }, + payload: { + reference: reference.trim() || undefined, + bookingId: item.bookingId ?? undefined, + customerId: undefined, + truckPlateNumber: truckPlateNumber.trim(), + trailerPlateNumber: trailerPlateNumber.trim() || undefined, + driverName: driverName.trim(), + driverLicense: driverLicense.trim() || undefined, + driverPhone: driverPhone.trim() || undefined, + truckType: truckType.trim() || undefined, + containerNumber: containerNumber.trim() || undefined, + gateInTime: toIsoDateTime(gateInTime), + tareWeight: Number(tareWeight), + grossWeight: Number(grossWeight), + netWeight: netWeight === '' ? computedNetWeight ?? undefined : Number(netWeight), + gateOutTime: toIsoDateTime(gateOutTime), + }, }); setDownloading(true); const response = await warehouseService.downloadReleaseDocument(item.id); @@ -56,12 +148,12 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr }; return ( - + } color="orange" variant="light"> - Creates the warehouse release document with booking, customer, cargo and location details. The - printed paper authorizes the goods to leave the warehouse gate. + Save the exit inspection before generating the exit paper. Gate clearance is blocked when + recorded net weight does not equal gross weight minus tare weight. setReference(e.currentTarget.value)} /> + setQrInput(e.target.value)} + onKeyPress={handleKeyPress} + placeholder="Type ticket number" + className="w-full px-4 py-4 text-lg border border-gray-300 dark:border-slate-600 rounded-xl + focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 + dark:bg-slate-700 dark:text-white dark:placeholder-slate-400 + font-mono tracking-wide" + autoCapitalize="characters" + autoComplete="off" + autoFocus + /> +
+ +
+ + + +
+
+ + + {/* Success Message */} + {success && ( +
+
+ + {success} +
+ + {lastScanned && ( +
+
+ + + {lastScanned.passengerName} + +
+ +
+ + + {lastScanned.route} + +
+ +
+ + + {lastScanned.trainName} - Coach {lastScanned.coach}, Seat {lastScanned.seat} + +
+ +
+ + + Boarded: {formatDateTime(lastScanned.boardedAt)} ({lastScanned.leg}) + +
+ + {lastScanned.isRoundTrip && ( +
+

+ ℹ️ Round-trip ticket: Scan again for return journey +

+
+ )} + +
+ Booking: {lastScanned.bookingRef} | Ticket: {lastScanned.ticketId} +
+ +
+ 📧 Email & SMS notifications sent to passenger +
+
+ )} +
+ )} + + {/* Error Message */} + {error && ( +
+
+ + {error} +
+
+ )} + + {/* Instructions */} +
+

How to scan:

+
    +
  • • Tap "Scan QR Code" and point at ticket QR code
  • +
  • • For manual option, type or paste booking reference
  • +
  • • Tickets can only be boarded on their departure date
  • +
  • • First scan boards outbound leg for round trips
  • +
  • • Email & SMS sent automatically to passenger contacts
  • +
  • • Red error shows validation issues
  • +
+
+ + {/* Quick Stats */} +
+

Session Summary

+
+ Status: + + Ready to scan + +
+
+ + + + + + + ); +} \ No newline at end of file diff --git a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx index 6caa5733b..b4cf005e8 100644 --- a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { Download, Eye, XCircle, Trash2 } from 'lucide-react'; +import { Download, Eye, Trash2 } from 'lucide-react'; import DataTable from '@/components/ui/DataTable'; import Badge from '@/components/ui/Badge'; import Pagination from '@/components/ui/Pagination'; @@ -31,7 +31,6 @@ const SectionHeader = ({ title }: { title: string }) => ( function BookingsPageContent() { const canManage = usePermission(PERMS.bookings.manage); - const canCancel = usePermission(PERMS.bookings.cancel); const [filters, setFilters] = useState({ page: 1, pageSize: 20, search: '', status: '' }); const [extraFilters, setExtraFilters] = useState({ bookingType: '', dateFrom: '', dateTo: '', paymentStatus: '' }); const [showExtraFilters, setShowExtraFilters] = useState(false); @@ -62,16 +61,6 @@ function BookingsPageContent() { }), }); - const cancelMutation = useMutation({ - mutationFn: ({ id, reason }: { id: string; reason?: string }) => bookingsApi.cancel(id, reason), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['bookings'] }); - setSuccessMessage('Booking cancelled successfully'); - setTimeout(() => setSuccessMessage(''), 3000); - }, - onError: (error: any) => alert(`Error: ${error.message || 'Failed to cancel booking'}`), - }); - const deleteMutation = useMutation({ mutationFn: (id: string) => apiClient.delete(`/bookings/${id}`), onSuccess: () => { @@ -87,12 +76,6 @@ function BookingsPageContent() { }, }); - const handleCancel = async (booking: any) => { - if (window.confirm(`Cancel booking ${booking.bookingRef}? This will process a refund.`)) { - await cancelMutation.mutateAsync({ id: booking.id, reason: 'Cancelled by admin' }); - } - }; - const BOOKING_COLS = [ { key: 'bookingRef', label: 'Booking Reference' }, { key: 'journeyType', label: 'Journey Type' }, { key: 'passengerNames', label: 'Passenger Names' }, { key: 'contactPhone', label: 'Contact Phone' }, @@ -167,9 +150,38 @@ function BookingsPageContent() { { key: 'passengerNames', label: 'Names', render: (booking: any) => { - const names: string[] = booking.passengerNames || []; - if (!names.length) return ; - return
{names.map((n, i) => {n})}
; + const passengers = booking.passengers || []; + if (!passengers.length) { + // Fallback to old logic if passengers array not available + const names: string[] = booking.passengerNames || []; + const adultCount = booking.adultCount || 0; + if (!names.length) return ; + return ( +
+ {names.map((name, i) => { + const isAdult = i < adultCount; + const passengerType = isAdult ? 'A' : 'C'; + return ( + + {name} ({passengerType}) + + ); + })} +
+ ); + } + return ( +
+ {passengers.map((p: any, i: number) => { + const passengerType = p.category === 'ADULT' ? 'A' : 'C'; + return ( + + {p.name} ({passengerType}) + + ); + })} +
+ ); }, }, { @@ -206,10 +218,6 @@ function BookingsPageContent() { const actions = [ { label: 'View Details', onClick: (b: any) => setSelectedBooking(b), variant: 'secondary' as const, icon: Eye }, - { - label: 'Cancel Booking', onClick: handleCancel, variant: 'danger' as const, icon: XCircle, - show: (b: any) => b.status !== 'CANCELLED' && b.status !== 'BOARDED', - }, { label: 'Delete', onClick: (b: any) => { setDeleteError(null); setBookingToDelete(b); setDeleteConfirmOpen(true); }, variant: 'danger' as const, icon: Trash2 }, ]; diff --git a/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx b/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx index d755e5d2f..f8ee29091 100644 --- a/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx @@ -144,8 +144,11 @@ export default function CoachesPage() { const [activeTab, setActiveTab] = useState('coaches'); const [search, setSearch] = useState(''); const [showModal, setShowModal] = useState(false); + const [showPreviewModal, setShowPreviewModal] = useState(false); + const [seatMapPreview, setSeatMapPreview] = useState(null); const [editingItem, setEditingItem] = useState(null); const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null; error?: string }>({ isOpen: false, item: null }); + const [selectedCoachTypeId, setSelectedCoachTypeId] = useState(''); const queryClient = useQueryClient(); // Coach Types Queries @@ -212,6 +215,14 @@ export default function CoachesPage() { }, }); + const generateSeatMapMutation = useMutation({ + mutationFn: fleetApi.generateSeatMap, + onSuccess: (data) => { + setSeatMapPreview(data); + setShowPreviewModal(true); + }, + }); + const handleCoachTypeSubmit = async (e: React.FormEvent) => { e.preventDefault(); const formData = new FormData(e.currentTarget); @@ -231,7 +242,8 @@ export default function CoachesPage() { const handleCoachSubmit = async (e: React.FormEvent) => { e.preventDefault(); const formData = new FormData(e.currentTarget); - const data = { + + const data: any = { number: formData.get('number') as string, coachTypeId: formData.get('coachTypeId') as string, arrangement: formData.get('arrangement') as string, @@ -240,6 +252,16 @@ export default function CoachesPage() { status: formData.get('status') as string, }; + // Add bed-specific fields if bed coach is selected + const bedCategory = formData.get('bedCategory') as string; + if (bedCategory) { + data.bedCategory = bedCategory as 'ECONOMY_BED' | 'VIP_BED'; + const bedsPerRoom = formData.get('bedsPerRoom') as string; + if (bedsPerRoom) { + data.bedsPerRoom = parseInt(bedsPerRoom); + } + } + if (editingItem?.isCoach) { await updateCoachMutation.mutateAsync({ id: editingItem.id, data }); } else { @@ -247,6 +269,27 @@ export default function CoachesPage() { } }; + const handlePreviewSeatMap = async () => { + const form = document.querySelector('form') as HTMLFormElement; + const formData = new FormData(form); + const bedCategory = formData.get('bedCategory') as string; + const capacity = parseInt(formData.get('capacity') as string); + + if (!bedCategory || !capacity) { + alert('Please select a bed category and enter capacity to preview seat map'); + return; + } + + const bedsPerRoom = bedCategory === 'VIP_BED' ? 4 : 6; + const roomsPerCoach = Math.ceil(capacity / bedsPerRoom); + + await generateSeatMapMutation.mutateAsync({ + coachCount: 1, + roomsPerCoach, + roomType: bedCategory, + }); + }; + const handleDelete = (item: any, isCoachType: boolean) => { setDeleteConfirm({ isOpen: true, item: { ...item, isCoachType } }); }; @@ -298,7 +341,6 @@ export default function CoachesPage() { const statusMap: Record = { ACTIVE: 'edr-badge-success', - MAINTENANCE: 'edr-badge-warning', INACTIVE: 'edr-badge-danger', }; @@ -373,10 +415,30 @@ export default function CoachesPage() { }, { key: 'arrangement', - label: 'Arrangement', - render: (coach: any) => ( - {coach.arrangement || 'N/A'} - ), + label: 'Type/Arrangement', + render: (coach: any) => { + // Check if this is a bed coach based on coach type name containing 'bed' + const coachTypeName = coach.coachType?.name?.toLowerCase() || ''; + const isBedCoach = coachTypeName.includes('bed') || coachTypeName.includes('sleeper') || coachTypeName.includes('berth'); + + if (isBedCoach) { + // Determine if it's VIP or Economy based on coach type name + const isVIP = coachTypeName.includes('vip'); + return ( +
+ + {coach.arrangement || 'N/A'} +
+ ); + } + + return ( +
+ + {coach.arrangement || 'N/A'} +
+ ); + }, }, { key: 'capacity', @@ -422,6 +484,7 @@ export default function CoachesPage() { label: 'Edit', onClick: (item: any) => { setEditingItem({ ...item, isCoach: true }); + setSelectedCoachTypeId(item.coachTypeId || ''); setShowModal(true); }, variant: 'secondary' as const, @@ -446,6 +509,7 @@ export default function CoachesPage() { icon={Plus} onClick={() => { setEditingItem(null); + setSelectedCoachTypeId(''); setSearch(''); setShowModal(true); }} @@ -556,6 +620,7 @@ export default function CoachesPage() { onClose={() => { setShowModal(false); setEditingItem(null); + setSelectedCoachTypeId(''); }} title={ activeTab === 'types' @@ -636,12 +701,13 @@ export default function CoachesPage() { name="coachTypeId" className="input" defaultValue={editingItem?.coachTypeId || ''} + onChange={(e) => setSelectedCoachTypeId(e.target.value)} required > {coachTypesArray.map((ct: any) => ( ))} @@ -659,17 +725,66 @@ export default function CoachesPage() { /> + {/* Conditionally show bed fields only for Economy and Regular coach types */} + {(() => { + const selectedCoachType = coachTypesArray.find((ct: any) => ct.id === (selectedCoachTypeId || editingItem?.coachTypeId)); + const isEconomyOrRegular = selectedCoachType && + (selectedCoachType.name?.toLowerCase().includes('economy') || + selectedCoachType.name?.toLowerCase().includes('regular') || + selectedCoachType.type?.toLowerCase().includes('economy') || + selectedCoachType.type?.toLowerCase().includes('regular')); + + return isEconomyOrRegular ? ( + <> +
+ + +

+ Select if this is a bed coach +

+
+ +
+ + +

+ Only applies to bed coaches +

+
+ + ) : null; + })()} +
-

Format: separate columns with +

+

+ For regular seats: columns separated by + +

@@ -691,12 +806,14 @@ export default function CoachesPage() { type="number" name="sequence" className="input" - defaultValue={editingItem?.sequence || 0} - min="0" + defaultValue={editingItem?.sequence || 1} + min="1" required - placeholder="e.g., 1" + placeholder="1" /> -

Used for ordering coaches in trains

+

+ Position in train consist +

@@ -708,19 +825,27 @@ export default function CoachesPage() { required > -
+ + Preview Bed Layout + { setShowModal(false); setEditingItem(null); + setSelectedCoachTypeId(''); }} > Cancel @@ -735,6 +860,58 @@ export default function CoachesPage() { )} + + {/* Seat Map Preview Modal */} + { + setShowPreviewModal(false); + setSeatMapPreview(null); + }} + title="Bed Layout Preview" + size="lg" + > + {seatMapPreview && ( +
+
+

Configuration

+
+
Room Type: {seatMapPreview.roomType}
+
Rooms per Coach: {seatMapPreview.roomsPerCoach}
+
Beds per Room: {seatMapPreview.bedsPerRoom}
+
Total Beds: {seatMapPreview.totalBeds}
+
+
+ +
+

Bed Layout Sample (First Few Rooms)

+
+ {seatMapPreview.seats?.slice(0, 24).map((seat: any, idx: number) => ( +
+ {seat.seat_id} - Room: {seat.room_id} - {seat.position} {seat.bed_type} +
+ ))} + {seatMapPreview.seats?.length > 24 && ( +
+ ... and {seatMapPreview.seats.length - 24} more beds +
+ )} +
+
+ +
+ { + setShowPreviewModal(false); + setSeatMapPreview(null); + }} + > + Close + +
+
+ )} +
); } diff --git a/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx b/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx index 66d5ebeb6..41b7abce7 100644 --- a/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx @@ -3,7 +3,7 @@ import { useQuery } from '@tanstack/react-query'; import { PermissionGuard } from '@/components/layout/PermissionGuard'; import { PERMS } from '@/lib/permissions'; -import { Ticket, Users, DollarSign, Percent } from 'lucide-react'; +import { Ticket, Users, DollarSign, Percent, AlertCircle, TrendingUp, Calendar } from 'lucide-react'; import StatCard from '@/components/dashboard/StatCard'; import DataTable from '@/components/ui/DataTable'; import Badge from '@/components/ui/Badge'; @@ -13,43 +13,83 @@ import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, R const COLORS = ['#2563eb', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6']; +// Mock data for fallback when API fails +const MOCK_STATS = { + totalBookings: 1247, + totalRevenue: 892450, + totalPassengers: 2156, + occupancyRate: 78 +}; + +const MOCK_RECENT_BOOKINGS = [ + { + id: '1', + bookingRef: 'BK-2024-001', + passenger: { fullName: 'John Doe' }, + totalMinor: 125000, + currency: 'ETB', + status: 'CONFIRMED', + createdAt: new Date().toISOString() + }, + { + id: '2', + bookingRef: 'BK-2024-002', + passenger: { fullName: 'Jane Smith' }, + totalMinor: 85000, + currency: 'ETB', + status: 'PENDING', + createdAt: new Date().toISOString() + } +]; + function DashboardPageContent() { - const { data: stats, isLoading: statsLoading } = useQuery({ + const { data: stats, isLoading: statsLoading, error: statsError } = useQuery({ queryKey: ['dashboard-stats'], queryFn: dashboardApi.getStats, + retry: 1, + staleTime: 60000, // 1 minute }); const { data: revenueData, isLoading: revenueLoading } = useQuery({ queryKey: ['revenue-chart'], queryFn: () => dashboardApi.getRevenueChart(30), + retry: 1, }); - const { data: recentBookingsData, isLoading: bookingsLoading } = useQuery({ + const { data: recentBookingsData, isLoading: bookingsLoading, error: bookingsError } = useQuery({ queryKey: ['recent-bookings'], queryFn: () => dashboardApi.getRecentBookings(10), + retry: 1, }); const { data: topAgents, isLoading: agentsLoading } = useQuery({ queryKey: ['top-agents'], queryFn: () => dashboardApi.getTopAgents(5), + retry: 1, }); const { data: occupancyTrend, isLoading: occupancyLoading } = useQuery({ queryKey: ['occupancy-trend'], queryFn: () => dashboardApi.getOccupancyTrend(7), + retry: 1, }); const { data: upcomingTrips, isLoading: tripsLoading } = useQuery({ queryKey: ['upcoming-trips'], queryFn: () => dashboardApi.getUpcomingTrips(5), + retry: 1, }); const { data: paymentMethods } = useQuery({ queryKey: ['payment-methods'], queryFn: dashboardApi.getPaymentMethods, + retry: 1, }); - const recentBookings = Array.isArray(recentBookingsData) ? recentBookingsData : []; + // Use actual data or fallback to mock/empty states + const displayStats = stats || (statsError ? MOCK_STATS : null); + const recentBookings = Array.isArray(recentBookingsData) ? recentBookingsData : + (bookingsError ? MOCK_RECENT_BOOKINGS : []); const bookingColumns = [ { key: 'reference', label: 'Reference', render: (item: any) => item.bookingRef || item.reference }, @@ -106,35 +146,52 @@ function DashboardPageContent() { ]; return ( -
+

Dashboard

Welcome back! Here's your operational summary.

+ {/* Error Alert */} + {(statsError || bookingsError) && ( +
+
+ +
+

+ Some data may be outdated +

+

+ Unable to fetch live data. Showing cached or sample information. +

+
+
+
+ )} + {/* Primary Metrics */} -
+
@@ -143,9 +200,16 @@ function DashboardPageContent() { {/* Charts Row */}
{/* Revenue Trend */} - {!revenueLoading && revenueData && revenueData.length > 0 && ( -
-

Revenue Trend (Last 30 Days)

+
+

+ + Revenue Trend (Last 30 Days) +

+ {revenueLoading ? ( +
+
+
+ ) : revenueData && revenueData.length > 0 ? ( @@ -155,13 +219,27 @@ function DashboardPageContent() { -
- )} + ) : ( +
+
+ +

No revenue data available

+
+
+ )} +
{/* Occupancy Trend */} - {!occupancyLoading && occupancyTrend && occupancyTrend.length > 0 && ( -
-

Occupancy Trend (Last 7 Days)

+
+

+ + Occupancy Trend (Last 7 Days) +

+ {occupancyLoading ? ( +
+
+
+ ) : occupancyTrend && occupancyTrend.length > 0 ? ( @@ -171,8 +249,15 @@ function DashboardPageContent() { -
- )} + ) : ( +
+
+ +

No occupancy data available

+
+
+ )} +
{/* Payment Methods Distribution */} @@ -202,40 +287,45 @@ function DashboardPageContent() { {/* Recent Bookings */}
-

Recent Bookings

+

+ + Recent Bookings +

{/* Upcoming Trips */} - {upcomingTrips && upcomingTrips.length > 0 && ( -
-

Upcoming Trips

- -
- )} +
+

+ + Upcoming Trips +

+ +
{/* Top Agents */} - {topAgents && topAgents.length > 0 && ( -
-

Top Performing Agents

- -
- )} +
+

+ + Top Performing Agents +

+ +
); } @@ -246,4 +336,4 @@ export default function DashboardPage() { ); -} +} \ No newline at end of file diff --git a/apps/edr-passenger-web/backoffice/src/app/docs/page.tsx b/apps/edr-passenger-web/backoffice/src/app/docs/page.tsx index deaf40007..650dde2cf 100644 --- a/apps/edr-passenger-web/backoffice/src/app/docs/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/docs/page.tsx @@ -14,6 +14,7 @@ const DocPage = () => { security: false, analytics: false, system: false, + enhanced: false, }); const toggleSection = (section: string) => { @@ -133,10 +134,32 @@ const DocPage = () => { { id: 'agents-how', label: '→ How-To' }, { id: 'users', label: 'Users' }, { id: 'users-how', label: '→ How-To' }, + { id: 'system-config', label: 'System Config' }, + { id: 'system-config-how', label: '→ How-To' }, { id: 'settings', label: 'Settings' }, { id: 'settings-how', label: '→ How-To' }, ] }, + { + id: 'enhanced', + title: '✨ Enhanced Features', + items: [ + { id: 'excess-baggage', label: 'Excess Baggage' }, + { id: 'excess-baggage-how', label: '→ How-To' }, + { id: 'packages', label: 'Travel Packages' }, + { id: 'packages-how', label: '→ How-To' }, + { id: 'package-inquiries', label: 'Package Inquiries' }, + { id: 'package-inquiries-how', label: '→ How-To' }, + { id: 'health', label: 'Health Monitoring' }, + { id: 'health-how', label: '→ How-To' }, + { id: 'boarding', label: 'Boarding Management' }, + { id: 'boarding-how', label: '→ How-To' }, + { id: 'fare-config', label: 'Advanced Fare Config' }, + { id: 'fare-config-how', label: '→ How-To' }, + { id: 'payment-methods', label: 'Payment Methods' }, + { id: 'payment-methods-how', label: '→ How-To' }, + ] + }, ]; const HowToStep = ({ number, title, children }: { number: number; title: string; children: React.ReactNode }) => ( @@ -203,7 +226,18 @@ const DocPage = () => {

Welcome to EDR Passenger Backoffice

-

Comprehensive management system for the Ethio-Djibouti Railway passenger platform. This documentation provides complete guidance on all features, operations, and best practices.

+

Comprehensive management system for the Ethio-Djibouti Railway passenger platform. This documentation provides complete guidance on all features, operations, and best practices.

+
+

🎆 Version 1.0.0 - Complete Platform Release

+
    +
  • Excess Baggage: Complete baggage handling with agent tools and passenger self-pay
  • +
  • Travel Packages: Bundled offerings with tiered pricing and inquiry management
  • +
  • Health Monitoring: Comprehensive system status and performance tracking
  • +
  • Boarding Management: Gate operations and passenger processing workflows
  • +
  • Advanced Fare Config: Dynamic pricing with segment-based rules
  • +
  • Payment Methods: Multi-provider payment configuration and management
  • +
+
@@ -222,7 +256,7 @@ const DocPage = () => {
    -
  1. Click {`"Bookings"`} in Operations section
  2. +
  3. Click "Bookings" in Operations section
  4. View all bookings in table format
@@ -234,704 +268,278 @@ const DocPage = () => {
    -
  1. Click {`"View Details"`} for full information
  2. -
  3. Click {`"Cancel Booking"`} to process refunds
  4. +
  5. Click "View Details" for full information
  6. +
  7. Click "Cancel Booking" to process refunds
- {/* PASSENGERS */} -
-

👥 Passengers

-

Manage passenger profiles, loyalty, and verification status.

+
+

⚙️ System Config

+

Centralized system configuration management with feature flags and operational controls.

-
-

👥 How-To: Manage Passengers

+
+

⚙️ How-To: Manage System Configuration

- +
    -
  1. Click {`"Passengers"`} in Operations
  2. -
  3. View all profiles with pagination
  4. +
  5. Click "System Config" in System section
  6. +
  7. View all configuration categories
  8. +
+
+ +
    +
  1. Adjust auth endpoints limit (default: 5 req/min)
  2. +
  3. Set strict endpoints limit (default: 20 req/min)
  4. +
  5. Configure default endpoints limit (default: 100 req/min)
  6. +
+
+ +
    +
  1. Set seat hold duration (default: 5 minutes)
  2. +
  3. Configure hold cutoff before departure (default: 2 hours)
  4. +
  5. Click "Save Changes" to apply
  6. +
+
+
+
+ + {/* EXCESS BAGGAGE */} +
+

📦 Excess Baggage

+

Manage excess baggage charges at boarding with agent tools and passenger self-pay options.

+
+ +
+

📦 How-To: Handle Excess Baggage

+
+ +
    +
  1. Click "Excess Baggage" in Enhanced Features
  2. +
  3. View all baggage charges and their status
    -
  1. Search by name, email, phone, ID
  2. -
  3. Filter by nationality, verification, loyalty tier
  4. +
  5. Search by booking reference
  6. +
  7. Filter by status: PENDING, PAID, CASH_COLLECTED, EXPIRED, WAIVED
  8. +
  9. Use date filters for specific periods
- +
    -
  1. Click passenger row to open modal
  2. -
  3. View account, loyalty, wallet, booking history
  4. +
  5. "Resend Link" for pending charges to passenger
  6. +
  7. "Waive" charges with reason (supervisor authority)
  8. +
  9. "Delete" expired or waived charges
- {/* TICKETS */} -
-

🎫 Tickets

-

Manage ticket generation, tracking, and validation.

+ {/* TRAVEL PACKAGES */} +
+

🎒 Travel Packages

+

Manage pilgrimage and group travel packages with tiered pricing and capacity management.

-
-

🎫 How-To: Manage Tickets

+
+

🎒 How-To: Manage Travel Packages

- +
    -
  1. Click {`"Tickets"`} in Operations
  2. -
  3. View all issued tickets with status
  4. +
  5. Click "New Package" button
  6. +
  7. Fill package details: code, name, stations, schedules
  8. +
  9. Set capacity, validity period, and included services
  10. +
  11. Save package (starts in DRAFT status)
- +
    -
  1. Search by booking reference or ticket number
  2. -
  3. Filter by validation status
  4. +
  5. Click "Tiers" on package to manage pricing
  6. +
  7. Add tiers: seat type, label, price, capacity
  8. +
  9. Edit existing tiers (limited if bookings exist)
  10. +
  11. Delete unused tiers
- +
    -
  1. Click ticket to view details
  2. -
  3. Click {`"Download PDF"`} for printable version
  4. +
  5. "Activate" draft packages to make bookable
  6. +
  7. "Deactivate" active packages to stop new bookings
  8. +
  9. "Delete" packages with no bookings if needed
- {/* STATIONS */} -
-

🏢 Stations

-

Configure railway stations with locations and timezones.

+ {/* PACKAGE INQUIRIES */} +
+

📝 Package Inquiries

+

Manage incoming package booking inquiries and track lead conversion.

-
-

🏢 How-To: Manage Stations

+
+

📝 How-To: Handle Package Inquiries

- +
    -
  1. Click {`"Stations"`} in Master Data
  2. -
  3. View all configured stations
  4. +
  5. Click "Package Inquiries" in Enhanced Features
  6. +
  7. Filter by package or inquiry status
  8. +
  9. View contact details, package interest, traveler count
- +
    -
  1. Click {`"Add Station"`}
  2. -
  3. Enter code, name, city, timezone, coordinates
  4. +
  5. Use status dropdown: NEW → CONTACTED → CONVERTED/CLOSED
  6. +
  7. Mark as CONTACTED after first customer contact
  8. +
  9. Mark as CONVERTED when inquiry becomes booking
  10. +
  11. Mark as CLOSED if customer not interested
- +
    -
  1. Click station to open details
  2. -
  3. Update information and save
  4. +
  5. Respond to NEW inquiries within 24 hours
  6. +
  7. Follow up on CONTACTED inquiries regularly
  8. +
  9. Delete spam or duplicate inquiries as needed
- {/* TRAINS */} -
-

🚂 Trains

-

Manage train fleet with coach assignments.

+ {/* HEALTH MONITORING */} +
+

🏥 Health Monitoring

+

Monitor EDR Passenger API health with real-time system status and performance metrics.

-
-

🚂 How-To: Manage Trains

+
+

🏥 How-To: Monitor System Health

- +
    -
  1. Click {`"Trains"`} in Master Data
  2. -
  3. View all trains and coaches
  4. +
  5. Click "Health Monitoring" in Enhanced Features
  6. +
  7. View overall system status banner
  8. +
  9. Check individual probe cards (auto-refreshing)
- +
    -
  1. Click {`"Add Train"`}
  2. -
  3. Enter code and select coaches
  4. +
  5. Liveness: API process alive (30s refresh)
  6. +
  7. Readiness: Database connectivity + latency (30s refresh)
  8. +
  9. App Info: Version, uptime, environment (60s refresh)
- +
    -
  1. Click train to edit
  2. -
  3. Add/remove coaches with position numbers
  4. +
  5. Red status: Check error details and system logs
  6. +
  7. High DB latency: Monitor database performance
  8. +
  9. Failed checks: Verify API server and connections
  10. +
  11. Use "Refresh" button for manual status update
- {/* COACHES */} -
-

🚃 Coaches

-

Manage coach inventory with seat configurations.

+ {/* BOARDING MANAGEMENT */} +
+

🚆 Boarding Management

+

Manage gate operations and passenger boarding processes with real-time tracking.

-
-

🚃 How-To: Manage Coaches

+
+

🚆 How-To: Manage Boarding Operations

- +
    -
  1. Click "Coaches" in Master Data
  2. -
  3. View all coaches and assignments
  4. +
  5. Click "Boarding" in Enhanced Features
  6. +
  7. Select active trip/schedule for boarding
  8. +
  9. View real-time boarding dashboard
- +
    -
  1. Click "Add Coach"
  2. -
  3. Enter code, select train, define seat layout
  4. +
  5. Track total passengers expected vs boarded
  6. +
  7. Monitor boarding progress percentage
  8. +
  9. View gate status and any alerts
- +
    -
  1. Click coach to edit
  2. -
  3. Add seats and assign classes
  4. +
  5. Validate passenger tickets and documents
  6. +
  7. Resolve seat conflicts or issues
  8. +
  9. Process last-minute passengers and no-shows
- {/* SEATS */} -
-

💺 Seats

-

Manage seat inventory with visual maps.

+ {/* ADVANCED FARE CONFIG */} +
+

📊 Advanced Fare Config

+

Configure complex fare rules and dynamic pricing strategies with segment-based pricing.

-
-

💺 How-To: Manage Seats

+
+

📊 How-To: Configure Advanced Fares

- +
    -
  1. Go to "Seats" in Master Data
  2. -
  3. Select coach from dropdown
  4. -
  5. Visual map shows: Green=Available, Red=Blocked
  6. +
  7. Click "Advanced Fare Config" in Enhanced Features
  8. +
  9. Choose between Schedule Fares or Segment Fares
  10. +
  11. View existing fare rules and calculations
- +
    -
  1. Click available seat
  2. -
  3. Click "Block" and select reason
  4. +
  5. Set fare amounts for specific schedules or segments
  6. +
  7. Define passenger categories (ADULT/CHILD) and nationalities
  8. +
  9. Configure validity periods and seasonal adjustments
- +
    -
  1. Click blocked seat
  2. -
  3. Click "Unblock" to restore
  4. +
  5. Apply route segment-specific pricing
  6. +
  7. Set nationality-based rate variations
  8. +
  9. Monitor fare engine integration and real-time calculations
- {/* SEAT CLASSES */} -
-

🎯 Seat Classes

-

Define seat class types with pricing.

+ {/* PAYMENT METHODS */} +
+

💳 Payment Methods

+

Configure and manage payment provider integrations with multi-provider support.

-
-

🎯 How-To: Manage Seat Classes

+
+

💳 How-To: Configure Payment Methods

- +
    -
  1. Click "Seat Classes" in Master Data
  2. -
  3. View all class types
  4. +
  5. Click "Payment Methods" in Enhanced Features
  6. +
  7. View all configured payment providers
  8. +
  9. Check provider status and connectivity
- +
    -
  1. Click "Add Class"
  2. -
  3. Enter name, base fare, premium, insurance
  4. +
  5. Set up API credentials (URLs, keys, merchant IDs)
  6. +
  7. Configure transaction fees and limits
  8. +
  9. Enable/disable specific payment methods
- +
    -
  1. Click class to edit
  2. -
  3. Update fares and save
  4. +
  5. Run test transactions for each provider
  6. +
  7. Validate webhook endpoints and security
  8. +
  9. Monitor API connectivity and error logs
- {/* ROUTES */} -
-

🛤️ Routes

-

Define railway routes with ordered stops.

-
- -
-

🛤️ How-To: Manage Routes

-
- -
    -
  1. Click "Routes" in Master Data
  2. -
  3. View all routes and stops
  4. -
-
- -
    -
  1. Click "Add Route"
  2. -
  3. Enter code and description
  4. -
-
- -
    -
  1. Click route to edit
  2. -
  3. Click "Add Stop" and select station
  4. -
-
-
-
- - {/* SCHEDULES */} -
-

📅 Schedules

-

Create and manage train schedules.

-
- -
-

📅 How-To: Create Schedules

-
- -
    -
  1. Go to "Schedules" in Master Data
  2. -
  3. Click "Create Schedule"
  4. -
  5. Fill train, route, departure/arrival times
  6. -
-
- -
    -
  1. Click "Bulk Generate"
  2. -
  3. Set recurring parameters and generate
  4. -
-
- -
    -
  1. Click schedule to edit
  2. -
  3. Update times and view fares
  4. -
-
-
-
- - {/* PRICING */} -
-

💰 Pricing & Fares

-

Configure dynamic pricing with segments.

-
- -
-

💰 How-To: Configure Pricing

-
- -
    -
  1. Click "Pricing & Fares" in Financial
  2. -
  3. Two tabs: Schedule Fares, Segment Fares
  4. -
-
- -
    -
  1. Click "Add Fare Rule"
  2. -
  3. Fill schedule, seat class, fare, nationality
  4. -
-
- -
    -
  1. Switch to "Segment Fares" tab
  2. -
  3. Select route and add origin/destination fare
  4. -
-
-
-
- - {/* CURRENCIES */} -
-

💵 Currencies

-

Manage exchange rates for multiple currencies.

-
- -
-

💵 How-To: Manage Currencies

-
- -
    -
  1. Click "Currencies" in Financial
  2. -
  3. View all configured rates
  4. -
-
- -
    -
  1. Click "Add Rate"
  2. -
  3. Select currency and enter exchange rate
  4. -
-
- -
    -
  1. Click rate to edit
  2. -
  3. Click "Sync" to update from provider
  4. -
-
-
-
- - {/* PAYMENTS */} -
-

💳 Payments

-

Monitor and process transactions.

-
- -
-

💳 How-To: Manage Payments

-
- -
    -
  1. Click "Payments" in Financial
  2. -
  3. View all transactions
  4. -
-
- -
    -
  1. Search by booking or transaction ID
  2. -
  3. Filter by status and payment method
  4. -
-
- -
    -
  1. Click transaction
  2. -
  3. Click "Refund" if eligible
  4. -
-
-
-
- - {/* PROMOS */} -
-

🎁 Promo Codes

-

Create and manage promotional campaigns.

-
- -
-

🎁 How-To: Manage Promo Codes

-
- -
    -
  1. Click "Promo Codes" in Financial
  2. -
  3. View all active codes
  4. -
-
- -
    -
  1. Click "Add Promo Code"
  2. -
  3. Enter code, discount type, validity dates
  4. -
-
- -
    -
  1. Click code to view analytics
  2. -
  3. View usage count and savings
  4. -
-
-
-
- - {/* LOYALTY */} -
-

🏆 Loyalty

-

Manage loyalty program and rewards.

-
- -
-

🏆 How-To: Manage Loyalty

-
- -
    -
  1. Click "Loyalty Program" in Services
  2. -
  3. View all loyalty accounts
  4. -
-
- -
    -
  1. Click account
  2. -
  3. Click "Adjust Points" and enter amount
  4. -
-
- -
    -
  1. Click account
  2. -
  3. Click "Grant Reward" and select reward
  4. -
-
-
-
- - {/* SUPPORT */} -
-

💬 Support

-

Manage support tickets and conversations.

-
- -
-

💬 How-To: Manage Support

-
- -
    -
  1. Click "Support Center" in Services
  2. -
  3. View all support tickets
  4. -
-
- -
    -
  1. Click ticket to open conversation
  2. -
  3. Add replies and update status
  4. -
-
- -
    -
  1. Go to FAQ management
  2. -
  3. Add or edit FAQ articles
  4. -
-
-
-
- - {/* NOTIFICATIONS */} -
-

🔔 Notifications

-

Send notifications via multiple channels.

-
- -
-

🔔 How-To: Manage Notifications

-
- -
    -
  1. Click "Notifications" in Services
  2. -
  3. View notification history
  4. -
-
- -
    -
  1. Click "Send Notification"
  2. -
  3. Select channel and message
  4. -
-
- -
    -
  1. Go to Templates section
  2. -
  3. Create or edit templates with variables
  4. -
-
-
-
- - {/* AUDIT */} -
-

📋 Audit Logs

-

Monitor system activities and user actions.

-
- -
-

📋 How-To: View Audit Logs

-
- -
    -
  1. Click "Audit Logs" in Security
  2. -
  3. View all recorded activities
  4. -
-
- -
    -
  1. Filter by user, action, or date
  2. -
  3. Search by entity ID
  4. -
-
- -
    -
  1. Click log entry for details
  2. -
  3. Click "Export" to download CSV
  4. -
-
-
-
- - {/* FRAUD */} -
-

🛡️ Fraud Detection

-

Monitor and manage fraud alerts.

-
- -
-

🛡️ How-To: Manage Fraud Detection

-
- -
    -
  1. Click "Fraud Detection" in Security
  2. -
  3. View all fraud alerts
  4. -
-
- -
    -
  1. Click alert to view details
  2. -
  3. Review triggered rules and patterns
  4. -
-
- -
    -
  1. Click "Allow" or "Block" with notes
  2. -
  3. Update user status
  4. -
-
-
-
- - {/* VERIFAYDA */} -
-

✅ Verifayda

-

Verify passenger identities against government database.

-
- -
-

✅ How-To: Manage Verifayda

-
- -
    -
  1. Click "Verifayda Integration" in Security
  2. -
  3. View verification history
  4. -
-
- -
    -
  1. Enter national ID or passport number
  2. -
  3. Click "Verify" to check database
  4. -
-
- -
    -
  1. View verified passenger data
  2. -
  3. Match with booking details
  4. -
-
-
-
- - {/* REPORTS */} -
-

📊 Reports

-

Generate business analytics and reports.

-
- -
-

📊 How-To: Generate Reports

-
- -
    -
  1. Click "Reports" in Analytics
  2. -
  3. View available report types
  4. -
-
- -
    -
  1. Click report type
  2. -
  3. Select date range and parameters
  4. -
-
- -
    -
  1. View report with charts
  2. -
  3. Click "Export" for PDF or CSV
  4. -
-
-
-
- - {/* AGENTS */} -
-

👤 Agents

-

Manage booking agents and commissions.

-
- -
-

👤 How-To: Manage Agents

-
- -
    -
  1. Click "Agents" in System
  2. -
  3. View all agents
  4. -
-
- -
    -
  1. Click "Add Agent"
  2. -
  3. Enter name, email, commission rate
  4. -
-
- -
    -
  1. Click agent to edit
  2. -
  3. Click "Create Shift" to assign schedule
  4. -
-
-
-
- - {/* USERS */} -
-

👥 Users

-

Manage backoffice user accounts and permissions.

-
- -
-

👥 How-To: Manage Users

-
- -
    -
  1. Click "Users" in System
  2. -
  3. View all user accounts
  4. -
-
- -
    -
  1. Click "Add User"
  2. -
  3. Enter email, name, select role
  4. -
-
- -
    -
  1. Click user to edit
  2. -
  3. Adjust roles and permissions
  4. -
-
-
-
- - {/* SETTINGS */} -
-

⚙️ Settings

-

Configure system-wide settings and integrations.

-
- -
-

⚙️ How-To: Configure Settings

-
- -
    -
  1. Click "Settings" in System
  2. -
  3. View configuration options
  4. -
-
- -
    -
  1. Go to Email tab
  2. -
  3. Enter SendGrid API key and email
  4. -
-
- -
    -
  1. Go to API tab
  2. -
  3. Add payment and Verifayda keys
  4. -
-
-
-
@@ -939,11 +547,11 @@ const DocPage = () => {
-

© 2026 Ethio-Djibouti Railway | Passenger Backoffice Documentation v1.0

+

© 2026 Ethio-Djibouti Railway | Passenger Backoffice Documentation v1.0.0

); }; -export default DocPage; +export default DocPage; \ No newline at end of file diff --git a/apps/edr-passenger-web/backoffice/src/app/excess-baggage/page.tsx b/apps/edr-passenger-web/backoffice/src/app/excess-baggage/page.tsx index 1eb6b2946..7b5db209a 100644 --- a/apps/edr-passenger-web/backoffice/src/app/excess-baggage/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/excess-baggage/page.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { RefreshCw, Send } from 'lucide-react'; +import { RefreshCw, Send, Trash2 } from 'lucide-react'; import DataTable from '@/components/ui/DataTable'; import Badge from '@/components/ui/Badge'; import ActionButton from '@/components/ui/ActionButton'; @@ -54,6 +54,11 @@ export default function ExcessBaggagePage() { onSuccess: () => queryClient.invalidateQueries({ queryKey: ['excess-baggage'] }), }); + const deleteMutation = useMutation({ + mutationFn: (id: string) => excessBaggageApi.delete(id), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['excess-baggage'] }), + }); + const columns = [ { key: 'booking', label: 'Booking', @@ -120,14 +125,25 @@ export default function ExcessBaggagePage() { onClick: (c: any) => { setWaiveModal(c); setWaiveReason(''); setWaiveError(null); }, show: (c: any) => !['PAID', 'CASH_COLLECTED', 'WAIVED'].includes(c.status), }, + { + label: 'Delete', + icon: Trash2, + variant: 'danger' as const, + onClick: (c: any) => { + if (confirm('Are you sure you want to delete this charge?')) { + deleteMutation.mutate(c.id); + } + }, + show: (c: any) => ['EXPIRED', 'WAIVED'].includes(c.status), + }, ]; return (
-

Excess Baggage

-

Track and manage excess baggage charges at boarding

+

Excess Lugagge

+

Track and manage excess luggage charges at boarding

diff --git a/apps/edr-passenger-web/backoffice/src/app/fare-management/layout.tsx b/apps/edr-passenger-web/backoffice/src/app/fare-management/layout.tsx new file mode 100644 index 000000000..33ccc861e --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/fare-management/layout.tsx @@ -0,0 +1,54 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import Sidebar from '@/components/layout/Sidebar'; +import Header from '@/components/layout/Header'; +import { useAuthStore } from '@/lib/auth-store'; + +export default function FareManagementLayout({ children }: { children: React.ReactNode }) { + const router = useRouter(); + const { isAuthenticated } = useAuthStore(); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const timer = setTimeout(() => { + setIsLoading(false); + }, 100); + + return () => clearTimeout(timer); + }, []); + + useEffect(() => { + if (!isLoading && !isAuthenticated) { + router.push('/login'); + } + }, [isAuthenticated, router, isLoading]); + + if (isLoading) { + return ( +
+
+
+

Loading...

+
+
+ ); + } + + if (!isAuthenticated) { + return null; + } + + return ( +
+ +
+
+
+ {children} +
+
+
+ ); +} \ No newline at end of file diff --git a/apps/edr-passenger-web/backoffice/src/app/fare-management/page.tsx b/apps/edr-passenger-web/backoffice/src/app/fare-management/page.tsx new file mode 100644 index 000000000..218fbd832 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/fare-management/page.tsx @@ -0,0 +1,535 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { Plus, Settings, Play, Square, Trash2, TestTube, History, Download } from 'lucide-react'; +import DataTable from '@/components/ui/DataTable'; +import Badge from '@/components/ui/Badge'; +import ActionButton from '@/components/ui/ActionButton'; +import Modal from '@/components/ui/Modal'; +import ConfirmDialog from '@/components/ui/ConfirmDialog'; +import { apiClient } from '@/lib/api-client'; + +interface FareConfiguration { + id: string; + name: string; + description?: string; + effective_date: string; + expiry_date?: string; + is_active: boolean; + is_default: boolean; + created_by?: string; + approved_by?: string; + approved_at?: string; + created_at: string; + updated_at: string; + rate_rules_count: number; + components_count: number; + age_rules_count: number; +} + +interface SystemStatus { + configurableFaresEnabled: boolean; + rolloutPercentage: number; + totalConfigurations: number; + activeConfiguration: string | null; + activeConfigurationName: string | null; + systemReady: boolean; +} + +interface FareTestResult { + baseFareMinor: number; + componentsTotal: number; + finalTotalMinor: number; + breakdown?: Array<{ + description: string; + runningTotal: number; + }>; +} + +export default function ConfigurableFarePage() { + const [showCreateModal, setShowCreateModal] = useState(false); + const [showTestModal, setShowTestModal] = useState(false); + const [selectedConfig, setSelectedConfig] = useState(null); + const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; config: FareConfiguration | null }>({ isOpen: false, config: null }); + const queryClient = useQueryClient(); + + // Queries + const { data: configurations = [], isLoading: configsLoading } = useQuery({ + queryKey: ['fare-configurations'], + queryFn: () => apiClient.get('/admin/fare-configurations'), + }); + + const { data: systemStatus } = useQuery({ + queryKey: ['fare-system-status'], + queryFn: () => apiClient.get('/admin/fare-migration/status'), + }); + + // Mutations + const activateMutation = useMutation({ + mutationFn: (id: string) => apiClient.post(`/admin/fare-configurations/${id}/activate`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['fare-configurations'] }); + queryClient.invalidateQueries({ queryKey: ['fare-system-status'] }); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: (id: string) => apiClient.delete(`/admin/fare-configurations/${id}`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['fare-configurations'] }); + setDeleteConfirm({ isOpen: false, config: null }); + }, + }); + + const toggleSystemMutation = useMutation({ + mutationFn: (enabled: boolean) => + enabled + ? apiClient.post('/admin/fare-configurations/system/enable-configurable-fares', { rolloutPercentage: 100 }) + : apiClient.post('/admin/fare-configurations/system/disable-configurable-fares'), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['fare-system-status'] }); + }, + }); + + const setupSystemMutation = useMutation({ + mutationFn: () => apiClient.post('/admin/fare-migration/complete-setup', { + activateNewFormula: true, + enableFeature: true + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['fare-configurations'] }); + queryClient.invalidateQueries({ queryKey: ['fare-system-status'] }); + }, + }); + + const handleActivate = async (config: FareConfiguration) => { + await activateMutation.mutateAsync(config.id); + }; + + const handleDelete = (config: FareConfiguration) => { + setDeleteConfirm({ isOpen: true, config }); + }; + + const confirmDelete = async () => { + if (deleteConfirm.config) { + await deleteMutation.mutateAsync(deleteConfirm.config.id); + } + }; + + const handleTest = (config: FareConfiguration) => { + setSelectedConfig(config); + setShowTestModal(true); + }; + + const columns = [ + { + key: 'name', + label: 'Configuration Name', + sortable: true, + render: (config: FareConfiguration) => ( +
+
{config.name}
+ {config.description && ( +
{config.description}
+ )} +
+ ), + }, + { + key: 'status', + label: 'Status', + render: (config: FareConfiguration) => ( +
+ + {config.is_active ? 'Active' : 'Inactive'} + + {config.is_default && ( + Default + )} +
+ ), + }, + { + key: 'rules', + label: 'Rules Count', + render: (config: FareConfiguration) => ( +
+
{config.rate_rules_count} rate rules
+
{config.components_count} components
+
{config.age_rules_count} age rules
+
+ ), + }, + { + key: 'dates', + label: 'Validity Period', + render: (config: FareConfiguration) => ( +
+
From: {new Date(config.effective_date).toLocaleDateString()}
+ {config.expiry_date && ( +
Until: {new Date(config.expiry_date).toLocaleDateString()}
+ )} +
+ ), + }, + { + key: 'created_at', + label: 'Created', + sortable: true, + render: (config: FareConfiguration) => ( +
+
{new Date(config.created_at).toLocaleDateString()}
+ {config.created_by && ( +
by {config.created_by}
+ )} +
+ ), + }, + ]; + + const actions = [ + { + label: 'Activate', + onClick: handleActivate, + variant: 'secondary' as const, + icon: Play, + show: (config: FareConfiguration) => !config.is_active, + }, + { + label: 'Test', + onClick: handleTest, + variant: 'secondary' as const, + icon: TestTube, + }, + { + label: 'Delete', + onClick: handleDelete, + variant: 'danger' as const, + icon: Trash2, + show: (config: FareConfiguration) => !config.is_active, + }, + ]; + + return ( +
+
+
+

Configurable Fare Management

+

+ Manage dynamic fare configurations with flexible rules, components, and pricing +

+
+
+ setupSystemMutation.mutate()} + loading={setupSystemMutation.isPending} + disabled={systemStatus?.systemReady} + > + {systemStatus?.systemReady ? 'System Ready' : 'Setup System'} + + setShowCreateModal(true)} + > + New Configuration + +
+
+ + {/* System Status */} +
+
+
+
+
System Status
+
+ {systemStatus?.systemReady ? 'Ready' : 'Setup Required'} +
+
+ + {systemStatus?.configurableFaresEnabled ? 'Enabled' : 'Disabled'} + +
+
+ +
+
Total Configurations
+
{systemStatus?.totalConfigurations || 0}
+
+ +
+
Rollout Percentage
+
{systemStatus?.rolloutPercentage || 0}%
+
+ +
+
Active Configuration
+
+ {systemStatus?.activeConfigurationName || 'None'} +
+
+
+ + {/* System Controls */} +
+
+
+

System Control

+

+ Enable or disable the configurable fare system globally +

+
+
+ + {systemStatus?.configurableFaresEnabled ? 'System Enabled' : 'Using Legacy System'} + + toggleSystemMutation.mutate(!systemStatus?.configurableFaresEnabled)} + loading={toggleSystemMutation.isPending} + icon={systemStatus?.configurableFaresEnabled ? Square : Play} + > + {systemStatus?.configurableFaresEnabled ? 'Disable' : 'Enable'} + +
+
+
+ + {/* Configurations Table */} +
+
+

Fare Configurations

+

+ Manage fare calculation configurations with custom rates, components, and age-based pricing +

+
+ + +
+ + {/* Delete Confirmation */} + setDeleteConfirm({ isOpen: false, config: null })} + onConfirm={confirmDelete} + title="Delete Configuration" + message={`Are you sure you want to delete "${deleteConfirm.config?.name}"? This action cannot be undone.`} + confirmText="Delete" + isDanger={true} + isLoading={deleteMutation.isPending} + warning="Active configurations cannot be deleted. Deactivate first if needed." + /> + + {/* Test Modal */} + {showTestModal && selectedConfig && ( + { + setShowTestModal(false); + setSelectedConfig(null); + }} + /> + )} + + {/* Create/Edit Modal */} + {showCreateModal && ( + setShowCreateModal(false)} + onSuccess={() => { + setShowCreateModal(false); + queryClient.invalidateQueries({ queryKey: ['fare-configurations'] }); + }} + /> + )} +
+ ); +} + +// Test Modal Component +function FareTestModal({ + configuration, + isOpen, + onClose +}: { + configuration: FareConfiguration; + isOpen: boolean; + onClose: () => void; +}) { + const [testData, setTestData] = useState({ + distanceKm: 100, + nationality: 'Ethiopian', + coachType: 'REGULAR_SEAT', + bedPosition: '', + adultCount: 2, + childCount: 1, + }); + + const testMutation = useMutation({ + mutationFn: () => apiClient.post(`/admin/fare-configurations/${configuration.id}/test`, testData), + }); + + const handleTest = () => { + testMutation.mutate(); + }; + + return ( + +
+
+
+ + setTestData({ ...testData, distanceKm: +e.target.value })} + /> +
+
+ + +
+
+ + +
+ {(testData.coachType === 'ECONOMY_BED' || testData.coachType === 'VIP_BED') && ( +
+ + +
+ )} +
+ + setTestData({ ...testData, adultCount: +e.target.value })} + /> +
+
+ + setTestData({ ...testData, childCount: +e.target.value })} + /> +
+
+ + + Calculate Fare + + + {testMutation.data && ( +
+

Calculation Result

+
+
+ Base Fare: + {(testMutation.data.baseFareMinor / 100).toFixed(2)} ETB +
+
+ Components: + {(testMutation.data.componentsTotal / 100).toFixed(2)} ETB +
+
+ Total: + {(testMutation.data.finalTotalMinor / 100).toFixed(2)} ETB +
+
+ + {testMutation.data.breakdown && ( +
+
Calculation Breakdown:
+
+ {testMutation.data.breakdown.map((step: any, index: number) => ( +
+ {step.description} + {(step.runningTotal / 100).toFixed(2)} ETB +
+ ))} +
+
+ )} +
+ )} + + {testMutation.error && ( +
+ {(testMutation.error as any)?.response?.data?.message || 'Test failed'} +
+ )} +
+
+ ); +} + +// Create Configuration Form Modal +function ConfigurationFormModal({ + isOpen, + onClose, + onSuccess +}: { + isOpen: boolean; + onClose: () => void; + onSuccess: () => void; +}) { + return ( + +
+

Configuration Form

+

+ This would contain a comprehensive form for creating fare configurations with rate rules, components, and age pricing. +

+ + Close for Now + +
+
+ ); +} \ No newline at end of file diff --git a/apps/edr-passenger-web/backoffice/src/app/login/page.tsx b/apps/edr-passenger-web/backoffice/src/app/login/page.tsx index d9c9b6220..b2c90d275 100644 --- a/apps/edr-passenger-web/backoffice/src/app/login/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/login/page.tsx @@ -143,17 +143,9 @@ export default function LoginPage() { {/* Password field */}
-
- - -
+
{ + // Auth is already initialized in root providers + // Just wait a tick for hydration + const timer = setTimeout(() => { + setIsLoading(false); + }, 100); + + return () => clearTimeout(timer); + }, []); + + useEffect(() => { + if (!isLoading && !isAuthenticated) { + router.push('/login'); + } + }, [isAuthenticated, router, isLoading]); + + if (isLoading) { + return ( +
+
+
+

Loading...

+
+
+ ); + } + + if (!isAuthenticated) { + return null; + } + + return ( +
+ +
+
+
+ {children} +
+
+
+ ); +} \ No newline at end of file diff --git a/apps/edr-passenger-web/backoffice/src/app/payment-methods/page.tsx b/apps/edr-passenger-web/backoffice/src/app/payment-methods/page.tsx new file mode 100644 index 000000000..ae3b20837 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/payment-methods/page.tsx @@ -0,0 +1,421 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { Plus, Edit2, Trash2 } from 'lucide-react'; +import DataTable from '@/components/ui/DataTable'; +import Badge from '@/components/ui/Badge'; +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 canManageAdmin = usePermission(PERMS.admin); + const canManage = canManagePayments || canManageAdmin; + const [createModalOpen, setCreateModalOpen] = useState(false); + const [editModalOpen, setEditModalOpen] = useState(false); + const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); + const [selectedMethod, setSelectedMethod] = useState(null); + const [successMessage, setSuccessMessage] = useState(''); + const [formData, setFormData] = useState({ + name: '', + type: 'TELEBIRR', + region: 'ETHIOPIA', + currency: 'ETB', + isEnabled: true, + displayOrder: 1, + description: '', + fees: '', + processingTime: '' + }); + + const queryClient = useQueryClient(); + + const { data, isLoading, error } = useQuery({ + queryKey: ['payment-methods'], + queryFn: () => paymentsApi.getMethods(), + }); + + const createMutation = useMutation({ + mutationFn: (data: any) => paymentsApi.addMethod(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['payment-methods'] }); + setCreateModalOpen(false); + resetForm(); + setSuccessMessage('Payment method added successfully'); + setTimeout(() => setSuccessMessage(''), 3000); + }, + }); + + const updateMutation = useMutation({ + mutationFn: ({ id, ...data }: any) => paymentsApi.updateMethod(id, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['payment-methods'] }); + queryClient.refetchQueries({ queryKey: ['payment-methods'] }); + setEditModalOpen(false); + setSelectedMethod(null); + resetForm(); + setSuccessMessage('Payment method updated successfully'); + setTimeout(() => setSuccessMessage(''), 3000); + }, + onError: (error) => { + console.error('Update failed:', error); + setSuccessMessage('Failed to update payment method'); + setTimeout(() => setSuccessMessage(''), 3000); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: (id: string) => paymentsApi.deleteMethod(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['payment-methods'] }); + setDeleteConfirmOpen(false); + setSelectedMethod(null); + setSuccessMessage('Payment method deleted successfully'); + setTimeout(() => setSuccessMessage(''), 3000); + }, + }); + + const resetForm = () => { + setFormData({ + name: '', + type: 'TELEBIRR', + region: 'ETHIOPIA', + currency: 'ETB', + isEnabled: true, + displayOrder: 1, + description: '', + fees: '', + processingTime: '' + }); + }; + + const handleEdit = (method: any) => { + setSelectedMethod(method); + setFormData({ + name: method.displayName || method.name || '', + type: method.type || 'TELEBIRR', + region: method.region || 'ETHIOPIA', + currency: method.currency || 'ETB', + isEnabled: method.enabled ?? method.isEnabled ?? true, + displayOrder: method.sortOrder ?? method.displayOrder ?? 1, + description: method.description || '', + fees: method.fees || '', + processingTime: method.processingTime || '' + }); + setEditModalOpen(true); + }; + + const handleDelete = (method: any) => { + setSelectedMethod(method); + setDeleteConfirmOpen(true); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const submitData = { + displayName: formData.name, + type: formData.type, + region: formData.region, + currency: formData.currency, + enabled: formData.isEnabled, + sortOrder: formData.displayOrder, + // Additional fields that might be expected + description: formData.description, + fees: formData.fees, + processingTime: formData.processingTime, + }; + + console.log('Submitting data:', submitData); + + if (selectedMethod) { + updateMutation.mutate({ id: selectedMethod.id, ...submitData }); + } else { + createMutation.mutate(submitData); + } + }; + + const columns = [ + { + key: 'displayName', + label: 'Name', + sortable: true, + render: (method: any) => ( +
+
{method.displayName || method.name}
+
{method.type}
+
+ ), + }, + { + key: 'region', + label: 'Region', + render: (method: any) => ( + {method.region} + ), + }, + { + key: 'currency', + label: 'Currency', + render: (method: any) => ( + {method.currency} + ), + }, + { + key: 'enabled', + label: 'Status', + render: (method: any) => ( + + {(method.enabled ?? method.isEnabled) ? 'Enabled' : 'Disabled'} + + ), + }, + { + key: 'sortOrder', + label: 'Order', + render: (method: any) => ( + {method.sortOrder ?? method.displayOrder} + ), + }, + ]; + + const actions = [ + { + label: 'Edit', + onClick: handleEdit, + variant: 'secondary' as const, + icon: Edit2, + show: () => canManage, + }, + { + label: 'Delete', + onClick: handleDelete, + variant: 'danger' as const, + icon: Trash2, + show: () => canManage, + }, + ]; + + const paymentTypes = [ + { value: 'TELEBIRR', label: 'Telebirr' }, + { value: 'CBE_BIRR', label: 'CBE Birr' }, + { value: 'EBIRR', label: 'eBirr' }, + { value: 'WAAFI', label: 'Waafi' }, + { value: 'CARD', label: 'Card Payment' }, + { value: 'WALLET', label: 'Internal Wallet' }, + ]; + + const regions = [ + { value: 'ETHIOPIA', label: 'Ethiopia' }, + { value: 'DJIBOUTI', label: 'Djibouti' }, + { value: 'INTERNATIONAL', label: 'International' }, + ]; + + const currencies = [ + { value: 'ETB', label: 'Ethiopian Birr (ETB)' }, + { value: 'DJF', label: 'Djiboutian Franc (DJF)' }, + { value: 'USD', label: 'US Dollar (USD)' }, + ]; + + return ( +
+
+
+

Payment Methods

+

Manage supported payment systems

+
+ + setCreateModalOpen(true)}> + Add Method + + +
+ + {successMessage && ( +
+ ✓ {successMessage} +
+ )} + + {error && ( +
+ Error loading payment methods: {error instanceof Error ? error.message : 'Unknown error'} +
+ )} + + + + { + setCreateModalOpen(false); + setEditModalOpen(false); + setSelectedMethod(null); + resetForm(); + }} + title={selectedMethod ? 'Edit Payment Method' : 'Add Payment Method'} + size="md" + > +
+
+
+ + setFormData({ ...formData, name: e.target.value })} + placeholder="e.g., Telebirr Mobile Money" + required + /> +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + setFormData({ ...formData, displayOrder: parseInt(e.target.value) || 1 })} + min="1" + /> +
+
+ +
+ +