mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 15:48:11 +00:00
Merge branch 'dev' into passenger/feat/iam
This commit is contained in:
27
.github/workflows/deploy.yml
vendored
27
.github/workflows/deploy.yml
vendored
@@ -53,7 +53,7 @@ jobs:
|
|||||||
NON_DEPLOYABLE_PATTERN="^docs/|^README[.]md$|^DEPLOYMENT[.]md$|^CLAUDE[.]md$|^checkpoint[.]md$|^orgstructure[.]md$|^ITMLS_DB_Design[.]md$|.*[.]md$|^[.]eslintrc|^[.]prettierrc|^[.]editorconfig|^[.]gitignore|^[.]gitattributes|^commitlint[.]config[.]js$"
|
NON_DEPLOYABLE_PATTERN="^docs/|^README[.]md$|^DEPLOYMENT[.]md$|^CLAUDE[.]md$|^checkpoint[.]md$|^orgstructure[.]md$|^ITMLS_DB_Design[.]md$|.*[.]md$|^[.]eslintrc|^[.]prettierrc|^[.]editorconfig|^[.]gitignore|^[.]gitattributes|^commitlint[.]config[.]js$"
|
||||||
|
|
||||||
GLOBAL_PATTERN="^[.]github/|^docker-compose[.]yaml$|^turbo[.]json$|^tsconfig[.]json$|^tsconfig[.]base[.]json$|^pnpm-workspace[.]yaml$|^pnpm-lock[.]yaml$|^package[.]json$|^[.]env([.][a-z]+)?$|^packages/|^local-packages/|^infrastructure/|^scripts/deploy/|^wagon[.][^/]*[.]ts$|^cargo[.][^/]*[.]ts$|^container[.][^/]*[.]ts$|^use-[^/]*[.]ts$|^[^/]*[.]service[.]ts$|^[^/]*[.]entity[.]ts$|^[^/]*-types[.]ts$"
|
GLOBAL_PATTERN="^[.]github/|^docker-compose[.]yaml$|^turbo[.]json$|^tsconfig[.]json$|^tsconfig[.]base[.]json$|^pnpm-workspace[.]yaml$|^pnpm-lock[.]yaml$|^package[.]json$|^[.]env([.][a-z]+)?$|^packages/|^local-packages/|^infrastructure/|^scripts/deploy/|^wagon[.][^/]*[.]ts$|^cargo[.][^/]*[.]ts$|^container[.][^/]*[.]ts$|^use-[^/]*[.]ts$|^[^/]*[.]service[.]ts$|^[^/]*[.]entity[.]ts$|^[^/]*-types[.]ts$"
|
||||||
|
|
||||||
DEPLOYABLE=$(echo "$CHANGED" | grep -vE "$NON_DEPLOYABLE_PATTERN" || true)
|
DEPLOYABLE=$(echo "$CHANGED" | grep -vE "$NON_DEPLOYABLE_PATTERN" || true)
|
||||||
if [ -z "$DEPLOYABLE" ]; then
|
if [ -z "$DEPLOYABLE" ]; then
|
||||||
echo "Only non-deployable files changed. Skipping deploy."
|
echo "Only non-deployable files changed. Skipping deploy."
|
||||||
@@ -142,6 +142,31 @@ jobs:
|
|||||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
run: ./scripts/deploy/create-npmrc.sh
|
run: ./scripts/deploy/create-npmrc.sh
|
||||||
|
|
||||||
|
- name: Resolve env file path for ${{ matrix.service }}
|
||||||
|
if: contains(fromJson('["passenger-api", "payment-api"]'), matrix.service)
|
||||||
|
run: |
|
||||||
|
case "${{ matrix.service }}" in
|
||||||
|
passenger-api) echo "SERVICE_ENV_FILE=apps/edr-passenger-api/.env" >> "$GITHUB_ENV" ;;
|
||||||
|
payment-api) echo "SERVICE_ENV_FILE=apps/edr-payment-api/.env" >> "$GITHUB_ENV" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
- name: Build migration image for ${{ matrix.service }}
|
||||||
|
if: contains(fromJson('["passenger-api", "payment-api"]'), matrix.service)
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
docker build \
|
||||||
|
--secret id=npmrc,src=.npmrc \
|
||||||
|
--target migration \
|
||||||
|
-f "apps/edr-${{ matrix.service }}/Dockerfile" \
|
||||||
|
-t "${COMPOSE_PROJECT_NAME}-${{ matrix.service }}-migration" \
|
||||||
|
.
|
||||||
|
|
||||||
|
- name: Run migrations for ${{ matrix.service }}
|
||||||
|
if: contains(fromJson('["passenger-api", "payment-api"]'), matrix.service)
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
docker run --rm --env-file "${SERVICE_ENV_FILE}" "${COMPOSE_PROJECT_NAME}-${{ matrix.service }}-migration"
|
||||||
|
|
||||||
- name: Build ${{ matrix.service }}
|
- name: Build ${{ matrix.service }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ DEFAULT_PASSWORD=password@tria
|
|||||||
# Freight org + staff (bookings / rule-engine IAM)
|
# Freight org + staff (bookings / rule-engine IAM)
|
||||||
SEED_EDR_ORG=true
|
SEED_EDR_ORG=true
|
||||||
SEED_FREIGHT_STAFF=true
|
SEED_FREIGHT_STAFF=true
|
||||||
|
SEED_EXPORT_DJIBOUTI_INTERCHANGE_DEMO=false
|
||||||
|
|
||||||
# MinIO (used by @tria-plc/iamapi-common for file storage)
|
# MinIO (used by @tria-plc/iamapi-common for file storage)
|
||||||
MINIO_ENDPOINT=localhost
|
MINIO_ENDPOINT=localhost
|
||||||
|
|||||||
@@ -19,8 +19,16 @@
|
|||||||
"seed:freight-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-freight-demo.ts",
|
"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: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: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: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"
|
"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",
|
||||||
|
"iam:migration:run": "pnpm run iam:typeorm:cli migration:run",
|
||||||
|
"iam:migration:revert": "pnpm run iam:typeorm:cli migration:revert",
|
||||||
|
"iam:migration:show": "pnpm run iam:typeorm:cli migration:show",
|
||||||
|
"iam:seed:run": "cross-env APP_MODULE_PATH=./dist/app.module dotenv -- node ./node_modules/@tria-plc/iamapi-common/dist/db/seed.cli.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@edr/api-common": "workspace:*",
|
"@edr/api-common": "workspace:*",
|
||||||
@@ -39,14 +47,15 @@
|
|||||||
"@nestjs/swagger": "^11.4.2",
|
"@nestjs/swagger": "^11.4.2",
|
||||||
"@nestjs/typeorm": "^11.0.1",
|
"@nestjs/typeorm": "^11.0.1",
|
||||||
"@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.4.3.tgz",
|
"@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.4.3.tgz",
|
||||||
"@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-0.7.6.tgz",
|
"@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-0.7.7.tgz",
|
||||||
|
|
||||||
"amqp-connection-manager": "^5.0.0",
|
"amqp-connection-manager": "^5.0.0",
|
||||||
"amqplib": "^2.0.1",
|
"amqplib": "^2.0.1",
|
||||||
"axios": "^1.16.1",
|
"axios": "^1.16.1",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.1",
|
"class-validator": "^0.14.1",
|
||||||
|
"cross-env": "^10.1.0",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
|
"dotenv-cli": "^11.0.0",
|
||||||
"handlebars": "^4.7.9",
|
"handlebars": "^4.7.9",
|
||||||
"libphonenumber-js": "^1.13.6",
|
"libphonenumber-js": "^1.13.6",
|
||||||
"minio": "7.1.3",
|
"minio": "7.1.3",
|
||||||
|
|||||||
@@ -52,8 +52,10 @@ import { Batch5TestDataSeeder } from "./seed/batch5-test-data.seeder";
|
|||||||
import { Batch7TestDataSeeder } from "./seed/batch7-test-data.seeder";
|
import { Batch7TestDataSeeder } from "./seed/batch7-test-data.seeder";
|
||||||
import { Batch8TestDataSeeder } from "./seed/batch8-test-data.seeder";
|
import { Batch8TestDataSeeder } from "./seed/batch8-test-data.seeder";
|
||||||
import { WarehouseDemoSeeder } from "./seed/warehouse-demo.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 { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder";
|
||||||
import { DemoFreightDataSeeder } from "./seed/demo-freight-data.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
|
//New Trains, Wagons, Container and Cargo management modules
|
||||||
import { TrainsModule } from "./modules/trains/trains.module";
|
import { TrainsModule } from "./modules/trains/trains.module";
|
||||||
import { WagonsModule } from './modules/wagons/wagons.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 { FirstMileModule } from './modules/first-mile/first-mile.module';
|
||||||
import { LastMileModule } from './modules/last-mile/last-mile.module';
|
import { LastMileModule } from './modules/last-mile/last-mile.module';
|
||||||
import { InterchangeDocumentsModule } from './modules/interchange-documents/interchange-documents.module';
|
import { InterchangeDocumentsModule } from './modules/interchange-documents/interchange-documents.module';
|
||||||
|
import { ImportOperationsModule } from './modules/import-operations/import-operations.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -130,6 +133,7 @@ import { InterchangeDocumentsModule } from './modules/interchange-documents/inte
|
|||||||
FirstMileModule,
|
FirstMileModule,
|
||||||
LastMileModule,
|
LastMileModule,
|
||||||
InterchangeDocumentsModule,
|
InterchangeDocumentsModule,
|
||||||
|
ImportOperationsModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
EdrOrgSeeder,
|
EdrOrgSeeder,
|
||||||
@@ -145,6 +149,8 @@ import { InterchangeDocumentsModule } from './modules/interchange-documents/inte
|
|||||||
Batch7TestDataSeeder,
|
Batch7TestDataSeeder,
|
||||||
Batch8TestDataSeeder,
|
Batch8TestDataSeeder,
|
||||||
WarehouseDemoSeeder,
|
WarehouseDemoSeeder,
|
||||||
|
ExportDjiboutiInterchangeDemoSeeder,
|
||||||
|
ApprovedFirstLastMileDemoBookingsSeeder,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule implements OnApplicationBootstrap {
|
export class AppModule implements OnApplicationBootstrap {
|
||||||
@@ -161,6 +167,7 @@ export class AppModule implements OnApplicationBootstrap {
|
|||||||
private readonly batch7TestDataSeeder: Batch7TestDataSeeder,
|
private readonly batch7TestDataSeeder: Batch7TestDataSeeder,
|
||||||
private readonly batch8TestDataSeeder: Batch8TestDataSeeder,
|
private readonly batch8TestDataSeeder: Batch8TestDataSeeder,
|
||||||
private readonly warehouseDemoSeeder: WarehouseDemoSeeder,
|
private readonly warehouseDemoSeeder: WarehouseDemoSeeder,
|
||||||
|
private readonly exportDjiboutiInterchangeDemoSeeder: ExportDjiboutiInterchangeDemoSeeder,
|
||||||
private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
|
private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
|
||||||
private readonly demoFreightDataSeeder: DemoFreightDataSeeder,
|
private readonly demoFreightDataSeeder: DemoFreightDataSeeder,
|
||||||
) { }
|
) { }
|
||||||
@@ -179,6 +186,7 @@ export class AppModule implements OnApplicationBootstrap {
|
|||||||
await this.batch7TestDataSeeder.run();
|
await this.batch7TestDataSeeder.run();
|
||||||
await this.batch8TestDataSeeder.run();
|
await this.batch8TestDataSeeder.run();
|
||||||
await this.warehouseDemoSeeder.run();
|
await this.warehouseDemoSeeder.run();
|
||||||
|
await this.exportDjiboutiInterchangeDemoSeeder.run();
|
||||||
// Idempotent demo data: ≥100 wagons/type, approval chains, 4 staff users.
|
// 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.
|
// Each block self-guards on an empty-table check, so this is safe every boot.
|
||||||
// Demo data seeds (DemoBookingsSeeder, PricingDataSeeder,
|
// Demo data seeds (DemoBookingsSeeder, PricingDataSeeder,
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddDistanceColumnsToVehicles1821000000002 implements MigrationInterface {
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.vehicles
|
||||||
|
ADD COLUMN IF NOT EXISTS estimated_distance_km NUMERIC,
|
||||||
|
ADD COLUMN IF NOT EXISTS actual_distance_km NUMERIC;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.vehicles
|
||||||
|
DROP COLUMN IF EXISTS estimated_distance_km,
|
||||||
|
DROP COLUMN IF EXISTS actual_distance_km;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { MigrationInterface, QueryRunner, Table, TableForeignKey, TableIndex } from 'typeorm';
|
||||||
|
|
||||||
|
export class CreateImportDjiboutiOperations1822000000000 implements MigrationInterface {
|
||||||
|
name = 'CreateImportDjiboutiOperations1822000000000';
|
||||||
|
|
||||||
|
async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
await queryRunner.dropTable('freight.import_djibouti_operations', true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm';
|
||||||
|
|
||||||
|
export class CreateImportOperationsTables1823000000000 implements MigrationInterface {
|
||||||
|
name = 'CreateImportOperationsTables1823000000000';
|
||||||
|
|
||||||
|
async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
await queryRunner.dropTable('freight.empty_container_returns', true);
|
||||||
|
await queryRunner.dropTable('freight.import_customs_finalizations', true);
|
||||||
|
await queryRunner.dropTable('freight.djibouti_import_incidents', true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||||
import { FindOptionsWhere } from 'typeorm';
|
import { FindOptionsWhere } from 'typeorm';
|
||||||
|
|
||||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||||
@@ -44,30 +44,19 @@ export class FirstMileService {
|
|||||||
* paid before any first-mile work proceeds. Throws if the reference is
|
* paid before any first-mile work proceeds. Throws if the reference is
|
||||||
* unknown or the booking has not reached PAID status.
|
* unknown or the booking has not reached PAID status.
|
||||||
*/
|
*/
|
||||||
async acceptBooking(bookingId: string): Promise<FirstMile | null> {
|
async acceptBooking(bookingId: string): Promise<FirstMile> {
|
||||||
const booking = await this.bookingsRepository.findById(bookingId, {
|
const booking = await this.bookingsRepository.findById(bookingId, {
|
||||||
relations: { serviceType: true },
|
relations: { serviceType: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!booking) {
|
if (!booking) {
|
||||||
return null;
|
throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (booking.paymentStatus !== 'PAID') {
|
return this.acceptEligibleBooking(booking);
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.bookingRequestsFirstMile(booking)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.create({
|
|
||||||
bookingId: booking.id,
|
|
||||||
advancedPayment: 0,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async acceptBookingByReference(bookingReference: string): Promise<FirstMile | null> {
|
async acceptBookingByReference(bookingReference: string): Promise<FirstMile> {
|
||||||
const [booking] = await this.bookingsRepository.findAll({
|
const [booking] = await this.bookingsRepository.findAll({
|
||||||
where: { reference: bookingReference },
|
where: { reference: bookingReference },
|
||||||
relations: { serviceType: true },
|
relations: { serviceType: true },
|
||||||
@@ -75,15 +64,39 @@ export class FirstMileService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!booking) {
|
if (!booking) {
|
||||||
return null;
|
throw new NotFoundException(`Booking ${bookingReference} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return this.acceptEligibleBooking(booking);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared accept path: validates payment + first-mile eligibility, rejects an
|
||||||
|
* already-assigned booking, then creates the first-mile record. Throws a
|
||||||
|
* meaningful HTTP error instead of returning null so the client can surface
|
||||||
|
* why an accept was refused.
|
||||||
|
*/
|
||||||
|
private async acceptEligibleBooking(booking: {
|
||||||
|
id: string;
|
||||||
|
reference?: string;
|
||||||
|
paymentStatus?: string | null;
|
||||||
|
tradeDirection?: string | null;
|
||||||
|
firstMilePickupAddress?: string | null;
|
||||||
|
serviceType?: { includesFirstMile?: boolean | null } | null;
|
||||||
|
}): Promise<FirstMile> {
|
||||||
|
const label = booking.reference ?? booking.id;
|
||||||
|
|
||||||
if (booking.paymentStatus !== 'PAID') {
|
if (booking.paymentStatus !== 'PAID') {
|
||||||
return null;
|
throw new BadRequestException(`Booking ${label} is not paid`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.bookingRequestsFirstMile(booking)) {
|
if (!this.bookingRequestsFirstMile(booking)) {
|
||||||
return null;
|
throw new BadRequestException(`Booking ${label} does not require a first mile`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await this.findByBookingId(booking.id);
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictException(`Booking ${label} already has a first-mile assignment`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.create({
|
return this.create({
|
||||||
@@ -174,10 +187,17 @@ export class FirstMileService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private bookingRequestsFirstMile(booking: {
|
private bookingRequestsFirstMile(booking: {
|
||||||
|
tradeDirection?: string | null;
|
||||||
firstMilePickupAddress?: string | null;
|
firstMilePickupAddress?: string | null;
|
||||||
serviceType?: { includesFirstMile?: boolean | null } | null;
|
serviceType?: { includesFirstMile?: boolean | null } | null;
|
||||||
}): boolean {
|
}): boolean {
|
||||||
return Boolean(booking.firstMilePickupAddress?.trim() || booking.serviceType?.includesFirstMile);
|
// Export bookings always need a first mile (pickup → origin yard); the
|
||||||
|
// pickup address is captured at assignment time, not required upfront.
|
||||||
|
return Boolean(
|
||||||
|
booking.tradeDirection === 'EXPORT' ||
|
||||||
|
booking.firstMilePickupAddress?.trim() ||
|
||||||
|
booking.serviceType?.includesFirstMile,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(id: string, dto: UpdateFirstMileDto): Promise<FirstMile> {
|
async update(id: string, dto: UpdateFirstMileDto): Promise<FirstMile> {
|
||||||
@@ -205,6 +225,16 @@ export class FirstMileService {
|
|||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateStatus(id: string, status: FirstMileStatus): Promise<FirstMile> {
|
||||||
|
const updated = await this.firstMileRepository.update(id, { status });
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
throw new NotFoundException(`First-mile record ${id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
private async notifyDriverAssignment(vehicleId: string, record: FirstMile): Promise<void> {
|
private async notifyDriverAssignment(vehicleId: string, record: FirstMile): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const vehicle = await this.vehiclesService.findById(vehicleId);
|
const vehicle = await this.vehiclesService.findById(vehicleId);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<Record<ImportCustomsDocumentType, string>>;
|
||||||
|
|
||||||
|
@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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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<DjiboutiIncident>,
|
||||||
|
@InjectRepository(ImportCustomsFinalization)
|
||||||
|
private readonly customs: Repository<ImportCustomsFinalization>,
|
||||||
|
@InjectRepository(EmptyContainerReturn)
|
||||||
|
private readonly emptyReturns: Repository<EmptyContainerReturn>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -265,12 +265,12 @@ export class InterchangeDocumentsService {
|
|||||||
)
|
)
|
||||||
SELECT a.booking_id AS "bookingId",
|
SELECT a.booking_id AS "bookingId",
|
||||||
a.reference AS "bookingReference",
|
a.reference AS "bookingReference",
|
||||||
'CONTAINER' AS "itemType",
|
'CONTAINER'::varchar AS "itemType",
|
||||||
COALESCE(c.booking_container_id, bc.id) AS "bookingContainerId",
|
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",
|
COALESCE(c.container_number, bc.container_number) AS "containerNumber",
|
||||||
c.seal_number AS "sealNumber",
|
c.seal_number AS "sealNumber",
|
||||||
NULL AS "cargoId",
|
NULL::uuid AS "cargoId",
|
||||||
a.booking_cargo_type AS "cargoType",
|
a.booking_cargo_type AS "cargoType",
|
||||||
a.cargo_free_text AS "cargoDescription",
|
a.cargo_free_text AS "cargoDescription",
|
||||||
COALESCE(bc.total_vgm_tons, c.max_gross_weight, a.cargo_total_weight_vgm) AS "weight",
|
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
|
UNION ALL
|
||||||
SELECT a.booking_id AS "bookingId",
|
SELECT a.booking_id AS "bookingId",
|
||||||
a.reference AS "bookingReference",
|
a.reference AS "bookingReference",
|
||||||
'CONTAINER' AS "itemType",
|
'CONTAINER'::varchar AS "itemType",
|
||||||
bc.id AS "bookingContainerId",
|
bc.id AS "bookingContainerId",
|
||||||
NULL AS "bookingCargoId",
|
NULL::uuid AS "bookingCargoId",
|
||||||
bc.container_number AS "containerNumber",
|
bc.container_number AS "containerNumber",
|
||||||
NULL AS "sealNumber",
|
NULL::varchar AS "sealNumber",
|
||||||
NULL AS "cargoId",
|
NULL::uuid AS "cargoId",
|
||||||
a.booking_cargo_type AS "cargoType",
|
a.booking_cargo_type AS "cargoType",
|
||||||
a.cargo_free_text AS "cargoDescription",
|
a.cargo_free_text AS "cargoDescription",
|
||||||
COALESCE(bc.total_vgm_tons, a.cargo_total_weight_vgm) AS "weight",
|
COALESCE(bc.total_vgm_tons, a.cargo_total_weight_vgm) AS "weight",
|
||||||
@@ -314,11 +314,11 @@ export class InterchangeDocumentsService {
|
|||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT a.booking_id AS "bookingId",
|
SELECT a.booking_id AS "bookingId",
|
||||||
a.reference AS "bookingReference",
|
a.reference AS "bookingReference",
|
||||||
'CARGO' AS "itemType",
|
'CARGO'::varchar AS "itemType",
|
||||||
NULL AS "bookingContainerId",
|
NULL::uuid AS "bookingContainerId",
|
||||||
cg.id AS "bookingCargoId",
|
cg.id AS "bookingCargoId",
|
||||||
NULL AS "containerNumber",
|
NULL::varchar AS "containerNumber",
|
||||||
NULL AS "sealNumber",
|
NULL::varchar AS "sealNumber",
|
||||||
cg.id AS "cargoId",
|
cg.id AS "cargoId",
|
||||||
COALESCE(cgt.cargo_type_name, a.booking_cargo_type) AS "cargoType",
|
COALESCE(cgt.cargo_type_name, a.booking_cargo_type) AS "cargoType",
|
||||||
COALESCE(cg.description, a.cargo_free_text) AS "cargoDescription",
|
COALESCE(cg.description, a.cargo_free_text) AS "cargoDescription",
|
||||||
@@ -337,12 +337,12 @@ export class InterchangeDocumentsService {
|
|||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT a.booking_id AS "bookingId",
|
SELECT a.booking_id AS "bookingId",
|
||||||
a.reference AS "bookingReference",
|
a.reference AS "bookingReference",
|
||||||
CASE WHEN a.booking_cargo_type ILIKE '%container%' THEN 'CONTAINER' ELSE 'CARGO' END AS "itemType",
|
(CASE WHEN a.booking_cargo_type ILIKE '%container%' THEN 'CONTAINER' ELSE 'CARGO' END)::varchar AS "itemType",
|
||||||
NULL AS "bookingContainerId",
|
NULL::uuid AS "bookingContainerId",
|
||||||
NULL AS "bookingCargoId",
|
NULL::uuid AS "bookingCargoId",
|
||||||
NULL AS "containerNumber",
|
NULL::varchar AS "containerNumber",
|
||||||
NULL AS "sealNumber",
|
NULL::varchar AS "sealNumber",
|
||||||
NULL AS "cargoId",
|
NULL::uuid AS "cargoId",
|
||||||
a.booking_cargo_type AS "cargoType",
|
a.booking_cargo_type AS "cargoType",
|
||||||
a.cargo_free_text AS "cargoDescription",
|
a.cargo_free_text AS "cargoDescription",
|
||||||
a.cargo_total_weight_vgm AS "weight",
|
a.cargo_total_weight_vgm AS "weight",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<Record<ImportDjiboutiDocumentType, ImportDjiboutiDocumentRecord>>;
|
||||||
|
|
||||||
|
@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;
|
||||||
|
}
|
||||||
@@ -8,9 +8,11 @@ import {
|
|||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
Query,
|
Query,
|
||||||
|
Res,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { CurrentUser } from "@edr/api-common";
|
import { CurrentUser } from "@edr/api-common";
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||||
|
import type { Response } from "express";
|
||||||
import type { AuthUserPayload } from "../../common/resolve-auth-user-id";
|
import type { AuthUserPayload } from "../../common/resolve-auth-user-id";
|
||||||
import { resolveAuthUserId } 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 { PreviewContainerTrainScheduleDto } from "./dto/preview-container-train-schedule.dto";
|
||||||
import { PreviewTrainScheduleDto } from "./dto/preview-train-schedule.dto";
|
import { PreviewTrainScheduleDto } from "./dto/preview-train-schedule.dto";
|
||||||
import { RecordCheckpointDto } from "./dto/record-checkpoint.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 { AvailableLocomotivesQueryDto } from "./dto/available-locomotives-query.dto";
|
||||||
import { BookableSchedulesQueryDto } from "./dto/bookable-schedules-query.dto";
|
import { BookableSchedulesQueryDto } from "./dto/bookable-schedules-query.dto";
|
||||||
import { AvailableDaysQueryDto } from "./dto/available-days-query.dto";
|
import { AvailableDaysQueryDto } from "./dto/available-days-query.dto";
|
||||||
@@ -304,6 +310,101 @@ export class TrainSchedulingController {
|
|||||||
return this.trainSchedulingService.dispatchSchedule(id);
|
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 ----
|
// ---- batch / booking-window staff actions ----
|
||||||
|
|
||||||
@Post("schedules/:id/run-batch")
|
@Post("schedules/:id/run-batch")
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { WagonTypesModule } from '../wagon-types/wagon-types.module';
|
|||||||
import { Wagon } from '../wagons/entities/wagon.entity';
|
import { Wagon } from '../wagons/entities/wagon.entity';
|
||||||
import { WarehousesModule } from '../warehouses/warehouses.module';
|
import { WarehousesModule } from '../warehouses/warehouses.module';
|
||||||
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
|
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 { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity';
|
||||||
import { TrainCheckpointEventsRepository } from './train-checkpoint-events.repository';
|
import { TrainCheckpointEventsRepository } from './train-checkpoint-events.repository';
|
||||||
import { TrainSchedulingController } from './train-scheduling.controller';
|
import { TrainSchedulingController } from './train-scheduling.controller';
|
||||||
@@ -38,6 +39,7 @@ import { NotificationsModule } from '../notifications/notifications.module';
|
|||||||
Container,
|
Container,
|
||||||
TrainSchedulingGlobalRules,
|
TrainSchedulingGlobalRules,
|
||||||
TrainCheckpointEvent,
|
TrainCheckpointEvent,
|
||||||
|
ImportDjiboutiOperation,
|
||||||
]),
|
]),
|
||||||
forwardRef(() => BookingsModule),
|
forwardRef(() => BookingsModule),
|
||||||
NotificationsModule,
|
NotificationsModule,
|
||||||
|
|||||||
@@ -151,6 +151,9 @@ describe('TrainSchedulingService', () => {
|
|||||||
autoUnloadArrivedBookings: jest.fn(),
|
autoUnloadArrivedBookings: jest.fn(),
|
||||||
autoUnloadExportAtDjibouti: jest.fn(),
|
autoUnloadExportAtDjibouti: jest.fn(),
|
||||||
} as never,
|
} as never,
|
||||||
|
{
|
||||||
|
htmlToPdfBuffer: jest.fn(),
|
||||||
|
} as never,
|
||||||
);
|
);
|
||||||
|
|
||||||
const defaultFleetWagons = [
|
const defaultFleetWagons = [
|
||||||
|
|||||||
@@ -49,6 +49,15 @@ import { PreviewBulkTrainScheduleDto } from './dto/preview-bulk-train-schedule.d
|
|||||||
import { PreviewContainerTrainScheduleDto } from './dto/preview-container-train-schedule.dto';
|
import { PreviewContainerTrainScheduleDto } from './dto/preview-container-train-schedule.dto';
|
||||||
import { PreviewTrainScheduleDto } from './dto/preview-train-schedule.dto';
|
import { PreviewTrainScheduleDto } from './dto/preview-train-schedule.dto';
|
||||||
import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity';
|
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 { UpdateTrainSchedulingGlobalRulesDto } from './dto/update-train-scheduling-global-rules.dto';
|
||||||
import {
|
import {
|
||||||
buildCappedWagonPlan,
|
buildCappedWagonPlan,
|
||||||
@@ -97,6 +106,7 @@ import { RecordCheckpointDto } from './dto/record-checkpoint.dto';
|
|||||||
import { RouteMilestone } from '../routes/entities/route-milestone.entity';
|
import { RouteMilestone } from '../routes/entities/route-milestone.entity';
|
||||||
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||||||
import { WarehouseInventoryService } from '../warehouses/warehouse-inventory.service';
|
import { WarehouseInventoryService } from '../warehouses/warehouse-inventory.service';
|
||||||
|
import { WarehouseReleaseDocumentService } from '../warehouses/warehouse-release-document.service';
|
||||||
import {
|
import {
|
||||||
autoFillPlacements,
|
autoFillPlacements,
|
||||||
findMissingContainerNumberIssues,
|
findMissingContainerNumberIssues,
|
||||||
@@ -170,6 +180,7 @@ export class TrainSchedulingService {
|
|||||||
private readonly trainCheckpointEventsRepository: TrainCheckpointEventsRepository,
|
private readonly trainCheckpointEventsRepository: TrainCheckpointEventsRepository,
|
||||||
private readonly trainCompositionRemovalLogRepository: TrainCompositionRemovalLogRepository,
|
private readonly trainCompositionRemovalLogRepository: TrainCompositionRemovalLogRepository,
|
||||||
private readonly warehouseInventoryService: WarehouseInventoryService,
|
private readonly warehouseInventoryService: WarehouseInventoryService,
|
||||||
|
private readonly pdfDocuments: WarehouseReleaseDocumentService,
|
||||||
private readonly configService?: ConfigService,
|
private readonly configService?: ConfigService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -767,6 +778,7 @@ export class TrainSchedulingService {
|
|||||||
if (schedule.status !== TrainScheduleStatusEnum.Scheduled) {
|
if (schedule.status !== TrainScheduleStatusEnum.Scheduled) {
|
||||||
throw new BadRequestException('Only SCHEDULED trains can be dispatched');
|
throw new BadRequestException('Only SCHEDULED trains can be dispatched');
|
||||||
}
|
}
|
||||||
|
await this.assertImportDjiboutiMayDepart(schedule);
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
await this.dataSource.transaction(async (manager) => {
|
await this.dataSource.transaction(async (manager) => {
|
||||||
@@ -806,9 +818,555 @@ export class TrainSchedulingService {
|
|||||||
.execute();
|
.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);
|
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, '"')
|
||||||
|
.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<string, unknown> | null | undefined;
|
||||||
|
const cargoType = (booking as unknown as { cargoType?: { name?: string; code?: string } } | undefined)?.cargoType;
|
||||||
|
const containerItems = allocation.containerItems ?? [];
|
||||||
|
const firstContainer = containerItems[0];
|
||||||
|
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 `<tr>
|
||||||
|
<td>${esc(wagon.sequenceNo)}</td>
|
||||||
|
<td>${esc(wagon.physicalWagon?.wagonNumber)}</td>
|
||||||
|
<td>${esc(wagon.wagonType?.code ?? wagon.wagonType?.name)}</td>
|
||||||
|
<td class="num">${esc(Number(wagon.lengthMeters || 0).toFixed(3))}</td>
|
||||||
|
<td class="num">${esc(Number(wagon.physicalWagon?.tareWeight ?? 0).toFixed(2))}</td>
|
||||||
|
<td class="num">${esc(Number(wagon.capacityTons || 0).toFixed(3))}</td>
|
||||||
|
<td>${esc(company?.name ?? company?.legalName ?? company?.tradeName ?? booking?.companyId)}</td>
|
||||||
|
<td>${esc(booking?.companyId)}</td>
|
||||||
|
<td>${esc(cargoType?.name ?? cargoType?.code ?? allocation.loadType)}</td>
|
||||||
|
<td>${esc(containerNumbers || firstContainer?.containerNumber)}</td>
|
||||||
|
<td>${esc(chassisNumbers)}</td>
|
||||||
|
<td>${esc(sealNumbers)}</td>
|
||||||
|
</tr>`;
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.join('');
|
||||||
|
const totalWeight = (schedule.trainSet?.wagons ?? []).reduce(
|
||||||
|
(sum, wagon) =>
|
||||||
|
sum + (wagon.allocations ?? []).reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
return `<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Export Marshalling Document</title>
|
||||||
|
<style>
|
||||||
|
@page { size: A4 landscape; margin: 10mm; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin: 0; color: #0f172a; font-family: Arial, sans-serif; }
|
||||||
|
.top { display: flex; justify-content: space-between; border-bottom: 3px solid #0f766e; padding-bottom: 10px; gap: 24px; }
|
||||||
|
.brand { font-size: 11px; color: #475569; text-transform: uppercase; letter-spacing: .08em; font-weight: 700; }
|
||||||
|
h1 { margin: 6px 0 0; font-size: 25px; line-height: 1.05; }
|
||||||
|
.meta { text-align: right; font-size: 11px; color: #475569; min-width: 210px; }
|
||||||
|
.meta strong { display: block; margin-top: 4px; color: #0f172a; font-size: 15px; }
|
||||||
|
.summary { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; margin: 14px 0; }
|
||||||
|
.tile { border: 1px solid #cbd5e1; padding: 8px; min-height: 50px; }
|
||||||
|
.tile span { display: block; color: #64748b; font-size: 9px; text-transform: uppercase; letter-spacing: .05em; margin-bottom: 4px; }
|
||||||
|
.tile strong { font-size: 11px; }
|
||||||
|
table { width: 100%; border-collapse: collapse; }
|
||||||
|
th { background: #f8fafc; color: #475569; text-align: left; }
|
||||||
|
th, td { border: 1px solid #cbd5e1; padding: 5px 6px; font-size: 9.5px; vertical-align: top; }
|
||||||
|
.num { text-align: right; }
|
||||||
|
.notice { margin-top: 10px; border-left: 4px solid #0f766e; background: #f0fdfa; padding: 8px 10px; font-size: 10px; color: #134e4a; }
|
||||||
|
.signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; margin-top: 34px; }
|
||||||
|
.line { border-top: 1px solid #334155; padding-top: 7px; font-size: 9px; color: #475569; min-height: 34px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="top">
|
||||||
|
<div>
|
||||||
|
<div class="brand">Ethio-Djibouti Railway S.C.</div>
|
||||||
|
<h1>Export Marshalling Document / Load List</h1>
|
||||||
|
</div>
|
||||||
|
<div class="meta">
|
||||||
|
Train / Schedule
|
||||||
|
<strong>${esc(schedule.trainNumber ?? schedule.id)}</strong>
|
||||||
|
Generated: ${esc(new Date().toLocaleString('en-GB'))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="summary">
|
||||||
|
<div class="tile"><span>Train ID</span><strong>${esc(schedule.trainNumber ?? schedule.id)}</strong></div>
|
||||||
|
<div class="tile"><span>Departure date</span><strong>${esc(date(schedule.scheduledDepartureDate))}</strong></div>
|
||||||
|
<div class="tile"><span>Departure time</span><strong>${esc(time(schedule.scheduledDepartureDate))}</strong></div>
|
||||||
|
<div class="tile"><span>Departure station</span><strong>${esc(schedule.originStation?.label ?? schedule.originStation?.code)}</strong></div>
|
||||||
|
<div class="tile"><span>Arrival station</span><strong>${esc(schedule.destinationStation?.label ?? schedule.destinationStation?.code)}</strong></div>
|
||||||
|
<div class="tile"><span>Total loaded weight</span><strong>${esc(totalWeight.toFixed(3))} T</strong></div>
|
||||||
|
<div class="tile"><span>Prepared person</span><strong>${esc(schedule.preparedByUserId)}</strong></div>
|
||||||
|
<div class="tile"><span>Check person</span><strong>${esc(schedule.checkedByUserId)}</strong></div>
|
||||||
|
<div class="tile"><span>Wagons</span><strong>${esc(schedule.trainSet?.wagons?.length ?? 0)}</strong></div>
|
||||||
|
<div class="tile"><span>Bookings</span><strong>${esc(schedule.scheduleBookings?.length ?? 0)}</strong></div>
|
||||||
|
<div class="tile"><span>Status</span><strong>${esc(schedule.status)}</strong></div>
|
||||||
|
<div class="tile"><span>Direction</span><strong>${esc(schedule.direction)}</strong></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Seq</th>
|
||||||
|
<th>Wagon No</th>
|
||||||
|
<th>Wagon Type</th>
|
||||||
|
<th class="num">Equated Length</th>
|
||||||
|
<th class="num">Tare Weight</th>
|
||||||
|
<th class="num">Load Capacity</th>
|
||||||
|
<th>Customer Name</th>
|
||||||
|
<th>Customer ID</th>
|
||||||
|
<th>Cargo Type</th>
|
||||||
|
<th>Container No</th>
|
||||||
|
<th>Chassis No</th>
|
||||||
|
<th>Seal No</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${rows || '<tr><td colspan="12">No wagon allocations found for this export train.</td></tr>'}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="notice">
|
||||||
|
Loading and dispatch staff must verify wagon identity, seal number, container number,
|
||||||
|
cargo type, and customer booking against the physical consist before departure.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="signatures">
|
||||||
|
<div class="line">Prepared person / date</div>
|
||||||
|
<div class="line">Check person / date</div>
|
||||||
|
<div class="line">Operations authorization / date</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<ReturnType<TrainSchedulingService['generateImportLoadList']>>): string {
|
||||||
|
const esc = (value: unknown) =>
|
||||||
|
String(value ?? '-')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.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) => `<tr>
|
||||||
|
<td>${esc(wagon.sequenceNo)}</td>
|
||||||
|
<td>${esc(wagon.wagonNumber)}</td>
|
||||||
|
<td>${esc(allocation.bookingReference ?? allocation.bookingId)}</td>
|
||||||
|
<td>${esc(allocation.loadType)}</td>
|
||||||
|
<td>${esc(allocation.containerNumbers.length ? allocation.containerNumbers.join(', ') : '-')}</td>
|
||||||
|
<td class="num">${esc(Number(allocation.allocatedWeightTons || 0).toFixed(3))}</td>
|
||||||
|
</tr>`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
return `<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Import Load List / Marshalling Document</title>
|
||||||
|
<style>
|
||||||
|
@page { size: A4; margin: 14mm; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin: 0; color: #0f172a; font-family: Arial, sans-serif; }
|
||||||
|
.doc { min-height: 100vh; position: relative; }
|
||||||
|
.top { display: flex; justify-content: space-between; gap: 24px; border-bottom: 3px solid #0f766e; padding-bottom: 14px; }
|
||||||
|
.brand { font-size: 12px; color: #475569; text-transform: uppercase; letter-spacing: .08em; font-weight: 700; }
|
||||||
|
h1 { margin: 8px 0 0; font-size: 28px; line-height: 1.05; }
|
||||||
|
.subtitle { margin-top: 6px; color: #64748b; font-size: 12px; }
|
||||||
|
.meta { text-align: right; font-size: 12px; color: #475569; min-width: 190px; }
|
||||||
|
.meta strong { display: block; margin-top: 5px; color: #0f172a; font-size: 16px; }
|
||||||
|
.summary { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-top: 18px; }
|
||||||
|
.tile { border: 1px solid #cbd5e1; padding: 10px; min-height: 58px; }
|
||||||
|
.tile span { display: block; color: #64748b; font-size: 10px; text-transform: uppercase; letter-spacing: .05em; margin-bottom: 5px; }
|
||||||
|
.tile strong { font-size: 13px; }
|
||||||
|
.status { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; margin-top: 14px; }
|
||||||
|
.step { border: 1px solid #cbd5e1; padding: 8px; font-size: 10px; text-align: center; min-height: 48px; }
|
||||||
|
.done { background: #ecfdf5; border-color: #22c55e; color: #14532d; font-weight: 700; }
|
||||||
|
.pending { background: #f8fafc; color: #64748b; }
|
||||||
|
h2 { margin: 22px 0 8px; font-size: 14px; color: #0f766e; text-transform: uppercase; letter-spacing: .06em; }
|
||||||
|
table { width: 100%; border-collapse: collapse; }
|
||||||
|
th { background: #f8fafc; color: #475569; text-align: left; }
|
||||||
|
th, td { border: 1px solid #cbd5e1; padding: 7px 8px; font-size: 11px; vertical-align: top; }
|
||||||
|
.num { text-align: right; }
|
||||||
|
.notice { margin-top: 16px; border-left: 4px solid #0f766e; background: #f0fdfa; padding: 10px 12px; font-size: 11px; color: #134e4a; }
|
||||||
|
.signatures { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 22px; margin-top: 44px; }
|
||||||
|
.line { border-top: 1px solid #334155; padding-top: 8px; font-size: 10px; color: #475569; min-height: 42px; }
|
||||||
|
.footer { position: fixed; left: 0; right: 0; bottom: 0; color: #64748b; font-size: 9px; border-top: 1px solid #e2e8f0; padding-top: 6px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="doc">
|
||||||
|
<div class="top">
|
||||||
|
<div>
|
||||||
|
<div class="brand">Ethio-Djibouti Railway S.C.</div>
|
||||||
|
<h1>Import Load List /<br />Marshalling Document</h1>
|
||||||
|
<div class="subtitle">Djibouti-side gatepass, loading, and departure manifest</div>
|
||||||
|
</div>
|
||||||
|
<div class="meta">
|
||||||
|
Train / Schedule
|
||||||
|
<strong>${esc(loadList.trainNumber ?? loadList.trainScheduleId)}</strong>
|
||||||
|
Generated: ${esc(date(loadList.generatedAt))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="summary">
|
||||||
|
<div class="tile"><span>Route</span><strong>${esc(loadList.route)}</strong></div>
|
||||||
|
<div class="tile"><span>Origin</span><strong>${esc(loadList.origin)}</strong></div>
|
||||||
|
<div class="tile"><span>Destination</span><strong>${esc(loadList.destination)}</strong></div>
|
||||||
|
<div class="tile"><span>Total bookings</span><strong>${esc(loadList.totalBookings)}</strong></div>
|
||||||
|
<div class="tile"><span>Wagons</span><strong>${esc(loadList.wagons.length)}</strong></div>
|
||||||
|
<div class="tile"><span>Allocations</span><strong>${esc(totalAllocations)}</strong></div>
|
||||||
|
<div class="tile"><span>Total weight</span><strong>${esc(totalWeight.toFixed(3))} T</strong></div>
|
||||||
|
<div class="tile"><span>Gatepass granted</span><strong>${esc(date(loadList.operation.gatepassGrantedAt))}</strong></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="status">
|
||||||
|
<div class="step ${status.documentsComplete ? 'done' : 'pending'}">Documents</div>
|
||||||
|
<div class="step ${status.gatepassGranted ? 'done' : 'pending'}">Gatepass</div>
|
||||||
|
<div class="step ${status.readyForLoading ? 'done' : 'pending'}">Ready</div>
|
||||||
|
<div class="step ${status.loadedOnTrain ? 'done' : 'pending'}">Loaded</div>
|
||||||
|
<div class="step ${status.departedFromDjibouti ? 'done' : 'pending'}">Departed</div>
|
||||||
|
<div class="step ${status.loadListGenerated ? 'done' : 'pending'}">Document</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Wagon Marshalling Allocation</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Seq</th>
|
||||||
|
<th>Wagon</th>
|
||||||
|
<th>Booking</th>
|
||||||
|
<th>Load</th>
|
||||||
|
<th>Container numbers</th>
|
||||||
|
<th class="num">Weight T</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${allocationRows || '<tr><td colspan="6">No wagon allocations found for this train.</td></tr>'}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="notice">
|
||||||
|
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.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="signatures">
|
||||||
|
<div class="line">Prepared by Djibouti operations</div>
|
||||||
|
<div class="line">Train loading supervisor</div>
|
||||||
|
<div class="line">EDR operations authorization</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
System generated marshalling document. Schedule ID: ${esc(loadList.trainScheduleId)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private safeDocumentName(value: string): string {
|
||||||
|
return value.replace(/[^a-zA-Z0-9_-]+/g, '-');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertImportDjiboutiMayDepart(schedule: TrainSchedule): Promise<void> {
|
||||||
|
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<TrainSchedule> {
|
||||||
|
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<ImportDjiboutiOperation> {
|
||||||
|
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
|
* 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
|
* for the train's dominant cargo type (container vs bulk) and trade direction
|
||||||
|
|||||||
@@ -49,4 +49,12 @@ export class CreateVehicleDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
trailerPlateNo?: string;
|
trailerPlateNo?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
estimatedDistanceKm?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
actualDistanceKm?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,4 +62,10 @@ export class Vehicle extends BaseEntity {
|
|||||||
|
|
||||||
@Column({ name: 'assigned_driver_name', nullable: true })
|
@Column({ name: 'assigned_driver_name', nullable: true })
|
||||||
assignedDriverName?: string;
|
assignedDriverName?: string;
|
||||||
|
|
||||||
|
@Column({ name: 'estimated_distance_km', type: 'numeric', nullable: true })
|
||||||
|
estimatedDistanceKm?: number;
|
||||||
|
|
||||||
|
@Column({ name: 'actual_distance_km', type: 'numeric', nullable: true })
|
||||||
|
actualDistanceKm?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
import { ArrayNotEmpty, IsArray, IsIn, IsNumber, IsOptional, IsString, IsUUID, Min } from 'class-validator';
|
import { ArrayNotEmpty, IsArray, IsIn, IsNumber, IsOptional, IsString, IsUUID, Min } from 'class-validator';
|
||||||
|
import { ValidateNested } from 'class-validator';
|
||||||
|
|
||||||
export class TruckEntranceDto {
|
export class TruckEntranceDto {
|
||||||
@ApiPropertyOptional()
|
@ApiPropertyOptional()
|
||||||
@@ -22,6 +24,11 @@ export class TruckEntranceDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
tin?: string;
|
tin?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
customerPhone?: string;
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
@IsString()
|
@IsString()
|
||||||
truckPlateNumber!: string;
|
truckPlateNumber!: string;
|
||||||
@@ -174,8 +181,11 @@ export class BulkReceiveDto {
|
|||||||
@IsUUID('all', { each: true })
|
@IsUUID('all', { each: true })
|
||||||
bookingIds!: string[];
|
bookingIds!: string[];
|
||||||
|
|
||||||
@ApiProperty({ type: TruckEntranceDto })
|
@ApiPropertyOptional({ type: TruckEntranceDto })
|
||||||
truckEntrance!: TruckEntranceDto;
|
@IsOptional()
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => TruckEntranceDto)
|
||||||
|
truckEntrance?: TruckEntranceDto;
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
@ApiPropertyOptional()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@@ -33,4 +33,14 @@ export class PayInvoiceBodyDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
reference?: string;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
import { IsNumber, IsOptional, IsString, IsUUID, Min } from 'class-validator';
|
import { IsNumber, IsOptional, IsString, IsUUID, Min } from 'class-validator';
|
||||||
|
import { ValidateNested } from 'class-validator';
|
||||||
import { TruckEntranceDto } from './bulk-receive.dto';
|
import { TruckEntranceDto } from './bulk-receive.dto';
|
||||||
|
|
||||||
export class ReceiveWarehouseInventoryDto {
|
export class ReceiveWarehouseInventoryDto {
|
||||||
@@ -57,6 +59,8 @@ export class ReceiveWarehouseInventoryDto {
|
|||||||
notes?: string;
|
notes?: string;
|
||||||
|
|
||||||
@ApiProperty({ type: TruckEntranceDto })
|
@ApiProperty({ type: TruckEntranceDto })
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => TruckEntranceDto)
|
||||||
truckEntrance!: TruckEntranceDto;
|
truckEntrance!: TruckEntranceDto;
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
@ApiPropertyOptional()
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
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. */
|
/** Records a DO / release order being sent to the customer for import pickup. */
|
||||||
export class ReleaseOrderDto {
|
export class ReleaseOrderDto {
|
||||||
@@ -17,4 +17,77 @@ export class ReleaseOrderDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
performedBy?: string;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -329,7 +329,7 @@ export class SchedulingReadFacade {
|
|||||||
}
|
}
|
||||||
if (filter.destination) {
|
if (filter.destination) {
|
||||||
params.push(`%${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) {
|
if (filter.dateFrom) {
|
||||||
params.push(filter.dateFrom);
|
params.push(filter.dateFrom);
|
||||||
@@ -351,7 +351,7 @@ export class SchedulingReadFacade {
|
|||||||
ts.train_number AS "trainNumber",
|
ts.train_number AS "trainNumber",
|
||||||
oy.code AS "origin",
|
oy.code AS "origin",
|
||||||
dy.code AS "destination",
|
dy.code AS "destination",
|
||||||
dy.name AS "destinationName",
|
dy.label AS "destinationName",
|
||||||
oy.country AS "originCountry",
|
oy.country AS "originCountry",
|
||||||
dy.country AS "destinationCountry",
|
dy.country AS "destinationCountry",
|
||||||
ts.scheduled_departure_date AS "departureTime",
|
ts.scheduled_departure_date AS "departureTime",
|
||||||
|
|||||||
@@ -86,6 +86,12 @@ export class WarehouseInventoryController {
|
|||||||
return this.inventoryService.readyToLoadExport();
|
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')
|
@Get('loaded-export')
|
||||||
@ApiOperation({ summary: 'EXPORT inventory that is LOADED and queued for dispatch' })
|
@ApiOperation({ summary: 'EXPORT inventory that is LOADED and queued for dispatch' })
|
||||||
loadedExport() {
|
loadedExport() {
|
||||||
|
|||||||
@@ -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 { Between, DataSource, EntityManager, FindManyOptions, ILike, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
|
||||||
|
|
||||||
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
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 { InterchangeDocumentsService } from '../interchange-documents/interchange-documents.service';
|
||||||
import type { InterchangeDocument } from '../interchange-documents/entities/interchange-document.entity';
|
import type { InterchangeDocument } from '../interchange-documents/entities/interchange-document.entity';
|
||||||
import { LastMileService } from '../last-mile/last-mile.service';
|
import { LastMileService } from '../last-mile/last-mile.service';
|
||||||
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
import { BulkInspectDto } from './dto/bulk-inspect.dto';
|
import { BulkInspectDto } from './dto/bulk-inspect.dto';
|
||||||
import { BulkReceiveDto, TruckEntranceDto } from './dto/bulk-receive.dto';
|
import { BulkReceiveDto, TruckEntranceDto } from './dto/bulk-receive.dto';
|
||||||
import { DeliverInventoryDto } from './dto/deliver-inventory.dto';
|
import { DeliverInventoryDto } from './dto/deliver-inventory.dto';
|
||||||
@@ -191,6 +192,13 @@ export interface EligibleBookingRow {
|
|||||||
reference: string;
|
reference: string;
|
||||||
customerId: string | null;
|
customerId: string | null;
|
||||||
customer: 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;
|
direction: string;
|
||||||
origin: string | null;
|
origin: string | null;
|
||||||
destination: string | null;
|
destination: string | null;
|
||||||
@@ -205,6 +213,10 @@ export interface EligibleBookingRow {
|
|||||||
firstMileVehicleId: string | null;
|
firstMileVehicleId: string | null;
|
||||||
firstMileTruckPlateNumber: string | null;
|
firstMileTruckPlateNumber: string | null;
|
||||||
firstMileTrailerPlateNumber: string | null;
|
firstMileTrailerPlateNumber: string | null;
|
||||||
|
firstMileDriverName: string | null;
|
||||||
|
firstMileDriverPhone: string | null;
|
||||||
|
firstMileDriverLicenseNumber: string | null;
|
||||||
|
firstMileTruckType: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BulkReceiveResult {
|
export interface BulkReceiveResult {
|
||||||
@@ -289,6 +301,8 @@ export interface ImportUnloadedRow {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class WarehouseInventoryService {
|
export class WarehouseInventoryService {
|
||||||
|
private readonly logger = new Logger(WarehouseInventoryService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly dataSource: DataSource,
|
private readonly dataSource: DataSource,
|
||||||
private readonly inventoryRepository: WarehouseInventoryRepository,
|
private readonly inventoryRepository: WarehouseInventoryRepository,
|
||||||
@@ -301,6 +315,7 @@ export class WarehouseInventoryService {
|
|||||||
private readonly releaseDocuments: WarehouseReleaseDocumentService,
|
private readonly releaseDocuments: WarehouseReleaseDocumentService,
|
||||||
private readonly interchangeDocuments: InterchangeDocumentsService,
|
private readonly interchangeDocuments: InterchangeDocumentsService,
|
||||||
private readonly lastMileService: LastMileService,
|
private readonly lastMileService: LastMileService,
|
||||||
|
private readonly notifications: NotificationsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -644,12 +659,20 @@ export class WarehouseInventoryService {
|
|||||||
b.reference AS "reference",
|
b.reference AS "reference",
|
||||||
b.company_id AS "customerId",
|
b.company_id AS "customerId",
|
||||||
company.name AS "customer",
|
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",
|
oy.code AS "origin",
|
||||||
dy.code AS "destination",
|
dy.code AS "destination",
|
||||||
oy.country AS "originCountry",
|
oy.country AS "originCountry",
|
||||||
dy.country AS "destinationCountry",
|
dy.country AS "destinationCountry",
|
||||||
b.freight_type AS "freightType",
|
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 "cargo",
|
||||||
|
COALESCE(ct.cargo_type_name, b.cargo_free_text) AS "cargoDescription",
|
||||||
b.cargo_total_weight_vgm AS "weight",
|
b.cargo_total_weight_vgm AS "weight",
|
||||||
b.payment_status AS "paymentStatus",
|
b.payment_status AS "paymentStatus",
|
||||||
b.status AS "status",
|
b.status AS "status",
|
||||||
@@ -659,7 +682,14 @@ export class WarehouseInventoryService {
|
|||||||
fm.status AS "firstMileStatus",
|
fm.status AS "firstMileStatus",
|
||||||
fm.vehicle_id AS "firstMileVehicleId",
|
fm.vehicle_id AS "firstMileVehicleId",
|
||||||
v.plate_number AS "firstMileTruckPlateNumber",
|
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
|
FROM freight.bookings b
|
||||||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
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 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.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.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 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 (
|
LEFT JOIN LATERAL (
|
||||||
SELECT first_mile.id, first_mile.status, first_mile.vehicle_id
|
SELECT first_mile.id, first_mile.status, first_mile.vehicle_id
|
||||||
FROM freight.first_mile first_mile
|
FROM freight.first_mile first_mile
|
||||||
@@ -675,6 +721,7 @@ export class WarehouseInventoryService {
|
|||||||
LIMIT 1
|
LIMIT 1
|
||||||
) fm ON true
|
) fm ON true
|
||||||
LEFT JOIN freight.vehicles v ON v.id = fm.vehicle_id
|
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
|
WHERE b.deleted_at IS NULL
|
||||||
AND b.payment_status = 'PAID'
|
AND b.payment_status = 'PAID'
|
||||||
AND inv.id IS NULL
|
AND inv.id IS NULL
|
||||||
@@ -695,7 +742,6 @@ export class WarehouseInventoryService {
|
|||||||
/** Bulk-receive eligible PAID bookings into a location. Skips duplicates / wrong direction. */
|
/** Bulk-receive eligible PAID bookings into a location. Skips duplicates / wrong direction. */
|
||||||
async bulkReceive(dto: BulkReceiveDto): Promise<BulkReceiveResult> {
|
async bulkReceive(dto: BulkReceiveDto): Promise<BulkReceiveResult> {
|
||||||
const result: BulkReceiveResult = { receivedCount: 0, skippedCount: 0, results: [] };
|
const result: BulkReceiveResult = { receivedCount: 0, skippedCount: 0, results: [] };
|
||||||
this.assertTruckEntrance(dto.truckEntrance);
|
|
||||||
|
|
||||||
await this.dataSource.transaction(async (manager) => {
|
await this.dataSource.transaction(async (manager) => {
|
||||||
await this.validateLocation(manager, {
|
await this.validateLocation(manager, {
|
||||||
@@ -711,23 +757,61 @@ export class WarehouseInventoryService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const [booking] = await manager.query(
|
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",
|
oy.country AS "originCountry", dy.country AS "destinationCountry",
|
||||||
(NULLIF(TRIM(COALESCE(b.first_mile_pickup_address, '')), '') IS NOT NULL
|
(NULLIF(TRIM(COALESCE(b.first_mile_pickup_address, '')), '') IS NOT NULL
|
||||||
OR COALESCE(st.includes_first_mile, false)) AS "hasFirstMile",
|
OR COALESCE(st.includes_first_mile, false)) AS "hasFirstMile",
|
||||||
fm.id AS "firstMileRequestId",
|
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
|
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 oy ON oy.id = b.origin_yard_id
|
||||||
LEFT JOIN freight.yards dy ON dy.id = b.destination_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.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 (
|
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
|
FROM freight.first_mile first_mile
|
||||||
WHERE first_mile.booking_id = b.id AND first_mile.deleted_at IS NULL
|
WHERE first_mile.booking_id = b.id AND first_mile.deleted_at IS NULL
|
||||||
ORDER BY first_mile.created_at DESC
|
ORDER BY first_mile.created_at DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
) fm ON true
|
) 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`,
|
WHERE b.id = $1 AND b.deleted_at IS NULL LIMIT 1`,
|
||||||
[bookingId],
|
[bookingId],
|
||||||
);
|
);
|
||||||
@@ -758,11 +842,17 @@ export class WarehouseInventoryService {
|
|||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const grnNumber = this.generateGrnNumber(dto.direction, bookingId, now);
|
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({
|
const receiveNote = this.buildReceiveNote({
|
||||||
grnNumber,
|
grnNumber,
|
||||||
direction: dto.direction,
|
direction: dto.direction,
|
||||||
notes: `Bulk received (${dto.direction})`,
|
notes: `Bulk received (${dto.direction})`,
|
||||||
truckEntrance: dto.truckEntrance,
|
truckEntrance,
|
||||||
});
|
});
|
||||||
const saved = await manager.getRepository(WarehouseInventory).save(
|
const saved = await manager.getRepository(WarehouseInventory).save(
|
||||||
manager.getRepository(WarehouseInventory).create({
|
manager.getRepository(WarehouseInventory).create({
|
||||||
@@ -770,7 +860,7 @@ export class WarehouseInventoryService {
|
|||||||
yardId: dto.yardId,
|
yardId: dto.yardId,
|
||||||
zoneId: dto.zoneId,
|
zoneId: dto.zoneId,
|
||||||
bookingId,
|
bookingId,
|
||||||
quantity: 1,
|
quantity: Number(booking.containerQuantity) || 1,
|
||||||
weight: Number(booking.weight) || 0,
|
weight: Number(booking.weight) || 0,
|
||||||
status: 'RECEIVED',
|
status: 'RECEIVED',
|
||||||
arrivedAt: now,
|
arrivedAt: now,
|
||||||
@@ -783,12 +873,23 @@ export class WarehouseInventoryService {
|
|||||||
activityType: 'INVENTORY_RECEIVED',
|
activityType: 'INVENTORY_RECEIVED',
|
||||||
inventoryId: saved.id,
|
inventoryId: saved.id,
|
||||||
warehouseId: dto.warehouseId,
|
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,
|
performedBy: dto.performedBy,
|
||||||
},
|
},
|
||||||
manager,
|
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.receivedCount += 1;
|
||||||
result.results.push({ bookingId, status: 'RECEIVED', inventoryId: saved.id, grnNumber });
|
result.results.push({ bookingId, status: 'RECEIVED', inventoryId: saved.id, grnNumber });
|
||||||
}
|
}
|
||||||
@@ -887,6 +988,11 @@ export class WarehouseInventoryService {
|
|||||||
return this.exportInventoryByStatus('READY_FOR_LOADING', true);
|
return this.exportInventoryByStatus('READY_FOR_LOADING', true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** EXPORT inventory received at the facility and awaiting inspection. */
|
||||||
|
async receivedExport(): Promise<ReadyToLoadRow[]> {
|
||||||
|
return this.exportInventoryByStatus('RECEIVED');
|
||||||
|
}
|
||||||
|
|
||||||
/** EXPORT inventory that has been loaded onto a wagon and is queued for dispatch (LOADED). */
|
/** EXPORT inventory that has been loaded onto a wagon and is queued for dispatch (LOADED). */
|
||||||
async loadedExport(): Promise<ReadyToLoadRow[]> {
|
async loadedExport(): Promise<ReadyToLoadRow[]> {
|
||||||
return this.exportInventoryByStatus('LOADED');
|
return this.exportInventoryByStatus('LOADED');
|
||||||
@@ -1166,7 +1272,7 @@ export class WarehouseInventoryService {
|
|||||||
oy.country AS "originCountry",
|
oy.country AS "originCountry",
|
||||||
dy.country AS "destinationCountry",
|
dy.country AS "destinationCountry",
|
||||||
dy.code AS "destinationCode",
|
dy.code AS "destinationCode",
|
||||||
dy.name AS "destinationName"
|
dy.label AS "destinationName"
|
||||||
FROM freight.train_schedules ts
|
FROM freight.train_schedules ts
|
||||||
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
|
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
|
||||||
LEFT JOIN freight.yards dy ON dy.id = ts.destination_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;
|
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)) {
|
if (!currentStatus || !this.EXPORT_DJIBOUTI_UNLOAD_ELIGIBLE_STATUSES.includes(currentStatus)) {
|
||||||
skip(`Status ${currentStatus ?? 'UNKNOWN'} is not eligible for Djibouti export unloading`);
|
skip(`Status ${currentStatus ?? 'UNKNOWN'} is not eligible for Djibouti export unloading`);
|
||||||
continue;
|
continue;
|
||||||
@@ -1483,18 +1603,29 @@ export class WarehouseInventoryService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async receive(dto: ReceiveWarehouseInventoryDto): Promise<WarehouseInventory> {
|
async receive(dto: ReceiveWarehouseInventoryDto): Promise<WarehouseInventory> {
|
||||||
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 bookingDirection = dto.bookingId ? await this.getBookingDirection(dto.bookingId) : null;
|
||||||
|
|
||||||
const id = await this.dataSource.transaction(async (manager) => {
|
const id = await this.dataSource.transaction(async (manager) => {
|
||||||
const { warehouse, yard, zone } = await this.validateLocation(manager, dto);
|
const { warehouse, yard, zone } = await this.validateLocation(manager, dto);
|
||||||
|
|
||||||
if (dto.bookingId) {
|
const bookingSource = dto.bookingId
|
||||||
await this.assertBookingExists(manager, 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('Warehouse', warehouse, weight, volume, containerCount);
|
||||||
this.assertCapacity('Yard', yard, weight, volume, containerCount);
|
this.assertCapacity('Yard', yard, weight, volume, containerCount);
|
||||||
@@ -1505,7 +1636,7 @@ export class WarehouseInventoryService {
|
|||||||
const receiveNote = this.buildReceiveNote({
|
const receiveNote = this.buildReceiveNote({
|
||||||
grnNumber,
|
grnNumber,
|
||||||
notes: dto.notes?.trim() || 'Single booking received',
|
notes: dto.notes?.trim() || 'Single booking received',
|
||||||
truckEntrance: dto.truckEntrance,
|
truckEntrance,
|
||||||
});
|
});
|
||||||
const saved = await manager.getRepository(WarehouseInventory).save(
|
const saved = await manager.getRepository(WarehouseInventory).save(
|
||||||
manager.getRepository(WarehouseInventory).create({
|
manager.getRepository(WarehouseInventory).create({
|
||||||
@@ -1516,7 +1647,7 @@ export class WarehouseInventoryService {
|
|||||||
cargoId: dto.cargoId ?? null,
|
cargoId: dto.cargoId ?? null,
|
||||||
containerId: dto.containerId ?? null,
|
containerId: dto.containerId ?? null,
|
||||||
goodsId: dto.goodsId ?? null,
|
goodsId: dto.goodsId ?? null,
|
||||||
quantity: Number(dto.quantity) || 0,
|
quantity,
|
||||||
weight,
|
weight,
|
||||||
volume: dto.volume ?? null,
|
volume: dto.volume ?? null,
|
||||||
status: 'RECEIVED',
|
status: 'RECEIVED',
|
||||||
@@ -1529,14 +1660,23 @@ export class WarehouseInventoryService {
|
|||||||
|
|
||||||
await this.activityLog.record(
|
await this.activityLog.record(
|
||||||
{
|
{
|
||||||
activityType: 'INVENTORY_RECEIVED',
|
activityType: 'INVENTORY_RECEIVED',
|
||||||
inventoryId: saved.id,
|
inventoryId: saved.id,
|
||||||
warehouseId: dto.warehouseId,
|
warehouseId: dto.warehouseId,
|
||||||
description: `GRN ${grnNumber}: received ${weight}kg via truck ${dto.truckEntrance.truckPlateNumber}`,
|
description: `GRN ${grnNumber}: received ${weight}kg via truck ${truckEntrance.truckPlateNumber}`,
|
||||||
performedBy: dto.performedBy,
|
performedBy: dto.performedBy,
|
||||||
},
|
},
|
||||||
manager,
|
manager,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await this.notifyOwnerInventoryReceived({
|
||||||
|
phone: truckEntrance.customerPhone,
|
||||||
|
ownerName: truckEntrance.ownerName,
|
||||||
|
bookingReference: truckEntrance.edrDigitalBookingId ?? dto.bookingId,
|
||||||
|
grnNumber,
|
||||||
|
direction: bookingDirection,
|
||||||
|
warehouseId: dto.warehouseId,
|
||||||
|
});
|
||||||
|
|
||||||
return saved.id;
|
return saved.id;
|
||||||
});
|
});
|
||||||
@@ -1776,11 +1916,13 @@ export class WarehouseInventoryService {
|
|||||||
|
|
||||||
const releaseDate = dto.releaseDate ? new Date(dto.releaseDate) : new Date();
|
const releaseDate = dto.releaseDate ? new Date(dto.releaseDate) : new Date();
|
||||||
const reference = dto.reference?.trim() || null;
|
const reference = dto.reference?.trim() || null;
|
||||||
|
const exitInspectionNote = this.buildExitInspectionNote(dto);
|
||||||
|
|
||||||
await this.dataSource.transaction(async (manager) => {
|
await this.dataSource.transaction(async (manager) => {
|
||||||
await manager.getRepository(WarehouseInventory).update(id, {
|
await manager.getRepository(WarehouseInventory).update(id, {
|
||||||
releaseDate,
|
releaseDate,
|
||||||
releaseOrderReference: reference,
|
releaseOrderReference: reference,
|
||||||
|
notes: [item.notes?.trim(), exitInspectionNote].filter(Boolean).join('\n\n'),
|
||||||
});
|
});
|
||||||
await this.activityLog.record(
|
await this.activityLog.record(
|
||||||
{
|
{
|
||||||
@@ -1807,6 +1949,7 @@ export class WarehouseInventoryService {
|
|||||||
inv.quantity,
|
inv.quantity,
|
||||||
inv.weight,
|
inv.weight,
|
||||||
inv.status,
|
inv.status,
|
||||||
|
inv.notes,
|
||||||
b.id AS "bookingId",
|
b.id AS "bookingId",
|
||||||
b.reference AS "bookingReference",
|
b.reference AS "bookingReference",
|
||||||
b.status AS "bookingStatus",
|
b.status AS "bookingStatus",
|
||||||
@@ -1867,6 +2010,7 @@ export class WarehouseInventoryService {
|
|||||||
zone: [row?.zoneName, row?.zoneCode].filter(Boolean).join(' / ') || null,
|
zone: [row?.zoneName, row?.zoneCode].filter(Boolean).join(' / ') || null,
|
||||||
inventoryStatus: row?.status ?? null,
|
inventoryStatus: row?.status ?? null,
|
||||||
clearanceStatus: 'CLEARED FOR WAREHOUSE EXIT',
|
clearanceStatus: 'CLEARED FOR WAREHOUSE EXIT',
|
||||||
|
exitInspectionSummary: this.extractExitInspectionNote(row?.notes),
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -2372,6 +2516,7 @@ export class WarehouseInventoryService {
|
|||||||
zone: string | null;
|
zone: string | null;
|
||||||
inventoryStatus: string | null;
|
inventoryStatus: string | null;
|
||||||
clearanceStatus: string;
|
clearanceStatus: string;
|
||||||
|
exitInspectionSummary?: string | null;
|
||||||
}): string {
|
}): string {
|
||||||
const esc = (value: unknown) =>
|
const esc = (value: unknown) =>
|
||||||
String(value ?? '-')
|
String(value ?? '-')
|
||||||
@@ -2402,6 +2547,7 @@ export class WarehouseInventoryService {
|
|||||||
['Zone', data.zone],
|
['Zone', data.zone],
|
||||||
['Inventory Status', data.inventoryStatus],
|
['Inventory Status', data.inventoryStatus],
|
||||||
['Clearance Status', data.clearanceStatus],
|
['Clearance Status', data.clearanceStatus],
|
||||||
|
...(data.exitInspectionSummary ? [['Exit Inspection', data.exitInspectionSummary] as [string, string]] : []),
|
||||||
];
|
];
|
||||||
|
|
||||||
return `<!doctype html>
|
return `<!doctype html>
|
||||||
@@ -2492,16 +2638,6 @@ export class WarehouseInventoryService {
|
|||||||
return { warehouse, yard, zone };
|
return { warehouse, yard, zone };
|
||||||
}
|
}
|
||||||
|
|
||||||
private async assertBookingExists(manager: EntityManager, bookingId: string): Promise<void> {
|
|
||||||
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 {
|
private appendNote(existing: string | null | undefined, note: string): string {
|
||||||
const trimmed = existing?.trim();
|
const trimmed = existing?.trim();
|
||||||
return trimmed ? `${trimmed}\n${note}` : note;
|
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<void> {
|
||||||
|
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 {
|
private generateGrnNumber(direction: string, referenceId: string, date: Date): string {
|
||||||
const stamp = date.toISOString().slice(0, 10).replace(/-/g, '');
|
const stamp = date.toISOString().slice(0, 10).replace(/-/g, '');
|
||||||
const suffix = referenceId.replace(/-/g, '').slice(0, 8).toUpperCase();
|
const suffix = referenceId.replace(/-/g, '').slice(0, 8).toUpperCase();
|
||||||
return `GRN-${direction.toUpperCase()}-${stamp}-${suffix}`;
|
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: {
|
private buildReceiveNote(input: {
|
||||||
grnNumber: string;
|
grnNumber: string;
|
||||||
direction?: string | null;
|
direction?: string | null;
|
||||||
notes?: string | null;
|
notes?: string | null;
|
||||||
truckEntrance: TruckEntranceDto;
|
truckEntrance?: TruckEntranceDto;
|
||||||
}): string {
|
}): string {
|
||||||
const truck = input.truckEntrance;
|
const truck = input.truckEntrance;
|
||||||
const rows = [
|
const rows = [
|
||||||
`GRN Number: ${input.grnNumber}`,
|
`GRN Number: ${input.grnNumber}`,
|
||||||
input.direction ? `Direction: ${input.direction}` : null,
|
input.direction ? `Direction: ${input.direction}` : null,
|
||||||
truck.ownerName ? `Owner / Shipper: ${truck.ownerName}` : null,
|
truck?.ownerName ? `Owner / Shipper: ${truck.ownerName}` : null,
|
||||||
truck.consigneeDetails ? `Consignee: ${truck.consigneeDetails}` : null,
|
truck?.consigneeDetails ? `Consignee: ${truck.consigneeDetails}` : null,
|
||||||
truck.edrDigitalBookingId ? `EDR Digital Booking ID: ${truck.edrDigitalBookingId}` : null,
|
truck?.edrDigitalBookingId ? `EDR Digital Booking ID: ${truck.edrDigitalBookingId}` : null,
|
||||||
truck.tin ? `TIN: ${truck.tin}` : null,
|
truck?.tin ? `TIN: ${truck.tin}` : null,
|
||||||
`Truck Plate: ${truck.truckPlateNumber}`,
|
truck?.customerPhone ? `Customer Phone: ${truck.customerPhone}` : null,
|
||||||
truck.trailerPlateNumber ? `Trailer Plate: ${truck.trailerPlateNumber}` : null,
|
truck?.truckPlateNumber ? `Truck Plate: ${truck.truckPlateNumber}` : null,
|
||||||
truck.assignedEquipmentNumber ? `Assigned Wagon / Container: ${truck.assignedEquipmentNumber}` : null,
|
truck?.trailerPlateNumber ? `Trailer Plate: ${truck.trailerPlateNumber}` : null,
|
||||||
truck.customsSealNumber ? `Customs Seal Number: ${truck.customsSealNumber}` : null,
|
truck?.assignedEquipmentNumber ? `Assigned Wagon / Container: ${truck.assignedEquipmentNumber}` : null,
|
||||||
truck.truckType ? `Truck Type: ${truck.truckType}` : null,
|
truck?.customsSealNumber ? `Customs Seal Number: ${truck.customsSealNumber}` : null,
|
||||||
`Driver: ${truck.driverName}`,
|
truck?.truckType ? `Truck Type: ${truck.truckType}` : null,
|
||||||
`Driver Phone: ${truck.driverPhone}`,
|
truck?.driverName ? `Driver: ${truck.driverName}` : null,
|
||||||
truck.driverLicenseNumber ? `Driver License: ${truck.driverLicenseNumber}` : null,
|
truck?.driverPhone ? `Driver Phone: ${truck.driverPhone}` : null,
|
||||||
`Entrance Tare Weight: ${Number(truck.entranceTareWeightKg)} kg`,
|
truck?.driverLicenseNumber ? `Driver License: ${truck.driverLicenseNumber}` : null,
|
||||||
truck.exitTareWeightKg !== undefined ? `Exit Tare Weight: ${Number(truck.exitTareWeightKg)} kg` : null,
|
truck?.entranceTareWeightKg !== undefined ? `Entrance Tare Weight: ${Number(truck.entranceTareWeightKg)} kg` : null,
|
||||||
truck.declarationNumber ? `Declaration / Bill of Entry: ${truck.declarationNumber}` : null,
|
truck?.exitTareWeightKg !== undefined ? `Exit Tare Weight: ${Number(truck.exitTareWeightKg)} kg` : null,
|
||||||
truck.incoterms ? `Incoterms: ${truck.incoterms}` : null,
|
truck?.declarationNumber ? `Declaration / Bill of Entry: ${truck.declarationNumber}` : null,
|
||||||
truck.hsCodes ? `HS Codes: ${truck.hsCodes}` : null,
|
truck?.incoterms ? `Incoterms: ${truck.incoterms}` : null,
|
||||||
truck.itemCode ? `Item Code: ${truck.itemCode}` : null,
|
truck?.hsCodes ? `HS Codes: ${truck.hsCodes}` : null,
|
||||||
truck.itemDescription ? `Item Description: ${truck.itemDescription}` : null,
|
truck?.itemCode ? `Item Code: ${truck.itemCode}` : null,
|
||||||
truck.packagingType ? `Packaging Type: ${truck.packagingType}` : null,
|
truck?.itemDescription ? `Item Description: ${truck.itemDescription}` : null,
|
||||||
truck.unitCount !== undefined ? `Unit Count: ${Number(truck.unitCount)}` : null,
|
truck?.packagingType ? `Packaging Type: ${truck.packagingType}` : null,
|
||||||
truck.grossWeightKg !== undefined ? `Gross Weight: ${Number(truck.grossWeightKg)} kg` : null,
|
truck?.unitCount !== undefined ? `Unit Count: ${Number(truck.unitCount)}` : null,
|
||||||
truck.netWeightKg !== undefined ? `Net Weight: ${Number(truck.netWeightKg)} kg` : null,
|
truck?.grossWeightKg !== undefined ? `Gross Weight: ${Number(truck.grossWeightKg)} kg` : null,
|
||||||
truck.volumeDimensions ? `Volume / Dimensions: ${truck.volumeDimensions}` : null,
|
truck?.netWeightKg !== undefined ? `Net Weight: ${Number(truck.netWeightKg)} kg` : null,
|
||||||
truck.conditionAtReceipt ? `Condition at Receipt: ${truck.conditionAtReceipt}` : null,
|
truck?.volumeDimensions ? `Volume / Dimensions: ${truck.volumeDimensions}` : null,
|
||||||
truck.damagedRejectedQuantity !== undefined ? `Damaged / Rejected Quantity: ${Number(truck.damagedRejectedQuantity)}` : null,
|
truck?.conditionAtReceipt ? `Condition at Receipt: ${truck.conditionAtReceipt}` : null,
|
||||||
truck.warehouseCodeLocation ? `Warehouse Code / Location: ${truck.warehouseCodeLocation}` : null,
|
truck?.damagedRejectedQuantity !== undefined ? `Damaged / Rejected Quantity: ${Number(truck.damagedRejectedQuantity)}` : null,
|
||||||
truck.driverSignatoryName ? `Driver Signatory: ${truck.driverSignatoryName}` : null,
|
truck?.warehouseCodeLocation ? `Warehouse Code / Location: ${truck.warehouseCodeLocation}` : null,
|
||||||
truck.warehouseManagerName ? `EDR Warehouse Manager: ${truck.warehouseManagerName}` : null,
|
truck?.driverSignatoryName ? `Driver Signatory: ${truck.driverSignatoryName}` : null,
|
||||||
|
truck?.warehouseManagerName ? `EDR Warehouse Manager: ${truck.warehouseManagerName}` : null,
|
||||||
input.notes?.trim() ? `Remarks: ${input.notes.trim()}` : null,
|
input.notes?.trim() ? `Remarks: ${input.notes.trim()}` : null,
|
||||||
];
|
];
|
||||||
return rows.filter(Boolean).join('\n');
|
return rows.filter(Boolean).join('\n');
|
||||||
|
|||||||
@@ -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 { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
import {
|
import {
|
||||||
WarehouseFeeInvoice,
|
WarehouseFeeInvoice,
|
||||||
WarehouseInvoiceStatus,
|
WarehouseInvoiceStatus,
|
||||||
@@ -22,6 +23,8 @@ export interface PayInvoiceDto {
|
|||||||
amount: number;
|
amount: number;
|
||||||
method?: string;
|
method?: string;
|
||||||
reference?: string;
|
reference?: string;
|
||||||
|
driverName?: string;
|
||||||
|
driverPhone?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Invoices that still owe money and therefore block terminal release. */
|
/** Invoices that still owe money and therefore block terminal release. */
|
||||||
@@ -46,12 +49,15 @@ export type WarehouseFeeInvoiceWithDisplay = WarehouseFeeInvoice & Partial<Invoi
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class WarehouseInvoiceService {
|
export class WarehouseInvoiceService {
|
||||||
|
private readonly logger = new Logger(WarehouseInvoiceService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly dataSource: DataSource,
|
private readonly dataSource: DataSource,
|
||||||
private readonly invoiceRepository: WarehouseFeeInvoiceRepository,
|
private readonly invoiceRepository: WarehouseFeeInvoiceRepository,
|
||||||
private readonly itemRepository: WarehouseFeeInvoiceItemRepository,
|
private readonly itemRepository: WarehouseFeeInvoiceItemRepository,
|
||||||
private readonly feeService: WarehouseFeeService,
|
private readonly feeService: WarehouseFeeService,
|
||||||
private readonly documents: WarehouseReleaseDocumentService,
|
private readonly documents: WarehouseReleaseDocumentService,
|
||||||
|
private readonly notifications: NotificationsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// ── Generation ───────────────────────────────────────────────────────────
|
// ── Generation ───────────────────────────────────────────────────────────
|
||||||
@@ -150,7 +156,9 @@ export class WarehouseInvoiceService {
|
|||||||
await this.itemRepository.create({ invoiceId: invoice.id, ...it });
|
await this.itemRepository.create({ invoiceId: invoice.id, ...it });
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.findById(invoice.id);
|
const saved = await this.findById(invoice.id);
|
||||||
|
await this.notifyWarehouseFeeIssued(saved);
|
||||||
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** WHF-YYYYMMDD-00001 — sequential per day. */
|
/** WHF-YYYYMMDD-00001 — sequential per day. */
|
||||||
@@ -246,7 +254,9 @@ export class WarehouseInvoiceService {
|
|||||||
paidAt: fullyPaid ? new Date() : invoice.paidAt ?? null,
|
paidAt: fullyPaid ? new Date() : invoice.paidAt ?? null,
|
||||||
payments,
|
payments,
|
||||||
});
|
});
|
||||||
return updated as WarehouseFeeInvoice;
|
const paidInvoice = updated as WarehouseFeeInvoice;
|
||||||
|
await this.notifyWarehouseFeePayment(paidInvoice, dto);
|
||||||
|
return paidInvoice;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Release blocking ──────────────────────────────────────────────────────
|
// ── Release blocking ──────────────────────────────────────────────────────
|
||||||
@@ -332,6 +342,125 @@ export class WarehouseInvoiceService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async getInvoiceNotificationContacts(invoice: WarehouseFeeInvoice): Promise<{
|
||||||
|
bookingReference: string | null;
|
||||||
|
customerName: string | null;
|
||||||
|
customerPhone: string | null;
|
||||||
|
driverName: string | null;
|
||||||
|
driverPhone: string | null;
|
||||||
|
containerNumber: string | null;
|
||||||
|
cargoDescription: string | null;
|
||||||
|
}> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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(
|
private buildInvoiceDocumentHtml(
|
||||||
invoice: WarehouseFeeInvoiceWithDisplay & { items: unknown[] },
|
invoice: WarehouseFeeInvoiceWithDisplay & { items: unknown[] },
|
||||||
kind: 'INVOICE' | 'RECEIPT',
|
kind: 'INVOICE' | 'RECEIPT',
|
||||||
|
|||||||
@@ -99,26 +99,51 @@ export class WarehouseReleaseDocumentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private htmlToBasicPdfBuffer(html: string): Buffer {
|
private htmlToBasicPdfBuffer(html: string): Buffer {
|
||||||
const text = this.htmlToPlainText(html);
|
const doc = this.extractReleaseDocument(html);
|
||||||
const lines = this.wrapLines(text, 86).slice(0, 52);
|
const body: string[] = [
|
||||||
const body = lines
|
this.lineOp(36, 810, 559, 810, '0 0 0', 2.2),
|
||||||
.map((line, index) => {
|
this.textOp('ETHIO-DJIBOUTI RAILWAY S.C.', 36, 787, 9, 'F2', '0.08 0.32 0.18'),
|
||||||
const y = 770 - index * 12;
|
this.textOp('WAREHOUSE GATE', 36, 764, 24, 'F2', '0.02 0.08 0.16'),
|
||||||
const isTitle = index < 2 || /clearance|release order/i.test(line);
|
this.textOp('CLEARANCE / RELEASE', 36, 742, 24, 'F2', '0.02 0.08 0.16'),
|
||||||
const size = index === 0 ? 13 : isTitle ? 11 : 9.6;
|
this.textOp('ORDER', 36, 720, 24, 'F2', '0.02 0.08 0.16'),
|
||||||
const font = isTitle ? 'F2' : 'F1';
|
this.textOp('OFFICIAL WAREHOUSE RELEASE AND EXIT AUTHORIZATION', 36, 696, 8.5, 'F1', '0.25 0.34 0.45'),
|
||||||
return this.textOp(line, 48, y, size, font);
|
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'),
|
||||||
.join('\n');
|
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 = [
|
const stream = [
|
||||||
this.lineOp(48, 752, 548, 752),
|
...body,
|
||||||
body,
|
this.lineOp(36, 60, 218, 60, '0 0 0', 1),
|
||||||
this.circularSealOps(184, 154),
|
this.textOp('Officer in charge name / signature / date', 36, 47, 7.4, 'F1'),
|
||||||
this.lineOp(48, 92, 278, 92, '0 0 0'),
|
this.circularSealOps(286, 62, 38),
|
||||||
this.textOp('Officer in charge name / signature / date', 48, 76, 9, 'F1'),
|
this.lineOp(341, 60, 559, 60, '0 0 0', 1),
|
||||||
this.lineOp(326, 92, 548, 92, '0 0 0'),
|
this.textOp('Customer or driver name / signature / date', 341, 47, 7.4, 'F1'),
|
||||||
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'),
|
|
||||||
].join('\n');
|
].join('\n');
|
||||||
|
|
||||||
const objects = [
|
const objects = [
|
||||||
@@ -149,6 +174,31 @@ export class WarehouseReleaseDocumentService {
|
|||||||
return Buffer.from(pdf, 'latin1');
|
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(/<strong>([\s\S]*?)<\/strong>/i)?.[1] ?? 'DO');
|
||||||
|
const issuedAt = textFromHtml(html.match(/Issued:\s*([^<]+)/i)?.[1] ?? '-');
|
||||||
|
const notice = textFromHtml(
|
||||||
|
html.match(/<div class="notice">([\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(/<div class="clause">([\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(/<tr><th>([\s\S]*?)<\/th><td>([\s\S]*?)<\/td><\/tr>/gi)) {
|
||||||
|
rows.push([textFromHtml(match[1]), textFromHtml(match[2])]);
|
||||||
|
}
|
||||||
|
return { reference, issuedAt, notice, clause, rows };
|
||||||
|
}
|
||||||
|
|
||||||
private htmlToPlainText(html: string): string {
|
private htmlToPlainText(html: string): string {
|
||||||
return html
|
return html
|
||||||
.replace(/<script[\s\S]*?<\/script>/gi, '')
|
.replace(/<script[\s\S]*?<\/script>/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`;
|
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 {
|
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\n0.8 w\n${x1} ${y1} m\n${x2} ${y2} l\nS\nQ`;
|
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 [
|
return [
|
||||||
'q',
|
'q',
|
||||||
'0.08 0.32 0.18 RG',
|
'0.08 0.32 0.18 RG',
|
||||||
'0.08 0.32 0.18 rg',
|
'0.08 0.32 0.18 rg',
|
||||||
'2.2 w',
|
'2.2 w',
|
||||||
this.circlePath(cx, cy, 51),
|
this.circlePath(cx, cy, radius),
|
||||||
'S',
|
'S',
|
||||||
'0.8 w',
|
'0.8 w',
|
||||||
this.circlePath(cx, cy, 41),
|
this.circlePath(cx, cy, radius - 10),
|
||||||
'S',
|
'S',
|
||||||
this.textOp('EDR', cx - 14, cy + 18, 14, '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 - 31, cy + 2, 9, '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 - 27, cy - 16, 11, 'F2', '0.08 0.32 0.18'),
|
this.textOp('CLEARED', cx - 21, cy - 13, 9, 'F2', '0.08 0.32 0.18'),
|
||||||
'Q',
|
'Q',
|
||||||
].join('\n');
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
|||||||
import { FilesModule } from '../files/files.module';
|
import { FilesModule } from '../files/files.module';
|
||||||
import { InterchangeDocumentsModule } from '../interchange-documents/interchange-documents.module';
|
import { InterchangeDocumentsModule } from '../interchange-documents/interchange-documents.module';
|
||||||
import { LastMileModule } from '../last-mile/last-mile.module';
|
import { LastMileModule } from '../last-mile/last-mile.module';
|
||||||
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity';
|
import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity';
|
||||||
import { WarehouseAllocationRule } from './entities/warehouse-allocation-rule.entity';
|
import { WarehouseAllocationRule } from './entities/warehouse-allocation-rule.entity';
|
||||||
import { WarehouseFeeInvoice } from './entities/warehouse-fee-invoice.entity';
|
import { WarehouseFeeInvoice } from './entities/warehouse-fee-invoice.entity';
|
||||||
@@ -71,6 +72,7 @@ import { WarehousesService } from './warehouses.service';
|
|||||||
FilesModule,
|
FilesModule,
|
||||||
InterchangeDocumentsModule,
|
InterchangeDocumentsModule,
|
||||||
forwardRef(() => LastMileModule),
|
forwardRef(() => LastMileModule),
|
||||||
|
NotificationsModule,
|
||||||
ExchangeModule.forRootAsync({
|
ExchangeModule.forRootAsync({
|
||||||
inject: [ConfigService],
|
inject: [ConfigService],
|
||||||
useFactory: (config: ConfigService): ExchangeOptions =>
|
useFactory: (config: ConfigService): ExchangeOptions =>
|
||||||
@@ -122,6 +124,7 @@ import { WarehousesService } from './warehouses.service';
|
|||||||
WarehouseAllocationService,
|
WarehouseAllocationService,
|
||||||
WarehouseFeeService,
|
WarehouseFeeService,
|
||||||
WarehouseSchedulingAdapterService,
|
WarehouseSchedulingAdapterService,
|
||||||
|
WarehouseReleaseDocumentService,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class WarehousesModule {}
|
export class WarehousesModule {}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -21,8 +21,18 @@ import { WarehouseYard } from '../modules/warehouses/entities/warehouse-yard.ent
|
|||||||
import { WarehouseZone } from '../modules/warehouses/entities/warehouse-zone.entity';
|
import { WarehouseZone } from '../modules/warehouses/entities/warehouse-zone.entity';
|
||||||
import { Warehouse } from '../modules/warehouses/entities/warehouse.entity';
|
import { Warehouse } from '../modules/warehouses/entities/warehouse.entity';
|
||||||
|
|
||||||
const TRAIN_NUMBER = 'ICD-DEMO-EXP-DJ-01';
|
const DEMO_TRAINS = [
|
||||||
const BOOKING_REFS = ['ICD-DEMO-EXP-001', 'ICD-DEMO-EXP-002', 'ICD-DEMO-EXP-003'];
|
{
|
||||||
|
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() {
|
async function main() {
|
||||||
const app = await NestFactory.createApplicationContext(AppModule, {
|
const app = await NestFactory.createApplicationContext(AppModule, {
|
||||||
@@ -44,13 +54,6 @@ async function main() {
|
|||||||
const scheduleRepo = dataSource.getRepository(TrainSchedule);
|
const scheduleRepo = dataSource.getRepository(TrainSchedule);
|
||||||
const scheduleBookingRepo = dataSource.getRepository(TrainScheduleBooking);
|
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 =
|
const originYard =
|
||||||
(await yardRepo.findOne({ where: { code: 'MOJO' } })) ??
|
(await yardRepo.findOne({ where: { code: 'MOJO' } })) ??
|
||||||
(await yardRepo.findOne({ where: { country: 'Ethiopia' } }));
|
(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(', ')}`);
|
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 =
|
const locomotive =
|
||||||
(await locomotiveRepo.findOne({ where: { code: 'ICD-DEMO-LOCO' } })) ??
|
(await locomotiveRepo.findOne({ where: { code: 'ICD-DEMO-LOCO' } })) ??
|
||||||
(await locomotiveRepo.save(
|
(await locomotiveRepo.save(
|
||||||
@@ -97,83 +96,106 @@ async function main() {
|
|||||||
}),
|
}),
|
||||||
));
|
));
|
||||||
|
|
||||||
const trainSet = await trainSetRepo.save(
|
const now = Date.now();
|
||||||
trainSetRepo.create({
|
const seededSchedules: TrainSchedule[] = [];
|
||||||
locomotiveId: locomotive.id,
|
|
||||||
totalWeightTons: 700,
|
|
||||||
totalLengthMeters: 360,
|
|
||||||
wagonCount: 12,
|
|
||||||
status: 'COMPLETED',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const schedule = await scheduleRepo.save(
|
for (const [trainIndex, demo] of DEMO_TRAINS.entries()) {
|
||||||
scheduleRepo.create({
|
const existingSchedule = await scheduleRepo.findOne({ where: { trainNumber: demo.trainNumber } });
|
||||||
trainSetId: trainSet.id,
|
if (existingSchedule) {
|
||||||
originStationId: originYard!.id,
|
console.log(`Export Djibouti interchange demo already seeded: ${demo.trainNumber}`);
|
||||||
destinationStationId: destinationYard!.id,
|
console.log(`Schedule ID: ${existingSchedule.id}`);
|
||||||
scheduledDepartureDate: departure,
|
seededSchedules.push(existingSchedule);
|
||||||
scheduledArrivalDate: arrival,
|
continue;
|
||||||
actualArrivalAt: arrival,
|
}
|
||||||
status: 'ARRIVED' as TrainSchedule['status'],
|
|
||||||
trainNumber: TRAIN_NUMBER,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const [index, reference] of BOOKING_REFS.entries()) {
|
const arrival = new Date(now - demo.arrivalOffsetHours * 60 * 60 * 1000);
|
||||||
const weight = 5200 + index * 800;
|
const departure = new Date(arrival.getTime() - 5 * 60 * 60 * 1000);
|
||||||
const booking = await bookingRepo.save(
|
|
||||||
bookingRepo.create({
|
const trainSet = await trainSetRepo.save(
|
||||||
reference,
|
trainSetRepo.create({
|
||||||
originYardId: originYard!.id,
|
locomotiveId: locomotive.id,
|
||||||
destinationYardId: destinationYard!.id,
|
totalWeightTons: 700 + trainIndex * 80,
|
||||||
serviceTypeId: serviceType!.id,
|
totalLengthMeters: 360 + trainIndex * 20,
|
||||||
status: 'IN_TRANSIT',
|
wagonCount: 12 + trainIndex,
|
||||||
paymentStatus: 'PAID',
|
status: 'COMPLETED',
|
||||||
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,
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
await inventoryRepo.save(
|
const schedule = await scheduleRepo.save(
|
||||||
inventoryRepo.create({
|
scheduleRepo.create({
|
||||||
warehouseId: warehouse!.id,
|
trainSetId: trainSet.id,
|
||||||
yardId: warehouseYard!.id,
|
originStationId: originYard!.id,
|
||||||
zoneId: warehouseZone!.id,
|
destinationStationId: destinationYard!.id,
|
||||||
bookingId: booking.id,
|
scheduledDepartureDate: departure,
|
||||||
quantity: 1,
|
scheduledArrivalDate: arrival,
|
||||||
weight,
|
actualArrivalAt: arrival,
|
||||||
status: 'DISPATCHED',
|
status: 'ARRIVED' as TrainSchedule['status'],
|
||||||
inspectionStatus: 'PASSED',
|
trainNumber: demo.trainNumber,
|
||||||
arrivedAt: new Date(now - 4 * 60 * 60 * 1000),
|
direction: 'EXPORT',
|
||||||
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',
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
await scheduleBookingRepo.save(
|
for (const [bookingIndex, reference] of demo.bookingRefs.entries()) {
|
||||||
scheduleBookingRepo.create({
|
const weight = 5200 + trainIndex * 600 + bookingIndex * 800;
|
||||||
trainScheduleId: schedule.id,
|
const booking = await bookingRepo.save(
|
||||||
bookingId: booking.id,
|
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('Export Djibouti interchange demo seeded.');
|
||||||
console.log(`Train number: ${TRAIN_NUMBER}`);
|
for (const schedule of seededSchedules) {
|
||||||
console.log(`Schedule ID: ${schedule.id}`);
|
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.');
|
console.log('Open Djibouti Unloading, click "Auto Unload Export Items", then check Interchange Documents.');
|
||||||
} finally {
|
} finally {
|
||||||
await app.close();
|
await app.close();
|
||||||
|
|||||||
219
apps/edr-freight-api/src/scripts/seed-import-djibouti-demo.ts
Normal file
219
apps/edr-freight-api/src/scripts/seed-import-djibouti-demo.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<void> {
|
||||||
|
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)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
"@mantine/hooks": "^9.3.0",
|
"@mantine/hooks": "^9.3.0",
|
||||||
"@tabler/icons-react": "^3.44.0",
|
"@tabler/icons-react": "^3.44.0",
|
||||||
"@tanstack/react-query": "^5.100.11",
|
"@tanstack/react-query": "^5.100.11",
|
||||||
"@tria-plc/iamui": "file:../../../local-packages/tria-plc-iamui-0.0.3.tgz",
|
"@tria-plc/iamui": "file:../../../local-packages/tria-plc-iamui-0.1.1.tgz",
|
||||||
"axios": "^1.7.7",
|
"axios": "^1.7.7",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ import WarehouseInventoryPage from "./pages/warehouses/WarehouseInventoryPage";
|
|||||||
import WarehouseInvoicesPage from "./pages/warehouses/WarehouseInvoicesPage";
|
import WarehouseInvoicesPage from "./pages/warehouses/WarehouseInvoicesPage";
|
||||||
import WarehouseListPage from "./pages/warehouses/WarehouseListPage";
|
import WarehouseListPage from "./pages/warehouses/WarehouseListPage";
|
||||||
import WarehouseRulesPage from "./pages/warehouses/WarehouseRulesPage";
|
import WarehouseRulesPage from "./pages/warehouses/WarehouseRulesPage";
|
||||||
|
import { HealthCheck } from "./features/health/HealthCheck";
|
||||||
|
|
||||||
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||||
{
|
{
|
||||||
@@ -88,7 +89,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
icon: <LayoutDashboard />,
|
icon: <LayoutDashboard />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "UM",
|
label: "User Management",
|
||||||
href: "/um",
|
href: "/um",
|
||||||
icon: <Users />,
|
icon: <Users />,
|
||||||
},
|
},
|
||||||
@@ -374,6 +375,7 @@ const App = () => {
|
|||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/um/*" element={<UserManagementHostPage />} />
|
<Route path="/um/*" element={<UserManagementHostPage />} />
|
||||||
|
<Route path="/health" element={<HealthCheck />} />
|
||||||
<Route path="/" element={<Navigate to="/dashboard/overview" replace />} />
|
<Route path="/" element={<Navigate to="/dashboard/overview" replace />} />
|
||||||
<Route path="/dashboard" element={<Navigate to="/dashboard/overview" replace />} />
|
<Route path="/dashboard" element={<Navigate to="/dashboard/overview" replace />} />
|
||||||
<Route path="/dashboard" element={<DashboardShell />}>
|
<Route path="/dashboard" element={<DashboardShell />}>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
import { API_BASE_URL } from "@/constants/apiConfig";
|
import { API_BASE_URL } from '@/constants/apiConfig';
|
||||||
|
|
||||||
interface Cargo {
|
interface Cargo {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ interface TruckEntranceFormState {
|
|||||||
consigneeDetails: string;
|
consigneeDetails: string;
|
||||||
edrDigitalBookingId: string;
|
edrDigitalBookingId: string;
|
||||||
tin: string;
|
tin: string;
|
||||||
|
customerPhone: string;
|
||||||
truckPlateNumber: string;
|
truckPlateNumber: string;
|
||||||
trailerPlateNumber: string;
|
trailerPlateNumber: string;
|
||||||
assignedEquipmentNumber: string;
|
assignedEquipmentNumber: string;
|
||||||
@@ -85,11 +86,27 @@ interface TruckEntranceFormState {
|
|||||||
warehouseManagerName: string;
|
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 => ({
|
const emptyTruckEntrance = (): TruckEntranceFormState => ({
|
||||||
ownerName: '',
|
ownerName: '',
|
||||||
consigneeDetails: '',
|
consigneeDetails: '',
|
||||||
edrDigitalBookingId: '',
|
edrDigitalBookingId: '',
|
||||||
tin: '',
|
tin: '',
|
||||||
|
customerPhone: '',
|
||||||
truckPlateNumber: '',
|
truckPlateNumber: '',
|
||||||
trailerPlateNumber: '',
|
trailerPlateNumber: '',
|
||||||
assignedEquipmentNumber: '',
|
assignedEquipmentNumber: '',
|
||||||
@@ -118,22 +135,13 @@ const emptyTruckEntrance = (): TruckEntranceFormState => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const toTruckEntrancePayload = (form: TruckEntranceFormState): TruckEntrancePayload => ({
|
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(),
|
truckPlateNumber: form.truckPlateNumber.trim(),
|
||||||
trailerPlateNumber: form.trailerPlateNumber.trim() || undefined,
|
trailerPlateNumber: form.trailerPlateNumber.trim() || undefined,
|
||||||
assignedEquipmentNumber: form.assignedEquipmentNumber.trim() || undefined,
|
|
||||||
customsSealNumber: form.customsSealNumber.trim() || undefined,
|
customsSealNumber: form.customsSealNumber.trim() || undefined,
|
||||||
declarationNumber: form.declarationNumber.trim() || undefined,
|
declarationNumber: form.declarationNumber.trim() || undefined,
|
||||||
incoterms: form.incoterms.trim() || undefined,
|
incoterms: form.incoterms.trim() || undefined,
|
||||||
hsCodes: form.hsCodes.trim() || undefined,
|
hsCodes: form.hsCodes.trim() || undefined,
|
||||||
itemCode: form.itemCode.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),
|
netWeightKg: form.netWeightKg === '' ? undefined : Number(form.netWeightKg),
|
||||||
volumeDimensions: form.volumeDimensions.trim() || undefined,
|
volumeDimensions: form.volumeDimensions.trim() || undefined,
|
||||||
conditionAtReceipt: form.conditionAtReceipt.trim() || undefined,
|
conditionAtReceipt: form.conditionAtReceipt.trim() || undefined,
|
||||||
@@ -149,13 +157,130 @@ const toTruckEntrancePayload = (form: TruckEntranceFormState): TruckEntrancePayl
|
|||||||
warehouseManagerName: form.warehouseManagerName.trim() || undefined,
|
warehouseManagerName: form.warehouseManagerName.trim() || undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const commonNonEmptyValue = (values: Array<string | null | undefined>) => {
|
||||||
|
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({
|
function TruckEntranceFields({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
|
lockedFields,
|
||||||
|
packagingFreightType = 'MIXED',
|
||||||
}: {
|
}: {
|
||||||
value: TruckEntranceFormState;
|
value: TruckEntranceFormState;
|
||||||
onChange: (next: TruckEntranceFormState) => void;
|
onChange: (next: TruckEntranceFormState) => void;
|
||||||
|
lockedFields?: LockedTruckEntranceFields;
|
||||||
|
packagingFreightType?: PackagingFreightType;
|
||||||
}) {
|
}) {
|
||||||
|
const packagingOptions = packagingOptionsFor(packagingFreightType);
|
||||||
|
const quantityLabel =
|
||||||
|
packagingFreightType === 'CONTAINER'
|
||||||
|
? 'Container quantity'
|
||||||
|
: packagingFreightType === 'BULK'
|
||||||
|
? 'Unit count'
|
||||||
|
: 'Quantity';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<Text size="sm" fw={600}>Customer and cargo ownership</Text>
|
<Text size="sm" fw={600}>Customer and cargo ownership</Text>
|
||||||
@@ -163,11 +288,13 @@ function TruckEntranceFields({
|
|||||||
<TextInput
|
<TextInput
|
||||||
label="Owner's name"
|
label="Owner's name"
|
||||||
value={value.ownerName}
|
value={value.ownerName}
|
||||||
|
readOnly={lockedFields?.ownerName}
|
||||||
onChange={(e) => onChange({ ...value, ownerName: e.currentTarget.value })}
|
onChange={(e) => onChange({ ...value, ownerName: e.currentTarget.value })}
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<TextInput
|
||||||
label="Consignee details"
|
label="Consignee details"
|
||||||
value={value.consigneeDetails}
|
value={value.consigneeDetails}
|
||||||
|
readOnly={lockedFields?.consigneeDetails}
|
||||||
onChange={(e) => onChange({ ...value, consigneeDetails: e.currentTarget.value })}
|
onChange={(e) => onChange({ ...value, consigneeDetails: e.currentTarget.value })}
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -175,14 +302,22 @@ function TruckEntranceFields({
|
|||||||
<TextInput
|
<TextInput
|
||||||
label="EDR digital booking ID"
|
label="EDR digital booking ID"
|
||||||
value={value.edrDigitalBookingId}
|
value={value.edrDigitalBookingId}
|
||||||
|
readOnly={lockedFields?.edrDigitalBookingId}
|
||||||
onChange={(e) => onChange({ ...value, edrDigitalBookingId: e.currentTarget.value })}
|
onChange={(e) => onChange({ ...value, edrDigitalBookingId: e.currentTarget.value })}
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<TextInput
|
||||||
label="TIN"
|
label="TIN"
|
||||||
value={value.tin}
|
value={value.tin}
|
||||||
|
readOnly={lockedFields?.tin}
|
||||||
onChange={(e) => onChange({ ...value, tin: e.currentTarget.value })}
|
onChange={(e) => onChange({ ...value, tin: e.currentTarget.value })}
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
|
<TextInput
|
||||||
|
label="Customer phone"
|
||||||
|
value={value.customerPhone}
|
||||||
|
readOnly={lockedFields?.customerPhone}
|
||||||
|
onChange={(e) => onChange({ ...value, customerPhone: e.currentTarget.value })}
|
||||||
|
/>
|
||||||
|
|
||||||
<Text size="sm" fw={600} mt="xs">Transport and equipment tracking</Text>
|
<Text size="sm" fw={600} mt="xs">Transport and equipment tracking</Text>
|
||||||
<Group grow>
|
<Group grow>
|
||||||
@@ -202,6 +337,7 @@ function TruckEntranceFields({
|
|||||||
<TextInput
|
<TextInput
|
||||||
label="Assigned wagon / container number"
|
label="Assigned wagon / container number"
|
||||||
value={value.assignedEquipmentNumber}
|
value={value.assignedEquipmentNumber}
|
||||||
|
readOnly={lockedFields?.assignedEquipmentNumber}
|
||||||
onChange={(e) => onChange({ ...value, assignedEquipmentNumber: e.currentTarget.value })}
|
onChange={(e) => onChange({ ...value, assignedEquipmentNumber: e.currentTarget.value })}
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<TextInput
|
||||||
@@ -281,19 +417,25 @@ function TruckEntranceFields({
|
|||||||
<TextInput
|
<TextInput
|
||||||
label="Item description"
|
label="Item description"
|
||||||
value={value.itemDescription}
|
value={value.itemDescription}
|
||||||
|
readOnly={lockedFields?.itemDescription}
|
||||||
onChange={(e) => onChange({ ...value, itemDescription: e.currentTarget.value })}
|
onChange={(e) => onChange({ ...value, itemDescription: e.currentTarget.value })}
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
<Group grow>
|
<Group grow>
|
||||||
<TextInput
|
<Select
|
||||||
label="Packaging type"
|
label="Packaging type"
|
||||||
|
data={packagingOptions}
|
||||||
|
clearable
|
||||||
|
searchable
|
||||||
|
readOnly={lockedFields?.packagingType}
|
||||||
value={value.packagingType}
|
value={value.packagingType}
|
||||||
onChange={(e) => onChange({ ...value, packagingType: e.currentTarget.value })}
|
onChange={(v) => onChange({ ...value, packagingType: v ?? '' })}
|
||||||
/>
|
/>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
label="Unit count"
|
label={quantityLabel}
|
||||||
min={0}
|
min={0}
|
||||||
value={value.unitCount}
|
value={value.unitCount}
|
||||||
|
readOnly={lockedFields?.unitCount}
|
||||||
onChange={(v) => onChange({ ...value, unitCount: v === '' ? '' : Number(v) })}
|
onChange={(v) => onChange({ ...value, unitCount: v === '' ? '' : Number(v) })}
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -302,6 +444,7 @@ function TruckEntranceFields({
|
|||||||
label="Gross weight (kg)"
|
label="Gross weight (kg)"
|
||||||
min={0}
|
min={0}
|
||||||
value={value.grossWeightKg}
|
value={value.grossWeightKg}
|
||||||
|
readOnly={lockedFields?.grossWeightKg}
|
||||||
onChange={(v) => onChange({ ...value, grossWeightKg: v === '' ? '' : Number(v) })}
|
onChange={(v) => onChange({ ...value, grossWeightKg: v === '' ? '' : Number(v) })}
|
||||||
/>
|
/>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
@@ -466,6 +609,8 @@ function EligibleTab({
|
|||||||
const [truckOpen, setTruckOpen] = useState(false);
|
const [truckOpen, setTruckOpen] = useState(false);
|
||||||
const [pendingReceiveIds, setPendingReceiveIds] = useState<string[]>([]);
|
const [pendingReceiveIds, setPendingReceiveIds] = useState<string[]>([]);
|
||||||
const [truckForm, setTruckForm] = useState<TruckEntranceFormState>(emptyTruckEntrance());
|
const [truckForm, setTruckForm] = useState<TruckEntranceFormState>(emptyTruckEntrance());
|
||||||
|
const [lockedTruckFields, setLockedTruckFields] = useState<LockedTruckEntranceFields>({});
|
||||||
|
const [packagingFreightType, setPackagingFreightType] = useState<PackagingFreightType>('MIXED');
|
||||||
|
|
||||||
const locationReady = Boolean(location.warehouseId && location.yardId && location.zoneId);
|
const locationReady = Boolean(location.warehouseId && location.yardId && location.zoneId);
|
||||||
const canReceiveBooking = (row: EligibleBooking) =>
|
const canReceiveBooking = (row: EligibleBooking) =>
|
||||||
@@ -539,6 +684,14 @@ function EligibleTab({
|
|||||||
const selectableRows = statusFilteredRows.filter(canReceiveBooking);
|
const selectableRows = statusFilteredRows.filter(canReceiveBooking);
|
||||||
const allSelected = selectableRows.length > 0 && selected.size === selectableRows.length;
|
const allSelected = selectableRows.length > 0 && selected.size === selectableRows.length;
|
||||||
const someSelected = selected.size > 0 && !allSelected;
|
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 = () =>
|
const toggleAll = () =>
|
||||||
setSelected(allSelected ? new Set() : new Set(selectableRows.map((r) => r.id)));
|
setSelected(allSelected ? new Set() : new Set(selectableRows.map((r) => r.id)));
|
||||||
@@ -549,6 +702,29 @@ function EligibleTab({
|
|||||||
return next;
|
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[]) => {
|
const openTruckReceive = (bookingIds: string[]) => {
|
||||||
if (!locationReady) {
|
if (!locationReady) {
|
||||||
toast({ variant: 'destructive', title: 'Select warehouse, yard and zone first' });
|
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' });
|
toast({ variant: 'destructive', title: 'No selected booking is ready to receive' });
|
||||||
return;
|
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);
|
setPendingReceiveIds(filteredIds);
|
||||||
setTruckForm({
|
setTruckForm(form);
|
||||||
...emptyTruckEntrance(),
|
setLockedTruckFields(lockedFields);
|
||||||
truckPlateNumber: row?.firstMileTruckPlateNumber ?? '',
|
setPackagingFreightType(nextPackagingFreightType);
|
||||||
trailerPlateNumber: row?.firstMileTrailerPlateNumber ?? '',
|
|
||||||
});
|
|
||||||
setTruckOpen(true);
|
setTruckOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -579,24 +760,7 @@ function EligibleTab({
|
|||||||
toast({ variant: 'destructive', title: 'Truck, driver and entrance tare weight are required' });
|
toast({ variant: 'destructive', title: 'Truck, driver and entrance tare weight are required' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
await receiveBookings(pendingReceiveIds, toTruckEntrancePayload(truckForm));
|
||||||
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) });
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadPassedExport = async () => {
|
const loadPassedExport = async () => {
|
||||||
@@ -800,18 +964,33 @@ function EligibleTab({
|
|||||||
</Table.ScrollContainer>
|
</Table.ScrollContainer>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Modal opened={truckOpen} onClose={() => setTruckOpen(false)} title="Truck Entrance Registration" centered size="lg">
|
<Modal
|
||||||
|
opened={truckOpen}
|
||||||
|
onClose={() => setTruckOpen(false)}
|
||||||
|
title="Export Truck Arrival / First Mile Receive Form"
|
||||||
|
centered
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<Text size="sm" c="dimmed">
|
<Alert icon={<Truck size={16} />} color={pendingHasFirstMile ? 'green' : 'blue'} variant="light">
|
||||||
Register the arriving truck and driver before receiving {pendingReceiveIds.length} booking{pendingReceiveIds.length === 1 ? '' : 's'}.
|
<Text size="sm">
|
||||||
</Text>
|
{pendingHasFirstMile
|
||||||
<TruckEntranceFields value={truckForm} onChange={setTruckForm} />
|
? '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.'}
|
||||||
|
</Text>
|
||||||
|
</Alert>
|
||||||
|
<TruckEntranceFields
|
||||||
|
value={truckForm}
|
||||||
|
onChange={setTruckForm}
|
||||||
|
lockedFields={lockedTruckFields}
|
||||||
|
packagingFreightType={packagingFreightType}
|
||||||
|
/>
|
||||||
<Group justify="flex-end">
|
<Group justify="flex-end">
|
||||||
<Button variant="default" onClick={() => setTruckOpen(false)} disabled={bulkReceive.isPending}>
|
<Button variant="default" onClick={() => setTruckOpen(false)} disabled={bulkReceive.isPending}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button leftSection={<Truck size={16} />} loading={bulkReceive.isPending} onClick={receive}>
|
<Button leftSection={<Truck size={16} />} loading={bulkReceive.isPending} onClick={receive}>
|
||||||
Receive and Generate GRN
|
Register Arrival & Generate GRN
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -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<Set<string>>(new Set());
|
||||||
|
const [inspectId, setInspectId] = useState<string | null>(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 (
|
||||||
|
<Stack gap="sm" mt="sm">
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Selected: <b>{selected.size}</b> / {pendingRows.length} received
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
size="compact-sm"
|
||||||
|
color="indigo"
|
||||||
|
leftSection={<ClipboardCheck size={14} />}
|
||||||
|
disabled={selected.size === 0}
|
||||||
|
loading={inspectMutation.isPending}
|
||||||
|
onClick={markInspected}
|
||||||
|
>
|
||||||
|
Mark Selected as Inspected
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<Group justify="center" py="lg">
|
||||||
|
<Loader size="sm" />
|
||||||
|
</Group>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<Text c="dimmed" ta="center" py="lg" size="sm">
|
||||||
|
No received export items awaiting inspection.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Table.ScrollContainer minWidth={1700}>
|
||||||
|
<Table highlightOnHover verticalSpacing="xs" striped>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th w={40}>
|
||||||
|
<Checkbox
|
||||||
|
aria-label="Select all"
|
||||||
|
checked={allSelected}
|
||||||
|
indeterminate={someSelected}
|
||||||
|
onChange={toggleAll}
|
||||||
|
/>
|
||||||
|
</Table.Th>
|
||||||
|
<Table.Th>Booking Ref</Table.Th>
|
||||||
|
<Table.Th>Booking ID</Table.Th>
|
||||||
|
<Table.Th>Customer ID</Table.Th>
|
||||||
|
<Table.Th>Customer Name</Table.Th>
|
||||||
|
<Table.Th>Container #</Table.Th>
|
||||||
|
<Table.Th>Cargo Type</Table.Th>
|
||||||
|
<Table.Th>Weight</Table.Th>
|
||||||
|
<Table.Th>Route</Table.Th>
|
||||||
|
<Table.Th>Inspection</Table.Th>
|
||||||
|
<Table.Th>Status</Table.Th>
|
||||||
|
<Table.Th ta="right">Actions</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{rows.map((r: ReadyToLoadRow) => {
|
||||||
|
const selectable = r.inspectionStatus !== 'PASSED';
|
||||||
|
return (
|
||||||
|
<Table.Tr key={r.id}>
|
||||||
|
<Table.Td>
|
||||||
|
<Checkbox
|
||||||
|
aria-label={`Select ${r.bookingReference ?? r.id}`}
|
||||||
|
checked={selected.has(r.id)}
|
||||||
|
disabled={!selectable}
|
||||||
|
onChange={() => toggleOne(r.id)}
|
||||||
|
/>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="xs" c="dimmed">{r.bookingId ? `${r.bookingId.slice(0, 8)}…` : '—'}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="xs" c="dimmed">{r.customerId ? `${r.customerId.slice(0, 8)}…` : '—'}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>{r.customerName ?? '—'}</Table.Td>
|
||||||
|
<Table.Td>{r.containerNumber ?? '—'}</Table.Td>
|
||||||
|
<Table.Td>{r.cargoType ?? '—'}</Table.Td>
|
||||||
|
<Table.Td>{formatNumber(Number(r.weight))}</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
{r.origin || r.destination ? `${r.origin ?? '?'} → ${r.destination ?? '?'}` : '—'}
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge color={r.inspectionStatus === 'PASSED' ? 'green' : 'orange'} variant="light" size="sm">
|
||||||
|
{r.inspectionStatus ?? 'PENDING'}
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge color="blue" variant="light" size="sm">
|
||||||
|
{r.status}
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td ta="right">
|
||||||
|
<Button size="compact-xs" variant="light" color="orange" onClick={() => setInspectId(r.id)}>
|
||||||
|
Inspect / Report
|
||||||
|
</Button>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</Table.ScrollContainer>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<InspectionReportModal
|
||||||
|
inventoryId={inspectId}
|
||||||
|
opened={Boolean(inspectId)}
|
||||||
|
onClose={() => setInspectId(null)}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Export items that passed inspection and are queued to be loaded onto a train. */
|
/** Export items that passed inspection and are queued to be loaded onto a train. */
|
||||||
function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?: () => void }) {
|
function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?: () => void }) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
@@ -1620,6 +1954,7 @@ function BulkReceiveModal({ opened, onClose, onReceived }: ReceiveInventoryModal
|
|||||||
<Tabs defaultValue="receive-queue" mt="xs">
|
<Tabs defaultValue="receive-queue" mt="xs">
|
||||||
<Tabs.List>
|
<Tabs.List>
|
||||||
<Tabs.Tab value="receive-queue">Receive Queue</Tabs.Tab>
|
<Tabs.Tab value="receive-queue">Receive Queue</Tabs.Tab>
|
||||||
|
<Tabs.Tab value="received">Received</Tabs.Tab>
|
||||||
<Tabs.Tab value="ready-to-load">Ready To Load</Tabs.Tab>
|
<Tabs.Tab value="ready-to-load">Ready To Load</Tabs.Tab>
|
||||||
<Tabs.Tab value="loaded">Loaded</Tabs.Tab>
|
<Tabs.Tab value="loaded">Loaded</Tabs.Tab>
|
||||||
<Tabs.Tab value="dispatch-queue">Dispatch Queue</Tabs.Tab>
|
<Tabs.Tab value="dispatch-queue">Dispatch Queue</Tabs.Tab>
|
||||||
@@ -1628,6 +1963,9 @@ function BulkReceiveModal({ opened, onClose, onReceived }: ReceiveInventoryModal
|
|||||||
<Tabs.Panel value="receive-queue">
|
<Tabs.Panel value="receive-queue">
|
||||||
<EligibleTab direction="EXPORT" location={location} enabled={opened} onChanged={onReceived} />
|
<EligibleTab direction="EXPORT" location={location} enabled={opened} onChanged={onReceived} />
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
<Tabs.Panel value="received">
|
||||||
|
<ExportReceivedTab enabled={opened} onChanged={onReceived} />
|
||||||
|
</Tabs.Panel>
|
||||||
<Tabs.Panel value="ready-to-load">
|
<Tabs.Panel value="ready-to-load">
|
||||||
<ReadyToLoadTab enabled={opened} onChanged={onReceived} />
|
<ReadyToLoadTab enabled={opened} onChanged={onReceived} />
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
@@ -1704,10 +2042,6 @@ function SingleBookingReceiveModal({
|
|||||||
toast({ variant: 'destructive', title: 'Select warehouse, yard and zone' });
|
toast({ variant: 'destructive', title: 'Select warehouse, yard and zone' });
|
||||||
return;
|
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 === '') {
|
if (!truckForm.truckPlateNumber.trim() || !truckForm.driverName.trim() || !truckForm.driverPhone.trim() || truckForm.entranceTareWeightKg === '') {
|
||||||
toast({ variant: 'destructive', title: 'Truck, driver and entrance tare weight are required' });
|
toast({ variant: 'destructive', title: 'Truck, driver and entrance tare weight are required' });
|
||||||
return;
|
return;
|
||||||
@@ -1717,8 +2051,8 @@ function SingleBookingReceiveModal({
|
|||||||
warehouseId: form.warehouseId,
|
warehouseId: form.warehouseId,
|
||||||
yardId: form.yardId,
|
yardId: form.yardId,
|
||||||
zoneId: form.zoneId,
|
zoneId: form.zoneId,
|
||||||
quantity: Number(form.quantity),
|
quantity: 0,
|
||||||
weight: Number(form.weight),
|
weight: 0,
|
||||||
volume: form.volume === '' ? undefined : Number(form.volume),
|
volume: form.volume === '' ? undefined : Number(form.volume),
|
||||||
notes: form.notes.trim() || undefined,
|
notes: form.notes.trim() || undefined,
|
||||||
truckEntrance: toTruckEntrancePayload(truckForm),
|
truckEntrance: toTruckEntrancePayload(truckForm),
|
||||||
@@ -1744,21 +2078,13 @@ function SingleBookingReceiveModal({
|
|||||||
|
|
||||||
<LocationSelects value={location} onChange={(next) => setForm((f) => ({ ...f, ...next }))} />
|
<LocationSelects value={location} onChange={(next) => setForm((f) => ({ ...f, ...next }))} />
|
||||||
|
|
||||||
|
<Alert icon={<Info size={16} />} color="blue" variant="light">
|
||||||
|
<Text size="sm">
|
||||||
|
Customer, TIN, phone, quantity and weight are pulled from the selected booking when the GRN is generated.
|
||||||
|
</Text>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
<Group grow>
|
<Group grow>
|
||||||
<NumberInput
|
|
||||||
label="Quantity"
|
|
||||||
required
|
|
||||||
min={0}
|
|
||||||
value={form.quantity}
|
|
||||||
onChange={(value) => setForm((f) => ({ ...f, quantity: value === '' ? '' : Number(value) }))}
|
|
||||||
/>
|
|
||||||
<NumberInput
|
|
||||||
label="Weight (kg)"
|
|
||||||
required
|
|
||||||
min={0}
|
|
||||||
value={form.weight}
|
|
||||||
onChange={(value) => setForm((f) => ({ ...f, weight: value === '' ? '' : Number(value) }))}
|
|
||||||
/>
|
|
||||||
<NumberInput
|
<NumberInput
|
||||||
label="Volume (m³)"
|
label="Volume (m³)"
|
||||||
placeholder="Optional"
|
placeholder="Optional"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Alert, Button, Group, Modal, Stack, Text, TextInput } from '@mantine/core';
|
import { Alert, Button, Group, Modal, NumberInput, Select, Stack, Text, TextInput } from '@mantine/core';
|
||||||
import { Info } from 'lucide-react';
|
import { Info, Scale } from 'lucide-react';
|
||||||
|
|
||||||
import { useMutation } from '@tanstack/react-query';
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
|
||||||
@@ -17,23 +17,115 @@ interface ReleaseOrderModalProps {
|
|||||||
item: WarehouseInventoryItem | null;
|
item: WarehouseInventoryItem | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const REGISTERED_FIRST_LAST_MILE_TRUCKS = [
|
||||||
|
['03-ET A45843', '43495'], ['03-ET A45866', '43470'], ['03-ET A45853', '43508'], ['03-ET A45849', '43414'],
|
||||||
|
['03-ET A45845', '43492'], ['03-ET A45820', '43487'], ['03-ET A45842', '43478'], ['03-ET A45841', '43515'],
|
||||||
|
['03-ET A45832', '43504'], ['03-ET A45856', '43510'], ['03-ET A45865', '43490'], ['03-ET A45855', '43485'],
|
||||||
|
['03-ET A45840', '43499'], ['03-ET A45867', '43493'], ['03-ET A45833', '43466'], ['03-ET A45858', '43496'],
|
||||||
|
['03-ET A45819', '43474'], ['03-ET A45834', '43502'], ['03-ET A45868', '43469'], ['03-ET A45831', '43488'],
|
||||||
|
['03-ET A45828', '43479'], ['03-ET A45850', '43505'], ['03-ET A45823', '43480'], ['03-ET A45838', '43472'],
|
||||||
|
['03-ET A45854', '43500'], ['03-ET A45839', '43486'], ['03-ET A45861', '43513'], ['03-ET A45830', '43501'],
|
||||||
|
['03-ET A45826', '43498'], ['03-ET A45836', '43467'], ['03-ET A45822', '43512'], ['03-ET A45821', '43210'],
|
||||||
|
['03-ET A45837', '43475'], ['03-ET A45860', '43497'], ['03-ET A45863', '43477'], ['03-ET A45825', '43483'],
|
||||||
|
['03-ET A45829', '43473'], ['03-ET A45824', '43491'], ['03-ET A45857', '43481'], ['03-ET A45851', '43509'],
|
||||||
|
['03-ET A45827', '43468'], ['03-ET A45859', '43887'], ['03-ET A45846', '43471'], ['03-ET A45847', '43511'],
|
||||||
|
['03-ET A45852', '43484'], ['03-ET A45844', '43476'], ['03-ET A45835', '43482'], ['03-ET A45864', '43503'],
|
||||||
|
['03-ET A45848', '43494'], ['03-ET A45862', '43465'], ['03-ET A39105', '41218'], ['03-ET A39098', '41220'],
|
||||||
|
['03-ET A29900', '41865'], ['03-ET A39097', '41226'], ['03-ET A39104', '41225'], ['03-ET A39103', '41223'],
|
||||||
|
['03-ET A39106', '41221'], ['03-ET A39107', '41215'], ['03-ET A39094', '41222'], ['03-ET A39099', '41216'],
|
||||||
|
['03-ET A39092', '41224'], ['03-ET A31801', '41214'],
|
||||||
|
].map(([powerPlate, trailerPlate], index) => ({
|
||||||
|
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) {
|
export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalProps) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const releaseMutation = useMutation(api.warehouses.release.mutationOptions());
|
const releaseMutation = useMutation(api.warehouses.release.mutationOptions());
|
||||||
const [reference, setReference] = useState('');
|
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<number | ''>('');
|
||||||
|
const [grossWeight, setGrossWeight] = useState<number | ''>('');
|
||||||
|
const [netWeight, setNetWeight] = useState<number | ''>('');
|
||||||
|
const [gateOutTime, setGateOutTime] = useState('');
|
||||||
const [downloading, setDownloading] = useState(false);
|
const [downloading, setDownloading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
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]);
|
}, [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 () => {
|
const handleSubmit = async () => {
|
||||||
if (!item) return;
|
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');
|
const pdfWindow = window.open('', '_blank');
|
||||||
try {
|
try {
|
||||||
const released = await releaseMutation.mutateAsync({
|
const released = await releaseMutation.mutateAsync({
|
||||||
id: item.id,
|
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);
|
setDownloading(true);
|
||||||
const response = await warehouseService.downloadReleaseDocument(item.id);
|
const response = await warehouseService.downloadReleaseDocument(item.id);
|
||||||
@@ -56,12 +148,12 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal opened={opened} onClose={onClose} title="Issue release exit paper" centered size="md">
|
<Modal opened={opened} onClose={onClose} title="Exit inspection and release paper" centered size="lg">
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<Alert icon={<Info size={16} />} color="orange" variant="light">
|
<Alert icon={<Info size={16} />} color="orange" variant="light">
|
||||||
<Text size="sm">
|
<Text size="sm">
|
||||||
Creates the warehouse release document with booking, customer, cargo and location details. The
|
Save the exit inspection before generating the exit paper. Gate clearance is blocked when
|
||||||
printed paper authorizes the goods to leave the warehouse gate.
|
recorded net weight does not equal gross weight minus tare weight.
|
||||||
</Text>
|
</Text>
|
||||||
</Alert>
|
</Alert>
|
||||||
<TextInput
|
<TextInput
|
||||||
@@ -70,12 +162,69 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
|
|||||||
value={reference}
|
value={reference}
|
||||||
onChange={(e) => setReference(e.currentTarget.value)}
|
onChange={(e) => setReference(e.currentTarget.value)}
|
||||||
/>
|
/>
|
||||||
|
<Select
|
||||||
|
label="Registered first / last-mile truck"
|
||||||
|
placeholder="Select truck or type plate manually below"
|
||||||
|
searchable
|
||||||
|
clearable
|
||||||
|
data={REGISTERED_FIRST_LAST_MILE_TRUCKS}
|
||||||
|
value={REGISTERED_FIRST_LAST_MILE_TRUCKS.some((truck) => truck.value === truckPlateNumber) ? truckPlateNumber : null}
|
||||||
|
onChange={(value) => {
|
||||||
|
const truck = REGISTERED_FIRST_LAST_MILE_TRUCKS.find((row) => row.value === value);
|
||||||
|
setTruckPlateNumber(truck?.value ?? '');
|
||||||
|
setTrailerPlateNumber(truck?.trailerPlate ?? '');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Group grow>
|
||||||
|
<TextInput
|
||||||
|
label="Truck plate number"
|
||||||
|
required
|
||||||
|
value={truckPlateNumber}
|
||||||
|
onChange={(e) => setTruckPlateNumber(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Trailer plate number"
|
||||||
|
value={trailerPlateNumber}
|
||||||
|
onChange={(e) => setTrailerPlateNumber(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
<Group grow>
|
||||||
|
<TextInput label="Driver name" required value={driverName} onChange={(e) => setDriverName(e.currentTarget.value)} />
|
||||||
|
<TextInput label="Driver license" value={driverLicense} onChange={(e) => setDriverLicense(e.currentTarget.value)} />
|
||||||
|
</Group>
|
||||||
|
<Group grow>
|
||||||
|
<TextInput label="Driver phone" value={driverPhone} onChange={(e) => setDriverPhone(e.currentTarget.value)} />
|
||||||
|
<TextInput label="Truck type" value={truckType} onChange={(e) => setTruckType(e.currentTarget.value)} />
|
||||||
|
</Group>
|
||||||
|
<Group grow>
|
||||||
|
<TextInput label="Container number" value={containerNumber} onChange={(e) => setContainerNumber(e.currentTarget.value)} />
|
||||||
|
<TextInput label="Gate in time" type="datetime-local" value={gateInTime} onChange={(e) => setGateInTime(e.currentTarget.value)} />
|
||||||
|
</Group>
|
||||||
|
<Group grow>
|
||||||
|
<NumberInput label="Tare weight (kg)" required min={0} value={tareWeight} onChange={(v) => setTareWeight(v === '' ? '' : Number(v))} />
|
||||||
|
<NumberInput label="Gross weight (kg)" required min={0} value={grossWeight} onChange={(v) => setGrossWeight(v === '' ? '' : Number(v))} />
|
||||||
|
<NumberInput label="Recorded net weight (kg)" min={0} value={netWeight} onChange={(v) => setNetWeight(v === '' ? '' : Number(v))} />
|
||||||
|
</Group>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text size="sm" c={weightMismatch ? 'red' : 'dimmed'}>
|
||||||
|
Computed net: <b>{computedNetWeight == null ? '-' : `${computedNetWeight.toLocaleString()} kg`}</b>
|
||||||
|
</Text>
|
||||||
|
<TextInput label="Gate out time" type="datetime-local" value={gateOutTime} onChange={(e) => setGateOutTime(e.currentTarget.value)} />
|
||||||
|
</Group>
|
||||||
|
{weightMismatch && (
|
||||||
|
<Alert icon={<Scale size={16} />} color="red" variant="light">
|
||||||
|
<Text size="sm">
|
||||||
|
Weight mismatch detected. Exit paper and gate clearance are blocked; use Store or Move to
|
||||||
|
reassign the item back to warehouse handling.
|
||||||
|
</Text>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
<Group justify="flex-end" mt="sm">
|
<Group justify="flex-end" mt="sm">
|
||||||
<Button variant="default" onClick={onClose} disabled={releaseMutation.isPending || downloading}>
|
<Button variant="default" onClick={onClose} disabled={releaseMutation.isPending || downloading}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button color="orange" onClick={handleSubmit} loading={releaseMutation.isPending || downloading}>
|
<Button color="orange" onClick={handleSubmit} loading={releaseMutation.isPending || downloading}>
|
||||||
Issue & view exit paper
|
Exit Inspection & View Exit Paper
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ export function WarehouseInventoryTable({
|
|||||||
loading={busy}
|
loading={busy}
|
||||||
onClick={() => onAdvance(item, nextAction)}
|
onClick={() => onAdvance(item, nextAction)}
|
||||||
>
|
>
|
||||||
{humanizeEnum(nextAction.replace(/-/g, '_'))}
|
{nextAction === 'release' ? 'Exit Inspection' : humanizeEnum(nextAction.replace(/-/g, '_'))}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{item.status === 'READY_FOR_PICKUP' && (
|
{item.status === 'READY_FOR_PICKUP' && (
|
||||||
|
|||||||
@@ -192,6 +192,24 @@ export const URL_CONSTANTS = {
|
|||||||
PIN_WAGONS: (id: string) => `/train-scheduling/schedules/${id}/pin-wagons`,
|
PIN_WAGONS: (id: string) => `/train-scheduling/schedules/${id}/pin-wagons`,
|
||||||
FINALIZE: (id: string) => `/train-scheduling/schedules/${id}/finalize`,
|
FINALIZE: (id: string) => `/train-scheduling/schedules/${id}/finalize`,
|
||||||
DISPATCH: (id: string) => `/train-scheduling/schedules/${id}/dispatch`,
|
DISPATCH: (id: string) => `/train-scheduling/schedules/${id}/dispatch`,
|
||||||
|
IMPORT_DJIBOUTI: (id: string) =>
|
||||||
|
`/train-scheduling/schedules/${id}/import-djibouti`,
|
||||||
|
IMPORT_DJIBOUTI_DOCUMENTS: (id: string) =>
|
||||||
|
`/train-scheduling/schedules/${id}/import-djibouti/documents`,
|
||||||
|
IMPORT_DJIBOUTI_GATEPASS_GRANTED: (id: string) =>
|
||||||
|
`/train-scheduling/schedules/${id}/import-djibouti/gatepass-granted`,
|
||||||
|
IMPORT_DJIBOUTI_READY_FOR_LOADING: (id: string) =>
|
||||||
|
`/train-scheduling/schedules/${id}/import-djibouti/ready-for-loading`,
|
||||||
|
IMPORT_DJIBOUTI_LOADED_ON_TRAIN: (id: string) =>
|
||||||
|
`/train-scheduling/schedules/${id}/import-djibouti/loaded-on-train`,
|
||||||
|
IMPORT_DJIBOUTI_DEPART: (id: string) =>
|
||||||
|
`/train-scheduling/schedules/${id}/import-djibouti/depart`,
|
||||||
|
IMPORT_DJIBOUTI_LOAD_LIST: (id: string) =>
|
||||||
|
`/train-scheduling/schedules/${id}/import-djibouti/load-list`,
|
||||||
|
IMPORT_DJIBOUTI_LOAD_LIST_DOCUMENT: (id: string) =>
|
||||||
|
`/train-scheduling/schedules/${id}/import-djibouti/load-list/document`,
|
||||||
|
EXPORT_LOAD_LIST_DOCUMENT: (id: string) =>
|
||||||
|
`/train-scheduling/schedules/${id}/export/load-list/document`,
|
||||||
CHECKPOINTS: (id: string) => `/train-scheduling/schedules/${id}/checkpoints`,
|
CHECKPOINTS: (id: string) => `/train-scheduling/schedules/${id}/checkpoints`,
|
||||||
ARRIVE: (id: string) => `/train-scheduling/schedules/${id}/arrive`,
|
ARRIVE: (id: string) => `/train-scheduling/schedules/${id}/arrive`,
|
||||||
RESCHEDULE_PREVIEW: (id: string) =>
|
RESCHEDULE_PREVIEW: (id: string) =>
|
||||||
@@ -322,6 +340,7 @@ export const URL_CONSTANTS = {
|
|||||||
RECEIVE_BULK: '/warehouse-inventory/receive-bulk',
|
RECEIVE_BULK: '/warehouse-inventory/receive-bulk',
|
||||||
LOAD_PASSED_EXPORT: '/warehouse-inventory/load-passed-export',
|
LOAD_PASSED_EXPORT: '/warehouse-inventory/load-passed-export',
|
||||||
BULK_MARK_INSPECTED: '/warehouse-inventory/bulk-mark-inspected',
|
BULK_MARK_INSPECTED: '/warehouse-inventory/bulk-mark-inspected',
|
||||||
|
RECEIVED_EXPORT: '/warehouse-inventory/received-export',
|
||||||
READY_TO_LOAD_EXPORT: '/warehouse-inventory/ready-to-load-export',
|
READY_TO_LOAD_EXPORT: '/warehouse-inventory/ready-to-load-export',
|
||||||
LOADED_EXPORT: '/warehouse-inventory/loaded-export',
|
LOADED_EXPORT: '/warehouse-inventory/loaded-export',
|
||||||
BULK_DISPATCH_EXPORT: '/warehouse-inventory/bulk-dispatch-export',
|
BULK_DISPATCH_EXPORT: '/warehouse-inventory/bulk-dispatch-export',
|
||||||
@@ -377,6 +396,23 @@ export const URL_CONSTANTS = {
|
|||||||
CANCEL: (id: string) => `/interchange-documents/${id}/cancel`,
|
CANCEL: (id: string) => `/interchange-documents/${id}/cancel`,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
IMPORT_OPERATIONS: {
|
||||||
|
DJIBOUTI_INCIDENTS: '/import-operations/djibouti-incidents',
|
||||||
|
CUSTOMS: (bookingId: string) => `/import-operations/customs/${bookingId}`,
|
||||||
|
CUSTOMS_DOCUMENTS: (bookingId: string) => `/import-operations/customs/${bookingId}/documents`,
|
||||||
|
CUSTOMS_DECLARATION: (bookingId: string) => `/import-operations/customs/${bookingId}/declaration`,
|
||||||
|
CUSTOMS_NOTIFY_DUTIES_TAXES: (bookingId: string) =>
|
||||||
|
`/import-operations/customs/${bookingId}/notify-duties-taxes`,
|
||||||
|
CUSTOMS_DUTIES_TAXES_PAID: (bookingId: string) =>
|
||||||
|
`/import-operations/customs/${bookingId}/duties-taxes-paid`,
|
||||||
|
CUSTOMS_RISK: (bookingId: string) => `/import-operations/customs/${bookingId}/risk`,
|
||||||
|
CUSTOMS_RELEASE_PERMITTED: (bookingId: string) =>
|
||||||
|
`/import-operations/customs/${bookingId}/release-permitted`,
|
||||||
|
EMPTY_CONTAINER_RETURNS: '/import-operations/empty-container-returns',
|
||||||
|
EMPTY_CONTAINER_RETURN_STATUS: (id: string) =>
|
||||||
|
`/import-operations/empty-container-returns/${id}/status`,
|
||||||
|
},
|
||||||
|
|
||||||
VEHICLES: {
|
VEHICLES: {
|
||||||
BASE: '/vehicles',
|
BASE: '/vehicles',
|
||||||
BY_ID: (id: string) => `/vehicles/${id}`,
|
BY_ID: (id: string) => `/vehicles/${id}`,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export const API_BASE_URL =
|
export const API_BASE_URL =
|
||||||
import.meta.env.VITE_BASE_API_URL ||
|
import.meta.env.VITE_BASE_API_URL ||
|
||||||
import.meta.env.VITE_API_URL ||
|
import.meta.env.VITE_API_URL ||
|
||||||
'https://edrfreightapi.triaplc.com';
|
'https://edrfreightapi.triaplc.com';
|
||||||
|
|
||||||
//export const API_BASE_URL = 'http://localhost:3001';
|
//export const API_BASE_URL = 'http://localhost:3001';
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
Package,
|
Package,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
|
||||||
@@ -84,9 +84,6 @@ export default function CustomerDetailPage() {
|
|||||||
enabled: Boolean(id),
|
enabled: Boolean(id),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const approveMutation = useMutation(
|
|
||||||
api.customers.setCompanyStatus.mutationOptions(),
|
|
||||||
);
|
|
||||||
const bookingsQuery = useQuery(
|
const bookingsQuery = useQuery(
|
||||||
api.customers.bookings.queryOptions({
|
api.customers.bookings.queryOptions({
|
||||||
input: { id: id ?? "" },
|
input: { id: id ?? "" },
|
||||||
@@ -394,28 +391,12 @@ export default function CustomerDetailPage() {
|
|||||||
]}
|
]}
|
||||||
backTo="/dashboard/customers"
|
backTo="/dashboard/customers"
|
||||||
title={company.name}
|
title={company.name}
|
||||||
subtitle={`TIN ${company.tin}${
|
subtitle={`TIN ${company.tin}${company.country ? ` · ${company.country}` : ""
|
||||||
company.country ? ` · ${company.country}` : ""
|
}`}
|
||||||
}`}
|
|
||||||
meta={
|
meta={
|
||||||
<Group gap="xs" wrap="nowrap">
|
<Group gap="xs" wrap="nowrap">
|
||||||
<CompanyTypeBadge type={company.type} />
|
<CompanyTypeBadge type={company.type} />
|
||||||
<CompanyStatusBadge status={company.status} />
|
<CompanyStatusBadge status={company.status} />
|
||||||
{company.status === "pending" && (
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
color="green"
|
|
||||||
loading={approveMutation.isPending}
|
|
||||||
onClick={() =>
|
|
||||||
approveMutation.mutate({
|
|
||||||
companyId: company.id,
|
|
||||||
status: "active",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Approve
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Group>
|
</Group>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -546,9 +527,9 @@ export default function CustomerDetailPage() {
|
|||||||
error={
|
error={
|
||||||
bookingsQuery.isError
|
bookingsQuery.isError
|
||||||
? {
|
? {
|
||||||
message: "Failed to load bookings.",
|
message: "Failed to load bookings.",
|
||||||
onRetry: () => void bookingsQuery.refetch(),
|
onRetry: () => void bookingsQuery.refetch(),
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -567,9 +548,9 @@ export default function CustomerDetailPage() {
|
|||||||
error={
|
error={
|
||||||
documentsQuery.isError
|
documentsQuery.isError
|
||||||
? {
|
? {
|
||||||
message: "Failed to load documents.",
|
message: "Failed to load documents.",
|
||||||
onRetry: () => void documentsQuery.refetch(),
|
onRetry: () => void documentsQuery.refetch(),
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -588,9 +569,9 @@ export default function CustomerDetailPage() {
|
|||||||
error={
|
error={
|
||||||
paymentsQuery.isError
|
paymentsQuery.isError
|
||||||
? {
|
? {
|
||||||
message: "Failed to load payments.",
|
message: "Failed to load payments.",
|
||||||
onRetry: () => void paymentsQuery.refetch(),
|
onRetry: () => void paymentsQuery.refetch(),
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,23 +1,34 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from 'react';
|
||||||
import { createRoot, type Root } from "react-dom/client";
|
import { createRoot, type Root } from 'react-dom/client';
|
||||||
import {
|
import {
|
||||||
UserManagementApp,
|
UserManagementApp,
|
||||||
type UserManagementRuntimeOptions,
|
type UserManagementRuntimeOptions,
|
||||||
type UserManagementSessionSeed,
|
type UserManagementSessionSeed,
|
||||||
} from "@tria-plc/iamui";
|
} from '@tria-plc/iamui';
|
||||||
|
import { iamConfig } from './iamConfig';
|
||||||
|
|
||||||
import { getCookie } from "@/auth/cookies";
|
function readCookieValue(name: string): string | null {
|
||||||
|
const escaped = name.replace(/([.$?*|{}()[\]\\/+^])/g, '\\$1');
|
||||||
|
const match = document.cookie.match(
|
||||||
|
new RegExp(`(?:^|; )${escaped}=([^;]*)`),
|
||||||
|
);
|
||||||
|
|
||||||
import { iamConfig } from "./iamConfig";
|
return match ? decodeURIComponent(match[1]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
function readInitialSession(): UserManagementSessionSeed | null {
|
function readInitialSession(): UserManagementSessionSeed | null {
|
||||||
const token = getCookie("auth-token");
|
const token =
|
||||||
|
localStorage.getItem('fhc-backoffice-auth-token') ??
|
||||||
|
readCookieValue('auth-token');
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const refreshToken = getCookie("refresh-token") ?? undefined;
|
const refreshToken =
|
||||||
|
localStorage.getItem('fhc-backoffice-auth-refresh-token') ??
|
||||||
|
readCookieValue('refresh-token') ??
|
||||||
|
undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
token,
|
token,
|
||||||
@@ -47,15 +58,14 @@ export default function UserManagementHostPage() {
|
|||||||
rootRef.current = createRoot(mountNode);
|
rootRef.current = createRoot(mountNode);
|
||||||
}
|
}
|
||||||
|
|
||||||
const apiBaseUrl = import.meta.env.VITE_BASE_API_URL.replace(/\/+$/, "");
|
const apiBaseUrl = import.meta.env.VITE_BASE_API_URL.replace(/\/+$/, '');
|
||||||
const iamApiUrl = "/um-api";
|
|
||||||
const runtime: UserManagementRuntimeOptions = {
|
const runtime: UserManagementRuntimeOptions = {
|
||||||
basename: "/um",
|
basename: '/um',
|
||||||
apiBaseUrl,
|
apiBaseUrl,
|
||||||
apiUrl: iamApiUrl,
|
apiUrl: `${apiBaseUrl}/api`,
|
||||||
recordApiUrl: iamApiUrl,
|
recordApiUrl: `${apiBaseUrl}/api`,
|
||||||
chronicleUrl: iamApiUrl,
|
chronicleUrl: `${apiBaseUrl}/api`,
|
||||||
auditApiUrl: iamApiUrl,
|
auditApiUrl: `${apiBaseUrl}/api`,
|
||||||
};
|
};
|
||||||
|
|
||||||
rootRef.current.render(
|
rootRef.current.render(
|
||||||
@@ -78,5 +88,5 @@ export default function UserManagementHostPage() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return <div ref={mountRef} style={{ position: "fixed", inset: 0 }} />;
|
return <div ref={mountRef} style={{ position: 'fixed', inset: 0 }} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { FleetResourceConfig } from "./resources";
|
import type { FleetResourceConfig } from "./resources";
|
||||||
import { API_BASE_URL } from "@/constants/apiConfig";
|
|
||||||
|
|
||||||
const VEHICLE_TYPE_OPTIONS = [
|
const VEHICLE_TYPE_OPTIONS = [
|
||||||
{ label: "Truck", value: "TRUCK" },
|
{ label: "Truck", value: "TRUCK" },
|
||||||
@@ -60,6 +59,8 @@ export const vehiclesConfig: FleetResourceConfig = {
|
|||||||
{ id: "fuelType", header: "Fuel Type", accessorKey: "fuelType", format: "code", size: 110 },
|
{ id: "fuelType", header: "Fuel Type", accessorKey: "fuelType", format: "code", size: 110 },
|
||||||
{ id: "capacity", header: "Capacity (tons)", accessorKey: "capacity", format: "number", size: 130 },
|
{ id: "capacity", header: "Capacity (tons)", accessorKey: "capacity", format: "number", size: 130 },
|
||||||
{ id: "assignedDriverName", header: "Assigned Driver", accessorKey: "assignedDriverName", format: "code", size: 140 },
|
{ id: "assignedDriverName", header: "Assigned Driver", accessorKey: "assignedDriverName", format: "code", size: 140 },
|
||||||
|
{ id: "estimatedDistanceKm", header: "Est. Distance (KM)", accessorKey: "estimatedDistanceKm", format: "number", size: 150 },
|
||||||
|
{ id: "actualDistanceKm", header: "Actual Distance (KM)", accessorKey: "actualDistanceKm", format: "number", size: 150 },
|
||||||
{ id: "status", header: "Status", accessorKey: "status", format: "statusBadge", size: 100 },
|
{ id: "status", header: "Status", accessorKey: "status", format: "statusBadge", size: 100 },
|
||||||
],
|
],
|
||||||
formFields: [
|
formFields: [
|
||||||
@@ -73,6 +74,8 @@ export const vehiclesConfig: FleetResourceConfig = {
|
|||||||
{ name: "year", label: "Year", type: "number", required: true },
|
{ name: "year", label: "Year", type: "number", required: true },
|
||||||
{ name: "fuelType", label: "Fuel Type", type: "select", required: true, options: FUEL_TYPE_OPTIONS },
|
{ name: "fuelType", label: "Fuel Type", type: "select", required: true, options: FUEL_TYPE_OPTIONS },
|
||||||
{ name: "capacity", label: "Capacity", type: "number", required: true },
|
{ name: "capacity", label: "Capacity", type: "number", required: true },
|
||||||
|
{ name: "estimatedDistanceKm", label: "Estimated Distance (KM)", type: "number" },
|
||||||
|
{ name: "actualDistanceKm", label: "Actual Distance (KM)", type: "number" },
|
||||||
{ name: "status", label: "Status", type: "select", required: true, options: VEHICLE_STATUS_OPTIONS },
|
{ name: "status", label: "Status", type: "select", required: true, options: VEHICLE_STATUS_OPTIONS },
|
||||||
{ name: "description", label: "Description", type: "textarea" },
|
{ name: "description", label: "Description", type: "textarea" },
|
||||||
],
|
],
|
||||||
@@ -87,10 +90,11 @@ export const vehiclesConfig: FleetResourceConfig = {
|
|||||||
year: new Date().getFullYear(),
|
year: new Date().getFullYear(),
|
||||||
fuelType: "DIESEL",
|
fuelType: "DIESEL",
|
||||||
capacity: 0,
|
capacity: 0,
|
||||||
|
estimatedDistanceKm: "",
|
||||||
|
actualDistanceKm: "",
|
||||||
status: "ACTIVE",
|
status: "ACTIVE",
|
||||||
description: "",
|
description: "",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export { VEHICLE_TYPE_OPTIONS, FUEL_TYPE_OPTIONS, VEHICLE_STATUS_OPTIONS };
|
export { VEHICLE_TYPE_OPTIONS, FUEL_TYPE_OPTIONS, VEHICLE_STATUS_OPTIONS };
|
||||||
export { API_BASE_URL };
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
Group,
|
Group,
|
||||||
Menu,
|
Menu,
|
||||||
Modal,
|
Modal,
|
||||||
|
NumberInput,
|
||||||
Select,
|
Select,
|
||||||
SimpleGrid,
|
SimpleGrid,
|
||||||
Stack,
|
Stack,
|
||||||
@@ -67,7 +68,7 @@ type StatusFilter = "ALL" | FirstMileApiStatus | AssignmentStatus;
|
|||||||
|
|
||||||
const FILTER_OPTIONS: { value: StatusFilter; label: string }[] = [
|
const FILTER_OPTIONS: { value: StatusFilter; label: string }[] = [
|
||||||
{ value: "ALL", label: "All" },
|
{ value: "ALL", label: "All" },
|
||||||
...FIRST_MILE_STATUSES.map((s) => ({ value: s as StatusFilter, label: STATUS_META[s].label })),
|
...FIRST_MILE_STATUSES.filter((s) => s !== "PAYMENT_PENDING").map((s) => ({ value: s as StatusFilter, label: STATUS_META[s].label })),
|
||||||
{ value: "ASSIGNED", label: "Assigned" },
|
{ value: "ASSIGNED", label: "Assigned" },
|
||||||
{ value: "UNASSIGNED", label: "Unassigned" },
|
{ value: "UNASSIGNED", label: "Unassigned" },
|
||||||
];
|
];
|
||||||
@@ -93,8 +94,6 @@ const cargoDesc = (r: FirstMileRecord) => {
|
|||||||
if (r.booking?.cargoTotalWeightVgm) parts.push(`${r.booking.cargoTotalWeightVgm} t`);
|
if (r.booking?.cargoTotalWeightVgm) parts.push(`${r.booking.cargoTotalWeightVgm} t`);
|
||||||
return parts.join(" · ") || "—";
|
return parts.join(" · ") || "—";
|
||||||
};
|
};
|
||||||
const priceAmount = (r: FirstMileRecord) =>
|
|
||||||
r.booking?.totalAmount ?? r.advancedPayment;
|
|
||||||
// First-mile destination is the origin yard (pickup → origin yard)
|
// First-mile destination is the origin yard (pickup → origin yard)
|
||||||
const destinationYardName = (r: FirstMileRecord) =>
|
const destinationYardName = (r: FirstMileRecord) =>
|
||||||
r.booking?.originYard?.label ?? "—";
|
r.booking?.originYard?.label ?? "—";
|
||||||
@@ -142,11 +141,14 @@ const BookingInfo = ({ record }: { record: FirstMileRecord }) => (
|
|||||||
<InfoRow label="Pickup location" value={pickupLocation(record)} />
|
<InfoRow label="Pickup location" value={pickupLocation(record)} />
|
||||||
<InfoRow label="Destination (origin yard)" value={destinationYardName(record)} />
|
<InfoRow label="Destination (origin yard)" value={destinationYardName(record)} />
|
||||||
<InfoRow label="Cargo" value={cargoDesc(record)} />
|
<InfoRow label="Cargo" value={cargoDesc(record)} />
|
||||||
<InfoRow label="Price" value={formatPrice(priceAmount(record))} />
|
<InfoRow label="Advanced Payment" value={formatPrice(record.advancedPayment)} />
|
||||||
|
<InfoRow label="Post Payment" value={formatPrice(record.remainingPayment)} />
|
||||||
<InfoRow label="Contact" value={contactPersonName(record)} />
|
<InfoRow label="Contact" value={contactPersonName(record)} />
|
||||||
<InfoRow label="Phone" value={contactPhone(record)} />
|
<InfoRow label="Phone" value={contactPhone(record)} />
|
||||||
<InfoRow label="Requested date" value={requestedDate(record)} />
|
<InfoRow label="Requested date" value={requestedDate(record)} />
|
||||||
<InfoRow label="Assigned vehicle" value={vehicleLabel(record) ?? "—"} />
|
<InfoRow label="Assigned vehicle" value={vehicleLabel(record) ?? "—"} />
|
||||||
|
<InfoRow label="Est. Distance (KM)" value={record.estimatedKm != null ? String(record.estimatedKm) : "—"} />
|
||||||
|
<InfoRow label="Actual Distance (KM)" value={record.exactKm != null ? String(record.exactKm) : "—"} />
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -158,10 +160,13 @@ const tripSlipRows = (record: FirstMileRecord): [string, string][] => [
|
|||||||
["Pickup location", pickupLocation(record)],
|
["Pickup location", pickupLocation(record)],
|
||||||
["Destination yard", destinationYardName(record)],
|
["Destination yard", destinationYardName(record)],
|
||||||
["Cargo", cargoDesc(record)],
|
["Cargo", cargoDesc(record)],
|
||||||
["Price", formatPrice(priceAmount(record))],
|
["Advanced Payment", formatPrice(record.advancedPayment)],
|
||||||
|
["Post Payment", formatPrice(record.remainingPayment)],
|
||||||
["Vehicle", vehicleLabel(record) ?? "Unassigned"],
|
["Vehicle", vehicleLabel(record) ?? "Unassigned"],
|
||||||
["Contact", `${contactPersonName(record)} · ${contactPhone(record)}`],
|
["Contact", `${contactPersonName(record)} · ${contactPhone(record)}`],
|
||||||
["Requested date", requestedDate(record)],
|
["Requested date", requestedDate(record)],
|
||||||
|
["Est. Distance (KM)", record.estimatedKm != null ? String(record.estimatedKm) : "—"],
|
||||||
|
["Actual Distance (KM)", record.exactKm != null ? String(record.exactKm) : "—"],
|
||||||
["Status", STATUS_META[record.status].label],
|
["Status", STATUS_META[record.status].label],
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -323,6 +328,9 @@ const FirstMilePage = () => {
|
|||||||
const [acceptVehicleValue, setAcceptVehicleValue] = useState<string | null>(null);
|
const [acceptVehicleValue, setAcceptVehicleValue] = useState<string | null>(null);
|
||||||
const [bookingSearch, setBookingSearch] = useState("");
|
const [bookingSearch, setBookingSearch] = useState("");
|
||||||
|
|
||||||
|
const [distanceOpen, setDistanceOpen] = useState(false);
|
||||||
|
const [distanceValue, setDistanceValue] = useState("");
|
||||||
|
|
||||||
const { data: listData, isLoading } = useQuery({
|
const { data: listData, isLoading } = useQuery({
|
||||||
queryKey: QUERY_KEYS.FIRST_MILE.list(),
|
queryKey: QUERY_KEYS.FIRST_MILE.list(),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@@ -375,10 +383,28 @@ const FirstMilePage = () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const updateDistanceMutation = useMutation({
|
||||||
|
mutationFn: ({ id, exactKm }: { id: string; exactKm: number }) =>
|
||||||
|
firstMileService.update(id, { exactKm }),
|
||||||
|
onSuccess: () => {
|
||||||
|
void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.ROOT });
|
||||||
|
if (activeRecord) {
|
||||||
|
toast({ title: "Distance updated", description: `${bookingRef(activeRecord)} → ${distanceValue} km` });
|
||||||
|
}
|
||||||
|
closeDistance();
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast({ title: "Update failed", variant: "destructive" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const acceptMutation = useMutation({
|
const acceptMutation = useMutation({
|
||||||
mutationFn: async ({ reference, vehicleId }: { reference: string; vehicleId: string | null }) => {
|
mutationFn: async ({ reference, vehicleId }: { reference: string; vehicleId: string | null }) => {
|
||||||
const res = await firstMileService.accept(reference);
|
const res = await firstMileService.accept(reference);
|
||||||
const created = res.data;
|
const created = res.data;
|
||||||
|
if (!created?.id) {
|
||||||
|
throw new Error("First-mile leg was not created for this booking.");
|
||||||
|
}
|
||||||
if (vehicleId) await firstMileService.update(created.id, { vehicleId });
|
if (vehicleId) await firstMileService.update(created.id, { vehicleId });
|
||||||
return created;
|
return created;
|
||||||
},
|
},
|
||||||
@@ -387,8 +413,11 @@ const FirstMilePage = () => {
|
|||||||
toast({ title: "Booking accepted", description: "First-mile leg created successfully." });
|
toast({ title: "Booking accepted", description: "First-mile leg created successfully." });
|
||||||
closeAccept();
|
closeAccept();
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: (err: unknown) => {
|
||||||
toast({ title: "Accept failed", variant: "destructive" });
|
const description =
|
||||||
|
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ??
|
||||||
|
(err instanceof Error ? err.message : undefined);
|
||||||
|
toast({ title: "Accept failed", description, variant: "destructive" });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -407,7 +436,6 @@ const FirstMilePage = () => {
|
|||||||
paidBookings.filter(
|
paidBookings.filter(
|
||||||
(booking) =>
|
(booking) =>
|
||||||
booking.tradeDirection === "EXPORT" &&
|
booking.tradeDirection === "EXPORT" &&
|
||||||
Boolean(booking.firstMilePickupAddress?.trim()) &&
|
|
||||||
!existingFirstMileBookingIds.has(booking.id),
|
!existingFirstMileBookingIds.has(booking.id),
|
||||||
),
|
),
|
||||||
[existingFirstMileBookingIds, paidBookings],
|
[existingFirstMileBookingIds, paidBookings],
|
||||||
@@ -445,6 +473,27 @@ const FirstMilePage = () => {
|
|||||||
acceptMutation.mutate({ reference: selectedBooking.reference, vehicleId: acceptVehicleValue });
|
acceptMutation.mutate({ reference: selectedBooking.reference, vehicleId: acceptVehicleValue });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openDistance = (id: string) => {
|
||||||
|
setActiveId(id);
|
||||||
|
setDistanceValue("");
|
||||||
|
setDistanceOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeDistance = () => {
|
||||||
|
setDistanceOpen(false);
|
||||||
|
setActiveId(null);
|
||||||
|
setDistanceValue("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveDistance = () => {
|
||||||
|
const distance = parseFloat(distanceValue);
|
||||||
|
if (!activeId || isNaN(distance) || distance < 0) {
|
||||||
|
toast({ title: "Invalid distance", description: "Enter a valid distance value.", variant: "destructive" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateDistanceMutation.mutate({ id: activeId, exactKm: distance });
|
||||||
|
};
|
||||||
|
|
||||||
const matchesFilter = (r: FirstMileRecord) => {
|
const matchesFilter = (r: FirstMileRecord) => {
|
||||||
switch (statusFilter) {
|
switch (statusFilter) {
|
||||||
case "ALL": return true;
|
case "ALL": return true;
|
||||||
@@ -623,10 +672,16 @@ const FirstMilePage = () => {
|
|||||||
cell: ({ row }) => cargoDesc(row.original),
|
cell: ({ row }) => cargoDesc(row.original),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "price",
|
id: "advancedPayment",
|
||||||
header: "Price",
|
header: "Advanced Payment",
|
||||||
meta: { headerClassName, cellClassName },
|
meta: { headerClassName, cellClassName },
|
||||||
cell: ({ row }) => formatPrice(priceAmount(row.original)),
|
cell: ({ row }) => formatPrice(row.original.advancedPayment),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "postPayment",
|
||||||
|
header: "Post Payment",
|
||||||
|
meta: { headerClassName, cellClassName },
|
||||||
|
cell: ({ row }) => formatPrice(row.original.remainingPayment),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "vehicle",
|
id: "vehicle",
|
||||||
@@ -634,6 +689,18 @@ const FirstMilePage = () => {
|
|||||||
meta: { headerClassName, cellClassName },
|
meta: { headerClassName, cellClassName },
|
||||||
cell: ({ row }) => vehicleLabel(row.original) ?? <Text c="dimmed">—</Text>,
|
cell: ({ row }) => vehicleLabel(row.original) ?? <Text c="dimmed">—</Text>,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "estimatedKm",
|
||||||
|
header: "Est. Distance (KM)",
|
||||||
|
meta: { headerClassName, cellClassName },
|
||||||
|
cell: ({ row }) => row.original.estimatedKm != null ? row.original.estimatedKm : <Text c="dimmed">—</Text>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "exactKm",
|
||||||
|
header: "Actual Distance (KM)",
|
||||||
|
meta: { headerClassName, cellClassName },
|
||||||
|
cell: ({ row }) => row.original.exactKm != null ? row.original.exactKm : <Text c="dimmed">—</Text>,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "status",
|
id: "status",
|
||||||
header: "Status",
|
header: "Status",
|
||||||
@@ -699,6 +766,11 @@ const FirstMilePage = () => {
|
|||||||
>
|
>
|
||||||
View detail
|
View detail
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
<Menu.Item
|
||||||
|
onClick={() => openDistance(row.original.id)}
|
||||||
|
>
|
||||||
|
Add Actual distance
|
||||||
|
</Menu.Item>
|
||||||
{canPrint && (
|
{canPrint && (
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<Printer size={15} />}
|
leftSection={<Printer size={15} />}
|
||||||
@@ -1003,6 +1075,53 @@ const FirstMilePage = () => {
|
|||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* Add Actual Distance modal */}
|
||||||
|
<Modal
|
||||||
|
opened={distanceOpen}
|
||||||
|
onClose={closeDistance}
|
||||||
|
title={<Text fw={600}>Add Actual Distance</Text>}
|
||||||
|
size="md"
|
||||||
|
radius="lg"
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
{activeRecord && (
|
||||||
|
<Card withBorder padding="md" radius="md" bg="var(--mantine-color-gray-0)">
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Text fw={600} size="sm">{bookingRef(activeRecord)}</Text>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text size="xs" c="dimmed">Customer</Text>
|
||||||
|
<Text size="sm">{customerName(activeRecord)}</Text>
|
||||||
|
</Group>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text size="xs" c="dimmed">Est. Distance (KM)</Text>
|
||||||
|
<Text size="sm">{activeRecord.estimatedKm ?? "—"}</Text>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
<NumberInput
|
||||||
|
label="Actual Distance (KM)"
|
||||||
|
placeholder="Enter distance"
|
||||||
|
value={distanceValue}
|
||||||
|
onChange={(v) => setDistanceValue(String(v ?? ""))}
|
||||||
|
min={0}
|
||||||
|
step={0.1}
|
||||||
|
decimalScale={2}
|
||||||
|
/>
|
||||||
|
<Group justify="flex-end" gap="sm">
|
||||||
|
<Button variant="default" onClick={closeDistance}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSaveDistance}
|
||||||
|
loading={updateDistanceMutation.isPending}
|
||||||
|
disabled={!distanceValue}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ type StatusFilter = "ALL" | LastMileApiStatus | AssignmentStatus;
|
|||||||
|
|
||||||
const FILTER_OPTIONS: { value: StatusFilter; label: string }[] = [
|
const FILTER_OPTIONS: { value: StatusFilter; label: string }[] = [
|
||||||
{ value: "ALL", label: "All" },
|
{ value: "ALL", label: "All" },
|
||||||
...LAST_MILE_STATUSES.map((s) => ({ value: s as StatusFilter, label: STATUS_META[s].label })),
|
...LAST_MILE_STATUSES.filter((s) => s !== "PAYMENT_PENDING").map((s) => ({ value: s as StatusFilter, label: STATUS_META[s].label })),
|
||||||
{ value: "ASSIGNED", label: "Assigned" },
|
{ value: "ASSIGNED", label: "Assigned" },
|
||||||
{ value: "UNASSIGNED", label: "Unassigned" },
|
{ value: "UNASSIGNED", label: "Unassigned" },
|
||||||
];
|
];
|
||||||
@@ -87,14 +87,12 @@ const bookingRef = (r: LastMileRecord) => r.booking?.reference ?? r.bookingId;
|
|||||||
const customerName = (r: LastMileRecord) => r.booking?.company?.name ?? "—";
|
const customerName = (r: LastMileRecord) => r.booking?.company?.name ?? "—";
|
||||||
const deliveryLocation = (r: LastMileRecord) => r.booking?.lastMileDeliveryAddress ?? "—";
|
const deliveryLocation = (r: LastMileRecord) => r.booking?.lastMileDeliveryAddress ?? "—";
|
||||||
const cargoDesc = (r: LastMileRecord) => {
|
const cargoDesc = (r: LastMileRecord) => {
|
||||||
const parts = [r.booking?.cargoType?.name ?? r.booking?.cargoFreeText].filter(Boolean);
|
const parts = [r.booking?.cargoType?.cargoTypeName ?? r.booking?.cargoType?.label ?? r.booking?.cargoType?.name ?? r.booking?.cargoFreeText].filter(Boolean);
|
||||||
if (r.booking?.cargoTotalWeightVgm) parts.push(`${r.booking.cargoTotalWeightVgm} t`);
|
if (r.booking?.cargoTotalWeightVgm) parts.push(`${r.booking.cargoTotalWeightVgm} t`);
|
||||||
return parts.join(" · ") || "—";
|
return parts.join(" · ") || "—";
|
||||||
};
|
};
|
||||||
const priceAmount = (r: LastMileRecord) =>
|
|
||||||
r.booking?.totalAmount ?? r.advancedPayment;
|
|
||||||
const originYardName = (r: LastMileRecord) =>
|
const originYardName = (r: LastMileRecord) =>
|
||||||
r.booking?.originYard?.name ?? "—";
|
r.booking?.originYard?.label ?? r.booking?.originYard?.name ?? "—";
|
||||||
const contactPersonName = (r: LastMileRecord) =>
|
const contactPersonName = (r: LastMileRecord) =>
|
||||||
r.booking?.company?.contactPersonName ?? "—";
|
r.booking?.company?.contactPersonName ?? "—";
|
||||||
const contactPhone = (r: LastMileRecord) =>
|
const contactPhone = (r: LastMileRecord) =>
|
||||||
@@ -104,7 +102,7 @@ const requestedDate = (r: LastMileRecord) => {
|
|||||||
return d ? new Date(d).toISOString().slice(0, 10) : "—";
|
return d ? new Date(d).toISOString().slice(0, 10) : "—";
|
||||||
};
|
};
|
||||||
const serviceTypeName = (r: LastMileRecord) =>
|
const serviceTypeName = (r: LastMileRecord) =>
|
||||||
r.booking?.serviceType?.name ?? "—";
|
r.booking?.serviceType?.label ?? r.booking?.serviceType?.name ?? "—";
|
||||||
|
|
||||||
const InfoRow = ({ label, value }: { label: string; value: string }) => (
|
const InfoRow = ({ label, value }: { label: string; value: string }) => (
|
||||||
<Stack gap={2}>
|
<Stack gap={2}>
|
||||||
@@ -130,14 +128,17 @@ const BookingInfo = ({ record }: { record: LastMileRecord }) => (
|
|||||||
<SimpleGrid cols={2} spacing="sm">
|
<SimpleGrid cols={2} spacing="sm">
|
||||||
<InfoRow label="Customer" value={customerName(record)} />
|
<InfoRow label="Customer" value={customerName(record)} />
|
||||||
<InfoRow label="Service type" value={serviceTypeName(record)} />
|
<InfoRow label="Service type" value={serviceTypeName(record)} />
|
||||||
<InfoRow label="Origin yard" value={originYardName(record)} />
|
<InfoRow label="Pickup (origin yard)" value={originYardName(record)} />
|
||||||
<InfoRow label="Destination" value={deliveryLocation(record)} />
|
<InfoRow label="Destination" value={deliveryLocation(record)} />
|
||||||
<InfoRow label="Cargo" value={cargoDesc(record)} />
|
<InfoRow label="Cargo" value={cargoDesc(record)} />
|
||||||
<InfoRow label="Price" value={formatPrice(priceAmount(record))} />
|
<InfoRow label="Advanced Payment" value={formatPrice(record.advancedPayment)} />
|
||||||
|
<InfoRow label="Post Payment" value={formatPrice(record.remainingPayment)} />
|
||||||
<InfoRow label="Contact" value={contactPersonName(record)} />
|
<InfoRow label="Contact" value={contactPersonName(record)} />
|
||||||
<InfoRow label="Phone" value={contactPhone(record)} />
|
<InfoRow label="Phone" value={contactPhone(record)} />
|
||||||
<InfoRow label="Requested date" value={requestedDate(record)} />
|
<InfoRow label="Requested date" value={requestedDate(record)} />
|
||||||
<InfoRow label="Assigned vehicle" value={vehicleLabel(record) ?? "—"} />
|
<InfoRow label="Assigned vehicle" value={vehicleLabel(record) ?? "—"} />
|
||||||
|
<InfoRow label="Est. Distance (KM)" value={record.estimatedKm != null ? String(record.estimatedKm) : "—"} />
|
||||||
|
<InfoRow label="Actual Distance (KM)" value={record.exactKm != null ? String(record.exactKm) : "—"} />
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -146,13 +147,16 @@ const BookingInfo = ({ record }: { record: LastMileRecord }) => (
|
|||||||
const tripSlipRows = (record: LastMileRecord): [string, string][] => [
|
const tripSlipRows = (record: LastMileRecord): [string, string][] => [
|
||||||
["Customer", customerName(record)],
|
["Customer", customerName(record)],
|
||||||
["Service", serviceTypeName(record)],
|
["Service", serviceTypeName(record)],
|
||||||
["Origin yard", originYardName(record)],
|
["Pickup (origin yard)", originYardName(record)],
|
||||||
["Destination", deliveryLocation(record)],
|
["Destination", deliveryLocation(record)],
|
||||||
["Cargo", cargoDesc(record)],
|
["Cargo", cargoDesc(record)],
|
||||||
["Price", formatPrice(priceAmount(record))],
|
["Advanced Payment", formatPrice(record.advancedPayment)],
|
||||||
|
["Post Payment", formatPrice(record.remainingPayment)],
|
||||||
["Vehicle", vehicleLabel(record) ?? "Unassigned"],
|
["Vehicle", vehicleLabel(record) ?? "Unassigned"],
|
||||||
["Contact", `${contactPersonName(record)} · ${contactPhone(record)}`],
|
["Contact", `${contactPersonName(record)} · ${contactPhone(record)}`],
|
||||||
["Requested date", requestedDate(record)],
|
["Requested date", requestedDate(record)],
|
||||||
|
["Est. Distance (KM)", record.estimatedKm != null ? String(record.estimatedKm) : "—"],
|
||||||
|
["Actual Distance (KM)", record.exactKm != null ? String(record.exactKm) : "—"],
|
||||||
["Status", STATUS_META[record.status].label],
|
["Status", STATUS_META[record.status].label],
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -587,6 +591,12 @@ const LastMilePage = () => {
|
|||||||
meta: { headerClassName, cellClassName },
|
meta: { headerClassName, cellClassName },
|
||||||
cell: ({ row }) => customerName(row.original),
|
cell: ({ row }) => customerName(row.original),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "pickup",
|
||||||
|
header: "Pickup",
|
||||||
|
meta: { headerClassName, cellClassName },
|
||||||
|
cell: ({ row }) => originYardName(row.original),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "destination",
|
id: "destination",
|
||||||
header: "Destination",
|
header: "Destination",
|
||||||
@@ -600,10 +610,16 @@ const LastMilePage = () => {
|
|||||||
cell: ({ row }) => cargoDesc(row.original),
|
cell: ({ row }) => cargoDesc(row.original),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "price",
|
id: "advancedPayment",
|
||||||
header: "Price",
|
header: "Advanced Payment",
|
||||||
meta: { headerClassName, cellClassName },
|
meta: { headerClassName, cellClassName },
|
||||||
cell: ({ row }) => formatPrice(priceAmount(row.original)),
|
cell: ({ row }) => formatPrice(row.original.advancedPayment),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "postPayment",
|
||||||
|
header: "Post Payment",
|
||||||
|
meta: { headerClassName, cellClassName },
|
||||||
|
cell: ({ row }) => formatPrice(row.original.remainingPayment),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "vehicle",
|
id: "vehicle",
|
||||||
@@ -611,6 +627,18 @@ const LastMilePage = () => {
|
|||||||
meta: { headerClassName, cellClassName },
|
meta: { headerClassName, cellClassName },
|
||||||
cell: ({ row }) => vehicleLabel(row.original) ?? <Text c="dimmed">—</Text>,
|
cell: ({ row }) => vehicleLabel(row.original) ?? <Text c="dimmed">—</Text>,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "estimatedKm",
|
||||||
|
header: "Est. Distance (KM)",
|
||||||
|
meta: { headerClassName, cellClassName },
|
||||||
|
cell: ({ row }) => row.original.estimatedKm != null ? row.original.estimatedKm : <Text c="dimmed">—</Text>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "exactKm",
|
||||||
|
header: "Actual Distance (KM)",
|
||||||
|
meta: { headerClassName, cellClassName },
|
||||||
|
cell: ({ row }) => row.original.exactKm != null ? row.original.exactKm : <Text c="dimmed">—</Text>,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "status",
|
id: "status",
|
||||||
header: "Status",
|
header: "Status",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Container as ContainerIcon,
|
Container as ContainerIcon,
|
||||||
Eye,
|
Eye,
|
||||||
|
FileText,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
Navigation,
|
Navigation,
|
||||||
Package,
|
Package,
|
||||||
@@ -56,8 +57,10 @@ import { shouldShowContainerPlacementStep } from "@/components/trainScheduling/s
|
|||||||
import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram";
|
import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram";
|
||||||
import { WagonPlanGrid } from "@/components/trainScheduling/WagonPlanGrid";
|
import { WagonPlanGrid } from "@/components/trainScheduling/WagonPlanGrid";
|
||||||
import { WorkflowRail, WorkflowStep } from "@/components/trainScheduling/WorkflowStep";
|
import { WorkflowRail, WorkflowStep } from "@/components/trainScheduling/WorkflowStep";
|
||||||
|
import { openPdfBlob } from "@/components/warehouses/pdf";
|
||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
|
import { trainSchedulingService } from "@/services/trainScheduling.service";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import type {
|
import type {
|
||||||
ContainerPlacement,
|
ContainerPlacement,
|
||||||
@@ -124,6 +127,12 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
const unassign = useMutation(api.trainScheduling.unassignBooking.mutationOptions());
|
const unassign = useMutation(api.trainScheduling.unassignBooking.mutationOptions());
|
||||||
const finalize = useMutation(api.trainScheduling.finalizeSchedule.mutationOptions());
|
const finalize = useMutation(api.trainScheduling.finalizeSchedule.mutationOptions());
|
||||||
const dispatch = useMutation(api.trainScheduling.dispatchSchedule.mutationOptions());
|
const dispatch = useMutation(api.trainScheduling.dispatchSchedule.mutationOptions());
|
||||||
|
const downloadMarshalling = useMutation({
|
||||||
|
mutationFn: ({ id, direction }: { id: string; direction?: string | null }) =>
|
||||||
|
direction === "EXPORT"
|
||||||
|
? trainSchedulingService.downloadExportLoadListDocument(id)
|
||||||
|
: trainSchedulingService.downloadImportDjiboutiLoadListDocument(id),
|
||||||
|
});
|
||||||
|
|
||||||
const assignedIds = useMemo(
|
const assignedIds = useMemo(
|
||||||
() => (schedule?.bookings ?? []).map((b) => b.id),
|
() => (schedule?.bookings ?? []).map((b) => b.id),
|
||||||
@@ -304,6 +313,33 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
const canDispatch = schedule.status === "SCHEDULED";
|
const canDispatch = schedule.status === "SCHEDULED";
|
||||||
const finalizeStep = hasContainerStep ? 3 : 2;
|
const finalizeStep = hasContainerStep ? 3 : 2;
|
||||||
const canModifyBookings = canEditBookings && !["DISPATCHED", "ARRIVED"].includes(schedule.status);
|
const canModifyBookings = canEditBookings && !["DISPATCHED", "ARRIVED"].includes(schedule.status);
|
||||||
|
const canPrintMarshalling = schedule.direction === "IMPORT" || schedule.direction === "EXPORT";
|
||||||
|
|
||||||
|
const openMarshallingDocument = async () => {
|
||||||
|
const pdfWindow = window.open("", "_blank");
|
||||||
|
try {
|
||||||
|
const blob = await downloadMarshalling.mutateAsync({
|
||||||
|
id: scheduleId,
|
||||||
|
direction: schedule.direction,
|
||||||
|
});
|
||||||
|
const prefix = schedule.direction === "EXPORT" ? "export-marshalling" : "import-marshalling";
|
||||||
|
const filename = `${prefix}-${schedule.trainNumber ?? scheduleId}.pdf`;
|
||||||
|
const opened = openPdfBlob(blob, filename, pdfWindow);
|
||||||
|
toast({
|
||||||
|
title: "Marshalling document ready",
|
||||||
|
description: opened
|
||||||
|
? "The PDF opened in a browser tab for printing or saving."
|
||||||
|
: "The browser blocked the preview tab, so the PDF was downloaded.",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
pdfWindow?.close();
|
||||||
|
toast({
|
||||||
|
title: "Could not open marshalling document",
|
||||||
|
description: parseError(error, "Make sure the train has wagon allocations, then try again."),
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleAssign = async () => {
|
const handleAssign = async () => {
|
||||||
if (!allSelectedIds.length) return;
|
if (!allSelectedIds.length) return;
|
||||||
@@ -766,6 +802,19 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
</Stack>
|
</Stack>
|
||||||
</Group>
|
</Group>
|
||||||
<Group gap="sm">
|
<Group gap="sm">
|
||||||
|
{canPrintMarshalling ? (
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
color="edr-green"
|
||||||
|
radius="lg"
|
||||||
|
size="sm"
|
||||||
|
leftSection={<FileText size={16} />}
|
||||||
|
loading={downloadMarshalling.isPending}
|
||||||
|
onClick={() => void openMarshallingDocument()}
|
||||||
|
>
|
||||||
|
Marshalling PDF
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
{["DISPATCHED", "ARRIVED"].includes(schedule.status) ? (
|
{["DISPATCHED", "ARRIVED"].includes(schedule.status) ? (
|
||||||
<Button
|
<Button
|
||||||
component={Link}
|
component={Link}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
Group,
|
Group,
|
||||||
Loader,
|
Loader,
|
||||||
Modal,
|
Modal,
|
||||||
|
Alert,
|
||||||
Stack,
|
Stack,
|
||||||
Table,
|
Table,
|
||||||
Tabs,
|
Tabs,
|
||||||
@@ -168,7 +169,7 @@ function ExportTrainDetailRows({
|
|||||||
export default function ExportDjiboutiUnloadingQueuePage() {
|
export default function ExportDjiboutiUnloadingQueuePage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { data: trains = [], isLoading } = useExportDjiboutiArrivalQueue();
|
const { data: trains = [], isLoading, isError, error } = useExportDjiboutiArrivalQueue();
|
||||||
const { data: interchangeDocuments = [] } = useInterchangeDocuments({ direction: 'EXPORT' });
|
const { data: interchangeDocuments = [] } = useInterchangeDocuments({ direction: 'EXPORT' });
|
||||||
const autoUnload = useAutoUnloadExportAtDjibouti();
|
const autoUnload = useAutoUnloadExportAtDjibouti();
|
||||||
const generateInterchange = useGenerateInterchangeDocument();
|
const generateInterchange = useGenerateInterchangeDocument();
|
||||||
@@ -272,6 +273,10 @@ export default function ExportDjiboutiUnloadingQueuePage() {
|
|||||||
<Group justify="center" py="xl">
|
<Group justify="center" py="xl">
|
||||||
<Loader />
|
<Loader />
|
||||||
</Group>
|
</Group>
|
||||||
|
) : isError ? (
|
||||||
|
<Alert color="red" variant="light" title="Could not load arrived export trains">
|
||||||
|
{getErrorMessage(error) ?? 'Check your API connection and sign in again.'}
|
||||||
|
</Alert>
|
||||||
) : trains.length === 0 ? (
|
) : trains.length === 0 ? (
|
||||||
<VisualEmptyState
|
<VisualEmptyState
|
||||||
variant="train"
|
variant="train"
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { CheckCircle2, Eye, FileText, Search, XCircle } from 'lucide-react';
|
import { CheckCircle2, Download, Eye, FileText, Printer, Search, XCircle } from 'lucide-react';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
import { PageContainer, PageHeader } from '@/components/page';
|
import { PageContainer, PageHeader } from '@/components/page';
|
||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
useInterchangeDocuments,
|
useInterchangeDocuments,
|
||||||
} from '@/hooks/useInterchangeDocuments';
|
} from '@/hooks/useInterchangeDocuments';
|
||||||
import { useToast } from '@/hooks/use-toast';
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
import { interchangeDocumentsService } from '@/services/interchange-documents.service';
|
||||||
import type { InterchangeDocument, InterchangeDocumentStatus } from '@/types/interchangeDocument';
|
import type { InterchangeDocument, InterchangeDocumentStatus } from '@/types/interchangeDocument';
|
||||||
|
|
||||||
const statusColor: Record<InterchangeDocumentStatus, string> = {
|
const statusColor: Record<InterchangeDocumentStatus, string> = {
|
||||||
@@ -45,6 +46,116 @@ const getErrorMessage = (error: unknown) => {
|
|||||||
return error instanceof Error ? error.message : undefined;
|
return error instanceof Error ? error.message : undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const escapeHtml = (value: unknown) =>
|
||||||
|
String(value ?? '-')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
|
||||||
|
const filenameFor = (document: InterchangeDocument) =>
|
||||||
|
`${document.documentNo || document.id}-interchange-document.html`.replace(/[\\/:*?"<>|]/g, '-');
|
||||||
|
|
||||||
|
const buildPrintableInterchangeHtml = (document: InterchangeDocument) => {
|
||||||
|
const items = document.items ?? [];
|
||||||
|
const rows = items
|
||||||
|
.map(
|
||||||
|
(item, index) => `
|
||||||
|
<tr>
|
||||||
|
<td>${index + 1}</td>
|
||||||
|
<td>${escapeHtml(item.bookingReference ?? item.bookingId?.slice(0, 8))}</td>
|
||||||
|
<td>${escapeHtml(item.itemType)}</td>
|
||||||
|
<td>${escapeHtml(item.containerNumber)}</td>
|
||||||
|
<td>${escapeHtml(item.sealNumber)}</td>
|
||||||
|
<td>${escapeHtml(item.cargoType ?? item.cargoDescription)}</td>
|
||||||
|
<td>${escapeHtml(formatNumber(item.weight))}</td>
|
||||||
|
<td>${escapeHtml(formatNumber(item.quantity))}</td>
|
||||||
|
<td>${escapeHtml(item.wagonNumber)}</td>
|
||||||
|
<td>${escapeHtml(item.conditionStatus)}</td>
|
||||||
|
<td>${escapeHtml(item.damageDescription)}</td>
|
||||||
|
</tr>`,
|
||||||
|
)
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
return `<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>${escapeHtml(document.documentNo)} Interchange Document</title>
|
||||||
|
<style>
|
||||||
|
@page { size: A4 landscape; margin: 14mm; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { font-family: Arial, sans-serif; color: #111827; margin: 0; }
|
||||||
|
.top { display: flex; justify-content: space-between; gap: 24px; border-bottom: 2px solid #111827; padding-bottom: 14px; }
|
||||||
|
h1 { margin: 0; font-size: 24px; }
|
||||||
|
.muted { color: #4b5563; font-size: 12px; }
|
||||||
|
.stamp { border: 2px solid #15803d; color: #15803d; border-radius: 999px; padding: 14px 18px; text-align: center; font-weight: 700; }
|
||||||
|
.grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px 18px; margin: 18px 0; }
|
||||||
|
.field { border-bottom: 1px solid #d1d5db; padding-bottom: 6px; }
|
||||||
|
.label { color: #6b7280; font-size: 10px; text-transform: uppercase; letter-spacing: .04em; }
|
||||||
|
.value { font-size: 13px; font-weight: 700; margin-top: 3px; }
|
||||||
|
table { width: 100%; border-collapse: collapse; margin-top: 12px; font-size: 11px; }
|
||||||
|
th, td { border: 1px solid #d1d5db; padding: 6px; text-align: left; vertical-align: top; }
|
||||||
|
th { background: #f3f4f6; }
|
||||||
|
.signatures { display: grid; grid-template-columns: 1fr 1fr; gap: 40px; margin-top: 34px; }
|
||||||
|
.signature { border-top: 1px solid #111827; padding-top: 8px; min-height: 48px; }
|
||||||
|
.footer { margin-top: 16px; font-size: 10px; color: #6b7280; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="top">
|
||||||
|
<div>
|
||||||
|
<h1>EDR / Djibouti Port Interchange Document</h1>
|
||||||
|
<div class="muted">Official export handover document</div>
|
||||||
|
<div class="muted">Document No: ${escapeHtml(document.documentNo)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stamp">${escapeHtml(document.status)}<br/>SIGNED HANDOVER</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<div class="field"><div class="label">Direction</div><div class="value">${escapeHtml(document.direction)}</div></div>
|
||||||
|
<div class="field"><div class="label">Train No</div><div class="value">${escapeHtml(document.trainNo)}</div></div>
|
||||||
|
<div class="field"><div class="label">Schedule</div><div class="value">${escapeHtml(document.scheduleId)}</div></div>
|
||||||
|
<div class="field"><div class="label">Handover Location</div><div class="value">${escapeHtml(document.handoverLocation)}</div></div>
|
||||||
|
<div class="field"><div class="label">Handover From</div><div class="value">${escapeHtml(document.handoverFrom)}</div></div>
|
||||||
|
<div class="field"><div class="label">Handover To</div><div class="value">${escapeHtml(document.handoverTo)}</div></div>
|
||||||
|
<div class="field"><div class="label">Generated At</div><div class="value">${escapeHtml(formatDate(document.generatedAt))}</div></div>
|
||||||
|
<div class="field"><div class="label">Acknowledged At</div><div class="value">${escapeHtml(formatDate(document.acknowledgedAt))}</div></div>
|
||||||
|
<div class="field"><div class="label">Signed by EDR</div><div class="value">${escapeHtml(document.generatedBy)}</div></div>
|
||||||
|
<div class="field"><div class="label">Signed by Djibouti Port</div><div class="value">${escapeHtml(document.acknowledgedBy)}</div></div>
|
||||||
|
<div class="field"><div class="label">Port Operator</div><div class="value">${escapeHtml(document.portOperatorName)}</div></div>
|
||||||
|
<div class="field"><div class="label">Manifest Ref</div><div class="value">${escapeHtml(document.manifestReference)}</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>Booking</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Container</th>
|
||||||
|
<th>Seal</th>
|
||||||
|
<th>Cargo</th>
|
||||||
|
<th>Weight</th>
|
||||||
|
<th>Qty</th>
|
||||||
|
<th>Wagon</th>
|
||||||
|
<th>Condition</th>
|
||||||
|
<th>Damage / Notes</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>${rows || '<tr><td colspan="11">No items</td></tr>'}</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="signatures">
|
||||||
|
<div class="signature">EDR Representative: ${escapeHtml(document.generatedBy)}</div>
|
||||||
|
<div class="signature">Djibouti Port Operator: ${escapeHtml(document.acknowledgedBy)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="footer">Generated from EDR Freight Management System. Printed on ${escapeHtml(new Date().toLocaleString())}.</div>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
};
|
||||||
|
|
||||||
function DetailField({ label, value }: { label: string; value: ReactNode }) {
|
function DetailField({ label, value }: { label: string; value: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<Stack gap={2}>
|
<Stack gap={2}>
|
||||||
@@ -157,6 +268,37 @@ export default function InterchangeDocumentsPage() {
|
|||||||
const dispute = useDisputeInterchangeDocument();
|
const dispute = useDisputeInterchangeDocument();
|
||||||
const cancel = useCancelInterchangeDocument();
|
const cancel = useCancelInterchangeDocument();
|
||||||
|
|
||||||
|
const getPrintableDocument = async (interchangeDocument: InterchangeDocument) => {
|
||||||
|
if (interchangeDocument.items?.length) return interchangeDocument;
|
||||||
|
return interchangeDocumentsService.getById(interchangeDocument.id).then((response) => response.data);
|
||||||
|
};
|
||||||
|
|
||||||
|
const printDocument = async (interchangeDocument: InterchangeDocument) => {
|
||||||
|
const fullDocument = await getPrintableDocument(interchangeDocument);
|
||||||
|
const win = window.open('', '_blank');
|
||||||
|
if (!win) {
|
||||||
|
toast({ variant: 'destructive', title: 'Pop-up blocked', description: 'Allow pop-ups to print the document.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
win.document.write(buildPrintableInterchangeHtml(fullDocument));
|
||||||
|
win.document.close();
|
||||||
|
win.focus();
|
||||||
|
setTimeout(() => win.print(), 250);
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadDocument = async (interchangeDocument: InterchangeDocument) => {
|
||||||
|
const fullDocument = await getPrintableDocument(interchangeDocument);
|
||||||
|
const blob = new Blob([buildPrintableInterchangeHtml(fullDocument)], { type: 'text/html;charset=utf-8' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filenameFor(fullDocument);
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
const run = async (fn: () => Promise<unknown>, title: string) => {
|
const run = async (fn: () => Promise<unknown>, title: string) => {
|
||||||
try {
|
try {
|
||||||
await fn();
|
await fn();
|
||||||
@@ -169,10 +311,10 @@ export default function InterchangeDocumentsPage() {
|
|||||||
const acknowledgeDocument = (document: InterchangeDocument) => {
|
const acknowledgeDocument = (document: InterchangeDocument) => {
|
||||||
const acknowledgedBy = window.prompt('Acknowledged by');
|
const acknowledgedBy = window.prompt('Acknowledged by');
|
||||||
if (!acknowledgedBy) return;
|
if (!acknowledgedBy) return;
|
||||||
run(
|
run(async () => {
|
||||||
() => acknowledge.mutateAsync({ id: document.id, acknowledgedBy }),
|
const response = await acknowledge.mutateAsync({ id: document.id, acknowledgedBy });
|
||||||
'Interchange document acknowledged',
|
await printDocument(response.data);
|
||||||
);
|
}, 'Interchange document acknowledged');
|
||||||
};
|
};
|
||||||
|
|
||||||
const disputeDocument = (document: InterchangeDocument) => {
|
const disputeDocument = (document: InterchangeDocument) => {
|
||||||
@@ -284,6 +426,28 @@ export default function InterchangeDocumentsPage() {
|
|||||||
Acknowledge
|
Acknowledge
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
|
{document.status === 'ACKNOWLEDGED' ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="compact-xs"
|
||||||
|
color="blue"
|
||||||
|
variant="light"
|
||||||
|
leftSection={<Printer size={14} />}
|
||||||
|
onClick={() => run(() => printDocument(document), 'Print view opened')}
|
||||||
|
>
|
||||||
|
Print
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="compact-xs"
|
||||||
|
color="blue"
|
||||||
|
variant="light"
|
||||||
|
leftSection={<Download size={14} />}
|
||||||
|
onClick={() => run(() => downloadDocument(document), 'Document downloaded')}
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
{document.status !== 'CANCELLED' ? (
|
{document.status !== 'CANCELLED' ? (
|
||||||
<Button
|
<Button
|
||||||
size="compact-xs"
|
size="compact-xs"
|
||||||
|
|||||||
@@ -165,6 +165,8 @@ function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () =>
|
|||||||
const cancel = useMutation(api.warehouses.cancelInvoice.mutationOptions());
|
const cancel = useMutation(api.warehouses.cancelInvoice.mutationOptions());
|
||||||
const gateClear = useMutation(api.warehouses.gateClearance.mutationOptions());
|
const gateClear = useMutation(api.warehouses.gateClearance.mutationOptions());
|
||||||
const [payAmount, setPayAmount] = useState<number | ''>('');
|
const [payAmount, setPayAmount] = useState<number | ''>('');
|
||||||
|
const [driverName, setDriverName] = useState('');
|
||||||
|
const [driverPhone, setDriverPhone] = useState('');
|
||||||
|
|
||||||
const canPay = inv && (inv.status === 'ISSUED' || inv.status === 'PARTIALLY_PAID');
|
const canPay = inv && (inv.status === 'ISSUED' || inv.status === 'PARTIALLY_PAID');
|
||||||
const canGateClear = inv?.status === 'PAID' && Boolean(inv.inventoryId);
|
const canGateClear = inv?.status === 'PAID' && Boolean(inv.inventoryId);
|
||||||
@@ -254,8 +256,18 @@ function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () =>
|
|||||||
const handlePay = async () => {
|
const handlePay = async () => {
|
||||||
if (!inv || !payAmount) return;
|
if (!inv || !payAmount) return;
|
||||||
try {
|
try {
|
||||||
const paidInvoice = await pay.mutateAsync({ id: inv.id, payload: { amount: Number(payAmount), method: 'MANUAL' } });
|
const paidInvoice = await pay.mutateAsync({
|
||||||
|
id: inv.id,
|
||||||
|
payload: {
|
||||||
|
amount: Number(payAmount),
|
||||||
|
method: 'MANUAL',
|
||||||
|
driverName: driverName.trim() || undefined,
|
||||||
|
driverPhone: driverPhone.trim() || undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
setPayAmount('');
|
setPayAmount('');
|
||||||
|
setDriverName('');
|
||||||
|
setDriverPhone('');
|
||||||
if (paidInvoice.status === 'PAID') {
|
if (paidInvoice.status === 'PAID') {
|
||||||
toast({ title: 'Payment recorded', description: 'Downloading receipt, then generating gate clearance and exit paper.' });
|
toast({ title: 'Payment recorded', description: 'Downloading receipt, then generating gate clearance and exit paper.' });
|
||||||
await downloadReceiptPdf(paidInvoice);
|
await downloadReceiptPdf(paidInvoice);
|
||||||
@@ -334,6 +346,18 @@ function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () =>
|
|||||||
onChange={(v) => setPayAmount(v === '' ? '' : Number(v))}
|
onChange={(v) => setPayAmount(v === '' ? '' : Number(v))}
|
||||||
style={{ flex: 1 }}
|
style={{ flex: 1 }}
|
||||||
/>
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Pickup driver"
|
||||||
|
value={driverName}
|
||||||
|
onChange={(e) => setDriverName(e.currentTarget.value)}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Driver phone"
|
||||||
|
value={driverPhone}
|
||||||
|
onChange={(e) => setDriverPhone(e.currentTarget.value)}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
<Button leftSection={<CreditCard size={16} />} loading={pay.isPending} onClick={handlePay}>
|
<Button leftSection={<CreditCard size={16} />} loading={pay.isPending} onClick={handlePay}>
|
||||||
Pay
|
Pay
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -644,6 +644,13 @@ export const api = {
|
|||||||
() => ["warehouse-inventory", "ready-to-load-export"],
|
() => ["warehouse-inventory", "ready-to-load-export"],
|
||||||
),
|
),
|
||||||
|
|
||||||
|
receivedExport: endpoint<void, ReadyToLoadRow[]>(
|
||||||
|
"warehouse-inventory",
|
||||||
|
"received-export",
|
||||||
|
() => warehouseService.receivedExport().then((r) => r.data),
|
||||||
|
() => ["warehouse-inventory", "received-export"],
|
||||||
|
),
|
||||||
|
|
||||||
loadedExport: endpoint<void, ReadyToLoadRow[]>(
|
loadedExport: endpoint<void, ReadyToLoadRow[]>(
|
||||||
"warehouse-inventory",
|
"warehouse-inventory",
|
||||||
"loaded-export",
|
"loaded-export",
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export const firstMileService = {
|
|||||||
list: (pageSize = 1000) =>
|
list: (pageSize = 1000) =>
|
||||||
api.get<FirstMileListResponse>(`${FM.BASE}?pageSize=${pageSize}`),
|
api.get<FirstMileListResponse>(`${FM.BASE}?pageSize=${pageSize}`),
|
||||||
getById: (id: string) => api.get<FirstMileRecord>(FM.BY_ID(id)),
|
getById: (id: string) => api.get<FirstMileRecord>(FM.BY_ID(id)),
|
||||||
update: (id: string, data: { status?: FirstMileApiStatus; vehicleId?: string | null }) =>
|
update: (id: string, data: { status?: FirstMileApiStatus; vehicleId?: string | null; estimatedKm?: number | null; exactKm?: number | null }) =>
|
||||||
api.patch<FirstMileRecord>(FM.BY_ID(id), data),
|
api.patch<FirstMileRecord>(FM.BY_ID(id), data),
|
||||||
accept: (bookingReference: string) =>
|
accept: (bookingReference: string) =>
|
||||||
api.post<FirstMileRecord>(FM.ACCEPT(bookingReference)),
|
api.post<FirstMileRecord>(FM.ACCEPT(bookingReference)),
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import { api as client } from '../auth/http';
|
||||||
|
import { URL_CONSTANTS } from '@/constants/URLS';
|
||||||
|
import { unwrap } from '@/utils/endpoint';
|
||||||
|
import type {
|
||||||
|
AssignCustomsRiskPayload,
|
||||||
|
CreateDjiboutiIncidentPayload,
|
||||||
|
CreateEmptyContainerReturnPayload,
|
||||||
|
DjiboutiIncident,
|
||||||
|
EmptyContainerReturn,
|
||||||
|
ImportCustomsFinalization,
|
||||||
|
ImportOperationActionPayload,
|
||||||
|
RecordDeclarationPayload,
|
||||||
|
UpdateEmptyContainerReturnStatusPayload,
|
||||||
|
UploadImportCustomsDocumentPayload,
|
||||||
|
} from '@/types/importOperations';
|
||||||
|
|
||||||
|
export const importOperationsService = {
|
||||||
|
listIncidents: async (bookingId?: string): Promise<DjiboutiIncident[]> => {
|
||||||
|
const response = await client.get<DjiboutiIncident[]>(
|
||||||
|
URL_CONSTANTS.IMPORT_OPERATIONS.DJIBOUTI_INCIDENTS,
|
||||||
|
{ params: { bookingId } },
|
||||||
|
);
|
||||||
|
return unwrap(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
createIncident: async (
|
||||||
|
payload: CreateDjiboutiIncidentPayload,
|
||||||
|
): Promise<DjiboutiIncident> => {
|
||||||
|
const response = await client.post<DjiboutiIncident>(
|
||||||
|
URL_CONSTANTS.IMPORT_OPERATIONS.DJIBOUTI_INCIDENTS,
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
return unwrap(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
getCustoms: async (bookingId: string): Promise<ImportCustomsFinalization> => {
|
||||||
|
const response = await client.get<ImportCustomsFinalization>(
|
||||||
|
URL_CONSTANTS.IMPORT_OPERATIONS.CUSTOMS(bookingId),
|
||||||
|
);
|
||||||
|
return unwrap(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
uploadCustomsDocument: async (
|
||||||
|
bookingId: string,
|
||||||
|
payload: UploadImportCustomsDocumentPayload,
|
||||||
|
): Promise<ImportCustomsFinalization> => {
|
||||||
|
const response = await client.post<ImportCustomsFinalization>(
|
||||||
|
URL_CONSTANTS.IMPORT_OPERATIONS.CUSTOMS_DOCUMENTS(bookingId),
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
return unwrap(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
recordDeclaration: async (
|
||||||
|
bookingId: string,
|
||||||
|
payload: RecordDeclarationPayload,
|
||||||
|
): Promise<ImportCustomsFinalization> => {
|
||||||
|
const response = await client.post<ImportCustomsFinalization>(
|
||||||
|
URL_CONSTANTS.IMPORT_OPERATIONS.CUSTOMS_DECLARATION(bookingId),
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
return unwrap(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
notifyDutiesTaxes: async (
|
||||||
|
bookingId: string,
|
||||||
|
payload: ImportOperationActionPayload = {},
|
||||||
|
): Promise<ImportCustomsFinalization> => {
|
||||||
|
const response = await client.post<ImportCustomsFinalization>(
|
||||||
|
URL_CONSTANTS.IMPORT_OPERATIONS.CUSTOMS_NOTIFY_DUTIES_TAXES(bookingId),
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
return unwrap(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
markDutiesTaxesPaid: async (
|
||||||
|
bookingId: string,
|
||||||
|
payload: ImportOperationActionPayload = {},
|
||||||
|
): Promise<ImportCustomsFinalization> => {
|
||||||
|
const response = await client.post<ImportCustomsFinalization>(
|
||||||
|
URL_CONSTANTS.IMPORT_OPERATIONS.CUSTOMS_DUTIES_TAXES_PAID(bookingId),
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
return unwrap(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
assignRisk: async (
|
||||||
|
bookingId: string,
|
||||||
|
payload: AssignCustomsRiskPayload,
|
||||||
|
): Promise<ImportCustomsFinalization> => {
|
||||||
|
const response = await client.post<ImportCustomsFinalization>(
|
||||||
|
URL_CONSTANTS.IMPORT_OPERATIONS.CUSTOMS_RISK(bookingId),
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
return unwrap(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
markReleasePermitted: async (
|
||||||
|
bookingId: string,
|
||||||
|
payload: ImportOperationActionPayload = {},
|
||||||
|
): Promise<ImportCustomsFinalization> => {
|
||||||
|
const response = await client.post<ImportCustomsFinalization>(
|
||||||
|
URL_CONSTANTS.IMPORT_OPERATIONS.CUSTOMS_RELEASE_PERMITTED(bookingId),
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
return unwrap(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
listEmptyReturns: async (): Promise<EmptyContainerReturn[]> => {
|
||||||
|
const response = await client.get<EmptyContainerReturn[]>(
|
||||||
|
URL_CONSTANTS.IMPORT_OPERATIONS.EMPTY_CONTAINER_RETURNS,
|
||||||
|
);
|
||||||
|
return unwrap(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
createEmptyReturn: async (
|
||||||
|
payload: CreateEmptyContainerReturnPayload,
|
||||||
|
): Promise<EmptyContainerReturn> => {
|
||||||
|
const response = await client.post<EmptyContainerReturn>(
|
||||||
|
URL_CONSTANTS.IMPORT_OPERATIONS.EMPTY_CONTAINER_RETURNS,
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
return unwrap(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
updateEmptyReturnStatus: async (
|
||||||
|
id: string,
|
||||||
|
payload: UpdateEmptyContainerReturnStatusPayload,
|
||||||
|
): Promise<EmptyContainerReturn> => {
|
||||||
|
const response = await client.post<EmptyContainerReturn>(
|
||||||
|
URL_CONSTANTS.IMPORT_OPERATIONS.EMPTY_CONTAINER_RETURN_STATUS(id),
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
return unwrap(response.data);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -18,10 +18,10 @@ export interface LastMileBooking {
|
|||||||
totalAmount: number;
|
totalAmount: number;
|
||||||
scheduledDate?: string | null;
|
scheduledDate?: string | null;
|
||||||
company?: { id: string; name?: string; phone?: string | null; contactPersonName?: string | null; contactPersonPhone?: string | null } | null;
|
company?: { id: string; name?: string; phone?: string | null; contactPersonName?: string | null; contactPersonPhone?: string | null } | null;
|
||||||
serviceType?: { id: string; name?: string } | null;
|
serviceType?: { id: string; name?: string; label?: string } | null;
|
||||||
originYard?: { id: string; name?: string } | null;
|
originYard?: { id: string; name?: string; label?: string } | null;
|
||||||
destinationYard?: { id: string; name?: string } | null;
|
destinationYard?: { id: string; name?: string; label?: string } | null;
|
||||||
cargoType?: { id: string; name?: string } | null;
|
cargoType?: { id: string; name?: string; label?: string; cargoTypeName?: string } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LastMileVehicle {
|
export interface LastMileVehicle {
|
||||||
@@ -60,7 +60,7 @@ export const lastMileService = {
|
|||||||
list: (pageSize = 1000) =>
|
list: (pageSize = 1000) =>
|
||||||
api.get<LastMileListResponse>(`${LM.BASE}?pageSize=${pageSize}`),
|
api.get<LastMileListResponse>(`${LM.BASE}?pageSize=${pageSize}`),
|
||||||
getById: (id: string) => api.get<LastMileRecord>(LM.BY_ID(id)),
|
getById: (id: string) => api.get<LastMileRecord>(LM.BY_ID(id)),
|
||||||
update: (id: string, data: { status?: LastMileApiStatus; vehicleId?: string | null }) =>
|
update: (id: string, data: { status?: LastMileApiStatus; vehicleId?: string | null; estimatedKm?: number | null; exactKm?: number | null }) =>
|
||||||
api.patch<LastMileRecord>(LM.BY_ID(id), data),
|
api.patch<LastMileRecord>(LM.BY_ID(id), data),
|
||||||
accept: (bookingReference: string) =>
|
accept: (bookingReference: string) =>
|
||||||
api.post<LastMileRecord>(LM.ACCEPT(encodeURIComponent(bookingReference))),
|
api.post<LastMileRecord>(LM.ACCEPT(encodeURIComponent(bookingReference))),
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ import type {
|
|||||||
CreateTrainSchedulePayload,
|
CreateTrainSchedulePayload,
|
||||||
EligibleContainerBookingsResponse,
|
EligibleContainerBookingsResponse,
|
||||||
FreightType,
|
FreightType,
|
||||||
|
ImportDjiboutiActionPayload,
|
||||||
|
ImportDjiboutiLoadList,
|
||||||
|
ImportDjiboutiOperation,
|
||||||
LocomotiveRecord,
|
LocomotiveRecord,
|
||||||
PinWagonsPayload,
|
PinWagonsPayload,
|
||||||
RecordCheckpointPayload,
|
RecordCheckpointPayload,
|
||||||
@@ -21,6 +24,7 @@ import type {
|
|||||||
TrainSchedulePreviewResponse,
|
TrainSchedulePreviewResponse,
|
||||||
TrainSchedulingGlobalRules,
|
TrainSchedulingGlobalRules,
|
||||||
TrainTrackResponse,
|
TrainTrackResponse,
|
||||||
|
UploadImportDjiboutiDocumentPayload,
|
||||||
WagonAllocationAttemptResult,
|
WagonAllocationAttemptResult,
|
||||||
YardOption,
|
YardOption,
|
||||||
} from "@/types/trainScheduling";
|
} from "@/types/trainScheduling";
|
||||||
@@ -261,6 +265,101 @@ export const trainSchedulingService = {
|
|||||||
return unwrap(response.data);
|
return unwrap(response.data);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getImportDjiboutiOperation: async (
|
||||||
|
scheduleId: string,
|
||||||
|
): Promise<ImportDjiboutiOperation> => {
|
||||||
|
const response = await client.get<ImportDjiboutiOperation>(
|
||||||
|
URL_CONSTANTS.TRAIN_SCHEDULING.IMPORT_DJIBOUTI(scheduleId),
|
||||||
|
);
|
||||||
|
return unwrap(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
uploadImportDjiboutiDocument: async (
|
||||||
|
scheduleId: string,
|
||||||
|
payload: UploadImportDjiboutiDocumentPayload,
|
||||||
|
): Promise<ImportDjiboutiOperation> => {
|
||||||
|
const response = await client.post<ImportDjiboutiOperation>(
|
||||||
|
URL_CONSTANTS.TRAIN_SCHEDULING.IMPORT_DJIBOUTI_DOCUMENTS(scheduleId),
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
return unwrap(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
grantImportDjiboutiGatepass: async (
|
||||||
|
scheduleId: string,
|
||||||
|
payload: ImportDjiboutiActionPayload = {},
|
||||||
|
): Promise<ImportDjiboutiOperation> => {
|
||||||
|
const response = await client.post<ImportDjiboutiOperation>(
|
||||||
|
URL_CONSTANTS.TRAIN_SCHEDULING.IMPORT_DJIBOUTI_GATEPASS_GRANTED(scheduleId),
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
return unwrap(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
markImportDjiboutiReadyForLoading: async (
|
||||||
|
scheduleId: string,
|
||||||
|
payload: ImportDjiboutiActionPayload = {},
|
||||||
|
): Promise<ImportDjiboutiOperation> => {
|
||||||
|
const response = await client.post<ImportDjiboutiOperation>(
|
||||||
|
URL_CONSTANTS.TRAIN_SCHEDULING.IMPORT_DJIBOUTI_READY_FOR_LOADING(scheduleId),
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
return unwrap(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
confirmImportDjiboutiLoadedOnTrain: async (
|
||||||
|
scheduleId: string,
|
||||||
|
payload: ImportDjiboutiActionPayload = {},
|
||||||
|
): Promise<ImportDjiboutiOperation> => {
|
||||||
|
const response = await client.post<ImportDjiboutiOperation>(
|
||||||
|
URL_CONSTANTS.TRAIN_SCHEDULING.IMPORT_DJIBOUTI_LOADED_ON_TRAIN(scheduleId),
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
return unwrap(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
departImportFromDjibouti: async (
|
||||||
|
scheduleId: string,
|
||||||
|
payload: ImportDjiboutiActionPayload = {},
|
||||||
|
): Promise<ImportDjiboutiOperation> => {
|
||||||
|
const response = await client.post<ImportDjiboutiOperation>(
|
||||||
|
URL_CONSTANTS.TRAIN_SCHEDULING.IMPORT_DJIBOUTI_DEPART(scheduleId),
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
return unwrap(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
generateImportDjiboutiLoadList: async (
|
||||||
|
scheduleId: string,
|
||||||
|
payload: ImportDjiboutiActionPayload = {},
|
||||||
|
): Promise<ImportDjiboutiLoadList> => {
|
||||||
|
const response = await client.post<ImportDjiboutiLoadList>(
|
||||||
|
URL_CONSTANTS.TRAIN_SCHEDULING.IMPORT_DJIBOUTI_LOAD_LIST(scheduleId),
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
return unwrap(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
downloadImportDjiboutiLoadListDocument: async (
|
||||||
|
scheduleId: string,
|
||||||
|
): Promise<Blob> => {
|
||||||
|
const response = await client.get(
|
||||||
|
URL_CONSTANTS.TRAIN_SCHEDULING.IMPORT_DJIBOUTI_LOAD_LIST_DOCUMENT(scheduleId),
|
||||||
|
{ responseType: "blob" },
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
downloadExportLoadListDocument: async (
|
||||||
|
scheduleId: string,
|
||||||
|
): Promise<Blob> => {
|
||||||
|
const response = await client.get(
|
||||||
|
URL_CONSTANTS.TRAIN_SCHEDULING.EXPORT_LOAD_LIST_DOCUMENT(scheduleId),
|
||||||
|
{ responseType: "blob" },
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
getTrack: async (scheduleId: string): Promise<TrainTrackResponse> => {
|
getTrack: async (scheduleId: string): Promise<TrainTrackResponse> => {
|
||||||
const response = await client.get<TrainTrackResponse>(
|
const response = await client.get<TrainTrackResponse>(
|
||||||
URL_CONSTANTS.TRAIN_SCHEDULING.CHECKPOINTS(scheduleId),
|
URL_CONSTANTS.TRAIN_SCHEDULING.CHECKPOINTS(scheduleId),
|
||||||
|
|||||||
@@ -149,6 +149,8 @@ export const warehouseService = {
|
|||||||
apiClient.post<LoadPassedExportResult>(URL_CONSTANTS.WAREHOUSE_INVENTORY.LOAD_PASSED_EXPORT, {}),
|
apiClient.post<LoadPassedExportResult>(URL_CONSTANTS.WAREHOUSE_INVENTORY.LOAD_PASSED_EXPORT, {}),
|
||||||
bulkMarkInspected: (payload: BulkInspectPayload) =>
|
bulkMarkInspected: (payload: BulkInspectPayload) =>
|
||||||
apiClient.post<BulkInspectResult>(URL_CONSTANTS.WAREHOUSE_INVENTORY.BULK_MARK_INSPECTED, payload),
|
apiClient.post<BulkInspectResult>(URL_CONSTANTS.WAREHOUSE_INVENTORY.BULK_MARK_INSPECTED, payload),
|
||||||
|
receivedExport: () =>
|
||||||
|
apiClient.get<ReadyToLoadRow[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.RECEIVED_EXPORT),
|
||||||
readyToLoadExport: () =>
|
readyToLoadExport: () =>
|
||||||
apiClient.get<ReadyToLoadRow[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.READY_TO_LOAD_EXPORT),
|
apiClient.get<ReadyToLoadRow[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.READY_TO_LOAD_EXPORT),
|
||||||
loadedExport: () =>
|
loadedExport: () =>
|
||||||
|
|||||||
124
apps/edr-freight-web/backoffice/src/types/importOperations.ts
Normal file
124
apps/edr-freight-web/backoffice/src/types/importOperations.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
export type DjiboutiIncidentType =
|
||||||
|
| 'SEAL_BROKEN'
|
||||||
|
| 'CONTAINER_OPENED'
|
||||||
|
| 'CONTAINER_DAMAGED'
|
||||||
|
| 'FLUID_LEAKING'
|
||||||
|
| 'QUANTITY_MISMATCH'
|
||||||
|
| 'WEIGHT_MISMATCH'
|
||||||
|
| 'OTHER';
|
||||||
|
|
||||||
|
export interface DjiboutiIncident {
|
||||||
|
id: string;
|
||||||
|
bookingId: string;
|
||||||
|
containerNumber: string | null;
|
||||||
|
cargoId: string | null;
|
||||||
|
facility: string | null;
|
||||||
|
station: string | null;
|
||||||
|
incidentType: DjiboutiIncidentType;
|
||||||
|
description: string;
|
||||||
|
photos: string[];
|
||||||
|
reportedBy: string | null;
|
||||||
|
reportedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateDjiboutiIncidentPayload {
|
||||||
|
bookingId: string;
|
||||||
|
containerNumber?: string;
|
||||||
|
cargoId?: string;
|
||||||
|
facility?: string;
|
||||||
|
station?: string;
|
||||||
|
incidentType: DjiboutiIncidentType;
|
||||||
|
description: string;
|
||||||
|
photos?: string[];
|
||||||
|
reportedBy?: string;
|
||||||
|
reportedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ImportCustomsDocumentType =
|
||||||
|
| 'IM4'
|
||||||
|
| 'IM5'
|
||||||
|
| 'T1_CLOSURE_PROOF'
|
||||||
|
| 'TRANSIT_PERMIT_SCREENSHOT'
|
||||||
|
| 'CUSTOMER_PAYMENT_SLIP'
|
||||||
|
| 'IMPORT_RELEASE_PERMIT';
|
||||||
|
|
||||||
|
export type ImportCustomsRiskLevel = 'GREEN' | 'YELLOW' | 'BLUE' | 'RED';
|
||||||
|
|
||||||
|
export interface ImportCustomsFinalization {
|
||||||
|
id: string;
|
||||||
|
bookingId: string;
|
||||||
|
documents: Partial<Record<ImportCustomsDocumentType, string>>;
|
||||||
|
declarationSerialNumber: string | null;
|
||||||
|
dutiesTaxesNotifiedAt: string | null;
|
||||||
|
dutiesTaxesPaidAt: string | null;
|
||||||
|
customsRisk: ImportCustomsRiskLevel | null;
|
||||||
|
importReleasePermittedAt: string | null;
|
||||||
|
completedAt: string | null;
|
||||||
|
performedBy: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportOperationActionPayload {
|
||||||
|
performedBy?: string;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadImportCustomsDocumentPayload {
|
||||||
|
documentType: ImportCustomsDocumentType;
|
||||||
|
fileId: string;
|
||||||
|
performedBy?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecordDeclarationPayload {
|
||||||
|
declarationSerialNumber: string;
|
||||||
|
performedBy?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssignCustomsRiskPayload {
|
||||||
|
risk: ImportCustomsRiskLevel;
|
||||||
|
performedBy?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EmptyContainerReturnStatus =
|
||||||
|
| 'RETURNED'
|
||||||
|
| 'ASSIGNED_STORAGE'
|
||||||
|
| 'DOCUMENTATION_CLEARED'
|
||||||
|
| 'WAGON_ALLOCATED'
|
||||||
|
| 'TRANSPORTED_TO_DJIBOUTI'
|
||||||
|
| 'HANDOVER_ISSUED'
|
||||||
|
| 'COMPLETED';
|
||||||
|
|
||||||
|
export interface EmptyContainerReturn {
|
||||||
|
id: string;
|
||||||
|
containerNumber: string;
|
||||||
|
bookingId: string | null;
|
||||||
|
customerId: string | null;
|
||||||
|
returnDate: string;
|
||||||
|
facility: string | null;
|
||||||
|
yard: string | null;
|
||||||
|
zone: string | null;
|
||||||
|
condition: string | null;
|
||||||
|
handoverNote: string | null;
|
||||||
|
status: EmptyContainerReturnStatus;
|
||||||
|
wagonAllocationReference: string | null;
|
||||||
|
performedBy: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateEmptyContainerReturnPayload {
|
||||||
|
containerNumber: string;
|
||||||
|
bookingId?: string;
|
||||||
|
customerId?: string;
|
||||||
|
returnDate?: string;
|
||||||
|
facility?: string;
|
||||||
|
yard?: string;
|
||||||
|
zone?: string;
|
||||||
|
condition?: string;
|
||||||
|
handoverNote?: string;
|
||||||
|
performedBy?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateEmptyContainerReturnStatusPayload extends ImportOperationActionPayload {
|
||||||
|
status: EmptyContainerReturnStatus;
|
||||||
|
wagonAllocationReference?: string;
|
||||||
|
handoverNote?: string;
|
||||||
|
}
|
||||||
@@ -399,6 +399,81 @@ export interface TrainScheduleDetail {
|
|||||||
warnings?: string[];
|
warnings?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportDjiboutiOperation {
|
||||||
|
trainScheduleId: string;
|
||||||
|
trainNumber: string | null;
|
||||||
|
direction: string | null;
|
||||||
|
status: {
|
||||||
|
documentsComplete: boolean;
|
||||||
|
missingDocuments: ImportDjiboutiDocumentType[];
|
||||||
|
gatepassGranted: boolean;
|
||||||
|
readyForLoading: boolean;
|
||||||
|
loadedOnTrain: boolean;
|
||||||
|
departedFromDjibouti: boolean;
|
||||||
|
loadListGenerated: boolean;
|
||||||
|
};
|
||||||
|
documents: Partial<Record<ImportDjiboutiDocumentType, ImportDjiboutiDocumentRecord>>;
|
||||||
|
gatepassGrantedAt: string | null;
|
||||||
|
readyForLoadingAt: string | null;
|
||||||
|
loadedOnTrainAt: string | null;
|
||||||
|
departedFromDjiboutiAt: string | null;
|
||||||
|
loadListGeneratedAt: string | null;
|
||||||
|
performedBy: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadImportDjiboutiDocumentPayload {
|
||||||
|
documentType: ImportDjiboutiDocumentType;
|
||||||
|
fileId?: string;
|
||||||
|
fileUrl?: string;
|
||||||
|
reference?: string;
|
||||||
|
notes?: string;
|
||||||
|
performedBy?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportDjiboutiActionPayload {
|
||||||
|
notes?: string;
|
||||||
|
performedBy?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportDjiboutiLoadList {
|
||||||
|
generatedAt: string;
|
||||||
|
trainScheduleId: string;
|
||||||
|
trainNumber: string | null;
|
||||||
|
route: string | null;
|
||||||
|
origin: string | null;
|
||||||
|
destination: string | null;
|
||||||
|
totalBookings: number;
|
||||||
|
wagons: Array<{
|
||||||
|
sequenceNo: number;
|
||||||
|
wagonNumber: string | null;
|
||||||
|
allocations: Array<{
|
||||||
|
bookingId: string;
|
||||||
|
bookingReference: string | null;
|
||||||
|
loadType: string | null;
|
||||||
|
allocatedWeightTons: number;
|
||||||
|
containerNumbers: string[];
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
|
operation: ImportDjiboutiOperation;
|
||||||
|
}
|
||||||
|
|
||||||
export type TrainCheckpointKind = "DEPARTED" | "PASSED" | "ARRIVED";
|
export type TrainCheckpointKind = "DEPARTED" | "PASSED" | "ARRIVED";
|
||||||
|
|
||||||
export interface TrackStation {
|
export interface TrackStation {
|
||||||
|
|||||||
@@ -341,6 +341,20 @@ export interface ReserveInventoryPayload {
|
|||||||
export interface ReleaseOrderPayload {
|
export interface ReleaseOrderPayload {
|
||||||
reference?: string;
|
reference?: string;
|
||||||
releaseDate?: string;
|
releaseDate?: string;
|
||||||
|
bookingId?: string;
|
||||||
|
customerId?: string;
|
||||||
|
truckPlateNumber?: string;
|
||||||
|
trailerPlateNumber?: string;
|
||||||
|
driverName?: string;
|
||||||
|
driverLicense?: string;
|
||||||
|
driverPhone?: string;
|
||||||
|
truckType?: string;
|
||||||
|
containerNumber?: string;
|
||||||
|
gateInTime?: string;
|
||||||
|
tareWeight?: number;
|
||||||
|
grossWeight?: number;
|
||||||
|
netWeight?: number;
|
||||||
|
gateOutTime?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Import branch: proof of delivery captured on customer pickup. */
|
/** Import branch: proof of delivery captured on customer pickup. */
|
||||||
@@ -356,6 +370,13 @@ export interface EligibleBooking {
|
|||||||
reference: string;
|
reference: string;
|
||||||
customerId: string | null;
|
customerId: string | null;
|
||||||
customer: 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;
|
direction: string;
|
||||||
origin: string | null;
|
origin: string | null;
|
||||||
destination: string | null;
|
destination: string | null;
|
||||||
@@ -370,6 +391,10 @@ export interface EligibleBooking {
|
|||||||
firstMileVehicleId: string | null;
|
firstMileVehicleId: string | null;
|
||||||
firstMileTruckPlateNumber: string | null;
|
firstMileTruckPlateNumber: string | null;
|
||||||
firstMileTrailerPlateNumber: string | null;
|
firstMileTrailerPlateNumber: string | null;
|
||||||
|
firstMileDriverName: string | null;
|
||||||
|
firstMileDriverPhone: string | null;
|
||||||
|
firstMileDriverLicenseNumber: string | null;
|
||||||
|
firstMileTruckType: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BulkReceivePayload {
|
export interface BulkReceivePayload {
|
||||||
@@ -378,7 +403,7 @@ export interface BulkReceivePayload {
|
|||||||
yardId: string;
|
yardId: string;
|
||||||
zoneId: string;
|
zoneId: string;
|
||||||
bookingIds: string[];
|
bookingIds: string[];
|
||||||
truckEntrance: TruckEntrancePayload;
|
truckEntrance?: TruckEntrancePayload;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BulkReceiveResult {
|
export interface BulkReceiveResult {
|
||||||
@@ -392,6 +417,7 @@ export interface TruckEntrancePayload {
|
|||||||
consigneeDetails?: string;
|
consigneeDetails?: string;
|
||||||
edrDigitalBookingId?: string;
|
edrDigitalBookingId?: string;
|
||||||
tin?: string;
|
tin?: string;
|
||||||
|
customerPhone?: string;
|
||||||
truckPlateNumber: string;
|
truckPlateNumber: string;
|
||||||
trailerPlateNumber?: string;
|
trailerPlateNumber?: string;
|
||||||
assignedEquipmentNumber?: string;
|
assignedEquipmentNumber?: string;
|
||||||
@@ -822,6 +848,8 @@ export interface PayInvoicePayload {
|
|||||||
amount: number;
|
amount: number;
|
||||||
method?: string;
|
method?: string;
|
||||||
reference?: string;
|
reference?: string;
|
||||||
|
driverName?: string;
|
||||||
|
driverPhone?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Payloads ───────────────────────────────────────────────────────────────
|
// ── Payloads ───────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -11,97 +11,9 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|||||||
const require = createRequire(import.meta.url);
|
const require = createRequire(import.meta.url);
|
||||||
const streamBrowserifyPath = require.resolve("stream-browserify");
|
const streamBrowserifyPath = require.resolve("stream-browserify");
|
||||||
|
|
||||||
function createIamApiAdapter(apiBaseUrl: string): Plugin {
|
|
||||||
const upstreamBaseUrl = `${apiBaseUrl.replace(/\/+$/, "")}/api`;
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: "iam-api-adapter",
|
|
||||||
configureServer(server) {
|
|
||||||
server.middlewares.use("/um-api", async (req, res) => {
|
|
||||||
const requestPath = req.url ?? "/";
|
|
||||||
const normalizedPath = requestPath.replace(/^\/+/, "");
|
|
||||||
const targetUrl = new URL(normalizedPath, `${upstreamBaseUrl}/`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const headers = new Headers();
|
|
||||||
for (const [key, value] of Object.entries(req.headers)) {
|
|
||||||
if (!value || key.toLowerCase() === "host") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
for (const item of value) {
|
|
||||||
headers.append(key, item);
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
headers.set(key, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
const body =
|
|
||||||
req.method === "GET" || req.method === "HEAD"
|
|
||||||
? undefined
|
|
||||||
: await new Promise<Buffer>((resolve, reject) => {
|
|
||||||
const chunks: Buffer[] = [];
|
|
||||||
req.on("data", (chunk) =>
|
|
||||||
chunks.push(
|
|
||||||
Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
req.on("end", () => resolve(Buffer.concat(chunks)));
|
|
||||||
req.on("error", reject);
|
|
||||||
});
|
|
||||||
|
|
||||||
const upstreamResponse = await fetch(targetUrl, {
|
|
||||||
method: req.method,
|
|
||||||
headers,
|
|
||||||
body,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (targetUrl.pathname.endsWith("/auth/me")) {
|
|
||||||
const payload = await upstreamResponse.json();
|
|
||||||
const unwrappedPayload =
|
|
||||||
payload &&
|
|
||||||
typeof payload === "object" &&
|
|
||||||
"success" in payload &&
|
|
||||||
"data" in payload
|
|
||||||
? payload.data
|
|
||||||
: payload;
|
|
||||||
|
|
||||||
res.statusCode = upstreamResponse.status;
|
|
||||||
res.setHeader("content-type", "application/json; charset=utf-8");
|
|
||||||
res.end(JSON.stringify(unwrappedPayload));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.statusCode = upstreamResponse.status;
|
|
||||||
upstreamResponse.headers.forEach((value, key) => {
|
|
||||||
res.setHeader(key, value);
|
|
||||||
});
|
|
||||||
res.end(Buffer.from(await upstreamResponse.arrayBuffer()));
|
|
||||||
} catch (error) {
|
|
||||||
server.ssrFixStacktrace(error as Error);
|
|
||||||
res.statusCode = 502;
|
|
||||||
res.setHeader("content-type", "application/json; charset=utf-8");
|
|
||||||
res.end(
|
|
||||||
JSON.stringify({
|
|
||||||
message: "Failed to forward IAM request",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default defineConfig(({ mode }) => {
|
export default defineConfig(({ mode }) => {
|
||||||
const env = loadEnv(mode, __dirname, "");
|
|
||||||
const apiBaseUrl =
|
|
||||||
env.VITE_BASE_API_URL?.trim() || "http://localhost:3000";
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
plugins: [react(), tailwindcss(), createIamApiAdapter(apiBaseUrl)],
|
plugins: [react(), tailwindcss()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@": path.resolve(__dirname, "./src"),
|
"@": path.resolve(__dirname, "./src"),
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
"@mantine/core": "^9.3.0",
|
"@mantine/core": "^9.3.0",
|
||||||
"@mantine/hooks": "^9.3.0",
|
"@mantine/hooks": "^9.3.0",
|
||||||
"@tanstack/react-query": "^5.59.0",
|
"@tanstack/react-query": "^5.59.0",
|
||||||
"@tria-plc/iamui": "file:../../../local-packages/tria-plc-iamui-0.0.3.tgz",
|
"@tria-plc/iamui": "file:../../../local-packages/tria-plc-iamui-0.1.1.tgz",
|
||||||
"axios": "^1.7.7",
|
"axios": "^1.7.7",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|||||||
@@ -164,7 +164,9 @@ export default function OnboardingWizardDialog({
|
|||||||
(company?.company?.nationality as CompanyNationality | null) ?? null;
|
(company?.company?.nationality as CompanyNationality | null) ?? null;
|
||||||
|
|
||||||
// Resume position from the backend-persisted step.
|
// Resume position from the backend-persisted step.
|
||||||
const resumeFormStep: FormStep = FORM_STEPS.includes(onboardingStep as FormStep)
|
const resumeFormStep: FormStep = FORM_STEPS.includes(
|
||||||
|
onboardingStep as FormStep,
|
||||||
|
)
|
||||||
? (onboardingStep as FormStep)
|
? (onboardingStep as FormStep)
|
||||||
: "company";
|
: "company";
|
||||||
|
|
||||||
@@ -275,7 +277,7 @@ export default function OnboardingWizardDialog({
|
|||||||
const idx = FORM_STEPS.indexOf(step as FormStep);
|
const idx = FORM_STEPS.indexOf(step as FormStep);
|
||||||
if (idx < 0 || idx <= furthestIdxRef.current) return;
|
if (idx < 0 || idx <= furthestIdxRef.current) return;
|
||||||
furthestIdxRef.current = idx;
|
furthestIdxRef.current = idx;
|
||||||
api.companies.setOnboardingStep.call({ step }).catch(() => {});
|
api.companies.setOnboardingStep.call({ step }).catch(() => { });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Mirror the form's step locally (for the header/pill) and persist it.
|
// Mirror the form's step locally (for the header/pill) and persist it.
|
||||||
@@ -321,7 +323,7 @@ export default function OnboardingWizardDialog({
|
|||||||
|
|
||||||
// Note: no "back to role selection" — once the draft is created the role(s)
|
// Note: no "back to role selection" — once the draft is created the role(s)
|
||||||
// are fixed; the form's first-step Back is a no-op so progress never resets.
|
// are fixed; the form's first-step Back is a no-op so progress never resets.
|
||||||
const handleBackToRoles = useCallback(() => {}, []);
|
const handleBackToRoles = useCallback(() => { }, []);
|
||||||
|
|
||||||
// Save the current step's fields to the draft (PATCH /profile). Returns the
|
// Save the current step's fields to the draft (PATCH /profile). Returns the
|
||||||
// server error message on failure so the form can show it (e.g. duplicate TIN).
|
// server error message on failure so the form can show it (e.g. duplicate TIN).
|
||||||
@@ -339,6 +341,31 @@ export default function OnboardingWizardDialog({
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Auto-upload the documents the user just selected as they leave the documents
|
||||||
|
// step. Only the in-memory selections are sent; once uploaded they're cleared
|
||||||
|
// (so the final submit never re-uploads them) and the requirements query is
|
||||||
|
// refreshed so the "Already uploaded" badges light up. Partial uploads are
|
||||||
|
// allowed — the user may continue even with required docs still outstanding.
|
||||||
|
const handleUploadDocuments = useCallback(async (): Promise<
|
||||||
|
{ ok: true } | { ok: false; error: string }
|
||||||
|
> => {
|
||||||
|
const companyId = company?.company?.id;
|
||||||
|
const hasNew = Object.values(documentFiles).some(
|
||||||
|
(f) => f !== null && (Array.isArray(f) ? f.length > 0 : true),
|
||||||
|
);
|
||||||
|
if (!companyId || !hasNew) return { ok: true };
|
||||||
|
try {
|
||||||
|
await companiesService.uploadDocuments(companyId, documentFiles);
|
||||||
|
setDocumentFiles({});
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: api.companies.onboardingRequirements.queryKey(),
|
||||||
|
});
|
||||||
|
return { ok: true };
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, error: extractApiError(err).message };
|
||||||
|
}
|
||||||
|
}, [company?.company?.id, documentFiles, queryClient]);
|
||||||
|
|
||||||
// Final confirm step → finalize onboarding (no company create; it already
|
// Final confirm step → finalize onboarding (no company create; it already
|
||||||
// exists as a draft that's been filled in step-by-step).
|
// exists as a draft that's been filled in step-by-step).
|
||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
@@ -383,6 +410,25 @@ export default function OnboardingWizardDialog({
|
|||||||
requirementsQuery.data?.documentSettingCode ??
|
requirementsQuery.data?.documentSettingCode ??
|
||||||
documentSettingCode(effectiveNationality);
|
documentSettingCode(effectiveNationality);
|
||||||
|
|
||||||
|
// Server-confirmed document state, used both to badge already-uploaded fields
|
||||||
|
// and to keep a refreshed resume from over-shooting the documents step.
|
||||||
|
const requirementDocuments = requirementsQuery.data?.documents ?? [];
|
||||||
|
const uploadedDocumentKeys = requirementDocuments
|
||||||
|
.filter((d) => d.uploaded)
|
||||||
|
.map((d) => d.fileKey);
|
||||||
|
// If any REQUIRED document is still missing, the resume must not rest past the
|
||||||
|
// documents step (don't skip to Business License) — clamp it back. This only
|
||||||
|
// changes the target once requirements load; the form follows the correction
|
||||||
|
// as long as the user hasn't navigated yet.
|
||||||
|
const requiredDocsMissing = requirementDocuments.some(
|
||||||
|
(d) => d.isRequired && !d.uploaded,
|
||||||
|
);
|
||||||
|
const effectiveResumeStep: FormStep =
|
||||||
|
requiredDocsMissing &&
|
||||||
|
FORM_STEPS.indexOf(resumeFormStep) > FORM_STEPS.indexOf("documents")
|
||||||
|
? "documents"
|
||||||
|
: resumeFormStep;
|
||||||
|
|
||||||
const formProps = {
|
const formProps = {
|
||||||
documentSettingCode: resolvedDocumentSettingCode,
|
documentSettingCode: resolvedDocumentSettingCode,
|
||||||
documentFiles,
|
documentFiles,
|
||||||
@@ -392,7 +438,7 @@ export default function OnboardingWizardDialog({
|
|||||||
isPending: finishMutation.isPending,
|
isPending: finishMutation.isPending,
|
||||||
onBack: handleBackToRoles,
|
onBack: handleBackToRoles,
|
||||||
hideFirstStepBack: true,
|
hideFirstStepBack: true,
|
||||||
initialStep: resumeFormStep,
|
initialStep: effectiveResumeStep,
|
||||||
resyncOpen: opened,
|
resyncOpen: opened,
|
||||||
onStepChange: handleStepChange,
|
onStepChange: handleStepChange,
|
||||||
onSaveStep: saveStep,
|
onSaveStep: saveStep,
|
||||||
@@ -400,6 +446,8 @@ export default function OnboardingWizardDialog({
|
|||||||
roleProfiles,
|
roleProfiles,
|
||||||
licenseFiles,
|
licenseFiles,
|
||||||
onLicenseChange: setLicenseFiles,
|
onLicenseChange: setLicenseFiles,
|
||||||
|
uploadedDocumentKeys,
|
||||||
|
onUploadDocuments: handleUploadDocuments,
|
||||||
// Surface a failed final submit (license/document upload or complete) inside
|
// Surface a failed final submit (license/document upload or complete) inside
|
||||||
// the form — otherwise the server message (e.g. a 500) would be invisible on
|
// the form — otherwise the server message (e.g. a 500) would be invisible on
|
||||||
// the submit step.
|
// the submit step.
|
||||||
@@ -413,7 +461,7 @@ export default function OnboardingWizardDialog({
|
|||||||
withCloseButton={!completed}
|
withCloseButton={!completed}
|
||||||
closeOnClickOutside={false}
|
closeOnClickOutside={false}
|
||||||
closeOnEscape={!completed}
|
closeOnEscape={!completed}
|
||||||
size={720}
|
size={1440}
|
||||||
radius="lg"
|
radius="lg"
|
||||||
padding="xl"
|
padding="xl"
|
||||||
centered
|
centered
|
||||||
@@ -422,11 +470,11 @@ export default function OnboardingWizardDialog({
|
|||||||
overlayProps={{ backgroundOpacity: 0.55, blur: 4 }}
|
overlayProps={{ backgroundOpacity: 0.55, blur: 4 }}
|
||||||
styles={{
|
styles={{
|
||||||
header: {
|
header: {
|
||||||
alignItems:"flex-start"
|
alignItems: "flex-start",
|
||||||
},
|
},
|
||||||
title: {
|
title: {
|
||||||
flex: 1
|
flex: 1,
|
||||||
}
|
},
|
||||||
}}
|
}}
|
||||||
title={
|
title={
|
||||||
completed ? null : (
|
completed ? null : (
|
||||||
@@ -448,59 +496,64 @@ export default function OnboardingWizardDialog({
|
|||||||
{completed ? (
|
{completed ? (
|
||||||
<OnboardingCompletePanel onClose={handleClose} />
|
<OnboardingCompletePanel onClose={handleClose} />
|
||||||
) : (
|
) : (
|
||||||
<Stack gap="xl">
|
<Stack gap="xl">
|
||||||
|
{phase === "nationality" ? (
|
||||||
{phase === "nationality" ? (
|
<Stack gap="lg">
|
||||||
<Stack gap="lg">
|
<NationalitySelect
|
||||||
<NationalitySelect
|
value={nationality}
|
||||||
value={nationality}
|
onChange={setNationality}
|
||||||
onChange={setNationality}
|
embedded
|
||||||
embedded
|
/>
|
||||||
/>
|
<Group justify="flex-end" pt="xs">
|
||||||
<Group justify="flex-end" pt="xs">
|
<Button
|
||||||
<Button
|
color="edr-green"
|
||||||
color="edr-green"
|
onClick={handleNationalityContinue}
|
||||||
onClick={handleNationalityContinue}
|
disabled={!nationality}
|
||||||
disabled={!nationality}
|
rightSection={<ArrowRight size={16} />}
|
||||||
rightSection={<ArrowRight size={16} />}
|
>
|
||||||
>
|
Continue
|
||||||
Continue
|
</Button>
|
||||||
</Button>
|
</Group>
|
||||||
</Group>
|
</Stack>
|
||||||
</Stack>
|
) : phase === "role" ? (
|
||||||
) : phase === "role" ? (
|
<Stack gap="lg">
|
||||||
<Stack gap="lg">
|
<OnboardingRoleSelect
|
||||||
<OnboardingRoleSelect value={roles} onChange={setRoles} embedded />
|
value={roles}
|
||||||
{startError && (
|
onChange={setRoles}
|
||||||
<Text size="sm" c="red">
|
embedded
|
||||||
{startError}
|
/>
|
||||||
</Text>
|
{startError && (
|
||||||
)}
|
<Text size="sm" c="red">
|
||||||
<Group justify="space-between" pt="xs">
|
{startError}
|
||||||
<Button
|
</Text>
|
||||||
variant="default"
|
)}
|
||||||
leftSection={<ArrowLeft size={16} />}
|
<Group justify="space-between" pt="xs">
|
||||||
onClick={() => setPhase("nationality")}
|
<Button
|
||||||
>
|
variant="default"
|
||||||
Back
|
leftSection={<ArrowLeft size={16} />}
|
||||||
</Button>
|
onClick={() => setPhase("nationality")}
|
||||||
<Button
|
>
|
||||||
color="edr-green"
|
Back
|
||||||
onClick={handleRolesContinue}
|
</Button>
|
||||||
disabled={!rolesValid}
|
<Button
|
||||||
loading={startMutation.isPending}
|
color="edr-green"
|
||||||
rightSection={
|
onClick={handleRolesContinue}
|
||||||
startMutation.isPending ? undefined : <ArrowRight size={16} />
|
disabled={!rolesValid}
|
||||||
}
|
loading={startMutation.isPending}
|
||||||
>
|
rightSection={
|
||||||
Continue
|
startMutation.isPending ? undefined : (
|
||||||
</Button>
|
<ArrowRight size={16} />
|
||||||
</Group>
|
)
|
||||||
</Stack>
|
}
|
||||||
) : (
|
>
|
||||||
<CompanyProfileForm {...formProps} />
|
Continue
|
||||||
)}
|
</Button>
|
||||||
</Stack>
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<CompanyProfileForm {...formProps} />
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
@@ -518,7 +571,10 @@ function OnboardingCompletePanel({ onClose }: { onClose: () => void }) {
|
|||||||
className="flex h-16 w-16 items-center justify-center rounded-full"
|
className="flex h-16 w-16 items-center justify-center rounded-full"
|
||||||
style={{ background: "var(--mantine-color-edr-green-1)" }}
|
style={{ background: "var(--mantine-color-edr-green-1)" }}
|
||||||
>
|
>
|
||||||
<PartyPopper size={32} className="text-[var(--mantine-color-edr-green-7)]" />
|
<PartyPopper
|
||||||
|
size={32}
|
||||||
|
className="text-[var(--mantine-color-edr-green-7)]"
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box>
|
<Box>
|
||||||
@@ -538,14 +594,20 @@ function OnboardingCompletePanel({ onClose }: { onClose: () => void }) {
|
|||||||
style={{ background: "var(--mantine-color-edr-green-0)" }}
|
style={{ background: "var(--mantine-color-edr-green-0)" }}
|
||||||
>
|
>
|
||||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||||
<Clock size={18} className="mt-0.5 shrink-0 text-[var(--mantine-color-edr-green-7)]" />
|
<Clock
|
||||||
|
size={18}
|
||||||
|
className="mt-0.5 shrink-0 text-[var(--mantine-color-edr-green-7)]"
|
||||||
|
/>
|
||||||
<Text size="sm" ta="left">
|
<Text size="sm" ta="left">
|
||||||
Each operational profile (importer, exporter, freight forwarder) is
|
Each operational profile (importer, exporter, freight forwarder) is
|
||||||
reviewed and approved individually.
|
reviewed and approved individually.
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||||
<ShieldCheck size={18} className="mt-0.5 shrink-0 text-[var(--mantine-color-edr-green-7)]" />
|
<ShieldCheck
|
||||||
|
size={18}
|
||||||
|
className="mt-0.5 shrink-0 text-[var(--mantine-color-edr-green-7)]"
|
||||||
|
/>
|
||||||
<Text size="sm" ta="left">
|
<Text size="sm" ta="left">
|
||||||
You can start creating bookings under a profile as soon as it's
|
You can start creating bookings under a profile as soon as it's
|
||||||
approved — we'll let you know the moment that happens.
|
approved — we'll let you know the moment that happens.
|
||||||
|
|||||||
@@ -1,14 +1,8 @@
|
|||||||
import {
|
import { Anchor, Group, Stack, Text } from "@mantine/core";
|
||||||
Anchor,
|
import { Paperclip } from "lucide-react";
|
||||||
Badge,
|
|
||||||
Card,
|
import { SmartFileInput } from "@edr/ui-common";
|
||||||
FileInput,
|
import type { IFileUploadSetting } from "@edr/types/freight";
|
||||||
Group,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
ThemeIcon,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import { FileText, Paperclip, Upload } from "lucide-react";
|
|
||||||
|
|
||||||
import type { LicenseFile } from "@/services/companies.service";
|
import type { LicenseFile } from "@/services/companies.service";
|
||||||
|
|
||||||
@@ -20,6 +14,48 @@ const ROLE_LABELS: Record<string, string> = {
|
|||||||
transporter: "Transporter",
|
transporter: "Transporter",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Field key the synthesized per-profile upload setting is keyed on. */
|
||||||
|
const LICENSE_FILE_KEY = "business_license";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a single-field upload setting so each profile's license input can reuse
|
||||||
|
* the shared SmartFileInput (same dropzone + "uploaded" state as the documents
|
||||||
|
* step), instead of a bespoke file picker.
|
||||||
|
*/
|
||||||
|
function buildLicenseSetting(
|
||||||
|
profileId: string,
|
||||||
|
profileName: string,
|
||||||
|
): IFileUploadSetting {
|
||||||
|
return {
|
||||||
|
id: `license-setting-${profileId}`,
|
||||||
|
createdAt: "",
|
||||||
|
updatedAt: "",
|
||||||
|
deletedAt: null,
|
||||||
|
code: "business_license",
|
||||||
|
label: "Business license",
|
||||||
|
description: null,
|
||||||
|
entity: "customer",
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
id: `${LICENSE_FILE_KEY}-${profileId}`,
|
||||||
|
createdAt: "",
|
||||||
|
updatedAt: "",
|
||||||
|
deletedAt: null,
|
||||||
|
settingId: `license-setting-${profileId}`,
|
||||||
|
fileKey: LICENSE_FILE_KEY,
|
||||||
|
fileLabel: `Upload ${profileName} Business license file(s)`,
|
||||||
|
helpText: null,
|
||||||
|
isRequired: true,
|
||||||
|
isMultiple: true,
|
||||||
|
maxFiles: 10,
|
||||||
|
allowedExtensions: ["pdf", "png", "jpg", "jpeg"],
|
||||||
|
maxSizeMb: 10,
|
||||||
|
order: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export interface RoleLicenseProfile {
|
export interface RoleLicenseProfile {
|
||||||
id: string;
|
id: string;
|
||||||
type: string;
|
type: string;
|
||||||
@@ -38,8 +74,9 @@ interface RoleLicenseStepProps {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Final onboarding step: collect a business license (one or more files) for
|
* Final onboarding step: collect a business license (one or more files) for
|
||||||
* each operational role the company holds. Each role gets its own multi-file
|
* each operational role the company holds. Each role gets its own SmartFileInput
|
||||||
* input; already-uploaded files are listed for context.
|
* dropzone; already-uploaded files are listed (with download links) for context
|
||||||
|
* and surface the input's "uploaded" state.
|
||||||
*/
|
*/
|
||||||
export default function RoleLicenseStep({
|
export default function RoleLicenseStep({
|
||||||
profiles,
|
profiles,
|
||||||
@@ -60,37 +97,11 @@ export default function RoleLicenseStep({
|
|||||||
{profiles.map((profile) => {
|
{profiles.map((profile) => {
|
||||||
const label = ROLE_LABELS[profile.type] ?? profile.type;
|
const label = ROLE_LABELS[profile.type] ?? profile.type;
|
||||||
const selected = value[profile.id] ?? [];
|
const selected = value[profile.id] ?? [];
|
||||||
const hasAny = selected.length > 0 || profile.existingFiles.length > 0;
|
const hasExisting = profile.existingFiles.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card key={profile.id} padding="lg" withBorder>
|
<>
|
||||||
<Group justify="space-between" mb="sm" wrap="nowrap">
|
{hasExisting && (
|
||||||
<Group gap="sm" wrap="nowrap">
|
|
||||||
<ThemeIcon
|
|
||||||
size={40}
|
|
||||||
radius="md"
|
|
||||||
variant="light"
|
|
||||||
color="edr-green"
|
|
||||||
>
|
|
||||||
<FileText size={20} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<div>
|
|
||||||
<Text fw={700} c="edr-text" fz={15}>
|
|
||||||
{label} — Business License
|
|
||||||
</Text>
|
|
||||||
<Text size="xs" c="edr-muted" ff="monospace">
|
|
||||||
{profile.reference}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</Group>
|
|
||||||
{hasAny && (
|
|
||||||
<Badge color="edr-green" variant="light">
|
|
||||||
Provided
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
{profile.existingFiles.length > 0 && (
|
|
||||||
<Stack gap={4} mb="sm">
|
<Stack gap={4} mb="sm">
|
||||||
{profile.existingFiles.map((f) => (
|
{profile.existingFiles.map((f) => (
|
||||||
<Group key={f.url} gap={6} wrap="nowrap">
|
<Group key={f.url} gap={6} wrap="nowrap">
|
||||||
@@ -108,20 +119,17 @@ export default function RoleLicenseStep({
|
|||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<FileInput
|
<SmartFileInput
|
||||||
multiple
|
file={buildLicenseSetting(profile.id, label)}
|
||||||
clearable
|
value={{ [LICENSE_FILE_KEY]: selected }}
|
||||||
accept="application/pdf,image/png,image/jpeg"
|
uploadedKeys={hasExisting ? [LICENSE_FILE_KEY] : undefined}
|
||||||
leftSection={<Upload size={16} />}
|
onChange={(v) => {
|
||||||
placeholder={
|
const next = v[LICENSE_FILE_KEY];
|
||||||
profile.existingFiles.length > 0
|
const files = Array.isArray(next) ? next : next ? [next] : [];
|
||||||
? "Upload more / replace files"
|
setFiles(profile.id, files);
|
||||||
: "Select license file(s)"
|
}}
|
||||||
}
|
|
||||||
value={selected}
|
|
||||||
onChange={(files) => setFiles(profile.id, files ?? [])}
|
|
||||||
/>
|
/>
|
||||||
</Card>
|
</>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
|
export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
|
||||||
// export const API_BASE_URL = 'http://localhost:3001';
|
//export const API_BASE_URL = 'http://localhost:3001';
|
||||||
|
|||||||
@@ -18,23 +18,17 @@ import {
|
|||||||
ArrowRight,
|
ArrowRight,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
RotateCw,
|
RotateCw,
|
||||||
ShieldCheck,
|
|
||||||
Smartphone,
|
Smartphone,
|
||||||
UserCheck,
|
UserCheck,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
import type { AuthUser } from "@/types/auth";
|
import type { AuthUser } from "@/types/auth";
|
||||||
import type { CreateCompanyPayload } from "@/services/companies.service";
|
import type { CreateCompanyPayload } from "@/services/companies.service";
|
||||||
import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
|
import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
|
||||||
import type { CompanyRegistrationData } from "@edr/types";
|
import type { CompanyRegistrationData } from "@edr/types";
|
||||||
import {
|
import { ControlledPhoneField, toEthiopianE164 } from "@/components/PhoneField";
|
||||||
ControlledPhoneField,
|
|
||||||
isValidPhone,
|
|
||||||
toEthiopianE164,
|
|
||||||
} from "@/components/PhoneField";
|
|
||||||
import { SmartFileInput } from "@edr/ui-common";
|
import { SmartFileInput } from "@edr/ui-common";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import RoleLicenseStep, {
|
import RoleLicenseStep, {
|
||||||
@@ -42,261 +36,21 @@ import RoleLicenseStep, {
|
|||||||
} from "@/components/onboarding/RoleLicenseStep";
|
} from "@/components/onboarding/RoleLicenseStep";
|
||||||
import ETradeInfo from "@/components/onboarding/ETradeInfo";
|
import ETradeInfo from "@/components/onboarding/ETradeInfo";
|
||||||
import { extractApiError } from "@/utils/result";
|
import { extractApiError } from "@/utils/result";
|
||||||
|
import {
|
||||||
type CompanyStep =
|
type CompanyStep,
|
||||||
| "company"
|
type FormData,
|
||||||
| "personnel"
|
onboardingSchema,
|
||||||
| "contact"
|
stepFields,
|
||||||
| "verify"
|
} from "./companyProfileForm/schema";
|
||||||
| "poa"
|
import {
|
||||||
| "documents"
|
buildPayload,
|
||||||
| "additional";
|
maskPhone,
|
||||||
|
samePhone,
|
||||||
/** Last 9 digits (Ethiopian national significant number) for tolerant compare. */
|
stepPayload,
|
||||||
const phoneDigits = (p?: string | null) => (p ?? "").replace(/\D/g, "").slice(-9);
|
toFormValues,
|
||||||
const samePhone = (a?: string | null, b?: string | null) => {
|
} from "./companyProfileForm/helpers";
|
||||||
const da = phoneDigits(a);
|
import { LinkCheckboxCard } from "./companyProfileForm/LinkCheckboxCard";
|
||||||
return da.length === 9 && da === phoneDigits(b);
|
import { ReadOnlyField } from "./companyProfileForm/ReadOnlyField";
|
||||||
};
|
|
||||||
/** Mask all but the first 7 chars of an E.164 phone for display. */
|
|
||||||
const maskPhone = (p: string) =>
|
|
||||||
p.length > 4 ? `${p.slice(0, 7)}${"*".repeat(Math.max(0, p.length - 7))}` : p;
|
|
||||||
|
|
||||||
const onboardingSchema = z.object({
|
|
||||||
companyName: z.string().min(1, "Company name is required"),
|
|
||||||
companyEmail: z.string().email("Invalid email address"),
|
|
||||||
companyPhone: z
|
|
||||||
.string()
|
|
||||||
.min(1, "Company phone is required")
|
|
||||||
.refine(isValidPhone, "Enter a valid phone number"),
|
|
||||||
companyLocation: z.string().min(1, "Location is required"),
|
|
||||||
// Derived from the eTrade address parts (kebele/woreda/zone/region); no
|
|
||||||
// standalone input — the granular fields live in the registration section.
|
|
||||||
companyAddress: z.string().optional(),
|
|
||||||
tinNumber: z.string().length(10, "TIN must be exactly 10 digits"),
|
|
||||||
vatNumber: z
|
|
||||||
.string()
|
|
||||||
.min(1, "VAT number is required")
|
|
||||||
.length(10, "VAT number must be exactly 10 digits"),
|
|
||||||
fanNumber: z.string().length(16, "FAN must be exactly 16 digits"),
|
|
||||||
licenceNumber: z.string().optional(),
|
|
||||||
statusDescription: z.string().optional(),
|
|
||||||
dateRegistered: z.string().optional(),
|
|
||||||
renewedFrom: z.string().optional(),
|
|
||||||
renewalDate: z.string().optional(),
|
|
||||||
renewedTo: z.string().optional(),
|
|
||||||
// Address fields are user-entered and required (the registration/license
|
|
||||||
// fields above are read-only confirmations pulled from eTrade).
|
|
||||||
region: z.string().min(1, "Region is required"),
|
|
||||||
zone: z.string().min(1, "Zone is required"),
|
|
||||||
woreda: z.string().min(1, "Woreda is required"),
|
|
||||||
kebele: z.string().min(1, "Kebele is required"),
|
|
||||||
houseNo: z.string().min(1, "House number is required"),
|
|
||||||
etradePhone: z.string().optional(),
|
|
||||||
contactPersonName: z.string().min(1, "Contact person name is required"),
|
|
||||||
contactPersonPosition: z.string().optional(),
|
|
||||||
contactPersonEmail: z
|
|
||||||
.string()
|
|
||||||
.email("Invalid email address")
|
|
||||||
.optional()
|
|
||||||
.or(z.literal("")),
|
|
||||||
contactPersonPhone: z
|
|
||||||
.string()
|
|
||||||
.min(1, "Contact person phone is required")
|
|
||||||
.refine(isValidPhone, "Enter a valid phone number"),
|
|
||||||
generalManagerName: z.string().min(1, "Manager name is required"),
|
|
||||||
generalManagerEmail: z.string().email("Invalid Manager email"),
|
|
||||||
generalManagerPhone: z
|
|
||||||
.string()
|
|
||||||
.min(1, "Manager phone is required")
|
|
||||||
.refine(isValidPhone, "Enter a valid phone number"),
|
|
||||||
poaName: z.string().optional(),
|
|
||||||
poaPhone: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.refine((v) => !v || isValidPhone(v), "Enter a valid phone number"),
|
|
||||||
poaAddress: z.string().optional(),
|
|
||||||
poaEmail: z.string().optional(),
|
|
||||||
poaLocation: z.string().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
type FormData = z.infer<typeof onboardingSchema>;
|
|
||||||
|
|
||||||
const stepFields: Record<CompanyStep, (keyof FormData)[]> = {
|
|
||||||
company: [
|
|
||||||
"companyName",
|
|
||||||
"companyEmail",
|
|
||||||
"companyPhone",
|
|
||||||
"companyLocation",
|
|
||||||
"companyAddress",
|
|
||||||
"tinNumber",
|
|
||||||
"vatNumber",
|
|
||||||
"fanNumber",
|
|
||||||
"licenceNumber",
|
|
||||||
"statusDescription",
|
|
||||||
"dateRegistered",
|
|
||||||
"renewedFrom",
|
|
||||||
"renewalDate",
|
|
||||||
"renewedTo",
|
|
||||||
"region",
|
|
||||||
"zone",
|
|
||||||
"woreda",
|
|
||||||
"kebele",
|
|
||||||
"houseNo",
|
|
||||||
"etradePhone",
|
|
||||||
],
|
|
||||||
personnel: [
|
|
||||||
"generalManagerName",
|
|
||||||
"generalManagerEmail",
|
|
||||||
"generalManagerPhone",
|
|
||||||
],
|
|
||||||
contact: [
|
|
||||||
"contactPersonName",
|
|
||||||
"contactPersonPosition",
|
|
||||||
"contactPersonEmail",
|
|
||||||
"contactPersonPhone",
|
|
||||||
],
|
|
||||||
verify: [],
|
|
||||||
poa: [],
|
|
||||||
documents: [],
|
|
||||||
additional: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload {
|
|
||||||
return {
|
|
||||||
companyName: data.companyName,
|
|
||||||
companyEmail: data.companyEmail,
|
|
||||||
companyPhone: data.companyPhone,
|
|
||||||
companyLocation: data.companyLocation,
|
|
||||||
companyAddress: data.companyAddress,
|
|
||||||
tin: data.tinNumber,
|
|
||||||
vatNumber: data.vatNumber,
|
|
||||||
fanNumber: data.fanNumber,
|
|
||||||
attributes: {
|
|
||||||
contactPersonName: data.contactPersonName,
|
|
||||||
contactPersonPosition: data.contactPersonPosition || undefined,
|
|
||||||
contactPersonEmail: data.contactPersonEmail || undefined,
|
|
||||||
contactPersonPhone: data.contactPersonPhone,
|
|
||||||
generalManagerName: data.generalManagerName,
|
|
||||||
generalManagerEmail: data.generalManagerEmail,
|
|
||||||
generalManagerPhone: data.generalManagerPhone,
|
|
||||||
poaName: data.poaName || undefined,
|
|
||||||
poaPhone: data.poaPhone || undefined,
|
|
||||||
poaAddress: data.poaAddress || undefined,
|
|
||||||
poaEmail: data.poaEmail || undefined,
|
|
||||||
poaLocation: data.poaLocation || undefined,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Map one wizard step's form values to the profile-update payload it saves. */
|
|
||||||
function stepPayload(
|
|
||||||
step: CompanyStep,
|
|
||||||
d: FormData,
|
|
||||||
): Partial<UpdateProfilePayload> {
|
|
||||||
switch (step) {
|
|
||||||
case "company":
|
|
||||||
return {
|
|
||||||
companyName: d.companyName,
|
|
||||||
companyEmail: d.companyEmail,
|
|
||||||
companyPhone: d.companyPhone,
|
|
||||||
companyLocation: d.companyLocation,
|
|
||||||
companyAddress: d.companyAddress,
|
|
||||||
tin: d.tinNumber,
|
|
||||||
vatNumber: d.vatNumber,
|
|
||||||
fanNumber: d.fanNumber,
|
|
||||||
licenceNumber: d.licenceNumber,
|
|
||||||
statusDescription: d.statusDescription,
|
|
||||||
dateRegistered: d.dateRegistered,
|
|
||||||
renewedFrom: d.renewedFrom,
|
|
||||||
renewalDate: d.renewalDate,
|
|
||||||
renewedTo: d.renewedTo,
|
|
||||||
region: d.region,
|
|
||||||
zone: d.zone,
|
|
||||||
woreda: d.woreda,
|
|
||||||
kebele: d.kebele,
|
|
||||||
houseNo: d.houseNo,
|
|
||||||
etradePhone: d.etradePhone,
|
|
||||||
};
|
|
||||||
case "personnel":
|
|
||||||
return {
|
|
||||||
generalManagerName: d.generalManagerName,
|
|
||||||
generalManagerEmail: d.generalManagerEmail,
|
|
||||||
generalManagerPhone: d.generalManagerPhone,
|
|
||||||
};
|
|
||||||
case "contact":
|
|
||||||
return {
|
|
||||||
contactPersonName: d.contactPersonName,
|
|
||||||
contactPersonPosition: d.contactPersonPosition || undefined,
|
|
||||||
contactPersonEmail: d.contactPersonEmail || undefined,
|
|
||||||
contactPersonPhone: d.contactPersonPhone,
|
|
||||||
};
|
|
||||||
case "poa":
|
|
||||||
return {
|
|
||||||
poaName: d.poaName || undefined,
|
|
||||||
poaPhone: d.poaPhone || undefined,
|
|
||||||
poaEmail: d.poaEmail || undefined,
|
|
||||||
poaLocation: d.poaLocation || undefined,
|
|
||||||
poaAddress: d.poaAddress || undefined,
|
|
||||||
};
|
|
||||||
default:
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Seed the form from previously-saved profile data. */
|
|
||||||
function toFormValues(p: ProfileResponse): FormData {
|
|
||||||
// The draft placeholder TIN ("D…") shouldn't show as a real value.
|
|
||||||
const tin = p.tinNumber && !p.tinNumber.startsWith("D") ? p.tinNumber : "";
|
|
||||||
return {
|
|
||||||
companyName: p.companyName ?? "",
|
|
||||||
companyEmail: p.companyEmail ?? "",
|
|
||||||
companyPhone: p.companyPhone ?? "",
|
|
||||||
companyLocation: p.companyLocation ?? "",
|
|
||||||
companyAddress: p.companyAddress ?? "",
|
|
||||||
tinNumber: tin,
|
|
||||||
vatNumber: p.vatNumber ?? "",
|
|
||||||
fanNumber: p.fanNumber ?? "",
|
|
||||||
licenceNumber: p.licenceNumber ?? "",
|
|
||||||
statusDescription: p.statusDescription ?? "",
|
|
||||||
dateRegistered: p.dateRegistered ?? "",
|
|
||||||
renewedFrom: p.renewedFrom ?? "",
|
|
||||||
renewalDate: p.renewalDate ?? "",
|
|
||||||
renewedTo: p.renewedTo ?? "",
|
|
||||||
region: p.region ?? "",
|
|
||||||
zone: p.zone ?? "",
|
|
||||||
woreda: p.woreda ?? "",
|
|
||||||
kebele: p.kebele ?? "",
|
|
||||||
houseNo: p.houseNo ?? "",
|
|
||||||
etradePhone: p.etradePhone ?? "",
|
|
||||||
contactPersonName: p.contactPersonName ?? "",
|
|
||||||
contactPersonPosition: p.contactPersonPosition ?? "",
|
|
||||||
contactPersonEmail: p.contactPersonEmail ?? "",
|
|
||||||
contactPersonPhone: p.contactPersonPhone ?? "",
|
|
||||||
generalManagerName: p.generalManagerName ?? "",
|
|
||||||
generalManagerEmail: p.generalManagerEmail ?? "",
|
|
||||||
generalManagerPhone: p.generalManagerPhone ?? "",
|
|
||||||
poaName: p.poaName ?? "",
|
|
||||||
poaPhone: p.poaPhone ?? "",
|
|
||||||
poaAddress: p.poaAddress ?? "",
|
|
||||||
poaEmail: p.poaEmail ?? "",
|
|
||||||
poaLocation: p.poaLocation ?? "",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A single read-only registration value rendered as a label/value pair. */
|
|
||||||
function ReadOnlyField({ label, value }: { label: string; value?: string }) {
|
|
||||||
return (
|
|
||||||
<Stack gap={2}>
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{label}
|
|
||||||
</Text>
|
|
||||||
<Text size="sm" c="edr-text" fw={500}>
|
|
||||||
{value && value.trim() ? value : "—"}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function CompanyProfileForm({
|
export default function CompanyProfileForm({
|
||||||
documentSettingCode,
|
documentSettingCode,
|
||||||
@@ -316,6 +70,8 @@ export default function CompanyProfileForm({
|
|||||||
licenseFiles,
|
licenseFiles,
|
||||||
onLicenseChange,
|
onLicenseChange,
|
||||||
submitError,
|
submitError,
|
||||||
|
uploadedDocumentKeys,
|
||||||
|
onUploadDocuments,
|
||||||
}: {
|
}: {
|
||||||
documentSettingCode: string;
|
documentSettingCode: string;
|
||||||
documentFiles?: Record<string, File | File[] | null>;
|
documentFiles?: Record<string, File | File[] | null>;
|
||||||
@@ -345,6 +101,16 @@ export default function CompanyProfileForm({
|
|||||||
onLicenseChange?: (value: Record<string, File[]>) => void;
|
onLicenseChange?: (value: Record<string, File[]>) => void;
|
||||||
/** Server error from the final submit (uploads/complete), shown verbatim. */
|
/** Server error from the final submit (uploads/complete), shown verbatim. */
|
||||||
submitError?: string | null;
|
submitError?: string | null;
|
||||||
|
/** fileKeys whose company document is already uploaded server-side (resume). */
|
||||||
|
uploadedDocumentKeys?: string[];
|
||||||
|
/**
|
||||||
|
* Auto-upload the currently-selected company documents (the Documents step's
|
||||||
|
* "Continue" action). Resolves to an error message string on failure so the
|
||||||
|
* step can surface it and hold the user in place.
|
||||||
|
*/
|
||||||
|
onUploadDocuments?: () => Promise<
|
||||||
|
{ ok: true } | { ok: false; error: string }
|
||||||
|
>;
|
||||||
}) {
|
}) {
|
||||||
const [step, setStep] = useState<CompanyStep>(initialStep ?? "company");
|
const [step, setStep] = useState<CompanyStep>(initialStep ?? "company");
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@@ -356,17 +122,40 @@ export default function CompanyProfileForm({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [step]);
|
}, [step]);
|
||||||
|
|
||||||
|
// Tracks whether the user has manually navigated the form this session. While
|
||||||
|
// false, the form still follows the parent's resume target (initialStep) —
|
||||||
|
// which can shift to an earlier step once server data lands (e.g. a required
|
||||||
|
// document turns out to be un-uploaded, so we must not rest on a later step).
|
||||||
|
const userNavigatedRef = useRef(false);
|
||||||
|
|
||||||
// On reopen, jump to the furthest step reached (initialStep) so progress
|
// On reopen, jump to the furthest step reached (initialStep) so progress
|
||||||
// never appears to reset.
|
// never appears to reset. Re-arm the follow-the-parent behaviour too.
|
||||||
const wasOpen = useRef(resyncOpen);
|
const wasOpen = useRef(resyncOpen);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (resyncOpen && !wasOpen.current && initialStep) {
|
if (resyncOpen && !wasOpen.current && initialStep) {
|
||||||
|
userNavigatedRef.current = false;
|
||||||
setStep(initialStep);
|
setStep(initialStep);
|
||||||
setSaveError(null);
|
setSaveError(null);
|
||||||
}
|
}
|
||||||
wasOpen.current = resyncOpen;
|
wasOpen.current = resyncOpen;
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [resyncOpen]);
|
}, [resyncOpen]);
|
||||||
|
|
||||||
|
// Follow a parent-driven resume correction: if initialStep changes (the wizard
|
||||||
|
// re-clamps it back once onboarding requirements load — e.g. a required
|
||||||
|
// document is still missing, so it must not skip ahead to Business License),
|
||||||
|
// adopt it, but only while the user hasn't started navigating themselves.
|
||||||
|
const lastInitialStep = useRef(initialStep);
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialStep && initialStep !== lastInitialStep.current) {
|
||||||
|
lastInitialStep.current = initialStep;
|
||||||
|
if (!userNavigatedRef.current) {
|
||||||
|
setStep(initialStep);
|
||||||
|
setSaveError(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [initialStep]);
|
||||||
const [internalFiles, setInternalFiles] = useState<
|
const [internalFiles, setInternalFiles] = useState<
|
||||||
Record<string, File | File[] | null>
|
Record<string, File | File[] | null>
|
||||||
>({});
|
>({});
|
||||||
@@ -410,7 +199,6 @@ export default function CompanyProfileForm({
|
|||||||
woreda: "",
|
woreda: "",
|
||||||
kebele: "",
|
kebele: "",
|
||||||
houseNo: "",
|
houseNo: "",
|
||||||
etradePhone: "",
|
|
||||||
contactPersonName: "",
|
contactPersonName: "",
|
||||||
contactPersonPosition: "",
|
contactPersonPosition: "",
|
||||||
contactPersonEmail: "",
|
contactPersonEmail: "",
|
||||||
@@ -482,7 +270,7 @@ export default function CompanyProfileForm({
|
|||||||
setValue("kebele", data.kebele);
|
setValue("kebele", data.kebele);
|
||||||
setValue("houseNo", data.houseNo);
|
setValue("houseNo", data.houseNo);
|
||||||
setValue(
|
setValue(
|
||||||
"etradePhone",
|
"companyPhone",
|
||||||
toEthiopianE164(data.regularPhone || data.mobilePhone),
|
toEthiopianE164(data.regularPhone || data.mobilePhone),
|
||||||
);
|
);
|
||||||
// companyAddress is composed reactively from the address fields below, so
|
// companyAddress is composed reactively from the address fields below, so
|
||||||
@@ -512,33 +300,54 @@ export default function CompanyProfileForm({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Copy the General Manager into the Contact Person fields (still editable). */
|
// "Same as …" links. A checked card prefills the target step's fields from the
|
||||||
const useGmAsContact = () => {
|
// source step and disables them (kept mirrored while linked); unchecking clears
|
||||||
setValue("contactPersonName", watch("generalManagerName"), {
|
// them and re-enables editing.
|
||||||
shouldValidate: true,
|
const [contactSameAsGm, setContactSameAsGm] = useState(false);
|
||||||
});
|
const [poaSameAsContact, setPoaSameAsContact] = useState(false);
|
||||||
setValue("contactPersonEmail", watch("generalManagerEmail"));
|
|
||||||
setValue("contactPersonPhone", watch("generalManagerPhone"), {
|
const gmName = watch("generalManagerName");
|
||||||
shouldValidate: true,
|
const gmEmail = watch("generalManagerEmail");
|
||||||
});
|
const gmPhone = watch("generalManagerPhone");
|
||||||
|
const contactName = watch("contactPersonName");
|
||||||
|
const contactEmail = watch("contactPersonEmail");
|
||||||
|
const contactPhone = watch("contactPersonPhone");
|
||||||
|
|
||||||
|
// While linked, mirror the source values into the (disabled) target fields so
|
||||||
|
// the copy stays current even if the user goes back and edits the source.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!contactSameAsGm) return;
|
||||||
|
setValue("contactPersonName", gmName ?? "", { shouldValidate: true });
|
||||||
|
setValue("contactPersonEmail", gmEmail ?? "");
|
||||||
|
setValue("contactPersonPhone", gmPhone ?? "", { shouldValidate: true });
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [contactSameAsGm, gmName, gmEmail, gmPhone]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!poaSameAsContact) return;
|
||||||
|
setValue("poaName", contactName ?? "");
|
||||||
|
setValue("poaEmail", contactEmail ?? "");
|
||||||
|
setValue("poaPhone", contactPhone ?? "");
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [poaSameAsContact, contactName, contactEmail, contactPhone]);
|
||||||
|
|
||||||
|
const toggleContactSameAsGm = (checked: boolean) => {
|
||||||
|
setContactSameAsGm(checked);
|
||||||
|
// Checked → the mirror effect fills the fields; unchecked → reset them.
|
||||||
|
if (!checked) {
|
||||||
|
setValue("contactPersonName", "");
|
||||||
|
setValue("contactPersonEmail", "");
|
||||||
|
setValue("contactPersonPhone", "");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Copy the Contact Person into the PoA fields (still editable). */
|
const togglePoaSameAsContact = (checked: boolean) => {
|
||||||
const useContactAsPoa = () => {
|
setPoaSameAsContact(checked);
|
||||||
setValue("poaName", watch("contactPersonName"));
|
if (!checked) {
|
||||||
setValue("poaEmail", watch("contactPersonEmail"));
|
setValue("poaName", "");
|
||||||
setValue("poaPhone", watch("contactPersonPhone"));
|
setValue("poaEmail", "");
|
||||||
};
|
setValue("poaPhone", "");
|
||||||
|
}
|
||||||
/** Populate the Contact Person from the currently logged-in user. */
|
|
||||||
const useLoggedInUserAsContact = () => {
|
|
||||||
setValue("contactPersonName", user?.name?.en ?? "", {
|
|
||||||
shouldValidate: true,
|
|
||||||
});
|
|
||||||
if (user?.email) setValue("contactPersonEmail", user.email);
|
|
||||||
setValue("contactPersonPhone", user?.phoneNumber ?? "", {
|
|
||||||
shouldValidate: true,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Contact-phone SMS OTP verification -----------------------------------
|
// --- Contact-phone SMS OTP verification -----------------------------------
|
||||||
@@ -612,7 +421,7 @@ export default function CompanyProfileForm({
|
|||||||
setOtpSent(false);
|
setOtpSent(false);
|
||||||
// Persist the verified phone so the step resumes as "done" after a refresh
|
// Persist the verified phone so the step resumes as "done" after a refresh
|
||||||
// (best-effort — the OTP itself already succeeded server-side).
|
// (best-effort — the OTP itself already succeeded server-side).
|
||||||
onSaveStep?.({ contactVerifiedPhone: contactPhoneE164 }).catch(() => {});
|
onSaveStep?.({ contactVerifiedPhone: contactPhoneE164 }).catch(() => { });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setOtpError(extractApiError(err).message);
|
setOtpError(extractApiError(err).message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -675,6 +484,7 @@ export default function CompanyProfileForm({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const nextStep = async () => {
|
const nextStep = async () => {
|
||||||
|
userNavigatedRef.current = true;
|
||||||
if (step === "additional") {
|
if (step === "additional") {
|
||||||
if (!licenseComplete) {
|
if (!licenseComplete) {
|
||||||
setSaveError(
|
setSaveError(
|
||||||
@@ -699,16 +509,34 @@ export default function CompanyProfileForm({
|
|||||||
setStep(stepOrder[currentIdx + 1]);
|
setStep(stepOrder[currentIdx + 1]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// The documents step has nothing to persist; field steps validate + save
|
// The documents step auto-uploads whatever the user selected as they
|
||||||
// before advancing.
|
// continue (partial uploads are allowed — required-doc completeness is
|
||||||
if (step !== "documents") {
|
// re-checked on resume). A failed upload holds them on the step.
|
||||||
const ok = await saveCurrentStep();
|
if (step === "documents") {
|
||||||
if (!ok) return;
|
if (onUploadDocuments) {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const res = await onUploadDocuments();
|
||||||
|
if (!res.ok) {
|
||||||
|
setSaveError(res.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setSaveError(null);
|
||||||
|
setStep(stepOrder[currentIdx + 1]);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
// Field steps validate + save before advancing.
|
||||||
|
const ok = await saveCurrentStep();
|
||||||
|
if (!ok) return;
|
||||||
setStep(stepOrder[currentIdx + 1]);
|
setStep(stepOrder[currentIdx + 1]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const prevStep = () => {
|
const prevStep = () => {
|
||||||
|
userNavigatedRef.current = true;
|
||||||
setSaveError(null);
|
setSaveError(null);
|
||||||
if (currentIdx === 0) onBack();
|
if (currentIdx === 0) onBack();
|
||||||
else setStep(stepOrder[currentIdx - 1]);
|
else setStep(stepOrder[currentIdx - 1]);
|
||||||
@@ -864,11 +692,6 @@ export default function CompanyProfileForm({
|
|||||||
error={errors.houseNo?.message}
|
error={errors.houseNo?.message}
|
||||||
{...register("houseNo")}
|
{...register("houseNo")}
|
||||||
/>
|
/>
|
||||||
<ControlledPhoneField
|
|
||||||
control={control}
|
|
||||||
name="etradePhone"
|
|
||||||
label="Phone"
|
|
||||||
/>
|
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -917,33 +740,17 @@ export default function CompanyProfileForm({
|
|||||||
|
|
||||||
{step === "contact" && (
|
{step === "contact" && (
|
||||||
<>
|
<>
|
||||||
<Group justify="space-between" align="center" wrap="nowrap">
|
<Text fw={600} size="sm" c="edr-text">
|
||||||
<Text fw={600} size="sm" c="edr-text">
|
Contact Person
|
||||||
Contact Person
|
</Text>
|
||||||
</Text>
|
{watch("generalManagerName") && (
|
||||||
<Group gap="xs" wrap="nowrap" style={{ flexShrink: 0 }}>
|
<LinkCheckboxCard
|
||||||
<Button
|
checked={contactSameAsGm}
|
||||||
variant="light"
|
onToggle={toggleContactSameAsGm}
|
||||||
color="edr-green"
|
title="Same as General Manager"
|
||||||
size="xs"
|
description="Reuse the general manager's name, email and phone. Uncheck to enter different details."
|
||||||
leftSection={<UserCheck size={14} />}
|
/>
|
||||||
onClick={useLoggedInUserAsContact}
|
)}
|
||||||
>
|
|
||||||
Use me
|
|
||||||
</Button>
|
|
||||||
{watch("generalManagerName") && (
|
|
||||||
<Button
|
|
||||||
variant="light"
|
|
||||||
color="edr-green"
|
|
||||||
size="xs"
|
|
||||||
leftSection={<UserCheck size={14} />}
|
|
||||||
onClick={useGmAsContact}
|
|
||||||
>
|
|
||||||
Use General Manager
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
<SimpleGrid cols={2} spacing="md">
|
<SimpleGrid cols={2} spacing="md">
|
||||||
<TextInput
|
<TextInput
|
||||||
label="Name"
|
label="Name"
|
||||||
@@ -978,12 +785,6 @@ export default function CompanyProfileForm({
|
|||||||
|
|
||||||
{step === "verify" && (
|
{step === "verify" && (
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<Group gap="xs" align="center">
|
|
||||||
<ShieldCheck size={18} className="text-[var(--mantine-color-edr-green-7)]" />
|
|
||||||
<Text fw={600} size="sm" c="edr-text">
|
|
||||||
Verify the contact person
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
<Text size="sm" c="edr-muted">
|
<Text size="sm" c="edr-muted">
|
||||||
We'll text a one-time code to the contact person's phone to
|
We'll text a one-time code to the contact person's phone to
|
||||||
confirm it's reachable. This is required before you continue.
|
confirm it's reachable. This is required before you continue.
|
||||||
@@ -1009,7 +810,10 @@ export default function CompanyProfileForm({
|
|||||||
) : (
|
) : (
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<Group gap="xs" align="center">
|
<Group gap="xs" align="center">
|
||||||
<Smartphone size={16} className="text-[var(--mantine-color-edr-muted)]" />
|
<Smartphone
|
||||||
|
size={16}
|
||||||
|
className="text-[var(--mantine-color-edr-muted)]"
|
||||||
|
/>
|
||||||
<Text size="sm" c="edr-text">
|
<Text size="sm" c="edr-text">
|
||||||
{maskPhone(contactPhoneE164)}
|
{maskPhone(contactPhoneE164)}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -1028,15 +832,17 @@ export default function CompanyProfileForm({
|
|||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<Text size="sm" c="edr-muted">
|
|
||||||
Enter the 6-digit code we sent to{" "}
|
|
||||||
{maskPhone(contactPhoneE164)}.
|
|
||||||
</Text>
|
|
||||||
<PinInput
|
<PinInput
|
||||||
length={6}
|
length={6}
|
||||||
type="number"
|
type="number"
|
||||||
oneTimeCode
|
oneTimeCode
|
||||||
value={otpCode}
|
value={otpCode}
|
||||||
|
placeholder="0"
|
||||||
|
styles={{
|
||||||
|
input: {
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
}}
|
||||||
onChange={setOtpCode}
|
onChange={setOtpCode}
|
||||||
/>
|
/>
|
||||||
<Group gap="sm">
|
<Group gap="sm">
|
||||||
@@ -1056,7 +862,9 @@ export default function CompanyProfileForm({
|
|||||||
disabled={resendIn > 0 || sendingOtp}
|
disabled={resendIn > 0 || sendingOtp}
|
||||||
leftSection={<RotateCw size={14} />}
|
leftSection={<RotateCw size={14} />}
|
||||||
>
|
>
|
||||||
{resendIn > 0 ? `Resend in ${resendIn}s` : "Resend code"}
|
{resendIn > 0
|
||||||
|
? `Resend in ${resendIn}s`
|
||||||
|
: "Resend code"}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -1078,24 +886,18 @@ export default function CompanyProfileForm({
|
|||||||
|
|
||||||
{step === "poa" && (
|
{step === "poa" && (
|
||||||
<>
|
<>
|
||||||
<Group justify="space-between" align="center" wrap="nowrap">
|
<Text size="sm" c="edr-muted">
|
||||||
<Text size="sm" c="edr-muted">
|
Power of Attorney details are optional. Fill them in if you have
|
||||||
Power of Attorney details are optional. Fill them in if you
|
them, or skip to continue.
|
||||||
have them, or skip to continue.
|
</Text>
|
||||||
</Text>
|
{watch("contactPersonName") && (
|
||||||
{watch("contactPersonName") && (
|
<LinkCheckboxCard
|
||||||
<Button
|
checked={poaSameAsContact}
|
||||||
variant="light"
|
onToggle={togglePoaSameAsContact}
|
||||||
color="edr-green"
|
title="Same as contact person"
|
||||||
size="xs"
|
description="Reuse the contact person's name, email and phone. Uncheck to enter different details."
|
||||||
leftSection={<UserCheck size={14} />}
|
/>
|
||||||
onClick={useContactAsPoa}
|
)}
|
||||||
style={{ flexShrink: 0 }}
|
|
||||||
>
|
|
||||||
Use contact person
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
<TextInput
|
<TextInput
|
||||||
label="PoA Name"
|
label="PoA Name"
|
||||||
placeholder="Authorized Representative Name"
|
placeholder="Authorized Representative Name"
|
||||||
@@ -1147,6 +949,8 @@ export default function CompanyProfileForm({
|
|||||||
<SmartFileInput
|
<SmartFileInput
|
||||||
file={uploadSetting}
|
file={uploadSetting}
|
||||||
value={documentFiles}
|
value={documentFiles}
|
||||||
|
uploadedKeys={uploadedDocumentKeys}
|
||||||
|
containerClassName="lg:grid grid-cols-2 items-stretch"
|
||||||
onChange={setDocumentFiles}
|
onChange={setDocumentFiles}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -1210,19 +1014,12 @@ export default function CompanyProfileForm({
|
|||||||
}
|
}
|
||||||
loading={isPending || saving}
|
loading={isPending || saving}
|
||||||
rightSection={
|
rightSection={
|
||||||
!isPending &&
|
!isPending && !saving && step !== "additional" ? (
|
||||||
!saving &&
|
|
||||||
step !== "additional" &&
|
|
||||||
step !== "documents" ? (
|
|
||||||
<ArrowRight size={16} />
|
<ArrowRight size={16} />
|
||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{step === "documents" || step === "verify"
|
{step === "additional" ? "Submit for review" : "Continue"}
|
||||||
? "Continue"
|
|
||||||
: step === "additional"
|
|
||||||
? "Submit for review"
|
|
||||||
: "Save & Continue"}
|
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { Group, Text, UnstyledButton } from "@mantine/core";
|
||||||
|
import { Check } from "lucide-react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A card styled as a large checkbox: clicking it toggles `checked`, which the
|
||||||
|
* caller uses to prefill + lock a set of fields (and clear them on uncheck).
|
||||||
|
*/
|
||||||
|
export function LinkCheckboxCard({
|
||||||
|
checked,
|
||||||
|
onToggle,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
}: {
|
||||||
|
checked: boolean;
|
||||||
|
onToggle: (checked: boolean) => void;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<UnstyledButton
|
||||||
|
onClick={() => onToggle(!checked)}
|
||||||
|
role="checkbox"
|
||||||
|
aria-checked={checked}
|
||||||
|
className={`w-full rounded-lg border! p-3! text-left transition-colors! ${checked
|
||||||
|
? "border-[var(--mantine-color-edr-green-6)]! bg-[var(--mantine-color-edr-green-0)]!"
|
||||||
|
: "border-[var(--mantine-color-gray-3)]! hover:border-[var(--mantine-color-edr-green-4)]! hover:bg-[var(--mantine-color-edr-green-0)]!"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||||
|
<div
|
||||||
|
className={`mt-px flex h-5 w-5 shrink-0 items-center justify-center rounded-[6px] border transition-colors ${checked
|
||||||
|
? "border-[var(--mantine-color-edr-green-6)] bg-[var(--mantine-color-edr-green-6)] text-white"
|
||||||
|
: "border-[var(--mantine-color-gray-4)] bg-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{checked && <Check size={14} strokeWidth={3} />}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Text fw={600} size="sm" c="edr-text" lh={1.25}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed" mt={2}>
|
||||||
|
{description}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
</UnstyledButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LinkCheckboxCard;
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { Stack, Text } from "@mantine/core";
|
||||||
|
|
||||||
|
/** A single read-only registration value rendered as a label/value pair. */
|
||||||
|
export function ReadOnlyField({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Stack gap={2}>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" c="edr-text" fw={500}>
|
||||||
|
{value && value.trim() ? value : "—"}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ReadOnlyField;
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import type { AuthUser } from "@/types/auth";
|
||||||
|
import type { CreateCompanyPayload } from "@/services/companies.service";
|
||||||
|
import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
|
||||||
|
|
||||||
|
import type { CompanyStep, FormData } from "./schema";
|
||||||
|
|
||||||
|
/** Last 9 digits (Ethiopian national significant number) for tolerant compare. */
|
||||||
|
export const phoneDigits = (p?: string | null) =>
|
||||||
|
(p ?? "").replace(/\D/g, "").slice(-9);
|
||||||
|
|
||||||
|
export const samePhone = (a?: string | null, b?: string | null) => {
|
||||||
|
const da = phoneDigits(a);
|
||||||
|
return da.length === 9 && da === phoneDigits(b);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Mask all but the first 7 chars of an E.164 phone for display. */
|
||||||
|
export const maskPhone = (p: string) =>
|
||||||
|
p.length > 4 ? `${p.slice(0, 7)}${"*".repeat(Math.max(0, p.length - 7))}` : p;
|
||||||
|
|
||||||
|
export function buildPayload(
|
||||||
|
data: FormData,
|
||||||
|
_user: AuthUser,
|
||||||
|
): CreateCompanyPayload {
|
||||||
|
return {
|
||||||
|
companyName: data.companyName,
|
||||||
|
companyEmail: data.companyEmail,
|
||||||
|
companyPhone: data.companyPhone,
|
||||||
|
companyLocation: data.companyLocation,
|
||||||
|
companyAddress: data.companyAddress,
|
||||||
|
tin: data.tinNumber,
|
||||||
|
vatNumber: data.vatNumber,
|
||||||
|
fanNumber: data.fanNumber,
|
||||||
|
attributes: {
|
||||||
|
contactPersonName: data.contactPersonName,
|
||||||
|
contactPersonPosition: data.contactPersonPosition || undefined,
|
||||||
|
contactPersonEmail: data.contactPersonEmail || undefined,
|
||||||
|
contactPersonPhone: data.contactPersonPhone,
|
||||||
|
generalManagerName: data.generalManagerName,
|
||||||
|
generalManagerEmail: data.generalManagerEmail,
|
||||||
|
generalManagerPhone: data.generalManagerPhone,
|
||||||
|
poaName: data.poaName || undefined,
|
||||||
|
poaPhone: data.poaPhone || undefined,
|
||||||
|
poaAddress: data.poaAddress || undefined,
|
||||||
|
poaEmail: data.poaEmail || undefined,
|
||||||
|
poaLocation: data.poaLocation || undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map one wizard step's form values to the profile-update payload it saves. */
|
||||||
|
export function stepPayload(
|
||||||
|
step: CompanyStep,
|
||||||
|
d: FormData,
|
||||||
|
): Partial<UpdateProfilePayload> {
|
||||||
|
switch (step) {
|
||||||
|
case "company":
|
||||||
|
return {
|
||||||
|
companyName: d.companyName,
|
||||||
|
companyEmail: d.companyEmail,
|
||||||
|
companyPhone: d.companyPhone,
|
||||||
|
companyLocation: d.companyLocation,
|
||||||
|
companyAddress: d.companyAddress,
|
||||||
|
tin: d.tinNumber,
|
||||||
|
vatNumber: d.vatNumber,
|
||||||
|
fanNumber: d.fanNumber,
|
||||||
|
licenceNumber: d.licenceNumber,
|
||||||
|
statusDescription: d.statusDescription,
|
||||||
|
dateRegistered: d.dateRegistered,
|
||||||
|
renewedFrom: d.renewedFrom,
|
||||||
|
renewalDate: d.renewalDate,
|
||||||
|
renewedTo: d.renewedTo,
|
||||||
|
region: d.region,
|
||||||
|
zone: d.zone,
|
||||||
|
woreda: d.woreda,
|
||||||
|
kebele: d.kebele,
|
||||||
|
houseNo: d.houseNo,
|
||||||
|
etradePhone: d.companyPhone,
|
||||||
|
};
|
||||||
|
case "personnel":
|
||||||
|
return {
|
||||||
|
generalManagerName: d.generalManagerName,
|
||||||
|
generalManagerEmail: d.generalManagerEmail,
|
||||||
|
generalManagerPhone: d.generalManagerPhone,
|
||||||
|
};
|
||||||
|
case "contact":
|
||||||
|
return {
|
||||||
|
contactPersonName: d.contactPersonName,
|
||||||
|
contactPersonPosition: d.contactPersonPosition || undefined,
|
||||||
|
contactPersonEmail: d.contactPersonEmail || undefined,
|
||||||
|
contactPersonPhone: d.contactPersonPhone,
|
||||||
|
};
|
||||||
|
case "poa":
|
||||||
|
return {
|
||||||
|
poaName: d.poaName || undefined,
|
||||||
|
poaPhone: d.poaPhone || undefined,
|
||||||
|
poaEmail: d.poaEmail || undefined,
|
||||||
|
poaLocation: d.poaLocation || undefined,
|
||||||
|
poaAddress: d.poaAddress || undefined,
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seed the form from previously-saved profile data. */
|
||||||
|
export function toFormValues(p: ProfileResponse): FormData {
|
||||||
|
// The draft placeholder TIN ("D…") shouldn't show as a real value.
|
||||||
|
const tin = p.tinNumber && !p.tinNumber.startsWith("D") ? p.tinNumber : "";
|
||||||
|
return {
|
||||||
|
companyName: p.companyName ?? "",
|
||||||
|
companyEmail: p.companyEmail ?? "",
|
||||||
|
companyPhone: p.companyPhone ?? "",
|
||||||
|
companyLocation: p.companyLocation ?? "",
|
||||||
|
companyAddress: p.companyAddress ?? "",
|
||||||
|
tinNumber: tin,
|
||||||
|
vatNumber: p.vatNumber ?? "",
|
||||||
|
fanNumber: p.fanNumber ?? "",
|
||||||
|
licenceNumber: p.licenceNumber ?? "",
|
||||||
|
statusDescription: p.statusDescription ?? "",
|
||||||
|
dateRegistered: p.dateRegistered ?? "",
|
||||||
|
renewedFrom: p.renewedFrom ?? "",
|
||||||
|
renewalDate: p.renewalDate ?? "",
|
||||||
|
renewedTo: p.renewedTo ?? "",
|
||||||
|
region: p.region ?? "",
|
||||||
|
zone: p.zone ?? "",
|
||||||
|
woreda: p.woreda ?? "",
|
||||||
|
kebele: p.kebele ?? "",
|
||||||
|
houseNo: p.houseNo ?? "",
|
||||||
|
contactPersonName: p.contactPersonName ?? "",
|
||||||
|
contactPersonPosition: p.contactPersonPosition ?? "",
|
||||||
|
contactPersonEmail: p.contactPersonEmail ?? "",
|
||||||
|
contactPersonPhone: p.contactPersonPhone ?? "",
|
||||||
|
generalManagerName: p.generalManagerName ?? "",
|
||||||
|
generalManagerEmail: p.generalManagerEmail ?? "",
|
||||||
|
generalManagerPhone: p.generalManagerPhone ?? "",
|
||||||
|
poaName: p.poaName ?? "",
|
||||||
|
poaPhone: p.poaPhone ?? "",
|
||||||
|
poaAddress: p.poaAddress ?? "",
|
||||||
|
poaEmail: p.poaEmail ?? "",
|
||||||
|
poaLocation: p.poaLocation ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { isValidPhone } from "@/components/PhoneField";
|
||||||
|
|
||||||
|
export type CompanyStep =
|
||||||
|
| "company"
|
||||||
|
| "personnel"
|
||||||
|
| "contact"
|
||||||
|
| "verify"
|
||||||
|
| "poa"
|
||||||
|
| "documents"
|
||||||
|
| "additional";
|
||||||
|
|
||||||
|
export const onboardingSchema = z.object({
|
||||||
|
companyName: z.string().min(1, "Company name is required"),
|
||||||
|
companyEmail: z.string().email("Invalid email address"),
|
||||||
|
companyPhone: z
|
||||||
|
.string()
|
||||||
|
.min(1, "Company phone is required")
|
||||||
|
.refine(isValidPhone, "Enter a valid phone number"),
|
||||||
|
companyLocation: z.string().min(1, "Location is required"),
|
||||||
|
// Derived from the eTrade address parts (kebele/woreda/zone/region); no
|
||||||
|
// standalone input — the granular fields live in the registration section.
|
||||||
|
companyAddress: z.string().optional(),
|
||||||
|
tinNumber: z.string().length(10, "TIN must be exactly 10 digits"),
|
||||||
|
vatNumber: z
|
||||||
|
.string()
|
||||||
|
.min(1, "VAT number is required")
|
||||||
|
.length(10, "VAT number must be exactly 10 digits"),
|
||||||
|
fanNumber: z.string().length(16, "FAN must be exactly 16 digits"),
|
||||||
|
licenceNumber: z.string().optional(),
|
||||||
|
statusDescription: z.string().optional(),
|
||||||
|
dateRegistered: z.string().optional(),
|
||||||
|
renewedFrom: z.string().optional(),
|
||||||
|
renewalDate: z.string().optional(),
|
||||||
|
renewedTo: z.string().optional(),
|
||||||
|
// Address fields are user-entered and required (the registration/license
|
||||||
|
// fields above are read-only confirmations pulled from eTrade).
|
||||||
|
region: z.string().min(1, "Region is required"),
|
||||||
|
zone: z.string().min(1, "Zone is required"),
|
||||||
|
woreda: z.string().min(1, "Woreda is required"),
|
||||||
|
kebele: z.string().min(1, "Kebele is required"),
|
||||||
|
houseNo: z.string().min(1, "House number is required"),
|
||||||
|
contactPersonName: z.string().min(1, "Contact person name is required"),
|
||||||
|
contactPersonPosition: z.string().optional(),
|
||||||
|
contactPersonEmail: z
|
||||||
|
.string()
|
||||||
|
.email("Invalid email address")
|
||||||
|
.optional()
|
||||||
|
.or(z.literal("")),
|
||||||
|
contactPersonPhone: z
|
||||||
|
.string()
|
||||||
|
.min(1, "Contact person phone is required")
|
||||||
|
.refine(isValidPhone, "Enter a valid phone number"),
|
||||||
|
generalManagerName: z.string().min(1, "Manager name is required"),
|
||||||
|
generalManagerEmail: z.string().email("Invalid Manager email"),
|
||||||
|
generalManagerPhone: z
|
||||||
|
.string()
|
||||||
|
.min(1, "Manager phone is required")
|
||||||
|
.refine(isValidPhone, "Enter a valid phone number"),
|
||||||
|
poaName: z.string().optional(),
|
||||||
|
poaPhone: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.refine((v) => !v || isValidPhone(v), "Enter a valid phone number"),
|
||||||
|
poaAddress: z.string().optional(),
|
||||||
|
poaEmail: z.string().optional(),
|
||||||
|
poaLocation: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type FormData = z.infer<typeof onboardingSchema>;
|
||||||
|
|
||||||
|
export const stepFields: Record<CompanyStep, (keyof FormData)[]> = {
|
||||||
|
company: [
|
||||||
|
"companyName",
|
||||||
|
"companyEmail",
|
||||||
|
"companyPhone",
|
||||||
|
"companyLocation",
|
||||||
|
"companyAddress",
|
||||||
|
"tinNumber",
|
||||||
|
"vatNumber",
|
||||||
|
"fanNumber",
|
||||||
|
"licenceNumber",
|
||||||
|
"statusDescription",
|
||||||
|
"dateRegistered",
|
||||||
|
"renewedFrom",
|
||||||
|
"renewalDate",
|
||||||
|
"renewedTo",
|
||||||
|
"region",
|
||||||
|
"zone",
|
||||||
|
"woreda",
|
||||||
|
"kebele",
|
||||||
|
"houseNo",
|
||||||
|
],
|
||||||
|
personnel: [
|
||||||
|
"generalManagerName",
|
||||||
|
"generalManagerEmail",
|
||||||
|
"generalManagerPhone",
|
||||||
|
],
|
||||||
|
contact: [
|
||||||
|
"contactPersonName",
|
||||||
|
"contactPersonPosition",
|
||||||
|
"contactPersonEmail",
|
||||||
|
"contactPersonPhone",
|
||||||
|
],
|
||||||
|
verify: [],
|
||||||
|
poa: [],
|
||||||
|
documents: [],
|
||||||
|
additional: [],
|
||||||
|
};
|
||||||
@@ -1,29 +1,25 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
# Build from monorepo root: docker build -f apps/edr-passenger-api/Dockerfile .
|
# Build from monorepo root: docker build -f apps/edr-passenger-api/Dockerfile .
|
||||||
# On start: runs prisma migrate deploy + seed, then the API.
|
# On start: runs the API only — migrations/seed now run separately via the
|
||||||
|
# `migration` stage, invoked as a one-shot container in CI before deploy.
|
||||||
FROM node:24.15.0-alpine AS base
|
FROM node:24.15.0-alpine AS base
|
||||||
RUN apk add --no-cache libc6-compat
|
RUN apk add --no-cache libc6-compat
|
||||||
RUN corepack enable
|
RUN corepack enable
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
FROM base AS pruner
|
FROM base AS pruner
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN pnpm dlx turbo prune "@edr/passenger-api" --docker
|
RUN pnpm dlx turbo prune "@edr/passenger-api" --docker
|
||||||
|
|
||||||
FROM base AS installer
|
FROM base AS installer
|
||||||
COPY --from=pruner /app/out/json/ .
|
COPY --from=pruner /app/out/json/ .
|
||||||
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
|
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||||
RUN --mount=type=secret,id=npmrc,target=./.npmrc,required=false \
|
RUN --mount=type=secret,id=npmrc,target=./.npmrc,required=false \
|
||||||
--mount=type=cache,id=pnpm,target=/pnpm/store \
|
--mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
|
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
COPY --from=installer /app/ .
|
COPY --from=installer /app/ .
|
||||||
COPY --from=pruner /app/out/full/ .
|
COPY --from=pruner /app/out/full/ .
|
||||||
RUN pnpm --filter "@edr/passenger-api" exec prisma generate
|
RUN pnpm --filter "@edr/passenger-api" exec prisma generate
|
||||||
RUN pnpm turbo build --filter="@edr/passenger-api..."
|
RUN pnpm turbo build --filter="@edr/passenger-api..."
|
||||||
|
|
||||||
FROM base AS deployer
|
FROM base AS deployer
|
||||||
COPY --from=builder /app/ .
|
COPY --from=builder /app/ .
|
||||||
RUN pnpm deploy --filter="@edr/passenger-api" --legacy /deploy
|
RUN pnpm deploy --filter="@edr/passenger-api" --legacy /deploy
|
||||||
@@ -32,20 +28,24 @@ RUN if [ -d node_modules/.prisma ]; then \
|
|||||||
cp -r node_modules/.prisma /deploy/node_modules/.prisma; \
|
cp -r node_modules/.prisma /deploy/node_modules/.prisma; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# --- Migration image: built in CI, run as a one-shot `docker run --rm --env-file ...`
|
||||||
|
# against the real DB, as its own gated step *before* the app image is built/deployed.
|
||||||
|
# Runs the same generate + migrate + seed sequence the old entrypoint used to run at
|
||||||
|
# container start, but now as a throwaway container, before the app container exists.
|
||||||
|
FROM deployer AS migration
|
||||||
|
WORKDIR /deploy
|
||||||
|
RUN corepack enable && corepack prepare pnpm@11.1.1 --activate
|
||||||
|
ENV CI=true
|
||||||
|
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
|
||||||
|
CMD ["sh", "-c", "npm run prisma:generate && npm run prisma:migrate && npm run prisma:seed"]
|
||||||
|
|
||||||
FROM node:24.15.0-alpine AS runner
|
FROM node:24.15.0-alpine AS runner
|
||||||
RUN apk add --no-cache libc6-compat
|
RUN apk add --no-cache libc6-compat
|
||||||
RUN corepack enable && corepack prepare pnpm@11.1.1 --activate
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN addgroup --system --gid 1001 nodejs \
|
RUN addgroup --system --gid 1001 nodejs \
|
||||||
&& adduser --system --uid 1001 --ingroup nodejs nestjs
|
&& adduser --system --uid 1001 --ingroup nodejs nestjs
|
||||||
COPY --from=deployer /deploy .
|
COPY --from=deployer --chown=nestjs:nodejs /deploy .
|
||||||
COPY apps/edr-passenger-api/docker-entrypoint.sh /docker-entrypoint.sh
|
|
||||||
RUN chmod +x /docker-entrypoint.sh \
|
|
||||||
&& chown -R nestjs:nodejs /app
|
|
||||||
USER nestjs
|
USER nestjs
|
||||||
ENV CI=true
|
|
||||||
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
|
|
||||||
EXPOSE 4000
|
EXPOSE 4000
|
||||||
ENTRYPOINT ["/docker-entrypoint.sh"]
|
|
||||||
CMD ["node", "dist/main.js"]
|
CMD ["node", "dist/main.js"]
|
||||||
|
|||||||
@@ -1,30 +1,32 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
# Build from monorepo root: docker build -f apps/edr-freight-api/Dockerfile .
|
# Build from monorepo root: docker build -f apps/edr-payment-api/Dockerfile .
|
||||||
|
|
||||||
FROM node:24.15.0-alpine AS base
|
FROM node:24.15.0-alpine AS base
|
||||||
RUN apk add --no-cache libc6-compat
|
RUN apk add --no-cache libc6-compat
|
||||||
RUN corepack enable
|
RUN corepack enable
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
FROM base AS pruner
|
FROM base AS pruner
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN pnpm dlx turbo prune "@edr/payment-api" --docker
|
RUN pnpm dlx turbo prune "@edr/payment-api" --docker
|
||||||
|
|
||||||
FROM base AS installer
|
FROM base AS installer
|
||||||
COPY --from=pruner /app/out/json/ .
|
COPY --from=pruner /app/out/json/ .
|
||||||
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
|
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||||
RUN --mount=type=secret,id=npmrc,target=./.npmrc,required=false \
|
RUN --mount=type=secret,id=npmrc,target=./.npmrc,required=false \
|
||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
|
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
COPY --from=installer /app/ .
|
COPY --from=installer /app/ .
|
||||||
COPY --from=pruner /app/out/full/ .
|
COPY --from=pruner /app/out/full/ .
|
||||||
RUN pnpm turbo build --filter="@edr/payment-api..."
|
RUN pnpm turbo build --filter="@edr/payment-api..."
|
||||||
|
|
||||||
FROM base AS deployer
|
FROM base AS deployer
|
||||||
COPY --from=builder /app/ .
|
COPY --from=builder /app/ .
|
||||||
RUN pnpm deploy --filter="@edr/payment-api" --prod --legacy --ignore-scripts /deploy
|
RUN pnpm deploy --filter="@edr/payment-api" --prod --legacy --ignore-scripts /deploy
|
||||||
|
|
||||||
|
# --- Migration image: built in CI, run as a one-shot `docker run --rm --env-file ...`
|
||||||
|
# against the real DB, as its own gated step *before* the app image is built/deployed.
|
||||||
|
# The runner below only ever runs `node dist/main.js` — migrations happen here, not there.
|
||||||
|
FROM deployer AS migration
|
||||||
|
WORKDIR /deploy
|
||||||
|
CMD ["node", "dist/scripts/migrate.js"]
|
||||||
|
|
||||||
FROM node:24.15.0-alpine AS runner
|
FROM node:24.15.0-alpine AS runner
|
||||||
RUN apk add --no-cache libc6-compat
|
RUN apk add --no-cache libc6-compat
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
@@ -32,10 +34,6 @@ WORKDIR /app
|
|||||||
RUN addgroup --system --gid 1001 nodejs \
|
RUN addgroup --system --gid 1001 nodejs \
|
||||||
&& adduser --system --uid 1001 --ingroup nodejs nestjs
|
&& adduser --system --uid 1001 --ingroup nodejs nestjs
|
||||||
COPY --from=deployer --chown=nestjs:nodejs /deploy .
|
COPY --from=deployer --chown=nestjs:nodejs /deploy .
|
||||||
COPY apps/edr-payment-api/docker-entrypoint.sh /docker-entrypoint.sh
|
|
||||||
RUN chmod +x /docker-entrypoint.sh \
|
|
||||||
&& chown -R nestjs:nodejs /app
|
|
||||||
USER nestjs
|
USER nestjs
|
||||||
EXPOSE 3008
|
EXPOSE 3008
|
||||||
ENTRYPOINT ["/docker-entrypoint.sh"]
|
|
||||||
CMD ["node", "dist/main.js"]
|
CMD ["node", "dist/main.js"]
|
||||||
|
|||||||
BIN
local-packages/tria-plc-iamapi-common-0.7.7.tgz
Normal file
BIN
local-packages/tria-plc-iamapi-common-0.7.7.tgz
Normal file
Binary file not shown.
BIN
local-packages/tria-plc-iamui-0.1.1.tgz
Normal file
BIN
local-packages/tria-plc-iamui-0.1.1.tgz
Normal file
Binary file not shown.
@@ -54,6 +54,10 @@ export class ResponseTransformInterceptor<T> implements NestInterceptor<
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if(path.startsWith("/api/positions/hierarchy") || path.startsWith("/api/positions/current")){
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data,
|
data,
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
import React, { useState, useMemo, useRef } from "react";
|
import React, { useState, useMemo, useRef } from "react";
|
||||||
import {
|
import { IFileUploadSetting, IFileUploadField } from "@edr/types/freight";
|
||||||
IFileUploadSetting,
|
|
||||||
IFileUploadField,
|
|
||||||
} from "@edr/types/freight";
|
|
||||||
import {
|
import {
|
||||||
UploadCloud,
|
UploadCloud,
|
||||||
FileText,
|
FileText,
|
||||||
@@ -24,12 +21,20 @@ export interface SmartFileInputProps {
|
|||||||
onChange?: (value: Record<string, File | File[] | null>) => void;
|
onChange?: (value: Record<string, File | File[] | null>) => void;
|
||||||
/** External form errors mapped by fileKey. */
|
/** External form errors mapped by fileKey. */
|
||||||
errors?: Record<string, string>;
|
errors?: Record<string, string>;
|
||||||
|
/**
|
||||||
|
* fileKeys whose document is already uploaded on the server. Such fields show
|
||||||
|
* an "Already uploaded" badge and a replace-oriented dropzone hint, even when
|
||||||
|
* no in-memory File is currently selected for them.
|
||||||
|
*/
|
||||||
|
uploadedKeys?: string[];
|
||||||
/** Disabled state for the entire file input group. */
|
/** Disabled state for the entire file input group. */
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
/** Display variant style. Default is "default" (large dropzone). Minimal renders a compact upload button. */
|
/** Display variant style. Default is "default" (large dropzone). Minimal renders a compact upload button. */
|
||||||
variant?: "default" | "minimal";
|
variant?: "default" | "minimal";
|
||||||
/** Optional custom container CSS classes. */
|
/** Optional custom container CSS classes. */
|
||||||
className?: string;
|
className?: string;
|
||||||
|
|
||||||
|
containerClassName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Helper to format file sizes in bytes to a human-readable string. */
|
/** Helper to format file sizes in bytes to a human-readable string. */
|
||||||
@@ -45,23 +50,23 @@ function formatBytes(bytes: number, decimals = 2) {
|
|||||||
/** Render a suitable icon based on file extension. */
|
/** Render a suitable icon based on file extension. */
|
||||||
function FileIcon({ name, className }: { name: string; className?: string }) {
|
function FileIcon({ name, className }: { name: string; className?: string }) {
|
||||||
const ext = name.split(".").pop()?.toLowerCase() || "";
|
const ext = name.split(".").pop()?.toLowerCase() || "";
|
||||||
|
|
||||||
if (ext === "pdf") {
|
if (ext === "pdf") {
|
||||||
return <FileText className={cn("text-red-500", className)} />;
|
return <FileText className={cn("text-red-500", className)} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (["png", "jpg", "jpeg", "webp", "svg", "gif"].includes(ext)) {
|
if (["png", "jpg", "jpeg", "webp", "svg", "gif"].includes(ext)) {
|
||||||
return <ImageIcon className={cn("text-blue-500", className)} />;
|
return <ImageIcon className={cn("text-blue-500", className)} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (["csv", "xls", "xlsx"].includes(ext)) {
|
if (["csv", "xls", "xlsx"].includes(ext)) {
|
||||||
return <FileText className={cn("text-emerald-500", className)} />;
|
return <FileText className={cn("text-emerald-500", className)} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (["zip", "rar", "tar", "gz", "7z"].includes(ext)) {
|
if (["zip", "rar", "tar", "gz", "7z"].includes(ext)) {
|
||||||
return <File className={cn("text-amber-500", className)} />;
|
return <File className={cn("text-amber-500", className)} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <File className={cn("text-slate-400", className)} />;
|
return <File className={cn("text-slate-400", className)} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,16 +75,20 @@ export function SmartFileInput({
|
|||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
errors,
|
errors,
|
||||||
|
uploadedKeys,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
variant = "default",
|
variant = "default",
|
||||||
className,
|
className,
|
||||||
|
containerClassName,
|
||||||
}: SmartFileInputProps) {
|
}: SmartFileInputProps) {
|
||||||
// Local state to manage files when the component is used in an uncontrolled manner
|
// Local state to manage files when the component is used in an uncontrolled manner
|
||||||
const [internalFiles, setInternalFiles] = useState<Record<string, File[]>>({});
|
const [internalFiles, setInternalFiles] = useState<Record<string, File[]>>(
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
// Local validation errors
|
// Local validation errors
|
||||||
const [localErrors, setLocalErrors] = useState<Record<string, string>>({});
|
const [localErrors, setLocalErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
// Drag-and-drop state active per field
|
// Drag-and-drop state active per field
|
||||||
const [dragActive, setDragActive] = useState<Record<string, boolean>>({});
|
const [dragActive, setDragActive] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
@@ -93,10 +102,13 @@ export function SmartFileInput({
|
|||||||
|
|
||||||
// Create a map of fields for quick lookup
|
// Create a map of fields for quick lookup
|
||||||
const fieldsMap = useMemo(() => {
|
const fieldsMap = useMemo(() => {
|
||||||
return file.fields.reduce((acc, currentField) => {
|
return file.fields.reduce(
|
||||||
acc[currentField.fileKey] = currentField;
|
(acc, currentField) => {
|
||||||
return acc;
|
acc[currentField.fileKey] = currentField;
|
||||||
}, {} as Record<string, IFileUploadField>);
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, IFileUploadField>,
|
||||||
|
);
|
||||||
}, [file.fields]);
|
}, [file.fields]);
|
||||||
|
|
||||||
// Resolve current files list for a field
|
// Resolve current files list for a field
|
||||||
@@ -109,8 +121,8 @@ export function SmartFileInput({
|
|||||||
const handleFilesChange = (fieldKey: string, newFiles: File[]) => {
|
const handleFilesChange = (fieldKey: string, newFiles: File[]) => {
|
||||||
const field = fieldsMap[fieldKey];
|
const field = fieldsMap[fieldKey];
|
||||||
if (!field) return;
|
if (!field) return;
|
||||||
|
|
||||||
const newValue = field.isMultiple ? newFiles : (newFiles[0] || null);
|
const newValue = field.isMultiple ? newFiles : newFiles[0] || null;
|
||||||
|
|
||||||
if (onChange) {
|
if (onChange) {
|
||||||
const updatedValues = {
|
const updatedValues = {
|
||||||
@@ -129,10 +141,10 @@ export function SmartFileInput({
|
|||||||
const processFiles = (field: IFileUploadField, incomingFiles: File[]) => {
|
const processFiles = (field: IFileUploadField, incomingFiles: File[]) => {
|
||||||
const currentFiles = getFilesForField(field.fileKey);
|
const currentFiles = getFilesForField(field.fileKey);
|
||||||
const maxAllowed = field.isMultiple ? Math.max(1, field.maxFiles) : 1;
|
const maxAllowed = field.isMultiple ? Math.max(1, field.maxFiles) : 1;
|
||||||
|
|
||||||
// Clean up extensions (e.g. '.pdf' or 'pdf' -> 'pdf')
|
// Clean up extensions (e.g. '.pdf' or 'pdf' -> 'pdf')
|
||||||
const allowedExts = field.allowedExtensions.map((ext) =>
|
const allowedExts = field.allowedExtensions.map((ext) =>
|
||||||
ext.toLowerCase().replace(/^\./, "")
|
ext.toLowerCase().replace(/^\./, ""),
|
||||||
);
|
);
|
||||||
|
|
||||||
let validIncoming: File[] = [];
|
let validIncoming: File[] = [];
|
||||||
@@ -140,13 +152,12 @@ export function SmartFileInput({
|
|||||||
|
|
||||||
for (const fileObj of incomingFiles) {
|
for (const fileObj of incomingFiles) {
|
||||||
const ext = fileObj.name.split(".").pop()?.toLowerCase() || "";
|
const ext = fileObj.name.split(".").pop()?.toLowerCase() || "";
|
||||||
const isExtValid =
|
const isExtValid = allowedExts.length === 0 || allowedExts.includes(ext);
|
||||||
allowedExts.length === 0 || allowedExts.includes(ext);
|
|
||||||
const isSizeValid = fileObj.size <= field.maxSizeMb * 1024 * 1024;
|
const isSizeValid = fileObj.size <= field.maxSizeMb * 1024 * 1024;
|
||||||
|
|
||||||
if (!isExtValid) {
|
if (!isExtValid) {
|
||||||
errorMsg = `Invalid file extension. Allowed: ${field.allowedExtensions.join(
|
errorMsg = `Invalid file extension. Allowed: ${field.allowedExtensions.join(
|
||||||
", "
|
", ",
|
||||||
)}`;
|
)}`;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -187,7 +198,11 @@ export function SmartFileInput({
|
|||||||
handleFilesChange(field.fileKey, newFilesList);
|
handleFilesChange(field.fileKey, newFilesList);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDrag = (e: React.DragEvent, fieldKey: string, active: boolean) => {
|
const handleDrag = (
|
||||||
|
e: React.DragEvent,
|
||||||
|
fieldKey: string,
|
||||||
|
active: boolean,
|
||||||
|
) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (disabled) return;
|
if (disabled) return;
|
||||||
@@ -208,7 +223,7 @@ export function SmartFileInput({
|
|||||||
|
|
||||||
const handleFileSelect = (
|
const handleFileSelect = (
|
||||||
e: React.ChangeEvent<HTMLInputElement>,
|
e: React.ChangeEvent<HTMLInputElement>,
|
||||||
field: IFileUploadField
|
field: IFileUploadField,
|
||||||
) => {
|
) => {
|
||||||
if (e.target.files && e.target.files.length > 0) {
|
if (e.target.files && e.target.files.length > 0) {
|
||||||
const filesArray = Array.from(e.target.files);
|
const filesArray = Array.from(e.target.files);
|
||||||
@@ -246,179 +261,275 @@ export function SmartFileInput({
|
|||||||
{file.description}
|
{file.description}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{sortedFields.map((field) => {
|
|
||||||
const currentFiles = getFilesForField(field.fileKey);
|
|
||||||
const maxFiles = field.isMultiple ? Math.max(1, field.maxFiles) : 1;
|
|
||||||
const reachedLimit = currentFiles.length >= maxFiles;
|
|
||||||
const fieldError = errors?.[field.fileKey] || localErrors[field.fileKey];
|
|
||||||
const isDragOver = dragActive[field.fileKey];
|
|
||||||
|
|
||||||
// Format accepted files for the HTML input element
|
<div className={cn("flex flex-col gap-6", containerClassName)}>
|
||||||
const acceptString = field.allowedExtensions
|
{sortedFields.map((field) => {
|
||||||
.map((ext) => (ext.startsWith(".") ? ext : `.${ext}`))
|
const currentFiles = getFilesForField(field.fileKey);
|
||||||
.join(",");
|
const maxFiles = field.isMultiple ? Math.max(1, field.maxFiles) : 1;
|
||||||
|
const reachedLimit = currentFiles.length >= maxFiles;
|
||||||
|
const fieldError =
|
||||||
|
errors?.[field.fileKey] || localErrors[field.fileKey];
|
||||||
|
const isDragOver = dragActive[field.fileKey];
|
||||||
|
// Already uploaded server-side and nothing newly picked to replace it.
|
||||||
|
const isUploaded =
|
||||||
|
(uploadedKeys?.includes(field.fileKey) ?? false) &&
|
||||||
|
currentFiles.length === 0;
|
||||||
|
|
||||||
return (
|
// Format accepted files for the HTML input element
|
||||||
<div key={field.id || field.fileKey} className="flex flex-col gap-2">
|
const acceptString = field.allowedExtensions
|
||||||
{/* Field Header */}
|
.map((ext) => (ext.startsWith(".") ? ext : `.${ext}`))
|
||||||
<div className="flex flex-col md:flex-row md:items-baseline justify-between gap-1">
|
.join(",");
|
||||||
<label className="text-sm font-semibold text-foreground flex items-center gap-1">
|
|
||||||
{field.fileLabel}
|
|
||||||
{field.isRequired && (
|
|
||||||
<span className="text-destructive font-bold" aria-hidden="true">
|
|
||||||
*
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
Max size: {field.maxSizeMb}MB
|
|
||||||
{field.isMultiple && ` • Files: ${currentFiles.length}/${maxFiles}`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Help / Description Text */}
|
return (
|
||||||
{field.helpText && (
|
<div
|
||||||
<p className="text-xs text-muted-foreground">{field.helpText}</p>
|
key={field.id || field.fileKey}
|
||||||
)}
|
className="flex flex-col gap-2"
|
||||||
|
>
|
||||||
{/* Selected Files List */}
|
{/* Field Header */}
|
||||||
{currentFiles.length > 0 && (
|
<div className="flex flex-col md:flex-row md:items-baseline justify-between gap-1">
|
||||||
<div className="flex flex-col gap-2">
|
<label className="text-sm font-semibold text-foreground flex items-center gap-1.5">
|
||||||
{currentFiles.map((fileObj, idx) => (
|
<span className="flex items-center gap-1">
|
||||||
<div
|
{field.fileLabel}
|
||||||
key={`${fileObj.name}-${idx}`}
|
{field.isRequired && (
|
||||||
className={cn(
|
<span
|
||||||
"flex items-center justify-between p-3 rounded-lg border bg-card transition shadow-2xs hover:shadow-xs",
|
className="text-destructive font-bold"
|
||||||
fieldError ? "border-destructive/30" : "border-border"
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
*
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
>
|
</span>
|
||||||
<div className="flex items-center gap-3 min-w-0">
|
{isUploaded && (
|
||||||
<div className="p-2 bg-muted rounded-md flex items-center justify-center">
|
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-50 px-2 py-0.5 text-[11px] font-medium text-emerald-600 dark:bg-emerald-500/10 dark:text-emerald-400">
|
||||||
<FileIcon name={fileObj.name} className="h-5 w-5" />
|
<CheckCircle2 className="h-3 w-3" /> Already uploaded
|
||||||
</div>
|
</span>
|
||||||
|
)}
|
||||||
<div className="min-w-0">
|
</label>
|
||||||
<p className="text-sm font-medium text-foreground truncate max-w-[200px] md:max-w-md" title={fileObj.name}>
|
|
||||||
{fileObj.name}
|
<span className="text-xs text-muted-foreground">
|
||||||
</p>
|
Max size: {field.maxSizeMb}MB
|
||||||
<div className="flex items-center gap-2 mt-0.5">
|
{field.isMultiple &&
|
||||||
<span className="text-xs text-muted-foreground">
|
` • Files: ${currentFiles.length}/${maxFiles}`}
|
||||||
{formatBytes(fileObj.size)}
|
</span>
|
||||||
</span>
|
</div>
|
||||||
<span className="flex items-center gap-0.5 text-xs text-primary font-medium">
|
|
||||||
<CheckCircle2 className="h-3 w-3" /> Ready
|
{/* Help / Description Text */}
|
||||||
</span>
|
{field.helpText && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{field.helpText}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Selected Files List */}
|
||||||
|
{currentFiles.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{currentFiles.map((fileObj, idx) => (
|
||||||
|
<div
|
||||||
|
key={`${fileObj.name}-${idx}`}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-between p-3 rounded-lg border bg-card transition shadow-2xs hover:shadow-xs",
|
||||||
|
fieldError ? "border-destructive/30" : "border-border",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<div className="p-2 bg-muted rounded-md flex items-center justify-center">
|
||||||
|
<FileIcon name={fileObj.name} className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p
|
||||||
|
className="text-sm font-medium text-foreground truncate max-w-[200px] md:max-w-md"
|
||||||
|
title={fileObj.name}
|
||||||
|
>
|
||||||
|
{fileObj.name}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2 mt-0.5">
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{formatBytes(fileObj.size)}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-0.5 text-xs text-primary font-medium">
|
||||||
|
<CheckCircle2 className="h-3 w-3" /> Ready
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => removeFile(field.fileKey, idx)}
|
||||||
|
className={cn(
|
||||||
|
"p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors",
|
||||||
|
disabled && "opacity-50 pointer-events-none",
|
||||||
|
)}
|
||||||
|
aria-label={`Remove file ${fileObj.name}`}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Hidden inputs to represent file details in traditional form submissions */}
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name={
|
||||||
|
field.isMultiple
|
||||||
|
? `${field.fileKey}[]`
|
||||||
|
: field.fileKey
|
||||||
|
}
|
||||||
|
value={fileObj.name}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Dropzone area */}
|
||||||
|
{!reachedLimit &&
|
||||||
|
(variant === "minimal" ? (
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() =>
|
||||||
|
fileInputRefs.current[field.fileKey]?.click()
|
||||||
|
}
|
||||||
|
className="gap-1.5 cursor-pointer"
|
||||||
|
>
|
||||||
|
<UploadCloud className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span>{isUploaded ? "Replace File" : "Upload File"}</span>
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref={(el) => {
|
||||||
|
if (fileInputRefs.current) {
|
||||||
|
fileInputRefs.current[field.fileKey] = el;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
multiple={field.isMultiple}
|
||||||
|
accept={acceptString}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => handleFileSelect(e, field)}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Accepts:{" "}
|
||||||
|
{field.allowedExtensions.join(", ").toUpperCase() ||
|
||||||
|
"All"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : isUploaded ? (
|
||||||
|
// Uploaded state: a solid success panel that still doubles as a
|
||||||
|
// replace target (click anywhere or drag a new file onto it).
|
||||||
|
<div
|
||||||
|
onDragOver={(e) => handleDrag(e, field.fileKey, true)}
|
||||||
|
onDragLeave={(e) => handleDrag(e, field.fileKey, false)}
|
||||||
|
onDrop={(e) => handleDrop(e, field)}
|
||||||
|
className={cn(
|
||||||
|
"group relative flex items-center gap-4 rounded-lg border p-4 transition-all",
|
||||||
|
isDragOver
|
||||||
|
? "border-2 border-dashed border-primary bg-primary/5 dark:bg-primary/10"
|
||||||
|
: "border-emerald-300/70 bg-emerald-50/60 dark:border-emerald-500/30 dark:bg-emerald-500/10",
|
||||||
|
disabled &&
|
||||||
|
"opacity-50 pointer-events-none cursor-not-allowed",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
multiple={field.isMultiple}
|
||||||
|
accept={acceptString}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => handleFileSelect(e, field)}
|
||||||
|
id={`file-input-${field.fileKey}`}
|
||||||
|
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed"
|
||||||
|
aria-label={`Replace ${field.fileLabel}`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-emerald-100 text-emerald-600 dark:bg-emerald-500/20 dark:text-emerald-400">
|
||||||
|
{isDragOver ? (
|
||||||
|
<UploadCloud className="h-5 w-5 animate-bounce" />
|
||||||
|
) : (
|
||||||
|
<CheckCircle2 className="h-5 w-5" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<div className="min-w-0 flex-1">
|
||||||
type="button"
|
<p className="text-sm font-semibold text-foreground">
|
||||||
disabled={disabled}
|
{isDragOver ? "Drop to replace" : "Document uploaded"}
|
||||||
onClick={() => removeFile(field.fileKey, idx)}
|
</p>
|
||||||
className={cn(
|
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||||
"p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors",
|
{isDragOver
|
||||||
disabled && "opacity-50 pointer-events-none"
|
? "Release to replace the document on file."
|
||||||
)}
|
: "Saved to your application. Drag a new file here or click to replace it."}
|
||||||
aria-label={`Remove file ${fileObj.name}`}
|
</p>
|
||||||
>
|
</div>
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</button>
|
<span className="hidden shrink-0 items-center gap-1.5 rounded-md border border-border bg-card px-3 py-1.5 text-xs font-medium text-foreground shadow-2xs transition group-hover:border-primary/50 group-hover:text-primary sm:inline-flex">
|
||||||
|
<UploadCloud className="h-3.5 w-3.5" />
|
||||||
{/* Hidden inputs to represent file details in traditional form submissions */}
|
Replace
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
onDragOver={(e) => handleDrag(e, field.fileKey, true)}
|
||||||
|
onDragLeave={(e) => handleDrag(e, field.fileKey, false)}
|
||||||
|
onDrop={(e) => handleDrop(e, field)}
|
||||||
|
className={cn(
|
||||||
|
"relative border-2 border-dashed rounded-lg p-6 flex flex-col items-center justify-center text-center transition-all bg-card/50",
|
||||||
|
isDragOver
|
||||||
|
? "border-primary bg-primary/5 dark:bg-primary/10"
|
||||||
|
: "border-border hover:border-primary/50 hover:bg-muted/10",
|
||||||
|
fieldError &&
|
||||||
|
"border-destructive hover:border-destructive/80",
|
||||||
|
disabled &&
|
||||||
|
"opacity-50 pointer-events-none cursor-not-allowed",
|
||||||
|
)}
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
type="hidden"
|
type="file"
|
||||||
name={field.isMultiple ? `${field.fileKey}[]` : field.fileKey}
|
multiple={field.isMultiple}
|
||||||
value={fileObj.name}
|
accept={acceptString}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => handleFileSelect(e, field)}
|
||||||
|
id={`file-input-${field.fileKey}`}
|
||||||
|
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div className="p-3 bg-muted rounded-full mb-3 text-muted-foreground transition group-hover:scale-110">
|
||||||
|
<UploadCloud
|
||||||
|
className={cn(
|
||||||
|
"h-6 w-6 text-muted-foreground",
|
||||||
|
isDragOver && "text-primary animate-bounce",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm font-semibold text-foreground">
|
||||||
|
Drag & drop your file here, or{" "}
|
||||||
|
<span className="text-primary font-bold hover:underline">
|
||||||
|
browse
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Supported formats:{" "}
|
||||||
|
{field.allowedExtensions.join(", ").toUpperCase() ||
|
||||||
|
"All"}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Dropzone area */}
|
{/* Validation Error Message */}
|
||||||
{!reachedLimit && (
|
{fieldError && (
|
||||||
variant === "minimal" ? (
|
<div className="flex items-center gap-1.5 mt-1 text-xs text-destructive animate-in fade-in slide-in-from-top-1 duration-200">
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<AlertCircle className="h-3.5 w-3.5" />
|
||||||
<Button
|
<span>{fieldError}</span>
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={disabled}
|
|
||||||
onClick={() => fileInputRefs.current[field.fileKey]?.click()}
|
|
||||||
className="gap-1.5 cursor-pointer"
|
|
||||||
>
|
|
||||||
<UploadCloud className="h-4 w-4 text-muted-foreground" />
|
|
||||||
<span>Upload File</span>
|
|
||||||
</Button>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
ref={(el) => {
|
|
||||||
if (fileInputRefs.current) {
|
|
||||||
fileInputRefs.current[field.fileKey] = el;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
multiple={field.isMultiple}
|
|
||||||
accept={acceptString}
|
|
||||||
disabled={disabled}
|
|
||||||
onChange={(e) => handleFileSelect(e, field)}
|
|
||||||
className="hidden"
|
|
||||||
/>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
Accepts: {field.allowedExtensions.join(", ").toUpperCase() || "All"}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
)}
|
||||||
<div
|
</div>
|
||||||
onDragOver={(e) => handleDrag(e, field.fileKey, true)}
|
);
|
||||||
onDragLeave={(e) => handleDrag(e, field.fileKey, false)}
|
})}
|
||||||
onDrop={(e) => handleDrop(e, field)}
|
</div>
|
||||||
className={cn(
|
|
||||||
"relative border-2 border-dashed rounded-lg p-6 flex flex-col items-center justify-center text-center transition-all bg-card/50",
|
|
||||||
isDragOver
|
|
||||||
? "border-primary bg-primary/5 dark:bg-primary/10"
|
|
||||||
: "border-border hover:border-primary/50 hover:bg-muted/10",
|
|
||||||
fieldError && "border-destructive hover:border-destructive/80",
|
|
||||||
disabled && "opacity-50 pointer-events-none cursor-not-allowed"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
multiple={field.isMultiple}
|
|
||||||
accept={acceptString}
|
|
||||||
disabled={disabled}
|
|
||||||
onChange={(e) => handleFileSelect(e, field)}
|
|
||||||
id={`file-input-${field.fileKey}`}
|
|
||||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="p-3 bg-muted rounded-full mb-3 text-muted-foreground transition group-hover:scale-110">
|
|
||||||
<UploadCloud className={cn("h-6 w-6 text-muted-foreground", isDragOver && "text-primary animate-bounce")} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-sm font-semibold text-foreground">
|
|
||||||
Drag & drop your file here, or <span className="text-primary font-bold hover:underline">browse</span>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
|
||||||
Supported formats: {field.allowedExtensions.join(", ").toUpperCase() || "All"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Validation Error Message */}
|
|
||||||
{fieldError && (
|
|
||||||
<div className="flex items-center gap-1.5 mt-1 text-xs text-destructive animate-in fade-in slide-in-from-top-1 duration-200">
|
|
||||||
<AlertCircle className="h-3.5 w-3.5" />
|
|
||||||
<span>{fieldError}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
276
pnpm-lock.yaml
generated
276
pnpm-lock.yaml
generated
@@ -10,7 +10,7 @@ importers:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@mantine/dates':
|
'@mantine/dates':
|
||||||
specifier: ^9.3.2
|
specifier: ^9.3.2
|
||||||
version: 9.4.0(@mantine/core@9.4.0(@mantine/hooks@9.4.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.4.0(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
version: 9.3.2(@mantine/core@9.3.2(@mantine/hooks@9.3.2(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.3.2(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@commitlint/cli':
|
'@commitlint/cli':
|
||||||
specifier: ^19.5.0
|
specifier: ^19.5.0
|
||||||
@@ -86,10 +86,10 @@ importers:
|
|||||||
version: 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)))
|
version: 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)))
|
||||||
'@tria-plc/api-common':
|
'@tria-plc/api-common':
|
||||||
specifier: file:../../local-packages/tria-plc-api-common-1.4.3.tgz
|
specifier: file:../../local-packages/tria-plc-api-common-1.4.3.tgz
|
||||||
version: file:local-packages/tria-plc-api-common-1.4.3.tgz(f4d5d43aaace93ac25ed0343d18ebc9e)
|
version: file:local-packages/tria-plc-api-common-1.4.3.tgz(bad2eb10df48448775040459098de142)
|
||||||
'@tria-plc/iamapi-common':
|
'@tria-plc/iamapi-common':
|
||||||
specifier: file:../../local-packages/tria-plc-iamapi-common-0.7.6.tgz
|
specifier: file:../../local-packages/tria-plc-iamapi-common-0.7.7.tgz
|
||||||
version: file:local-packages/tria-plc-iamapi-common-0.7.6.tgz(578386f46cf99fd4720e3e99f196f69e)
|
version: file:local-packages/tria-plc-iamapi-common-0.7.7.tgz(578386f46cf99fd4720e3e99f196f69e)
|
||||||
amqp-connection-manager:
|
amqp-connection-manager:
|
||||||
specifier: ^5.0.0
|
specifier: ^5.0.0
|
||||||
version: 5.0.0(amqplib@2.0.1)
|
version: 5.0.0(amqplib@2.0.1)
|
||||||
@@ -105,9 +105,15 @@ importers:
|
|||||||
class-validator:
|
class-validator:
|
||||||
specifier: ^0.14.1
|
specifier: ^0.14.1
|
||||||
version: 0.14.4
|
version: 0.14.4
|
||||||
|
cross-env:
|
||||||
|
specifier: ^10.1.0
|
||||||
|
version: 10.1.0
|
||||||
dotenv:
|
dotenv:
|
||||||
specifier: ^17.4.2
|
specifier: ^17.4.2
|
||||||
version: 17.4.2
|
version: 17.4.2
|
||||||
|
dotenv-cli:
|
||||||
|
specifier: ^11.0.0
|
||||||
|
version: 11.0.0
|
||||||
handlebars:
|
handlebars:
|
||||||
specifier: ^4.7.9
|
specifier: ^4.7.9
|
||||||
version: 4.7.9
|
version: 4.7.9
|
||||||
@@ -215,8 +221,8 @@ importers:
|
|||||||
specifier: ^5.100.11
|
specifier: ^5.100.11
|
||||||
version: 5.101.0(react@19.2.6)
|
version: 5.101.0(react@19.2.6)
|
||||||
'@tria-plc/iamui':
|
'@tria-plc/iamui':
|
||||||
specifier: file:../../../local-packages/tria-plc-iamui-0.0.3.tgz
|
specifier: file:../../../local-packages/tria-plc-iamui-0.1.1.tgz
|
||||||
version: file:local-packages/tria-plc-iamui-0.0.3.tgz(0ce39b7e349029277dcd938d06eeb0f7)
|
version: file:local-packages/tria-plc-iamui-0.1.1.tgz(0ce39b7e349029277dcd938d06eeb0f7)
|
||||||
axios:
|
axios:
|
||||||
specifier: ^1.7.7
|
specifier: ^1.7.7
|
||||||
version: 1.17.0
|
version: 1.17.0
|
||||||
@@ -327,8 +333,8 @@ importers:
|
|||||||
specifier: ^5.59.0
|
specifier: ^5.59.0
|
||||||
version: 5.101.0(react@19.2.6)
|
version: 5.101.0(react@19.2.6)
|
||||||
'@tria-plc/iamui':
|
'@tria-plc/iamui':
|
||||||
specifier: file:../../../local-packages/tria-plc-iamui-0.0.3.tgz
|
specifier: file:../../../local-packages/tria-plc-iamui-0.1.1.tgz
|
||||||
version: file:local-packages/tria-plc-iamui-0.0.3.tgz(0ce39b7e349029277dcd938d06eeb0f7)
|
version: file:local-packages/tria-plc-iamui-0.1.1.tgz(0ce39b7e349029277dcd938d06eeb0f7)
|
||||||
axios:
|
axios:
|
||||||
specifier: ^1.7.7
|
specifier: ^1.7.7
|
||||||
version: 1.17.0
|
version: 1.17.0
|
||||||
@@ -1476,6 +1482,9 @@ packages:
|
|||||||
'@emotion/weak-memoize@0.4.0':
|
'@emotion/weak-memoize@0.4.0':
|
||||||
resolution: {integrity: sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==}
|
resolution: {integrity: sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==}
|
||||||
|
|
||||||
|
'@epic-web/invariant@1.0.0':
|
||||||
|
resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==}
|
||||||
|
|
||||||
'@esbuild/aix-ppc64@0.21.5':
|
'@esbuild/aix-ppc64@0.21.5':
|
||||||
resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==}
|
resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -2034,10 +2043,10 @@ packages:
|
|||||||
react: ^19.2.0
|
react: ^19.2.0
|
||||||
react-dom: ^19.2.0
|
react-dom: ^19.2.0
|
||||||
|
|
||||||
'@mantine/core@9.4.0':
|
'@mantine/core@9.3.2':
|
||||||
resolution: {integrity: sha512-BvTzaJ5Nut4ZiWLCcy1/cRXE8yjCgxsBhyAynY1pEg6XBw/fChEqSgbEB2dybOnkiAxvYt89TrnTrBBo9qJtmg==}
|
resolution: {integrity: sha512-Upy/Z9Sj2eW2dGrFgUy/2kISVsxMTBYTDfP2TFdsIA3PdPSBkdqfSOPX/ug3d3F3a4bnwQSqcO6+aPEpBIh8dg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@mantine/hooks': 9.4.0
|
'@mantine/hooks': 9.3.2
|
||||||
react: ^19.2.0
|
react: ^19.2.0
|
||||||
react-dom: ^19.2.0
|
react-dom: ^19.2.0
|
||||||
|
|
||||||
@@ -2050,11 +2059,11 @@ packages:
|
|||||||
react: ^18.x || ^19.x
|
react: ^18.x || ^19.x
|
||||||
react-dom: ^18.x || ^19.x
|
react-dom: ^18.x || ^19.x
|
||||||
|
|
||||||
'@mantine/dates@9.4.0':
|
'@mantine/dates@9.3.2':
|
||||||
resolution: {integrity: sha512-15iCWxykutEVoFUPTq4z+An3YQZb7UH6Fwzb3stsVhTMLm6FyGLYSSB6PL+9sBV1TPllIJSm1IizY2SSTDGqQQ==}
|
resolution: {integrity: sha512-MzHrXGoOb3rCnHBlsI/BPAjC8K5tZ4f8pzg66tsTsupVCQBaBpHSVatQwNRJ6K2JC9pyCyTa9BL803C8AiWycA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@mantine/core': 9.4.0
|
'@mantine/core': 9.3.2
|
||||||
'@mantine/hooks': 9.4.0
|
'@mantine/hooks': 9.3.2
|
||||||
dayjs: '>=1.0.0'
|
dayjs: '>=1.0.0'
|
||||||
react: ^19.2.0
|
react: ^19.2.0
|
||||||
react-dom: ^19.2.0
|
react-dom: ^19.2.0
|
||||||
@@ -2069,8 +2078,8 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^19.2.0
|
react: ^19.2.0
|
||||||
|
|
||||||
'@mantine/hooks@9.4.0':
|
'@mantine/hooks@9.3.2':
|
||||||
resolution: {integrity: sha512-SUkge8KlhyLoSSKhEHERx7jLotNu632kldhQPlLn5kbOuJixoEbU8fKcuwpXSsZTtzRgCCT2IkoN+rORhtZTHg==}
|
resolution: {integrity: sha512-jOjpUe0x1A/k3XUiu2/aaSCasXRI5ZKZOucm3ypwsvm9F2u8C1xzwBvagrzlbNZwJRF5vNYIJtCaT7NunPyc0A==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^19.2.0
|
react: ^19.2.0
|
||||||
|
|
||||||
@@ -4087,9 +4096,31 @@ packages:
|
|||||||
rxjs: ^7.8.0
|
rxjs: ^7.8.0
|
||||||
typeorm: ^0.3.0
|
typeorm: ^0.3.0
|
||||||
|
|
||||||
'@tria-plc/iamui@file:local-packages/tria-plc-iamui-0.0.3.tgz':
|
'@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-0.7.7.tgz':
|
||||||
resolution: {integrity: sha512-aZhIeNq2Uui7TUkG88G9QTBqdhaVB5kvqfd47HLgVE7qKkVyfjlm4nwlSWnHh5MO+CjgP9vRi6ILwMJx1ULO8g==, tarball: file:local-packages/tria-plc-iamui-0.0.3.tgz}
|
resolution: {integrity: sha512-zW7dIEnoai9NSagFsjdH3CyU4dizhFnV9AkO0sOb8MJhFGaifFaMLyWkB9QMQvUIxFRziOcqw3vR0yjqLmTNPQ==, tarball: file:local-packages/tria-plc-iamapi-common-0.7.7.tgz}
|
||||||
version: 0.0.3
|
version: 0.7.7
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
peerDependencies:
|
||||||
|
'@nestjs/axios': ^4.0.0
|
||||||
|
'@nestjs/common': ^11.0.0
|
||||||
|
'@nestjs/core': ^11.0.0
|
||||||
|
'@nestjs/jwt': ^11.0.0
|
||||||
|
'@nestjs/microservices': ^11.0.0
|
||||||
|
'@nestjs/passport': ^11.0.0
|
||||||
|
'@nestjs/swagger': ^11.0.0
|
||||||
|
'@nestjs/throttler': ^6.0.0
|
||||||
|
'@nestjs/typeorm': ^11.0.0
|
||||||
|
'@tria-plc/api-common': '*'
|
||||||
|
axios: ^1.9.0
|
||||||
|
class-transformer: ^0.5.1
|
||||||
|
class-validator: ^0.14.1
|
||||||
|
reflect-metadata: ^0.2.0
|
||||||
|
rxjs: ^7.8.0
|
||||||
|
typeorm: ^0.3.0
|
||||||
|
|
||||||
|
'@tria-plc/iamui@file:local-packages/tria-plc-iamui-0.1.1.tgz':
|
||||||
|
resolution: {integrity: sha512-FTihoH0lIqKV/0s+FTJZokQw8xX3XztXIqT8eEYEZgDM2xyzVSCHY8pHCUxw0MQtiR8R97zxZJ/o192eWt+E/g==, tarball: file:local-packages/tria-plc-iamui-0.1.1.tgz}
|
||||||
|
version: 0.1.1
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^18.3.1 || ^19.0.0
|
react: ^18.3.1 || ^19.0.0
|
||||||
@@ -5734,6 +5765,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==}
|
resolution: {integrity: sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==}
|
||||||
engines: {node: '>=18.x'}
|
engines: {node: '>=18.x'}
|
||||||
|
|
||||||
|
cross-env@10.1.0:
|
||||||
|
resolution: {integrity: sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
cross-spawn@7.0.6:
|
cross-spawn@7.0.6:
|
||||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
@@ -6069,6 +6105,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==}
|
resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
dotenv-cli@11.0.0:
|
||||||
|
resolution: {integrity: sha512-r5pA8idbk7GFWuHEU7trSTflWcdBpQEK+Aw17UrSHjS6CReuhrrPcyC3zcQBPQvhArRHnBo/h6eLH1fkCvNlww==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
dotenv-expand@12.0.3:
|
dotenv-expand@12.0.3:
|
||||||
resolution: {integrity: sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==}
|
resolution: {integrity: sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -11778,6 +11818,8 @@ snapshots:
|
|||||||
|
|
||||||
'@emotion/weak-memoize@0.4.0': {}
|
'@emotion/weak-memoize@0.4.0': {}
|
||||||
|
|
||||||
|
'@epic-web/invariant@1.0.0': {}
|
||||||
|
|
||||||
'@esbuild/aix-ppc64@0.21.5':
|
'@esbuild/aix-ppc64@0.21.5':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -12440,10 +12482,10 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@types/react'
|
- '@types/react'
|
||||||
|
|
||||||
'@mantine/core@9.4.0(@mantine/hooks@9.4.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
|
'@mantine/core@9.3.2(@mantine/hooks@9.3.2(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@floating-ui/react': 0.27.19(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
'@floating-ui/react': 0.27.19(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||||
'@mantine/hooks': 9.4.0(react@19.2.6)
|
'@mantine/hooks': 9.3.2(react@19.2.6)
|
||||||
clsx: 2.1.1
|
clsx: 2.1.1
|
||||||
react: 19.2.6
|
react: 19.2.6
|
||||||
react-dom: 19.2.6(react@19.2.6)
|
react-dom: 19.2.6(react@19.2.6)
|
||||||
@@ -12453,19 +12495,19 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@types/react'
|
- '@types/react'
|
||||||
|
|
||||||
'@mantine/dates@7.17.8(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.3.0(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
|
'@mantine/dates@7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@mantine/core': 9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
'@mantine/core': 7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||||
'@mantine/hooks': 9.3.0(react@19.2.6)
|
'@mantine/hooks': 7.17.8(react@19.2.6)
|
||||||
clsx: 2.1.1
|
clsx: 2.1.1
|
||||||
dayjs: 1.11.21
|
dayjs: 1.11.21
|
||||||
react: 19.2.6
|
react: 19.2.6
|
||||||
react-dom: 19.2.6(react@19.2.6)
|
react-dom: 19.2.6(react@19.2.6)
|
||||||
|
|
||||||
'@mantine/dates@9.4.0(@mantine/core@9.4.0(@mantine/hooks@9.4.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.4.0(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
|
'@mantine/dates@9.3.2(@mantine/core@9.3.2(@mantine/hooks@9.3.2(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.3.2(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@mantine/core': 9.4.0(@mantine/hooks@9.4.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
'@mantine/core': 9.3.2(@mantine/hooks@9.3.2(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||||
'@mantine/hooks': 9.4.0(react@19.2.6)
|
'@mantine/hooks': 9.3.2(react@19.2.6)
|
||||||
clsx: 2.1.1
|
clsx: 2.1.1
|
||||||
dayjs: 1.11.21
|
dayjs: 1.11.21
|
||||||
react: 19.2.6
|
react: 19.2.6
|
||||||
@@ -12483,7 +12525,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.6
|
react: 19.2.6
|
||||||
|
|
||||||
'@mantine/hooks@9.4.0(react@19.2.6)':
|
'@mantine/hooks@9.3.2(react@19.2.6)':
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.6
|
react: 19.2.6
|
||||||
|
|
||||||
@@ -15191,6 +15233,50 @@ snapshots:
|
|||||||
|
|
||||||
'@tootallnate/quickjs-emscripten@0.23.0': {}
|
'@tootallnate/quickjs-emscripten@0.23.0': {}
|
||||||
|
|
||||||
|
'@tria-plc/api-common@file:local-packages/tria-plc-api-common-1.4.3.tgz(bad2eb10df48448775040459098de142)':
|
||||||
|
dependencies:
|
||||||
|
'@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2)
|
||||||
|
'@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
|
'@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
|
'@nestjs/jwt': 10.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))
|
||||||
|
'@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
|
'@nestjs/passport': 10.0.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0)
|
||||||
|
'@nestjs/swagger': 11.4.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)
|
||||||
|
'@nestjs/throttler': 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)
|
||||||
|
'@nestjs/typeorm': 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)))
|
||||||
|
'@tria-plc/iamapi-common': file:local-packages/tria-plc-iamapi-common-0.7.7.tgz(578386f46cf99fd4720e3e99f196f69e)
|
||||||
|
argon2: 0.43.1
|
||||||
|
axios: 1.17.0
|
||||||
|
change-case: 5.4.4
|
||||||
|
class-transformer: 0.5.1
|
||||||
|
class-validator: 0.14.4
|
||||||
|
dotenv: 16.6.1
|
||||||
|
ethiopian-calendar-date-converter: 2.1.6
|
||||||
|
ethiopian-date: 0.0.6
|
||||||
|
exceljs: 4.4.0
|
||||||
|
file-type: 21.3.4
|
||||||
|
handlebars: 4.7.9
|
||||||
|
handlebars-helpers: 0.10.0
|
||||||
|
jmespath: 0.16.0
|
||||||
|
jose: 5.10.0
|
||||||
|
jsonwebtoken: 9.0.3
|
||||||
|
libphonenumber-js: 1.13.6
|
||||||
|
libreoffice-convert: 1.8.1
|
||||||
|
nestjs-minio-client: 2.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)
|
||||||
|
passport-jwt: 4.0.1
|
||||||
|
qrcode: 1.5.4
|
||||||
|
reflect-metadata: 0.2.2
|
||||||
|
rxjs: 7.8.2
|
||||||
|
style-object-to-css-string: 1.1.3
|
||||||
|
typeorm: 0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))
|
||||||
|
typeorm-extension: 3.9.0(@faker-js/faker@10.4.0)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)))
|
||||||
|
uuid: 11.1.1
|
||||||
|
xlsx: 0.18.5
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@faker-js/faker'
|
||||||
|
- debug
|
||||||
|
- supports-color
|
||||||
|
|
||||||
'@tria-plc/api-common@file:local-packages/tria-plc-api-common-1.4.3.tgz(c061d697b8a1e15b1d1aba893da7b0e6)':
|
'@tria-plc/api-common@file:local-packages/tria-plc-api-common-1.4.3.tgz(c061d697b8a1e15b1d1aba893da7b0e6)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2)
|
'@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2)
|
||||||
@@ -15235,85 +15321,6 @@ snapshots:
|
|||||||
- debug
|
- debug
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
'@tria-plc/api-common@file:local-packages/tria-plc-api-common-1.4.3.tgz(f4d5d43aaace93ac25ed0343d18ebc9e)':
|
|
||||||
dependencies:
|
|
||||||
'@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2)
|
|
||||||
'@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
|
||||||
'@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
|
||||||
'@nestjs/jwt': 10.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))
|
|
||||||
'@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
|
||||||
'@nestjs/passport': 10.0.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0)
|
|
||||||
'@nestjs/swagger': 11.4.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)
|
|
||||||
'@nestjs/throttler': 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)
|
|
||||||
'@nestjs/typeorm': 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)))
|
|
||||||
'@tria-plc/iamapi-common': file:local-packages/tria-plc-iamapi-common-0.7.6.tgz(578386f46cf99fd4720e3e99f196f69e)
|
|
||||||
argon2: 0.43.1
|
|
||||||
axios: 1.17.0
|
|
||||||
change-case: 5.4.4
|
|
||||||
class-transformer: 0.5.1
|
|
||||||
class-validator: 0.14.4
|
|
||||||
dotenv: 16.6.1
|
|
||||||
ethiopian-calendar-date-converter: 2.1.6
|
|
||||||
ethiopian-date: 0.0.6
|
|
||||||
exceljs: 4.4.0
|
|
||||||
file-type: 21.3.4
|
|
||||||
handlebars: 4.7.9
|
|
||||||
handlebars-helpers: 0.10.0
|
|
||||||
jmespath: 0.16.0
|
|
||||||
jose: 5.10.0
|
|
||||||
jsonwebtoken: 9.0.3
|
|
||||||
libphonenumber-js: 1.13.6
|
|
||||||
libreoffice-convert: 1.8.1
|
|
||||||
nestjs-minio-client: 2.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)
|
|
||||||
passport-jwt: 4.0.1
|
|
||||||
qrcode: 1.5.4
|
|
||||||
reflect-metadata: 0.2.2
|
|
||||||
rxjs: 7.8.2
|
|
||||||
style-object-to-css-string: 1.1.3
|
|
||||||
typeorm: 0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))
|
|
||||||
typeorm-extension: 3.9.0(@faker-js/faker@10.4.0)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)))
|
|
||||||
uuid: 11.1.1
|
|
||||||
xlsx: 0.18.5
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- '@faker-js/faker'
|
|
||||||
- debug
|
|
||||||
- supports-color
|
|
||||||
|
|
||||||
'@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-0.7.6.tgz(578386f46cf99fd4720e3e99f196f69e)':
|
|
||||||
dependencies:
|
|
||||||
'@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2)
|
|
||||||
'@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
|
||||||
'@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
|
||||||
'@nestjs/jwt': 10.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))
|
|
||||||
'@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
|
||||||
'@nestjs/passport': 10.0.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0)
|
|
||||||
'@nestjs/swagger': 11.4.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)
|
|
||||||
'@nestjs/throttler': 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)
|
|
||||||
'@nestjs/typeorm': 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)))
|
|
||||||
'@tria-plc/api-common': file:local-packages/tria-plc-api-common-1.4.3.tgz(f4d5d43aaace93ac25ed0343d18ebc9e)
|
|
||||||
api-common: 1.2.2
|
|
||||||
argon2: 0.43.1
|
|
||||||
axios: 1.17.0
|
|
||||||
class-transformer: 0.5.1
|
|
||||||
class-validator: 0.14.4
|
|
||||||
dotenv: 17.4.2
|
|
||||||
ethiopian-date: 0.0.6
|
|
||||||
file-type: 21.3.4
|
|
||||||
jose: 5.10.0
|
|
||||||
jsonwebtoken: 9.0.3
|
|
||||||
libphonenumber-js: 1.13.6
|
|
||||||
nestjs-minio-client: 2.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)
|
|
||||||
passport-jwt: 4.0.1
|
|
||||||
qrcode: 1.5.4
|
|
||||||
reflect-metadata: 0.2.2
|
|
||||||
rxjs: 7.8.2
|
|
||||||
typeorm: 0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))
|
|
||||||
typeorm-extension: 3.9.0(@faker-js/faker@10.4.0)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)))
|
|
||||||
uuid: 11.1.1
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- '@faker-js/faker'
|
|
||||||
- supports-color
|
|
||||||
|
|
||||||
'@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-0.7.6.tgz(c97ba831ddde82920910406ab5262991)':
|
'@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-0.7.6.tgz(c97ba831ddde82920910406ab5262991)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2)
|
'@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2)
|
||||||
@@ -15349,7 +15356,42 @@ snapshots:
|
|||||||
- '@faker-js/faker'
|
- '@faker-js/faker'
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
'@tria-plc/iamui@file:local-packages/tria-plc-iamui-0.0.3.tgz(0ce39b7e349029277dcd938d06eeb0f7)':
|
'@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-0.7.7.tgz(578386f46cf99fd4720e3e99f196f69e)':
|
||||||
|
dependencies:
|
||||||
|
'@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2)
|
||||||
|
'@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
|
'@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
|
'@nestjs/jwt': 10.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))
|
||||||
|
'@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
|
'@nestjs/passport': 10.0.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0)
|
||||||
|
'@nestjs/swagger': 11.4.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)
|
||||||
|
'@nestjs/throttler': 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)
|
||||||
|
'@nestjs/typeorm': 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)))
|
||||||
|
'@tria-plc/api-common': file:local-packages/tria-plc-api-common-1.4.3.tgz(bad2eb10df48448775040459098de142)
|
||||||
|
api-common: 1.2.2
|
||||||
|
argon2: 0.43.1
|
||||||
|
axios: 1.17.0
|
||||||
|
class-transformer: 0.5.1
|
||||||
|
class-validator: 0.14.4
|
||||||
|
dotenv: 17.4.2
|
||||||
|
ethiopian-date: 0.0.6
|
||||||
|
file-type: 21.3.4
|
||||||
|
jose: 5.10.0
|
||||||
|
jsonwebtoken: 9.0.3
|
||||||
|
libphonenumber-js: 1.13.6
|
||||||
|
nestjs-minio-client: 2.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)
|
||||||
|
passport-jwt: 4.0.1
|
||||||
|
qrcode: 1.5.4
|
||||||
|
reflect-metadata: 0.2.2
|
||||||
|
rxjs: 7.8.2
|
||||||
|
typeorm: 0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))
|
||||||
|
typeorm-extension: 3.9.0(@faker-js/faker@10.4.0)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)))
|
||||||
|
uuid: 11.1.1
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@faker-js/faker'
|
||||||
|
- supports-color
|
||||||
|
|
||||||
|
'@tria-plc/iamui@file:local-packages/tria-plc-iamui-0.1.1.tgz(0ce39b7e349029277dcd938d06eeb0f7)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@emotion/react': 11.14.0(@types/react@18.3.31)(react@19.2.6)
|
'@emotion/react': 11.14.0(@types/react@18.3.31)(react@19.2.6)
|
||||||
'@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@18.3.31)(react@19.2.6))(@types/react@18.3.31)(react@19.2.6)
|
'@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@18.3.31)(react@19.2.6))(@types/react@18.3.31)(react@19.2.6)
|
||||||
@@ -15357,7 +15399,7 @@ snapshots:
|
|||||||
'@lottiefiles/react-lottie-player': 3.6.0(react@19.2.6)
|
'@lottiefiles/react-lottie-player': 3.6.0(react@19.2.6)
|
||||||
'@mantine/charts': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(recharts@3.8.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)(redux@5.0.1))
|
'@mantine/charts': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(recharts@3.8.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)(redux@5.0.1))
|
||||||
'@mantine/core': 7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
'@mantine/core': 7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||||
'@mantine/dates': 7.17.8(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.3.0(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
'@mantine/dates': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||||
'@mantine/hooks': 7.17.8(react@19.2.6)
|
'@mantine/hooks': 7.17.8(react@19.2.6)
|
||||||
'@mantine/notifications': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
'@mantine/notifications': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||||
'@radix-ui/react-accordion': 1.2.13(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
'@radix-ui/react-accordion': 1.2.13(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||||
@@ -17284,6 +17326,11 @@ snapshots:
|
|||||||
'@types/luxon': 3.7.1
|
'@types/luxon': 3.7.1
|
||||||
luxon: 3.7.2
|
luxon: 3.7.2
|
||||||
|
|
||||||
|
cross-env@10.1.0:
|
||||||
|
dependencies:
|
||||||
|
'@epic-web/invariant': 1.0.0
|
||||||
|
cross-spawn: 7.0.6
|
||||||
|
|
||||||
cross-spawn@7.0.6:
|
cross-spawn@7.0.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
path-key: 3.1.1
|
path-key: 3.1.1
|
||||||
@@ -17575,6 +17622,13 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
is-obj: 2.0.0
|
is-obj: 2.0.0
|
||||||
|
|
||||||
|
dotenv-cli@11.0.0:
|
||||||
|
dependencies:
|
||||||
|
cross-spawn: 7.0.6
|
||||||
|
dotenv: 17.4.2
|
||||||
|
dotenv-expand: 12.0.3
|
||||||
|
minimist: 1.2.8
|
||||||
|
|
||||||
dotenv-expand@12.0.3:
|
dotenv-expand@12.0.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
dotenv: 16.6.1
|
dotenv: 16.6.1
|
||||||
@@ -20179,7 +20233,7 @@ snapshots:
|
|||||||
mantine-react-table@2.0.0-beta.9(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/dates@7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(@tabler/icons-react@3.44.0(react@19.2.6))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
|
mantine-react-table@2.0.0-beta.9(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/dates@7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(@tabler/icons-react@3.44.0(react@19.2.6))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@mantine/core': 7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
'@mantine/core': 7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||||
'@mantine/dates': 7.17.8(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.3.0(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
'@mantine/dates': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||||
'@mantine/hooks': 7.17.8(react@19.2.6)
|
'@mantine/hooks': 7.17.8(react@19.2.6)
|
||||||
'@tabler/icons-react': 3.44.0(react@19.2.6)
|
'@tabler/icons-react': 3.44.0(react@19.2.6)
|
||||||
'@tanstack/match-sorter-utils': 8.19.4
|
'@tanstack/match-sorter-utils': 8.19.4
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
# @tria-plc/iamapi-common
|
|
||||||
|
|
||||||
Standalone IAM NestJS module extracted from the Smart Office monorepo. Provides authentication, user management, organization structure, and record elements functionality as a reusable package for Tria PLC platform services.
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install @tria-plc/iamapi-common --registry=https://npm.pkg.github.com
|
|
||||||
```
|
|
||||||
|
|
||||||
Or with a project-level `.npmrc`:
|
|
||||||
|
|
||||||
```
|
|
||||||
@tria-plc:registry=https://npm.pkg.github.com
|
|
||||||
//npm.pkg.github.com/:_authToken=YOUR_GITHUB_TOKEN
|
|
||||||
```
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { IamModule } from '@tria-plc/iamapi-common';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
imports: [IamModule],
|
|
||||||
})
|
|
||||||
export class AppModule {}
|
|
||||||
```
|
|
||||||
|
|
||||||
The `IamModule` registers all sub-modules: `AuthModule`, `UserModule`, `OrganizationStructureModule`, and `RecordElementModule`.
|
|
||||||
|
|
||||||
Database configuration, migrations, and seeding remain the responsibility of the consuming application.
|
|
||||||
|
|
||||||
## Peer Dependencies
|
|
||||||
|
|
||||||
- `@nestjs/common` ^11
|
|
||||||
- `@nestjs/core` ^11
|
|
||||||
- `@nestjs/jwt` ^11
|
|
||||||
- `@nestjs/passport` ^11
|
|
||||||
- `@nestjs/typeorm` ^11
|
|
||||||
- `@smart-office/be` ^1.0.0
|
|
||||||
- `reflect-metadata` ^0.2
|
|
||||||
- `rxjs` ^7.8
|
|
||||||
- `typeorm` ^0.3
|
|
||||||
|
|
||||||
## Publishing
|
|
||||||
|
|
||||||
Releases are published automatically to GitHub Packages when a version tag (`v*`) is pushed:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git tag v1.0.1
|
|
||||||
git push origin v1.0.1
|
|
||||||
```
|
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm install
|
|
||||||
pnpm build
|
|
||||||
```
|
|
||||||
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@tria-plc/iamapi-common",
|
|
||||||
"version": "0.7.3",
|
|
||||||
"description": "Standalone IAM NestJS module for Tria PLC platform services",
|
|
||||||
"repository": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/Tria-plc/iamapi-common.git"
|
|
||||||
},
|
|
||||||
"author": "Tria PLC",
|
|
||||||
"license": "UNLICENSED",
|
|
||||||
"main": "./dist/index.js",
|
|
||||||
"types": "./dist/index.d.ts",
|
|
||||||
"files": [
|
|
||||||
"dist",
|
|
||||||
"scripts"
|
|
||||||
],
|
|
||||||
"exports": {
|
|
||||||
".": {
|
|
||||||
"types": "./dist/index.d.ts",
|
|
||||||
"require": "./dist/index.js"
|
|
||||||
},
|
|
||||||
"./package.json": "./package.json",
|
|
||||||
"./*.js": "./dist/*.js",
|
|
||||||
"./*": {
|
|
||||||
"types": "./dist/*.d.ts",
|
|
||||||
"require": "./dist/*.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"typesVersions": {
|
|
||||||
"*": {
|
|
||||||
"*": [
|
|
||||||
"./dist/*.d.ts",
|
|
||||||
"./dist/*/index.d.ts"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"publishConfig": {
|
|
||||||
"registry": "https://npm.pkg.github.com"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20"
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"prepare": "husky || true",
|
|
||||||
"test": "echo 'No tests configured' && exit 0",
|
|
||||||
"clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
|
|
||||||
"build": "npm run clean && nest build",
|
|
||||||
"prepublishOnly": "npm run build",
|
|
||||||
"typeorm:cli": "node ./scripts/typeorm-cli.cjs",
|
|
||||||
"migration:run": "npm run typeorm:cli migration:run",
|
|
||||||
"migration:generate": "npm run build && npm run typeorm:cli migration:generate src/db/migrations/IamApiCommonMigration",
|
|
||||||
"migration:create": "npm run typeorm:cli migration:create",
|
|
||||||
"migration:revert": "npm run typeorm:cli migration:revert"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@nestjs/axios": "^4.0.0",
|
|
||||||
"@nestjs/common": "^11.0.0",
|
|
||||||
"@nestjs/core": "^11.0.0",
|
|
||||||
"@nestjs/jwt": "^11.0.0",
|
|
||||||
"@nestjs/microservices": "^11.0.0",
|
|
||||||
"@nestjs/passport": "^11.0.0",
|
|
||||||
"@nestjs/swagger": "^11.0.0",
|
|
||||||
"@nestjs/throttler": "^6.0.0",
|
|
||||||
"@nestjs/typeorm": "^11.0.0",
|
|
||||||
"@tria-plc/api-common": "*",
|
|
||||||
"axios": "^1.9.0",
|
|
||||||
"class-transformer": "^0.5.1",
|
|
||||||
"class-validator": "^0.14.1",
|
|
||||||
"reflect-metadata": "^0.2.0",
|
|
||||||
"rxjs": "^7.8.0",
|
|
||||||
"typeorm": "^0.3.0"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"api-common": "1.2.2",
|
|
||||||
"argon2": "^0.43.0",
|
|
||||||
"dotenv": "^17.4.2",
|
|
||||||
"ethiopian-date": "^0.0.6",
|
|
||||||
"file-type": "^21.3.0",
|
|
||||||
"jose": "^5.3.0",
|
|
||||||
"jsonwebtoken": "^9.0.2",
|
|
||||||
"libphonenumber-js": "^1.12.9",
|
|
||||||
"nestjs-minio-client": "^2.2.0",
|
|
||||||
"passport-jwt": "^4.0.1",
|
|
||||||
"qrcode": "^1.5.4",
|
|
||||||
"typeorm-extension": "^3.9.0",
|
|
||||||
"uuid": "^11.1.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@commitlint/cli": "^19.0.0",
|
|
||||||
"@commitlint/config-conventional": "^19.0.0",
|
|
||||||
"@nestjs/axios": "^4.0.0",
|
|
||||||
"@nestjs/cli": "^11.0.7",
|
|
||||||
"@nestjs/common": "^11.0.0",
|
|
||||||
"@nestjs/core": "^11.0.0",
|
|
||||||
"@nestjs/jwt": "^11.0.0",
|
|
||||||
"@nestjs/microservices": "^11.0.0",
|
|
||||||
"@nestjs/passport": "^11.0.0",
|
|
||||||
"@nestjs/schematics": "^10.2.3",
|
|
||||||
"@nestjs/swagger": "^11.0.0",
|
|
||||||
"@nestjs/testing": "^11.0.8",
|
|
||||||
"@nestjs/throttler": "^6.0.0",
|
|
||||||
"@nestjs/typeorm": "^11.0.0",
|
|
||||||
"@semantic-release/changelog": "^6.0.3",
|
|
||||||
"@semantic-release/git": "^10.0.1",
|
|
||||||
"@semantic-release/github": "^12.0.8",
|
|
||||||
"@semantic-release/npm": "^13.1.5",
|
|
||||||
"@tria-plc/api-common": "^1.4.3",
|
|
||||||
"@types/express": "^4.17.21",
|
|
||||||
"@types/jsonwebtoken": "^9.0.10",
|
|
||||||
"@types/node": "^20.17.32",
|
|
||||||
"@types/passport-jwt": "^4.0.1",
|
|
||||||
"axios": "^1.9.0",
|
|
||||||
"class-transformer": "^0.5.1",
|
|
||||||
"class-validator": "^0.14.1",
|
|
||||||
"husky": "^9.0.0",
|
|
||||||
"reflect-metadata": "^0.2.0",
|
|
||||||
"rxjs": "^7.8.0",
|
|
||||||
"semantic-release": "^25.0.3",
|
|
||||||
"ts-node": "^10.9.2",
|
|
||||||
"tsconfig-paths": "^4.2.0",
|
|
||||||
"typeorm": "^0.3.0",
|
|
||||||
"typescript": "^5.8.3"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
const { spawnSync } = require("child_process");
|
|
||||||
const path = require("path");
|
|
||||||
|
|
||||||
const tsNodeBin = require.resolve("ts-node/dist/bin.js");
|
|
||||||
const typeormCli = require.resolve("typeorm/cli.js");
|
|
||||||
const dataSource = path.resolve(__dirname, "../src/typeorm.config.ts");
|
|
||||||
|
|
||||||
const args = [
|
|
||||||
tsNodeBin,
|
|
||||||
"-r",
|
|
||||||
"tsconfig-paths/register",
|
|
||||||
typeormCli,
|
|
||||||
"-d",
|
|
||||||
dataSource,
|
|
||||||
...process.argv.slice(2),
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = spawnSync(process.execPath, args, { stdio: "inherit" });
|
|
||||||
process.exit(result.status ?? 1);
|
|
||||||
Reference in New Issue
Block a user