mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 18:48:11 +00:00
Merge freight/develop into Warehouse_updates
This commit is contained in:
4
.github/workflows/deploy.yml
vendored
4
.github/workflows/deploy.yml
vendored
@@ -69,8 +69,8 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo "$CHANGED" | grep -q "^apps/edr-freight-api/" && SERVICES+=("freight-api")
|
echo "$CHANGED" | grep -q "^apps/edr-freight-api/" && SERVICES+=("freight-api")
|
||||||
echo "$CHANGED" | grep -q "^apps/edr-freight-web-portal/" && SERVICES+=("freight-portal")
|
echo "$CHANGED" | grep -q "^apps/edr-freight-web/portal/" && SERVICES+=("freight-portal")
|
||||||
echo "$CHANGED" | grep -q "^apps/edr-freight-web-backoffice/" && SERVICES+=("freight-backoffice")
|
echo "$CHANGED" | grep -q "^apps/edr-freight-web/backoffice/" && SERVICES+=("freight-backoffice")
|
||||||
echo "$CHANGED" | grep -q "^apps/edr-passenger-api/" && SERVICES+=("passenger-api")
|
echo "$CHANGED" | grep -q "^apps/edr-passenger-api/" && SERVICES+=("passenger-api")
|
||||||
echo "$CHANGED" | grep -q "^apps/edr-passenger-web/portal/" && SERVICES+=("passenger-portal")
|
echo "$CHANGED" | grep -q "^apps/edr-passenger-web/portal/" && SERVICES+=("passenger-portal")
|
||||||
echo "$CHANGED" | grep -q "^apps/edr-passenger-web/backoffice/" && SERVICES+=("passenger-backoffice")
|
echo "$CHANGED" | grep -q "^apps/edr-passenger-web/backoffice/" && SERVICES+=("passenger-backoffice")
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -29,3 +29,6 @@ coverage/
|
|||||||
*~
|
*~
|
||||||
\#*\#
|
\#*\#
|
||||||
.\#*
|
.\#*
|
||||||
|
branch_structure.json
|
||||||
|
temp_auto_push.bat
|
||||||
|
temp_interactive_push.bat
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ TELEBIRR_TIMEOUT_EXPRESS=15m
|
|||||||
TELEBIRR_PRIVATE_KEY=
|
TELEBIRR_PRIVATE_KEY=
|
||||||
TELEBIRR_PUBLIC_KEY=
|
TELEBIRR_PUBLIC_KEY=
|
||||||
TELEBIRR_INSECURE_TLS=false
|
TELEBIRR_INSECURE_TLS=false
|
||||||
|
|
||||||
|
# Portal pages the payment provider redirects the browser to after payment.
|
||||||
|
# Point these at the freight portal's public payment result routes.
|
||||||
|
PAYMENT_RETURN_URL=http://localhost:5173/payment/success
|
||||||
|
PAYMENT_FAILURE_URL=http://localhost:5173/payment/failure
|
||||||
# JWT (used by @tria-plc/api-common SharedAuthModule)
|
# JWT (used by @tria-plc/api-common SharedAuthModule)
|
||||||
JWT_SECRET=
|
JWT_SECRET=
|
||||||
JWT_ACCESS_TOKEN_SECRET=
|
JWT_ACCESS_TOKEN_SECRET=
|
||||||
|
|||||||
@@ -24,14 +24,13 @@ import { TrainSetsModule } from "./modules/train-sets/train-sets.module";
|
|||||||
import { TrainSchedulesModule } from "./modules/train-schedules/train-schedules.module";
|
import { TrainSchedulesModule } from "./modules/train-schedules/train-schedules.module";
|
||||||
import { TrainSchedulingModule } from "./modules/train-scheduling/train-scheduling.module";
|
import { TrainSchedulingModule } from "./modules/train-scheduling/train-scheduling.module";
|
||||||
import { SchedulingRescheduleModule } from "./modules/scheduling-reschedule/scheduling-reschedule.module";
|
import { SchedulingRescheduleModule } from "./modules/scheduling-reschedule/scheduling-reschedule.module";
|
||||||
import { CustomersModule } from "./modules/customers/customers.module";
|
|
||||||
import { CompaniesModule } from "./modules/companies/companies.module";
|
import { CompaniesModule } from "./modules/companies/companies.module";
|
||||||
import { TrackingModule } from "./modules/tracking/tracking.module";
|
import { TrackingModule } from "./modules/tracking/tracking.module";
|
||||||
import { BillingModule } from "./modules/billing/billing.module";
|
import { BillingModule } from "./modules/billing/billing.module";
|
||||||
import { NotificationsModule } from "./modules/notifications/notifications.module";
|
import { NotificationsModule } from "./modules/notifications/notifications.module";
|
||||||
import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module";
|
import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module";
|
||||||
import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module";
|
import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module";
|
||||||
import { OtpModule } from './modules/otp/otp.module';
|
import { OtpModule } from "./modules/otp/otp.module";
|
||||||
import { RuleEngineModule } from "./modules/rule-engine/rule-engine.module";
|
import { RuleEngineModule } from "./modules/rule-engine/rule-engine.module";
|
||||||
import { BackofficeModule } from "./modules/backoffice/backoffice.module";
|
import { BackofficeModule } from "./modules/backoffice/backoffice.module";
|
||||||
import { DemoPermissionsModule } from "./modules/demo-permissions/demo-permissions.module";
|
import { DemoPermissionsModule } from "./modules/demo-permissions/demo-permissions.module";
|
||||||
@@ -44,7 +43,6 @@ import { EdrOrgSeeder } from "./seed/edr-org.seeder";
|
|||||||
import { DemoUsersSeeder } from "./seed/demo-users.seeder";
|
import { DemoUsersSeeder } from "./seed/demo-users.seeder";
|
||||||
import { FreightStaffUsersSeeder } from "./seed/freight-staff-users.seeder";
|
import { FreightStaffUsersSeeder } from "./seed/freight-staff-users.seeder";
|
||||||
import { PaymentModule } from "./modules/payment/payment.module";
|
import { PaymentModule } from "./modules/payment/payment.module";
|
||||||
import { DemoBookingsSeeder } from "./seed/demo-bookings.seeder";
|
|
||||||
import { PricingDataSeeder } from "./seed/pricing-data.seeder";
|
import { PricingDataSeeder } from "./seed/pricing-data.seeder";
|
||||||
import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder";
|
import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder";
|
||||||
import { IndodeFacilitySeeder } from "./seed/indode-facility.seeder";
|
import { IndodeFacilitySeeder } from "./seed/indode-facility.seeder";
|
||||||
@@ -64,6 +62,7 @@ import { RoutesModule } from './modules/routes/routes.module';
|
|||||||
import { WarehousesModule } from './modules/warehouses/warehouses.module';
|
import { WarehousesModule } from './modules/warehouses/warehouses.module';
|
||||||
import { FacilitiesModule } from './modules/facilities/facilities.module';
|
import { FacilitiesModule } from './modules/facilities/facilities.module';
|
||||||
import { OverviewModule } from './modules/overview/overview.module';
|
import { OverviewModule } from './modules/overview/overview.module';
|
||||||
|
import { VehiclesModule } from './modules/vehicles/vehicles.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -101,7 +100,6 @@ import { OverviewModule } from './modules/overview/overview.module';
|
|||||||
TrainSchedulesModule,
|
TrainSchedulesModule,
|
||||||
TrainSchedulingModule,
|
TrainSchedulingModule,
|
||||||
SchedulingRescheduleModule,
|
SchedulingRescheduleModule,
|
||||||
CustomersModule,
|
|
||||||
CompaniesModule,
|
CompaniesModule,
|
||||||
TrackingModule,
|
TrackingModule,
|
||||||
BillingModule,
|
BillingModule,
|
||||||
@@ -123,12 +121,12 @@ import { OverviewModule } from './modules/overview/overview.module';
|
|||||||
FacilitiesModule,
|
FacilitiesModule,
|
||||||
WarehousesModule,
|
WarehousesModule,
|
||||||
OverviewModule,
|
OverviewModule,
|
||||||
|
VehiclesModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
EdrOrgSeeder,
|
EdrOrgSeeder,
|
||||||
DemoUsersSeeder,
|
DemoUsersSeeder,
|
||||||
FreightStaffUsersSeeder,
|
FreightStaffUsersSeeder,
|
||||||
DemoBookingsSeeder,
|
|
||||||
PricingDataSeeder,
|
PricingDataSeeder,
|
||||||
FileUploadSettingsSeeder,
|
FileUploadSettingsSeeder,
|
||||||
FreightPermissionKeyMigrationSeeder,
|
FreightPermissionKeyMigrationSeeder,
|
||||||
@@ -147,7 +145,6 @@ export class AppModule implements OnApplicationBootstrap {
|
|||||||
private readonly edrOrgSeeder: EdrOrgSeeder,
|
private readonly edrOrgSeeder: EdrOrgSeeder,
|
||||||
private readonly demoUsersSeeder: DemoUsersSeeder,
|
private readonly demoUsersSeeder: DemoUsersSeeder,
|
||||||
private readonly freightStaffUsersSeeder: FreightStaffUsersSeeder,
|
private readonly freightStaffUsersSeeder: FreightStaffUsersSeeder,
|
||||||
private readonly demoBookingsSeeder: DemoBookingsSeeder,
|
|
||||||
private readonly pricingDataSeeder: PricingDataSeeder,
|
private readonly pricingDataSeeder: PricingDataSeeder,
|
||||||
private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder,
|
private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder,
|
||||||
private readonly indodeFacilitySeeder: IndodeFacilitySeeder,
|
private readonly indodeFacilitySeeder: IndodeFacilitySeeder,
|
||||||
@@ -166,7 +163,6 @@ export class AppModule implements OnApplicationBootstrap {
|
|||||||
await this.edrOrgSeeder.run();
|
await this.edrOrgSeeder.run();
|
||||||
await this.demoUsersSeeder.run();
|
await this.demoUsersSeeder.run();
|
||||||
await this.freightStaffUsersSeeder.run();
|
await this.freightStaffUsersSeeder.run();
|
||||||
await this.demoBookingsSeeder.run();
|
|
||||||
await this.pricingDataSeeder.run();
|
await this.pricingDataSeeder.run();
|
||||||
await this.fileUploadSettingsSeeder.run();
|
await this.fileUploadSettingsSeeder.run();
|
||||||
await this.indodeFacilitySeeder.run();
|
await this.indodeFacilitySeeder.run();
|
||||||
@@ -177,6 +173,11 @@ export class AppModule implements OnApplicationBootstrap {
|
|||||||
await this.warehouseDemoSeeder.run();
|
await this.warehouseDemoSeeder.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,
|
||||||
|
// FileUploadSettingsSeeder) are intentionally disabled — they stay
|
||||||
|
// registered as providers but are not run. Re-inject + call .run() to enable.
|
||||||
|
// demoFreightDataSeeder now seeds ONLY the 4 staff users (wagons + approval
|
||||||
|
// rules are disabled inside the seeder). Kept running for the staff users.
|
||||||
await this.demoFreightDataSeeder.run();
|
await this.demoFreightDataSeeder.run();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,7 +114,9 @@ export class ContractViewModelBuilder {
|
|||||||
tinNumber: this.valueOrDash(booking.company?.tin),
|
tinNumber: this.valueOrDash(booking.company?.tin),
|
||||||
vatNumber: this.valueOrDash(booking.company?.vatNumber),
|
vatNumber: this.valueOrDash(booking.company?.vatNumber),
|
||||||
fanNumber: this.valueOrDash(booking.company?.fanNumber),
|
fanNumber: this.valueOrDash(booking.company?.fanNumber),
|
||||||
businessLicense: this.valueOrDash(booking.company?.businessLicense),
|
businessLicense: this.valueOrDash(
|
||||||
|
booking.company?.companyProfiles?.[0]?.businessLicense,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
provider: {
|
provider: {
|
||||||
name: 'Ethio-Djibouti Standard Gauge Railway Share Company',
|
name: 'Ethio-Djibouti Standard Gauge Railway Share Company',
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import {
|
||||||
|
MigrationInterface,
|
||||||
|
QueryRunner,
|
||||||
|
Table,
|
||||||
|
TableIndex,
|
||||||
|
TableForeignKey,
|
||||||
|
} from "typeorm";
|
||||||
|
|
||||||
|
export class CreateCompanyProfiles1752000000000 implements MigrationInterface {
|
||||||
|
name = "CreateCompanyProfiles1752000000000";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.createTable(
|
||||||
|
new Table({
|
||||||
|
schema: "freight",
|
||||||
|
name: "company_profiles",
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
name: "id",
|
||||||
|
type: "uuid",
|
||||||
|
isPrimary: true,
|
||||||
|
generationStrategy: "uuid",
|
||||||
|
default: "gen_random_uuid()",
|
||||||
|
},
|
||||||
|
{ name: "company_id", type: "uuid" },
|
||||||
|
{ name: "type", type: "varchar", length: "32" },
|
||||||
|
{ name: "reference", type: "varchar", length: "20", isUnique: true },
|
||||||
|
{
|
||||||
|
name: "status",
|
||||||
|
type: "varchar",
|
||||||
|
length: "32",
|
||||||
|
default: "'active'",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "business_license",
|
||||||
|
type: "varchar",
|
||||||
|
length: "100",
|
||||||
|
isNullable: true,
|
||||||
|
},
|
||||||
|
{ name: "attributes", type: "jsonb", 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.createForeignKey(
|
||||||
|
"freight.company_profiles",
|
||||||
|
new TableForeignKey({
|
||||||
|
columnNames: ["company_id"],
|
||||||
|
referencedTableName: "companies",
|
||||||
|
referencedSchema: "freight",
|
||||||
|
referencedColumnNames: ["id"],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.createIndex(
|
||||||
|
"freight.company_profiles",
|
||||||
|
new TableIndex({ columnNames: ["company_id"] }),
|
||||||
|
);
|
||||||
|
await queryRunner.createIndex(
|
||||||
|
"freight.company_profiles",
|
||||||
|
new TableIndex({ columnNames: ["type"] }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE SEQUENCE IF NOT EXISTS freight.seq_company_profile_ex START WITH 1`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE SEQUENCE IF NOT EXISTS freight.seq_company_profile_im START WITH 1`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE SEQUENCE IF NOT EXISTS freight.seq_company_profile_ffe START WITH 1`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE SEQUENCE IF NOT EXISTS freight.seq_company_profile_fwj START WITH 1`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE SEQUENCE IF NOT EXISTS freight.seq_company_profile_tr START WITH 1`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.dropTable("freight.company_profiles");
|
||||||
|
await queryRunner.query(
|
||||||
|
`DROP SEQUENCE IF EXISTS freight.seq_company_profile_ex`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`DROP SEQUENCE IF EXISTS freight.seq_company_profile_im`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`DROP SEQUENCE IF EXISTS freight.seq_company_profile_ffe`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`DROP SEQUENCE IF EXISTS freight.seq_company_profile_fwj`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`DROP SEQUENCE IF EXISTS freight.seq_company_profile_tr`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class MoveBusinessLicenseToProfile1752000000001
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
name = 'MoveBusinessLicenseToProfile1752000000001';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.company_profiles cp
|
||||||
|
SET business_license = c.business_license
|
||||||
|
FROM freight.companies c
|
||||||
|
WHERE cp.company_id = c.id AND c.business_license IS NOT NULL
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.companies DROP COLUMN IF EXISTS business_license`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.companies ADD COLUMN business_license varchar(100) NULL`,
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.companies c
|
||||||
|
SET business_license = cp.business_license
|
||||||
|
FROM (
|
||||||
|
SELECT DISTINCT ON (cp2.company_id)
|
||||||
|
cp2.company_id, cp2.business_license
|
||||||
|
FROM freight.company_profiles cp2
|
||||||
|
WHERE cp2.business_license IS NOT NULL
|
||||||
|
ORDER BY cp2.company_id, cp2.created_at
|
||||||
|
) cp
|
||||||
|
WHERE cp.company_id = c.id
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class CreateVehiclesTable1770000000000 implements MigrationInterface {
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'vehicles' AND table_schema = 'freight') THEN
|
||||||
|
CREATE TABLE freight.vehicles (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
plate_number VARCHAR NOT NULL UNIQUE,
|
||||||
|
registration_number VARCHAR NOT NULL UNIQUE,
|
||||||
|
vehicle_type VARCHAR NOT NULL,
|
||||||
|
manufacturer VARCHAR NOT NULL,
|
||||||
|
model VARCHAR NOT NULL,
|
||||||
|
year INTEGER NOT NULL,
|
||||||
|
fuel_type VARCHAR NOT NULL,
|
||||||
|
capacity NUMERIC NOT NULL,
|
||||||
|
status VARCHAR DEFAULT 'ACTIVE' NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
deleted_at TIMESTAMP NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_vehicles_plate_number ON freight.vehicles(plate_number);
|
||||||
|
CREATE INDEX idx_vehicles_registration_number ON freight.vehicles(registration_number);
|
||||||
|
CREATE INDEX idx_vehicles_status ON freight.vehicles(status);
|
||||||
|
CREATE INDEX idx_vehicles_vehicle_type ON freight.vehicles(vehicle_type);
|
||||||
|
CREATE INDEX idx_vehicles_manufacturer ON freight.vehicles(manufacturer);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS freight.vehicles CASCADE;`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full wagon re-seed — runs in this order:
|
||||||
|
*
|
||||||
|
* 1. DELETE all existing wagons (hard delete, not soft).
|
||||||
|
* 2. UPSERT all 10 standard wagon types so they are guaranteed to exist.
|
||||||
|
* 3. INSERT 50 wagons per wagon type (500 total), distributed evenly across
|
||||||
|
* the 5 main operational yards (10 wagons per yard per type):
|
||||||
|
*
|
||||||
|
* KALITY — Kality Rail Terminal
|
||||||
|
* MOJO — Mojo Dry Port
|
||||||
|
* DIRE_DAWA — Dire Dawa Yard
|
||||||
|
* DJIB_PORT — Djibouti Port Terminal
|
||||||
|
* NAGAD — Nagad Terminal, Djibouti
|
||||||
|
*
|
||||||
|
* Wagon numbers follow the pattern <TYPE_CODE>-NNNN (e.g. NW5-0001 … NW5-0050).
|
||||||
|
* Yard IDs are fetched live from freight.yards so the migration is safe across
|
||||||
|
* all environments regardless of UUID values.
|
||||||
|
*/
|
||||||
|
export class SeedWagonsWithYardAssignment1784000000001
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
name = 'SeedWagonsWithYardAssignment1784000000001';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// ── STEP 1: Remove all wagons ──────────────────────────────────────────
|
||||||
|
await queryRunner.query(`DELETE FROM freight.wagons;`);
|
||||||
|
|
||||||
|
// ── STEP 2: Ensure all 10 wagon types exist ────────────────────────────
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO freight.wagon_types (
|
||||||
|
code,
|
||||||
|
name,
|
||||||
|
capacity_tons,
|
||||||
|
length_meters,
|
||||||
|
max_wagons_per_train,
|
||||||
|
supported_load_types,
|
||||||
|
is_active,
|
||||||
|
tare_weight_tons
|
||||||
|
)
|
||||||
|
VALUES
|
||||||
|
('NW7', 'Double deck sedan wagon', 22, 26.066, NULL, ARRAY['vehicles', 'sedan'], true, 18.0),
|
||||||
|
('NW5', 'Flat wagon (container)', 70, 14.000, 53, ARRAY['container', 'steel', 'machinery'], true, 22.0),
|
||||||
|
('PW2', 'Box wagon', 70, 17.066, 18, ARRAY['general cargo', 'break bulk'], true, 20.0),
|
||||||
|
('GW2', 'Tank wagon', 70, 12.228, 37, ARRAY['liquid', 'fuel'], true, 25.0),
|
||||||
|
('CW4', 'Gondola covered wagon', 70, 13.976, 37, ARRAY['covered bulk cargo'], true, 22.0),
|
||||||
|
('CW3', 'Gondola open wagon', 70, 13.976, NULL, ARRAY['open bulk cargo'], true, 20.0),
|
||||||
|
('KW2', 'Hopper covered wagon', 69, 16.466, NULL, ARRAY['bulk grains'], true, 22.0),
|
||||||
|
('KW3', 'Hopper open wagon', 70, 14.400, NULL, ARRAY['coal', 'bulk cargo'], true, 20.0),
|
||||||
|
('NW6', 'Flat wagon (long cargo)', 70, 18.560, NULL, ARRAY['long cargo'], true, 22.0),
|
||||||
|
('BW1', 'Refrigerated wagon', 38, 21.996, NULL, ARRAY['refrigerated cargo'], true, 24.0)
|
||||||
|
ON CONFLICT (code) DO UPDATE SET
|
||||||
|
name = EXCLUDED.name,
|
||||||
|
capacity_tons = EXCLUDED.capacity_tons,
|
||||||
|
length_meters = EXCLUDED.length_meters,
|
||||||
|
max_wagons_per_train = EXCLUDED.max_wagons_per_train,
|
||||||
|
supported_load_types = EXCLUDED.supported_load_types,
|
||||||
|
is_active = true,
|
||||||
|
tare_weight_tons = EXCLUDED.tare_weight_tons,
|
||||||
|
deleted_at = NULL,
|
||||||
|
updated_at = now();
|
||||||
|
`);
|
||||||
|
|
||||||
|
// ── STEP 3: Seed 50 wagons per type across 5 yards ────────────────────
|
||||||
|
await queryRunner.query(`
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
wt RECORD;
|
||||||
|
yard_kality UUID;
|
||||||
|
yard_mojo UUID;
|
||||||
|
yard_dire_dawa UUID;
|
||||||
|
yard_djib_port UUID;
|
||||||
|
yard_nagad UUID;
|
||||||
|
yards UUID[];
|
||||||
|
i INT;
|
||||||
|
yard_id UUID;
|
||||||
|
wagon_num TEXT;
|
||||||
|
v_tare NUMERIC;
|
||||||
|
v_payload NUMERIC;
|
||||||
|
BEGIN
|
||||||
|
-- Fetch yard IDs by code (safe across envs — UUIDs differ per DB)
|
||||||
|
SELECT id INTO yard_kality FROM freight.yards WHERE code = 'KALITY' LIMIT 1;
|
||||||
|
SELECT id INTO yard_mojo FROM freight.yards WHERE code = 'MOJO' LIMIT 1;
|
||||||
|
SELECT id INTO yard_dire_dawa FROM freight.yards WHERE code = 'DIRE_DAWA' LIMIT 1;
|
||||||
|
SELECT id INTO yard_djib_port FROM freight.yards WHERE code = 'DJIB_PORT' LIMIT 1;
|
||||||
|
SELECT id INTO yard_nagad FROM freight.yards WHERE code = 'NAGAD' LIMIT 1;
|
||||||
|
|
||||||
|
IF yard_kality IS NULL OR yard_mojo IS NULL OR yard_dire_dawa IS NULL
|
||||||
|
OR yard_djib_port IS NULL OR yard_nagad IS NULL
|
||||||
|
THEN
|
||||||
|
RAISE EXCEPTION 'One or more operational yards not found. Run the yards seed first.';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
yards := ARRAY[
|
||||||
|
yard_kality,
|
||||||
|
yard_mojo,
|
||||||
|
yard_dire_dawa,
|
||||||
|
yard_djib_port,
|
||||||
|
yard_nagad
|
||||||
|
];
|
||||||
|
|
||||||
|
FOR wt IN
|
||||||
|
SELECT id, code, capacity_tons, tare_weight_tons
|
||||||
|
FROM freight.wagon_types
|
||||||
|
WHERE is_active = true
|
||||||
|
ORDER BY code
|
||||||
|
LOOP
|
||||||
|
v_tare := COALESCE(wt.tare_weight_tons, 20.0);
|
||||||
|
v_payload := COALESCE(wt.capacity_tons, 60.0);
|
||||||
|
|
||||||
|
FOR i IN 1 .. 50 LOOP
|
||||||
|
wagon_num := wt.code || '-' || LPAD(i::TEXT, 4, '0');
|
||||||
|
yard_id := yards[ ((i - 1) % 5) + 1 ]; -- round-robin: 1→K, 2→M, 3→D, 4→J, 5→N, 6→K …
|
||||||
|
|
||||||
|
INSERT INTO freight.wagons (
|
||||||
|
id,
|
||||||
|
wagon_number,
|
||||||
|
wagon_type_id,
|
||||||
|
tare_weight,
|
||||||
|
max_payload_weight,
|
||||||
|
status,
|
||||||
|
current_yard_id,
|
||||||
|
train_id,
|
||||||
|
sequence_number,
|
||||||
|
notes,
|
||||||
|
train_set_wagon_id,
|
||||||
|
current_train_schedule_id,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
uuid_generate_v4(),
|
||||||
|
wagon_num,
|
||||||
|
wt.id,
|
||||||
|
v_tare,
|
||||||
|
v_payload,
|
||||||
|
'Available',
|
||||||
|
yard_id,
|
||||||
|
NULL, NULL, NULL, NULL, NULL,
|
||||||
|
now(), now()
|
||||||
|
)
|
||||||
|
ON CONFLICT (wagon_number) DO NOTHING;
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
RAISE NOTICE 'Seeded 50 wagons for type %.', wt.code;
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// Remove all seeded wagons (full wipe — mirrors what up() did)
|
||||||
|
await queryRunner.query(`DELETE FROM freight.wagons;`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Day-level booking pool: customers select a DAY (route + day), not a specific
|
||||||
|
* train. The batch engine's pool query filters bookings on
|
||||||
|
* (origin_yard_id, destination_yard_id, scheduled_date, status); this partial
|
||||||
|
* index backs that scan.
|
||||||
|
*/
|
||||||
|
export class AddBookingRouteDayIndex1784100000000 implements MigrationInterface {
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bookings_route_day
|
||||||
|
ON freight.bookings (origin_yard_id, destination_yard_id, scheduled_date, status)
|
||||||
|
WHERE deleted_at IS NULL;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_bookings_route_day;`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
ConflictException,
|
|
||||||
forwardRef,
|
forwardRef,
|
||||||
Inject,
|
Inject,
|
||||||
Injectable,
|
Injectable,
|
||||||
@@ -69,7 +68,12 @@ export class BookingTransitionService {
|
|||||||
priorityScore,
|
priorityScore,
|
||||||
} as never);
|
} as never);
|
||||||
|
|
||||||
const finalBooking = await this.bookingsService.findById(updated!.id);
|
// Auto-consolidate now: a partial-wagon booking either pairs with a waiting
|
||||||
|
// partner (both → SUBMITTED) or is parked as PENDING_CONSOLIDATION until one
|
||||||
|
// arrives. The returned status reflects that outcome.
|
||||||
|
const finalBooking = await this.bookingsService.runConsolidationOnSubmit(
|
||||||
|
updated!.id,
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
bookingId: finalBooking.id,
|
bookingId: finalBooking.id,
|
||||||
status: finalBooking.status,
|
status: finalBooking.status,
|
||||||
@@ -143,7 +147,10 @@ export class BookingTransitionService {
|
|||||||
},
|
},
|
||||||
} as never);
|
} as never);
|
||||||
|
|
||||||
const finalBooking = await this.bookingsService.findById(updated!.id);
|
// Same consolidation treatment as the direct submit path.
|
||||||
|
const finalBooking = await this.bookingsService.runConsolidationOnSubmit(
|
||||||
|
updated!.id,
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
bookingId: finalBooking.id,
|
bookingId: finalBooking.id,
|
||||||
status: finalBooking.status,
|
status: finalBooking.status,
|
||||||
@@ -188,18 +195,11 @@ export class BookingTransitionService {
|
|||||||
|
|
||||||
async acceptIntake(bookingId: string, actorId: string): Promise<Booking> {
|
async acceptIntake(bookingId: string, actorId: string): Promise<Booking> {
|
||||||
const booking = await this.bookingsService.findById(bookingId);
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
|
// Only SUBMITTED bookings are acceptable. A booking that still needs
|
||||||
|
// consolidation sits in PENDING_CONSOLIDATION (resolved at submit time) and
|
||||||
|
// is therefore never offered for accept until a partner moves it to SUBMITTED.
|
||||||
assertBookingStatus(booking, ['SUBMITTED']);
|
assertBookingStatus(booking, ['SUBMITTED']);
|
||||||
|
|
||||||
// Consolidation gate: a booking whose containers don't fill whole wagons
|
|
||||||
// cannot be accepted until it is paired with a complementary booking.
|
|
||||||
const gate = await this.bookingsService.resolveConsolidationGate(bookingId);
|
|
||||||
if (gate.blocked) {
|
|
||||||
throw new ConflictException(
|
|
||||||
gate.message ??
|
|
||||||
'Booking requires consolidation and cannot be accepted until a partner is found.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.ruleEngineService.instantiateApprovalSteps(bookingId, {
|
await this.ruleEngineService.instantiateApprovalSteps(bookingId, {
|
||||||
freightType: booking.freightType as 'CONTAINER' | 'BULK',
|
freightType: booking.freightType as 'CONTAINER' | 'BULK',
|
||||||
cargoTypeId: booking.cargoTypeId,
|
cargoTypeId: booking.cargoTypeId,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
Query,
|
Query,
|
||||||
Request,
|
Request,
|
||||||
Res,
|
Res,
|
||||||
|
UnauthorizedException,
|
||||||
UploadedFiles,
|
UploadedFiles,
|
||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
@@ -117,8 +118,22 @@ export class BookingsController {
|
|||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOperation({ summary: 'List freight bookings (paginated)' })
|
@ApiOperation({ summary: 'List freight bookings (paginated)' })
|
||||||
findAll(@Query() filter: FilterBookingDto) {
|
async findAll(
|
||||||
return this.bookingsService.findAll(filter);
|
@Query() filter: FilterBookingDto,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
|
) {
|
||||||
|
// Staff (backoffice) see every booking. Customers (portal) are always
|
||||||
|
// force-scoped to their own company, regardless of any companyId they pass.
|
||||||
|
if (hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||||
|
return this.bookingsService.findAll(filter);
|
||||||
|
}
|
||||||
|
const userId = user?.id;
|
||||||
|
if (!userId) throw new UnauthorizedException('Authentication required');
|
||||||
|
const companyId =
|
||||||
|
await this.bookingsService.resolveCustomerCompanyId(userId);
|
||||||
|
// No linked company yet → no bookings to show (avoids leaking all bookings).
|
||||||
|
if (!companyId) return { items: [], total: 0 };
|
||||||
|
return this.bookingsService.findAll(filter, companyId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('list-summary')
|
@Get('list-summary')
|
||||||
@@ -166,18 +181,60 @@ export class BookingsController {
|
|||||||
|
|
||||||
@Get('by-reference/:reference')
|
@Get('by-reference/:reference')
|
||||||
@ApiOperation({ summary: 'Get booking by reference' })
|
@ApiOperation({ summary: 'Get booking by reference' })
|
||||||
async findByReference(@Param('reference') reference: string) {
|
async findByReference(
|
||||||
|
@Param('reference') reference: string,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
|
) {
|
||||||
const booking = await this.bookingsService.findByReference(reference);
|
const booking = await this.bookingsService.findByReference(reference);
|
||||||
|
// Staff see any booking; customers only their own company's.
|
||||||
|
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||||
|
await this.bookingsService.assertCustomerCanAccessBooking(
|
||||||
|
user?.id,
|
||||||
|
booking,
|
||||||
|
);
|
||||||
|
}
|
||||||
return this.transitionService.enrichBookingResponse(booking);
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@ApiOperation({ summary: 'Get booking by ID' })
|
@ApiOperation({ summary: 'Get booking by ID' })
|
||||||
async findOne(@Param('id', ParseUUIDPipe) id: string) {
|
async findOne(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
|
) {
|
||||||
const booking = await this.bookingsService.findById(id);
|
const booking = await this.bookingsService.findById(id);
|
||||||
|
// Staff see any booking; customers only their own company's.
|
||||||
|
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||||
|
await this.bookingsService.assertCustomerCanAccessBooking(
|
||||||
|
user?.id,
|
||||||
|
booking,
|
||||||
|
);
|
||||||
|
}
|
||||||
return this.transitionService.enrichBookingResponse(booking);
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get(':id/tracking')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Shipment tracking timeline for a booking',
|
||||||
|
description:
|
||||||
|
"Returns the booking's consignment (once dispatched) and its ordered " +
|
||||||
|
'tracking events. Scoped to the customer\'s own company.',
|
||||||
|
})
|
||||||
|
async findTracking(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
|
) {
|
||||||
|
const booking = await this.bookingsService.findById(id);
|
||||||
|
// Staff see any booking; customers only their own company's.
|
||||||
|
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||||
|
await this.bookingsService.assertCustomerCanAccessBooking(
|
||||||
|
user?.id,
|
||||||
|
booking,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.bookingsService.getBookingTracking(id);
|
||||||
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@HttpCode(204)
|
@HttpCode(204)
|
||||||
@ApiOperation({ summary: 'Soft-delete DRAFT booking' })
|
@ApiOperation({ summary: 'Soft-delete DRAFT booking' })
|
||||||
|
|||||||
@@ -177,8 +177,11 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
.where('b.id != :bookingId', { bookingId: booking.id })
|
.where('b.id != :bookingId', { bookingId: booking.id })
|
||||||
.andWhere('b.allowConsolidation = true')
|
.andWhere('b.allowConsolidation = true')
|
||||||
.andWhere('b.consolidationPartnerId IS NULL')
|
.andWhere('b.consolidationPartnerId IS NULL')
|
||||||
|
// Only pair bookings the customer has committed (SUBMITTED) or that are
|
||||||
|
// already waiting (PENDING_CONSOLIDATION). DRAFT bookings are excluded so
|
||||||
|
// pairing never prematurely submits an unfinished/unpriced draft.
|
||||||
.andWhere('b.status IN (:...statuses)', {
|
.andWhere('b.status IN (:...statuses)', {
|
||||||
statuses: ['DRAFT', 'SUBMITTED', 'PENDING_CONSOLIDATION'],
|
statuses: ['SUBMITTED', 'PENDING_CONSOLIDATION'],
|
||||||
})
|
})
|
||||||
.andWhere('b.originYardId = :originYardId', {
|
.andWhere('b.originYardId = :originYardId', {
|
||||||
originYardId: booking.originYardId,
|
originYardId: booking.originYardId,
|
||||||
@@ -686,6 +689,11 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
destinationStationId?: string;
|
destinationStationId?: string;
|
||||||
schedulingStatus?: string;
|
schedulingStatus?: string;
|
||||||
trainScheduleId?: string;
|
trainScheduleId?: string;
|
||||||
|
/**
|
||||||
|
* EAT calendar day (yyyy-MM-dd). With day-level pooling the staff wizard sees
|
||||||
|
* the whole (route, day) pool rather than bookings pre-targeted to one train.
|
||||||
|
*/
|
||||||
|
day?: string;
|
||||||
}): Promise<Booking[]> {
|
}): Promise<Booking[]> {
|
||||||
const qb = this.repository
|
const qb = this.repository
|
||||||
.createQueryBuilder('booking')
|
.createQueryBuilder('booking')
|
||||||
@@ -703,9 +711,16 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
.where('booking.status = :paidStatus', { paidStatus: 'PAID' })
|
.where('booking.status = :paidStatus', { paidStatus: 'PAID' })
|
||||||
.andWhere('scheduleBooking.id IS NULL');
|
.andWhere('scheduleBooking.id IS NULL');
|
||||||
|
|
||||||
// Mirror the automatic batch pool: a schedule only ever considers bookings that
|
// Day-level pooling: customers no longer set train_schedule_id, so the wizard
|
||||||
// targeted THAT schedule (same as findBatchPool's train_schedule_id filter).
|
// surfaces the whole (route, EAT day) pool. Fall back to the legacy
|
||||||
if (options.trainScheduleId) {
|
// single-schedule filter only when no day is supplied (e.g. a staff-pinned
|
||||||
|
// booking that still carries train_schedule_id).
|
||||||
|
if (options.day) {
|
||||||
|
qb.andWhere(
|
||||||
|
`DATE(booking.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = :day`,
|
||||||
|
{ day: options.day },
|
||||||
|
);
|
||||||
|
} else if (options.trainScheduleId) {
|
||||||
qb.andWhere('booking.train_schedule_id = :trainScheduleId', {
|
qb.andWhere('booking.train_schedule_id = :trainScheduleId', {
|
||||||
trainScheduleId: options.trainScheduleId,
|
trainScheduleId: options.trainScheduleId,
|
||||||
});
|
});
|
||||||
@@ -762,6 +777,43 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
.getMany();
|
.getMany();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Day-level batch pool: ready, not-yet-allocated bookings on a route for one
|
||||||
|
* EAT calendar day, regardless of which train they end up on. Same status
|
||||||
|
* rules and ordering as {@link findBatchPool}, but keyed on
|
||||||
|
* (origin, destination, day) instead of train_schedule_id — the engine then
|
||||||
|
* distributes these across all trains departing that day.
|
||||||
|
*/
|
||||||
|
findBatchPoolByRouteDay(
|
||||||
|
originYardId: string,
|
||||||
|
destinationYardId: string,
|
||||||
|
day: string,
|
||||||
|
): Promise<Booking[]> {
|
||||||
|
return this.repository
|
||||||
|
.createQueryBuilder('booking')
|
||||||
|
.leftJoinAndSelect('booking.company', 'company')
|
||||||
|
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
||||||
|
.leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id')
|
||||||
|
.where('booking.origin_yard_id = :originYardId', { originYardId })
|
||||||
|
.andWhere('booking.destination_yard_id = :destinationYardId', {
|
||||||
|
destinationYardId,
|
||||||
|
})
|
||||||
|
.andWhere(
|
||||||
|
`DATE(booking.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = :day`,
|
||||||
|
{ day },
|
||||||
|
)
|
||||||
|
.andWhere('sb.id IS NULL')
|
||||||
|
.andWhere(
|
||||||
|
`((booking.is_government = false AND booking.status = 'FULLY_EXECUTED')
|
||||||
|
OR (booking.is_government = true AND booking.status IN ('APPROVED','PAID')))`,
|
||||||
|
)
|
||||||
|
.orderBy('booking.is_government', 'DESC')
|
||||||
|
.addOrderBy('booking.priority_score', 'DESC')
|
||||||
|
.addOrderBy('booking.fully_executed_at', 'ASC')
|
||||||
|
.addOrderBy('booking.created_at', 'ASC')
|
||||||
|
.getMany();
|
||||||
|
}
|
||||||
|
|
||||||
/** Every booking that targeted a schedule (any status) — for the batch monitoring board. */
|
/** Every booking that targeted a schedule (any status) — for the batch monitoring board. */
|
||||||
findAllBySchedule(scheduleId: string): Promise<Booking[]> {
|
findAllBySchedule(scheduleId: string): Promise<Booking[]> {
|
||||||
return this.repository
|
return this.repository
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
ConflictException,
|
ConflictException,
|
||||||
|
ForbiddenException,
|
||||||
|
forwardRef,
|
||||||
|
Inject,
|
||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { SchedulingStatus } from '@edr/types';
|
import { Freight, SchedulingStatus } from '@edr/types';
|
||||||
// import { CustomersService } from '../customers/customers.service';
|
// import { CustomersService } from '../customers/customers.service';
|
||||||
import { CompaniesService } from '../companies/companies.service';
|
import { CompaniesService } from '../companies/companies.service';
|
||||||
|
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
|
||||||
|
import { eatDay } from '../train-scheduling/batch-window.util';
|
||||||
import { FilesService } from '../files/files.service';
|
import { FilesService } from '../files/files.service';
|
||||||
import { MinioService } from '../minio/minio.service';
|
import { MinioService } from '../minio/minio.service';
|
||||||
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
||||||
@@ -52,6 +57,8 @@ export class BookingsService {
|
|||||||
private readonly minioService: MinioService,
|
private readonly minioService: MinioService,
|
||||||
// private readonly customersService: CustomersService,
|
// private readonly customersService: CustomersService,
|
||||||
private readonly companiesService: CompaniesService,
|
private readonly companiesService: CompaniesService,
|
||||||
|
@Inject(forwardRef(() => TrainSchedulingService))
|
||||||
|
private readonly trainSchedulingService: TrainSchedulingService,
|
||||||
private readonly ruleEngineService: RuleEngineService,
|
private readonly ruleEngineService: RuleEngineService,
|
||||||
private readonly containerTypesService: ContainerTypesService,
|
private readonly containerTypesService: ContainerTypesService,
|
||||||
private readonly consolidationService: ConsolidationService,
|
private readonly consolidationService: ConsolidationService,
|
||||||
@@ -146,13 +153,17 @@ export class BookingsService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Enable consolidation when any container line leaves a wagon partially filled
|
* Enable consolidation when any container line leaves a wagon partially filled
|
||||||
* (e.g. 1×20ft on a 2-slot wagon, 1×10ft on a 4-slot wagon), unless opted out.
|
* (e.g. 1×20ft on a 2-slot wagon, 1×10ft on a 4-slot wagon).
|
||||||
|
*
|
||||||
|
* Partial-wagon cargo ALWAYS consolidates — the customer cannot opt out of a
|
||||||
|
* half-empty wagon, so `explicit === false` is ignored when consolidation is
|
||||||
|
* actually needed. The opt-in flag only matters for cargo that already fills
|
||||||
|
* whole wagons (where consolidation is moot anyway).
|
||||||
*/
|
*/
|
||||||
private async resolveConsolidation(
|
private async resolveConsolidation(
|
||||||
containers: CreateBookingContainerDto[],
|
containers: CreateBookingContainerDto[],
|
||||||
explicit?: boolean,
|
explicit?: boolean,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
if (explicit === false) return false;
|
|
||||||
const needs = await this.consolidationService.needsConsolidation(
|
const needs = await this.consolidationService.needsConsolidation(
|
||||||
containers.map((c) => ({
|
containers.map((c) => ({
|
||||||
containerTypeId: c.containerTypeId,
|
containerTypeId: c.containerTypeId,
|
||||||
@@ -193,10 +204,11 @@ export class BookingsService {
|
|||||||
return { booking: paired, messages };
|
return { booking: paired, messages };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (booking.status === 'DRAFT') {
|
// No partner yet — park the booking so it waits. Applies both pre-submit
|
||||||
await this.bookingsRepository.update(booking.id, {
|
// (DRAFT) and at submit time (SUBMITTED); accepted/approved bookings never
|
||||||
status: 'PENDING_CONSOLIDATION',
|
// reach this method.
|
||||||
} as never);
|
if (booking.status === 'DRAFT' || booking.status === 'SUBMITTED') {
|
||||||
|
await this.bookingsRepository.parkForConsolidation(booking.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
const pending = await this.findById(booking.id);
|
const pending = await this.findById(booking.id);
|
||||||
@@ -205,45 +217,24 @@ export class BookingsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Consolidation gate used at staff-accept time. Returns the (possibly newly
|
* Run consolidation right after a booking reaches SUBMITTED. If a complementary
|
||||||
* paired) booking plus whether it still needs a consolidation partner.
|
* partner already exists, both are paired and moved (back) to SUBMITTED so staff
|
||||||
* When a booking needs consolidation and none is found, it is parked in
|
* can accept them. Otherwise the booking is parked in PENDING_CONSOLIDATION and
|
||||||
* PENDING_CONSOLIDATION and `blocked` is true so the caller refuses the accept.
|
* waits for a later complementary booking to complete the wagon.
|
||||||
|
*
|
||||||
|
* Returns the re-fetched booking, so callers can reflect the resulting status
|
||||||
|
* (SUBMITTED when paired/not-needed, PENDING_CONSOLIDATION when waiting).
|
||||||
*/
|
*/
|
||||||
async resolveConsolidationGate(bookingId: string): Promise<{
|
async runConsolidationOnSubmit(bookingId: string): Promise<Booking> {
|
||||||
booking: Booking;
|
const booking = await this.findById(bookingId);
|
||||||
blocked: boolean;
|
|
||||||
message?: string;
|
|
||||||
}> {
|
|
||||||
let booking = await this.findById(bookingId);
|
|
||||||
|
|
||||||
// Already paired — passes the gate.
|
// Already paired (e.g. a partner submitted first) — nothing to do.
|
||||||
if (booking.consolidationPartnerId) {
|
if (booking.consolidationPartnerId) {
|
||||||
return { booking, blocked: false };
|
return booking;
|
||||||
}
|
}
|
||||||
|
|
||||||
const needs =
|
|
||||||
await this.consolidationService.needsConsolidationFromBooking(booking);
|
|
||||||
if (!needs) {
|
|
||||||
return { booking, blocked: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
// A partner may have appeared since submission — try to pair now.
|
|
||||||
const result = await this.tryAutoConsolidate(booking);
|
const result = await this.tryAutoConsolidate(booking);
|
||||||
booking = result.booking;
|
return result.booking;
|
||||||
if (booking.consolidationPartnerId) {
|
|
||||||
return { booking, blocked: false, message: result.messages.join(' ') };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Still no partner — park it and block the accept.
|
|
||||||
await this.bookingsRepository.parkForConsolidation(booking.id);
|
|
||||||
booking = await this.findById(booking.id);
|
|
||||||
const slots = await this.consolidationService.slotsFromBooking(booking);
|
|
||||||
return {
|
|
||||||
booking,
|
|
||||||
blocked: true,
|
|
||||||
message: this.consolidationService.describePending(booking, slots),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Create a new freight booking. */
|
/** Create a new freight booking. */
|
||||||
@@ -283,8 +274,8 @@ export class BookingsService {
|
|||||||
companyId = company.id;
|
companyId = company.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Schedule targeting: when provided, the schedule must be OPEN and on the same route.
|
|
||||||
if (dto.trainScheduleId) {
|
if (dto.trainScheduleId) {
|
||||||
|
// Staff manual pin: the schedule must be OPEN and on the same route.
|
||||||
const schedule = await this.dataSource
|
const schedule = await this.dataSource
|
||||||
.getRepository(TrainSchedule)
|
.getRepository(TrainSchedule)
|
||||||
.findOne({ where: { id: dto.trainScheduleId } });
|
.findOne({ where: { id: dto.trainScheduleId } });
|
||||||
@@ -300,6 +291,22 @@ export class BookingsService {
|
|||||||
) {
|
) {
|
||||||
throw new BadRequestException('Selected schedule is not on the booking route');
|
throw new BadRequestException('Selected schedule is not on the booking route');
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Day-level pool: the customer picked a DAY — require that the route has at
|
||||||
|
// least one OPEN departure on that EAT day. The batch engine assigns the
|
||||||
|
// train later.
|
||||||
|
const day = eatDay(new Date(dto.scheduledDate));
|
||||||
|
const hasDeparture =
|
||||||
|
await this.trainSchedulingService.existsOpenScheduleOnRouteDay(
|
||||||
|
dto.originYardId,
|
||||||
|
dto.destinationYardId,
|
||||||
|
day,
|
||||||
|
);
|
||||||
|
if (!hasDeparture) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'No departures available on the selected day for this route',
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const reference = dto.reference || (await this.generateReference());
|
const reference = dto.reference || (await this.generateReference());
|
||||||
@@ -575,6 +582,7 @@ export class BookingsService {
|
|||||||
/** Return a paginated list of bookings matching the filter. */
|
/** Return a paginated list of bookings matching the filter. */
|
||||||
async findAll(
|
async findAll(
|
||||||
filter: FilterBookingDto,
|
filter: FilterBookingDto,
|
||||||
|
forceCompanyId?: string,
|
||||||
): Promise<{ items: Booking[]; total: number }> {
|
): Promise<{ items: Booking[]; total: number }> {
|
||||||
const page = filter.page ?? 1;
|
const page = filter.page ?? 1;
|
||||||
const pageSize = filter.pageSize ?? 20;
|
const pageSize = filter.pageSize ?? 20;
|
||||||
@@ -587,7 +595,9 @@ export class BookingsService {
|
|||||||
...statusFilter,
|
...statusFilter,
|
||||||
...schedulingStatusFilter,
|
...schedulingStatusFilter,
|
||||||
assignedToSchedule: filter.assignedToSchedule,
|
assignedToSchedule: filter.assignedToSchedule,
|
||||||
companyId: filter.companyId,
|
// A forced company scope (portal/customer) overrides any caller-provided
|
||||||
|
// companyId so a customer can only ever see their own company's bookings.
|
||||||
|
companyId: forceCompanyId ?? filter.companyId,
|
||||||
contractType: filter.contractType,
|
contractType: filter.contractType,
|
||||||
serviceTypeId: filter.serviceTypeId,
|
serviceTypeId: filter.serviceTypeId,
|
||||||
cargoTypeId: filter.cargoTypeId,
|
cargoTypeId: filter.cargoTypeId,
|
||||||
@@ -631,6 +641,109 @@ export class BookingsService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the company a customer user belongs to, for scoping their own
|
||||||
|
* bookings. Returns null when no profile/company is linked yet.
|
||||||
|
*/
|
||||||
|
async resolveCustomerCompanyId(userId: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const { company } =
|
||||||
|
await this.companiesService.getCompanyInfoByUserId(userId);
|
||||||
|
return company?.id ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authorize a customer's access to a single booking. Staff are scoped at the
|
||||||
|
* controller (they pass `isStaff`); for a customer, the booking must belong
|
||||||
|
* to the company the authenticated user is linked to — otherwise it is hidden
|
||||||
|
* behind a NotFound so booking IDs can't be probed.
|
||||||
|
*/
|
||||||
|
async assertCustomerCanAccessBooking(
|
||||||
|
userId: string | undefined,
|
||||||
|
booking: Booking,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!userId) {
|
||||||
|
throw new ForbiddenException('Authentication required');
|
||||||
|
}
|
||||||
|
const companyId = await this.resolveCustomerCompanyId(userId);
|
||||||
|
if (!companyId || booking.companyId !== companyId) {
|
||||||
|
// Don't reveal that the booking exists for another company.
|
||||||
|
throw new NotFoundException(`Booking ${booking.id} not found`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the customer-facing shipment tracking payload for a booking from the
|
||||||
|
* train schedule it is assigned to and the live checkpoint log. The caller is
|
||||||
|
* responsible for authorizing access to the booking first.
|
||||||
|
*
|
||||||
|
* When the booking has not been assigned to a train yet, returns a valid
|
||||||
|
* "no schedule" payload so the UI can show a pre-dispatch state.
|
||||||
|
*/
|
||||||
|
async getBookingTracking(
|
||||||
|
bookingId: string,
|
||||||
|
): Promise<Freight.IBookingTracking> {
|
||||||
|
const booking = await this.findById(bookingId);
|
||||||
|
|
||||||
|
const empty: Freight.IBookingTracking = {
|
||||||
|
bookingId: booking.id,
|
||||||
|
bookingReference: booking.reference,
|
||||||
|
hasSchedule: false,
|
||||||
|
scheduleId: null,
|
||||||
|
trainNumber: null,
|
||||||
|
scheduleStatus: null,
|
||||||
|
direction: null,
|
||||||
|
origin: null,
|
||||||
|
destination: null,
|
||||||
|
stations: [],
|
||||||
|
checkpoints: [],
|
||||||
|
currentSequenceNo: -1,
|
||||||
|
actualDepartureAt: null,
|
||||||
|
actualArrivalAt: null,
|
||||||
|
scheduledDepartureAt: null,
|
||||||
|
scheduledArrivalAt: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!booking.trainScheduleId) {
|
||||||
|
return empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pull the live corridor + checkpoints for the assigned schedule. If the
|
||||||
|
// schedule was removed, fall back to the pre-dispatch state rather than 500.
|
||||||
|
let track: Awaited<
|
||||||
|
ReturnType<TrainSchedulingService['getScheduleCheckpoints']>
|
||||||
|
>;
|
||||||
|
try {
|
||||||
|
track = await this.trainSchedulingService.getScheduleCheckpoints(
|
||||||
|
booking.trainScheduleId,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
bookingId: booking.id,
|
||||||
|
bookingReference: booking.reference,
|
||||||
|
hasSchedule: true,
|
||||||
|
scheduleId: track.scheduleId,
|
||||||
|
trainNumber: track.trainNumber,
|
||||||
|
scheduleStatus: track.status as Freight.TrainScheduleStatus,
|
||||||
|
direction: track.direction,
|
||||||
|
origin: track.origin,
|
||||||
|
destination: track.destination,
|
||||||
|
stations: track.stations,
|
||||||
|
checkpoints: track.checkpoints as Freight.ITrackingCheckpoint[],
|
||||||
|
currentSequenceNo: track.currentSequenceNo,
|
||||||
|
actualDepartureAt: track.actualDepartureAt,
|
||||||
|
actualArrivalAt: track.actualArrivalAt,
|
||||||
|
scheduledDepartureAt: track.scheduledDepartureAt,
|
||||||
|
scheduledArrivalAt: track.scheduledArrivalAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** Aggregate metrics and tab counts for the backoffice booking list. */
|
/** Aggregate metrics and tab counts for the backoffice booking list. */
|
||||||
async getListSummary(filter: FilterBookingDto): Promise<BookingListSummaryDto> {
|
async getListSummary(filter: FilterBookingDto): Promise<BookingListSummaryDto> {
|
||||||
const page = filter.page ?? 1;
|
const page = filter.page ?? 1;
|
||||||
|
|||||||
@@ -91,12 +91,21 @@ export class CreateBookingDto {
|
|||||||
@IsUUID()
|
@IsUUID()
|
||||||
trainId?: string;
|
trainId?: string;
|
||||||
|
|
||||||
/** Target schedule this booking is created against (required by the backoffice create form). */
|
/**
|
||||||
@ApiPropertyOptional({ format: 'uuid', description: 'Target train schedule (pool membership)' })
|
* Staff-only manual pin to a specific train. Customers omit this — they pick a
|
||||||
|
* DAY via {@link scheduledDate} and the batch engine assigns a train within
|
||||||
|
* that (route, day) pool. When provided, the schedule must be OPEN and on the
|
||||||
|
* booking route.
|
||||||
|
*/
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
format: 'uuid',
|
||||||
|
description: 'Staff only: pin to a specific train schedule. Customers omit this.',
|
||||||
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
trainScheduleId?: string;
|
trainScheduleId?: string;
|
||||||
|
|
||||||
|
/** The day the customer wants to ship (the pool day key). */
|
||||||
@ApiProperty({ example: '2026-06-15T00:00:00.000Z' })
|
@ApiProperty({ example: '2026-06-15T00:00:00.000Z' })
|
||||||
@IsDateString()
|
@IsDateString()
|
||||||
scheduledDate!: string;
|
scheduledDate!: string;
|
||||||
|
|||||||
@@ -280,7 +280,15 @@ export class Booking extends BaseEntity {
|
|||||||
@Column({ name: 'scheduled_at', type: 'timestamptz', nullable: true })
|
@Column({ name: 'scheduled_at', type: 'timestamptz', nullable: true })
|
||||||
scheduledAt?: Date | null;
|
scheduledAt?: Date | null;
|
||||||
|
|
||||||
/** The schedule this booking targets (pool membership), set at creation. FK to train_schedules. */
|
/**
|
||||||
|
* The train this booking is assigned to. FK to train_schedules.
|
||||||
|
*
|
||||||
|
* Day-level pooling: customers no longer pick a train — they pick a DAY, and
|
||||||
|
* this stays null at creation. The batch engine sets it when it assigns the
|
||||||
|
* booking to a specific train within its (route, day) pool; staff may also
|
||||||
|
* pin it manually. The day-level pool is keyed on
|
||||||
|
* (origin_yard_id, destination_yard_id, day of scheduled_date), not this column.
|
||||||
|
*/
|
||||||
@Column({ name: 'train_schedule_id', type: 'uuid', nullable: true })
|
@Column({ name: 'train_schedule_id', type: 'uuid', nullable: true })
|
||||||
trainScheduleId?: string | null;
|
trainScheduleId?: string | null;
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +1,38 @@
|
|||||||
import { Controller, Get, Post, Patch, Delete, Body, Param, Query, ParseUUIDPipe, HttpCode, HttpStatus, UseInterceptors, UploadedFiles } from '@nestjs/common';
|
import {
|
||||||
import { AnyFilesInterceptor } from '@nestjs/platform-express';
|
Controller,
|
||||||
import { ApiOperation, ApiTags, ApiConsumes } from '@nestjs/swagger';
|
Get,
|
||||||
import { CurrentUser } from '@edr/api-common';
|
Post,
|
||||||
import { FreightAdmin } from '../../common/booking-guards';
|
Patch,
|
||||||
import { FilesService } from '../files/files.service';
|
Delete,
|
||||||
import { CompaniesService } from './companies.service';
|
Body,
|
||||||
import { CreateCompanyDto } from './dto/create-company.dto';
|
Param,
|
||||||
import { UpdateCompanyDto } from './dto/update-company.dto';
|
Query,
|
||||||
import { CreateExternalProfileDto } from './dto/create-external-profile.dto';
|
ParseUUIDPipe,
|
||||||
import { CreateFFClientDto } from './dto/create-ff-client.dto';
|
HttpCode,
|
||||||
import { CreateCompanyWithProfileDto } from './dto/create-company-with-profile.dto';
|
HttpStatus,
|
||||||
import { ResponseCompanyDto } from './dto/response-company.dto';
|
UseInterceptors,
|
||||||
import { ResponseExternalProfileDto } from './dto/response-external-profile.dto';
|
UploadedFiles,
|
||||||
import { ResponseFFClientDto } from './dto/response-ff-client.dto';
|
} from "@nestjs/common";
|
||||||
import { CompanyInfoResponseDto } from './dto/company-info-response.dto';
|
import { AnyFilesInterceptor } from "@nestjs/platform-express";
|
||||||
import { UpdateProfileDto } from './dto/update-profile.dto';
|
import { ApiOperation, ApiTags, ApiConsumes } from "@nestjs/swagger";
|
||||||
import { ProfileResponseDto } from './dto/profile-response.dto';
|
import { CurrentUser } from "@edr/api-common";
|
||||||
import { DashboardSummaryResponseDto } from './dto/dashboard-summary-response.dto';
|
import { FreightAdmin } from "../../common/booking-guards";
|
||||||
|
import { FilesService } from "../files/files.service";
|
||||||
|
import { CompaniesService } from "./companies.service";
|
||||||
|
import { CreateCompanyDto } from "./dto/create-company.dto";
|
||||||
|
import { UpdateCompanyDto } from "./dto/update-company.dto";
|
||||||
|
import { CreateExternalProfileDto } from "./dto/create-external-profile.dto";
|
||||||
|
import { CreateCompanyWithProfileDto } from "./dto/create-company-with-profile.dto";
|
||||||
|
import { AddCompanyProfilesDto } from "./dto/add-company-profiles.dto";
|
||||||
|
import {
|
||||||
|
ResponseCompanyDto,
|
||||||
|
ResponseCompanyProfileDto,
|
||||||
|
} from "./dto/response-company.dto";
|
||||||
|
import { ResponseExternalProfileDto } from "./dto/response-external-profile.dto";
|
||||||
|
import { CompanyInfoResponseDto } from "./dto/company-info-response.dto";
|
||||||
|
import { UpdateProfileDto } from "./dto/update-profile.dto";
|
||||||
|
import { ProfileResponseDto } from "./dto/profile-response.dto";
|
||||||
|
import { DashboardSummaryResponseDto } from "./dto/dashboard-summary-response.dto";
|
||||||
|
|
||||||
interface CurrentIamUser {
|
interface CurrentIamUser {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -25,36 +41,47 @@ interface CurrentIamUser {
|
|||||||
phoneNumber?: string;
|
phoneNumber?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiTags('Companies')
|
@ApiTags("Companies")
|
||||||
@Controller('companies')
|
@Controller("companies")
|
||||||
export class CompaniesController {
|
export class CompaniesController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly companiesService: CompaniesService,
|
private readonly companiesService: CompaniesService,
|
||||||
private readonly filesService: FilesService,
|
private readonly filesService: FilesService,
|
||||||
) {}
|
) { }
|
||||||
|
|
||||||
@Get('getInfo')
|
@Get("getInfo")
|
||||||
@ApiOperation({ summary: 'Get company info for the current user' })
|
@ApiOperation({ summary: "Get company info for the current user" })
|
||||||
async getInfo(@CurrentUser() user: CurrentIamUser): Promise<CompanyInfoResponseDto> {
|
async getInfo(
|
||||||
const { profile, company } = await this.companiesService.getCompanyInfoByUserId(user.id);
|
@CurrentUser() user: CurrentIamUser,
|
||||||
|
): Promise<CompanyInfoResponseDto> {
|
||||||
|
const { profile, company } =
|
||||||
|
await this.companiesService.getCompanyInfoByUserId(user.id);
|
||||||
return new CompanyInfoResponseDto(profile, company);
|
return new CompanyInfoResponseDto(profile, company);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('profile')
|
@Get("profile")
|
||||||
@ApiOperation({ summary: 'Get flattened profile for the settings page' })
|
@ApiOperation({ summary: "Get flattened profile for the settings page" })
|
||||||
async getProfile(@CurrentUser() user: CurrentIamUser): Promise<ProfileResponseDto> {
|
async getProfile(
|
||||||
const { profile, company } = await this.companiesService.getCompanyInfoByUserId(user.id);
|
@CurrentUser() user: CurrentIamUser,
|
||||||
|
): Promise<ProfileResponseDto> {
|
||||||
|
const { profile, company } =
|
||||||
|
await this.companiesService.getCompanyInfoByUserId(user.id);
|
||||||
return new ProfileResponseDto(profile, company);
|
return new ProfileResponseDto(profile, company);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('dashboard')
|
@Get("dashboard")
|
||||||
@ApiOperation({ summary: 'Get portal dashboard KPIs (delivered, spend, freight volume) for the current user' })
|
@ApiOperation({
|
||||||
async getDashboard(@CurrentUser() user: CurrentIamUser): Promise<DashboardSummaryResponseDto> {
|
summary:
|
||||||
|
"Get portal dashboard KPIs (delivered, spend, freight volume) for the current user",
|
||||||
|
})
|
||||||
|
async getDashboard(
|
||||||
|
@CurrentUser() user: CurrentIamUser,
|
||||||
|
): Promise<DashboardSummaryResponseDto> {
|
||||||
return this.companiesService.getDashboardSummary(user.id);
|
return this.companiesService.getDashboardSummary(user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch('profile')
|
@Patch("profile")
|
||||||
@ApiOperation({ summary: 'Update profile (flattened settings page)' })
|
@ApiOperation({ summary: "Update profile (flattened settings page)" })
|
||||||
async updateProfile(
|
async updateProfile(
|
||||||
@CurrentUser() user: CurrentIamUser,
|
@CurrentUser() user: CurrentIamUser,
|
||||||
@Body() dto: UpdateProfileDto,
|
@Body() dto: UpdateProfileDto,
|
||||||
@@ -62,145 +89,153 @@ export class CompaniesController {
|
|||||||
return this.companiesService.updateProfile(user.id, dto);
|
return this.companiesService.updateProfile(user.id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('create')
|
@Post("company-profiles")
|
||||||
@ApiOperation({ summary: 'Create a company with its associated external profile (onboarding)' })
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
"Add operational profile(s) (importer/exporter/forwarder) to the current user's company",
|
||||||
|
})
|
||||||
|
async addCompanyProfiles(
|
||||||
|
@CurrentUser() user: CurrentIamUser,
|
||||||
|
@Body() dto: AddCompanyProfilesDto,
|
||||||
|
): Promise<ResponseCompanyProfileDto[]> {
|
||||||
|
const profiles = await this.companiesService.addCompanyProfilesForUser(
|
||||||
|
user.id,
|
||||||
|
dto.types,
|
||||||
|
);
|
||||||
|
return profiles.map((p) => new ResponseCompanyProfileDto(p));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Used by portal
|
||||||
|
@Post("create")
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
"Create a company with its associated external profile (onboarding)",
|
||||||
|
})
|
||||||
async createWithProfile(
|
async createWithProfile(
|
||||||
@CurrentUser() user: CurrentIamUser,
|
@CurrentUser() user: CurrentIamUser,
|
||||||
@Body() dto: CreateCompanyWithProfileDto,
|
@Body() dto: CreateCompanyWithProfileDto,
|
||||||
): Promise<CompanyInfoResponseDto> {
|
): Promise<CompanyInfoResponseDto> {
|
||||||
const nameParts = (user.name?.en ?? '').split(' ');
|
const nameParts = (user.name?.en ?? "").split(" ");
|
||||||
const { profile, company } = await this.companiesService.createCompanyWithProfile(
|
const { profile, company } =
|
||||||
{
|
await this.companiesService.createCompanyWithProfile(
|
||||||
userId: user.id,
|
{
|
||||||
firstName: nameParts[0] || '',
|
userId: user.id,
|
||||||
lastName: nameParts.slice(-1)[0] || '',
|
firstName: nameParts[0] || "",
|
||||||
email: user.email ?? '',
|
lastName: nameParts.slice(-1)[0] || "",
|
||||||
phone: user.phoneNumber ?? '',
|
email: user.email ?? "",
|
||||||
},
|
phone: user.phoneNumber ?? "",
|
||||||
dto,
|
},
|
||||||
);
|
dto,
|
||||||
|
);
|
||||||
return new CompanyInfoResponseDto(profile, company);
|
return new CompanyInfoResponseDto(profile, company);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Used by backoffice
|
||||||
@Post()
|
@Post()
|
||||||
@FreightAdmin()
|
@FreightAdmin()
|
||||||
@ApiOperation({ summary: 'Create a new company (customer, forwarder, transporter, broker)' })
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
"Create a new company (customer, freight_forwarder, dj_freight_forwarder, transporter)",
|
||||||
|
})
|
||||||
async create(@Body() dto: CreateCompanyDto): Promise<ResponseCompanyDto> {
|
async create(@Body() dto: CreateCompanyDto): Promise<ResponseCompanyDto> {
|
||||||
const company = await this.companiesService.createCompany(dto);
|
const company = await this.companiesService.createCompany(dto);
|
||||||
return new ResponseCompanyDto(company);
|
return new ResponseCompanyDto(company);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOperation({ summary: 'List all companies' })
|
@ApiOperation({ summary: "List all companies" })
|
||||||
async findAll(): Promise<ResponseCompanyDto[]> {
|
async findAll(): Promise<ResponseCompanyDto[]> {
|
||||||
const companies = await this.companiesService.findAllCompanies();
|
const companies = await this.companiesService.findAllCompanies();
|
||||||
return companies.map((c) => new ResponseCompanyDto(c));
|
return companies.map((c) => new ResponseCompanyDto(c));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('type/:type')
|
@Get("type/:type")
|
||||||
@ApiOperation({ summary: 'Find companies by type' })
|
@ApiOperation({ summary: "Find companies by type" })
|
||||||
async findByType(@Param('type') type: string): Promise<ResponseCompanyDto[]> {
|
async findByType(@Param("type") type: string): Promise<ResponseCompanyDto[]> {
|
||||||
const companies = await this.companiesService.findAllCompanies();
|
const companies = await this.companiesService.findAllCompanies();
|
||||||
return companies.filter((c) => c.type === type).map((c) => new ResponseCompanyDto(c));
|
return companies
|
||||||
|
.filter((c) => c.type === type)
|
||||||
|
.map((c) => new ResponseCompanyDto(c));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('search')
|
@Get("search")
|
||||||
@ApiOperation({ summary: 'Search companies by name' })
|
@ApiOperation({ summary: "Search companies by name" })
|
||||||
async search(@Query('name') name: string): Promise<ResponseCompanyDto[]> {
|
async search(@Query("name") name: string): Promise<ResponseCompanyDto[]> {
|
||||||
const companies = await this.companiesService.findAllCompanies();
|
const companies = await this.companiesService.findAllCompanies();
|
||||||
return companies
|
return companies
|
||||||
.filter((c) => c.name.toLowerCase().includes(name.toLowerCase()))
|
.filter((c) => c.name.toLowerCase().includes(name.toLowerCase()))
|
||||||
.map((c) => new ResponseCompanyDto(c));
|
.map((c) => new ResponseCompanyDto(c));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(":id")
|
||||||
@ApiOperation({ summary: 'Get company by ID' })
|
@ApiOperation({ summary: "Get company by ID" })
|
||||||
async findById(@Param('id', ParseUUIDPipe) id: string): Promise<ResponseCompanyDto> {
|
async findById(
|
||||||
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
|
): Promise<ResponseCompanyDto> {
|
||||||
const company = await this.companiesService.findCompanyById(id);
|
const company = await this.companiesService.findCompanyById(id);
|
||||||
return new ResponseCompanyDto(company);
|
return new ResponseCompanyDto(company);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(":id")
|
||||||
@FreightAdmin()
|
@FreightAdmin()
|
||||||
@ApiOperation({ summary: 'Update a company' })
|
@ApiOperation({ summary: "Update a company" })
|
||||||
async update(
|
async update(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
@Body() dto: UpdateCompanyDto,
|
@Body() dto: UpdateCompanyDto,
|
||||||
): Promise<ResponseCompanyDto> {
|
): Promise<ResponseCompanyDto> {
|
||||||
const company = await this.companiesService.updateCompany(id, dto);
|
const company = await this.companiesService.updateCompany(id, dto);
|
||||||
return new ResponseCompanyDto(company);
|
return new ResponseCompanyDto(company);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(":id")
|
||||||
@FreightAdmin()
|
@FreightAdmin()
|
||||||
@ApiOperation({ summary: 'Soft-delete a company' })
|
@ApiOperation({ summary: "Soft-delete a company" })
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
async remove(@Param('id', ParseUUIDPipe) id: string): Promise<void> {
|
async remove(@Param("id", ParseUUIDPipe) id: string): Promise<void> {
|
||||||
await this.companiesService.deleteCompany(id);
|
await this.companiesService.deleteCompany(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':companyId/documents')
|
@Post(":companyId/documents")
|
||||||
@UseInterceptors(AnyFilesInterceptor())
|
@UseInterceptors(AnyFilesInterceptor())
|
||||||
@ApiConsumes('multipart/form-data')
|
@ApiConsumes("multipart/form-data")
|
||||||
@ApiOperation({ summary: 'Upload documents for a company (onboarding)' })
|
@ApiOperation({ summary: "Upload documents for a company (onboarding)" })
|
||||||
async uploadDocuments(
|
async uploadDocuments(
|
||||||
@Param('companyId', ParseUUIDPipe) companyId: string,
|
@Param("companyId", ParseUUIDPipe) companyId: string,
|
||||||
@UploadedFiles() files: Array<Express.Multer.File>,
|
@UploadedFiles() files: Array<Express.Multer.File>,
|
||||||
) {
|
) {
|
||||||
return this.filesService.uploadMany(companyId, 'companies', files);
|
return this.filesService.uploadMany(companyId, "companies", files);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':companyId/profiles')
|
@Post(":companyId/profiles")
|
||||||
@FreightAdmin()
|
@FreightAdmin()
|
||||||
@ApiOperation({ summary: 'Add a profile (employee) to a company' })
|
@ApiOperation({ summary: "Add a profile (employee) to a company" })
|
||||||
async createProfile(
|
async createProfile(
|
||||||
@Param('companyId', ParseUUIDPipe) companyId: string,
|
@Param("companyId", ParseUUIDPipe) companyId: string,
|
||||||
@Body() dto: CreateExternalProfileDto,
|
@Body() dto: CreateExternalProfileDto,
|
||||||
): Promise<ResponseExternalProfileDto> {
|
): Promise<ResponseExternalProfileDto> {
|
||||||
const profile = await this.companiesService.createProfile({ ...dto, companyId });
|
const profile = await this.companiesService.createProfile({
|
||||||
|
...dto,
|
||||||
|
companyId,
|
||||||
|
});
|
||||||
return new ResponseExternalProfileDto(profile);
|
return new ResponseExternalProfileDto(profile);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':companyId/profiles')
|
@Get(":companyId/profiles")
|
||||||
@ApiOperation({ summary: 'List profiles for a company' })
|
@ApiOperation({ summary: "List profiles for a company" })
|
||||||
async listProfiles(
|
async listProfiles(
|
||||||
@Param('companyId', ParseUUIDPipe) companyId: string,
|
@Param("companyId", ParseUUIDPipe) companyId: string,
|
||||||
): Promise<ResponseExternalProfileDto[]> {
|
): Promise<ResponseExternalProfileDto[]> {
|
||||||
const profiles = await this.companiesService.findProfilesByCompany(companyId);
|
const profiles =
|
||||||
|
await this.companiesService.findProfilesByCompany(companyId);
|
||||||
return profiles.map((p) => new ResponseExternalProfileDto(p));
|
return profiles.map((p) => new ResponseExternalProfileDto(p));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('profile/user/:userId')
|
@Get("profile/user/:userId")
|
||||||
@ApiOperation({ summary: 'Get profile by IAM user ID' })
|
@ApiOperation({ summary: "Get profile by IAM user ID" })
|
||||||
async findProfileByUser(
|
async findProfileByUser(
|
||||||
@Param('userId', ParseUUIDPipe) userId: string,
|
@Param("userId", ParseUUIDPipe) userId: string,
|
||||||
): Promise<ResponseExternalProfileDto> {
|
): Promise<ResponseExternalProfileDto> {
|
||||||
const profile = await this.companiesService.findProfileByUserId(userId);
|
const profile = await this.companiesService.findProfileByUserId(userId);
|
||||||
return new ResponseExternalProfileDto(profile);
|
return new ResponseExternalProfileDto(profile);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('ff-clients')
|
|
||||||
@FreightAdmin()
|
|
||||||
@ApiOperation({ summary: 'Link a forwarder to a client company' })
|
|
||||||
async createFFClient(@Body() dto: CreateFFClientDto): Promise<ResponseFFClientDto> {
|
|
||||||
const client = await this.companiesService.createFFClient(dto);
|
|
||||||
return new ResponseFFClientDto(client);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get(':forwarderCompanyId/clients')
|
|
||||||
@ApiOperation({ summary: 'List clients of a forwarder' })
|
|
||||||
async listFFClients(
|
|
||||||
@Param('forwarderCompanyId', ParseUUIDPipe) forwarderCompanyId: string,
|
|
||||||
): Promise<ResponseFFClientDto[]> {
|
|
||||||
const clients = await this.companiesService.findForwarderClients(forwarderCompanyId);
|
|
||||||
return clients.map((c) => new ResponseFFClientDto(c));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Delete('ff-clients/:id')
|
|
||||||
@FreightAdmin()
|
|
||||||
@ApiOperation({ summary: 'Remove a forwarder-client relationship' })
|
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
|
||||||
async removeFFClient(@Param('id', ParseUUIDPipe) id: string): Promise<void> {
|
|
||||||
await this.companiesService.deleteFFClient(id);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,30 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from "@nestjs/common";
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||||
import { FilesModule } from '../files/files.module';
|
import { FilesModule } from "../files/files.module";
|
||||||
import { CompaniesController } from './companies.controller';
|
import { CompaniesController } from "./companies.controller";
|
||||||
import { CompaniesService } from './companies.service';
|
import { CompaniesService } from "./companies.service";
|
||||||
import { CompaniesRepository } from './companies.repository';
|
import { CompaniesRepository } from "./companies.repository";
|
||||||
import { ExternalProfileRepository } from './external-profile.repository';
|
import { ExternalProfileRepository } from "./external-profile.repository";
|
||||||
import { FFClientRepository } from './ff-client.repository';
|
import { CompanyDashboardRepository } from "./company-dashboard.repository";
|
||||||
import { CompanyDashboardRepository } from './company-dashboard.repository';
|
import { Company } from "./entities/company.entity";
|
||||||
import { Company } from './entities/company.entity';
|
import { ExternalProfile } from "./entities/external-profile.entity";
|
||||||
import { ExternalProfile } from './entities/external-profile.entity';
|
import { CompanyProfile } from "./entities/company-profile.entity";
|
||||||
import { FFClient } from './entities/ff-client.entity';
|
import { Booking } from "../bookings/entities/booking.entity";
|
||||||
import { Booking } from '../bookings/entities/booking.entity';
|
import { CompanyProfileRepository } from "./company-profile.repository";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([Company, ExternalProfile, FFClient, Booking]), FilesModule],
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([Company, ExternalProfile, CompanyProfile, Booking]),
|
||||||
|
FilesModule,
|
||||||
|
],
|
||||||
controllers: [CompaniesController],
|
controllers: [CompaniesController],
|
||||||
providers: [CompaniesService, CompaniesRepository, ExternalProfileRepository, FFClientRepository, CompanyDashboardRepository],
|
providers: [
|
||||||
|
CompaniesService,
|
||||||
|
CompaniesRepository,
|
||||||
|
ExternalProfileRepository,
|
||||||
|
CompanyProfileRepository,
|
||||||
|
CompanyDashboardRepository,
|
||||||
|
],
|
||||||
exports: [CompaniesService],
|
exports: [CompaniesService],
|
||||||
})
|
})
|
||||||
export class CompaniesModule {}
|
export class CompaniesModule { }
|
||||||
|
|||||||
@@ -1,19 +1,27 @@
|
|||||||
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
import {
|
||||||
import { CompaniesRepository } from './companies.repository';
|
Injectable,
|
||||||
import { ExternalProfileRepository } from './external-profile.repository';
|
NotFoundException,
|
||||||
import { FFClientRepository } from './ff-client.repository';
|
ConflictException,
|
||||||
import { CompanyDashboardRepository } from './company-dashboard.repository';
|
BadRequestException,
|
||||||
import { CreateCompanyDto } from './dto/create-company.dto';
|
} from "@nestjs/common";
|
||||||
import { UpdateCompanyDto } from './dto/update-company.dto';
|
import { CompaniesRepository } from "./companies.repository";
|
||||||
import { CreateExternalProfileDto } from './dto/create-external-profile.dto';
|
import { CompanyProfileRepository } from "./company-profile.repository";
|
||||||
import { CreateFFClientDto } from './dto/create-ff-client.dto';
|
import { ExternalProfileRepository } from "./external-profile.repository";
|
||||||
import { CreateCompanyWithProfileDto } from './dto/create-company-with-profile.dto';
|
import { CompanyDashboardRepository } from "./company-dashboard.repository";
|
||||||
import { UpdateProfileDto } from './dto/update-profile.dto';
|
import { CreateCompanyDto } from "./dto/create-company.dto";
|
||||||
import { ProfileResponseDto } from './dto/profile-response.dto';
|
import { UpdateCompanyDto } from "./dto/update-company.dto";
|
||||||
import { DashboardSummaryResponseDto } from './dto/dashboard-summary-response.dto';
|
import { CreateExternalProfileDto } from "./dto/create-external-profile.dto";
|
||||||
import { Company } from './entities/company.entity';
|
import { CreateCompanyWithProfileDto } from "./dto/create-company-with-profile.dto";
|
||||||
import { ExternalProfile } from './entities/external-profile.entity';
|
import { UpdateProfileDto } from "./dto/update-profile.dto";
|
||||||
import { FFClient } from './entities/ff-client.entity';
|
import { ProfileResponseDto } from "./dto/profile-response.dto";
|
||||||
|
import { DashboardSummaryResponseDto } from "./dto/dashboard-summary-response.dto";
|
||||||
|
import { Company } from "./entities/company.entity";
|
||||||
|
import { ExternalProfile } from "./entities/external-profile.entity";
|
||||||
|
import {
|
||||||
|
CompanyProfile,
|
||||||
|
ProfileType,
|
||||||
|
ProfileStatus,
|
||||||
|
} from "./entities/company-profile.entity";
|
||||||
|
|
||||||
export interface UserIdentity {
|
export interface UserIdentity {
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -27,10 +35,10 @@ export interface UserIdentity {
|
|||||||
export class CompaniesService {
|
export class CompaniesService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly companiesRepo: CompaniesRepository,
|
private readonly companiesRepo: CompaniesRepository,
|
||||||
|
private readonly companyProfilesRepo: CompanyProfileRepository,
|
||||||
private readonly profilesRepo: ExternalProfileRepository,
|
private readonly profilesRepo: ExternalProfileRepository,
|
||||||
private readonly ffClientsRepo: FFClientRepository,
|
|
||||||
private readonly dashboardRepo: CompanyDashboardRepository,
|
private readonly dashboardRepo: CompanyDashboardRepository,
|
||||||
) {}
|
) { }
|
||||||
|
|
||||||
async createCompany(dto: CreateCompanyDto): Promise<Company> {
|
async createCompany(dto: CreateCompanyDto): Promise<Company> {
|
||||||
const exists = await this.companiesRepo.existsByTin(dto.tin);
|
const exists = await this.companiesRepo.existsByTin(dto.tin);
|
||||||
@@ -40,27 +48,33 @@ export class CompaniesService {
|
|||||||
return this.companiesRepo.create(dto);
|
return this.companiesRepo.create(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
async createCompanyWithProfile(identity: UserIdentity, dto: CreateCompanyWithProfileDto): Promise<{ company: Company; profile: ExternalProfile }> {
|
async createCompanyWithProfile(
|
||||||
|
identity: UserIdentity,
|
||||||
|
dto: CreateCompanyWithProfileDto,
|
||||||
|
): Promise<{ company: Company; profile: ExternalProfile }> {
|
||||||
if (dto.tin) {
|
if (dto.tin) {
|
||||||
const exists = await this.companiesRepo.existsByTin(dto.tin);
|
const exists = await this.companiesRepo.existsByTin(dto.tin);
|
||||||
if (exists) {
|
if (exists) {
|
||||||
throw new ConflictException(`Company with TIN ${dto.tin} already exists`);
|
throw new ConflictException(
|
||||||
|
`Company with TIN ${dto.tin} already exists`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingProfile = await this.profilesRepo.findByEmail(identity.email);
|
const existingProfile = await this.profilesRepo.findByEmail(identity.email);
|
||||||
if (existingProfile) {
|
if (existingProfile) {
|
||||||
throw new ConflictException(`Profile with email ${identity.email} already exists`);
|
throw new ConflictException(
|
||||||
|
`Profile with email ${identity.email} already exists`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const company = await this.companiesRepo.create({
|
const company = await this.companiesRepo.create({
|
||||||
name: dto.companyName,
|
name: dto.companyName,
|
||||||
type: dto.companyType,
|
type: dto.companyType,
|
||||||
tin: dto.tin ?? '',
|
tin: dto.tin ?? "",
|
||||||
vatNumber: dto.vatNumber ?? null,
|
vatNumber: dto.vatNumber ?? null,
|
||||||
businessLicense: dto.fanNumber ?? null,
|
|
||||||
fanNumber: dto.fanNumber ?? null,
|
fanNumber: dto.fanNumber ?? null,
|
||||||
country: dto.companyLocation ?? 'Ethiopia',
|
country: dto.companyLocation ?? "Ethiopia",
|
||||||
address: dto.companyAddress ?? null,
|
address: dto.companyAddress ?? null,
|
||||||
phone: dto.companyPhone ?? null,
|
phone: dto.companyPhone ?? null,
|
||||||
email: dto.companyEmail ?? null,
|
email: dto.companyEmail ?? null,
|
||||||
@@ -78,11 +92,39 @@ export class CompaniesService {
|
|||||||
isPrimaryContact: dto.isPrimaryContact ?? true,
|
isPrimaryContact: dto.isPrimaryContact ?? true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Persist the operational role(s) chosen during onboarding. Types are
|
||||||
|
// already constrained to the company type on the client; any that don't
|
||||||
|
// match are skipped defensively rather than failing the whole signup.
|
||||||
|
if (dto.companyProfiles?.length) {
|
||||||
|
const allowedTypes = this.getProfileTypeForCompanyType(company.type);
|
||||||
|
for (const input of dto.companyProfiles) {
|
||||||
|
if (!allowedTypes.includes(input.type)) continue;
|
||||||
|
const existing = await this.companyProfilesRepo.findByType(
|
||||||
|
company.id,
|
||||||
|
input.type,
|
||||||
|
);
|
||||||
|
if (existing) continue;
|
||||||
|
const reference = await this.companyProfilesRepo.generateReference(
|
||||||
|
input.type,
|
||||||
|
);
|
||||||
|
await this.companyProfilesRepo.create({
|
||||||
|
companyId: company.id,
|
||||||
|
type: input.type,
|
||||||
|
reference,
|
||||||
|
businessLicense: input.businessLicense ?? null,
|
||||||
|
status: ProfileStatus.Active,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
company.companyProfiles = await this.companyProfilesRepo.findByCompanyId(
|
||||||
|
company.id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return { company, profile };
|
return { company, profile };
|
||||||
}
|
}
|
||||||
|
|
||||||
async findAllCompanies(): Promise<Company[]> {
|
async findAllCompanies(): Promise<Company[]> {
|
||||||
return this.companiesRepo.findAll({ order: { name: 'ASC' as any } });
|
return this.companiesRepo.findAll({ order: { name: "ASC" } });
|
||||||
}
|
}
|
||||||
|
|
||||||
async findCompanyById(id: string): Promise<Company> {
|
async findCompanyById(id: string): Promise<Company> {
|
||||||
@@ -91,12 +133,21 @@ export class CompaniesService {
|
|||||||
return company;
|
return company;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getCompanyInfoByUserId(userId: string): Promise<{ profile: ExternalProfile; company: Company }> {
|
async getCompanyInfoByUserId(
|
||||||
|
userId: string,
|
||||||
|
): Promise<{ profile: ExternalProfile; company: Company }> {
|
||||||
const profile = await this.profilesRepo.findByUserId(userId);
|
const profile = await this.profilesRepo.findByUserId(userId);
|
||||||
if (!profile) throw new NotFoundException(`Profile for user ${userId} not found`);
|
if (!profile)
|
||||||
|
throw new NotFoundException(`Profile for user ${userId} not found`);
|
||||||
|
|
||||||
const company = profile.company;
|
const company = profile.company;
|
||||||
if (!company) throw new NotFoundException(`Company for profile ${profile.id} not found`);
|
if (!company)
|
||||||
|
throw new NotFoundException(
|
||||||
|
`Company for profile ${profile.id} not found`,
|
||||||
|
);
|
||||||
|
|
||||||
|
company.companyProfiles =
|
||||||
|
await this.companyProfilesRepo.findByCompanyId(company.id);
|
||||||
|
|
||||||
return { profile, company };
|
return { profile, company };
|
||||||
}
|
}
|
||||||
@@ -114,7 +165,9 @@ export class CompaniesService {
|
|||||||
* column, so "delivered YTD" counts bookings created this year that reached a
|
* column, so "delivered YTD" counts bookings created this year that reached a
|
||||||
* delivered/completed status.
|
* delivered/completed status.
|
||||||
*/
|
*/
|
||||||
async getDashboardSummary(userId: string): Promise<DashboardSummaryResponseDto> {
|
async getDashboardSummary(
|
||||||
|
userId: string,
|
||||||
|
): Promise<DashboardSummaryResponseDto> {
|
||||||
// A user without a company profile has no bookings — return an empty summary
|
// A user without a company profile has no bookings — return an empty summary
|
||||||
// rather than 404, so the portal home still renders.
|
// rather than 404, so the portal home still renders.
|
||||||
const profile = await this.profilesRepo.findByUserId(userId);
|
const profile = await this.profilesRepo.findByUserId(userId);
|
||||||
@@ -125,7 +178,9 @@ export class CompaniesService {
|
|||||||
const yearStart = new Date(now.getFullYear(), 0, 1);
|
const yearStart = new Date(now.getFullYear(), 0, 1);
|
||||||
const prevYearStart = new Date(now.getFullYear() - 1, 0, 1);
|
const prevYearStart = new Date(now.getFullYear() - 1, 0, 1);
|
||||||
// Same point in the previous year, so YoY compares like-for-like windows.
|
// Same point in the previous year, so YoY compares like-for-like windows.
|
||||||
const prevYearToDate = new Date(prevYearStart.getTime() + (now.getTime() - yearStart.getTime()));
|
const prevYearToDate = new Date(
|
||||||
|
prevYearStart.getTime() + (now.getTime() - yearStart.getTime()),
|
||||||
|
);
|
||||||
|
|
||||||
const [
|
const [
|
||||||
deliveredThis,
|
deliveredThis,
|
||||||
@@ -139,20 +194,36 @@ export class CompaniesService {
|
|||||||
this.dashboardRepo.countDelivered(companyId, yearStart, now),
|
this.dashboardRepo.countDelivered(companyId, yearStart, now),
|
||||||
this.dashboardRepo.countCommitted(companyId, yearStart, now),
|
this.dashboardRepo.countCommitted(companyId, yearStart, now),
|
||||||
this.dashboardRepo.sumPaidSpendByCurrency(companyId, yearStart, now),
|
this.dashboardRepo.sumPaidSpendByCurrency(companyId, yearStart, now),
|
||||||
this.dashboardRepo.sumPaidSpendByCurrency(companyId, prevYearStart, prevYearToDate),
|
this.dashboardRepo.sumPaidSpendByCurrency(
|
||||||
|
companyId,
|
||||||
|
prevYearStart,
|
||||||
|
prevYearToDate,
|
||||||
|
),
|
||||||
this.dashboardRepo.sumCommittedTonnage(companyId, yearStart, now),
|
this.dashboardRepo.sumCommittedTonnage(companyId, yearStart, now),
|
||||||
this.dashboardRepo.sumCommittedTonnage(companyId, prevYearStart, prevYearToDate),
|
this.dashboardRepo.sumCommittedTonnage(
|
||||||
this.dashboardRepo.monthlyCommittedTonnage(companyId, this.monthsAgo(now, 5), now),
|
companyId,
|
||||||
|
prevYearStart,
|
||||||
|
prevYearToDate,
|
||||||
|
),
|
||||||
|
this.dashboardRepo.monthlyCommittedTonnage(
|
||||||
|
companyId,
|
||||||
|
this.monthsAgo(now, 5),
|
||||||
|
now,
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Spend can span currencies; report the dominant one (prefer ETB on ties).
|
// Spend can span currencies; report the dominant one (prefer ETB on ties).
|
||||||
const spend = this.pickCurrencyTotal(spendThisByCcy);
|
const spend = this.pickCurrencyTotal(spendThisByCcy);
|
||||||
const spendPrev = spendPrevByCcy.find((c) => c.currency === spend.currency)?.total ?? 0;
|
const spendPrev =
|
||||||
|
spendPrevByCcy.find((c) => c.currency === spend.currency)?.total ?? 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
deliveredCount: deliveredThis,
|
deliveredCount: deliveredThis,
|
||||||
// Share of committed bookings that reached delivered/completed.
|
// Share of committed bookings that reached delivered/completed.
|
||||||
completionRate: committedThis > 0 ? Math.round((deliveredThis / committedThis) * 100) : 0,
|
completionRate:
|
||||||
|
committedThis > 0
|
||||||
|
? Math.round((deliveredThis / committedThis) * 100)
|
||||||
|
: 0,
|
||||||
spendYtd: spend.total,
|
spendYtd: spend.total,
|
||||||
spendCurrency: spend.currency,
|
spendCurrency: spend.currency,
|
||||||
spendYtdChangePct: this.changePct(spend.total, spendPrev),
|
spendYtdChangePct: this.changePct(spend.total, spendPrev),
|
||||||
@@ -172,12 +243,12 @@ export class CompaniesService {
|
|||||||
deliveredCount: 0,
|
deliveredCount: 0,
|
||||||
completionRate: 0,
|
completionRate: 0,
|
||||||
spendYtd: 0,
|
spendYtd: 0,
|
||||||
spendCurrency: 'ETB',
|
spendCurrency: "ETB",
|
||||||
spendYtdChangePct: 0,
|
spendYtdChangePct: 0,
|
||||||
freightVolume: {
|
freightVolume: {
|
||||||
totalTonnes: 0,
|
totalTonnes: 0,
|
||||||
totalValue: 0,
|
totalValue: 0,
|
||||||
currency: 'ETB',
|
currency: "ETB",
|
||||||
ytdChangePct: 0,
|
ytdChangePct: 0,
|
||||||
monthly: this.buildMonthlySeries(now, []),
|
monthly: this.buildMonthlySeries(now, []),
|
||||||
},
|
},
|
||||||
@@ -190,8 +261,11 @@ export class CompaniesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Pick the currency with the largest total, preferring ETB on ties / when empty. */
|
/** Pick the currency with the largest total, preferring ETB on ties / when empty. */
|
||||||
private pickCurrencyTotal(totals: { currency: string; total: number }[]): { currency: string; total: number } {
|
private pickCurrencyTotal(totals: { currency: string; total: number }[]): {
|
||||||
if (totals.length === 0) return { currency: 'ETB', total: 0 };
|
currency: string;
|
||||||
|
total: number;
|
||||||
|
} {
|
||||||
|
if (totals.length === 0) return { currency: "ETB", total: 0 };
|
||||||
return totals.reduce((best, cur) => (cur.total > best.total ? cur : best));
|
return totals.reduce((best, cur) => (cur.total > best.total ? cur : best));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,13 +280,29 @@ export class CompaniesService {
|
|||||||
now: Date,
|
now: Date,
|
||||||
rows: { year: number; month: number; tonnes: number }[],
|
rows: { year: number; month: number; tonnes: number }[],
|
||||||
): { month: string; tonnes: number }[] {
|
): { month: string; tonnes: number }[] {
|
||||||
const labels = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
const labels = [
|
||||||
|
"Jan",
|
||||||
|
"Feb",
|
||||||
|
"Mar",
|
||||||
|
"Apr",
|
||||||
|
"May",
|
||||||
|
"Jun",
|
||||||
|
"Jul",
|
||||||
|
"Aug",
|
||||||
|
"Sep",
|
||||||
|
"Oct",
|
||||||
|
"Nov",
|
||||||
|
"Dec",
|
||||||
|
];
|
||||||
const byKey = new Map(rows.map((r) => [`${r.year}-${r.month}`, r.tonnes]));
|
const byKey = new Map(rows.map((r) => [`${r.year}-${r.month}`, r.tonnes]));
|
||||||
const series: { month: string; tonnes: number }[] = [];
|
const series: { month: string; tonnes: number }[] = [];
|
||||||
for (let i = 5; i >= 0; i--) {
|
for (let i = 5; i >= 0; i--) {
|
||||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||||
const key = `${d.getFullYear()}-${d.getMonth() + 1}`;
|
const key = `${d.getFullYear()}-${d.getMonth() + 1}`;
|
||||||
series.push({ month: labels[d.getMonth()], tonnes: Math.round(byKey.get(key) ?? 0) });
|
series.push({
|
||||||
|
month: labels[d.getMonth()],
|
||||||
|
tonnes: Math.round(byKey.get(key) ?? 0),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return series;
|
return series;
|
||||||
}
|
}
|
||||||
@@ -224,7 +314,10 @@ export class CompaniesService {
|
|||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateProfile(userId: string, dto: UpdateProfileDto): Promise<ProfileResponseDto> {
|
async updateProfile(
|
||||||
|
userId: string,
|
||||||
|
dto: UpdateProfileDto,
|
||||||
|
): Promise<ProfileResponseDto> {
|
||||||
const { profile, company } = await this.getCompanyInfoByUserId(userId);
|
const { profile, company } = await this.getCompanyInfoByUserId(userId);
|
||||||
|
|
||||||
const companyUpdates: Record<string, any> = {};
|
const companyUpdates: Record<string, any> = {};
|
||||||
@@ -233,30 +326,38 @@ export class CompaniesService {
|
|||||||
if (dto.companyName !== undefined) companyUpdates.name = dto.companyName;
|
if (dto.companyName !== undefined) companyUpdates.name = dto.companyName;
|
||||||
if (dto.companyEmail !== undefined) companyUpdates.email = dto.companyEmail;
|
if (dto.companyEmail !== undefined) companyUpdates.email = dto.companyEmail;
|
||||||
if (dto.companyPhone !== undefined) companyUpdates.phone = dto.companyPhone;
|
if (dto.companyPhone !== undefined) companyUpdates.phone = dto.companyPhone;
|
||||||
if (dto.companyLocation !== undefined) companyUpdates.country = dto.companyLocation;
|
if (dto.companyLocation !== undefined)
|
||||||
if (dto.companyAddress !== undefined) companyUpdates.address = dto.companyAddress;
|
companyUpdates.country = dto.companyLocation;
|
||||||
|
if (dto.companyAddress !== undefined)
|
||||||
|
companyUpdates.address = dto.companyAddress;
|
||||||
if (dto.tin !== undefined) companyUpdates.tin = dto.tin;
|
if (dto.tin !== undefined) companyUpdates.tin = dto.tin;
|
||||||
if (dto.vatNumber !== undefined) companyUpdates.vatNumber = dto.vatNumber;
|
if (dto.vatNumber !== undefined) companyUpdates.vatNumber = dto.vatNumber;
|
||||||
if (dto.fanNumber !== undefined) {
|
if (dto.fanNumber !== undefined) {
|
||||||
companyUpdates.businessLicense = dto.fanNumber;
|
|
||||||
companyUpdates.fanNumber = dto.fanNumber;
|
companyUpdates.fanNumber = dto.fanNumber;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dto.contactPersonName !== undefined) attrUpdates.contactPersonName = dto.contactPersonName;
|
if (dto.contactPersonName !== undefined)
|
||||||
if (dto.contactPersonPhone !== undefined) attrUpdates.contactPersonPhone = dto.contactPersonPhone;
|
attrUpdates.contactPersonName = dto.contactPersonName;
|
||||||
if (dto.generalManagerName !== undefined) attrUpdates.generalManagerName = dto.generalManagerName;
|
if (dto.contactPersonPhone !== undefined)
|
||||||
if (dto.generalManagerEmail !== undefined) attrUpdates.generalManagerEmail = dto.generalManagerEmail;
|
attrUpdates.contactPersonPhone = dto.contactPersonPhone;
|
||||||
if (dto.generalManagerPhone !== undefined) attrUpdates.generalManagerPhone = dto.generalManagerPhone;
|
if (dto.generalManagerName !== undefined)
|
||||||
|
attrUpdates.generalManagerName = dto.generalManagerName;
|
||||||
|
if (dto.generalManagerEmail !== undefined)
|
||||||
|
attrUpdates.generalManagerEmail = dto.generalManagerEmail;
|
||||||
|
if (dto.generalManagerPhone !== undefined)
|
||||||
|
attrUpdates.generalManagerPhone = dto.generalManagerPhone;
|
||||||
if (dto.poaName !== undefined) attrUpdates.poaName = dto.poaName;
|
if (dto.poaName !== undefined) attrUpdates.poaName = dto.poaName;
|
||||||
if (dto.poaPhone !== undefined) attrUpdates.poaPhone = dto.poaPhone;
|
if (dto.poaPhone !== undefined) attrUpdates.poaPhone = dto.poaPhone;
|
||||||
if (dto.poaEmail !== undefined) attrUpdates.poaEmail = dto.poaEmail;
|
if (dto.poaEmail !== undefined) attrUpdates.poaEmail = dto.poaEmail;
|
||||||
if (dto.poaLocation !== undefined) attrUpdates.poaLocation = dto.poaLocation;
|
if (dto.poaLocation !== undefined)
|
||||||
|
attrUpdates.poaLocation = dto.poaLocation;
|
||||||
if (dto.poaAddress !== undefined) attrUpdates.poaAddress = dto.poaAddress;
|
if (dto.poaAddress !== undefined) attrUpdates.poaAddress = dto.poaAddress;
|
||||||
|
|
||||||
companyUpdates.attributes = attrUpdates;
|
companyUpdates.attributes = attrUpdates;
|
||||||
|
|
||||||
const updated = await this.companiesRepo.update(company.id, companyUpdates);
|
const updated = await this.companiesRepo.update(company.id, companyUpdates);
|
||||||
if (!updated) throw new NotFoundException(`Company ${company.id} not found`);
|
if (!updated)
|
||||||
|
throw new NotFoundException(`Company ${company.id} not found`);
|
||||||
return new ProfileResponseDto(profile, updated);
|
return new ProfileResponseDto(profile, updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,7 +371,9 @@ export class CompaniesService {
|
|||||||
|
|
||||||
const existing = await this.profilesRepo.findByEmail(dto.email);
|
const existing = await this.profilesRepo.findByEmail(dto.email);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
throw new ConflictException(`Profile with email ${dto.email} already exists`);
|
throw new ConflictException(
|
||||||
|
`Profile with email ${dto.email} already exists`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.profilesRepo.create(dto);
|
return this.profilesRepo.create(dto);
|
||||||
@@ -278,7 +381,8 @@ export class CompaniesService {
|
|||||||
|
|
||||||
async findProfileByUserId(userId: string): Promise<ExternalProfile> {
|
async findProfileByUserId(userId: string): Promise<ExternalProfile> {
|
||||||
const profile = await this.profilesRepo.findByUserId(userId);
|
const profile = await this.profilesRepo.findByUserId(userId);
|
||||||
if (!profile) throw new NotFoundException(`Profile for user ${userId} not found`);
|
if (!profile)
|
||||||
|
throw new NotFoundException(`Profile for user ${userId} not found`);
|
||||||
return profile;
|
return profile;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,28 +390,119 @@ export class CompaniesService {
|
|||||||
return this.profilesRepo.findByCompanyId(companyId);
|
return this.profilesRepo.findByCompanyId(companyId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async createFFClient(dto: CreateFFClientDto): Promise<FFClient> {
|
private getProfileTypeForCompanyType(companyType: string): ProfileType[] {
|
||||||
await this.findCompanyById(dto.forwarderCompanyId);
|
switch (companyType) {
|
||||||
await this.findCompanyById(dto.clientCompanyId);
|
case "customer":
|
||||||
|
return [ProfileType.importer, ProfileType.exporter];
|
||||||
|
case "freight_forwarder":
|
||||||
|
return [ProfileType.freightForwarder];
|
||||||
|
case "dj_freight_forwarder":
|
||||||
|
return [ProfileType.djFreightForwarder];
|
||||||
|
case "transporter":
|
||||||
|
return [ProfileType.transporter];
|
||||||
|
default:
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const existing = await this.ffClientsRepo.findRelationship(
|
async createCompanyProfile(
|
||||||
dto.forwarderCompanyId,
|
companyId: string,
|
||||||
dto.clientCompanyId,
|
profileType?: ProfileType,
|
||||||
);
|
): Promise<CompanyProfile> {
|
||||||
if (existing) {
|
const company = await this.findCompanyById(companyId);
|
||||||
throw new ConflictException('This forwarder-client relationship already exists');
|
|
||||||
|
const allowedTypes = this.getProfileTypeForCompanyType(company.type);
|
||||||
|
|
||||||
|
const type = profileType ?? allowedTypes[0];
|
||||||
|
if (!allowedTypes.includes(type)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Profile type "${type}" is not allowed for company type "${company.type}"`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.ffClientsRepo.create(dto);
|
const existing = await this.companyProfilesRepo.findByType(companyId, type);
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Company already has a ${type} profile (${existing.reference})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reference = await this.companyProfilesRepo.generateReference(type);
|
||||||
|
|
||||||
|
return this.companyProfilesRepo.create({
|
||||||
|
companyId,
|
||||||
|
type,
|
||||||
|
reference,
|
||||||
|
status: ProfileStatus.Active,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async findForwarderClients(forwarderCompanyId: string): Promise<FFClient[]> {
|
async createDefaultProfilesForCompany(
|
||||||
return this.ffClientsRepo.findByForwarder(forwarderCompanyId);
|
companyId: string,
|
||||||
|
): Promise<CompanyProfile[]> {
|
||||||
|
const company = await this.findCompanyById(companyId);
|
||||||
|
const types = this.getProfileTypeForCompanyType(company.type);
|
||||||
|
|
||||||
|
const profiles: CompanyProfile[] = [];
|
||||||
|
for (const type of types) {
|
||||||
|
const existing = await this.companyProfilesRepo.findByType(
|
||||||
|
companyId,
|
||||||
|
type,
|
||||||
|
);
|
||||||
|
if (!existing) {
|
||||||
|
profiles.push(await this.createCompanyProfile(companyId, type));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (profiles.length === 0) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Company of type "${company.type}" must have at least one operational profile`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return profiles;
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteFFClient(id: string): Promise<void> {
|
/**
|
||||||
const client = await this.ffClientsRepo.findById(id);
|
* Add operational profile(s) to the current user's company (portal settings).
|
||||||
if (!client) throw new NotFoundException(`FFClient ${id} not found`);
|
* Add-only and idempotent: each requested type must be allowed for the
|
||||||
await this.ffClientsRepo.softDelete(id);
|
* company's type, profiles that already exist are skipped (not re-created or
|
||||||
|
* rejected), and the full updated list is returned.
|
||||||
|
*/
|
||||||
|
async addCompanyProfilesForUser(
|
||||||
|
userId: string,
|
||||||
|
types: ProfileType[],
|
||||||
|
): Promise<CompanyProfile[]> {
|
||||||
|
const profile = await this.profilesRepo.findByUserId(userId);
|
||||||
|
if (!profile)
|
||||||
|
throw new NotFoundException(`Profile for user ${userId} not found`);
|
||||||
|
|
||||||
|
const companyId = profile.company?.id ?? profile.companyId;
|
||||||
|
const company = await this.findCompanyById(companyId);
|
||||||
|
const allowedTypes = this.getProfileTypeForCompanyType(company.type);
|
||||||
|
|
||||||
|
for (const type of types) {
|
||||||
|
if (!allowedTypes.includes(type)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Profile type "${type}" is not allowed for company type "${company.type}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await this.companyProfilesRepo.findByType(
|
||||||
|
companyId,
|
||||||
|
type,
|
||||||
|
);
|
||||||
|
if (existing) continue;
|
||||||
|
|
||||||
|
const reference = await this.companyProfilesRepo.generateReference(type);
|
||||||
|
await this.companyProfilesRepo.create({
|
||||||
|
companyId,
|
||||||
|
type,
|
||||||
|
reference,
|
||||||
|
status: ProfileStatus.Active,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.companyProfilesRepo.findByCompanyId(companyId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { InjectRepository } from "@nestjs/typeorm";
|
||||||
|
import { Repository } from "typeorm";
|
||||||
|
import { BaseRepository } from "@edr/api-common";
|
||||||
|
import { CompanyProfile, ProfileType } from "./entities/company-profile.entity";
|
||||||
|
|
||||||
|
const SEQUENCE_MAP: Record<ProfileType, string> = {
|
||||||
|
[ProfileType.exporter]: "seq_company_profile_ex",
|
||||||
|
[ProfileType.importer]: "seq_company_profile_im",
|
||||||
|
[ProfileType.freightForwarder]: "seq_company_profile_ffe",
|
||||||
|
[ProfileType.djFreightForwarder]: "seq_company_profile_fwj",
|
||||||
|
[ProfileType.transporter]: "seq_company_profile_tr",
|
||||||
|
};
|
||||||
|
|
||||||
|
const PREFIX_MAP: Record<ProfileType, string> = {
|
||||||
|
[ProfileType.exporter]: "EX",
|
||||||
|
[ProfileType.importer]: "IM",
|
||||||
|
[ProfileType.freightForwarder]: "FFE",
|
||||||
|
[ProfileType.djFreightForwarder]: "FWJ",
|
||||||
|
[ProfileType.transporter]: "TR",
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CompanyProfileRepository extends BaseRepository<CompanyProfile> {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(CompanyProfile)
|
||||||
|
repo: Repository<CompanyProfile>,
|
||||||
|
) {
|
||||||
|
super(repo);
|
||||||
|
}
|
||||||
|
|
||||||
|
async generateReference(type: ProfileType): Promise<string> {
|
||||||
|
const seqName = SEQUENCE_MAP[type];
|
||||||
|
const result = await this.repository.query(
|
||||||
|
`SELECT nextval('${seqName}') AS next_id`,
|
||||||
|
);
|
||||||
|
const nextId = result[0].next_id as number;
|
||||||
|
const prefix = PREFIX_MAP[type];
|
||||||
|
return `${prefix}-${String(nextId).padStart(5, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByCompanyId(companyId: string): Promise<CompanyProfile[]> {
|
||||||
|
return this.repository.find({
|
||||||
|
where: { companyId },
|
||||||
|
relations: ["company"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByType(
|
||||||
|
companyId: string,
|
||||||
|
type: ProfileType,
|
||||||
|
): Promise<CompanyProfile | null> {
|
||||||
|
return this.repository.findOne({
|
||||||
|
where: { companyId, type },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByReference(reference: string): Promise<CompanyProfile | null> {
|
||||||
|
return this.repository.findOne({ where: { reference } });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { IsArray, IsEnum, ArrayMinSize } from "class-validator";
|
||||||
|
import { ProfileType } from "../entities/company-profile.entity";
|
||||||
|
|
||||||
|
export class AddCompanyProfilesDto {
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMinSize(1)
|
||||||
|
@IsEnum(ProfileType, { each: true })
|
||||||
|
types!: ProfileType[];
|
||||||
|
}
|
||||||
@@ -1,5 +1,17 @@
|
|||||||
import { IsString, IsNotEmpty, IsOptional, IsEmail, MaxLength, IsBoolean, IsEnum } from 'class-validator';
|
import { IsString, IsNotEmpty, IsOptional, IsEmail, MaxLength, IsBoolean, IsEnum, IsArray, ValidateNested, ArrayMinSize } from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
import { CompanyType } from '../entities/company.entity';
|
import { CompanyType } from '../entities/company.entity';
|
||||||
|
import { ProfileType } from '../entities/company-profile.entity';
|
||||||
|
|
||||||
|
export class CompanyProfileInputDto {
|
||||||
|
@IsEnum(ProfileType)
|
||||||
|
type!: ProfileType;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
businessLicense?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class CreateCompanyWithProfileDto {
|
export class CreateCompanyWithProfileDto {
|
||||||
@IsEnum(CompanyType)
|
@IsEnum(CompanyType)
|
||||||
@@ -55,4 +67,11 @@ export class CreateCompanyWithProfileDto {
|
|||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
attributes?: Record<string, any>;
|
attributes?: Record<string, any>;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMinSize(1)
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => CompanyProfileInputDto)
|
||||||
|
companyProfiles?: CompanyProfileInputDto[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,11 +25,6 @@ export class CreateCompanyDto {
|
|||||||
@MaxLength(50)
|
@MaxLength(50)
|
||||||
vatNumber?: string;
|
vatNumber?: string;
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(100)
|
|
||||||
businessLicense?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(32)
|
@MaxLength(32)
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
import { IsUUID, IsNotEmpty, IsOptional, IsBoolean, IsEnum } from 'class-validator';
|
|
||||||
import { FFClientRelationship } from '../entities/ff-client.entity';
|
|
||||||
|
|
||||||
export class CreateFFClientDto {
|
|
||||||
@IsUUID()
|
|
||||||
@IsNotEmpty()
|
|
||||||
forwarderCompanyId!: string;
|
|
||||||
|
|
||||||
@IsUUID()
|
|
||||||
@IsNotEmpty()
|
|
||||||
clientCompanyId!: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsEnum(FFClientRelationship)
|
|
||||||
relationshipType?: FFClientRelationship;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsBoolean()
|
|
||||||
canBookOnBehalf?: boolean;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsBoolean()
|
|
||||||
canViewDocuments?: boolean;
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import { Company } from '../entities/company.entity';
|
import { Company } from '../entities/company.entity';
|
||||||
import { ExternalProfile } from '../entities/external-profile.entity';
|
import { ExternalProfile } from '../entities/external-profile.entity';
|
||||||
|
import { ResponseCompanyProfileDto } from './response-company.dto';
|
||||||
|
|
||||||
export class ProfileResponseDto {
|
export class ProfileResponseDto {
|
||||||
companyId: string;
|
companyId: string;
|
||||||
companyName: string;
|
companyName: string;
|
||||||
|
companyType: string;
|
||||||
companyEmail: string | null;
|
companyEmail: string | null;
|
||||||
companyPhone: string | null;
|
companyPhone: string | null;
|
||||||
companyLocation: string;
|
companyLocation: string;
|
||||||
@@ -12,6 +14,8 @@ export class ProfileResponseDto {
|
|||||||
vatNumber: string | null;
|
vatNumber: string | null;
|
||||||
fanNumber: string | null;
|
fanNumber: string | null;
|
||||||
|
|
||||||
|
companyProfiles: ResponseCompanyProfileDto[];
|
||||||
|
|
||||||
contactPersonName: string | null;
|
contactPersonName: string | null;
|
||||||
contactPersonPhone: string | null;
|
contactPersonPhone: string | null;
|
||||||
generalManagerName: string | null;
|
generalManagerName: string | null;
|
||||||
@@ -29,6 +33,10 @@ export class ProfileResponseDto {
|
|||||||
constructor(profile: ExternalProfile, company: Company) {
|
constructor(profile: ExternalProfile, company: Company) {
|
||||||
this.companyId = company.id;
|
this.companyId = company.id;
|
||||||
this.companyName = company.name;
|
this.companyName = company.name;
|
||||||
|
this.companyType = company.type;
|
||||||
|
this.companyProfiles =
|
||||||
|
company.companyProfiles?.map((p) => new ResponseCompanyProfileDto(p)) ??
|
||||||
|
[];
|
||||||
this.companyEmail = company.email ?? null;
|
this.companyEmail = company.email ?? null;
|
||||||
this.companyPhone = company.phone ?? null;
|
this.companyPhone = company.phone ?? null;
|
||||||
this.companyLocation = company.country;
|
this.companyLocation = company.country;
|
||||||
|
|||||||
@@ -1,6 +1,29 @@
|
|||||||
import { Company, CompanyType, CompanyStatus } from '../entities/company.entity';
|
import { Company, CompanyType, CompanyStatus } from '../entities/company.entity';
|
||||||
|
import { CompanyProfile } from '../entities/company-profile.entity';
|
||||||
import { ResponseExternalProfileDto } from './response-external-profile.dto';
|
import { ResponseExternalProfileDto } from './response-external-profile.dto';
|
||||||
|
|
||||||
|
export class ResponseCompanyProfileDto {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
reference: string;
|
||||||
|
status: string;
|
||||||
|
businessLicense?: string | null;
|
||||||
|
attributes?: Record<string, any> | null;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
constructor(profile: CompanyProfile) {
|
||||||
|
this.id = profile.id;
|
||||||
|
this.type = profile.type;
|
||||||
|
this.reference = profile.reference;
|
||||||
|
this.status = profile.status;
|
||||||
|
this.businessLicense = profile.businessLicense;
|
||||||
|
this.attributes = profile.attributes;
|
||||||
|
this.createdAt = profile.createdAt;
|
||||||
|
this.updatedAt = profile.updatedAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class ResponseCompanyDto {
|
export class ResponseCompanyDto {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -8,7 +31,6 @@ export class ResponseCompanyDto {
|
|||||||
status: CompanyStatus;
|
status: CompanyStatus;
|
||||||
tin: string;
|
tin: string;
|
||||||
vatNumber?: string | null;
|
vatNumber?: string | null;
|
||||||
businessLicense?: string | null;
|
|
||||||
fanNumber?: string | null;
|
fanNumber?: string | null;
|
||||||
country: string;
|
country: string;
|
||||||
address?: string | null;
|
address?: string | null;
|
||||||
@@ -17,6 +39,7 @@ export class ResponseCompanyDto {
|
|||||||
website?: string | null;
|
website?: string | null;
|
||||||
attributes?: Record<string, any> | null;
|
attributes?: Record<string, any> | null;
|
||||||
profiles?: ResponseExternalProfileDto[];
|
profiles?: ResponseExternalProfileDto[];
|
||||||
|
companyProfiles?: ResponseCompanyProfileDto[];
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
|
|
||||||
@@ -27,7 +50,6 @@ export class ResponseCompanyDto {
|
|||||||
this.status = company.status;
|
this.status = company.status;
|
||||||
this.tin = company.tin;
|
this.tin = company.tin;
|
||||||
this.vatNumber = company.vatNumber;
|
this.vatNumber = company.vatNumber;
|
||||||
this.businessLicense = company.businessLicense;
|
|
||||||
this.fanNumber = company.fanNumber;
|
this.fanNumber = company.fanNumber;
|
||||||
this.country = company.country;
|
this.country = company.country;
|
||||||
this.address = company.address;
|
this.address = company.address;
|
||||||
@@ -36,6 +58,7 @@ export class ResponseCompanyDto {
|
|||||||
this.website = company.website;
|
this.website = company.website;
|
||||||
this.attributes = company.attributes;
|
this.attributes = company.attributes;
|
||||||
this.profiles = company.profiles?.map((p) => new ResponseExternalProfileDto(p));
|
this.profiles = company.profiles?.map((p) => new ResponseExternalProfileDto(p));
|
||||||
|
this.companyProfiles = company.companyProfiles?.map((p) => new ResponseCompanyProfileDto(p));
|
||||||
this.createdAt = company.createdAt;
|
this.createdAt = company.createdAt;
|
||||||
this.updatedAt = company.updatedAt;
|
this.updatedAt = company.updatedAt;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
import { FFClient, FFClientRelationship } from '../entities/ff-client.entity';
|
|
||||||
|
|
||||||
export class ResponseFFClientDto {
|
|
||||||
id: string;
|
|
||||||
forwarderCompanyId: string;
|
|
||||||
clientCompanyId: string;
|
|
||||||
relationshipType: FFClientRelationship;
|
|
||||||
canBookOnBehalf: boolean;
|
|
||||||
canViewDocuments: boolean;
|
|
||||||
createdAt: Date;
|
|
||||||
updatedAt: Date;
|
|
||||||
|
|
||||||
constructor(client: FFClient) {
|
|
||||||
this.id = client.id;
|
|
||||||
this.forwarderCompanyId = client.forwarderCompanyId;
|
|
||||||
this.clientCompanyId = client.clientCompanyId;
|
|
||||||
this.relationshipType = client.relationshipType;
|
|
||||||
this.canBookOnBehalf = client.canBookOnBehalf;
|
|
||||||
this.canViewDocuments = client.canViewDocuments;
|
|
||||||
this.createdAt = client.createdAt;
|
|
||||||
this.updatedAt = client.updatedAt;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
import { PartialType } from '@nestjs/mapped-types';
|
|
||||||
import { CreateFFClientDto } from './create-ff-client.dto';
|
|
||||||
|
|
||||||
export class UpdateFFClientDto extends PartialType(CreateFFClientDto) {}
|
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { BaseEntity } from "@edr/api-common";
|
||||||
|
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||||
|
import { Company } from "./company.entity";
|
||||||
|
|
||||||
|
export enum ProfileType {
|
||||||
|
importer = "importer",
|
||||||
|
exporter = "exporter",
|
||||||
|
freightForwarder = "freight_forwarder",
|
||||||
|
djFreightForwarder = "dj_freight_forwarder",
|
||||||
|
transporter = "transporter",
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ProfileStatus {
|
||||||
|
Active = "active",
|
||||||
|
Pending = "pending",
|
||||||
|
Suspended = "suspended",
|
||||||
|
Blacklisted = "blacklisted",
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity({ schema: "freight", name: "company_profiles" })
|
||||||
|
@Index(["reference"], { unique: true })
|
||||||
|
@Index(["type"])
|
||||||
|
@Index(["companyId"])
|
||||||
|
export class CompanyProfile extends BaseEntity {
|
||||||
|
@Column({ name: "company_id", type: "uuid" })
|
||||||
|
companyId!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Company, (company) => company.companyProfiles)
|
||||||
|
@JoinColumn({ name: "company_id" })
|
||||||
|
company!: Company;
|
||||||
|
|
||||||
|
@Column({ name: "type", type: "varchar", length: 32, enum: ProfileType })
|
||||||
|
type!: ProfileType;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: "reference",
|
||||||
|
type: "varchar",
|
||||||
|
length: 20,
|
||||||
|
nullable: false,
|
||||||
|
unique: true,
|
||||||
|
})
|
||||||
|
reference!: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: "status",
|
||||||
|
type: "varchar",
|
||||||
|
length: 32,
|
||||||
|
default: ProfileStatus.Active,
|
||||||
|
})
|
||||||
|
status!: ProfileStatus;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: "business_license",
|
||||||
|
type: "varchar",
|
||||||
|
length: 100,
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
businessLicense?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: "attributes", type: "jsonb", nullable: true })
|
||||||
|
attributes?: Record<string, any> | null;
|
||||||
|
}
|
||||||
@@ -1,79 +1,110 @@
|
|||||||
import { BaseEntity } from '@edr/api-common';
|
import { BaseEntity } from "@edr/api-common";
|
||||||
import { Column, Entity, Index, OneToMany } from 'typeorm';
|
import { Column, Entity, Index, OneToMany } from "typeorm";
|
||||||
import { ExternalProfile } from './external-profile.entity';
|
import { ExternalProfile } from "./external-profile.entity";
|
||||||
|
import { CompanyProfile } from "./company-profile.entity";
|
||||||
|
|
||||||
export enum CompanyType {
|
export enum CompanyType {
|
||||||
Customer = 'customer',
|
Customer = "customer",
|
||||||
Forwarder = 'forwarder',
|
FreightForwarder = "freight_forwarder",
|
||||||
Transporter = 'transporter',
|
DJFreightForwarder = "dj_freight_forwarder",
|
||||||
Broker = 'broker',
|
Transporter = "transporter",
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum CompanyStatus {
|
export enum CompanyStatus {
|
||||||
Active = 'active',
|
Active = "active",
|
||||||
Pending = 'pending',
|
Pending = "pending",
|
||||||
Suspended = 'suspended',
|
Suspended = "suspended",
|
||||||
Blacklisted = 'blacklisted',
|
Blacklisted = "blacklisted",
|
||||||
}
|
}
|
||||||
|
|
||||||
@Entity({ schema: 'freight', name: 'companies' })
|
@Entity({ schema: "freight", name: "companies" })
|
||||||
@Index(['tin'])
|
@Index(["tin"])
|
||||||
@Index(['type'])
|
@Index(["type"])
|
||||||
export class Company extends BaseEntity {
|
export class Company extends BaseEntity {
|
||||||
@Column({ name: 'name', type: 'varchar', length: 200 })
|
@Column({ name: "name", type: "varchar", length: 200 })
|
||||||
name!: string;
|
name!: string;
|
||||||
|
|
||||||
@Column({ name: 'type', type: 'varchar', length: 32, enum: CompanyType })
|
@Column({ name: "type", type: "varchar", length: 32, enum: CompanyType })
|
||||||
type!: CompanyType;
|
type!: CompanyType;
|
||||||
|
|
||||||
@Column({ name: 'status', type: 'varchar', length: 32, default: CompanyStatus.Pending })
|
@Column({
|
||||||
|
name: "status",
|
||||||
|
type: "varchar",
|
||||||
|
length: 32,
|
||||||
|
default: CompanyStatus.Pending,
|
||||||
|
})
|
||||||
status!: CompanyStatus;
|
status!: CompanyStatus;
|
||||||
|
|
||||||
@Column({ name: 'tin', type: 'varchar', length: 10, unique: true })
|
@Column({ name: "tin", type: "varchar", length: 10, unique: true })
|
||||||
tin!: string;
|
tin!: string;
|
||||||
|
|
||||||
@Column({ name: 'vat_number', type: 'varchar', length: 50, nullable: true })
|
@Column({ name: "vat_number", type: "varchar", length: 50, nullable: true })
|
||||||
vatNumber?: string | null;
|
vatNumber?: string | null;
|
||||||
|
|
||||||
@Column({ name: 'business_license', type: 'varchar', length: 100, nullable: true })
|
@Column({ name: "fan_number", type: "varchar", length: 16, nullable: true })
|
||||||
businessLicense?: string | null;
|
|
||||||
|
|
||||||
@Column({ name: 'fan_number', type: 'varchar', length: 16, nullable: true })
|
|
||||||
fanNumber?: string | null;
|
fanNumber?: string | null;
|
||||||
|
|
||||||
@Column({ name: 'country', type: 'varchar', length: 32, default: 'Ethiopia' })
|
@Column({ name: "country", type: "varchar", length: 32, default: "Ethiopia" })
|
||||||
country!: string;
|
country!: string;
|
||||||
|
|
||||||
@Column({ name: 'address', type: 'text', nullable: true })
|
@Column({ name: "address", type: "text", nullable: true })
|
||||||
address?: string | null;
|
address?: string | null;
|
||||||
|
|
||||||
@Column({ name: 'phone', type: 'varchar', length: 20, nullable: true })
|
@Column({ name: "phone", type: "varchar", length: 20, nullable: true })
|
||||||
phone?: string | null;
|
phone?: string | null;
|
||||||
|
|
||||||
@Column({ name: 'email', type: 'varchar', length: 150, nullable: true })
|
@Column({ name: "email", type: "varchar", length: 150, nullable: true })
|
||||||
email?: string | null;
|
email?: string | null;
|
||||||
|
|
||||||
@Column({ name: 'contact_person_name', type: 'varchar', length: 100, nullable: true })
|
@Column({
|
||||||
|
name: "contact_person_name",
|
||||||
|
type: "varchar",
|
||||||
|
length: 100,
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
contactPersonName?: string | null;
|
contactPersonName?: string | null;
|
||||||
|
|
||||||
@Column({ name: 'contact_person_phone', type: 'varchar', length: 20, nullable: true })
|
@Column({
|
||||||
|
name: "contact_person_phone",
|
||||||
|
type: "varchar",
|
||||||
|
length: 20,
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
contactPersonPhone?: string | null;
|
contactPersonPhone?: string | null;
|
||||||
|
|
||||||
@Column({ name: 'general_manager_name', type: 'varchar', length: 100, nullable: true })
|
@Column({
|
||||||
|
name: "general_manager_name",
|
||||||
|
type: "varchar",
|
||||||
|
length: 100,
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
generalManagerName?: string | null;
|
generalManagerName?: string | null;
|
||||||
|
|
||||||
@Column({ name: 'general_manager_email', type: 'varchar', length: 150, nullable: true })
|
@Column({
|
||||||
|
name: "general_manager_email",
|
||||||
|
type: "varchar",
|
||||||
|
length: 150,
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
generalManagerEmail?: string | null;
|
generalManagerEmail?: string | null;
|
||||||
|
|
||||||
@Column({ name: 'general_manager_phone', type: 'varchar', length: 20, nullable: true })
|
@Column({
|
||||||
|
name: "general_manager_phone",
|
||||||
|
type: "varchar",
|
||||||
|
length: 20,
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
generalManagerPhone?: string | null;
|
generalManagerPhone?: string | null;
|
||||||
|
|
||||||
@Column({ name: 'website', type: 'varchar', length: 200, nullable: true })
|
@Column({ name: "website", type: "varchar", length: 200, nullable: true })
|
||||||
website?: string | null;
|
website?: string | null;
|
||||||
|
|
||||||
@Column({ name: 'attributes', type: 'jsonb', nullable: true })
|
@Column({ name: "attributes", type: "jsonb", nullable: true })
|
||||||
attributes?: Record<string, any> | null;
|
attributes?: Record<string, any> | null;
|
||||||
|
|
||||||
@OneToMany(() => ExternalProfile, (profile) => profile.company)
|
@OneToMany(() => ExternalProfile, (profile) => profile.company)
|
||||||
profiles?: ExternalProfile[];
|
profiles?: ExternalProfile[];
|
||||||
|
|
||||||
|
@OneToMany(() => CompanyProfile, (profile) => profile.company)
|
||||||
|
companyProfiles?: CompanyProfile[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
import { BaseEntity } from '@edr/api-common';
|
|
||||||
import { Column, Entity, Index, ManyToOne, JoinColumn, Unique } from 'typeorm';
|
|
||||||
import { Company } from './company.entity';
|
|
||||||
|
|
||||||
export enum FFClientRelationship {
|
|
||||||
ManagedAccount = 'managed_account',
|
|
||||||
SubAgent = 'sub_agent',
|
|
||||||
}
|
|
||||||
|
|
||||||
@Entity({ schema: 'freight', name: 'ff_clients' })
|
|
||||||
@Unique(['forwarderCompanyId', 'clientCompanyId'])
|
|
||||||
@Index(['forwarderCompanyId'])
|
|
||||||
@Index(['clientCompanyId'])
|
|
||||||
export class FFClient extends BaseEntity {
|
|
||||||
@Column({ name: 'forwarder_company_id', type: 'uuid' })
|
|
||||||
forwarderCompanyId!: string;
|
|
||||||
|
|
||||||
@ManyToOne(() => Company)
|
|
||||||
@JoinColumn({ name: 'forwarder_company_id' })
|
|
||||||
forwarderCompany!: Company;
|
|
||||||
|
|
||||||
@Column({ name: 'client_company_id', type: 'uuid' })
|
|
||||||
clientCompanyId!: string;
|
|
||||||
|
|
||||||
@ManyToOne(() => Company)
|
|
||||||
@JoinColumn({ name: 'client_company_id' })
|
|
||||||
clientCompany!: Company;
|
|
||||||
|
|
||||||
@Column({ name: 'relationship_type', type: 'varchar', length: 32, default: FFClientRelationship.ManagedAccount })
|
|
||||||
relationshipType!: FFClientRelationship;
|
|
||||||
|
|
||||||
@Column({ name: 'can_book_on_behalf', type: 'boolean', default: true })
|
|
||||||
canBookOnBehalf!: boolean;
|
|
||||||
|
|
||||||
@Column({ name: 'can_view_documents', type: 'boolean', default: true })
|
|
||||||
canViewDocuments!: boolean;
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
|
||||||
import { Repository } from 'typeorm';
|
|
||||||
import { BaseRepository } from '@edr/api-common';
|
|
||||||
import { FFClient } from './entities/ff-client.entity';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class FFClientRepository extends BaseRepository<FFClient> {
|
|
||||||
constructor(
|
|
||||||
@InjectRepository(FFClient)
|
|
||||||
repo: Repository<FFClient>,
|
|
||||||
) {
|
|
||||||
super(repo);
|
|
||||||
}
|
|
||||||
|
|
||||||
async findByForwarder(forwarderCompanyId: string): Promise<FFClient[]> {
|
|
||||||
return this.repository.find({ where: { forwarderCompanyId } as any });
|
|
||||||
}
|
|
||||||
|
|
||||||
async findByClient(clientCompanyId: string): Promise<FFClient[]> {
|
|
||||||
return this.repository.find({ where: { clientCompanyId } as any });
|
|
||||||
}
|
|
||||||
|
|
||||||
async findRelationship(
|
|
||||||
forwarderCompanyId: string,
|
|
||||||
clientCompanyId: string,
|
|
||||||
): Promise<FFClient | null> {
|
|
||||||
return this.repository.findOne({
|
|
||||||
where: { forwarderCompanyId, clientCompanyId } as any,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
// src/modules/customers/customers.controller.ts
|
|
||||||
|
|
||||||
import {
|
|
||||||
Controller,
|
|
||||||
Delete,
|
|
||||||
Get,
|
|
||||||
HttpCode,
|
|
||||||
HttpStatus,
|
|
||||||
Param,
|
|
||||||
ParseUUIDPipe,
|
|
||||||
Patch,
|
|
||||||
Post,
|
|
||||||
Body,
|
|
||||||
Query,
|
|
||||||
} from "@nestjs/common";
|
|
||||||
|
|
||||||
import { ApiOperation } from "@nestjs/swagger";
|
|
||||||
|
|
||||||
import { FreightAdmin } from "../../common/booking-guards";
|
|
||||||
import { CustomersService } from "./customers.service";
|
|
||||||
import { CreateCustomerDto } from "./dto/create-customer.dto";
|
|
||||||
import { UpdateCustomerDto } from "./dto/update-customer.dto";
|
|
||||||
import { Customer } from "./entities/customer.entity";
|
|
||||||
|
|
||||||
@Controller("customers")
|
|
||||||
@FreightAdmin()
|
|
||||||
export class CustomersController {
|
|
||||||
constructor(private readonly customersService: CustomersService) {}
|
|
||||||
|
|
||||||
@Post()
|
|
||||||
create(@Body() createCustomerDto: CreateCustomerDto): Promise<Customer> {
|
|
||||||
return this.customersService.create(createCustomerDto);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get()
|
|
||||||
findAll(): Promise<Customer[]> {
|
|
||||||
return this.customersService.findAll();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get("stats")
|
|
||||||
@ApiOperation({ summary: "Get customer statistics" })
|
|
||||||
getStats(): Promise<{ total: number; withVatNumber: number }> {
|
|
||||||
return this.customersService.getStats();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get("search")
|
|
||||||
searchByName(@Query("name") name: string): Promise<Customer[]> {
|
|
||||||
return this.customersService.searchByName(name);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get("email/:email")
|
|
||||||
findByEmail(@Param("email") email: string): Promise<Customer> {
|
|
||||||
return this.customersService.findByEmail(email);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get("vat/:vatNumber")
|
|
||||||
findByVatNumber(@Param("vatNumber") vatNumber: string): Promise<Customer> {
|
|
||||||
return this.customersService.findByVatNumber(vatNumber);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get(":id")
|
|
||||||
findById(@Param("id", ParseUUIDPipe) id: string): Promise<Customer> {
|
|
||||||
return this.customersService.findById(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// @Get("user/:userId")
|
|
||||||
// findByUserId(@Param("userId", ParseUUIDPipe) userId: string): Promise<any> {
|
|
||||||
// return this.customersService.findByUserId(userId);
|
|
||||||
// }
|
|
||||||
|
|
||||||
@Patch(":id")
|
|
||||||
@ApiOperation({ summary: "Update a customer" })
|
|
||||||
update(
|
|
||||||
@Param("id", ParseUUIDPipe) id: string,
|
|
||||||
@Body() dto: UpdateCustomerDto,
|
|
||||||
): Promise<Customer> {
|
|
||||||
return this.customersService.update(id, dto);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Delete(":id")
|
|
||||||
@ApiOperation({ summary: "Soft-delete a customer" })
|
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
|
||||||
remove(@Param("id", ParseUUIDPipe) id: string): Promise<void> {
|
|
||||||
return this.customersService.delete(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { Module } from "@nestjs/common";
|
|
||||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
|
||||||
|
|
||||||
import { CustomersController } from "./customers.controller";
|
|
||||||
import { CustomersRepository } from "./customers.repository";
|
|
||||||
import { CustomersService } from "./customers.service";
|
|
||||||
import { Customer } from "./entities/customer.entity";
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
imports: [TypeOrmModule.forFeature([Customer])],
|
|
||||||
controllers: [CustomersController],
|
|
||||||
providers: [CustomersService, CustomersRepository],
|
|
||||||
exports: [CustomersService],
|
|
||||||
})
|
|
||||||
export class CustomersModule {}
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
// import { BaseRepository } from "@edr/api-common";
|
|
||||||
// import { EntityRepository } from "typeorm";
|
|
||||||
|
|
||||||
// src/modules/customers/customers.repository.ts
|
|
||||||
import { Injectable } from "@nestjs/common";
|
|
||||||
import { InjectRepository } from "@nestjs/typeorm";
|
|
||||||
import { Repository, FindManyOptions, FindOptionsWhere } from "typeorm";
|
|
||||||
import { Customer } from "./entities/customer.entity";
|
|
||||||
import { CreateCustomerDto } from "./dto/create-customer.dto";
|
|
||||||
// import { UpdateCustomerDto } from "./dto/update-customer.dto";
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class CustomersRepository {
|
|
||||||
constructor(
|
|
||||||
@InjectRepository(Customer)
|
|
||||||
private readonly repository: Repository<Customer>,
|
|
||||||
) { }
|
|
||||||
|
|
||||||
async create(dto: CreateCustomerDto): Promise<Customer> {
|
|
||||||
const customer = this.repository.create(dto);
|
|
||||||
return await this.repository.save(customer);
|
|
||||||
}
|
|
||||||
|
|
||||||
async findAll(options?: FindManyOptions<Customer>): Promise<Customer[]> {
|
|
||||||
return await this.repository.find(options);
|
|
||||||
}
|
|
||||||
|
|
||||||
async findById(id: string): Promise<Customer | null> {
|
|
||||||
return await this.repository.findOne({ where: { id } as FindOptionsWhere<Customer> });
|
|
||||||
}
|
|
||||||
|
|
||||||
async findByUserId(userId: string): Promise<Customer | null> {
|
|
||||||
return await this.repository.findOne({ where: { userId } as FindOptionsWhere<Customer> });
|
|
||||||
}
|
|
||||||
|
|
||||||
async findByEmail(email: string): Promise<Customer | null> {
|
|
||||||
return await this.repository.findOne({ where: { email } as FindOptionsWhere<Customer> });
|
|
||||||
}
|
|
||||||
|
|
||||||
async findByVatNumber(vatNumber: string): Promise<Customer | null> {
|
|
||||||
return await this.repository.findOne({ where: { vatNumber } as FindOptionsWhere<Customer> });
|
|
||||||
}
|
|
||||||
|
|
||||||
async findByName(name: string): Promise<Customer[]> {
|
|
||||||
return await this.repository
|
|
||||||
.createQueryBuilder("customer")
|
|
||||||
.where("customer.companyName ILIKE :name", { name: `%${name}%` })
|
|
||||||
.getMany();
|
|
||||||
}
|
|
||||||
|
|
||||||
async findOneByEmailOrVat(email?: string, vatNumber?: string): Promise<Customer | null> {
|
|
||||||
if (!email && !vatNumber) return null;
|
|
||||||
|
|
||||||
const queryBuilder = this.repository.createQueryBuilder('customer');
|
|
||||||
|
|
||||||
if (email && vatNumber) {
|
|
||||||
queryBuilder.where('customer.email = :email', { email })
|
|
||||||
.orWhere('customer.vatNumber = :vatNumber', { vatNumber });
|
|
||||||
} else if (email) {
|
|
||||||
queryBuilder.where('customer.email = :email', { email });
|
|
||||||
} else if (vatNumber) {
|
|
||||||
queryBuilder.where('customer.vatNumber = :vatNumber', { vatNumber });
|
|
||||||
}
|
|
||||||
|
|
||||||
return await queryBuilder.getOne();
|
|
||||||
}
|
|
||||||
|
|
||||||
async update(id: string, updates: Partial<Customer>): Promise<Customer | null> {
|
|
||||||
await this.repository.update(id, updates);
|
|
||||||
return this.findById(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
async delete(id: string): Promise<boolean> {
|
|
||||||
const result = await this.repository.delete(id);
|
|
||||||
return (result.affected ?? 0) > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
async count(where?: any): Promise<number> {
|
|
||||||
if (where?.createdAt) {
|
|
||||||
const result = await this.repository
|
|
||||||
.createQueryBuilder('customer')
|
|
||||||
.where('customer.createdAt >= :date', { date: where.createdAt })
|
|
||||||
.getCount();
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
return await this.repository.count();
|
|
||||||
}
|
|
||||||
|
|
||||||
async existsByUniqueFields(email: string, vatNumber?: string): Promise<boolean> {
|
|
||||||
const queryBuilder = this.repository.createQueryBuilder('customer')
|
|
||||||
.where('customer.email = :email', { email });
|
|
||||||
|
|
||||||
if (vatNumber) {
|
|
||||||
queryBuilder.orWhere('customer.vatNumber = :vatNumber', { vatNumber });
|
|
||||||
}
|
|
||||||
|
|
||||||
const count = await queryBuilder.getCount();
|
|
||||||
return count > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
async countWithVatNumber(): Promise<number> {
|
|
||||||
const count = await this.repository
|
|
||||||
.createQueryBuilder('customer')
|
|
||||||
.where('customer.vatNumber IS NOT NULL')
|
|
||||||
.andWhere("customer.vatNumber != ''")
|
|
||||||
.getCount();
|
|
||||||
|
|
||||||
return count;
|
|
||||||
}
|
|
||||||
|
|
||||||
getRepository(): Repository<Customer> {
|
|
||||||
return this.repository;
|
|
||||||
}
|
|
||||||
softDelete(id: string): any {
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
import {
|
|
||||||
Injectable,
|
|
||||||
NotFoundException,
|
|
||||||
ConflictException,
|
|
||||||
BadRequestException,
|
|
||||||
} from "@nestjs/common";
|
|
||||||
|
|
||||||
import { CustomersRepository } from "./customers.repository";
|
|
||||||
import { CreateCustomerDto } from "./dto/create-customer.dto";
|
|
||||||
import { UpdateCustomerDto } from "./dto/update-customer.dto";
|
|
||||||
import { Customer } from "./entities/customer.entity";
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class CustomersService {
|
|
||||||
constructor(private readonly customersRepository: CustomersRepository) {}
|
|
||||||
|
|
||||||
/** Create a new customer */
|
|
||||||
async create(dto: CreateCustomerDto): Promise<Customer> {
|
|
||||||
const exists = await this.customersRepository.existsByUniqueFields(
|
|
||||||
dto.email,
|
|
||||||
dto.vatNumber,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (exists) {
|
|
||||||
throw new ConflictException(
|
|
||||||
"Customer with same email or VAT number already exists",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dto.vatNumber && dto.vatNumber.length !== 10) {
|
|
||||||
throw new BadRequestException("VAT number must be exactly 10 digits");
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.customersRepository.create(dto);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get all customers */
|
|
||||||
findAll(): Promise<Customer[]> {
|
|
||||||
return this.customersRepository.findAll({
|
|
||||||
order: { companyName: "ASC" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get customer by ID */
|
|
||||||
async findById(id: string): Promise<Customer> {
|
|
||||||
const customer = await this.customersRepository.findById(id);
|
|
||||||
|
|
||||||
if (!customer) {
|
|
||||||
throw new NotFoundException(`Customer with ID ${id} not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return customer;
|
|
||||||
}
|
|
||||||
|
|
||||||
// async findByUserId(userId: string): Promise<Customer> {
|
|
||||||
// const customer = await this.customersRepository.findByUserId(userId);
|
|
||||||
|
|
||||||
// if (!customer) {
|
|
||||||
// throw new NotFoundException(`Customer with ID ${userId} not found`);
|
|
||||||
// }
|
|
||||||
|
|
||||||
// return customer;
|
|
||||||
//}
|
|
||||||
|
|
||||||
/** Get customer by email */
|
|
||||||
async findByEmail(email: string): Promise<Customer> {
|
|
||||||
const customer = await this.customersRepository.findByEmail(email);
|
|
||||||
|
|
||||||
if (!customer) {
|
|
||||||
throw new NotFoundException(`Customer with email ${email} not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return customer;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get customer by VAT number */
|
|
||||||
async findByVatNumber(vatNumber: string): Promise<Customer> {
|
|
||||||
const customer = await this.customersRepository.findByVatNumber(vatNumber);
|
|
||||||
|
|
||||||
if (!customer) {
|
|
||||||
throw new NotFoundException(
|
|
||||||
`Customer with VAT number ${vatNumber} not found`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return customer;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Search customers by name */
|
|
||||||
searchByName(name: string): Promise<Customer[]> {
|
|
||||||
return this.customersRepository.findByName(name);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Update customer */
|
|
||||||
async update(id: string, dto: UpdateCustomerDto): Promise<Customer> {
|
|
||||||
await this.findById(id);
|
|
||||||
|
|
||||||
// Validate VAT number if provided
|
|
||||||
if (dto.vatNumber && dto.vatNumber.length !== 10) {
|
|
||||||
throw new BadRequestException("VAT number must be exactly 10 digits");
|
|
||||||
}
|
|
||||||
|
|
||||||
// // Check email conflict
|
|
||||||
// if (dto.email) {
|
|
||||||
// const existing = await this.customersRepository.findByEmail(dto.email);
|
|
||||||
|
|
||||||
// // if (existing && existing.userId !== id) {
|
|
||||||
// // throw new ConflictException(
|
|
||||||
// // `Customer with email "${dto.email}" already exists`,
|
|
||||||
// // );
|
|
||||||
// // }
|
|
||||||
// }
|
|
||||||
|
|
||||||
const updated = await this.customersRepository.update(id, dto);
|
|
||||||
|
|
||||||
if (!updated) {
|
|
||||||
throw new NotFoundException(`Customer ${id} not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return updated;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Delete customer (soft delete) */
|
|
||||||
async remove(id: string): Promise<void> {
|
|
||||||
await this.findById(id);
|
|
||||||
await this.customersRepository.softDelete(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get customer statistics */
|
|
||||||
async getStats(): Promise<{ total: number; withVatNumber: number }> {
|
|
||||||
const total = await this.customersRepository.count();
|
|
||||||
const withVatNumber = await this.customersRepository.countWithVatNumber();
|
|
||||||
|
|
||||||
return { total, withVatNumber };
|
|
||||||
}
|
|
||||||
|
|
||||||
delete(id: string): any {
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
import {
|
|
||||||
IsEmail,
|
|
||||||
IsEnum,
|
|
||||||
IsOptional,
|
|
||||||
IsString,
|
|
||||||
MaxLength,
|
|
||||||
IsNotEmpty,
|
|
||||||
Length,
|
|
||||||
Matches,
|
|
||||||
} from "class-validator";
|
|
||||||
|
|
||||||
// Enums
|
|
||||||
export enum CustomerStatusDto {
|
|
||||||
Active = "Active",
|
|
||||||
Pending = "Pending",
|
|
||||||
Inactive = "Inactive",
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum CustomerTypeDto {
|
|
||||||
Importer = "Importer",
|
|
||||||
Exporter = "Exporter",
|
|
||||||
Supplier = "Supplier",
|
|
||||||
}
|
|
||||||
|
|
||||||
// DTO
|
|
||||||
export class CreateCustomerDto {
|
|
||||||
// Basic identity
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
userId!: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
@MaxLength(100)
|
|
||||||
firstName!: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
@MaxLength(100)
|
|
||||||
lastName!: string;
|
|
||||||
|
|
||||||
@IsEmail()
|
|
||||||
@IsNotEmpty()
|
|
||||||
email!: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
@MaxLength(20)
|
|
||||||
phone!: string;
|
|
||||||
|
|
||||||
// Company info
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
@MaxLength(200)
|
|
||||||
companyName!: string;
|
|
||||||
|
|
||||||
@IsEmail()
|
|
||||||
@IsNotEmpty()
|
|
||||||
companyEmail!: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
@MaxLength(20)
|
|
||||||
companyPhone!: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
@MaxLength(100)
|
|
||||||
companyLocation!: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
companyAddress!: string;
|
|
||||||
|
|
||||||
// Classification
|
|
||||||
@IsOptional()
|
|
||||||
@IsEnum(CustomerTypeDto)
|
|
||||||
customerType?: CustomerTypeDto;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsEnum(CustomerStatusDto)
|
|
||||||
status?: CustomerStatusDto;
|
|
||||||
|
|
||||||
// Legal identifiers
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
@Length(10, 10)
|
|
||||||
@Matches(/^\d+$/, { message: "TIN must contain only digits" })
|
|
||||||
tinNumber!: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
@Length(16, 16)
|
|
||||||
@Matches(/^\d+$/, { message: "FAN must contain only digits" })
|
|
||||||
fanNumber!: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
@MaxLength(50)
|
|
||||||
vatNumber!: string;
|
|
||||||
|
|
||||||
// Contact person
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
@MaxLength(100)
|
|
||||||
contactPersonName!: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
@MaxLength(20)
|
|
||||||
contactPersonPhone!: string;
|
|
||||||
|
|
||||||
// Management
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
@MaxLength(100)
|
|
||||||
generalManagerName!: string;
|
|
||||||
|
|
||||||
@IsEmail()
|
|
||||||
@IsNotEmpty()
|
|
||||||
generalManagerEmail!: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
@MaxLength(20)
|
|
||||||
generalManagerPhone!: string;
|
|
||||||
|
|
||||||
// POA (Power of Attorney)
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(100)
|
|
||||||
poaName?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(20)
|
|
||||||
poaPhone?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
poaAddress?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsEmail()
|
|
||||||
poaEmail?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(100)
|
|
||||||
poaLocation?: string;
|
|
||||||
|
|
||||||
// Extra
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
notes?: string;
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
// src/modules/customers/dto/response-customer.dto.ts
|
|
||||||
import { Customer } from '../entities/customer.entity';
|
|
||||||
|
|
||||||
export class ResponseCustomerDto {
|
|
||||||
//UserId: string;
|
|
||||||
firstName: string;
|
|
||||||
lastName: string;
|
|
||||||
email: string;
|
|
||||||
phone: string;
|
|
||||||
companyName: string;
|
|
||||||
companyEmail: string;
|
|
||||||
companyPhone: string;
|
|
||||||
companyLocation: string;
|
|
||||||
companyAddress: string;
|
|
||||||
contactPersonName: string;
|
|
||||||
contactPersonPhone: string;
|
|
||||||
tinNumber: string;
|
|
||||||
vatNumber?: string;
|
|
||||||
fanNumber: string;
|
|
||||||
generalManagerName: string;
|
|
||||||
generalManagerEmail: string;
|
|
||||||
generalManagerPhone: string;
|
|
||||||
poaName?: string;
|
|
||||||
poaPhone?: string;
|
|
||||||
poaAddress?: string;
|
|
||||||
poaEmail?: string;
|
|
||||||
poaLocation?: string;
|
|
||||||
notes?: string;
|
|
||||||
createdAt: Date;
|
|
||||||
updatedAt: Date;
|
|
||||||
|
|
||||||
constructor(customer: Customer) {
|
|
||||||
//this.UserId = customer.userId;
|
|
||||||
this.firstName = customer.firstName;
|
|
||||||
this.lastName = customer.lastName;
|
|
||||||
this.email = customer.email;
|
|
||||||
this.phone = customer.phone;
|
|
||||||
this.companyName = customer.companyName;
|
|
||||||
this.companyEmail = customer.companyEmail;
|
|
||||||
this.companyPhone = customer.companyPhone;
|
|
||||||
this.companyLocation = customer.companyLocation;
|
|
||||||
this.companyAddress = customer.companyAddress;
|
|
||||||
this.contactPersonName = customer.contactPersonName;
|
|
||||||
this.contactPersonPhone = customer.contactPersonPhone;
|
|
||||||
this.tinNumber = customer.tinNumber;
|
|
||||||
this.vatNumber = customer.vatNumber ?? undefined;
|
|
||||||
this.fanNumber = customer.fanNumber;
|
|
||||||
this.generalManagerName = customer.generalManagerName;
|
|
||||||
this.generalManagerEmail = customer.generalManagerEmail;
|
|
||||||
this.generalManagerPhone = customer.generalManagerPhone;
|
|
||||||
this.poaName = customer.poaName ?? '';
|
|
||||||
this.poaPhone = customer.poaPhone ?? '';
|
|
||||||
this.poaAddress = customer.poaAddress ?? '';
|
|
||||||
this.poaEmail = customer.poaEmail ?? '';
|
|
||||||
this.poaLocation = customer.poaLocation ?? '';
|
|
||||||
this.notes = customer.notes ?? '';
|
|
||||||
this.createdAt = customer.createdAt;
|
|
||||||
this.updatedAt = customer.updatedAt;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
// src/modules/customers/dto/update-customer.dto.ts
|
|
||||||
import { PartialType } from '@nestjs/mapped-types';
|
|
||||||
import { CreateCustomerDto } from './create-customer.dto';
|
|
||||||
|
|
||||||
export class UpdateCustomerDto extends PartialType(CreateCustomerDto) {
|
|
||||||
email?: string;
|
|
||||||
vatNumber?: string;
|
|
||||||
// Add any other properties you need to access directly
|
|
||||||
}
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
import { BaseEntity } from '@edr/api-common';
|
|
||||||
import { Column, Entity, Index } from 'typeorm';
|
|
||||||
|
|
||||||
@Entity({ schema: 'freight', name: 'customers' })
|
|
||||||
@Index(['email'])
|
|
||||||
//@Index(['userId'])
|
|
||||||
@Index(['tinNumber'])
|
|
||||||
@Index(['fanNumber'])
|
|
||||||
export class Customer extends BaseEntity {
|
|
||||||
//@Column({ name: 'user_id', type: 'uuid' })
|
|
||||||
//userId!: string;
|
|
||||||
|
|
||||||
@Column({ name: 'first_name', type: 'varchar', length: 100 })
|
|
||||||
firstName!: string;
|
|
||||||
|
|
||||||
@Column({ name: 'last_name', type: 'varchar', length: 100 })
|
|
||||||
lastName!: string;
|
|
||||||
|
|
||||||
@Column({ name: 'email', type: 'varchar', length: 150, unique: true })
|
|
||||||
email!: string;
|
|
||||||
|
|
||||||
@Column({ name: 'phone', type: 'varchar', length: 20 })
|
|
||||||
phone!: string;
|
|
||||||
|
|
||||||
@Column({ name: 'company_name', type: 'varchar', length: 200 })
|
|
||||||
companyName!: string;
|
|
||||||
|
|
||||||
@Column({ name: 'company_email', type: 'varchar', length: 150 })
|
|
||||||
companyEmail!: string;
|
|
||||||
|
|
||||||
@Column({ name: 'company_phone', type: 'varchar', length: 20 })
|
|
||||||
companyPhone!: string;
|
|
||||||
|
|
||||||
@Column({ name: 'company_location', type: 'varchar', length: 100 })
|
|
||||||
companyLocation!: string;
|
|
||||||
|
|
||||||
@Column({ name: 'company_address', type: 'text' })
|
|
||||||
companyAddress!: string;
|
|
||||||
|
|
||||||
@Column({ name: 'customer_type', type: 'varchar', length: 32, nullable: true })
|
|
||||||
customerType?: string | null;
|
|
||||||
|
|
||||||
@Column({ name: 'status', type: 'varchar', length: 32, nullable: true })
|
|
||||||
status?: string | null;
|
|
||||||
|
|
||||||
@Column({ name: 'contact_person_name', type: 'varchar', length: 100 })
|
|
||||||
contactPersonName!: string;
|
|
||||||
|
|
||||||
@Column({ name: 'contact_person_phone', type: 'varchar', length: 20 })
|
|
||||||
contactPersonPhone!: string;
|
|
||||||
|
|
||||||
@Column({ name: 'tin_number', type: 'varchar', length: 10, unique: true })
|
|
||||||
tinNumber!: string;
|
|
||||||
|
|
||||||
@Column({ name: 'vat_number', type: 'varchar', length: 50, nullable: true })
|
|
||||||
vatNumber?: string | null;
|
|
||||||
|
|
||||||
@Column({ name: 'fan_number', type: 'varchar', length: 16, unique: true })
|
|
||||||
fanNumber!: string;
|
|
||||||
|
|
||||||
@Column({ name: 'general_manager_name', type: 'varchar', length: 100 })
|
|
||||||
generalManagerName!: string;
|
|
||||||
|
|
||||||
@Column({ name: 'general_manager_email', type: 'varchar', length: 150 })
|
|
||||||
generalManagerEmail!: string;
|
|
||||||
|
|
||||||
@Column({ name: 'general_manager_phone', type: 'varchar', length: 20 })
|
|
||||||
generalManagerPhone!: string;
|
|
||||||
|
|
||||||
@Column({ name: 'poa_name', type: 'varchar', length: 100, nullable: true })
|
|
||||||
poaName?: string | null;
|
|
||||||
|
|
||||||
@Column({ name: 'poa_phone', type: 'varchar', length: 20, nullable: true })
|
|
||||||
poaPhone?: string | null;
|
|
||||||
|
|
||||||
@Column({ name: 'poa_address', type: 'text', nullable: true })
|
|
||||||
poaAddress?: string | null;
|
|
||||||
|
|
||||||
@Column({ name: 'poa_email', type: 'varchar', length: 150, nullable: true })
|
|
||||||
poaEmail?: string | null;
|
|
||||||
|
|
||||||
@Column({ name: 'poa_location', type: 'varchar', length: 100, nullable: true })
|
|
||||||
poaLocation?: string | null;
|
|
||||||
|
|
||||||
@Column({ name: 'notes', type: 'text', nullable: true })
|
|
||||||
notes?: string | null;
|
|
||||||
}
|
|
||||||
@@ -1,25 +1,25 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from "@nestjs/common";
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||||
import { Employee } from '@tria-plc/iamapi-common';
|
import { Employee } from "@tria-plc/iamapi-common";
|
||||||
import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity';
|
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
|
||||||
|
|
||||||
import { Booking } from '../bookings/entities/booking.entity';
|
import { Booking } from "../bookings/entities/booking.entity";
|
||||||
import { Cargo } from '../cargoes/entities/cargoes.entity';
|
import { Cargo } from "../cargoes/entities/cargoes.entity";
|
||||||
import { Container } from '../container-management/entities/container.entity';
|
import { Container } from "../container-management/entities/container.entity";
|
||||||
import { Customer } from '../customers/entities/customer.entity';
|
import { Company } from "../companies/entities/company.entity";
|
||||||
import { PaymentEntity } from '../payment/entities/payment.entity';
|
import { PaymentEntity } from "../payment/entities/payment.entity";
|
||||||
import { Train } from '../trains/entities/train.entity';
|
import { Train } from "../trains/entities/train.entity";
|
||||||
import { Wagon } from '../wagons/entities/wagon.entity';
|
import { Wagon } from "../wagons/entities/wagon.entity";
|
||||||
import { OverviewController } from './overview.controller';
|
import { OverviewController } from "./overview.controller";
|
||||||
import { OverviewRepository } from './overview.repository';
|
import { OverviewRepository } from "./overview.repository";
|
||||||
import { OverviewService } from './overview.service';
|
import { OverviewService } from "./overview.service";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([
|
TypeOrmModule.forFeature([
|
||||||
Booking,
|
Booking,
|
||||||
PaymentEntity,
|
PaymentEntity,
|
||||||
Customer,
|
Company,
|
||||||
Train,
|
Train,
|
||||||
Wagon,
|
Wagon,
|
||||||
Container,
|
Container,
|
||||||
@@ -31,4 +31,4 @@ import { OverviewService } from './overview.service';
|
|||||||
controllers: [OverviewController],
|
controllers: [OverviewController],
|
||||||
providers: [OverviewService, OverviewRepository],
|
providers: [OverviewService, OverviewRepository],
|
||||||
})
|
})
|
||||||
export class OverviewModule {}
|
export class OverviewModule { }
|
||||||
|
|||||||
@@ -1,24 +1,24 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from "@nestjs/common";
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from "@nestjs/typeorm";
|
||||||
import { EUserStatus } from '@tria-plc/api-common/utils/enums/user.enum';
|
import { EUserStatus } from "@tria-plc/api-common/utils/enums/user.enum";
|
||||||
import { Employee } from '@tria-plc/iamapi-common';
|
import { Employee } from "@tria-plc/iamapi-common";
|
||||||
import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity';
|
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
|
||||||
import { Freight } from '@edr/types';
|
import { Freight } from "@edr/types";
|
||||||
import { Repository, ObjectLiteral } from 'typeorm';
|
import { Repository, ObjectLiteral } from "typeorm";
|
||||||
|
|
||||||
import { Booking } from '../bookings/entities/booking.entity';
|
import { Booking } from "../bookings/entities/booking.entity";
|
||||||
import { Cargo } from '../cargoes/entities/cargoes.entity';
|
import { Cargo } from "../cargoes/entities/cargoes.entity";
|
||||||
import { Container } from '../container-management/entities/container.entity';
|
import { Container } from "../container-management/entities/container.entity";
|
||||||
import { Customer } from '../customers/entities/customer.entity';
|
import { PaymentEntity } from "../payment/entities/payment.entity";
|
||||||
import { PaymentEntity } from '../payment/entities/payment.entity';
|
import { Train } from "../trains/entities/train.entity";
|
||||||
import { Train } from '../trains/entities/train.entity';
|
import { Wagon } from "../wagons/entities/wagon.entity";
|
||||||
import { Wagon } from '../wagons/entities/wagon.entity';
|
|
||||||
import {
|
import {
|
||||||
OVERVIEW_CLOSED_STATUSES,
|
OVERVIEW_CLOSED_STATUSES,
|
||||||
OVERVIEW_IN_APPROVAL_STATUSES,
|
OVERVIEW_IN_APPROVAL_STATUSES,
|
||||||
OVERVIEW_NEEDS_ACTION_STATUSES,
|
OVERVIEW_NEEDS_ACTION_STATUSES,
|
||||||
OVERVIEW_URGENT_PRIORITY_THRESHOLD,
|
OVERVIEW_URGENT_PRIORITY_THRESHOLD,
|
||||||
} from './overview.constants';
|
} from "./overview.constants";
|
||||||
|
import { Company } from "../companies/entities/company.entity";
|
||||||
|
|
||||||
export type OverviewBookingKpisRow = {
|
export type OverviewBookingKpisRow = {
|
||||||
totalActive: number;
|
totalActive: number;
|
||||||
@@ -46,8 +46,8 @@ export class OverviewRepository {
|
|||||||
private readonly bookingRepository: Repository<Booking>,
|
private readonly bookingRepository: Repository<Booking>,
|
||||||
@InjectRepository(PaymentEntity)
|
@InjectRepository(PaymentEntity)
|
||||||
private readonly paymentRepository: Repository<PaymentEntity>,
|
private readonly paymentRepository: Repository<PaymentEntity>,
|
||||||
@InjectRepository(Customer)
|
@InjectRepository(Company)
|
||||||
private readonly customerRepository: Repository<Customer>,
|
private readonly companyRepository: Repository<Company>,
|
||||||
@InjectRepository(Train)
|
@InjectRepository(Train)
|
||||||
private readonly trainRepository: Repository<Train>,
|
private readonly trainRepository: Repository<Train>,
|
||||||
@InjectRepository(Wagon)
|
@InjectRepository(Wagon)
|
||||||
@@ -60,32 +60,32 @@ export class OverviewRepository {
|
|||||||
private readonly employeeRepository: Repository<Employee>,
|
private readonly employeeRepository: Repository<Employee>,
|
||||||
@InjectRepository(User)
|
@InjectRepository(User)
|
||||||
private readonly userRepository: Repository<User>,
|
private readonly userRepository: Repository<User>,
|
||||||
) {}
|
) { }
|
||||||
|
|
||||||
async getBookingKpis(): Promise<OverviewBookingKpisRow> {
|
async getBookingKpis(): Promise<OverviewBookingKpisRow> {
|
||||||
const row = await this.bookingRepository
|
const row = await this.bookingRepository
|
||||||
.createQueryBuilder('booking')
|
.createQueryBuilder("booking")
|
||||||
.select(
|
.select(
|
||||||
`COUNT(*) FILTER (WHERE booking.status NOT IN (:...closedStatuses) AND booking.status != 'DRAFT')::int`,
|
`COUNT(*) FILTER (WHERE booking.status NOT IN (:...closedStatuses) AND booking.status != 'DRAFT')::int`,
|
||||||
'totalActive',
|
"totalActive",
|
||||||
)
|
)
|
||||||
.addSelect(
|
.addSelect(
|
||||||
`COUNT(*) FILTER (WHERE booking.status IN (:...needsActionStatuses))::int`,
|
`COUNT(*) FILTER (WHERE booking.status IN (:...needsActionStatuses))::int`,
|
||||||
'needsAction',
|
"needsAction",
|
||||||
)
|
)
|
||||||
.addSelect(
|
.addSelect(
|
||||||
`COUNT(*) FILTER (WHERE booking.priority_score >= :urgentThreshold)::int`,
|
`COUNT(*) FILTER (WHERE booking.priority_score >= :urgentThreshold)::int`,
|
||||||
'urgent',
|
"urgent",
|
||||||
)
|
)
|
||||||
.addSelect(
|
.addSelect(
|
||||||
`COUNT(*) FILTER (WHERE booking.status IN (:...inApprovalStatuses))::int`,
|
`COUNT(*) FILTER (WHERE booking.status IN (:...inApprovalStatuses))::int`,
|
||||||
'inApproval',
|
"inApproval",
|
||||||
)
|
)
|
||||||
.addSelect(
|
.addSelect(
|
||||||
`COUNT(*) FILTER (WHERE booking.created_at >= CURRENT_DATE AND booking.status != 'DRAFT')::int`,
|
`COUNT(*) FILTER (WHERE booking.created_at >= CURRENT_DATE AND booking.status != 'DRAFT')::int`,
|
||||||
'submittedToday',
|
"submittedToday",
|
||||||
)
|
)
|
||||||
.where('booking.deleted_at IS NULL')
|
.where("booking.deleted_at IS NULL")
|
||||||
.setParameters({
|
.setParameters({
|
||||||
closedStatuses: [...OVERVIEW_CLOSED_STATUSES],
|
closedStatuses: [...OVERVIEW_CLOSED_STATUSES],
|
||||||
needsActionStatuses: [...OVERVIEW_NEEDS_ACTION_STATUSES],
|
needsActionStatuses: [...OVERVIEW_NEEDS_ACTION_STATUSES],
|
||||||
@@ -112,9 +112,9 @@ export class OverviewRepository {
|
|||||||
const [trainsActive, wagonsAvailable, containersInTransit, cargoesLoaded] =
|
const [trainsActive, wagonsAvailable, containersInTransit, cargoesLoaded] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
this.trainRepository
|
this.trainRepository
|
||||||
.createQueryBuilder('train')
|
.createQueryBuilder("train")
|
||||||
.where('train.deleted_at IS NULL')
|
.where("train.deleted_at IS NULL")
|
||||||
.andWhere('train.status IN (:...statuses)', {
|
.andWhere("train.status IN (:...statuses)", {
|
||||||
statuses: [
|
statuses: [
|
||||||
Freight.TrainStatus.InService,
|
Freight.TrainStatus.InService,
|
||||||
Freight.TrainStatus.Scheduled,
|
Freight.TrainStatus.Scheduled,
|
||||||
@@ -122,39 +122,46 @@ export class OverviewRepository {
|
|||||||
})
|
})
|
||||||
.getCount(),
|
.getCount(),
|
||||||
this.wagonRepository
|
this.wagonRepository
|
||||||
.createQueryBuilder('wagon')
|
.createQueryBuilder("wagon")
|
||||||
.where('wagon.deleted_at IS NULL')
|
.where("wagon.deleted_at IS NULL")
|
||||||
.andWhere('wagon.status = :status', { status: Freight.WagonStatus.Available })
|
.andWhere("wagon.status = :status", {
|
||||||
|
status: Freight.WagonStatus.Available,
|
||||||
|
})
|
||||||
.getCount(),
|
.getCount(),
|
||||||
this.containerRepository
|
this.containerRepository
|
||||||
.createQueryBuilder('container')
|
.createQueryBuilder("container")
|
||||||
.where('container.deleted_at IS NULL')
|
.where("container.deleted_at IS NULL")
|
||||||
.andWhere('container.status = :status', { status: 'IN_TRANSIT' })
|
.andWhere("container.status = :status", { status: "IN_TRANSIT" })
|
||||||
.getCount(),
|
.getCount(),
|
||||||
this.cargoRepository
|
this.cargoRepository
|
||||||
.createQueryBuilder('cargo')
|
.createQueryBuilder("cargo")
|
||||||
.where('cargo.deleted_at IS NULL')
|
.where("cargo.deleted_at IS NULL")
|
||||||
.andWhere('cargo.status IN (:...statuses)', {
|
.andWhere("cargo.status IN (:...statuses)", {
|
||||||
statuses: ['LOADED', 'IN_TRANSIT'],
|
statuses: ["LOADED", "IN_TRANSIT"],
|
||||||
})
|
})
|
||||||
.getCount(),
|
.getCount(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return { trainsActive, wagonsAvailable, containersInTransit, cargoesLoaded };
|
return {
|
||||||
|
trainsActive,
|
||||||
|
wagonsAvailable,
|
||||||
|
containersInTransit,
|
||||||
|
cargoesLoaded,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async getCustomerKpis(): Promise<{
|
async getCustomerKpis(): Promise<{
|
||||||
totalCustomers: number;
|
totalCustomers: number;
|
||||||
newCustomersThisMonth: number;
|
newCustomersThisMonth: number;
|
||||||
}> {
|
}> {
|
||||||
const row = await this.customerRepository
|
const row = await this.companyRepository
|
||||||
.createQueryBuilder('customer')
|
.createQueryBuilder("customer")
|
||||||
.select('COUNT(*)::int', 'totalCustomers')
|
.select("COUNT(*)::int", "totalCustomers")
|
||||||
.addSelect(
|
.addSelect(
|
||||||
`COUNT(*) FILTER (WHERE customer.created_at >= date_trunc('month', CURRENT_DATE))::int`,
|
`COUNT(*) FILTER (WHERE customer.created_at >= date_trunc('month', CURRENT_DATE))::int`,
|
||||||
'newCustomersThisMonth',
|
"newCustomersThisMonth",
|
||||||
)
|
)
|
||||||
.where('customer.deleted_at IS NULL')
|
.where("customer.deleted_at IS NULL")
|
||||||
.getRawOne<Record<string, string>>();
|
.getRawOne<Record<string, string>>();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -170,26 +177,26 @@ export class OverviewRepository {
|
|||||||
successfulPaymentsMtd: number;
|
successfulPaymentsMtd: number;
|
||||||
}> {
|
}> {
|
||||||
const revenueRow = await this.paymentRepository
|
const revenueRow = await this.paymentRepository
|
||||||
.createQueryBuilder('payment')
|
.createQueryBuilder("payment")
|
||||||
.select(
|
.select(
|
||||||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB'), 0)`,
|
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB'), 0)`,
|
||||||
'revenueMtdEtb',
|
"revenueMtdEtb",
|
||||||
)
|
)
|
||||||
.addSelect(
|
.addSelect(
|
||||||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
|
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
|
||||||
'revenueMtdUsd',
|
"revenueMtdUsd",
|
||||||
)
|
)
|
||||||
.addSelect(`COUNT(*)::int`, 'successfulPaymentsMtd')
|
.addSelect(`COUNT(*)::int`, "successfulPaymentsMtd")
|
||||||
.where('payment.status = :status', { status: 'success' })
|
.where("payment.status = :status", { status: "success" })
|
||||||
.andWhere(
|
.andWhere(
|
||||||
`COALESCE(payment.paid_at, payment.created_at) >= date_trunc('month', CURRENT_DATE)`,
|
`COALESCE(payment.paid_at, payment.created_at) >= date_trunc('month', CURRENT_DATE)`,
|
||||||
)
|
)
|
||||||
.getRawOne<Record<string, string>>();
|
.getRawOne<Record<string, string>>();
|
||||||
|
|
||||||
const pendingPayments = await this.paymentRepository
|
const pendingPayments = await this.paymentRepository
|
||||||
.createQueryBuilder('payment')
|
.createQueryBuilder("payment")
|
||||||
.where('payment.status IN (:...statuses)', {
|
.where("payment.status IN (:...statuses)", {
|
||||||
statuses: ['action-required', 'processing'],
|
statuses: ["action-required", "processing"],
|
||||||
})
|
})
|
||||||
.getCount();
|
.getCount();
|
||||||
|
|
||||||
@@ -201,7 +208,10 @@ export class OverviewRepository {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async getStaffKpis(): Promise<{ activeEmployees: number; activeUsers: number }> {
|
async getStaffKpis(): Promise<{
|
||||||
|
activeEmployees: number;
|
||||||
|
activeUsers: number;
|
||||||
|
}> {
|
||||||
const [activeEmployees, activeUsers] = await Promise.all([
|
const [activeEmployees, activeUsers] = await Promise.all([
|
||||||
this.employeeRepository.count({
|
this.employeeRepository.count({
|
||||||
where: { isCurrent: true },
|
where: { isCurrent: true },
|
||||||
@@ -217,15 +227,17 @@ export class OverviewRepository {
|
|||||||
return { activeEmployees, activeUsers };
|
return { activeEmployees, activeUsers };
|
||||||
}
|
}
|
||||||
|
|
||||||
async getBookingTrend(days: number): Promise<{ date: string; count: number }[]> {
|
async getBookingTrend(
|
||||||
|
days: number,
|
||||||
|
): Promise<{ date: string; count: number }[]> {
|
||||||
const rows = await this.bookingRepository
|
const rows = await this.bookingRepository
|
||||||
.createQueryBuilder('booking')
|
.createQueryBuilder("booking")
|
||||||
.select(`to_char(booking.created_at::date, 'YYYY-MM-DD')`, 'date')
|
.select(`to_char(booking.created_at::date, 'YYYY-MM-DD')`, "date")
|
||||||
.addSelect('COUNT(*)::int', 'count')
|
.addSelect("COUNT(*)::int", "count")
|
||||||
.where('booking.deleted_at IS NULL')
|
.where("booking.deleted_at IS NULL")
|
||||||
.andWhere(`booking.created_at >= CURRENT_DATE - :days::int + 1`, { days })
|
.andWhere(`booking.created_at >= CURRENT_DATE - :days::int + 1`, { days })
|
||||||
.groupBy('booking.created_at::date')
|
.groupBy("booking.created_at::date")
|
||||||
.orderBy('booking.created_at::date', 'ASC')
|
.orderBy("booking.created_at::date", "ASC")
|
||||||
.getRawMany<{ date: string; count: string }>();
|
.getRawMany<{ date: string; count: string }>();
|
||||||
|
|
||||||
return rows.map((row) => ({
|
return rows.map((row) => ({
|
||||||
@@ -236,11 +248,11 @@ export class OverviewRepository {
|
|||||||
|
|
||||||
async getStatusCounts(): Promise<Record<string, number>> {
|
async getStatusCounts(): Promise<Record<string, number>> {
|
||||||
const rows = await this.bookingRepository
|
const rows = await this.bookingRepository
|
||||||
.createQueryBuilder('booking')
|
.createQueryBuilder("booking")
|
||||||
.select('booking.status', 'status')
|
.select("booking.status", "status")
|
||||||
.addSelect('COUNT(*)::int', 'count')
|
.addSelect("COUNT(*)::int", "count")
|
||||||
.where('booking.deleted_at IS NULL')
|
.where("booking.deleted_at IS NULL")
|
||||||
.groupBy('booking.status')
|
.groupBy("booking.status")
|
||||||
.getRawMany<{ status: string; count: string }>();
|
.getRawMany<{ status: string; count: string }>();
|
||||||
|
|
||||||
return Object.fromEntries(
|
return Object.fromEntries(
|
||||||
@@ -252,26 +264,26 @@ export class OverviewRepository {
|
|||||||
days: number,
|
days: number,
|
||||||
): Promise<{ date: string; amountEtb: number; amountUsd: number }[]> {
|
): Promise<{ date: string; amountEtb: number; amountUsd: number }[]> {
|
||||||
const rows = await this.paymentRepository
|
const rows = await this.paymentRepository
|
||||||
.createQueryBuilder('payment')
|
.createQueryBuilder("payment")
|
||||||
.select(
|
.select(
|
||||||
`to_char(COALESCE(payment.paid_at, payment.created_at)::date, 'YYYY-MM-DD')`,
|
`to_char(COALESCE(payment.paid_at, payment.created_at)::date, 'YYYY-MM-DD')`,
|
||||||
'date',
|
"date",
|
||||||
)
|
)
|
||||||
.addSelect(
|
.addSelect(
|
||||||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB'), 0)`,
|
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB'), 0)`,
|
||||||
'amountEtb',
|
"amountEtb",
|
||||||
)
|
)
|
||||||
.addSelect(
|
.addSelect(
|
||||||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
|
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
|
||||||
'amountUsd',
|
"amountUsd",
|
||||||
)
|
)
|
||||||
.where('payment.status = :status', { status: 'success' })
|
.where("payment.status = :status", { status: "success" })
|
||||||
.andWhere(
|
.andWhere(
|
||||||
`COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`,
|
`COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`,
|
||||||
{ days },
|
{ days },
|
||||||
)
|
)
|
||||||
.groupBy(`COALESCE(payment.paid_at, payment.created_at)::date`)
|
.groupBy(`COALESCE(payment.paid_at, payment.created_at)::date`)
|
||||||
.orderBy(`COALESCE(payment.paid_at, payment.created_at)::date`, 'ASC')
|
.orderBy(`COALESCE(payment.paid_at, payment.created_at)::date`, "ASC")
|
||||||
.getRawMany<{ date: string; amountEtb: string; amountUsd: string }>();
|
.getRawMany<{ date: string; amountEtb: string; amountUsd: string }>();
|
||||||
|
|
||||||
return rows.map((row) => ({
|
return rows.map((row) => ({
|
||||||
@@ -283,18 +295,18 @@ export class OverviewRepository {
|
|||||||
|
|
||||||
async getRecentBookings(limit: number): Promise<OverviewRecentBookingRow[]> {
|
async getRecentBookings(limit: number): Promise<OverviewRecentBookingRow[]> {
|
||||||
const rows = await this.bookingRepository
|
const rows = await this.bookingRepository
|
||||||
.createQueryBuilder('booking')
|
.createQueryBuilder("booking")
|
||||||
.leftJoin('booking.company', 'company')
|
.leftJoin("booking.company", "company")
|
||||||
.select('booking.id', 'id')
|
.select("booking.id", "id")
|
||||||
.addSelect('booking.reference', 'reference')
|
.addSelect("booking.reference", "reference")
|
||||||
.addSelect('COALESCE(company.name, \'—\')', 'customerLabel')
|
.addSelect("COALESCE(company.name, '—')", "customerLabel")
|
||||||
.addSelect('booking.status', 'status')
|
.addSelect("booking.status", "status")
|
||||||
.addSelect('booking.priority_score', 'priorityScore')
|
.addSelect("booking.priority_score", "priorityScore")
|
||||||
.addSelect('booking.total_amount', 'totalAmount')
|
.addSelect("booking.total_amount", "totalAmount")
|
||||||
.addSelect('booking.payment_currency', 'paymentCurrency')
|
.addSelect("booking.payment_currency", "paymentCurrency")
|
||||||
.addSelect('booking.created_at', 'createdAt')
|
.addSelect("booking.created_at", "createdAt")
|
||||||
.where('booking.deleted_at IS NULL')
|
.where("booking.deleted_at IS NULL")
|
||||||
.orderBy('booking.created_at', 'DESC')
|
.orderBy("booking.created_at", "DESC")
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.getRawMany<{
|
.getRawMany<{
|
||||||
id: string;
|
id: string;
|
||||||
@@ -319,15 +331,17 @@ export class OverviewRepository {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
async getBookingsByFreightType(): Promise<{ label: string; count: number }[]> {
|
async getBookingsByFreightType(): Promise<
|
||||||
|
{ label: string; count: number }[]
|
||||||
|
> {
|
||||||
const rows = await this.bookingRepository
|
const rows = await this.bookingRepository
|
||||||
.createQueryBuilder('booking')
|
.createQueryBuilder("booking")
|
||||||
.select('booking.freight_type', 'label')
|
.select("booking.freight_type", "label")
|
||||||
.addSelect('COUNT(*)::int', 'count')
|
.addSelect("COUNT(*)::int", "count")
|
||||||
.where('booking.deleted_at IS NULL')
|
.where("booking.deleted_at IS NULL")
|
||||||
.andWhere("booking.status != 'DRAFT'")
|
.andWhere("booking.status != 'DRAFT'")
|
||||||
.groupBy('booking.freight_type')
|
.groupBy("booking.freight_type")
|
||||||
.orderBy('count', 'DESC')
|
.orderBy("count", "DESC")
|
||||||
.getRawMany<{ label: string; count: string }>();
|
.getRawMany<{ label: string; count: string }>();
|
||||||
|
|
||||||
return rows.map((row) => ({
|
return rows.map((row) => ({
|
||||||
@@ -338,13 +352,13 @@ export class OverviewRepository {
|
|||||||
|
|
||||||
async getBookingsByCurrency(): Promise<{ label: string; count: number }[]> {
|
async getBookingsByCurrency(): Promise<{ label: string; count: number }[]> {
|
||||||
const rows = await this.bookingRepository
|
const rows = await this.bookingRepository
|
||||||
.createQueryBuilder('booking')
|
.createQueryBuilder("booking")
|
||||||
.select('booking.payment_currency', 'label')
|
.select("booking.payment_currency", "label")
|
||||||
.addSelect('COUNT(*)::int', 'count')
|
.addSelect("COUNT(*)::int", "count")
|
||||||
.where('booking.deleted_at IS NULL')
|
.where("booking.deleted_at IS NULL")
|
||||||
.andWhere("booking.status != 'DRAFT'")
|
.andWhere("booking.status != 'DRAFT'")
|
||||||
.groupBy('booking.payment_currency')
|
.groupBy("booking.payment_currency")
|
||||||
.orderBy('count', 'DESC')
|
.orderBy("count", "DESC")
|
||||||
.getRawMany<{ label: string; count: string }>();
|
.getRawMany<{ label: string; count: string }>();
|
||||||
|
|
||||||
return rows.map((row) => ({
|
return rows.map((row) => ({
|
||||||
@@ -355,11 +369,11 @@ export class OverviewRepository {
|
|||||||
|
|
||||||
async getPaymentsByStatus(): Promise<{ status: string; count: number }[]> {
|
async getPaymentsByStatus(): Promise<{ status: string; count: number }[]> {
|
||||||
const rows = await this.paymentRepository
|
const rows = await this.paymentRepository
|
||||||
.createQueryBuilder('payment')
|
.createQueryBuilder("payment")
|
||||||
.select('payment.status', 'status')
|
.select("payment.status", "status")
|
||||||
.addSelect('COUNT(*)::int', 'count')
|
.addSelect("COUNT(*)::int", "count")
|
||||||
.groupBy('payment.status')
|
.groupBy("payment.status")
|
||||||
.orderBy('count', 'DESC')
|
.orderBy("count", "DESC")
|
||||||
.getRawMany<{ status: string; count: string }>();
|
.getRawMany<{ status: string; count: string }>();
|
||||||
|
|
||||||
return rows.map((row) => ({
|
return rows.map((row) => ({
|
||||||
@@ -372,20 +386,25 @@ export class OverviewRepository {
|
|||||||
{ method: string; count: number; amountEtb: number; amountUsd: number }[]
|
{ method: string; count: number; amountEtb: number; amountUsd: number }[]
|
||||||
> {
|
> {
|
||||||
const rows = await this.paymentRepository
|
const rows = await this.paymentRepository
|
||||||
.createQueryBuilder('payment')
|
.createQueryBuilder("payment")
|
||||||
.select('payment.method', 'method')
|
.select("payment.method", "method")
|
||||||
.addSelect('COUNT(*)::int', 'count')
|
.addSelect("COUNT(*)::int", "count")
|
||||||
.addSelect(
|
.addSelect(
|
||||||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB' AND payment.status = 'success'), 0)`,
|
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB' AND payment.status = 'success'), 0)`,
|
||||||
'amountEtb',
|
"amountEtb",
|
||||||
)
|
)
|
||||||
.addSelect(
|
.addSelect(
|
||||||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD' AND payment.status = 'success'), 0)`,
|
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD' AND payment.status = 'success'), 0)`,
|
||||||
'amountUsd',
|
"amountUsd",
|
||||||
)
|
)
|
||||||
.groupBy('payment.method')
|
.groupBy("payment.method")
|
||||||
.orderBy('count', 'DESC')
|
.orderBy("count", "DESC")
|
||||||
.getRawMany<{ method: string; count: string; amountEtb: string; amountUsd: string }>();
|
.getRawMany<{
|
||||||
|
method: string;
|
||||||
|
count: string;
|
||||||
|
amountEtb: string;
|
||||||
|
amountUsd: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
return rows.map((row) => ({
|
return rows.map((row) => ({
|
||||||
method: row.method,
|
method: row.method,
|
||||||
@@ -395,16 +414,18 @@ export class OverviewRepository {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
async getRevenueByCurrency(): Promise<{ currency: string; amount: number }[]> {
|
async getRevenueByCurrency(): Promise<
|
||||||
|
{ currency: string; amount: number }[]
|
||||||
|
> {
|
||||||
const rows = await this.paymentRepository
|
const rows = await this.paymentRepository
|
||||||
.createQueryBuilder('payment')
|
.createQueryBuilder("payment")
|
||||||
.select('payment.currency', 'currency')
|
.select("payment.currency", "currency")
|
||||||
.addSelect('COALESCE(SUM(payment.amount), 0)', 'amount')
|
.addSelect("COALESCE(SUM(payment.amount), 0)", "amount")
|
||||||
.where('payment.status = :status', { status: 'success' })
|
.where("payment.status = :status", { status: "success" })
|
||||||
.andWhere(
|
.andWhere(
|
||||||
`COALESCE(payment.paid_at, payment.created_at) >= date_trunc('month', CURRENT_DATE)`,
|
`COALESCE(payment.paid_at, payment.created_at) >= date_trunc('month', CURRENT_DATE)`,
|
||||||
)
|
)
|
||||||
.groupBy('payment.currency')
|
.groupBy("payment.currency")
|
||||||
.getRawMany<{ currency: string; amount: string }>();
|
.getRawMany<{ currency: string; amount: string }>();
|
||||||
|
|
||||||
return rows.map((row) => ({
|
return rows.map((row) => ({
|
||||||
@@ -413,20 +434,28 @@ export class OverviewRepository {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
async getTrainStatusBreakdown(): Promise<{ status: string; count: number }[]> {
|
async getTrainStatusBreakdown(): Promise<
|
||||||
return this.statusBreakdown(this.trainRepository, 'train');
|
{ status: string; count: number }[]
|
||||||
|
> {
|
||||||
|
return this.statusBreakdown(this.trainRepository, "train");
|
||||||
}
|
}
|
||||||
|
|
||||||
async getWagonStatusBreakdown(): Promise<{ status: string; count: number }[]> {
|
async getWagonStatusBreakdown(): Promise<
|
||||||
return this.statusBreakdown(this.wagonRepository, 'wagon');
|
{ status: string; count: number }[]
|
||||||
|
> {
|
||||||
|
return this.statusBreakdown(this.wagonRepository, "wagon");
|
||||||
}
|
}
|
||||||
|
|
||||||
async getContainerStatusBreakdown(): Promise<{ status: string; count: number }[]> {
|
async getContainerStatusBreakdown(): Promise<
|
||||||
return this.statusBreakdown(this.containerRepository, 'container');
|
{ status: string; count: number }[]
|
||||||
|
> {
|
||||||
|
return this.statusBreakdown(this.containerRepository, "container");
|
||||||
}
|
}
|
||||||
|
|
||||||
async getCargoStatusBreakdown(): Promise<{ status: string; count: number }[]> {
|
async getCargoStatusBreakdown(): Promise<
|
||||||
return this.statusBreakdown(this.cargoRepository, 'cargo');
|
{ status: string; count: number }[]
|
||||||
|
> {
|
||||||
|
return this.statusBreakdown(this.cargoRepository, "cargo");
|
||||||
}
|
}
|
||||||
|
|
||||||
private async statusBreakdown(
|
private async statusBreakdown(
|
||||||
@@ -435,11 +464,11 @@ export class OverviewRepository {
|
|||||||
): Promise<{ status: string; count: number }[]> {
|
): Promise<{ status: string; count: number }[]> {
|
||||||
const rows = await repository
|
const rows = await repository
|
||||||
.createQueryBuilder(alias)
|
.createQueryBuilder(alias)
|
||||||
.select(`${alias}.status`, 'status')
|
.select(`${alias}.status`, "status")
|
||||||
.addSelect('COUNT(*)::int', 'count')
|
.addSelect("COUNT(*)::int", "count")
|
||||||
.where(`${alias}.deleted_at IS NULL`)
|
.where(`${alias}.deleted_at IS NULL`)
|
||||||
.groupBy(`${alias}.status`)
|
.groupBy(`${alias}.status`)
|
||||||
.orderBy('count', 'DESC')
|
.orderBy("count", "DESC")
|
||||||
.getRawMany<{ status: string; count: string }>();
|
.getRawMany<{ status: string; count: string }>();
|
||||||
|
|
||||||
return rows.map((row) => ({
|
return rows.map((row) => ({
|
||||||
@@ -448,15 +477,19 @@ export class OverviewRepository {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
async getCustomerGrowthTrend(days: number): Promise<{ date: string; count: number }[]> {
|
async getCustomerGrowthTrend(
|
||||||
const rows = await this.customerRepository
|
days: number,
|
||||||
.createQueryBuilder('customer')
|
): Promise<{ date: string; count: number }[]> {
|
||||||
.select(`to_char(customer.created_at::date, 'YYYY-MM-DD')`, 'date')
|
const rows = await this.companyRepository
|
||||||
.addSelect('COUNT(*)::int', 'count')
|
.createQueryBuilder("customer")
|
||||||
.where('customer.deleted_at IS NULL')
|
.select(`to_char(customer.created_at::date, 'YYYY-MM-DD')`, "date")
|
||||||
.andWhere(`customer.created_at >= CURRENT_DATE - :days::int + 1`, { days })
|
.addSelect("COUNT(*)::int", "count")
|
||||||
.groupBy('customer.created_at::date')
|
.where("customer.deleted_at IS NULL")
|
||||||
.orderBy('customer.created_at::date', 'ASC')
|
.andWhere(`customer.created_at >= CURRENT_DATE - :days::int + 1`, {
|
||||||
|
days,
|
||||||
|
})
|
||||||
|
.groupBy("customer.created_at::date")
|
||||||
|
.orderBy("customer.created_at::date", "ASC")
|
||||||
.getRawMany<{ date: string; count: string }>();
|
.getRawMany<{ date: string; count: string }>();
|
||||||
|
|
||||||
return rows.map((row) => ({
|
return rows.map((row) => ({
|
||||||
@@ -466,13 +499,16 @@ export class OverviewRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getCustomersByType(): Promise<{ label: string; count: number }[]> {
|
async getCustomersByType(): Promise<{ label: string; count: number }[]> {
|
||||||
const rows = await this.customerRepository
|
const rows = await this.companyRepository
|
||||||
.createQueryBuilder('customer')
|
.createQueryBuilder("customer")
|
||||||
.select(`COALESCE(NULLIF(customer.customer_type, ''), 'Unknown')`, 'label')
|
.select(
|
||||||
.addSelect('COUNT(*)::int', 'count')
|
`COALESCE(NULLIF(customer.type, ''), 'Unknown')`,
|
||||||
.where('customer.deleted_at IS NULL')
|
"label",
|
||||||
.groupBy('customer.customer_type')
|
)
|
||||||
.orderBy('count', 'DESC')
|
.addSelect("COUNT(*)::int", "count")
|
||||||
|
.where("customer.deleted_at IS NULL")
|
||||||
|
.groupBy("customer.type")
|
||||||
|
.orderBy("count", "DESC")
|
||||||
.getRawMany<{ label: string; count: string }>();
|
.getRawMany<{ label: string; count: string }>();
|
||||||
|
|
||||||
return rows.map((row) => ({
|
return rows.map((row) => ({
|
||||||
@@ -481,16 +517,18 @@ export class OverviewRepository {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
async getTopCustomersByBookings(limit: number): Promise<{ label: string; count: number }[]> {
|
async getTopCustomersByBookings(
|
||||||
|
limit: number,
|
||||||
|
): Promise<{ label: string; count: number }[]> {
|
||||||
const rows = await this.bookingRepository
|
const rows = await this.bookingRepository
|
||||||
.createQueryBuilder('booking')
|
.createQueryBuilder("booking")
|
||||||
.leftJoin('booking.company', 'company')
|
.leftJoin("booking.company", "company")
|
||||||
.select(`COALESCE(company.name, 'Unknown')`, 'label')
|
.select(`COALESCE(company.name, 'Unknown')`, "label")
|
||||||
.addSelect('COUNT(*)::int', 'count')
|
.addSelect("COUNT(*)::int", "count")
|
||||||
.where('booking.deleted_at IS NULL')
|
.where("booking.deleted_at IS NULL")
|
||||||
.andWhere("booking.status != 'DRAFT'")
|
.andWhere("booking.status != 'DRAFT'")
|
||||||
.groupBy('company.name')
|
.groupBy("company.name")
|
||||||
.orderBy('count', 'DESC')
|
.orderBy("count", "DESC")
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.getRawMany<{ label: string; count: string }>();
|
.getRawMany<{ label: string; count: string }>();
|
||||||
|
|
||||||
@@ -502,11 +540,11 @@ export class OverviewRepository {
|
|||||||
|
|
||||||
async getUsersByStatus(): Promise<{ status: string; count: number }[]> {
|
async getUsersByStatus(): Promise<{ status: string; count: number }[]> {
|
||||||
const rows = await this.userRepository
|
const rows = await this.userRepository
|
||||||
.createQueryBuilder('user')
|
.createQueryBuilder("user")
|
||||||
.select('user.status', 'status')
|
.select("user.status", "status")
|
||||||
.addSelect('COUNT(*)::int', 'count')
|
.addSelect("COUNT(*)::int", "count")
|
||||||
.groupBy('user.status')
|
.groupBy("user.status")
|
||||||
.orderBy('count', 'DESC')
|
.orderBy("count", "DESC")
|
||||||
.getRawMany<{ status: string; count: string }>();
|
.getRawMany<{ status: string; count: string }>();
|
||||||
|
|
||||||
return rows.map((row) => ({
|
return rows.map((row) => ({
|
||||||
@@ -515,15 +553,19 @@ export class OverviewRepository {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
async getEmployeeGrowthTrend(days: number): Promise<{ date: string; count: number }[]> {
|
async getEmployeeGrowthTrend(
|
||||||
|
days: number,
|
||||||
|
): Promise<{ date: string; count: number }[]> {
|
||||||
const rows = await this.employeeRepository
|
const rows = await this.employeeRepository
|
||||||
.createQueryBuilder('employee')
|
.createQueryBuilder("employee")
|
||||||
.select(`to_char(employee.created_at::date, 'YYYY-MM-DD')`, 'date')
|
.select(`to_char(employee.created_at::date, 'YYYY-MM-DD')`, "date")
|
||||||
.addSelect('COUNT(*)::int', 'count')
|
.addSelect("COUNT(*)::int", "count")
|
||||||
.where('employee.is_current = true')
|
.where("employee.is_current = true")
|
||||||
.andWhere(`employee.created_at >= CURRENT_DATE - :days::int + 1`, { days })
|
.andWhere(`employee.created_at >= CURRENT_DATE - :days::int + 1`, {
|
||||||
.groupBy('employee.created_at::date')
|
days,
|
||||||
.orderBy('employee.created_at::date', 'ASC')
|
})
|
||||||
|
.groupBy("employee.created_at::date")
|
||||||
|
.orderBy("employee.created_at::date", "ASC")
|
||||||
.getRawMany<{ date: string; count: string }>();
|
.getRawMany<{ date: string; count: string }>();
|
||||||
|
|
||||||
return rows.map((row) => ({
|
return rows.map((row) => ({
|
||||||
@@ -538,16 +580,16 @@ export class OverviewRepository {
|
|||||||
where: { isActive: true, status: EUserStatus.ACCEPTED },
|
where: { isActive: true, status: EUserStatus.ACCEPTED },
|
||||||
}),
|
}),
|
||||||
this.userRepository
|
this.userRepository
|
||||||
.createQueryBuilder('user')
|
.createQueryBuilder("user")
|
||||||
.where('user.is_active = false OR user.status != :status', {
|
.where("user.is_active = false OR user.status != :status", {
|
||||||
status: EUserStatus.ACCEPTED,
|
status: EUserStatus.ACCEPTED,
|
||||||
})
|
})
|
||||||
.getCount(),
|
.getCount(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{ label: 'Active', count: active },
|
{ label: "Active", count: active },
|
||||||
{ label: 'Inactive', count: inactive },
|
{ label: "Inactive", count: inactive },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ import {
|
|||||||
export class PaymentClientService {
|
export class PaymentClientService {
|
||||||
private readonly logger = new Logger(PaymentClientService.name);
|
private readonly logger = new Logger(PaymentClientService.name);
|
||||||
private readonly baseUrl = (
|
private readonly baseUrl = (
|
||||||
process.env.PAYMENT_API_URL ?? "https://paymentcallback.triaplc.com"
|
// process.env.PAYMENT_API_URL ??
|
||||||
|
"https://paymentcallback.triaplc.com"
|
||||||
).replace(/\/$/, "");
|
).replace(/\/$/, "");
|
||||||
private readonly serviceToken = process.env.SERVICE_AUTH_TOKEN ?? "";
|
private readonly serviceToken = process.env.SERVICE_AUTH_TOKEN ?? "";
|
||||||
|
|
||||||
|
|||||||
@@ -145,9 +145,7 @@ export class PaymentService {
|
|||||||
.findOneBy({ id: dto.bookingId });
|
.findOneBy({ id: dto.bookingId });
|
||||||
if (!booking) throw new NotFoundException("Booking not found");
|
if (!booking) throw new NotFoundException("Booking not found");
|
||||||
|
|
||||||
console.log("bookingbooking",booking)
|
const amountMinor = Math.round(Number(booking.totalAmount));
|
||||||
const amountMinor = Math.round(Number(booking.totalAmount) * 100);
|
|
||||||
console.log("amountminor",amountMinor)
|
|
||||||
|
|
||||||
const snapshot = await this.paymentClient.initiate({
|
const snapshot = await this.paymentClient.initiate({
|
||||||
service: PaymentServiceEnum.FREIGHT,
|
service: PaymentServiceEnum.FREIGHT,
|
||||||
@@ -159,8 +157,8 @@ export class PaymentService {
|
|||||||
provider: dto.method as unknown as ProviderMethod,
|
provider: dto.method as unknown as ProviderMethod,
|
||||||
platform: dto.platform,
|
platform: dto.platform,
|
||||||
payerAccount: dto.payerAccount,
|
payerAccount: dto.payerAccount,
|
||||||
returnUrl: dto.returnUrl ?? process.env.PAYMENT_RETURN_URL,
|
returnUrl:'https://edrfreight.triaplc.com/payment/success',
|
||||||
failureUrl: dto.failureUrl ?? process.env.PAYMENT_FAILURE_URL,
|
failureUrl: 'https://edrfreight.triaplc.com/payment/failure',
|
||||||
});
|
});
|
||||||
|
|
||||||
const intent = await this.syncIntentProjection(booking.id, booking, snapshot);
|
const intent = await this.syncIntentProjection(booking.id, booking, snapshot);
|
||||||
|
|||||||
@@ -49,8 +49,17 @@ export class SchedulingRescheduleService {
|
|||||||
if (!schedule) {
|
if (!schedule) {
|
||||||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||||
}
|
}
|
||||||
|
// A train can be rescheduled (with or without bookings) at any time UNLESS it
|
||||||
|
// is already on the move (DISPATCHED), has completed its run (ARRIVED), or was
|
||||||
|
// cancelled. Only DRAFT / SCHEDULED trains are reschedulable.
|
||||||
if (schedule.status === TrainScheduleStatus.Dispatched) {
|
if (schedule.status === TrainScheduleStatus.Dispatched) {
|
||||||
throw new BadRequestException('Cannot reschedule a dispatched train');
|
throw new BadRequestException('Cannot reschedule a train that is already dispatched');
|
||||||
|
}
|
||||||
|
if (schedule.status === TrainScheduleStatus.Arrived) {
|
||||||
|
throw new BadRequestException('Cannot reschedule a train that has already arrived');
|
||||||
|
}
|
||||||
|
if (schedule.status === TrainScheduleStatus.Cancelled) {
|
||||||
|
throw new BadRequestException('Cannot reschedule a cancelled train');
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentOnSchedule = (schedule.scheduleBookings ?? [])
|
const currentOnSchedule = (schedule.scheduleBookings ?? [])
|
||||||
@@ -156,10 +165,24 @@ export class SchedulingRescheduleService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const assignResult = await this.trainSchedulingService.assignBookingsToSchedule(scheduleId, {
|
// A train can be rescheduled even with no bookings (e.g. moved for
|
||||||
bookingIds: dto.finalBookingIds,
|
// maintenance). assignBookingsToSchedule requires at least one booking, so
|
||||||
forceAssign: dto.trigger === 'GOVERNMENT_PREEMPT',
|
// only call it when something is actually being (re)assigned — the new
|
||||||
});
|
// departure date above is the meaningful change for an empty train. The
|
||||||
|
// empty-train branch returns the same schedule-detail shape as the assign
|
||||||
|
// path so callers get a consistent response.
|
||||||
|
const assignResult = dto.finalBookingIds.length
|
||||||
|
? await this.trainSchedulingService.assignBookingsToSchedule(scheduleId, {
|
||||||
|
bookingIds: dto.finalBookingIds,
|
||||||
|
forceAssign: dto.trigger === 'GOVERNMENT_PREEMPT',
|
||||||
|
})
|
||||||
|
: {
|
||||||
|
...(await this.trainSchedulingService.getContainerTrainScheduleById(
|
||||||
|
scheduleId,
|
||||||
|
)),
|
||||||
|
warnings: [] as string[],
|
||||||
|
deferredBookings: [] as unknown[],
|
||||||
|
};
|
||||||
|
|
||||||
await this.schedulingRescheduleRepository.createEvent({
|
await this.schedulingRescheduleRepository.createEvent({
|
||||||
trainScheduleId: scheduleId,
|
trainScheduleId: scheduleId,
|
||||||
|
|||||||
@@ -55,6 +55,16 @@ function eatParts(date: Date): EatDateParts {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The EAT calendar day a timestamp falls on, as `yyyy-MM-dd`. This is the day
|
||||||
|
* key for day-level booking pools — it must match the day the portal calendar
|
||||||
|
* renders, so always derive day keys through this (never `toISOString().slice`).
|
||||||
|
*/
|
||||||
|
export function eatDay(date: Date): string {
|
||||||
|
const { year, month, day } = eatParts(date);
|
||||||
|
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
/** Build a UTC Date for a given EAT local wall-clock time on a calendar day. */
|
/** Build a UTC Date for a given EAT local wall-clock time on a calendar day. */
|
||||||
function eatToUtc(
|
function eatToUtc(
|
||||||
year: number,
|
year: number,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ describe('BookingBatchService — PAID reconcile', () => {
|
|||||||
let bookingsRepository: {
|
let bookingsRepository: {
|
||||||
findPaidUnlinkedForSchedule: jest.Mock;
|
findPaidUnlinkedForSchedule: jest.Mock;
|
||||||
findBatchPool: jest.Mock;
|
findBatchPool: jest.Mock;
|
||||||
|
findBatchPoolByRouteDay: jest.Mock;
|
||||||
findReservedForSchedule: jest.Mock;
|
findReservedForSchedule: jest.Mock;
|
||||||
update: jest.Mock;
|
update: jest.Mock;
|
||||||
};
|
};
|
||||||
@@ -33,16 +34,24 @@ describe('BookingBatchService — PAID reconcile', () => {
|
|||||||
};
|
};
|
||||||
let trainSchedulingService: {
|
let trainSchedulingService: {
|
||||||
tryAutoWagonAllocation: jest.Mock;
|
tryAutoWagonAllocation: jest.Mock;
|
||||||
|
getBookableSchedules: jest.Mock;
|
||||||
};
|
};
|
||||||
let dataSource: {
|
let dataSource: {
|
||||||
getRepository: jest.Mock;
|
getRepository: jest.Mock;
|
||||||
transaction: jest.Mock;
|
transaction: jest.Mock;
|
||||||
};
|
};
|
||||||
|
let notifier: {
|
||||||
|
payNow: jest.Mock;
|
||||||
|
secured: jest.Mock;
|
||||||
|
expired: jest.Mock;
|
||||||
|
unplaced: jest.Mock;
|
||||||
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
bookingsRepository = {
|
bookingsRepository = {
|
||||||
findPaidUnlinkedForSchedule: jest.fn().mockResolvedValue([]),
|
findPaidUnlinkedForSchedule: jest.fn().mockResolvedValue([]),
|
||||||
findBatchPool: jest.fn().mockResolvedValue([]),
|
findBatchPool: jest.fn().mockResolvedValue([]),
|
||||||
|
findBatchPoolByRouteDay: jest.fn().mockResolvedValue([]),
|
||||||
findReservedForSchedule: jest.fn().mockResolvedValue([]),
|
findReservedForSchedule: jest.fn().mockResolvedValue([]),
|
||||||
update: jest.fn().mockResolvedValue(undefined),
|
update: jest.fn().mockResolvedValue(undefined),
|
||||||
};
|
};
|
||||||
@@ -67,11 +76,14 @@ describe('BookingBatchService — PAID reconcile', () => {
|
|||||||
issues: [],
|
issues: [],
|
||||||
violations: [],
|
violations: [],
|
||||||
}),
|
}),
|
||||||
|
getBookableSchedules: jest.fn().mockResolvedValue([]),
|
||||||
};
|
};
|
||||||
|
|
||||||
const bookingRepo = {
|
const bookingRepo = {
|
||||||
findOne: jest.fn().mockResolvedValue(paidBooking),
|
findOne: jest.fn().mockResolvedValue(paidBooking),
|
||||||
update: jest.fn().mockResolvedValue(undefined),
|
update: jest.fn().mockResolvedValue(undefined),
|
||||||
|
// WagonType.find() / global-rules find() fall back to defaults when empty.
|
||||||
|
find: jest.fn().mockResolvedValue([]),
|
||||||
};
|
};
|
||||||
dataSource = {
|
dataSource = {
|
||||||
getRepository: jest.fn().mockReturnValue(bookingRepo),
|
getRepository: jest.fn().mockReturnValue(bookingRepo),
|
||||||
@@ -83,12 +95,19 @@ describe('BookingBatchService — PAID reconcile', () => {
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
notifier = {
|
||||||
|
payNow: jest.fn(),
|
||||||
|
secured: jest.fn(),
|
||||||
|
expired: jest.fn(),
|
||||||
|
unplaced: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
service = new BookingBatchService(
|
service = new BookingBatchService(
|
||||||
dataSource as never,
|
dataSource as never,
|
||||||
bookingsRepository as never,
|
bookingsRepository as never,
|
||||||
trainSchedulesRepository as never,
|
trainSchedulesRepository as never,
|
||||||
trainScheduleBookingsRepository as never,
|
trainScheduleBookingsRepository as never,
|
||||||
{ payNow: jest.fn(), secured: jest.fn(), expired: jest.fn() } as never,
|
notifier as never,
|
||||||
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
|
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
|
||||||
trainSchedulingService as never,
|
trainSchedulingService as never,
|
||||||
);
|
);
|
||||||
@@ -141,4 +160,96 @@ describe('BookingBatchService — PAID reconcile', () => {
|
|||||||
expect(fillOrder).toBeLessThan(reconcileOrder);
|
expect(fillOrder).toBeLessThan(reconcileOrder);
|
||||||
expect(reconcileOrder).toBeLessThan(wagonOrder);
|
expect(reconcileOrder).toBeLessThan(wagonOrder);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('fillRouteDay — day-level distribution', () => {
|
||||||
|
const originYardId = 'yard-origin';
|
||||||
|
const destinationYardId = 'yard-dest';
|
||||||
|
const day = '2026-06-20';
|
||||||
|
// 06:00Z and 09:00Z on 2026-06-20 both land on the same EAT day.
|
||||||
|
const trainA = 'train-a';
|
||||||
|
const trainB = 'train-b';
|
||||||
|
|
||||||
|
// A tiny locomotive: default wagon = 14m / 70t → exactly 1 wagon slot fits.
|
||||||
|
const smallLoco = { maxPullWeightTons: 70, maxTrainLengthMeters: 14 };
|
||||||
|
|
||||||
|
const commercial = (id: string, priority: number): Booking =>
|
||||||
|
({
|
||||||
|
id,
|
||||||
|
reference: id,
|
||||||
|
isGovernment: false,
|
||||||
|
priorityScore: priority,
|
||||||
|
status: 'FULLY_EXECUTED',
|
||||||
|
wagonsRequired: 1,
|
||||||
|
cargoTotalWeightVgm: 10,
|
||||||
|
freightType: 'CONTAINER',
|
||||||
|
bookingContainers: [],
|
||||||
|
}) as unknown as Booking;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Two OPEN trains on the same route + day, train A earlier than train B.
|
||||||
|
trainSchedulingService.getBookableSchedules.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: trainA,
|
||||||
|
scheduleDate: '2026-06-20T06:00:00.000Z',
|
||||||
|
bookingWindowStatus: 'OPEN',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: trainB,
|
||||||
|
scheduleDate: '2026-06-20T09:00:00.000Z',
|
||||||
|
bookingWindowStatus: 'OPEN',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
trainSchedulesRepository.findByIdWithFullGraph.mockImplementation((id: string) =>
|
||||||
|
Promise.resolve({
|
||||||
|
id,
|
||||||
|
maxWagons: 1,
|
||||||
|
bookingWindowStatus: 'OPEN',
|
||||||
|
trainSetId: `set-${id}`,
|
||||||
|
trainSet: { locomotive: smallLoco },
|
||||||
|
scheduleBookings: [],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('spills overflow to the next train by priority, then reports unplaced', async () => {
|
||||||
|
// 3 commercial bookings, descending priority; only 1 fits per train (2 total).
|
||||||
|
bookingsRepository.findBatchPoolByRouteDay.mockResolvedValue([
|
||||||
|
commercial('hi', 30),
|
||||||
|
commercial('mid', 20),
|
||||||
|
commercial('lo', 10),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const touched = await service.fillRouteDay(originYardId, destinationYardId, day);
|
||||||
|
|
||||||
|
expect(bookingsRepository.findBatchPoolByRouteDay).toHaveBeenCalledWith(
|
||||||
|
originYardId,
|
||||||
|
destinationYardId,
|
||||||
|
day,
|
||||||
|
);
|
||||||
|
// Both trains were processed.
|
||||||
|
expect(touched).toEqual([trainA, trainB]);
|
||||||
|
// Highest priority reserved on train A, next on train B (commercial → reserve).
|
||||||
|
const reservedOn = notifier.payNow.mock.calls.map((c) => (c[0] as Booking).id);
|
||||||
|
expect(reservedOn).toEqual(['hi', 'mid']);
|
||||||
|
// The third booking fits no train and is reported unplaced (and only it).
|
||||||
|
expect(notifier.unplaced).toHaveBeenCalledTimes(1);
|
||||||
|
expect((notifier.unplaced.mock.calls[0][0] as Booking).id).toBe('lo');
|
||||||
|
expect(notifier.unplaced.mock.calls[0][1]).toBe(day);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reserves the chosen train id on each commercial booking', async () => {
|
||||||
|
bookingsRepository.findBatchPoolByRouteDay.mockResolvedValue([commercial('hi', 30)]);
|
||||||
|
|
||||||
|
await service.fillRouteDay(originYardId, destinationYardId, day);
|
||||||
|
|
||||||
|
// reserve() persists trainScheduleId so the settle lifecycle can find the train.
|
||||||
|
expect(bookingsRepository.update).toHaveBeenCalledWith(
|
||||||
|
'hi',
|
||||||
|
expect.objectContaining({
|
||||||
|
trainScheduleId: trainA,
|
||||||
|
status: 'SELECTED_FOR_BATCH',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedu
|
|||||||
import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity';
|
import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity';
|
||||||
import { BookingNotifierService } from './booking-notifier.service';
|
import { BookingNotifierService } from './booking-notifier.service';
|
||||||
import { TrainSchedulingService } from './train-scheduling.service';
|
import { TrainSchedulingService } from './train-scheduling.service';
|
||||||
import { groupBookingsIntoBoardWindows } from './batch-window.util';
|
import { eatDay, groupBookingsIntoBoardWindows } from './batch-window.util';
|
||||||
import {
|
import {
|
||||||
BATCH_CRON,
|
BATCH_CRON,
|
||||||
BATCH_TIMEZONE,
|
BATCH_TIMEZONE,
|
||||||
@@ -42,6 +42,14 @@ interface Capacity {
|
|||||||
lengthMeters: number;
|
lengthMeters: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A day-level pool key: all trains on this route departing on this EAT day. */
|
||||||
|
interface RouteDayGroup {
|
||||||
|
originYardId: string;
|
||||||
|
destinationYardId: string;
|
||||||
|
/** EAT calendar day, `yyyy-MM-dd`. */
|
||||||
|
day: string;
|
||||||
|
}
|
||||||
|
|
||||||
type WagonLengths = { container: number; bulk: number };
|
type WagonLengths = { container: number; bulk: number };
|
||||||
|
|
||||||
export type BatchBoardBookingState =
|
export type BatchBoardBookingState =
|
||||||
@@ -173,16 +181,16 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
private readonly trainSchedulingService: TrainSchedulingService,
|
private readonly trainSchedulingService: TrainSchedulingService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** On boot, reconcile OPEN schedules and re-arm settle timers. */
|
/** On boot, reconcile OPEN route-days and re-arm settle timers. */
|
||||||
async onModuleInit(): Promise<void> {
|
async onModuleInit(): Promise<void> {
|
||||||
const open = await this.trainSchedulesRepository.findAll({
|
const groups = await this.openRouteDayGroups();
|
||||||
where: { bookingWindowStatus: 'OPEN' },
|
for (const group of groups) {
|
||||||
});
|
|
||||||
for (const s of open) {
|
|
||||||
try {
|
try {
|
||||||
await this.processSchedule(s.id);
|
await this.processRouteDay(group);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.warn(`Boot reconcile failed for ${s.id}: ${(err as Error).message}`);
|
this.logger.warn(
|
||||||
|
`Boot reconcile failed for ${this.groupLabel(group)}: ${(err as Error).message}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const reserved = await this.dataSource
|
const reserved = await this.dataSource
|
||||||
@@ -195,13 +203,48 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
for (const { scheduleId } of reserved) this.armSettle(scheduleId);
|
for (const { scheduleId } of reserved) this.armSettle(scheduleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fire-and-forget batch pipeline for a schedule (contract sign, cron, payment). */
|
/**
|
||||||
|
* Fire-and-forget batch pipeline for the (route, day) a schedule belongs to
|
||||||
|
* (contract sign, payment). Day-level pooling distributes across all of that
|
||||||
|
* day's trains, so a single schedule id maps to its whole route-day group.
|
||||||
|
*/
|
||||||
enqueueScheduleProcessing(scheduleId: string): void {
|
enqueueScheduleProcessing(scheduleId: string): void {
|
||||||
void this.processSchedule(scheduleId).catch((err) =>
|
void this.processRouteDayForSchedule(scheduleId).catch((err) =>
|
||||||
this.logger.error(`processSchedule ${scheduleId} failed: ${(err as Error).message}`),
|
this.logger.error(
|
||||||
|
`processRouteDay for schedule ${scheduleId} failed: ${(err as Error).message}`,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Resolve a schedule's (route, day) group and run the day-level pipeline. */
|
||||||
|
private async processRouteDayForSchedule(scheduleId: string): Promise<void> {
|
||||||
|
const schedule = await this.trainSchedulesRepository.findById(scheduleId);
|
||||||
|
if (!schedule?.scheduledDepartureDate) return;
|
||||||
|
await this.processRouteDay({
|
||||||
|
originYardId: schedule.originStationId,
|
||||||
|
destinationYardId: schedule.destinationStationId,
|
||||||
|
day: eatDay(schedule.scheduledDepartureDate),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Day-level pipeline: distribute the (route, day) pool across all its trains,
|
||||||
|
* then settle / reconcile / assign wagons per schedule (those steps stay
|
||||||
|
* schedule-scoped — only the fill is day-level).
|
||||||
|
*/
|
||||||
|
async processRouteDay(group: RouteDayGroup): Promise<void> {
|
||||||
|
const scheduleIds = await this.fillRouteDay(
|
||||||
|
group.originYardId,
|
||||||
|
group.destinationYardId,
|
||||||
|
group.day,
|
||||||
|
);
|
||||||
|
for (const scheduleId of scheduleIds) {
|
||||||
|
await this.settleDueReservations(scheduleId);
|
||||||
|
await this.reconcilePaidUnlinked(scheduleId);
|
||||||
|
await this.trainSchedulingService.tryAutoWagonAllocation(scheduleId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Fill pool, settle due reservations, link orphaned PAID, then assign wagons. */
|
/** Fill pool, settle due reservations, link orphaned PAID, then assign wagons. */
|
||||||
async processSchedule(scheduleId: string): Promise<void> {
|
async processSchedule(scheduleId: string): Promise<void> {
|
||||||
await this.fillSchedule(scheduleId);
|
await this.fillSchedule(scheduleId);
|
||||||
@@ -210,6 +253,31 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
await this.trainSchedulingService.tryAutoWagonAllocation(scheduleId);
|
await this.trainSchedulingService.tryAutoWagonAllocation(scheduleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Distinct (origin, destination, EAT day) groups across all OPEN schedules. */
|
||||||
|
private async openRouteDayGroups(): Promise<RouteDayGroup[]> {
|
||||||
|
const open = await this.trainSchedulesRepository.findAll({
|
||||||
|
where: { bookingWindowStatus: 'OPEN' },
|
||||||
|
});
|
||||||
|
const groups = new Map<string, RouteDayGroup>();
|
||||||
|
for (const s of open) {
|
||||||
|
if (!s.scheduledDepartureDate) continue;
|
||||||
|
const day = eatDay(s.scheduledDepartureDate);
|
||||||
|
const key = `${s.originStationId}|${s.destinationStationId}|${day}`;
|
||||||
|
if (!groups.has(key)) {
|
||||||
|
groups.set(key, {
|
||||||
|
originYardId: s.originStationId,
|
||||||
|
destinationYardId: s.destinationStationId,
|
||||||
|
day,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...groups.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
private groupLabel(group: RouteDayGroup): string {
|
||||||
|
return `${group.originYardId}→${group.destinationYardId} on ${group.day}`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Idempotent: link a paid batch booking to its schedule and assign wagons.
|
* Idempotent: link a paid batch booking to its schedule and assign wagons.
|
||||||
* Handles SELECTED_FOR_BATCH, PAID-without-link, and PAID-already-linked cases.
|
* Handles SELECTED_FOR_BATCH, PAID-without-link, and PAID-already-linked cases.
|
||||||
@@ -289,15 +357,15 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
|
|
||||||
@Cron(BATCH_CRON, { name: 'booking-batch-fill', timeZone: BATCH_TIMEZONE })
|
@Cron(BATCH_CRON, { name: 'booking-batch-fill', timeZone: BATCH_TIMEZONE })
|
||||||
async runBatchFill(): Promise<void> {
|
async runBatchFill(): Promise<void> {
|
||||||
const open = await this.trainSchedulesRepository.findAll({
|
const groups = await this.openRouteDayGroups();
|
||||||
where: { bookingWindowStatus: 'OPEN' },
|
this.logger.log(`Batch fill: ${groups.length} OPEN route-day group(s).`);
|
||||||
});
|
for (const group of groups) {
|
||||||
this.logger.log(`Batch fill: ${open.length} OPEN schedule(s).`);
|
|
||||||
for (const s of open) {
|
|
||||||
try {
|
try {
|
||||||
await this.processSchedule(s.id);
|
await this.processRouteDay(group);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(`Batch fill failed for ${s.id}: ${(err as Error).message}`);
|
this.logger.error(
|
||||||
|
`Batch fill failed for ${this.groupLabel(group)}: ${(err as Error).message}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -611,7 +679,7 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
if (booking.isGovernment) {
|
if (booking.isGovernment) {
|
||||||
await this.allocate(scheduleId, booking, 'gov');
|
await this.allocate(scheduleId, booking, 'gov');
|
||||||
} else {
|
} else {
|
||||||
await this.reserve(booking);
|
await this.reserve(booking, scheduleId);
|
||||||
armed = true;
|
armed = true;
|
||||||
}
|
}
|
||||||
budget = this.subtract(budget, need);
|
budget = this.subtract(budget, need);
|
||||||
@@ -623,6 +691,106 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
void this.triggerWagonAllocation(scheduleId);
|
void this.triggerWagonAllocation(scheduleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Distribute one (route, day) pool across ALL of that day's OPEN trains, by
|
||||||
|
* priority, filling each train (earliest departure first) until it's full and
|
||||||
|
* spilling overflow to the next. Government bookings that fit no train preempt
|
||||||
|
* lower-priority commercial; bookings that fit no train at all stay pending and
|
||||||
|
* trigger a staff `unplaced` warning. Returns the schedule ids that were touched
|
||||||
|
* (or that had remaining pool work) so the caller can settle them per-schedule.
|
||||||
|
*/
|
||||||
|
async fillRouteDay(
|
||||||
|
originYardId: string,
|
||||||
|
destinationYardId: string,
|
||||||
|
day: string,
|
||||||
|
): Promise<string[]> {
|
||||||
|
// The day's OPEN bookable schedules on this exact corridor, earliest first.
|
||||||
|
const bookable = await this.trainSchedulingService.getBookableSchedules(
|
||||||
|
originYardId,
|
||||||
|
destinationYardId,
|
||||||
|
);
|
||||||
|
const scheduleIds = bookable
|
||||||
|
.filter(
|
||||||
|
(s) =>
|
||||||
|
s.bookingWindowStatus === 'OPEN' &&
|
||||||
|
s.scheduleDate != null &&
|
||||||
|
eatDay(new Date(s.scheduleDate)) === day,
|
||||||
|
)
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
new Date(a.scheduleDate).getTime() - new Date(b.scheduleDate).getTime(),
|
||||||
|
)
|
||||||
|
.map((s) => s.id);
|
||||||
|
|
||||||
|
if (scheduleIds.length === 0) return [];
|
||||||
|
|
||||||
|
const rules = await this.loadGlobalRules();
|
||||||
|
const wagonLengths = await this.loadWagonLengths();
|
||||||
|
|
||||||
|
// Live per-schedule budget + arm flag, in departure order.
|
||||||
|
const trains: Array<{ id: string; budget: Capacity; armed: boolean }> = [];
|
||||||
|
for (const id of scheduleIds) {
|
||||||
|
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id);
|
||||||
|
const locomotive = schedule?.trainSet?.locomotive;
|
||||||
|
if (!schedule || !schedule.trainSetId || !locomotive) {
|
||||||
|
this.logger.warn(`Schedule ${id} has no locomotive/train set — skipped.`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const limits = await this.capacityLimits(locomotive, rules);
|
||||||
|
await this.syncScheduleMaxWagons(schedule, locomotive, rules);
|
||||||
|
const budget = await this.remainingCapacity(schedule, limits, wagonLengths);
|
||||||
|
trains.push({ id, budget, armed: false });
|
||||||
|
}
|
||||||
|
if (trains.length === 0) return [];
|
||||||
|
|
||||||
|
const pool = await this.bookingsRepository.findBatchPoolByRouteDay(
|
||||||
|
originYardId,
|
||||||
|
destinationYardId,
|
||||||
|
day,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const booking of pool) {
|
||||||
|
const need = this.needFor(booking, wagonLengths);
|
||||||
|
|
||||||
|
// First train (earliest departure) that fits this booking as-is.
|
||||||
|
let target = trains.find((t) => this.fits(need, t.budget));
|
||||||
|
|
||||||
|
if (!target && booking.isGovernment) {
|
||||||
|
// Government booking fits nowhere on its own — try to preempt commercial
|
||||||
|
// on each train (earliest first) until one frees enough room.
|
||||||
|
for (const t of trains) {
|
||||||
|
t.budget = await this.preemptForGovernment(t.id, need, t.budget, wagonLengths);
|
||||||
|
if (this.fits(need, t.budget)) {
|
||||||
|
target = t;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
// Fits no train this day — stays in the pool, retried next batch.
|
||||||
|
this.notifier.unplaced(booking, day);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (booking.isGovernment) {
|
||||||
|
await this.allocate(target.id, booking, 'gov');
|
||||||
|
} else {
|
||||||
|
await this.reserve(booking, target.id);
|
||||||
|
target.armed = true;
|
||||||
|
}
|
||||||
|
target.budget = this.subtract(target.budget, need);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const t of trains) {
|
||||||
|
if (t.budget.wagons <= 0) await this.setWindow(t.id, 'FULL');
|
||||||
|
if (t.armed) this.armSettle(t.id);
|
||||||
|
void this.triggerWagonAllocation(t.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return trains.map((t) => t.id);
|
||||||
|
}
|
||||||
|
|
||||||
/** Durable settle: allocate paid / expire overdue reservations, then top up. */
|
/** Durable settle: allocate paid / expire overdue reservations, then top up. */
|
||||||
async settleDueReservations(scheduleId: string): Promise<void> {
|
async settleDueReservations(scheduleId: string): Promise<void> {
|
||||||
const reserved = await this.bookingsRepository.findReservedForSchedule(scheduleId);
|
const reserved = await this.bookingsRepository.findReservedForSchedule(scheduleId);
|
||||||
@@ -766,15 +934,24 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
|
|
||||||
// ---- mutations ------------------------------------------------------------
|
// ---- mutations ------------------------------------------------------------
|
||||||
|
|
||||||
/** Reserve capacity for a commercial booking and open its pay window. */
|
/**
|
||||||
private async reserve(booking: Booking): Promise<void> {
|
* Reserve capacity for a commercial booking on a specific train and open its
|
||||||
|
* pay window. `scheduleId` is persisted so the settle/allocate lifecycle
|
||||||
|
* (settleDueReservations, settleBatch, ensurePaidBookingAllocated, markPaid),
|
||||||
|
* which is all keyed off `booking.trainScheduleId`, can find the train — with
|
||||||
|
* day-level pooling the booking arrives here with `trainScheduleId` still null,
|
||||||
|
* so the engine sets it as it picks the train.
|
||||||
|
*/
|
||||||
|
private async reserve(booking: Booking, scheduleId: string): Promise<void> {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const deadline = new Date(now.getTime() + PAYMENT_WINDOW_MS);
|
const deadline = new Date(now.getTime() + PAYMENT_WINDOW_MS);
|
||||||
await this.bookingsRepository.update(booking.id, {
|
await this.bookingsRepository.update(booking.id, {
|
||||||
|
trainScheduleId: scheduleId,
|
||||||
status: 'SELECTED_FOR_BATCH',
|
status: 'SELECTED_FOR_BATCH',
|
||||||
selectedForBatchAt: now,
|
selectedForBatchAt: now,
|
||||||
paymentDeadline: deadline,
|
paymentDeadline: deadline,
|
||||||
} as never);
|
} as never);
|
||||||
|
booking.trainScheduleId = scheduleId;
|
||||||
await this.notifier.payNow(booking, deadline);
|
await this.notifier.payNow(booking, deadline);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -807,14 +984,20 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
void this.triggerWagonAllocation(scheduleId);
|
void this.triggerWagonAllocation(scheduleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Expire an unpaid reservation and free its capacity. */
|
/**
|
||||||
|
* Expire an unpaid reservation and free its capacity. With day-level pooling we
|
||||||
|
* also clear `trainScheduleId` so the booking is no longer pinned to the train
|
||||||
|
* it failed to pay for — it's back in the day pool for staff to act on.
|
||||||
|
*/
|
||||||
private async expire(booking: Booking): Promise<void> {
|
private async expire(booking: Booking): Promise<void> {
|
||||||
await this.bookingsRepository.update(booking.id, {
|
await this.bookingsRepository.update(booking.id, {
|
||||||
|
trainScheduleId: null,
|
||||||
status: 'EXPIRED',
|
status: 'EXPIRED',
|
||||||
schedulingStatus: 'ELIGIBLE',
|
schedulingStatus: 'ELIGIBLE',
|
||||||
paymentDeadline: null,
|
paymentDeadline: null,
|
||||||
selectedForBatchAt: null,
|
selectedForBatchAt: null,
|
||||||
} as never);
|
} as never);
|
||||||
|
booking.trainScheduleId = null;
|
||||||
this.notifier.expired(booking);
|
this.notifier.expired(booking);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -67,6 +67,17 @@ export class BookingNotifierService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Staff-facing warning when a pooled booking fits no train on its chosen day.
|
||||||
|
* It stays pending and is retried next batch; staff can add capacity or pin it
|
||||||
|
* to a train manually. Mirrors {@link scheduleFull} — no customer notification.
|
||||||
|
*/
|
||||||
|
unplaced(b: Booking, day: string): void {
|
||||||
|
this.logger.warn(
|
||||||
|
`UNPLACED — ${this.ref(b)} could not be placed on any train for ${day}; add capacity or assign it manually.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
displaced(b: Booking): void {
|
displaced(b: Booking): void {
|
||||||
const msg = `Booking ${b.reference ?? b.id} was displaced by a government booking. Move to another schedule or cancel.`;
|
const msg = `Booking ${b.reference ?? b.id} was displaced by a government booking. Move to another schedule or cancel.`;
|
||||||
void this.notifyContact(b, msg, 'DISPLACED');
|
void this.notifyContact(b, msg, 'DISPLACED');
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsOptional, IsUUID } from 'class-validator';
|
||||||
|
|
||||||
|
export class AvailableDaysQueryDto {
|
||||||
|
@ApiPropertyOptional({ format: 'uuid' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
originYardId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ format: 'uuid' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
destinationYardId?: string;
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@ import { PreviewTrainScheduleDto } from "./dto/preview-train-schedule.dto";
|
|||||||
import { RecordCheckpointDto } from "./dto/record-checkpoint.dto";
|
import { RecordCheckpointDto } from "./dto/record-checkpoint.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 { UpdateTrainSchedulingGlobalRulesDto } from "./dto/update-train-scheduling-global-rules.dto";
|
import { UpdateTrainSchedulingGlobalRulesDto } from "./dto/update-train-scheduling-global-rules.dto";
|
||||||
import { TrainSchedulingService } from "./train-scheduling.service";
|
import { TrainSchedulingService } from "./train-scheduling.service";
|
||||||
import { BookingBatchService } from "./booking-batch.service";
|
import { BookingBatchService } from "./booking-batch.service";
|
||||||
@@ -108,6 +109,20 @@ export class TrainSchedulingController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get("available-days")
|
||||||
|
// No staff guard: customers hit this while creating a booking to find which
|
||||||
|
// DAYS have a departure on their route. Day-level pooling — no capacity is
|
||||||
|
// returned, only the list of bookable days.
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Distinct days with an OPEN same-route departure (day-level pool)",
|
||||||
|
})
|
||||||
|
getAvailableDays(@Query() query: AvailableDaysQueryDto) {
|
||||||
|
return this.trainSchedulingService.getAvailableDays(
|
||||||
|
query.originYardId,
|
||||||
|
query.destinationYardId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@Get("container/eligible-bookings")
|
@Get("container/eligible-bookings")
|
||||||
@TrainSchedulingView()
|
@TrainSchedulingView()
|
||||||
@ApiOperation({ summary: "List eligible container bookings" })
|
@ApiOperation({ summary: "List eligible container bookings" })
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ import {
|
|||||||
DEFAULT_BULK_WAGON_LENGTH_METERS,
|
DEFAULT_BULK_WAGON_LENGTH_METERS,
|
||||||
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
|
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
|
||||||
} from './booking-batch.constants';
|
} from './booking-batch.constants';
|
||||||
|
import { eatDay } from './batch-window.util';
|
||||||
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
|
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
|
||||||
import { TrainCheckpointEventsRepository } from './train-checkpoint-events.repository';
|
import { TrainCheckpointEventsRepository } from './train-checkpoint-events.repository';
|
||||||
import { RecordCheckpointDto } from './dto/record-checkpoint.dto';
|
import { RecordCheckpointDto } from './dto/record-checkpoint.dto';
|
||||||
@@ -167,12 +168,28 @@ export class TrainSchedulingService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getEligibleBookings(query: GetEligibleBookingsDto) {
|
async getEligibleBookings(query: GetEligibleBookingsDto) {
|
||||||
|
// Day-level pooling: when the wizard targets a schedule, surface the whole
|
||||||
|
// (route, EAT day) pool — not just bookings pre-pinned to that train — by
|
||||||
|
// resolving the schedule's route + day and filtering on the day instead.
|
||||||
|
let day: string | undefined;
|
||||||
|
let originStationId = query.originStationId;
|
||||||
|
let destinationStationId = query.destinationStationId;
|
||||||
|
if (query.trainScheduleId) {
|
||||||
|
const schedule = await this.trainSchedulesRepository.findById(query.trainScheduleId);
|
||||||
|
if (schedule?.scheduledDepartureDate) {
|
||||||
|
day = eatDay(schedule.scheduledDepartureDate);
|
||||||
|
originStationId = originStationId ?? schedule.originStationId;
|
||||||
|
destinationStationId = destinationStationId ?? schedule.destinationStationId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const bookings = await this.bookingsRepository.findEligibleForScheduling({
|
const bookings = await this.bookingsRepository.findEligibleForScheduling({
|
||||||
freightType: query.freightType,
|
freightType: query.freightType,
|
||||||
originStationId: query.originStationId,
|
originStationId,
|
||||||
destinationStationId: query.destinationStationId,
|
destinationStationId,
|
||||||
schedulingStatus: query.schedulingStatus,
|
schedulingStatus: query.schedulingStatus,
|
||||||
trainScheduleId: query.trainScheduleId,
|
trainScheduleId: query.trainScheduleId,
|
||||||
|
day,
|
||||||
});
|
});
|
||||||
return { count: bookings.length, items: bookings.map((b) => this.mapEligibleBooking(b)) };
|
return { count: bookings.length, items: bookings.map((b) => this.mapEligibleBooking(b)) };
|
||||||
}
|
}
|
||||||
@@ -721,27 +738,37 @@ export class TrainSchedulingService {
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (route) {
|
if (route) {
|
||||||
const origin = route.originYard;
|
// `route.milestones` is the complete ordered corridor and already includes
|
||||||
const destination = route.destinationYard;
|
// the origin (first) and destination (last) yards — `route.originYardId`
|
||||||
|
// and `route.destinationYardId` are derived from them. Use the milestones
|
||||||
|
// directly so the endpoints aren't double-counted (Addis…Addis, Dire…Dire).
|
||||||
const milestones = [...(route.milestones ?? [])].sort(
|
const milestones = [...(route.milestones ?? [])].sort(
|
||||||
(a: RouteMilestone, b: RouteMilestone) => a.sequenceNo - b.sequenceNo,
|
(a: RouteMilestone, b: RouteMilestone) => a.sequenceNo - b.sequenceNo,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (milestones.length > 0) {
|
||||||
|
milestones.forEach((m, i) =>
|
||||||
|
stations.push({
|
||||||
|
sequenceNo: i,
|
||||||
|
yardId: m.yardId,
|
||||||
|
label: m.yard?.label ?? m.yard?.code ?? `Stop ${i + 1}`,
|
||||||
|
code: m.yard?.code ?? '',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return stations;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Route with no milestones recorded — fall back to its origin/destination.
|
||||||
|
const origin = route.originYard;
|
||||||
|
const destination = route.destinationYard;
|
||||||
stations.push({
|
stations.push({
|
||||||
sequenceNo: 0,
|
sequenceNo: 0,
|
||||||
yardId: route.originYardId,
|
yardId: route.originYardId,
|
||||||
label: origin?.label ?? origin?.code ?? 'Origin',
|
label: origin?.label ?? origin?.code ?? 'Origin',
|
||||||
code: origin?.code ?? '',
|
code: origin?.code ?? '',
|
||||||
});
|
});
|
||||||
milestones.forEach((m, i) =>
|
|
||||||
stations.push({
|
|
||||||
sequenceNo: i + 1,
|
|
||||||
yardId: m.yardId,
|
|
||||||
label: m.yard?.label ?? m.yard?.code ?? `Stop ${i + 1}`,
|
|
||||||
code: m.yard?.code ?? '',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
stations.push({
|
stations.push({
|
||||||
sequenceNo: milestones.length + 1,
|
sequenceNo: 1,
|
||||||
yardId: route.destinationYardId,
|
yardId: route.destinationYardId,
|
||||||
label: destination?.label ?? destination?.code ?? 'Destination',
|
label: destination?.label ?? destination?.code ?? 'Destination',
|
||||||
code: destination?.code ?? '',
|
code: destination?.code ?? '',
|
||||||
@@ -775,8 +802,16 @@ export class TrainSchedulingService {
|
|||||||
|
|
||||||
const stations = await this.buildScheduleStations(schedule);
|
const stations = await this.buildScheduleStations(schedule);
|
||||||
const events = await this.trainCheckpointEventsRepository.findBySchedule(scheduleId);
|
const events = await this.trainCheckpointEventsRepository.findBySchedule(scheduleId);
|
||||||
|
|
||||||
|
// Resolve each checkpoint's position by its yard against the canonical
|
||||||
|
// corridor rather than the stored sequenceNo, so legacy checkpoints logged
|
||||||
|
// under an older station numbering still line up with the current stations.
|
||||||
|
const seqByYard = new Map(stations.map((s) => [s.yardId, s.sequenceNo]));
|
||||||
|
const resolvedSeq = (e: TrainCheckpointEvent) =>
|
||||||
|
seqByYard.get(e.yardId) ?? e.sequenceNo;
|
||||||
|
|
||||||
const currentSequenceNo = events.length
|
const currentSequenceNo = events.length
|
||||||
? Math.max(...events.map((e) => e.sequenceNo))
|
? Math.max(...events.map(resolvedSeq))
|
||||||
: -1;
|
: -1;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -790,13 +825,19 @@ export class TrainSchedulingService {
|
|||||||
actualArrivalAt: schedule.actualArrivalAt
|
actualArrivalAt: schedule.actualArrivalAt
|
||||||
? schedule.actualArrivalAt.toISOString()
|
? schedule.actualArrivalAt.toISOString()
|
||||||
: null,
|
: null,
|
||||||
|
scheduledDepartureAt: schedule.scheduledDepartureDate
|
||||||
|
? schedule.scheduledDepartureDate.toISOString()
|
||||||
|
: null,
|
||||||
|
scheduledArrivalAt: schedule.scheduledArrivalDate
|
||||||
|
? schedule.scheduledArrivalDate.toISOString()
|
||||||
|
: null,
|
||||||
origin: stations[0]?.label ?? null,
|
origin: stations[0]?.label ?? null,
|
||||||
destination: stations[stations.length - 1]?.label ?? null,
|
destination: stations[stations.length - 1]?.label ?? null,
|
||||||
stations,
|
stations,
|
||||||
currentSequenceNo,
|
currentSequenceNo,
|
||||||
checkpoints: events.map((e) => ({
|
checkpoints: events.map((e) => ({
|
||||||
id: e.id,
|
id: e.id,
|
||||||
sequenceNo: e.sequenceNo,
|
sequenceNo: resolvedSeq(e),
|
||||||
yardId: e.yardId,
|
yardId: e.yardId,
|
||||||
label: e.yard?.label ?? e.yard?.code ?? null,
|
label: e.yard?.label ?? e.yard?.code ?? null,
|
||||||
kind: e.kind,
|
kind: e.kind,
|
||||||
@@ -1965,6 +2006,33 @@ export class TrainSchedulingService {
|
|||||||
return filteredSchedules;
|
return filteredSchedules;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Day-level pool: the distinct EAT calendar days that have ≥1 OPEN bookable
|
||||||
|
* departure on the route. Customers pick a DAY (not a train) — so this returns
|
||||||
|
* only the day strings, no capacity, counts or train info.
|
||||||
|
*/
|
||||||
|
async getAvailableDays(
|
||||||
|
originYardId?: string,
|
||||||
|
destinationYardId?: string,
|
||||||
|
): Promise<{ days: string[] }> {
|
||||||
|
const schedules = await this.getBookableSchedules(originYardId, destinationYardId);
|
||||||
|
const days = new Set<string>();
|
||||||
|
for (const s of schedules) {
|
||||||
|
if (s.scheduleDate) days.add(eatDay(new Date(s.scheduleDate)));
|
||||||
|
}
|
||||||
|
return { days: [...days].sort() };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a route has ≥1 OPEN bookable departure on a given EAT day. */
|
||||||
|
async existsOpenScheduleOnRouteDay(
|
||||||
|
originYardId: string,
|
||||||
|
destinationYardId: string,
|
||||||
|
day: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const { days } = await this.getAvailableDays(originYardId, destinationYardId);
|
||||||
|
return days.includes(day);
|
||||||
|
}
|
||||||
|
|
||||||
private async mapScheduleDetail(
|
private async mapScheduleDetail(
|
||||||
schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule,
|
schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { IsString, IsEnum, IsNumber, IsOptional } from 'class-validator';
|
||||||
|
import { VehicleType, FuelType, VehicleStatus } from '../entities/vehicle.entity';
|
||||||
|
|
||||||
|
export class CreateVehicleDto {
|
||||||
|
@IsString()
|
||||||
|
plateNumber!: string;
|
||||||
|
|
||||||
|
@IsEnum(VehicleType)
|
||||||
|
vehicleType!: VehicleType;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
manufacturer!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
model!: string;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
year!: number;
|
||||||
|
|
||||||
|
@IsEnum(FuelType)
|
||||||
|
fuelType!: FuelType;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
capacity!: number;
|
||||||
|
|
||||||
|
@IsEnum(VehicleStatus)
|
||||||
|
status!: VehicleStatus;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { PartialType } from '@nestjs/mapped-types';
|
||||||
|
import { CreateVehicleDto } from './create-vehicle.dto';
|
||||||
|
|
||||||
|
export class UpdateVehicleDto extends PartialType(CreateVehicleDto) {}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { Entity, Column, Index } from 'typeorm';
|
||||||
|
import { BaseEntity } from '@edr/api-common';
|
||||||
|
|
||||||
|
export enum VehicleType {
|
||||||
|
TRUCK = 'TRUCK',
|
||||||
|
VAN = 'VAN',
|
||||||
|
CAR = 'CAR',
|
||||||
|
BUS = 'BUS',
|
||||||
|
TRAILER = 'TRAILER',
|
||||||
|
TANKER = 'TANKER',
|
||||||
|
FLATBED = 'FLATBED',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum FuelType {
|
||||||
|
PETROL = 'PETROL',
|
||||||
|
DIESEL = 'DIESEL',
|
||||||
|
ELECTRIC = 'ELECTRIC',
|
||||||
|
HYBRID = 'HYBRID',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum VehicleStatus {
|
||||||
|
ACTIVE = 'ACTIVE',
|
||||||
|
MAINTENANCE = 'MAINTENANCE',
|
||||||
|
RETIRED = 'RETIRED',
|
||||||
|
OUT_OF_SERVICE = 'OUT_OF_SERVICE',
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity({ name: 'vehicles', schema: 'freight' })
|
||||||
|
@Index(['plateNumber'])
|
||||||
|
@Index(['registrationNumber'])
|
||||||
|
@Index(['status'])
|
||||||
|
@Index(['vehicleType'])
|
||||||
|
@Index(['manufacturer'])
|
||||||
|
export class Vehicle extends BaseEntity {
|
||||||
|
@Column({ name: 'plate_number', unique: true })
|
||||||
|
plateNumber!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'registration_number', unique: true })
|
||||||
|
registrationNumber!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'vehicle_type', type: 'varchar' })
|
||||||
|
vehicleType!: VehicleType;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
manufacturer!: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
model!: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
year!: number;
|
||||||
|
|
||||||
|
@Column({ name: 'fuel_type', type: 'varchar' })
|
||||||
|
fuelType!: FuelType;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
capacity!: number;
|
||||||
|
|
||||||
|
@Column({ name: 'status', type: 'varchar', default: VehicleStatus.ACTIVE })
|
||||||
|
status!: VehicleStatus;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
description!: string | null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Patch,
|
||||||
|
Delete,
|
||||||
|
Param,
|
||||||
|
Body,
|
||||||
|
Query,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { FleetManage, FleetView } from '../../common/booking-guards';
|
||||||
|
import { VehiclesService } from './vehicles.service';
|
||||||
|
import { CreateVehicleDto } from './dto/create-vehicle.dto';
|
||||||
|
import { UpdateVehicleDto } from './dto/update-vehicle.dto';
|
||||||
|
|
||||||
|
@ApiTags('vehicles')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('vehicles')
|
||||||
|
@FleetView()
|
||||||
|
export class VehiclesController {
|
||||||
|
constructor(private readonly vehiclesService: VehiclesService) {}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@FleetManage()
|
||||||
|
@ApiOperation({ summary: 'Create a new vehicle' })
|
||||||
|
create(@Body() createVehicleDto: CreateVehicleDto) {
|
||||||
|
return this.vehiclesService.create(createVehicleDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: 'Get all vehicles with filters' })
|
||||||
|
findAll(
|
||||||
|
@Query('search') search?: string,
|
||||||
|
@Query('status') status?: string,
|
||||||
|
@Query('page') page?: string,
|
||||||
|
@Query('limit') limit?: string,
|
||||||
|
@Query('sortBy') sortBy?: string,
|
||||||
|
@Query('sortOrder') sortOrder?: 'ASC' | 'DESC',
|
||||||
|
) {
|
||||||
|
return this.vehiclesService.findAll({
|
||||||
|
search,
|
||||||
|
status: status as any,
|
||||||
|
page: page ? parseInt(page) : undefined,
|
||||||
|
limit: limit ? parseInt(limit) : undefined,
|
||||||
|
sortBy,
|
||||||
|
sortOrder,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@ApiOperation({ summary: 'Get vehicle by id' })
|
||||||
|
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.vehiclesService.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
@FleetManage()
|
||||||
|
@ApiOperation({ summary: 'Update a vehicle' })
|
||||||
|
update(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() updateVehicleDto: UpdateVehicleDto,
|
||||||
|
) {
|
||||||
|
return this.vehiclesService.update(id, updateVehicleDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@FleetManage()
|
||||||
|
@ApiOperation({ summary: 'Delete a vehicle' })
|
||||||
|
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.vehiclesService.remove(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
apps/edr-freight-api/src/modules/vehicles/vehicles.module.ts
Normal file
13
apps/edr-freight-api/src/modules/vehicles/vehicles.module.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { Vehicle } from './entities/vehicle.entity';
|
||||||
|
import { VehiclesService } from './vehicles.service';
|
||||||
|
import { VehiclesController } from './vehicles.controller';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([Vehicle])],
|
||||||
|
providers: [VehiclesService],
|
||||||
|
controllers: [VehiclesController],
|
||||||
|
exports: [VehiclesService],
|
||||||
|
})
|
||||||
|
export class VehiclesModule {}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { BaseRepository } from '@edr/api-common';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { Vehicle } from './entities/vehicle.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class VehiclesRepository extends BaseRepository<Vehicle> {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(Vehicle)
|
||||||
|
repository: Repository<Vehicle>,
|
||||||
|
) {
|
||||||
|
super(repository);
|
||||||
|
}
|
||||||
|
}
|
||||||
109
apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts
Normal file
109
apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { CreateVehicleDto } from './dto/create-vehicle.dto';
|
||||||
|
import { UpdateVehicleDto } from './dto/update-vehicle.dto';
|
||||||
|
import { Vehicle, VehicleStatus } from './entities/vehicle.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class VehiclesService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(Vehicle)
|
||||||
|
private readonly vehicleRepo: Repository<Vehicle>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async create(dto: CreateVehicleDto): Promise<Vehicle> {
|
||||||
|
const existing = await this.vehicleRepo.findOne({
|
||||||
|
where: { plateNumber: dto.plateNumber },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Vehicle with plate number ${dto.plateNumber} already exists`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const registrationNumber = `REG-${dto.vehicleType}-${Date.now()}`;
|
||||||
|
const vehicle = this.vehicleRepo.create({
|
||||||
|
...dto,
|
||||||
|
registrationNumber,
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.vehicleRepo.save(vehicle);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAll(query: {
|
||||||
|
search?: string;
|
||||||
|
status?: VehicleStatus | string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
sortBy?: string;
|
||||||
|
sortOrder?: 'ASC' | 'DESC';
|
||||||
|
} = {}): Promise<{ data: Vehicle[]; total: number; page: number; limit: number }> {
|
||||||
|
const page = query.page || 1;
|
||||||
|
const limit = query.limit || 10;
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
const where: any = {};
|
||||||
|
if (query.status) where.status = query.status;
|
||||||
|
|
||||||
|
let qb = this.vehicleRepo.createQueryBuilder('v');
|
||||||
|
|
||||||
|
if (query.search) {
|
||||||
|
qb = qb.where(
|
||||||
|
'v.plateNumber ILIKE :search OR v.manufacturer ILIKE :search',
|
||||||
|
{ search: `%${query.search}%` },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.status) {
|
||||||
|
qb = qb.andWhere('v.status = :status', { status: query.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortBy = ['plateNumber', 'status', 'year', 'createdAt'].includes(
|
||||||
|
query.sortBy ?? '',
|
||||||
|
)
|
||||||
|
? query.sortBy
|
||||||
|
: 'createdAt';
|
||||||
|
const sortOrder = (query.sortOrder ?? 'DESC').toUpperCase();
|
||||||
|
|
||||||
|
const [data, total] = await qb
|
||||||
|
.orderBy(`v.${sortBy}`, sortOrder as 'ASC' | 'DESC')
|
||||||
|
.skip(skip)
|
||||||
|
.take(limit)
|
||||||
|
.getManyAndCount();
|
||||||
|
|
||||||
|
return { data, total, page, limit };
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string): Promise<Vehicle> {
|
||||||
|
const vehicle = await this.vehicleRepo.findOne({ where: { id } });
|
||||||
|
if (!vehicle) {
|
||||||
|
throw new NotFoundException(`Vehicle ${id} not found`);
|
||||||
|
}
|
||||||
|
return vehicle;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateVehicleDto): Promise<Vehicle> {
|
||||||
|
const vehicle = await this.findById(id);
|
||||||
|
|
||||||
|
if (dto.plateNumber && dto.plateNumber !== vehicle.plateNumber) {
|
||||||
|
const existing = await this.vehicleRepo.findOne({
|
||||||
|
where: { plateNumber: dto.plateNumber },
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Vehicle with plate number ${dto.plateNumber} already exists`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(vehicle, dto);
|
||||||
|
return this.vehicleRepo.save(vehicle);
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(id: string): Promise<void> {
|
||||||
|
await this.findById(id);
|
||||||
|
await this.vehicleRepo.softDelete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,8 +42,13 @@ export class DemoFreightDataSeeder {
|
|||||||
|
|
||||||
async run() {
|
async run() {
|
||||||
await this.dataSource.transaction(async (manager) => {
|
await this.dataSource.transaction(async (manager) => {
|
||||||
await this.seedWagons(manager);
|
// Demo freight data (wagons + approval rules) disabled — keep only the
|
||||||
await this.seedApprovalRules(manager);
|
// 4 staff users. The seeders are retained for easy re-enabling; flip
|
||||||
|
// SEED_DEMO_FREIGHT_DATA=true to run them.
|
||||||
|
if (process.env.SEED_DEMO_FREIGHT_DATA === 'true') {
|
||||||
|
await this.seedWagons(manager);
|
||||||
|
await this.seedApprovalRules(manager);
|
||||||
|
}
|
||||||
await this.seedStaffUsers(manager);
|
await this.seedStaffUsers(manager);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,10 +9,7 @@
|
|||||||
"preview": "vite preview --port 5183",
|
"preview": "vite preview --port 5183",
|
||||||
"lint": "eslint src",
|
"lint": "eslint src",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"type-check": "tsc --noEmit",
|
"type-check": "tsc --noEmit"
|
||||||
"build:user-management": "cd user-management-config && npm run build",
|
|
||||||
"backoffice": "npm run build:user-management && nx serve @fhc-platform/backoffice",
|
|
||||||
"backoffice:no-build": "nx serve @fhc-platform/backoffice"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@edr/types": "workspace:*",
|
"@edr/types": "workspace:*",
|
||||||
@@ -22,7 +19,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-common": "1.1.2",
|
"@tria-plc/iamui": "0.0.3",
|
||||||
"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",
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage
|
|||||||
import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
|
import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
|
||||||
import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect";
|
import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect";
|
||||||
import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
|
import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
|
||||||
|
import CargoTypesPage from "./pages/ruleEngine/CargoTypesPage";
|
||||||
import TrainScheduleV2ListPage from "./pages/trainScheduling/TrainScheduleV2ListPage";
|
import TrainScheduleV2ListPage from "./pages/trainScheduling/TrainScheduleV2ListPage";
|
||||||
import BatchBoardPage from "./pages/trainScheduling/BatchBoardPage";
|
import BatchBoardPage from "./pages/trainScheduling/BatchBoardPage";
|
||||||
import BatchScheduleDetailPage from "./pages/trainScheduling/BatchScheduleDetailPage";
|
import BatchScheduleDetailPage from "./pages/trainScheduling/BatchScheduleDetailPage";
|
||||||
@@ -215,34 +216,6 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
{
|
{
|
||||||
title: "Administration",
|
title: "Administration",
|
||||||
items: [
|
items: [
|
||||||
{
|
|
||||||
label: "User management",
|
|
||||||
href: "/dashboard/user-management",
|
|
||||||
icon: <Network />,
|
|
||||||
permission: FREIGHT_PERMS.admin,
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
label: "Users",
|
|
||||||
href: "/dashboard/user-management/users",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Employees",
|
|
||||||
href: "/dashboard/user-management/employees",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Position Types",
|
|
||||||
href: "/dashboard/user-management/position-types",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Permissions",
|
|
||||||
href: "/dashboard/user-management/permissions",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Roles",
|
|
||||||
href: "/dashboard/user-management/roles",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
label: "File settings",
|
label: "File settings",
|
||||||
href: "/dashboard/file-settings",
|
href: "/dashboard/file-settings",
|
||||||
@@ -343,216 +316,210 @@ const App = () => {
|
|||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/auth" element={<LoginPage />} />
|
<Route path="/auth" element={<LoginPage />} />
|
||||||
<Route path='/um/*' element={<UserManagementHostPage />} />
|
<Route path="/um/*" element={<UserManagementHostPage />} />
|
||||||
<Route path="*" element={<Navigate to="/um" replace />} />
|
<Route path="*" element={<Navigate to="/auth" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Navigate to="/dashboard/overview" replace />} />
|
<Route path="/um/*" element={<UserManagementHostPage />} />
|
||||||
<Route path="/dashboard" element={<Navigate to="/dashboard/overview" replace />} />
|
<Route path="/" element={<Navigate to="/dashboard/overview" replace />} />
|
||||||
<Route path="/dashboard" element={<DashboardShell />}>
|
<Route path="/dashboard" element={<Navigate to="/dashboard/overview" replace />} />
|
||||||
<Route path="overview" element={<OverviewPage />} />
|
<Route path="/dashboard" element={<DashboardShell />}>
|
||||||
<Route path="profile" element={<MyProfilePage />} />
|
<Route path="overview" element={<OverviewPage />} />
|
||||||
|
<Route path="profile" element={<MyProfilePage />} />
|
||||||
|
|
||||||
<Route path="booking-requests" element={<BookingRequestsPage />} />
|
<Route path="booking-requests" element={<BookingRequestsPage />} />
|
||||||
<Route
|
<Route
|
||||||
path="payments"
|
path="payments"
|
||||||
element={
|
element={
|
||||||
<RequirePermission permission={FREIGHT_PERMS.bookings.view}>
|
<RequirePermission permission={FREIGHT_PERMS.bookings.view}>
|
||||||
<PaymentsPage />
|
<PaymentsPage />
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route path="booking-requests/new" element={<NewBookingPage />} />
|
<Route path="booking-requests/new" element={<NewBookingPage />} />
|
||||||
<Route path="booking-requests/:id" element={<BookingRequestDetailPage />} />
|
<Route path="booking-requests/:id" element={<BookingRequestDetailPage />} />
|
||||||
<Route
|
<Route
|
||||||
path="booking-requests/:id/contract"
|
path="booking-requests/:id/contract"
|
||||||
element={<BookingContractPage />}
|
element={<BookingContractPage />}
|
||||||
/>
|
/>
|
||||||
<Route path="warehouses" element={<WarehouseListPage />} />
|
<Route path="warehouses" element={<WarehouseListPage />} />
|
||||||
<Route path="warehouses/:id" element={<WarehouseDetailPage />} />
|
<Route path="warehouses/:id" element={<WarehouseDetailPage />} />
|
||||||
<Route path="warehouse-inventory" element={<WarehouseInventoryPage />} />
|
<Route path="warehouse-inventory" element={<WarehouseInventoryPage />} />
|
||||||
<Route path="arrival-queue" element={<ArrivalQueuePage />} />
|
<Route path="arrival-queue" element={<ArrivalQueuePage />} />
|
||||||
<Route path="loading-queue" element={<LoadingQueuePage />} />
|
<Route path="loading-queue" element={<LoadingQueuePage />} />
|
||||||
<Route path="loaded-inventory" element={<LoadedInventoryPage />} />
|
<Route path="loaded-inventory" element={<LoadedInventoryPage />} />
|
||||||
<Route path="dispatch-queue" element={<DispatchQueuePage />} />
|
<Route path="dispatch-queue" element={<DispatchQueuePage />} />
|
||||||
<Route path="inventory-inquiry" element={<InventoryInquiryPage />} />
|
<Route path="inventory-inquiry" element={<InventoryInquiryPage />} />
|
||||||
<Route path="warehouse-rules" element={<WarehouseRulesPage />} />
|
<Route path="warehouse-rules" element={<WarehouseRulesPage />} />
|
||||||
<Route path="warehouse-fee-invoices" element={<WarehouseInvoicesPage />} />
|
<Route path="warehouse-fee-invoices" element={<WarehouseInvoicesPage />} />
|
||||||
<Route path="warehouse-dashboard" element={<WarehouseDashboardPage />} />
|
<Route path="warehouse-dashboard" element={<WarehouseDashboardPage />} />
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="operations/train-scheduling"
|
path="operations/train-scheduling"
|
||||||
element={<Navigate to="/dashboard/operations/train-scheduling-v2" replace />}
|
element={<Navigate to="/dashboard/operations/train-scheduling-v2" replace />}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="operations/batch-board"
|
path="operations/batch-board"
|
||||||
element={
|
element={
|
||||||
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
||||||
<BatchBoardPage />
|
<BatchBoardPage />
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="operations/batch-board/:scheduleId"
|
path="operations/batch-board/:scheduleId"
|
||||||
element={
|
element={
|
||||||
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
||||||
<BatchScheduleDetailPage />
|
<BatchScheduleDetailPage />
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="operations/train-scheduling-v2"
|
path="operations/train-scheduling-v2"
|
||||||
element={
|
element={
|
||||||
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
||||||
<TrainScheduleV2ListPage />
|
<TrainScheduleV2ListPage />
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="operations/train-scheduling-v2/:scheduleId"
|
path="operations/train-scheduling-v2/:scheduleId"
|
||||||
element={
|
element={
|
||||||
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
||||||
<TrainScheduleV2DetailPage />
|
<TrainScheduleV2DetailPage />
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="operations/train-scheduling-v2/:scheduleId/track"
|
path="operations/train-scheduling-v2/:scheduleId/track"
|
||||||
element={
|
element={
|
||||||
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
||||||
<TrainScheduleTrackPage />
|
<TrainScheduleTrackPage />
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="routes"
|
path="routes"
|
||||||
element={
|
element={
|
||||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||||
<RoutesPage />
|
<RoutesPage />
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="locomotives"
|
path="locomotives"
|
||||||
element={
|
element={
|
||||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||||
<FleetResourcePage />
|
<FleetResourcePage />
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="trains"
|
path="trains"
|
||||||
element={
|
element={
|
||||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||||
<FleetResourcePage />
|
<FleetResourcePage />
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="trains/:id"
|
path="trains/:id"
|
||||||
element={
|
element={
|
||||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||||
<TrainDetailPage />
|
<TrainDetailPage />
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="wagons"
|
path="wagons"
|
||||||
element={
|
element={
|
||||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||||
<FleetResourcePage />
|
<FleetResourcePage />
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="containers"
|
path="containers"
|
||||||
element={
|
element={
|
||||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||||
<FleetResourcePage />
|
<FleetResourcePage />
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="cargoes"
|
path="cargoes"
|
||||||
element={
|
element={
|
||||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||||
<FleetResourcePage />
|
<FleetResourcePage />
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* iframe-based user management module */}
|
{/* Legacy embedded user management routes */}
|
||||||
<Route path="um/*" element={<UserManagementHostPage />} />
|
<Route path="user-management" element={<UserManagementPage />} />
|
||||||
|
<Route path="user-management/users" element={<UsersPage />} />
|
||||||
|
<Route path="user-management/position-types" element={<PositionTypesPage />} />
|
||||||
|
{/* <Route path="user-management/employees" element={<EmployeesPage />} /> */}
|
||||||
|
<Route path="user-management/permissions" element={<PermissionsPage />} />
|
||||||
|
<Route path="user-management/roles" element={<RolesPage />} />
|
||||||
|
|
||||||
{/* Legacy embedded user management routes */}
|
<Route
|
||||||
<Route path="user-management" element={<UserManagementPage />} />
|
path="file-settings"
|
||||||
<Route path="user-management/users" element={<UsersPage />} />
|
element={
|
||||||
<Route path="user-management/position-types" element={<PositionTypesPage />} />
|
<RequirePermission permission={FREIGHT_PERMS.admin}>
|
||||||
{/* <Route path="user-management/employees" element={<EmployeesPage />} /> */}
|
<FileUploadSettingsPage />
|
||||||
<Route path="user-management/permissions" element={<PermissionsPage />} />
|
</RequirePermission>
|
||||||
<Route path="user-management/roles" element={<RolesPage />} />
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="dropdown-settings"
|
||||||
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.admin}>
|
||||||
|
<DropdownSettingsPage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="file-settings"
|
path="configuration"
|
||||||
element={
|
element={<Navigate to="/dashboard/configuration/cargo-types" replace />}
|
||||||
<RequirePermission permission={FREIGHT_PERMS.admin}>
|
/>
|
||||||
<FileUploadSettingsPage />
|
<Route
|
||||||
</RequirePermission>
|
path="configuration/train-scheduling-rules"
|
||||||
}
|
element={
|
||||||
/>
|
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
||||||
<Route
|
<TrainSchedulingGlobalRulesPage />
|
||||||
path="dropdown-settings"
|
</RequirePermission>
|
||||||
element={
|
}
|
||||||
<RequirePermission permission={FREIGHT_PERMS.admin}>
|
/>
|
||||||
<DropdownSettingsPage />
|
<Route path="configuration/cargo-types" element={<CargoTypesPage />} />
|
||||||
</RequirePermission>
|
<Route path="configuration/cargo-types/:id" element={<CargoTypesPage />} />
|
||||||
}
|
<Route path="configuration/:resource" element={<RuleEngineResourcePage />} />
|
||||||
/>
|
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="configuration"
|
path="rules"
|
||||||
element={<Navigate to="/dashboard/configuration/cargo-types" replace />}
|
element={<Navigate to="/dashboard/rules/priority-configs" replace />}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route path="rules/:resource" element={<RuleEngineResourcePage />} />
|
||||||
path="configuration/train-scheduling-rules"
|
|
||||||
element={
|
|
||||||
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
|
||||||
<TrainSchedulingGlobalRulesPage />
|
|
||||||
</RequirePermission>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route path="configuration/:resource" element={<RuleEngineResourcePage />} />
|
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="rules"
|
path="rule-engine"
|
||||||
element={<Navigate to="/dashboard/rules/priority-configs" replace />}
|
element={<Navigate to="/dashboard/configuration/cargo-types" replace />}
|
||||||
/>
|
/>
|
||||||
<Route path="rules/:resource" element={<RuleEngineResourcePage />} />
|
<Route path="rule-engine/:resource" element={<RuleEngineLegacyRedirect />} />
|
||||||
|
|
||||||
<Route
|
<Route path="user1" element={<DemoUser1Page />} />
|
||||||
path="rule-engine"
|
<Route path="user2" element={<DemoUser2Page />} />
|
||||||
element={<Navigate to="/dashboard/configuration/cargo-types" replace />}
|
|
||||||
/>
|
|
||||||
<Route path="rule-engine/:resource" element={<RuleEngineLegacyRedirect />} />
|
|
||||||
|
|
||||||
<Route path="user1" element={<DemoUser1Page />} />
|
<Route path="org-structure" element={<Navigate to="/um" replace />} />
|
||||||
<Route path="user2" element={<DemoUser2Page />} />
|
<Route path="org-structure/*" element={<Navigate to="/um" replace />} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
<Route
|
<Route path="*" element={<Navigate to="/dashboard/overview" replace />} />
|
||||||
path="org-structure"
|
|
||||||
element={<Navigate to="/dashboard/user-management" replace />}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="org-structure/*"
|
|
||||||
element={<Navigate to="/dashboard/user-management" replace />}
|
|
||||||
/>
|
|
||||||
</Route>
|
|
||||||
|
|
||||||
<Route path="*" element={<Navigate to="/dashboard/overview" replace />} />
|
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -226,7 +226,7 @@ const FreightSidebar = ({
|
|||||||
return (
|
return (
|
||||||
<Box component="aside" className="fsb-aside">
|
<Box component="aside" className="fsb-aside">
|
||||||
<div className="fsb-brand">
|
<div className="fsb-brand">
|
||||||
<div className="fsb-logo">
|
<div className="fsb-logo">
|
||||||
<Train size={23} color="white" strokeWidth={2.1} />
|
<Train size={23} color="white" strokeWidth={2.1} />
|
||||||
</div>
|
</div>
|
||||||
<Stack gap={1} style={{ minWidth: 0, position: "relative", zIndex: 1 }}>
|
<Stack gap={1} style={{ minWidth: 0, position: "relative", zIndex: 1 }}>
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ export const URL_CONSTANTS = {
|
|||||||
TRAIN_SCHEDULING: {
|
TRAIN_SCHEDULING: {
|
||||||
ELIGIBLE_BOOKINGS: "/train-scheduling/eligible-bookings",
|
ELIGIBLE_BOOKINGS: "/train-scheduling/eligible-bookings",
|
||||||
BOOKABLE_SCHEDULES: "/train-scheduling/bookable-schedules",
|
BOOKABLE_SCHEDULES: "/train-scheduling/bookable-schedules",
|
||||||
|
AVAILABLE_DAYS: "/train-scheduling/available-days",
|
||||||
AVAILABLE_LOCOMOTIVES: "/train-scheduling/available-locomotives",
|
AVAILABLE_LOCOMOTIVES: "/train-scheduling/available-locomotives",
|
||||||
BATCH_BOARD: "/train-scheduling/batch-board",
|
BATCH_BOARD: "/train-scheduling/batch-board",
|
||||||
BATCH_BOARD_DETAIL: (scheduleId: string) =>
|
BATCH_BOARD_DETAIL: (scheduleId: string) =>
|
||||||
|
|||||||
@@ -132,6 +132,29 @@ export const useBookableSchedules = (
|
|||||||
enabled: Boolean(originYardId && destinationYardId),
|
enabled: Boolean(originYardId && destinationYardId),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Day-level pool: which days have an OPEN departure on the route. Staff pick a
|
||||||
|
* day (not a train) when creating a booking; the engine assigns the train.
|
||||||
|
*/
|
||||||
|
export const useAvailableDays = (
|
||||||
|
originYardId?: string | null,
|
||||||
|
destinationYardId?: string | null,
|
||||||
|
) =>
|
||||||
|
useQuery({
|
||||||
|
queryKey: [
|
||||||
|
...QUERY_KEYS.TRAIN_SCHEDULING.ROOT,
|
||||||
|
"available-days",
|
||||||
|
originYardId ?? "",
|
||||||
|
destinationYardId ?? "",
|
||||||
|
],
|
||||||
|
queryFn: () =>
|
||||||
|
trainSchedulingService.getAvailableDays(
|
||||||
|
originYardId ?? undefined,
|
||||||
|
destinationYardId ?? undefined,
|
||||||
|
),
|
||||||
|
enabled: Boolean(originYardId && destinationYardId),
|
||||||
|
});
|
||||||
|
|
||||||
export const useTrainTrack = (id: string | undefined) =>
|
export const useTrainTrack = (id: string | undefined) =>
|
||||||
useQuery({
|
useQuery({
|
||||||
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.track(id ?? ""),
|
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.track(id ?? ""),
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ import toast from "react-hot-toast";
|
|||||||
|
|
||||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||||
import { bookingsService } from "@/services/bookings.service";
|
import { bookingsService } from "@/services/bookings.service";
|
||||||
import { useBookableSchedules } from "@/hooks/trainScheduling/useTrainScheduling";
|
import { useAvailableDays } from "@/hooks/trainScheduling/useTrainScheduling";
|
||||||
import { api } from "@/auth/http";
|
import { api } from "@/auth/http";
|
||||||
import { unwrap } from "@/utils/endpoint";
|
import { unwrap } from "@/utils/endpoint";
|
||||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||||
@@ -194,9 +194,9 @@ export default function NewBookingPage() {
|
|||||||
const [freightType, setFreightType] = useState<FreightType>("CONTAINER");
|
const [freightType, setFreightType] = useState<FreightType>("CONTAINER");
|
||||||
const [originYardId, setOriginYardId] = useState<string | null>(null);
|
const [originYardId, setOriginYardId] = useState<string | null>(null);
|
||||||
const [destinationYardId, setDestinationYardId] = useState<string | null>(null);
|
const [destinationYardId, setDestinationYardId] = useState<string | null>(null);
|
||||||
const [trainScheduleId, setTrainScheduleId] = useState<string | null>(null);
|
|
||||||
const [serviceTypeId, setServiceTypeId] = useState<string | null>(null);
|
const [serviceTypeId, setServiceTypeId] = useState<string | null>(null);
|
||||||
const [scheduledDate, setScheduledDate] = useState("");
|
// Day-level pool: staff pick a DAY (yyyy-MM-dd); the engine assigns the train.
|
||||||
|
const [scheduledDay, setScheduledDay] = useState<string | null>(null);
|
||||||
const [paymentCurrency, setPaymentCurrency] = useState("ETB");
|
const [paymentCurrency, setPaymentCurrency] = useState("ETB");
|
||||||
|
|
||||||
// container freight
|
// container freight
|
||||||
@@ -232,34 +232,37 @@ export default function NewBookingPage() {
|
|||||||
label: c.name || c.email || c.tin || c.id,
|
label: c.name || c.email || c.tin || c.id,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const { data: bookableSchedules, isLoading: schedulesLoading } = useBookableSchedules(
|
// Day-level pool: fetch only the days that have a departure on the route (no
|
||||||
|
// train, no capacity). The batch engine assigns the train after booking.
|
||||||
|
const { data: availableDays, isLoading: daysLoading } = useAvailableDays(
|
||||||
originYardId,
|
originYardId,
|
||||||
destinationYardId,
|
destinationYardId,
|
||||||
);
|
);
|
||||||
const scheduleOptions = (bookableSchedules ?? []).map((s) => ({
|
const dayOptions = (availableDays ?? []).map((day) => ({
|
||||||
value: s.id,
|
value: day,
|
||||||
label: `${s.routeName ?? `${s.origin} → ${s.destination}`} · ${new Date(
|
label: new Date(`${day}T00:00:00`).toLocaleDateString(undefined, {
|
||||||
s.scheduleDate,
|
weekday: "short",
|
||||||
).toLocaleString()} · ${s.remainingWagons}/${s.maxWagons} wagons free`,
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
const selectedSchedule = (bookableSchedules ?? []).find((s) => s.id === trainScheduleId);
|
const hasAvailableDays = (availableDays ?? []).length > 0;
|
||||||
|
|
||||||
// When a schedule is chosen its date IS the departure; otherwise fall back to the manual field.
|
// The chosen day becomes the booking's scheduledDate (start of day, ISO).
|
||||||
const effectiveDepartureIso = selectedSchedule
|
const effectiveDepartureIso = scheduledDay
|
||||||
? new Date(selectedSchedule.scheduleDate).toISOString()
|
? new Date(`${scheduledDay}T00:00:00`).toISOString()
|
||||||
: scheduledDate
|
: "";
|
||||||
? new Date(scheduledDate).toISOString()
|
|
||||||
: "";
|
|
||||||
|
|
||||||
const yardRecords = refData?.yard ?? [];
|
const yardRecords = refData?.yard ?? [];
|
||||||
const yards = yardRecords.map((y) => ({ value: y.id, label: y.name ?? y.code }));
|
const yards = yardRecords.map((y) => ({ value: y.id, label: y.name ?? y.code }));
|
||||||
const originYard = yardRecords.find((y) => y.id === originYardId) ?? null;
|
const originYard = yardRecords.find((y) => y.id === originYardId) ?? null;
|
||||||
const destinationYard = yardRecords.find((y) => y.id === destinationYardId) ?? null;
|
const destinationYard = yardRecords.find((y) => y.id === destinationYardId) ?? null;
|
||||||
const tradeDirection = deriveTradeDirectionFromYards(originYard, destinationYard);
|
const tradeDirection = deriveTradeDirectionFromYards(originYard, destinationYard);
|
||||||
const hasBookableSchedules = (bookableSchedules ?? []).length > 0;
|
|
||||||
|
|
||||||
|
// Reset the day when the route changes — available days depend on the route.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTrainScheduleId(null);
|
setScheduledDay(null);
|
||||||
}, [originYardId, destinationYardId]);
|
}, [originYardId, destinationYardId]);
|
||||||
const services = (refData?.service ?? []).map((s) => ({ value: s.id, label: s.name ?? s.code }));
|
const services = (refData?.service ?? []).map((s) => ({ value: s.id, label: s.name ?? s.code }));
|
||||||
const shippingLines = (refData?.shipping_line ?? []).map((s) => ({ value: s.id, label: s.name ?? s.code }));
|
const shippingLines = (refData?.shipping_line ?? []).map((s) => ({ value: s.id, label: s.name ?? s.code }));
|
||||||
@@ -302,16 +305,15 @@ export default function NewBookingPage() {
|
|||||||
const allLinesValid = lines.length > 0 && lines.every(lineValid);
|
const allLinesValid = lines.length > 0 && lines.every(lineValid);
|
||||||
const sameYard = Boolean(originYardId && originYardId === destinationYardId);
|
const sameYard = Boolean(originYardId && originYardId === destinationYardId);
|
||||||
|
|
||||||
const scheduleSatisfied =
|
// Day-level pool: a shipment DAY is all staff pick. The batch engine assigns
|
||||||
hasBookableSchedules ? Boolean(trainScheduleId) : Boolean(scheduledDate);
|
// the train afterwards (same flow as the customer portal).
|
||||||
const departureSatisfied = Boolean(selectedSchedule) || Boolean(scheduledDate);
|
const departureSatisfied = Boolean(scheduledDay);
|
||||||
|
|
||||||
const canSubmit =
|
const canSubmit =
|
||||||
Boolean(originYardId) &&
|
Boolean(originYardId) &&
|
||||||
Boolean(destinationYardId) &&
|
Boolean(destinationYardId) &&
|
||||||
!sameYard &&
|
!sameYard &&
|
||||||
Boolean(tradeDirection) &&
|
Boolean(tradeDirection) &&
|
||||||
scheduleSatisfied &&
|
|
||||||
Boolean(serviceTypeId) &&
|
Boolean(serviceTypeId) &&
|
||||||
departureSatisfied &&
|
departureSatisfied &&
|
||||||
(isGovernment ? governmentInstitution.trim().length >= 2 : Boolean(companyId)) &&
|
(isGovernment ? governmentInstitution.trim().length >= 2 : Boolean(companyId)) &&
|
||||||
@@ -339,7 +341,7 @@ export default function NewBookingPage() {
|
|||||||
scheduledDate: effectiveDepartureIso || new Date().toISOString(),
|
scheduledDate: effectiveDepartureIso || new Date().toISOString(),
|
||||||
originYardId,
|
originYardId,
|
||||||
destinationYardId,
|
destinationYardId,
|
||||||
trainScheduleId: trainScheduleId || undefined,
|
// Day-level pool: no trainScheduleId — the engine assigns the train.
|
||||||
serviceTypeId,
|
serviceTypeId,
|
||||||
shippingLineId: shippingLineId || undefined,
|
shippingLineId: shippingLineId || undefined,
|
||||||
firstMilePickupAddress: firstMilePickupAddress.trim() || undefined,
|
firstMilePickupAddress: firstMilePickupAddress.trim() || undefined,
|
||||||
@@ -464,36 +466,30 @@ export default function NewBookingPage() {
|
|||||||
value={destinationYardId}
|
value={destinationYardId}
|
||||||
onChange={(v) => {
|
onChange={(v) => {
|
||||||
setDestinationYardId(v);
|
setDestinationYardId(v);
|
||||||
setTrainScheduleId(null);
|
setScheduledDay(null);
|
||||||
}}
|
}}
|
||||||
searchable
|
searchable
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
error={sameYard ? "Same as origin" : undefined}
|
error={sameYard ? "Same as origin" : undefined}
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
{hasBookableSchedules ? (
|
<Select
|
||||||
<Select
|
label="Shipment day"
|
||||||
label="Train schedule"
|
placeholder={
|
||||||
placeholder={
|
originYardId && destinationYardId
|
||||||
originYardId && destinationYardId
|
? "Select a day with a departure"
|
||||||
? "Select an open schedule on this route"
|
: "Pick origin & destination first"
|
||||||
: "Pick origin & destination first"
|
}
|
||||||
}
|
data={dayOptions}
|
||||||
data={scheduleOptions}
|
value={scheduledDay}
|
||||||
value={trainScheduleId}
|
onChange={setScheduledDay}
|
||||||
onChange={setTrainScheduleId}
|
searchable
|
||||||
searchable
|
disabled={!originYardId || !destinationYardId || daysLoading}
|
||||||
required
|
nothingFoundMessage={
|
||||||
disabled={!originYardId || !destinationYardId || schedulesLoading}
|
hasAvailableDays ? "No match" : "No departures on this route"
|
||||||
nothingFoundMessage="No open schedules on this route"
|
}
|
||||||
description="The booking will be batched against this schedule once its contract is signed."
|
description="Pick a day with a departure. The batch engine assigns the train by priority."
|
||||||
/>
|
/>
|
||||||
) : originYardId && destinationYardId ? (
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
No open train schedule on this route — set a preferred departure below. Staff can
|
|
||||||
link a schedule later.
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
<Group grow align="flex-end">
|
<Group grow align="flex-end">
|
||||||
<Select
|
<Select
|
||||||
label="Service type"
|
label="Service type"
|
||||||
@@ -528,21 +524,22 @@ export default function NewBookingPage() {
|
|||||||
|
|
||||||
<FormSection icon={CalendarClock} title="Schedule & payment" accent="grape">
|
<FormSection icon={CalendarClock} title="Schedule & payment" accent="grape">
|
||||||
<Group grow align="flex-start">
|
<Group grow align="flex-start">
|
||||||
{selectedSchedule ? (
|
<TextInput
|
||||||
<TextInput
|
label="Shipment day"
|
||||||
label="Departure"
|
value={
|
||||||
value={new Date(selectedSchedule.scheduleDate).toLocaleString()}
|
scheduledDay
|
||||||
readOnly
|
? new Date(`${scheduledDay}T00:00:00`).toLocaleDateString(undefined, {
|
||||||
description="Taken from the selected train schedule"
|
weekday: "short",
|
||||||
/>
|
year: "numeric",
|
||||||
) : (
|
month: "short",
|
||||||
<TextInput
|
day: "numeric",
|
||||||
label="Preferred departure"
|
})
|
||||||
type="datetime-local"
|
: ""
|
||||||
value={scheduledDate}
|
}
|
||||||
onChange={(e) => setScheduledDate(e.target.value)}
|
placeholder="Pick a day in the Route section"
|
||||||
/>
|
readOnly
|
||||||
)}
|
description="The engine assigns the train on this day"
|
||||||
|
/>
|
||||||
<Select
|
<Select
|
||||||
label="Payment currency"
|
label="Payment currency"
|
||||||
data={[
|
data={[
|
||||||
|
|||||||
@@ -1,113 +1,82 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef } from "react";
|
||||||
import { useNavigate, useLocation } from 'react-router-dom';
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
import { getCookie } from '@/auth/cookies';
|
import {
|
||||||
|
UserManagementApp,
|
||||||
|
type UserManagementRuntimeOptions,
|
||||||
|
type UserManagementSessionSeed,
|
||||||
|
} from "@tria-plc/iamui";
|
||||||
|
|
||||||
function readToken(): string | null {
|
import { getCookie } from "@/auth/cookies";
|
||||||
return getCookie('auth-token') ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readRefreshToken(): string | null {
|
import { iamConfig } from "./iamConfig";
|
||||||
return getCookie('refresh-token') ?? null;
|
|
||||||
|
function readInitialSession(): UserManagementSessionSeed | null {
|
||||||
|
const token = getCookie("auth-token");
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshToken = getCookie("refresh-token") ?? undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
token,
|
||||||
|
refreshToken,
|
||||||
|
rememberMe: true,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function UserManagementHostPage() {
|
export default function UserManagementHostPage() {
|
||||||
const navigate = useNavigate();
|
const mountRef = useRef<HTMLDivElement | null>(null);
|
||||||
const location = useLocation();
|
const rootRef = useRef<Root | null>(null);
|
||||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
const unmountTimerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
const mountBase = (
|
|
||||||
(import.meta.env.VITE_USER_MANAGEMENT_BASE as string | undefined) ?? '/_um'
|
|
||||||
).replace(/\/$/, '');
|
|
||||||
|
|
||||||
const moduleOrigin = window.location.origin;
|
|
||||||
|
|
||||||
const [iframeSrc] = useState(() => {
|
|
||||||
const sub = location.pathname.replace(/^\/(?:dashboard\/)?um(?=\/|$)/, '');
|
|
||||||
return mountBase + (sub || '/') + location.search;
|
|
||||||
});
|
|
||||||
|
|
||||||
// ✅ Send token when iframe loads
|
|
||||||
const handleIframeLoad = () => {
|
|
||||||
const token = readToken();
|
|
||||||
const refreshToken = readRefreshToken();
|
|
||||||
const target = iframeRef.current?.contentWindow;
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
console.warn('⚠️ No authentication token found');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!target) {
|
|
||||||
console.warn('⚠️ No iframe reference');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
target.postMessage(
|
|
||||||
{
|
|
||||||
type: 'UM_AUTH_TOKEN',
|
|
||||||
token,
|
|
||||||
refreshToken,
|
|
||||||
},
|
|
||||||
moduleOrigin
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log('✅ Token sent to iframe module');
|
|
||||||
};
|
|
||||||
|
|
||||||
// ✅ Listen for messages from iframe
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onMessage = (event: MessageEvent) => {
|
const mountNode = mountRef.current;
|
||||||
// Security: Only accept from same origin
|
|
||||||
if (event.origin !== moduleOrigin) {
|
|
||||||
console.warn('🚫 Blocked message from different origin:', event.origin);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = event.data as { type?: string; path?: string } | undefined;
|
if (!mountNode) {
|
||||||
if (!data) return;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Handle auth request (if module asks for token again)
|
if (unmountTimerRef.current !== null) {
|
||||||
if (data.type === 'UM_REQUEST_AUTH') {
|
window.clearTimeout(unmountTimerRef.current);
|
||||||
const token = readToken();
|
unmountTimerRef.current = null;
|
||||||
const refreshToken = readRefreshToken();
|
}
|
||||||
const target = iframeRef.current?.contentWindow;
|
|
||||||
|
|
||||||
if (token && target) {
|
if (!rootRef.current) {
|
||||||
target.postMessage(
|
rootRef.current = createRoot(mountNode);
|
||||||
{
|
}
|
||||||
type: 'UM_AUTH_TOKEN',
|
|
||||||
token,
|
|
||||||
refreshToken,
|
|
||||||
},
|
|
||||||
moduleOrigin
|
|
||||||
);
|
|
||||||
console.log('✅ Token resent to iframe (on request)');
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle route synchronization
|
const apiBaseUrl = import.meta.env.VITE_BASE_API_URL.replace(/\/+$/, "");
|
||||||
if (data.type === 'UM_ROUTE_CHANGED' && typeof data.path === 'string') {
|
const iamApiUrl = "/um-api";
|
||||||
const target = '/dashboard/um' + data.path;
|
const runtime: UserManagementRuntimeOptions = {
|
||||||
if (window.location.pathname + window.location.search !== target) {
|
basename: "/um",
|
||||||
navigate(target, { replace: true });
|
apiBaseUrl,
|
||||||
}
|
apiUrl: iamApiUrl,
|
||||||
}
|
recordApiUrl: iamApiUrl,
|
||||||
|
chronicleUrl: iamApiUrl,
|
||||||
|
auditApiUrl: iamApiUrl,
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('message', onMessage);
|
rootRef.current.render(
|
||||||
return () => window.removeEventListener('message', onMessage);
|
<UserManagementApp
|
||||||
}, [moduleOrigin, navigate]);
|
config={iamConfig}
|
||||||
|
runtime={runtime}
|
||||||
|
session={{
|
||||||
|
initialSession: readInitialSession(),
|
||||||
|
enableEmbeddedAuthBridge: false,
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return () => {
|
||||||
<div style={{ position: 'fixed', inset: 0 }}>
|
unmountTimerRef.current = window.setTimeout(() => {
|
||||||
<iframe
|
rootRef.current?.unmount();
|
||||||
ref={iframeRef}
|
rootRef.current = null;
|
||||||
title="User Management"
|
unmountTimerRef.current = null;
|
||||||
src={iframeSrc}
|
}, 0);
|
||||||
onLoad={handleIframeLoad}
|
};
|
||||||
style={{ width: '100%', height: '100%', border: 0, display: 'block' }}
|
}, []);
|
||||||
/>
|
|
||||||
</div>
|
return <div ref={mountRef} style={{ position: "fixed", inset: 0 }} />;
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
import type { DesignConfig } from "@tria-plc/iamui";
|
||||||
|
|
||||||
|
import {
|
||||||
|
FREIGHT_BRAND,
|
||||||
|
FREIGHT_BRAND_DARK,
|
||||||
|
FREIGHT_BRAND_LIGHT,
|
||||||
|
freightBrand,
|
||||||
|
} from "@/theme/freight-brand";
|
||||||
|
|
||||||
|
export const iamConfig: DesignConfig = {
|
||||||
|
brand: {
|
||||||
|
appName: "EDR Freight Backoffice",
|
||||||
|
logoUrl: "/assets/logo.svg",
|
||||||
|
},
|
||||||
|
colors: {
|
||||||
|
primary: FREIGHT_BRAND,
|
||||||
|
primaryForeground: "#ffffff",
|
||||||
|
secondary: "#f4f7fb",
|
||||||
|
background: "#f7f9fb",
|
||||||
|
foreground: "#0f172a",
|
||||||
|
border: "#eef1f4",
|
||||||
|
muted: "#f1f5f9",
|
||||||
|
mutedForeground: "#64748b",
|
||||||
|
card: "#ffffff",
|
||||||
|
sidebar: "#ffffff",
|
||||||
|
danger: "#ef4444",
|
||||||
|
},
|
||||||
|
typography: {
|
||||||
|
fontFamily: "'Outfit', var(--font-sans), system-ui, sans-serif",
|
||||||
|
headingFontFamily: "'Outfit', var(--font-sans), system-ui, sans-serif",
|
||||||
|
baseFontSize: "15px",
|
||||||
|
fontWeight: "500",
|
||||||
|
},
|
||||||
|
shape: {
|
||||||
|
radius: "1rem",
|
||||||
|
},
|
||||||
|
shadows: {
|
||||||
|
card: "0 1px 2px rgba(15, 23, 42, 0.04), 0 8px 24px -16px rgba(15, 23, 42, 0.12)",
|
||||||
|
dropdown: "0 12px 30px rgba(15, 23, 42, 0.12)",
|
||||||
|
modal: "0 20px 45px rgba(15, 23, 42, 0.2)",
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
buttonDefaultVariant: "filled",
|
||||||
|
inputDefaultSize: "sm",
|
||||||
|
inputRadius: "md",
|
||||||
|
modalRadius: "lg",
|
||||||
|
tableHighlightOnHover: true,
|
||||||
|
},
|
||||||
|
layout: {
|
||||||
|
userManagementView: "classic",
|
||||||
|
showTopBar: true,
|
||||||
|
sidebarWidth: "280px",
|
||||||
|
sidebarCollapsedWidth: "80px",
|
||||||
|
headerHeight: "80px",
|
||||||
|
contentMaxWidth: "none",
|
||||||
|
sidebarBackground: "#ffffff",
|
||||||
|
sidebarColor: "#475569",
|
||||||
|
sidebarMutedColor: "#94a3b8",
|
||||||
|
sidebarActiveBackground:
|
||||||
|
"linear-gradient(135deg, rgba(45, 191, 149, 0.14) 0%, rgba(27, 158, 122, 0.06) 100%)",
|
||||||
|
sidebarActiveColor: FREIGHT_BRAND,
|
||||||
|
sidebarHoverBackground: "#f5f7fa",
|
||||||
|
sidebarBorder: "#eef1f4",
|
||||||
|
sidebarRail: `linear-gradient(180deg, ${FREIGHT_BRAND_LIGHT} 0%, ${FREIGHT_BRAND} 100%)`,
|
||||||
|
sidebarBrandLabel: "EDR Freight",
|
||||||
|
sidebarBrandSublabel: "Backoffice Console",
|
||||||
|
menuBackground: "#ffffff",
|
||||||
|
menuActiveColor: FREIGHT_BRAND,
|
||||||
|
menuActiveBorderColor: FREIGHT_BRAND,
|
||||||
|
menuColor: "#64748b",
|
||||||
|
menuHoverColor: "#0f172a",
|
||||||
|
modalAccentColor: `linear-gradient(135deg, ${FREIGHT_BRAND_LIGHT} 0%, ${FREIGHT_BRAND} 100%)`,
|
||||||
|
modalHeaderBackground: "#ffffff",
|
||||||
|
modalHeaderEditBackground: "#ffffff",
|
||||||
|
modalIconBackground: freightBrand.mutedBg,
|
||||||
|
modalIconColor: FREIGHT_BRAND,
|
||||||
|
modalTitleColor: "#0f172a",
|
||||||
|
modalFocusColor: FREIGHT_BRAND,
|
||||||
|
modalSurface: "#ffffff",
|
||||||
|
},
|
||||||
|
appearance: {
|
||||||
|
colorScheme: "light",
|
||||||
|
slots: {
|
||||||
|
root: {
|
||||||
|
styles: {
|
||||||
|
background: "#f7f9fb",
|
||||||
|
color: "#0f172a",
|
||||||
|
fontFamily: "'Outfit', var(--font-sans), system-ui, sans-serif",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
shell: {
|
||||||
|
styles: {
|
||||||
|
background: "#f7f9fb",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
styles: {
|
||||||
|
background: "#f7f9fb",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
page: {
|
||||||
|
styles: {
|
||||||
|
background: "#ffffff",
|
||||||
|
border: "1px solid #eef1f4",
|
||||||
|
borderRadius: "24px",
|
||||||
|
boxShadow:
|
||||||
|
"0 1px 2px rgba(15, 23, 42, 0.04), 0 8px 24px -16px rgba(15, 23, 42, 0.12)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
styles: {
|
||||||
|
background: "#ffffff",
|
||||||
|
border: "1px solid #eef1f4",
|
||||||
|
borderRadius: "20px",
|
||||||
|
boxShadow:
|
||||||
|
"0 1px 2px rgba(15, 23, 42, 0.04), 0 8px 24px -16px rgba(15, 23, 42, 0.12)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
sidebar: {
|
||||||
|
styles: {
|
||||||
|
background: "#ffffff",
|
||||||
|
border: "1px solid #eef1f4",
|
||||||
|
borderRadius: "16px",
|
||||||
|
boxShadow:
|
||||||
|
"0 1px 2px rgba(15, 23, 42, 0.04), 0 8px 24px -16px rgba(15, 23, 42, 0.12)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"sidebar-brand": {
|
||||||
|
styles: {
|
||||||
|
minHeight: "80px",
|
||||||
|
borderBottom: "1px solid #f1f5f9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
topbar: {
|
||||||
|
styles: {
|
||||||
|
background: "#ffffff",
|
||||||
|
border: "1px solid #eef1f4",
|
||||||
|
borderRadius: "16px",
|
||||||
|
boxShadow:
|
||||||
|
"0 1px 2px rgba(15, 23, 42, 0.04), 0 8px 24px -16px rgba(15, 23, 42, 0.12)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"topbar-panel": {
|
||||||
|
styles: {
|
||||||
|
background: "#f7f9fb",
|
||||||
|
border: "1px solid #eef1f4",
|
||||||
|
borderRadius: "12px",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"topbar-user-summary": {
|
||||||
|
styles: {
|
||||||
|
borderRadius: "14px",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
table: {
|
||||||
|
styles: {
|
||||||
|
background: "#ffffff",
|
||||||
|
border: "1px solid #eef1f4",
|
||||||
|
borderRadius: "20px",
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"table-header": {
|
||||||
|
styles: {
|
||||||
|
background: "#f8fafc",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
modal: {
|
||||||
|
styles: {
|
||||||
|
borderRadius: "24px",
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"modal-header": {
|
||||||
|
styles: {
|
||||||
|
background: "#ffffff",
|
||||||
|
borderBottom: "1px solid #eef1f4",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
customCss: `
|
||||||
|
[data-um-app="user-management"] {
|
||||||
|
--um-page-gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-um-app="user-management"] h1,
|
||||||
|
[data-um-app="user-management"] h2,
|
||||||
|
[data-um-app="user-management"] h3,
|
||||||
|
[data-um-app="user-management"] h4,
|
||||||
|
[data-um-app="user-management"] h5,
|
||||||
|
[data-um-app="user-management"] h6 {
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-um-app="user-management"] [data-um-slot="sidebar-item"][aria-current="page"] {
|
||||||
|
box-shadow: inset 3px 0 0 ${FREIGHT_BRAND};
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-um-app="user-management"] button,
|
||||||
|
[data-um-app="user-management"] input,
|
||||||
|
[data-um-app="user-management"] select,
|
||||||
|
[data-um-app="user-management"] textarea {
|
||||||
|
font-family: 'Outfit', var(--font-sans), system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -12,9 +12,11 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
|
import { Badge as MantineBadge } from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
CircleDollarSign,
|
CircleDollarSign,
|
||||||
|
LayoutGrid,
|
||||||
Loader2,
|
Loader2,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
Search,
|
Search,
|
||||||
@@ -24,6 +26,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||||
|
import "@/components/overview/overview.css";
|
||||||
import { usePaymentList, usePaymentSummary } from "@/hooks/usePayments";
|
import { usePaymentList, usePaymentSummary } from "@/hooks/usePayments";
|
||||||
import type {
|
import type {
|
||||||
PaymentMethod,
|
PaymentMethod,
|
||||||
@@ -39,11 +42,16 @@ import {
|
|||||||
} from "@edr/ui-common";
|
} from "@edr/ui-common";
|
||||||
|
|
||||||
const STATUS_TABS = [
|
const STATUS_TABS = [
|
||||||
{ key: "all", label: "All", statuses: undefined as string | undefined },
|
{ key: "all", label: "All", statuses: undefined as string | undefined, icon: LayoutGrid },
|
||||||
{ key: "success", label: "Success", statuses: "success" },
|
{ key: "success", label: "Success", statuses: "success", icon: CheckCircle2 },
|
||||||
{ key: "processing", label: "Processing", statuses: "processing,action-required" },
|
{
|
||||||
{ key: "failed", label: "Failed", statuses: "failed,canceled" },
|
key: "processing",
|
||||||
{ key: "refunded", label: "Refunded", statuses: "refunded" },
|
label: "Processing",
|
||||||
|
statuses: "processing,action-required",
|
||||||
|
icon: Loader2,
|
||||||
|
},
|
||||||
|
{ key: "failed", label: "Failed", statuses: "failed,canceled", icon: XCircle },
|
||||||
|
{ key: "refunded", label: "Refunded", statuses: "refunded", icon: RotateCcw },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
type StatusTabKey = (typeof STATUS_TABS)[number]["key"];
|
type StatusTabKey = (typeof STATUS_TABS)[number]["key"];
|
||||||
@@ -166,6 +174,20 @@ export default function PaymentsPage() {
|
|||||||
|
|
||||||
const val = (n?: number) => (summaryLoading ? "—" : (n ?? 0));
|
const val = (n?: number) => (summaryLoading ? "—" : (n ?? 0));
|
||||||
|
|
||||||
|
const tabCounts: Record<StatusTabKey, number | undefined> = {
|
||||||
|
all:
|
||||||
|
summary === undefined
|
||||||
|
? undefined
|
||||||
|
: (summary.success ?? 0) +
|
||||||
|
(summary.processing ?? 0) +
|
||||||
|
(summary.failed ?? 0) +
|
||||||
|
(summary.refunded ?? 0),
|
||||||
|
success: summary?.success,
|
||||||
|
processing: summary?.processing,
|
||||||
|
failed: summary?.failed,
|
||||||
|
refunded: summary?.refunded,
|
||||||
|
};
|
||||||
|
|
||||||
const columns: ColumnDef<PaymentRow>[] = [
|
const columns: ColumnDef<PaymentRow>[] = [
|
||||||
{
|
{
|
||||||
id: "order",
|
id: "order",
|
||||||
@@ -274,13 +296,48 @@ export default function PaymentsPage() {
|
|||||||
setStatusTab((value as StatusTabKey) ?? "all");
|
setStatusTab((value as StatusTabKey) ?? "all");
|
||||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||||
}}
|
}}
|
||||||
|
variant="pills"
|
||||||
|
color="green"
|
||||||
|
keepMounted={false}
|
||||||
|
classNames={{ list: "ov-tablist", tab: "ov-tab" }}
|
||||||
>
|
>
|
||||||
<Tabs.List>
|
<Tabs.List>
|
||||||
{STATUS_TABS.map((t) => (
|
{STATUS_TABS.map((t) => {
|
||||||
<Tabs.Tab key={t.key} value={t.key}>
|
const isActive = statusTab === t.key;
|
||||||
{t.label}
|
const count = tabCounts[t.key];
|
||||||
</Tabs.Tab>
|
const Icon = t.icon;
|
||||||
))}
|
return (
|
||||||
|
<Tabs.Tab
|
||||||
|
key={t.key}
|
||||||
|
value={t.key}
|
||||||
|
leftSection={<Icon size={17} strokeWidth={1.85} />}
|
||||||
|
rightSection={
|
||||||
|
count !== undefined ? (
|
||||||
|
<MantineBadge
|
||||||
|
size="sm"
|
||||||
|
radius="sm"
|
||||||
|
variant={isActive ? "white" : "light"}
|
||||||
|
color={isActive ? "green" : "gray"}
|
||||||
|
styles={
|
||||||
|
isActive
|
||||||
|
? {
|
||||||
|
root: {
|
||||||
|
background: "rgba(255,255,255,0.9)",
|
||||||
|
color: "#15805f",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{count}
|
||||||
|
</MantineBadge>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</Tabs.Tab>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,512 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { Navigate, useNavigate, useParams } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Box,
|
||||||
|
Breadcrumbs,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
Modal,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
ThemeIcon,
|
||||||
|
Tooltip,
|
||||||
|
UnstyledButton,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import {
|
||||||
|
Boxes,
|
||||||
|
ChevronRight,
|
||||||
|
FileText,
|
||||||
|
Home,
|
||||||
|
Layers,
|
||||||
|
Package,
|
||||||
|
Pencil,
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
ShieldCheck,
|
||||||
|
Trash2,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import { useAuth } from "@/auth/useAuth";
|
||||||
|
import { canAccessRuleEngineResource } from "@/lib/permissions";
|
||||||
|
import RuleEngineFormDialog from "@/components/ruleEngine/RuleEngineFormDialog";
|
||||||
|
import {
|
||||||
|
getRuleEngineResource,
|
||||||
|
type FormFieldDef,
|
||||||
|
} from "@/pages/ruleEngine/config/resources";
|
||||||
|
import {
|
||||||
|
useRuleEngineList,
|
||||||
|
useRuleEngineMutations,
|
||||||
|
} from "@/hooks/rule-engine/useRuleEngine";
|
||||||
|
import type { RuleEngineRecord } from "@/types/rule-engine";
|
||||||
|
|
||||||
|
const CARGO_SLUG = "cargo-types";
|
||||||
|
const BASE_PATH = "/dashboard/configuration/cargo-types";
|
||||||
|
|
||||||
|
interface CargoNode extends RuleEngineRecord {
|
||||||
|
cargoTypeName?: string;
|
||||||
|
code?: string;
|
||||||
|
parentGroupId?: string | null;
|
||||||
|
showFreeTextBox?: boolean;
|
||||||
|
requiresDirectorApproval?: boolean;
|
||||||
|
isActive?: boolean;
|
||||||
|
displayOrder?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const str = (v: unknown): string => (v == null ? "" : String(v));
|
||||||
|
const orderOf = (n: CargoNode): number => Number(n.displayOrder ?? 0);
|
||||||
|
|
||||||
|
/** Create/edit form fields. Parent is set from the current page, never picked. */
|
||||||
|
const FORM_FIELDS: FormFieldDef[] = [
|
||||||
|
{ name: "cargoTypeName", label: "Cargo type name", type: "text", required: true },
|
||||||
|
{ name: "showFreeTextBox", label: "Show free text box", type: "boolean" },
|
||||||
|
{ name: "requiresDirectorApproval", label: "Requires director approval", type: "boolean" },
|
||||||
|
{ name: "isActive", label: "Active", type: "boolean" },
|
||||||
|
];
|
||||||
|
|
||||||
|
type FormMode = { kind: "create" } | { kind: "edit"; record: CargoNode };
|
||||||
|
|
||||||
|
const CargoTypesPage = () => {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { id: currentId } = useParams<{ id: string }>();
|
||||||
|
const config = getRuleEngineResource(CARGO_SLUG);
|
||||||
|
|
||||||
|
const canView = canAccessRuleEngineResource(user, CARGO_SLUG, "view");
|
||||||
|
const canManage = canAccessRuleEngineResource(user, CARGO_SLUG, "manage");
|
||||||
|
|
||||||
|
// One fetch of the whole (small) set; the tree, ancestry and each level are
|
||||||
|
// derived client-side so drilling between levels is instant.
|
||||||
|
const { data, isLoading, isError } = useRuleEngineList(CARGO_SLUG, {
|
||||||
|
page: 1,
|
||||||
|
pageSize: 500,
|
||||||
|
sortBy: "displayOrder",
|
||||||
|
sortOrder: "ASC",
|
||||||
|
});
|
||||||
|
|
||||||
|
const { create, update, remove } = useRuleEngineMutations(CARGO_SLUG);
|
||||||
|
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [formMode, setFormMode] = useState<FormMode | null>(null);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<CargoNode | null>(null);
|
||||||
|
|
||||||
|
const all = (data?.data ?? []) as CargoNode[];
|
||||||
|
|
||||||
|
const { byId, childrenOf } = useMemo(() => {
|
||||||
|
const byId = new Map<string, CargoNode>(all.map((n) => [n.id, n]));
|
||||||
|
const childrenOf = new Map<string, CargoNode[]>();
|
||||||
|
for (const node of all) {
|
||||||
|
const parentId = node.parentGroupId && byId.has(node.parentGroupId) ? node.parentGroupId : "";
|
||||||
|
const key = parentId || "__root__";
|
||||||
|
const list = childrenOf.get(key) ?? [];
|
||||||
|
list.push(node);
|
||||||
|
childrenOf.set(key, list);
|
||||||
|
}
|
||||||
|
for (const list of childrenOf.values()) {
|
||||||
|
list.sort(
|
||||||
|
(a, b) =>
|
||||||
|
orderOf(a) - orderOf(b) ||
|
||||||
|
str(a.cargoTypeName).localeCompare(str(b.cargoTypeName)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { byId, childrenOf };
|
||||||
|
}, [all]);
|
||||||
|
|
||||||
|
// Current node (null at root) and its ancestor chain for the breadcrumb.
|
||||||
|
const current = currentId ? byId.get(currentId) ?? null : null;
|
||||||
|
const ancestors = useMemo(() => {
|
||||||
|
const chain: CargoNode[] = [];
|
||||||
|
let node = current;
|
||||||
|
const seen = new Set<string>();
|
||||||
|
while (node && !seen.has(node.id)) {
|
||||||
|
chain.unshift(node);
|
||||||
|
seen.add(node.id);
|
||||||
|
node = node.parentGroupId ? byId.get(node.parentGroupId) ?? null : null;
|
||||||
|
}
|
||||||
|
return chain;
|
||||||
|
}, [current, byId]);
|
||||||
|
|
||||||
|
const levelKey = current ? current.id : "__root__";
|
||||||
|
const levelNodes = childrenOf.get(levelKey) ?? [];
|
||||||
|
|
||||||
|
const term = search.trim().toLowerCase();
|
||||||
|
const matches = (n: CargoNode) =>
|
||||||
|
!term ||
|
||||||
|
str(n.cargoTypeName).toLowerCase().includes(term) ||
|
||||||
|
str(n.code).toLowerCase().includes(term);
|
||||||
|
const visibleNodes = useMemo(
|
||||||
|
() => (term ? levelNodes.filter(matches) : levelNodes),
|
||||||
|
[levelNodes, term],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!config) return <Navigate to="/dashboard/overview" replace />;
|
||||||
|
if (!canView) return <Navigate to="/dashboard/overview" replace />;
|
||||||
|
// A bad/stale :id (after data loads) → fall back to the root list.
|
||||||
|
if (!isLoading && currentId && !current) return <Navigate to={BASE_PATH} replace />;
|
||||||
|
|
||||||
|
const atRoot = !current;
|
||||||
|
const countAtRoot = (childrenOf.get("__root__") ?? []).length;
|
||||||
|
|
||||||
|
const handleSubmit = (values: Record<string, unknown>) => {
|
||||||
|
const payload: Record<string, unknown> = { ...values };
|
||||||
|
// Add always attaches to the page we're on; edit keeps the node's parent.
|
||||||
|
if (formMode?.kind === "create" && current) {
|
||||||
|
payload.parentGroupId = current.id;
|
||||||
|
}
|
||||||
|
const done = () => setFormMode(null);
|
||||||
|
if (formMode?.kind === "edit") {
|
||||||
|
update.mutate({ id: formMode.record.id, payload }, { onSuccess: done });
|
||||||
|
} else {
|
||||||
|
create.mutate(payload, { onSuccess: done });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addLabel = atRoot ? "Add category" : "Add cargo type";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="lg">
|
||||||
|
{/* ── Header ─────────────────────────────────────────────── */}
|
||||||
|
<Card
|
||||||
|
p="lg"
|
||||||
|
radius="lg"
|
||||||
|
withBorder
|
||||||
|
style={{ background: "white", boxShadow: "0 1px 3px rgba(0,0,0,0.05)" }}
|
||||||
|
>
|
||||||
|
{/* Breadcrumb */}
|
||||||
|
<Breadcrumbs
|
||||||
|
separator={<ChevronRight size={14} style={{ color: "var(--mantine-color-gray-5)" }} />}
|
||||||
|
mb="md"
|
||||||
|
>
|
||||||
|
<UnstyledButton onClick={() => navigate(BASE_PATH)}>
|
||||||
|
<Group gap={5} wrap="nowrap">
|
||||||
|
<Home size={14} style={{ color: "var(--mantine-color-teal-7)" }} />
|
||||||
|
<Text fz={13} fw={600} c={atRoot ? "dark.7" : "teal.7"}>
|
||||||
|
Cargo Types
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</UnstyledButton>
|
||||||
|
{ancestors.map((node, i) => {
|
||||||
|
const isLast = i === ancestors.length - 1;
|
||||||
|
return (
|
||||||
|
<UnstyledButton
|
||||||
|
key={node.id}
|
||||||
|
onClick={() => !isLast && navigate(`${BASE_PATH}/${node.id}`)}
|
||||||
|
style={{ cursor: isLast ? "default" : "pointer" }}
|
||||||
|
>
|
||||||
|
<Text fz={13} fw={isLast ? 700 : 600} c={isLast ? "dark.7" : "teal.7"} truncate maw={220}>
|
||||||
|
{str(node.cargoTypeName) || "Untitled"}
|
||||||
|
</Text>
|
||||||
|
</UnstyledButton>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Breadcrumbs>
|
||||||
|
|
||||||
|
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
|
||||||
|
<Group gap="md" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||||
|
<ThemeIcon size={48} radius="md" variant="light" color="teal">
|
||||||
|
{atRoot ? <Boxes size={26} /> : <Layers size={26} />}
|
||||||
|
</ThemeIcon>
|
||||||
|
<Box style={{ minWidth: 0 }}>
|
||||||
|
<Group gap={8} wrap="nowrap">
|
||||||
|
<Text fw={800} fz={22} c="dark.8" truncate>
|
||||||
|
{atRoot ? "Cargo Types" : str(current?.cargoTypeName) || "Untitled"}
|
||||||
|
</Text>
|
||||||
|
{!atRoot && current?.code ? (
|
||||||
|
<Badge variant="default" radius="sm">
|
||||||
|
{str(current.code)}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
{!atRoot && current?.isActive === false ? (
|
||||||
|
<Badge variant="light" color="gray" radius="sm">
|
||||||
|
Inactive
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
|
<Text fz={13} c="dimmed" mt={2}>
|
||||||
|
{atRoot
|
||||||
|
? `${countAtRoot} top-level categor${countAtRoot === 1 ? "y" : "ies"} — click one to see what's inside`
|
||||||
|
: `${levelNodes.length} cargo type${levelNodes.length === 1 ? "" : "s"} directly under this category`}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group gap="sm" wrap="nowrap">
|
||||||
|
<TextInput
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||||
|
placeholder="Search this level…"
|
||||||
|
leftSection={<Search size={16} />}
|
||||||
|
w={240}
|
||||||
|
/>
|
||||||
|
{canManage && (
|
||||||
|
<Button
|
||||||
|
color="teal"
|
||||||
|
leftSection={<Plus size={16} />}
|
||||||
|
onClick={() => setFormMode({ kind: "create" })}
|
||||||
|
>
|
||||||
|
{addLabel}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* ── Level list ─────────────────────────────────────────── */}
|
||||||
|
<Card
|
||||||
|
p={0}
|
||||||
|
radius="lg"
|
||||||
|
withBorder
|
||||||
|
style={{
|
||||||
|
background: "white",
|
||||||
|
boxShadow: "0 1px 3px rgba(0,0,0,0.05)",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<Group justify="center" p="xl">
|
||||||
|
<Loader color="teal" />
|
||||||
|
</Group>
|
||||||
|
) : isError ? (
|
||||||
|
<Text p="xl" c="red" ta="center">
|
||||||
|
Failed to load cargo types.
|
||||||
|
</Text>
|
||||||
|
) : visibleNodes.length === 0 ? (
|
||||||
|
<Stack align="center" gap="sm" py={56}>
|
||||||
|
<ThemeIcon size={52} radius="xl" variant="light" color="gray">
|
||||||
|
<Package size={26} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text fw={600} c="dark.6">
|
||||||
|
{term
|
||||||
|
? "Nothing matches your search"
|
||||||
|
: atRoot
|
||||||
|
? "No cargo categories yet"
|
||||||
|
: `No cargo types under “${str(current?.cargoTypeName)}” yet`}
|
||||||
|
</Text>
|
||||||
|
{!term && canManage && (
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
color="teal"
|
||||||
|
leftSection={<Plus size={16} />}
|
||||||
|
onClick={() => setFormMode({ kind: "create" })}
|
||||||
|
>
|
||||||
|
{atRoot ? "Add your first category" : "Add the first cargo type"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<Stack gap={0}>
|
||||||
|
{visibleNodes.map((node, i) => (
|
||||||
|
<CargoRow
|
||||||
|
key={node.id}
|
||||||
|
node={node}
|
||||||
|
childCount={(childrenOf.get(node.id) ?? []).length}
|
||||||
|
topBorder={i > 0}
|
||||||
|
canManage={canManage}
|
||||||
|
onOpen={() => navigate(`${BASE_PATH}/${node.id}`)}
|
||||||
|
onEdit={() => setFormMode({ kind: "edit", record: node })}
|
||||||
|
onDelete={() => setDeleteTarget(node)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* ── Create / edit dialog ───────────────────────────────── */}
|
||||||
|
<RuleEngineFormDialog
|
||||||
|
open={Boolean(formMode)}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setFormMode(null);
|
||||||
|
}}
|
||||||
|
title={
|
||||||
|
formMode?.kind === "edit"
|
||||||
|
? `Edit ${str(formMode.record.cargoTypeName)}`
|
||||||
|
: atRoot
|
||||||
|
? "Add category"
|
||||||
|
: `Add cargo under “${str(current?.cargoTypeName)}”`
|
||||||
|
}
|
||||||
|
description={
|
||||||
|
formMode?.kind === "edit"
|
||||||
|
? "Update this cargo type."
|
||||||
|
: atRoot
|
||||||
|
? "Create a top-level cargo category."
|
||||||
|
: "Create a cargo type inside this category. It's attached here automatically."
|
||||||
|
}
|
||||||
|
fields={FORM_FIELDS}
|
||||||
|
initialRecord={formMode?.kind === "edit" ? formMode.record : null}
|
||||||
|
isSubmitting={create.isPending || update.isPending}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ── Delete confirm ─────────────────────────────────────── */}
|
||||||
|
<Modal
|
||||||
|
opened={Boolean(deleteTarget)}
|
||||||
|
onClose={() => setDeleteTarget(null)}
|
||||||
|
title="Delete cargo type?"
|
||||||
|
centered
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Text size="sm">
|
||||||
|
{deleteTarget && (childrenOf.get(deleteTarget.id)?.length ?? 0) > 0 ? (
|
||||||
|
<>
|
||||||
|
<Text span fw={600}>
|
||||||
|
{str(deleteTarget?.cargoTypeName)}
|
||||||
|
</Text>{" "}
|
||||||
|
has {childrenOf.get(deleteTarget!.id)?.length} cargo type(s) under it. Deleting it
|
||||||
|
leaves them without a category. Continue?
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
This will delete{" "}
|
||||||
|
<Text span fw={600}>
|
||||||
|
{str(deleteTarget?.cargoTypeName)}
|
||||||
|
</Text>
|
||||||
|
.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
<Group justify="flex-end" gap="sm">
|
||||||
|
<Button variant="default" onClick={() => setDeleteTarget(null)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="red"
|
||||||
|
loading={remove.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
remove.mutate(deleteTarget.id, { onSuccess: () => setDeleteTarget(null) });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── A single cargo row — drills into its own page on click ──────────────────
|
||||||
|
interface CargoRowProps {
|
||||||
|
node: CargoNode;
|
||||||
|
childCount: number;
|
||||||
|
topBorder: boolean;
|
||||||
|
canManage: boolean;
|
||||||
|
onOpen: () => void;
|
||||||
|
onEdit: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CargoRow({
|
||||||
|
node,
|
||||||
|
childCount,
|
||||||
|
topBorder,
|
||||||
|
canManage,
|
||||||
|
onOpen,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
}: CargoRowProps) {
|
||||||
|
const inactive = node.isActive === false;
|
||||||
|
const hasChildren = childCount > 0;
|
||||||
|
return (
|
||||||
|
<Group
|
||||||
|
justify="space-between"
|
||||||
|
wrap="nowrap"
|
||||||
|
px="lg"
|
||||||
|
py="md"
|
||||||
|
style={{
|
||||||
|
borderTop: topBorder ? "1px solid var(--mantine-color-gray-2)" : undefined,
|
||||||
|
transition: "background 120ms ease",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.background = "var(--mantine-color-teal-0)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.background = "";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<UnstyledButton onClick={onOpen} style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Group gap="sm" wrap="nowrap">
|
||||||
|
<ThemeIcon size={36} radius="md" variant="light" color={inactive ? "gray" : "teal"}>
|
||||||
|
{hasChildren ? <Layers size={18} /> : <Package size={18} />}
|
||||||
|
</ThemeIcon>
|
||||||
|
<Box style={{ minWidth: 0 }}>
|
||||||
|
<Group gap={8} wrap="nowrap">
|
||||||
|
<Text fw={650} fz={15} c="dark.8" truncate>
|
||||||
|
{str(node.cargoTypeName) || "Untitled"}
|
||||||
|
</Text>
|
||||||
|
{node.code ? (
|
||||||
|
<Badge size="xs" variant="default" radius="sm">
|
||||||
|
{str(node.code)}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
{node.requiresDirectorApproval ? (
|
||||||
|
<Tooltip label="Requires director approval" withArrow>
|
||||||
|
<Badge
|
||||||
|
size="xs"
|
||||||
|
variant="light"
|
||||||
|
color="orange"
|
||||||
|
radius="sm"
|
||||||
|
leftSection={<ShieldCheck size={11} />}
|
||||||
|
>
|
||||||
|
Approval
|
||||||
|
</Badge>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
|
{node.showFreeTextBox ? (
|
||||||
|
<Tooltip label="Shows a free-text box on booking" withArrow>
|
||||||
|
<Badge
|
||||||
|
size="xs"
|
||||||
|
variant="light"
|
||||||
|
color="blue"
|
||||||
|
radius="sm"
|
||||||
|
leftSection={<FileText size={11} />}
|
||||||
|
>
|
||||||
|
Free text
|
||||||
|
</Badge>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
|
{inactive ? (
|
||||||
|
<Badge size="xs" variant="light" color="gray" radius="sm">
|
||||||
|
Inactive
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
|
<Text fz={12.5} c="dimmed" mt={2}>
|
||||||
|
{hasChildren
|
||||||
|
? `${childCount} cargo type${childCount === 1 ? "" : "s"} inside`
|
||||||
|
: "No cargo types inside yet — open to add"}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
</UnstyledButton>
|
||||||
|
|
||||||
|
<Group gap={4} wrap="nowrap">
|
||||||
|
{canManage && (
|
||||||
|
<>
|
||||||
|
<Tooltip label="Edit" withArrow>
|
||||||
|
<Button size="compact-sm" variant="subtle" color="gray" onClick={onEdit} px={8}>
|
||||||
|
<Pencil size={15} />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip label="Delete" withArrow>
|
||||||
|
<Button size="compact-sm" variant="subtle" color="red" onClick={onDelete} px={8}>
|
||||||
|
<Trash2 size={15} />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Tooltip label="Open" withArrow>
|
||||||
|
<Button size="compact-sm" variant="subtle" color="teal" onClick={onOpen} px={8}>
|
||||||
|
<ChevronRight size={18} />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CargoTypesPage;
|
||||||
@@ -109,6 +109,21 @@ export const trainSchedulingService = {
|
|||||||
return unwrap(response.data);
|
return unwrap(response.data);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Day-level pool: the days that have an OPEN departure on the route. Staff pick
|
||||||
|
* a day; the batch engine assigns the train. No capacity is returned.
|
||||||
|
*/
|
||||||
|
getAvailableDays: async (
|
||||||
|
originYardId?: string,
|
||||||
|
destinationYardId?: string,
|
||||||
|
): Promise<string[]> => {
|
||||||
|
const response = await client.get<{ days: string[] }>(
|
||||||
|
URL_CONSTANTS.TRAIN_SCHEDULING.AVAILABLE_DAYS,
|
||||||
|
{ params: { originYardId, destinationYardId } },
|
||||||
|
);
|
||||||
|
return unwrap(response.data).days;
|
||||||
|
},
|
||||||
|
|
||||||
runBatch: async (scheduleId: string): Promise<BatchBoardScheduleDetail> => {
|
runBatch: async (scheduleId: string): Promise<BatchBoardScheduleDetail> => {
|
||||||
const response = await client.post<BatchBoardScheduleDetail>(
|
const response = await client.post<BatchBoardScheduleDetail>(
|
||||||
URL_CONSTANTS.TRAIN_SCHEDULING.RUN_BATCH(scheduleId),
|
URL_CONSTANTS.TRAIN_SCHEDULING.RUN_BATCH(scheduleId),
|
||||||
|
|||||||
1
apps/edr-freight-web/backoffice/user-management
Submodule
1
apps/edr-freight-web/backoffice/user-management
Submodule
Submodule apps/edr-freight-web/backoffice/user-management added at e3a50a7c91
@@ -1,58 +1,127 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
||||||
|
|
||||||
import { defineConfig } from "vitest/config";
|
import { defineConfig } from "vitest/config";
|
||||||
|
import { loadEnv, type Plugin } from "vite";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
import type { ViteDevServer, PreviewServer } from "vite";
|
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
function userManagementSpaFallback() {
|
function createIamApiAdapter(apiBaseUrl: string): Plugin {
|
||||||
const rewrite = (req: IncomingMessage) => {
|
const upstreamBaseUrl = `${apiBaseUrl.replace(/\/+$/, "")}/api`;
|
||||||
const url = req.url ?? '';
|
|
||||||
if (!url.startsWith('/_um/') && url !== '/_um') return;
|
|
||||||
if (/\.[a-zA-Z0-9]+$/.test(url.split('?')[0])) return;
|
|
||||||
req.url = '/_um/index.html';
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
name: 'user-management-spa-fallback',
|
|
||||||
configureServer(s: ViteDevServer) {
|
|
||||||
s.middlewares.use((req: IncomingMessage, _r: ServerResponse, next: () => void) => {
|
|
||||||
rewrite(req);
|
|
||||||
next();
|
|
||||||
});
|
|
||||||
|
|
||||||
},
|
return {
|
||||||
configurePreviewServer(s: PreviewServer) {
|
name: "iam-api-adapter",
|
||||||
s.middlewares.use((req: IncomingMessage, _r: ServerResponse, next: () => void) => {
|
configureServer(server) {
|
||||||
rewrite(req);
|
server.middlewares.use("/um-api", async (req, res) => {
|
||||||
next();
|
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({
|
export default defineConfig(({ mode }) => {
|
||||||
plugins: [userManagementSpaFallback(), react(), tailwindcss()],
|
const env = loadEnv(mode, __dirname, "");
|
||||||
resolve: {
|
const apiBaseUrl =
|
||||||
alias: {
|
env.VITE_BASE_API_URL?.trim() || "http://localhost:3000";
|
||||||
"@": path.resolve(__dirname, "./src"),
|
|
||||||
// Resolve from TS source so Vite gets ESM named exports (dist is CommonJS).
|
return {
|
||||||
"@edr/types": path.resolve(__dirname, "../../../packages/types/src/index.ts"),
|
plugins: [react(), tailwindcss(), createIamApiAdapter(apiBaseUrl)],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(__dirname, "./src"),
|
||||||
|
"node:buffer": "buffer",
|
||||||
|
"node:stream": "stream-browserify",
|
||||||
|
// Resolve from TS source so Vite gets ESM named exports (dist is CommonJS).
|
||||||
|
"@edr/types": path.resolve(
|
||||||
|
__dirname,
|
||||||
|
"../../../packages/types/src/index.ts",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
// Force a single copy of these singletons so MantineProvider context is
|
||||||
|
// shared between the backoffice app and @edr/ui-common (which ships its
|
||||||
|
// own node_modules copy). Without this, two separate @mantine/core
|
||||||
|
// instances are bundled and the context lookup fails at runtime.
|
||||||
|
dedupe: ["react", "react-dom", "@mantine/core", "@mantine/hooks"],
|
||||||
},
|
},
|
||||||
// Force a single copy of these singletons so MantineProvider context is
|
server: {
|
||||||
// shared between the backoffice app and @edr/ui-common (which ships its
|
port: 5183,
|
||||||
// own node_modules copy). Without this, two separate @mantine/core
|
host: "0.0.0.0",
|
||||||
// instances are bundled and the context lookup fails at runtime.
|
},
|
||||||
dedupe: ["react", "react-dom", "@mantine/core", "@mantine/hooks"],
|
test: {
|
||||||
},
|
environment: "node",
|
||||||
server: {
|
},
|
||||||
port: 5183,
|
};
|
||||||
host: "0.0.0.0",
|
|
||||||
},
|
|
||||||
test: {
|
|
||||||
environment: "node",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|||||||
BIN
apps/edr-freight-web/portal/public/assets/login.png
Normal file
BIN
apps/edr-freight-web/portal/public/assets/login.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.9 MiB |
BIN
apps/edr-freight-web/portal/public/assets/telebirr.jpeg
Normal file
BIN
apps/edr-freight-web/portal/public/assets/telebirr.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
apps/edr-freight-web/portal/public/assets/waafi.jpeg
Normal file
BIN
apps/edr-freight-web/portal/public/assets/waafi.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -36,6 +36,8 @@ import EditBookingPage from "./pages/bookings/EditBookingPage";
|
|||||||
import MyBookings from "./pages/bookings/MyBookings";
|
import MyBookings from "./pages/bookings/MyBookings";
|
||||||
import NewBookingPage from "./pages/bookings/NewBookingPage";
|
import NewBookingPage from "./pages/bookings/NewBookingPage";
|
||||||
import CheckPaymentPage from "./pages/payments/CheckPaymentPage";
|
import CheckPaymentPage from "./pages/payments/CheckPaymentPage";
|
||||||
|
import PaymentSuccessPage from "./pages/payments/PaymentSuccessPage";
|
||||||
|
import PaymentFailurePage from "./pages/payments/PaymentFailurePage";
|
||||||
import TrackingPage from "./pages/tracking/TrackingPage";
|
import TrackingPage from "./pages/tracking/TrackingPage";
|
||||||
|
|
||||||
function FullScreenSpinner() {
|
function FullScreenSpinner() {
|
||||||
@@ -144,10 +146,11 @@ const sidebarItems: SidebarItem[] = [
|
|||||||
const App = () => {
|
const App = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { user } = useAuth();
|
const { user, company } = useAuth();
|
||||||
|
|
||||||
const displayName = user?.name?.en || user?.username || user?.email || "User";
|
const displayName = user?.name?.en || user?.username || user?.email || "User";
|
||||||
const userEmail = user?.email;
|
const userEmail = user?.email;
|
||||||
|
const companyProfiles = company?.company?.companyProfiles ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
@@ -158,6 +161,9 @@ const App = () => {
|
|||||||
path="/booking/check-status/:orderId"
|
path="/booking/check-status/:orderId"
|
||||||
element={<CheckPaymentPage />}
|
element={<CheckPaymentPage />}
|
||||||
/>
|
/>
|
||||||
|
{/* Payment provider browser redirects (PAYMENT_RETURN_URL / PAYMENT_FAILURE_URL) */}
|
||||||
|
<Route path="/payment/success" element={<PaymentSuccessPage />} />
|
||||||
|
<Route path="/payment/failure" element={<PaymentFailurePage />} />
|
||||||
|
|
||||||
{/* Auth pages — inaccessible once logged in */}
|
{/* Auth pages — inaccessible once logged in */}
|
||||||
<Route element={<RedirectIfAuthed />}>
|
<Route element={<RedirectIfAuthed />}>
|
||||||
@@ -185,6 +191,7 @@ const App = () => {
|
|||||||
enableThemeToggle
|
enableThemeToggle
|
||||||
userName={displayName}
|
userName={displayName}
|
||||||
userEmail={userEmail}
|
userEmail={userEmail}
|
||||||
|
companyProfiles={companyProfiles}
|
||||||
>
|
>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
|
|||||||
@@ -47,9 +47,19 @@ export interface AppLayoutProps {
|
|||||||
enableThemeToggle?: boolean;
|
enableThemeToggle?: boolean;
|
||||||
userName?: string;
|
userName?: string;
|
||||||
userEmail?: string;
|
userEmail?: string;
|
||||||
|
/** Operational profiles for the company — surfaced as reference chips in the account menu. */
|
||||||
|
companyProfiles?: { type: string; reference: string; status?: string }[];
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PROFILE_TYPE_LABELS: Record<string, string> = {
|
||||||
|
importer: "Importer",
|
||||||
|
exporter: "Exporter",
|
||||||
|
freight_forwarder: "Freight Forwarder",
|
||||||
|
dj_freight_forwarder: "DJ Freight Forwarder",
|
||||||
|
transporter: "Transporter",
|
||||||
|
};
|
||||||
|
|
||||||
function getInitials(name: string): string {
|
function getInitials(name: string): string {
|
||||||
return name
|
return name
|
||||||
.split(" ")
|
.split(" ")
|
||||||
@@ -106,6 +116,7 @@ export function AppLayout({
|
|||||||
enableThemeToggle = false,
|
enableThemeToggle = false,
|
||||||
userName = "User",
|
userName = "User",
|
||||||
userEmail,
|
userEmail,
|
||||||
|
companyProfiles = [],
|
||||||
children,
|
children,
|
||||||
}: AppLayoutProps) {
|
}: AppLayoutProps) {
|
||||||
const [mobileOpen, { toggle: toggleMobile }] = useDisclosure();
|
const [mobileOpen, { toggle: toggleMobile }] = useDisclosure();
|
||||||
@@ -306,6 +317,34 @@ export function AppLayout({
|
|||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
{companyProfiles.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
<Box px="sm" py="xs">
|
||||||
|
<Stack gap={6}>
|
||||||
|
{companyProfiles.map((p) => (
|
||||||
|
<Group
|
||||||
|
key={p.reference}
|
||||||
|
justify="space-between"
|
||||||
|
gap="sm"
|
||||||
|
wrap="nowrap"
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
size="xs"
|
||||||
|
fw={600}
|
||||||
|
style={{ color: textColor }}
|
||||||
|
>
|
||||||
|
{PROFILE_TYPE_LABELS[p.type] ?? p.type}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" ff="monospace" c="dimmed">
|
||||||
|
{p.reference}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<Divider />
|
<Divider />
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<User size={15} />}
|
leftSection={<User size={15} />}
|
||||||
|
|||||||
151
apps/edr-freight-web/portal/src/components/auth/AuthShell.tsx
Normal file
151
apps/edr-freight-web/portal/src/components/auth/AuthShell.tsx
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { ArrowUpRight, ChevronDown, Globe } from "lucide-react";
|
||||||
|
|
||||||
|
const LOGIN_IMAGE = "/assets/login.png";
|
||||||
|
const EDR_LOGO = "/assets/logo.svg";
|
||||||
|
|
||||||
|
export const fieldClass =
|
||||||
|
"h-11 w-full rounded-xl border border-gray-200/90 bg-white px-4 text-sm text-gray-900 shadow-sm placeholder:text-gray-400 outline-none transition-all duration-200 hover:border-gray-300 focus:border-primary focus:bg-white focus:ring-4 focus:ring-primary/10";
|
||||||
|
|
||||||
|
export const primaryButtonClass =
|
||||||
|
"h-11 w-full rounded-full bg-primary text-sm font-semibold text-primary-foreground shadow-[0_8px_20px_-6px_rgba(16,94,52,0.5)] transition-all duration-200 hover:bg-primary/90 hover:shadow-[0_10px_24px_-6px_rgba(16,94,52,0.55)] active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-60 disabled:shadow-none";
|
||||||
|
|
||||||
|
const LeftPanelDecor = () => (
|
||||||
|
<div className="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden>
|
||||||
|
<svg
|
||||||
|
className="absolute -bottom-24 -left-24 h-[420px] w-[420px] text-white/[0.07]"
|
||||||
|
viewBox="0 0 400 400"
|
||||||
|
fill="none"
|
||||||
|
>
|
||||||
|
{[0, 1, 2, 3, 4, 5].map((ring) => (
|
||||||
|
<circle key={ring} cx="200" cy="200" r={60 + ring * 36} stroke="currentColor" strokeWidth="1" />
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
<div className="absolute right-0 top-0 h-40 w-40 rounded-full bg-white/[0.06] blur-2xl" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const RightPanelDecor = () => (
|
||||||
|
<div className="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden>
|
||||||
|
<div className="absolute -right-16 -top-20 h-56 w-56 rounded-full bg-primary/[0.06] blur-3xl" />
|
||||||
|
<div className="absolute -bottom-12 left-1/4 h-40 w-40 rounded-full bg-primary/[0.04] blur-2xl" />
|
||||||
|
<svg className="absolute inset-0 h-full w-full text-gray-200/40" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<pattern id="auth-grid" width="28" height="28" patternUnits="userSpaceOnUse">
|
||||||
|
<circle cx="1" cy="1" r="0.75" fill="currentColor" />
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
<rect width="100%" height="100%" fill="url(#auth-grid)" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface AuthShellProps {
|
||||||
|
children: ReactNode;
|
||||||
|
/** Tagline shown in the highlighted card over the left image panel. */
|
||||||
|
tagline?: string;
|
||||||
|
taglineBody?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LeftPanel = ({ tagline, taglineBody }: Pick<AuthShellProps, "tagline" | "taglineBody">) => (
|
||||||
|
<div className="relative hidden shrink-0 flex-col overflow-hidden rounded-2xl shadow-[0_8px_32px_rgba(15,23,42,0.1)] lg:flex lg:h-auto lg:min-h-0 lg:flex-1 lg:basis-1/2 lg:rounded-[28px]">
|
||||||
|
<img
|
||||||
|
src={LOGIN_IMAGE}
|
||||||
|
alt="Ethio Djibouti Railway"
|
||||||
|
className="absolute inset-0 h-full w-full object-cover object-center"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-[#0a2e1a]/92 via-[#0f4a2a]/55 to-[#1a5c34]/45" />
|
||||||
|
<LeftPanelDecor />
|
||||||
|
|
||||||
|
<div className="relative z-10 flex shrink-0 items-center justify-between px-4 pt-4 sm:px-6 sm:pt-6 lg:px-8 lg:pt-8">
|
||||||
|
<img src={EDR_LOGO} alt="EDR Freight" className="h-7 w-auto brightness-0 invert sm:h-9" />
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
className="flex items-center gap-1.5 rounded-full border border-white/70 bg-white/10 px-3 py-1.5 text-xs font-medium text-white backdrop-blur-sm transition-colors hover:bg-white/20 sm:px-4 sm:py-2 sm:text-sm"
|
||||||
|
>
|
||||||
|
Support
|
||||||
|
<ArrowUpRight className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative z-10 mt-auto hidden px-8 pb-8 lg:block">
|
||||||
|
<div className="max-w-md rounded-2xl border border-white/15 bg-black/30 p-5 backdrop-blur-md">
|
||||||
|
<div className="mb-2 flex items-center gap-2">
|
||||||
|
<div className="h-2 w-2 shrink-0 rounded-full bg-primary" />
|
||||||
|
<span className="text-sm font-semibold text-white">
|
||||||
|
{tagline ?? "Empower Your Freight Operations"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm leading-relaxed text-white/85">
|
||||||
|
{taglineBody ??
|
||||||
|
"Sign in to manage shipments, track cargo, and run logistics operations on the Ethio Djibouti Railway freight platform."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const LanguageSelector = () => (
|
||||||
|
<div className="flex cursor-pointer items-center gap-1.5 rounded-full border border-gray-200/80 bg-white px-3 py-1.5 text-sm text-gray-600 shadow-sm">
|
||||||
|
<Globe className="h-4 w-4 text-gray-500" />
|
||||||
|
<span>Eng</span>
|
||||||
|
<ChevronDown className="h-4 w-4 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const FormFooter = () => (
|
||||||
|
<div className="relative z-10 flex shrink-0 flex-col items-center justify-between gap-3 border-t border-gray-100 px-4 py-4 text-xs text-gray-400 sm:flex-row sm:gap-4 sm:px-6 sm:py-4 lg:px-8 lg:pb-6">
|
||||||
|
<span className="shrink-0">© 2026 EDR Freight</span>
|
||||||
|
<div className="flex flex-wrap items-center justify-center gap-3 sm:justify-end sm:gap-6">
|
||||||
|
<a href="#" className="font-semibold text-gray-700 transition-colors hover:text-primary">
|
||||||
|
Terms & Conditions
|
||||||
|
</a>
|
||||||
|
<a href="#" className="font-semibold text-gray-700 transition-colors hover:text-primary">
|
||||||
|
Privacy Policy
|
||||||
|
</a>
|
||||||
|
<a href="#" className="font-semibold text-gray-700 transition-colors hover:text-primary">
|
||||||
|
Help & Support
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function AuthShell({ children, tagline, taglineBody }: AuthShellProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="" />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex h-[100dvh] overflow-hidden bg-[#e8eaef] px-4 py-3 antialiased sm:px-6 sm:py-4 md:px-[70px]"
|
||||||
|
style={{ fontFamily: "'Outfit', var(--font-sans)" }}
|
||||||
|
>
|
||||||
|
<div className="flex h-full min-h-0 w-full flex-col gap-3 lg:flex-row lg:gap-4">
|
||||||
|
<LeftPanel tagline={tagline} taglineBody={taglineBody} />
|
||||||
|
|
||||||
|
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-2xl bg-[#f5f7fa] shadow-[0_8px_32px_rgba(15,23,42,0.08)] lg:basis-1/2 lg:rounded-[28px]">
|
||||||
|
<RightPanelDecor />
|
||||||
|
|
||||||
|
<div className="relative z-10 flex shrink-0 justify-end px-4 pt-4 sm:px-6 sm:pt-6 lg:px-8 lg:pt-8">
|
||||||
|
<LanguageSelector />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative z-10 min-h-0 flex-1 overflow-y-auto overscroll-contain">
|
||||||
|
<div className="flex min-h-full justify-center px-4 py-4 sm:px-6 sm:py-6 lg:px-8 lg:py-8">
|
||||||
|
<div className="my-auto w-full max-w-xl rounded-3xl border border-gray-100/80 bg-white px-5 py-6 shadow-[0_4px_24px_rgba(15,23,42,0.06)] sm:px-7 sm:py-8 lg:px-9 lg:py-9">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormFooter />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -83,6 +83,7 @@ export const URL_CONSTANTS = {
|
|||||||
GET_INFO: "/api/companies/getInfo",
|
GET_INFO: "/api/companies/getInfo",
|
||||||
CREATE: "/api/companies/create",
|
CREATE: "/api/companies/create",
|
||||||
PROFILE: "/api/companies/profile",
|
PROFILE: "/api/companies/profile",
|
||||||
|
COMPANY_PROFILES: "/api/companies/company-profiles",
|
||||||
DASHBOARD: "/api/companies/dashboard",
|
DASHBOARD: "/api/companies/dashboard",
|
||||||
DOCUMENTS: (id: string) => `/api/companies/${id}/documents`,
|
DOCUMENTS: (id: string) => `/api/companies/${id}/documents`,
|
||||||
},
|
},
|
||||||
@@ -100,6 +101,7 @@ export const URL_CONSTANTS = {
|
|||||||
|
|
||||||
TRAIN_SCHEDULING: {
|
TRAIN_SCHEDULING: {
|
||||||
BOOKABLE_SCHEDULES: "/api/train-scheduling/bookable-schedules",
|
BOOKABLE_SCHEDULES: "/api/train-scheduling/bookable-schedules",
|
||||||
|
AVAILABLE_DAYS: "/api/train-scheduling/available-days",
|
||||||
},
|
},
|
||||||
|
|
||||||
PAYMENTS: {
|
PAYMENTS: {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Box, Group, Stack, Text } from "@mantine/core";
|
|||||||
import { memo } from "react";
|
import { memo } from "react";
|
||||||
import { ACTION_PROPS, STATUS_CONFIG, cv } from "../constants";
|
import { ACTION_PROPS, STATUS_CONFIG, cv } from "../constants";
|
||||||
import { Stepper } from "./Stepper";
|
import { Stepper } from "./Stepper";
|
||||||
|
import { PayNowButton } from "@/pages/bookings/payments/PayNowButton";
|
||||||
|
|
||||||
interface BookingRowProps {
|
interface BookingRowProps {
|
||||||
booking: any;
|
booking: any;
|
||||||
@@ -18,6 +19,10 @@ export const BookingRow = memo(function BookingRow({
|
|||||||
const Icon = cfg.icon;
|
const Icon = cfg.icon;
|
||||||
const AIcon = cfg.action.icon;
|
const AIcon = cfg.action.icon;
|
||||||
const ap = ACTION_PROPS[cfg.action.kind];
|
const ap = ACTION_PROPS[cfg.action.kind];
|
||||||
|
// Payable bookings get an inline "Pay now" that opens the payment modal
|
||||||
|
// instead of navigating to the detail page.
|
||||||
|
const canPay =
|
||||||
|
booking.status === "SELECTED_FOR_BATCH" && booking.paymentStatus !== "PAID";
|
||||||
const origin = booking.originYard?.label ?? booking.originYard?.code ?? "—";
|
const origin = booking.originYard?.label ?? booking.originYard?.code ?? "—";
|
||||||
const dest =
|
const dest =
|
||||||
booking.destinationYard?.label ?? booking.destinationYard?.code ?? "—";
|
booking.destinationYard?.label ?? booking.destinationYard?.code ?? "—";
|
||||||
@@ -78,25 +83,29 @@ export const BookingRow = memo(function BookingRow({
|
|||||||
{cfg.badgeLabel}
|
{cfg.badgeLabel}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<Group
|
{canPay ? (
|
||||||
gap={5}
|
<PayNowButton booking={booking} size="sm" />
|
||||||
align="center"
|
) : (
|
||||||
px={15}
|
<Group
|
||||||
py={8}
|
gap={5}
|
||||||
bg={ap.bg}
|
align="center"
|
||||||
bd={ap.bd}
|
px={15}
|
||||||
className="cursor-pointer rounded-[9px]"
|
py={8}
|
||||||
>
|
bg={ap.bg}
|
||||||
<Text fz={13} fw={700} c={ap.c}>
|
bd={ap.bd}
|
||||||
{cfg.action.label}
|
className="cursor-pointer rounded-[9px]"
|
||||||
</Text>
|
>
|
||||||
{AIcon && (
|
<Text fz={13} fw={700} c={ap.c}>
|
||||||
<AIcon
|
{cfg.action.label}
|
||||||
size={15}
|
</Text>
|
||||||
color={ap.c === "white" ? "#fff" : cv("edr-text")}
|
{AIcon && (
|
||||||
/>
|
<AIcon
|
||||||
)}
|
size={15}
|
||||||
</Group>
|
color={ap.c === "white" ? "#fff" : cv("edr-text")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Group>
|
</Group>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export const InvoicesSection = memo(function InvoicesSection({
|
|||||||
Outstanding balance
|
Outstanding balance
|
||||||
</Text>
|
</Text>
|
||||||
<Text fz={24} fw={800} mt={4} c="edr-text">
|
<Text fz={24} fw={800} mt={4} c="edr-text">
|
||||||
{formatCurrency(totalOutstanding || 377500, "ETB")}
|
{formatCurrency(totalOutstanding || 0, "ETB")}
|
||||||
</Text>
|
</Text>
|
||||||
<Group
|
<Group
|
||||||
justify="space-between"
|
justify="space-between"
|
||||||
|
|||||||
@@ -1,17 +1,113 @@
|
|||||||
import { User, Building2, Phone, Mail, MapPin, ShieldCheck, Briefcase, UserCheck, Fingerprint, FileCheck, Globe, Building } from "lucide-react";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, Badge, Separator } from "@edr/ui-common";
|
import {
|
||||||
|
Badge,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Center,
|
||||||
|
Container,
|
||||||
|
Divider,
|
||||||
|
Grid,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
SimpleGrid,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
ThemeIcon,
|
||||||
|
Title,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
BadgeCheck,
|
||||||
|
Briefcase,
|
||||||
|
Building,
|
||||||
|
Building2,
|
||||||
|
FileCheck,
|
||||||
|
Globe,
|
||||||
|
Mail,
|
||||||
|
MapPin,
|
||||||
|
Phone,
|
||||||
|
Plus,
|
||||||
|
ShieldCheck,
|
||||||
|
User,
|
||||||
|
UserCheck,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { rolesForCompanyType } from "./settings/companyRoles";
|
||||||
|
|
||||||
function InfoItem({ icon, label, value }: { icon?: React.ReactNode; label: string; value?: string | null }) {
|
function InfoItem({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
label: string;
|
||||||
|
value?: string | null;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start gap-3">
|
<Group gap="sm" align="flex-start" wrap="nowrap">
|
||||||
{icon && <div className="mt-1 text-muted-foreground [&_svg]:size-4">{icon}</div>}
|
{icon && (
|
||||||
<div className="flex flex-col gap-0.5">
|
<ThemeIcon variant="light" color="edr-green" size="md" radius="md">
|
||||||
<p className="text-[10px] font-bold uppercase tracking-tight text-muted-foreground">{label}</p>
|
{icon}
|
||||||
<p className="text-sm font-bold text-foreground">{value || "—"}</p>
|
</ThemeIcon>
|
||||||
</div>
|
)}
|
||||||
</div>
|
<Stack gap={2}>
|
||||||
|
<Text size="xs" fw={700} tt="uppercase" c="edr-muted">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" fw={600} c="edr-text">
|
||||||
|
{value || "—"}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardHeading({
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Stack gap={2} mb="md">
|
||||||
|
<Group gap="sm">
|
||||||
|
{icon}
|
||||||
|
<Title order={4} size="h5">
|
||||||
|
{title}
|
||||||
|
</Title>
|
||||||
|
</Group>
|
||||||
|
<Text size="sm" c="edr-muted">
|
||||||
|
{description}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PersonnelGroup({
|
||||||
|
color,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
color: string;
|
||||||
|
title: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Group gap="xs">
|
||||||
|
<Box w={4} h={16} bg={color} style={{ borderRadius: 2 }} />
|
||||||
|
<Text size="sm" fw={700} tt="uppercase" c="edr-text">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Stack gap="sm" pl="lg">
|
||||||
|
{children}
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,163 +118,293 @@ export default function ProfilePage() {
|
|||||||
|
|
||||||
if (isPending) {
|
if (isPending) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center">
|
<Center h="100%">
|
||||||
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-primary" />
|
<Loader color="edr-green" size="lg" />
|
||||||
</div>
|
</Center>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!profile) {
|
if (!profile) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center">
|
<Center h="100%">
|
||||||
<p className="text-muted-foreground">No company profile found.</p>
|
<Text c="edr-muted">No company profile found.</Text>
|
||||||
</div>
|
</Center>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Registered operational profiles keyed by type, plus the roles this company
|
||||||
|
// type may hold (importer/exporter for a customer). Mirrors CompanyRolesCard.
|
||||||
|
const refByType = new Map(profile.companyProfiles.map((p) => [p.type, p]));
|
||||||
|
const roleOptions = rolesForCompanyType(profile.companyType);
|
||||||
|
const activeOptions = roleOptions.filter((o) => refByType.has(o.type));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-4 py-8">
|
<Container size="xl" px="lg" py="xl">
|
||||||
<div className="mx-auto max-w-7xl">
|
{/* Header */}
|
||||||
<div className="flex flex-col gap-8">
|
<Group gap="lg" align="center" mb="lg">
|
||||||
{/* Header */}
|
<ThemeIcon variant="light" color="edr-green" size={88} radius="lg">
|
||||||
<div className="flex items-center gap-6">
|
<User size={44} />
|
||||||
<div className="flex size-24 items-center justify-center rounded-2xl bg-primary/10 text-primary shadow-inner">
|
</ThemeIcon>
|
||||||
<User className="size-12" />
|
<Stack gap={6}>
|
||||||
</div>
|
<Group gap="sm" align="center">
|
||||||
<div className="flex flex-col gap-1">
|
<Title order={1} size="h2">
|
||||||
<div className="flex items-center gap-3">
|
{profile.companyName}
|
||||||
<h1 className="text-3xl font-black tracking-tight text-foreground">
|
</Title>
|
||||||
{profile.companyName}
|
<Badge color="edr-green" variant="light">
|
||||||
</h1>
|
Verified
|
||||||
<Badge variant="secondary" className="font-bold uppercase tracking-wider">
|
</Badge>
|
||||||
Verified
|
</Group>
|
||||||
|
{activeOptions.length > 0 ? (
|
||||||
|
<Group gap="xs">
|
||||||
|
{activeOptions.map((opt) => (
|
||||||
|
<Badge
|
||||||
|
key={opt.type}
|
||||||
|
variant="light"
|
||||||
|
color="edr-green"
|
||||||
|
size="lg"
|
||||||
|
radius="sm"
|
||||||
|
>
|
||||||
|
{opt.label} · {refByType.get(opt.type)!.reference}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
))}
|
||||||
<p className="flex items-center gap-2 font-medium text-muted-foreground">
|
</Group>
|
||||||
<Building className="size-4" />
|
) : (
|
||||||
{profile.companyName}
|
<Group gap={6} c="edr-muted">
|
||||||
</p>
|
<Building size={16} />
|
||||||
</div>
|
<Text c="edr-muted" fw={500}>
|
||||||
</div>
|
{profile.companyType}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
|
||||||
<Separator />
|
<Divider mb="lg" />
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
|
<Grid gap="lg">
|
||||||
{/* Left Column */}
|
{/* Left Column */}
|
||||||
<div className="flex flex-col gap-8 lg:col-span-2">
|
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||||
<div className="grid grid-cols-1 gap-8 md:grid-cols-2">
|
<Stack gap="lg">
|
||||||
{/* Company Details */}
|
{/* Company Details */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeading
|
||||||
<CardTitle className="flex items-center gap-2">
|
icon={
|
||||||
<Building2 className="size-5 text-primary" />
|
<Building2
|
||||||
Company Details
|
size={20}
|
||||||
</CardTitle>
|
color="var(--mantine-color-edr-green-6)"
|
||||||
<CardDescription>Business registration information</CardDescription>
|
/>
|
||||||
</CardHeader>
|
}
|
||||||
<CardContent className="flex flex-col gap-4">
|
title="Company Details"
|
||||||
<InfoItem icon={<Globe />} label="Location" value={profile.companyLocation} />
|
description="Business registration information"
|
||||||
<InfoItem icon={<MapPin />} label="Address" value={profile.companyAddress} />
|
/>
|
||||||
<InfoItem icon={<FileCheck />} label="TIN Number" value={profile.tinNumber} />
|
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||||
<InfoItem icon={<ShieldCheck />} label="FAN Number" value={profile.fanNumber} />
|
<InfoItem
|
||||||
<InfoItem icon={<Mail />} label="Email" value={profile.companyEmail} />
|
icon={<Globe size={16} />}
|
||||||
<InfoItem icon={<Phone />} label="Phone" value={profile.companyPhone} />
|
label="Location"
|
||||||
</CardContent>
|
value={profile.companyLocation}
|
||||||
</Card>
|
/>
|
||||||
|
<InfoItem
|
||||||
|
icon={<MapPin size={16} />}
|
||||||
|
label="Address"
|
||||||
|
value={profile.companyAddress}
|
||||||
|
/>
|
||||||
|
<InfoItem
|
||||||
|
icon={<FileCheck size={16} />}
|
||||||
|
label="TIN Number"
|
||||||
|
value={profile.tinNumber}
|
||||||
|
/>
|
||||||
|
<InfoItem
|
||||||
|
icon={<ShieldCheck size={16} />}
|
||||||
|
label="FAN Number"
|
||||||
|
value={profile.fanNumber}
|
||||||
|
/>
|
||||||
|
<InfoItem
|
||||||
|
icon={<Mail size={16} />}
|
||||||
|
label="Email"
|
||||||
|
value={profile.companyEmail}
|
||||||
|
/>
|
||||||
|
<InfoItem
|
||||||
|
icon={<Phone size={16} />}
|
||||||
|
label="Phone"
|
||||||
|
value={profile.companyPhone}
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Personal Details (from ExternalProfile) */}
|
{/* Key Personnel */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeading
|
||||||
<CardTitle className="flex items-center gap-2">
|
icon={
|
||||||
<Fingerprint className="size-5 text-primary" />
|
<Briefcase
|
||||||
Profile Details
|
size={20}
|
||||||
</CardTitle>
|
color="var(--mantine-color-edr-green-6)"
|
||||||
<CardDescription>Your linked user profile</CardDescription>
|
/>
|
||||||
</CardHeader>
|
}
|
||||||
<CardContent className="flex flex-col gap-4">
|
title="Key Personnel"
|
||||||
<InfoItem icon={<User />} label="Profile" value="Primary Contact" />
|
description="Management and contact persons"
|
||||||
</CardContent>
|
/>
|
||||||
</Card>
|
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="lg">
|
||||||
</div>
|
<PersonnelGroup color="edr-green" title="Contact Person">
|
||||||
|
<InfoItem label="Name" value={profile.contactPersonName} />
|
||||||
|
<InfoItem label="Phone" value={profile.contactPersonPhone} />
|
||||||
|
</PersonnelGroup>
|
||||||
|
<PersonnelGroup color="edr-accent" title="General Manager">
|
||||||
|
<InfoItem label="Name" value={profile.generalManagerName} />
|
||||||
|
<InfoItem label="Email" value={profile.generalManagerEmail} />
|
||||||
|
<InfoItem label="Phone" value={profile.generalManagerPhone} />
|
||||||
|
</PersonnelGroup>
|
||||||
|
</SimpleGrid>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Personnel Card */}
|
{/* Power of Attorney */}
|
||||||
<Card>
|
{profile.poaName && (
|
||||||
<CardHeader>
|
<Card style={{ borderStyle: "dashed" }}>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardHeading
|
||||||
<Briefcase className="size-5 text-primary" />
|
icon={
|
||||||
Key Personnel
|
<UserCheck
|
||||||
</CardTitle>
|
size={20}
|
||||||
<CardDescription>Management and contact persons</CardDescription>
|
color="var(--mantine-color-edr-accent-6)"
|
||||||
</CardHeader>
|
/>
|
||||||
<CardContent className="grid grid-cols-1 gap-8 md:grid-cols-2">
|
}
|
||||||
<div className="flex flex-col gap-4">
|
title="Power of Attorney"
|
||||||
<h3 className="border-l-4 border-primary pl-3 text-sm font-bold uppercase tracking-wide text-foreground">
|
description="Authorized representative details"
|
||||||
Contact Person
|
/>
|
||||||
</h3>
|
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="md">
|
||||||
<div className="flex flex-col gap-3 pl-4">
|
<InfoItem label="PoA Name" value={profile.poaName} />
|
||||||
<InfoItem label="Name" value={profile.contactPersonName} />
|
<InfoItem label="PoA Email" value={profile.poaEmail} />
|
||||||
<InfoItem label="Phone" value={profile.contactPersonPhone} />
|
<InfoItem label="PoA Phone" value={profile.poaPhone} />
|
||||||
</div>
|
<InfoItem label="PoA Location" value={profile.poaLocation} />
|
||||||
</div>
|
</SimpleGrid>
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<h3 className="border-l-4 border-accent pl-3 text-sm font-bold uppercase tracking-wide text-foreground">
|
|
||||||
General Manager
|
|
||||||
</h3>
|
|
||||||
<div className="flex flex-col gap-3 pl-4">
|
|
||||||
<InfoItem label="Name" value={profile.generalManagerName} />
|
|
||||||
<InfoItem label="Email" value={profile.generalManagerEmail} />
|
|
||||||
<InfoItem label="Phone" value={profile.generalManagerPhone} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
</Card>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Grid.Col>
|
||||||
|
|
||||||
{/* Power of Attorney */}
|
{/* Right Column */}
|
||||||
{profile.poaName && (
|
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||||
<Card className="border-dashed">
|
<Stack gap="lg">
|
||||||
<CardHeader>
|
{/* Operating Roles */}
|
||||||
<CardTitle className="flex items-center gap-2">
|
<Card>
|
||||||
<UserCheck className="size-5 text-accent" />
|
<CardHeading
|
||||||
Power of Attorney
|
icon={
|
||||||
</CardTitle>
|
<BadgeCheck
|
||||||
<CardDescription>Authorized representative details</CardDescription>
|
size={20}
|
||||||
</CardHeader>
|
color="var(--mantine-color-edr-green-6)"
|
||||||
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
/>
|
||||||
<InfoItem label="PoA Name" value={profile.poaName} />
|
}
|
||||||
<InfoItem label="PoA Email" value={profile.poaEmail} />
|
title="Operating Roles"
|
||||||
<InfoItem label="PoA Phone" value={profile.poaPhone} />
|
description="Your registered freight roles and reference numbers"
|
||||||
<InfoItem label="PoA Location" value={profile.poaLocation} />
|
/>
|
||||||
</CardContent>
|
{roleOptions.length === 0 ? (
|
||||||
</Card>
|
<Text size="sm" c="edr-muted">
|
||||||
|
Role management for this company type is coming soon.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Stack gap="md">
|
||||||
|
{roleOptions.map((opt) => {
|
||||||
|
const active = refByType.get(opt.type);
|
||||||
|
return (
|
||||||
|
<Group
|
||||||
|
key={opt.type}
|
||||||
|
justify="space-between"
|
||||||
|
wrap="nowrap"
|
||||||
|
align="center"
|
||||||
|
>
|
||||||
|
<Group gap="sm" wrap="nowrap">
|
||||||
|
<ThemeIcon
|
||||||
|
variant="light"
|
||||||
|
color="edr-green"
|
||||||
|
size="lg"
|
||||||
|
radius="md"
|
||||||
|
>
|
||||||
|
{opt.icon}
|
||||||
|
</ThemeIcon>
|
||||||
|
<Stack gap={2}>
|
||||||
|
<Text size="sm" fw={600} c="edr-text">
|
||||||
|
{opt.label}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
size="xs"
|
||||||
|
c="edr-muted"
|
||||||
|
ff={active ? "monospace" : undefined}
|
||||||
|
>
|
||||||
|
{active ? active.reference : "Not registered"}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
{active ? (
|
||||||
|
<Badge
|
||||||
|
variant="light"
|
||||||
|
color={
|
||||||
|
active.status === "active"
|
||||||
|
? "edr-green"
|
||||||
|
: "edr-accent"
|
||||||
|
}
|
||||||
|
tt="capitalize"
|
||||||
|
>
|
||||||
|
{active.status}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
component={Link}
|
||||||
|
to="/settings?tab=company"
|
||||||
|
size="xs"
|
||||||
|
variant="light"
|
||||||
|
color="edr-green"
|
||||||
|
leftSection={<Plus size={14} />}
|
||||||
|
>
|
||||||
|
Add {opt.label}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Card>
|
||||||
|
|
||||||
{/* Right Column */}
|
{/* Secure Account */}
|
||||||
<div className="flex flex-col gap-8">
|
<Card
|
||||||
<Card className="relative overflow-hidden border-none bg-foreground text-background shadow-xl">
|
padding="xl"
|
||||||
<div className="absolute right-0 top-0 p-4 opacity-10">
|
style={{
|
||||||
<ShieldCheck className="size-32" />
|
background: "var(--mantine-color-edr-ink-6)",
|
||||||
</div>
|
position: "relative",
|
||||||
<CardContent className="relative z-10 flex flex-col gap-4 px-6 py-8">
|
overflow: "hidden",
|
||||||
<h3 className="text-xl font-black">Secure Account</h3>
|
}}
|
||||||
<p className="text-sm leading-relaxed text-muted-foreground/80">
|
>
|
||||||
Your information is protected by enterprise-grade security.
|
<Box
|
||||||
Contact support for verified information updates.
|
style={{
|
||||||
</p>
|
position: "absolute",
|
||||||
<div className="pt-2">
|
top: 16,
|
||||||
<a
|
right: 16,
|
||||||
href="/settings"
|
opacity: 0.1,
|
||||||
className="inline-flex h-9 items-center justify-center rounded-md bg-background px-4 text-sm font-medium text-foreground hover:bg-background/90"
|
}}
|
||||||
>
|
>
|
||||||
Edit Settings
|
<ShieldCheck size={128} color="white" />
|
||||||
</a>
|
</Box>
|
||||||
</div>
|
<Stack gap="md" style={{ position: "relative", zIndex: 1 }}>
|
||||||
</CardContent>
|
<Title order={3} size="h4" c="white">
|
||||||
</Card>
|
Secure Account
|
||||||
</div>
|
</Title>
|
||||||
</div>
|
<Text size="sm" c="gray.4">
|
||||||
</div>
|
Your information is protected by enterprise-grade security.
|
||||||
</div>
|
Contact support for verified information updates.
|
||||||
</div>
|
</Text>
|
||||||
|
<Button
|
||||||
|
component={Link}
|
||||||
|
to="/settings"
|
||||||
|
variant="white"
|
||||||
|
color="dark"
|
||||||
|
mt="xs"
|
||||||
|
w="fit-content"
|
||||||
|
>
|
||||||
|
Edit Settings
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</Stack>
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
</Container>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,39 +1,51 @@
|
|||||||
import { Alert, Box, Button, Group, PasswordInput, SegmentedControl, Stack, Text, TextInput } from "@mantine/core";
|
import { type FormEvent, useState } from "react";
|
||||||
import { ArrowRight, Mail, Phone } from "lucide-react";
|
import { ChevronDown, Eye, EyeOff, Mail, Smartphone } from "lucide-react";
|
||||||
import { useState } from "react";
|
|
||||||
import { useLocation, useNavigate } from "react-router-dom";
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import useAuth from "@/hooks/useAuth";
|
import useAuth from "@/hooks/useAuth";
|
||||||
import AuthLayout from "@/components/auth/AuthLayout";
|
import AuthShell, { fieldClass, primaryButtonClass } from "@/components/auth/AuthShell";
|
||||||
import PhoneInput from "@/components/auth/PhoneInput";
|
|
||||||
|
const EDR_LOGO = "/assets/edr-logo.png";
|
||||||
|
|
||||||
type LoginMethod = "email" | "phone";
|
type LoginMethod = "email" | "phone";
|
||||||
|
|
||||||
|
const loginMethods: Array<{
|
||||||
|
value: LoginMethod;
|
||||||
|
label: string;
|
||||||
|
icon: typeof Mail;
|
||||||
|
placeholder: string;
|
||||||
|
}> = [
|
||||||
|
{ value: "email", label: "Email", icon: Mail, placeholder: "name@company.com" },
|
||||||
|
{ value: "phone", label: "Phone", icon: Smartphone, placeholder: "09XXXXXXXX" },
|
||||||
|
];
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { login } = useAuth();
|
const { login } = useAuth();
|
||||||
const [method, setMethod] = useState<LoginMethod>("email");
|
const [method, setMethod] = useState<LoginMethod>("email");
|
||||||
const [identifier, setIdentifier] = useState("");
|
const [identifier, setIdentifier] = useState("");
|
||||||
const [countryCode, setCountryCode] = useState("+251");
|
const [countryCode] = useState("+251");
|
||||||
const [phoneNumber, setPhoneNumber] = useState("");
|
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const currentMethod = loginMethods.find((item) => item.value === method)!;
|
||||||
e.preventDefault();
|
|
||||||
|
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
setError(null);
|
setError(null);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const loginId =
|
const loginId =
|
||||||
method === "email"
|
method === "email"
|
||||||
? identifier
|
? identifier
|
||||||
: `${countryCode}${phoneNumber.startsWith("0") ? phoneNumber.slice(1) : phoneNumber}`;
|
: `${countryCode}${identifier.startsWith("0") ? identifier.slice(1) : identifier}`;
|
||||||
const result = await login({ email: loginId, password });
|
const result = await login({ email: loginId, password });
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
const from = (location.state as { from?: { pathname: string } } | null)
|
const from = (location.state as { from?: { pathname: string } } | null)?.from
|
||||||
?.from?.pathname;
|
?.pathname;
|
||||||
navigate(from ?? "/portal", { replace: true });
|
navigate(from ?? "/portal", { replace: true });
|
||||||
} else {
|
} else {
|
||||||
setError(result.error.message);
|
setError(result.error.message);
|
||||||
@@ -46,154 +58,105 @@ export default function LoginPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthLayout
|
<AuthShell>
|
||||||
left={{
|
<form className="flex w-full flex-col" onSubmit={handleSubmit}>
|
||||||
badge: "Welcome Back",
|
<div className="mb-4 flex justify-center sm:mb-6">
|
||||||
title: "Sign in to your freight operations account",
|
<img src={EDR_LOGO} alt="EDR Freight" className="h-9 w-auto sm:h-11" />
|
||||||
description:
|
</div>
|
||||||
"Access your dashboard to manage shipments, monitor railway operations, track consignments, and streamline logistics workflows.",
|
|
||||||
features: [
|
<div className="mb-4 space-y-1.5 text-center sm:mb-5">
|
||||||
"Real-time shipment tracking",
|
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
|
||||||
"Secure logistics management",
|
|
||||||
"Enterprise-grade operations",
|
|
||||||
"Multi-corridor freight monitoring",
|
|
||||||
],
|
|
||||||
stats: { label: "Active Corridors", value: "24+", footer: "Operational", progress: "w-[95%]" },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Stack gap="xs" mb="lg">
|
|
||||||
<Box
|
|
||||||
w={48}
|
|
||||||
h={48}
|
|
||||||
bg="edr-soft"
|
|
||||||
className="flex items-center justify-center rounded-2xl"
|
|
||||||
>
|
|
||||||
<Mail size={22} color="var(--mantine-color-edr-green-6)" />
|
|
||||||
</Box>
|
|
||||||
<Box>
|
|
||||||
<Text fz={24} fw={800} c="edr-text" className="tracking-tight">
|
|
||||||
Welcome back
|
Welcome back
|
||||||
</Text>
|
</h1>
|
||||||
<Text fz={15} c="edr-muted" mt={4}>
|
<p className="text-sm leading-relaxed text-gray-500">
|
||||||
Enter your credentials to access your portal
|
Enter your credentials to access your freight portal.
|
||||||
</Text>
|
</p>
|
||||||
</Box>
|
</div>
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
<div className="flex w-full flex-col gap-4">
|
||||||
<Stack gap="md">
|
<div className="space-y-1.5">
|
||||||
<SegmentedControl
|
<label className="text-sm font-medium text-gray-800">Sign in method</label>
|
||||||
value={method}
|
<div className="relative">
|
||||||
onChange={(v) => setMethod(v as LoginMethod)}
|
<select
|
||||||
fullWidth
|
value={method}
|
||||||
radius="md"
|
onChange={(event) => setMethod(event.target.value as LoginMethod)}
|
||||||
data={[
|
disabled={loading}
|
||||||
{
|
className={`${fieldClass} appearance-none pr-10`}
|
||||||
label: (
|
|
||||||
<Group gap={6} justify="center" wrap="nowrap">
|
|
||||||
<Mail size={15} />
|
|
||||||
<Text size="sm">Email</Text>
|
|
||||||
</Group>
|
|
||||||
),
|
|
||||||
value: "email",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: (
|
|
||||||
<Group gap={6} justify="center" wrap="nowrap">
|
|
||||||
<Phone size={15} />
|
|
||||||
<Text size="sm">Phone</Text>
|
|
||||||
</Group>
|
|
||||||
),
|
|
||||||
value: "phone",
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{method === "email" ? (
|
|
||||||
<TextInput
|
|
||||||
label="Email Address"
|
|
||||||
placeholder="name@company.com"
|
|
||||||
type="email"
|
|
||||||
value={identifier}
|
|
||||||
onChange={(e) => setIdentifier(e.target.value)}
|
|
||||||
required
|
|
||||||
disabled={loading}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<PhoneInput
|
|
||||||
disabled={loading}
|
|
||||||
countryCode={{
|
|
||||||
value: countryCode,
|
|
||||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) =>
|
|
||||||
setCountryCode(e.target.value),
|
|
||||||
}}
|
|
||||||
phone={{
|
|
||||||
value: phoneNumber,
|
|
||||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) =>
|
|
||||||
setPhoneNumber(e.target.value),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Box>
|
|
||||||
<Group justify="space-between" mb={6}>
|
|
||||||
<Text size="sm" fw={500} c="edr-text">
|
|
||||||
Password
|
|
||||||
</Text>
|
|
||||||
<Button
|
|
||||||
variant="transparent"
|
|
||||||
size="xs"
|
|
||||||
c="edr-green.6"
|
|
||||||
p={0}
|
|
||||||
h="auto"
|
|
||||||
fz={12}
|
|
||||||
>
|
>
|
||||||
Forgot password?
|
{loginMethods.map((item) => (
|
||||||
</Button>
|
<option key={item.value} value={item.value}>
|
||||||
</Group>
|
{item.label}
|
||||||
<PasswordInput
|
</option>
|
||||||
placeholder="••••••••"
|
))}
|
||||||
value={password}
|
</select>
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
<ChevronDown className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||||
required
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-sm font-medium text-gray-800">
|
||||||
|
{currentMethod.label} <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
value={identifier}
|
||||||
|
onChange={(event) => setIdentifier(event.target.value)}
|
||||||
|
placeholder={currentMethod.placeholder}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
|
className={fieldClass}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</div>
|
||||||
|
|
||||||
{error && (
|
<div className="space-y-1.5">
|
||||||
<Alert color="red" variant="light" radius="md">
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="text-sm font-medium text-gray-800">
|
||||||
|
Password <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<a href="#" className="text-xs font-semibold text-primary hover:underline">
|
||||||
|
Forgot password?
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
placeholder="Enter your password"
|
||||||
|
disabled={loading}
|
||||||
|
className={`${fieldClass} pr-11`}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword((current) => !current)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 transition-colors hover:text-gray-600"
|
||||||
|
aria-label={showPassword ? "Hide password" : "Show password"}
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2.5 text-sm text-red-700">
|
||||||
{error}
|
{error}
|
||||||
</Alert>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
|
|
||||||
<Button
|
<button type="submit" disabled={loading} className={primaryButtonClass}>
|
||||||
type="submit"
|
{loading ? "Signing in..." : "Sign In"}
|
||||||
disabled={loading}
|
</button>
|
||||||
loading={loading}
|
|
||||||
size="lg"
|
|
||||||
color="edr-green"
|
|
||||||
fullWidth
|
|
||||||
rightSection={!loading ? <ArrowRight size={18} /> : undefined}
|
|
||||||
>
|
|
||||||
Sign In
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Text size="sm" c="edr-muted" ta="center">
|
<p className="text-center text-sm text-gray-500">
|
||||||
Don't have an account?{" "}
|
Don't have an account?{" "}
|
||||||
<Button
|
<button
|
||||||
variant="transparent"
|
type="button"
|
||||||
p={0}
|
|
||||||
h="auto"
|
|
||||||
c="edr-green.6"
|
|
||||||
fw={600}
|
|
||||||
fz="sm"
|
|
||||||
onClick={() => navigate("/signup")}
|
onClick={() => navigate("/signup")}
|
||||||
|
className="font-semibold text-primary hover:underline"
|
||||||
>
|
>
|
||||||
Create an account
|
Create an account
|
||||||
</Button>
|
</button>
|
||||||
</Text>
|
</p>
|
||||||
</Stack>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</AuthLayout>
|
</AuthShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Alert, Box, Button, Group, PasswordInput, SimpleGrid, Stack, Text, TextInput, ThemeIcon } from "@mantine/core";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { Check, ArrowRight, UserPlus, X } from "lucide-react";
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { ArrowRight, Check, Eye, EyeOff, X } from "lucide-react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -9,8 +8,9 @@ import { z } from "zod";
|
|||||||
import { userType } from "@/enums/userType";
|
import { userType } from "@/enums/userType";
|
||||||
import useAuth from "@/hooks/useAuth";
|
import useAuth from "@/hooks/useAuth";
|
||||||
import type { SignupPayload } from "@/types/auth";
|
import type { SignupPayload } from "@/types/auth";
|
||||||
import AuthLayout from "@/components/auth/AuthLayout";
|
import AuthShell, { fieldClass, primaryButtonClass } from "@/components/auth/AuthShell";
|
||||||
import PhoneInput from "@/components/auth/PhoneInput";
|
|
||||||
|
const EDR_LOGO = "/assets/edr-logo.png";
|
||||||
|
|
||||||
const passwordRequirements = [
|
const passwordRequirements = [
|
||||||
{ label: "At least 8 characters", test: (v: string) => v.length >= 8 },
|
{ label: "At least 8 characters", test: (v: string) => v.length >= 8 },
|
||||||
@@ -20,11 +20,22 @@ const passwordRequirements = [
|
|||||||
{ label: "One special character", test: (v: string) => /[^A-Za-z0-9]/.test(v) },
|
{ label: "One special character", test: (v: string) => /[^A-Za-z0-9]/.test(v) },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
const ETHIOPIA_COUNTRY_CODE = "+251";
|
||||||
|
|
||||||
|
const isValidEthiopianMobile = (value: string) => {
|
||||||
|
const digits = value.replace(/\D/g, "");
|
||||||
|
const normalized = digits.startsWith("0") ? digits.slice(1) : digits;
|
||||||
|
return /^9\d{8}$/.test(normalized);
|
||||||
|
};
|
||||||
|
|
||||||
const userSchema = z
|
const userSchema = z
|
||||||
.object({
|
.object({
|
||||||
email: z.string().email("Invalid email address"),
|
email: z.string().email("Invalid email address"),
|
||||||
countryCode: z.string().min(1, "Country code is required"),
|
countryCode: z.literal(ETHIOPIA_COUNTRY_CODE),
|
||||||
phone: z.string().min(9, "Phone number is too short").max(9, "Phone number is too long"),
|
phone: z
|
||||||
|
.string()
|
||||||
|
.min(1, "Phone number is required")
|
||||||
|
.refine(isValidEthiopianMobile, "Enter a valid mobile number (e.g. 0912345678)"),
|
||||||
userType: z.string(),
|
userType: z.string(),
|
||||||
firstName: z.object({ en: z.string().min(2, "Name is required"), am: z.string().nullable() }),
|
firstName: z.object({ en: z.string().min(2, "Name is required"), am: z.string().nullable() }),
|
||||||
lastName: z.object({ en: z.string().min(2, "Name is required"), am: z.string().nullable() }),
|
lastName: z.object({ en: z.string().min(2, "Name is required"), am: z.string().nullable() }),
|
||||||
@@ -44,11 +55,16 @@ const userSchema = z
|
|||||||
|
|
||||||
type FormData = z.infer<typeof userSchema>;
|
type FormData = z.infer<typeof userSchema>;
|
||||||
|
|
||||||
|
const errorText = (msg?: string) =>
|
||||||
|
msg ? <p className="mt-1 text-xs text-red-600">{msg}</p> : null;
|
||||||
|
|
||||||
export default function SignupPage() {
|
export default function SignupPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { signup } = useAuth();
|
const { signup } = useAuth();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [showConfirm, setShowConfirm] = useState(false);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
@@ -59,7 +75,7 @@ export default function SignupPage() {
|
|||||||
resolver: zodResolver(userSchema),
|
resolver: zodResolver(userSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
email: "",
|
email: "",
|
||||||
countryCode: "+251",
|
countryCode: ETHIOPIA_COUNTRY_CODE,
|
||||||
phone: "",
|
phone: "",
|
||||||
userType: userType.individual,
|
userType: userType.individual,
|
||||||
firstName: { en: "", am: "" },
|
firstName: { en: "", am: "" },
|
||||||
@@ -73,7 +89,8 @@ export default function SignupPage() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const normalizedPhone = data.phone.startsWith("0") ? data.phone.slice(1) : data.phone;
|
const digits = data.phone.replace(/\D/g, "");
|
||||||
|
const normalizedPhone = digits.startsWith("0") ? digits.slice(1) : digits;
|
||||||
const payload: SignupPayload = {
|
const payload: SignupPayload = {
|
||||||
email: data.email,
|
email: data.email,
|
||||||
username: data.email,
|
username: data.email,
|
||||||
@@ -102,145 +119,194 @@ export default function SignupPage() {
|
|||||||
const passwordValue = watch("password") ?? "";
|
const passwordValue = watch("password") ?? "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthLayout
|
<AuthShell
|
||||||
left={{
|
tagline="Smart Freight Operations"
|
||||||
badge: "Smart Freight Operations",
|
taglineBody="Join EDR Freight to manage shipments, track consignments, and streamline logistics workflows across Ethiopia and Djibouti."
|
||||||
title: "Create your freight operations account",
|
|
||||||
description:
|
|
||||||
"Join EDR Freight to manage shipments, monitor railway operations, track consignments, and streamline logistics workflows across Ethiopia and Djibouti.",
|
|
||||||
features: [
|
|
||||||
"Real-time shipment tracking",
|
|
||||||
"Secure logistics management",
|
|
||||||
"Enterprise-grade operations",
|
|
||||||
"Multi-corridor freight monitoring",
|
|
||||||
],
|
|
||||||
stats: { label: "Active Corridors", value: "24+", footer: "Operational", progress: "w-[95%]" },
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Stack gap="xs" mb="lg">
|
<form className="flex w-full flex-col" onSubmit={handleSubmit(onSubmit)}>
|
||||||
<Box w={48} h={48} bg="edr-soft" className="flex items-center justify-center rounded-2xl">
|
<div className="mb-4 flex justify-center sm:mb-6">
|
||||||
<UserPlus size={22} color="var(--mantine-color-edr-green-6)" />
|
<img src={EDR_LOGO} alt="EDR Freight" className="h-9 w-auto sm:h-11" />
|
||||||
</Box>
|
</div>
|
||||||
<Box>
|
|
||||||
<Text fz={24} fw={800} c="edr-text" className="tracking-tight">
|
<div className="mb-4 space-y-1.5 text-center sm:mb-5">
|
||||||
Create Account
|
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
|
||||||
</Text>
|
Create account
|
||||||
<Text fz={15} c="edr-muted" mt={4}>
|
</h1>
|
||||||
|
<p className="text-sm leading-relaxed text-gray-500">
|
||||||
Register to access EDR Freight services.
|
Register to access EDR Freight services.
|
||||||
</Text>
|
</p>
|
||||||
</Box>
|
</div>
|
||||||
</Stack>
|
|
||||||
|
|
||||||
{error && (
|
<div className="flex w-full flex-col gap-4">
|
||||||
<Alert color="red" variant="light" radius="md" mb="md">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
{error}
|
<div className="space-y-1.5">
|
||||||
</Alert>
|
<label className="text-sm font-medium text-gray-800">
|
||||||
)}
|
First name <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
placeholder="John"
|
||||||
|
disabled={loading}
|
||||||
|
className={fieldClass}
|
||||||
|
{...register("firstName.en")}
|
||||||
|
/>
|
||||||
|
{errorText(errors.firstName?.en?.message)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-sm font-medium text-gray-800">
|
||||||
|
Last name <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
placeholder="Doe"
|
||||||
|
disabled={loading}
|
||||||
|
className={fieldClass}
|
||||||
|
{...register("lastName.en")}
|
||||||
|
/>
|
||||||
|
{errorText(errors.lastName?.en?.message)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
<div className="space-y-1.5">
|
||||||
<Stack gap="md">
|
<label className="text-sm font-medium text-gray-800">
|
||||||
<SimpleGrid cols={2} spacing="md">
|
Email <span className="text-red-500">*</span>
|
||||||
<TextInput
|
</label>
|
||||||
label="First Name"
|
<input
|
||||||
placeholder="John"
|
type="email"
|
||||||
|
placeholder="john@example.com"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
error={errors.firstName?.en?.message}
|
className={fieldClass}
|
||||||
{...register("firstName.en")}
|
{...register("email")}
|
||||||
/>
|
/>
|
||||||
<TextInput
|
{errorText(errors.email?.message)}
|
||||||
label="Last Name"
|
</div>
|
||||||
placeholder="Doe"
|
|
||||||
disabled={loading}
|
|
||||||
error={errors.lastName?.en?.message}
|
|
||||||
{...register("lastName.en")}
|
|
||||||
/>
|
|
||||||
</SimpleGrid>
|
|
||||||
|
|
||||||
<TextInput
|
<div className="space-y-1.5">
|
||||||
label="Email Address"
|
<label htmlFor="signup-phone" className="text-sm font-medium text-gray-800">
|
||||||
placeholder="john@example.com"
|
Phone <span className="text-red-500">*</span>
|
||||||
type="email"
|
</label>
|
||||||
disabled={loading}
|
<input type="hidden" {...register("countryCode")} />
|
||||||
error={errors.email?.message}
|
<div
|
||||||
{...register("email")}
|
className={`flex overflow-hidden rounded-xl border bg-white shadow-sm transition-all duration-200 hover:border-gray-300 focus-within:border-primary focus-within:ring-4 focus-within:ring-primary/10 ${
|
||||||
/>
|
errors.phone ? "border-red-300 focus-within:border-red-400 focus-within:ring-red-100" : "border-gray-200/90"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="flex h-11 shrink-0 items-center border-r border-gray-200/90 bg-gray-50 px-3 text-sm font-medium text-gray-600">
|
||||||
|
{ETHIOPIA_COUNTRY_CODE}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
id="signup-phone"
|
||||||
|
type="tel"
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete="tel-national"
|
||||||
|
placeholder="0912345678"
|
||||||
|
maxLength={10}
|
||||||
|
disabled={loading}
|
||||||
|
className="h-11 min-w-0 flex-1 border-0 bg-transparent px-4 text-sm text-gray-900 outline-none placeholder:text-gray-400"
|
||||||
|
{...register("phone", {
|
||||||
|
onChange: (event) => {
|
||||||
|
event.target.value = event.target.value.replace(/\D/g, "").slice(0, 10);
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{errorText(errors.phone?.message)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<PhoneInput
|
<div className="space-y-1.5">
|
||||||
disabled={loading}
|
<label className="text-sm font-medium text-gray-800">
|
||||||
countryCode={{ ...register("countryCode") }}
|
Password <span className="text-red-500">*</span>
|
||||||
phone={{ ...register("phone") }}
|
</label>
|
||||||
countryCodeError={errors.countryCode}
|
<div className="relative">
|
||||||
phoneError={errors.phone}
|
<input
|
||||||
/>
|
type={showPassword ? "text" : "password"}
|
||||||
|
placeholder="Create a strong password"
|
||||||
<Box>
|
disabled={loading}
|
||||||
<PasswordInput
|
className={`${fieldClass} pr-11`}
|
||||||
label="Password"
|
{...register("password")}
|
||||||
placeholder="Create a strong password"
|
/>
|
||||||
disabled={loading}
|
<button
|
||||||
error={errors.password?.message}
|
type="button"
|
||||||
{...register("password")}
|
onClick={() => setShowPassword((current) => !current)}
|
||||||
/>
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 transition-colors hover:text-gray-600"
|
||||||
{passwordValue.length > 0 && (
|
aria-label={showPassword ? "Hide password" : "Show password"}
|
||||||
<Stack gap={4} mt={8}>
|
>
|
||||||
|
{showPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{errorText(errors.password?.message)}
|
||||||
|
{passwordValue.length > 0 ? (
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
{passwordRequirements.map((req) => {
|
{passwordRequirements.map((req) => {
|
||||||
const met = req.test(passwordValue);
|
const met = req.test(passwordValue);
|
||||||
return (
|
return (
|
||||||
<Group key={req.label} gap={6} align="center" wrap="nowrap">
|
<div key={req.label} className="flex items-center gap-2">
|
||||||
<ThemeIcon
|
<span
|
||||||
size={16}
|
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded-full ${
|
||||||
radius="xl"
|
met ? "bg-primary text-primary-foreground" : "bg-gray-200 text-gray-500"
|
||||||
variant={met ? "filled" : "light"}
|
}`}
|
||||||
color={met ? "edr-green" : "gray"}
|
|
||||||
>
|
>
|
||||||
{met ? <Check size={10} /> : <X size={10} />}
|
{met ? <Check className="h-2.5 w-2.5" /> : <X className="h-2.5 w-2.5" />}
|
||||||
</ThemeIcon>
|
</span>
|
||||||
<Text size="xs" c={met ? "edr-green.7" : "edr-muted"}>
|
<span className={`text-xs ${met ? "text-primary" : "text-gray-500"}`}>
|
||||||
{req.label}
|
{req.label}
|
||||||
</Text>
|
</span>
|
||||||
</Group>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Stack>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
</Box>
|
</div>
|
||||||
|
|
||||||
<PasswordInput
|
<div className="space-y-1.5">
|
||||||
label="Confirm Password"
|
<label className="text-sm font-medium text-gray-800">
|
||||||
placeholder="Re-enter your password"
|
Confirm password <span className="text-red-500">*</span>
|
||||||
disabled={loading}
|
</label>
|
||||||
error={errors.confirmPassword?.message}
|
<div className="relative">
|
||||||
{...register("confirmPassword")}
|
<input
|
||||||
/>
|
type={showConfirm ? "text" : "password"}
|
||||||
|
placeholder="Re-enter your password"
|
||||||
|
disabled={loading}
|
||||||
|
className={`${fieldClass} pr-11`}
|
||||||
|
{...register("confirmPassword")}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowConfirm((current) => !current)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 transition-colors hover:text-gray-600"
|
||||||
|
aria-label={showConfirm ? "Hide password" : "Show password"}
|
||||||
|
>
|
||||||
|
{showConfirm ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{errorText(errors.confirmPassword?.message)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<Button
|
{error ? (
|
||||||
|
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2.5 text-sm text-red-700">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
loading={loading}
|
className={`${primaryButtonClass} flex items-center justify-center gap-2`}
|
||||||
size="lg"
|
|
||||||
color="edr-green"
|
|
||||||
fullWidth
|
|
||||||
rightSection={!loading ? <ArrowRight size={18} /> : undefined}
|
|
||||||
>
|
>
|
||||||
Create Account
|
{loading ? "Creating account..." : "Create Account"}
|
||||||
</Button>
|
{!loading ? <ArrowRight className="h-4 w-4" /> : null}
|
||||||
|
</button>
|
||||||
|
|
||||||
<Text size="sm" c="edr-muted" ta="center">
|
<p className="text-center text-sm text-gray-500">
|
||||||
Already have an account?{" "}
|
Already have an account?{" "}
|
||||||
<Button
|
<button
|
||||||
variant="transparent"
|
type="button"
|
||||||
p={0}
|
|
||||||
h="auto"
|
|
||||||
c="edr-green.6"
|
|
||||||
fw={600}
|
|
||||||
fz="sm"
|
|
||||||
onClick={() => navigate("/login")}
|
onClick={() => navigate("/login")}
|
||||||
|
className="font-semibold text-primary hover:underline"
|
||||||
>
|
>
|
||||||
Sign In
|
Sign In
|
||||||
</Button>
|
</button>
|
||||||
</Text>
|
</p>
|
||||||
</Stack>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</AuthLayout>
|
</AuthShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { useMemo, useRef, useState } from "react";
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
|
import type { SubmitBookingResponse } from "@/services/bookings.service";
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
import { REQUIRED_DOC_FIELDS } from "./constants";
|
import { REQUIRED_DOC_FIELDS } from "./constants";
|
||||||
@@ -60,6 +61,8 @@ export function DraftBookingView({
|
|||||||
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
|
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
|
||||||
const [cancelReason, setCancelReason] = useState("");
|
const [cancelReason, setCancelReason] = useState("");
|
||||||
const [docError, setDocError] = useState("");
|
const [docError, setDocError] = useState("");
|
||||||
|
const [priceChangeModal, setPriceChangeModal] =
|
||||||
|
useState<SubmitBookingResponse | null>(null);
|
||||||
|
|
||||||
const anyFileSelected = Object.values(selectedFiles).some(Boolean);
|
const anyFileSelected = Object.values(selectedFiles).some(Boolean);
|
||||||
const uploadedCodes = useMemo(
|
const uploadedCodes = useMemo(
|
||||||
@@ -72,9 +75,11 @@ export function DraftBookingView({
|
|||||||
const allDocsUploaded = uploadedCount === REQUIRED_DOC_FIELDS.length;
|
const allDocsUploaded = uploadedCount === REQUIRED_DOC_FIELDS.length;
|
||||||
|
|
||||||
const { data: generatedPricing } = useQuery(
|
const { data: generatedPricing } = useQuery(
|
||||||
api.bookings.generatePrice.queryOptions({ input: { id: booking.id },
|
api.bookings.generatePrice.queryOptions({
|
||||||
|
input: { id: booking.id },
|
||||||
enabled: booking.status === "DRAFT" && !booking.pricingBreakdown,
|
enabled:
|
||||||
|
(booking.status === "DRAFT" || booking.status === "CHANGES_REQUESTED") &&
|
||||||
|
!booking.pricingBreakdown,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const pricing = (booking.pricingBreakdown ??
|
const pricing = (booking.pricingBreakdown ??
|
||||||
@@ -82,8 +87,17 @@ export function DraftBookingView({
|
|||||||
null) as Freight.PricingBreakdown | null;
|
null) as Freight.PricingBreakdown | null;
|
||||||
|
|
||||||
const uploadMutation = useMutation({
|
const uploadMutation = useMutation({
|
||||||
mutationFn: (files: Record<string, File | File[] | null>) =>
|
mutationFn: async (files: Record<string, File | File[] | null>) => {
|
||||||
api.bookings.uploadDocuments.call({ id: booking.id, files }),
|
if (booking.status === "CHANGES_REQUESTED") {
|
||||||
|
const result = await api.bookings.update.call({
|
||||||
|
id: booking.id,
|
||||||
|
dto: {},
|
||||||
|
documents: files,
|
||||||
|
});
|
||||||
|
return result.booking;
|
||||||
|
}
|
||||||
|
return api.bookings.uploadDocuments.call({ id: booking.id, files });
|
||||||
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setSelectedFiles({});
|
setSelectedFiles({});
|
||||||
setDocError("");
|
setDocError("");
|
||||||
@@ -93,7 +107,20 @@ export function DraftBookingView({
|
|||||||
|
|
||||||
const submitMutation = useMutation({
|
const submitMutation = useMutation({
|
||||||
mutationFn: () => api.bookings.submit.call({ id: booking.id }),
|
mutationFn: () => api.bookings.submit.call({ id: booking.id }),
|
||||||
|
onSuccess: (result) => {
|
||||||
|
if (result.priceChanged) {
|
||||||
|
setPriceChangeModal(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onBookingUpdated();
|
||||||
|
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const confirmSubmitMutation = useMutation({
|
||||||
|
mutationFn: () => api.bookings.confirmSubmit.call({ id: booking.id }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
setPriceChangeModal(null);
|
||||||
onBookingUpdated();
|
onBookingUpdated();
|
||||||
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
|
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
|
||||||
},
|
},
|
||||||
@@ -155,7 +182,12 @@ export function DraftBookingView({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<MutationErrors
|
<MutationErrors
|
||||||
mutations={[uploadMutation, submitMutation, cancelMutation]}
|
mutations={[
|
||||||
|
uploadMutation,
|
||||||
|
submitMutation,
|
||||||
|
confirmSubmitMutation,
|
||||||
|
cancelMutation,
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<StatusHero booking={booking}>
|
<StatusHero booking={booking}>
|
||||||
@@ -163,7 +195,9 @@ export function DraftBookingView({
|
|||||||
booking.latestChangeRequestNote ? (
|
booking.latestChangeRequestNote ? (
|
||||||
<ActionRequiredBanner
|
<ActionRequiredBanner
|
||||||
title="Review the requested changes, then resubmit."
|
title="Review the requested changes, then resubmit."
|
||||||
onAction={() => navigate(`/bookings/${booking.id}/edit`)}
|
onAction={() =>
|
||||||
|
navigate(`/bookings/${booking.id}/edit?section=documents`)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{booking.latestChangeRequestNote}
|
{booking.latestChangeRequestNote}
|
||||||
</ActionRequiredBanner>
|
</ActionRequiredBanner>
|
||||||
@@ -260,6 +294,8 @@ export function DraftBookingView({
|
|||||||
const isUploaded = uploadedCodes.has(doc.key);
|
const isUploaded = uploadedCodes.has(doc.key);
|
||||||
const selected = selectedFiles[doc.key];
|
const selected = selectedFiles[doc.key];
|
||||||
const file = booking.files?.find((f) => f.code === doc.key);
|
const file = booking.files?.find((f) => f.code === doc.key);
|
||||||
|
const allowReplace =
|
||||||
|
!isUploaded || booking.status === "CHANGES_REQUESTED";
|
||||||
return (
|
return (
|
||||||
<DocRow
|
<DocRow
|
||||||
key={doc.key}
|
key={doc.key}
|
||||||
@@ -276,13 +312,19 @@ export function DraftBookingView({
|
|||||||
isUploaded ? "verified" : selected ? "ready" : "missing"
|
isUploaded ? "verified" : selected ? "ready" : "missing"
|
||||||
}
|
}
|
||||||
action={
|
action={
|
||||||
isUploaded ? (
|
isUploaded && !allowReplace ? (
|
||||||
<IconSquare
|
<IconSquare
|
||||||
href={file?.signedUrl ?? file?.url}
|
href={file?.signedUrl ?? file?.url}
|
||||||
icon={<Download size={16} />}
|
icon={<Download size={16} />}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
{isUploaded && (
|
||||||
|
<IconSquare
|
||||||
|
href={file?.signedUrl ?? file?.url}
|
||||||
|
icon={<Download size={16} />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<input
|
<input
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
fileInputRefs.current[doc.key] = el;
|
fileInputRefs.current[doc.key] = el;
|
||||||
@@ -318,7 +360,7 @@ export function DraftBookingView({
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{selected ? "Change" : "Add"}
|
{selected ? "Change" : isUploaded ? "Replace" : "Add"}
|
||||||
</Button>
|
</Button>
|
||||||
{selected && (
|
{selected && (
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
@@ -375,6 +417,72 @@ export function DraftBookingView({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
opened={priceChangeModal !== null}
|
||||||
|
onClose={() => setPriceChangeModal(null)}
|
||||||
|
title={<Text fw={700}>Price has changed</Text>}
|
||||||
|
radius="lg"
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
{priceChangeModal && (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{priceChangeModal.message ??
|
||||||
|
"The booking price has been updated. Confirm to submit with the new total."}
|
||||||
|
</Text>
|
||||||
|
{priceChangeModal.previousTotalAmount !== undefined && (
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Previous total
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" td="line-through">
|
||||||
|
{priceChangeModal.previousTotalAmount.toLocaleString()}{" "}
|
||||||
|
{priceChangeModal.currency}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text fw={700}>New total</Text>
|
||||||
|
<Text fw={800} c="edr-green">
|
||||||
|
{priceChangeModal.totalAmount.toLocaleString()}{" "}
|
||||||
|
{priceChangeModal.currency}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
{priceChangeModal.lineItems && priceChangeModal.lineItems.length > 0 && (
|
||||||
|
<Stack gap={4}>
|
||||||
|
{priceChangeModal.lineItems.map((item) => (
|
||||||
|
<Group key={item.code} justify="space-between">
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{item.description}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
{item.amount.toLocaleString()} {item.currency}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
<Group justify="flex-end" gap="sm">
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
radius="md"
|
||||||
|
onClick={() => setPriceChangeModal(null)}
|
||||||
|
>
|
||||||
|
Review later
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
radius="md"
|
||||||
|
loading={confirmSubmitMutation.isPending}
|
||||||
|
onClick={() => confirmSubmitMutation.mutate()}
|
||||||
|
>
|
||||||
|
Confirm & submit
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
opened={cancelDialogOpen}
|
opened={cancelDialogOpen}
|
||||||
onClose={() => setCancelDialogOpen(false)}
|
onClose={() => setCancelDialogOpen(false)}
|
||||||
|
|||||||
@@ -12,7 +12,11 @@ import { ActivityCard } from "./components/ActivityCard";
|
|||||||
import { ContractCard } from "./components/ContractCard";
|
import { ContractCard } from "./components/ContractCard";
|
||||||
import { DocRow, IconSquare } from "./components/Documents";
|
import { DocRow, IconSquare } from "./components/Documents";
|
||||||
import { BodyGrid, CardTitle, PageShell, SectionCard } from "./components/layout";
|
import { BodyGrid, CardTitle, PageShell, SectionCard } from "./components/layout";
|
||||||
import { CancelledBanner } from "./components/Notices";
|
import {
|
||||||
|
CancelledBanner,
|
||||||
|
ConsolidationPairedNotice,
|
||||||
|
ConsolidationWaitingBanner,
|
||||||
|
} from "./components/Notices";
|
||||||
import { HeaderButton, PageHeader } from "./components/PageHeader";
|
import { HeaderButton, PageHeader } from "./components/PageHeader";
|
||||||
import { PaymentDeadlineCard } from "./components/PaymentDeadlineCard";
|
import { PaymentDeadlineCard } from "./components/PaymentDeadlineCard";
|
||||||
import { PaymentMethodModal } from "./components/PaymentMethodModal";
|
import { PaymentMethodModal } from "./components/PaymentMethodModal";
|
||||||
@@ -48,6 +52,13 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
|
|||||||
status === "SELECTED_FOR_BATCH" && booking.paymentStatus !== "PAID";
|
status === "SELECTED_FOR_BATCH" && booking.paymentStatus !== "PAID";
|
||||||
const showCountdown = canPay && !!booking.paymentDeadline;
|
const showCountdown = canPay && !!booking.paymentDeadline;
|
||||||
const isExpired = status === "EXPIRED";
|
const isExpired = status === "EXPIRED";
|
||||||
|
const isPendingConsolidation = status === "PENDING_CONSOLIDATION";
|
||||||
|
// Paired: a consolidation partner was found and the booking resumed the normal
|
||||||
|
// flow. Surface the "partner found" reassurance only in the early stages,
|
||||||
|
// before approval, so it doesn't linger for the rest of the booking's life.
|
||||||
|
const showPairedNotice =
|
||||||
|
!!booking.consolidationPartnerId &&
|
||||||
|
["SUBMITTED", "PENDING_APPROVAL", "CHANGES_REQUESTED"].includes(status);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
@@ -92,10 +103,16 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
|
|||||||
subtitle="Payment wasn't completed in time, so this booking lost its slot. Rebook to try another schedule."
|
subtitle="Payment wasn't completed in time, so this booking lost its slot. Rebook to try another schedule."
|
||||||
onRebook={() => navigate("/bookings/new")}
|
onRebook={() => navigate("/bookings/new")}
|
||||||
/>
|
/>
|
||||||
|
) : isPendingConsolidation ? (
|
||||||
|
<ConsolidationWaitingBanner
|
||||||
|
priceLabel={pricing ? priceTotal(pricing) : undefined}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<StatusHero booking={booking} />
|
<StatusHero booking={booking} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showPairedNotice && <ConsolidationPairedNotice />}
|
||||||
|
|
||||||
<ContractCard booking={booking} navigate={navigate} />
|
<ContractCard booking={booking} navigate={navigate} />
|
||||||
|
|
||||||
<BodyGrid
|
<BodyGrid
|
||||||
@@ -163,6 +180,7 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
amountLabel={pricing ? priceTotal(pricing) : undefined}
|
amountLabel={pricing ? priceTotal(pricing) : undefined}
|
||||||
|
currency={pricing?.currency ?? booking.paymentCurrency}
|
||||||
processing={payMutation.isPending}
|
processing={payMutation.isPending}
|
||||||
error={
|
error={
|
||||||
payMutation.isError
|
payMutation.isError
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import { Box, Button, Group, Paper, Stack, Text } from "@mantine/core";
|
import { Box, Button, Group, Paper, Stack, Text } from "@mantine/core";
|
||||||
import { AlertCircle, AlertTriangle, PencilLine, StickyNote, XCircle } from "lucide-react";
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
AlertTriangle,
|
||||||
|
CheckCircle2,
|
||||||
|
Link2,
|
||||||
|
PencilLine,
|
||||||
|
StickyNote,
|
||||||
|
XCircle,
|
||||||
|
} from "lucide-react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
export function NoticeBanner({
|
export function NoticeBanner({
|
||||||
@@ -186,6 +194,90 @@ export function CancelledBanner({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shown to the customer while their booking is PENDING_CONSOLIDATION: it is
|
||||||
|
* waiting for another shipment to share the wagon. The price shown is this
|
||||||
|
* booking's own held amount — bookings and contracts are independent, so the
|
||||||
|
* partner's amount is never shown. Once a partner is found the backend moves
|
||||||
|
* the booking back to SUBMITTED and it continues the normal flow.
|
||||||
|
*/
|
||||||
|
export function ConsolidationWaitingBanner({
|
||||||
|
priceLabel,
|
||||||
|
}: {
|
||||||
|
priceLabel?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Paper radius={16} p={20} bg="#FDF3E0" className="border border-[#F4D9A8]">
|
||||||
|
<Group justify="space-between" align="center" wrap="wrap" gap={20}>
|
||||||
|
<Group gap={16} align="center" wrap="nowrap" miw={0}>
|
||||||
|
<div
|
||||||
|
className="flex shrink-0 items-center justify-center rounded-[13px] border border-[#F4D9A8]"
|
||||||
|
style={{ width: 46, height: 46, backgroundColor: "#fff", color: "#C77F12" }}
|
||||||
|
>
|
||||||
|
<Link2 size={24} />
|
||||||
|
</div>
|
||||||
|
<Box miw={0}>
|
||||||
|
<span
|
||||||
|
className="inline-flex rounded-full px-[10px] py-1 text-[10.5px] font-extrabold uppercase tracking-[0.3px] text-white"
|
||||||
|
style={{ backgroundColor: "#C77F12" }}
|
||||||
|
>
|
||||||
|
Waiting for a partner
|
||||||
|
</span>
|
||||||
|
<Text mt={6} fz="15.5px" fw={700} c="#10202F">
|
||||||
|
Your shipment is waiting to share a wagon
|
||||||
|
</Text>
|
||||||
|
<Text mt={2} fz="13px" c="#7A6A4E" className="leading-[1.45]">
|
||||||
|
Your cargo only fills part of a wagon, so we’re pairing it with
|
||||||
|
another shipment on the same route to share the space. As soon as a
|
||||||
|
matching shipment is found, your booking continues automatically —
|
||||||
|
acceptance, approval and contract stay independent and yours alone.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
{priceLabel && (
|
||||||
|
<div
|
||||||
|
className="flex shrink-0 flex-col items-end rounded-xl border border-[#F4D9A8] px-[16px] py-[12px]"
|
||||||
|
style={{ backgroundColor: "#fff" }}
|
||||||
|
>
|
||||||
|
<Text fz="10.5px" fw={700} c="#B07A2A" tt="uppercase" className="tracking-[0.5px]">
|
||||||
|
Your price (held)
|
||||||
|
</Text>
|
||||||
|
<Text mt={3} fz="18px" fw={800} c="#10202F">
|
||||||
|
{priceLabel}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A brief positive notice shown once a consolidation partner has been found and
|
||||||
|
* the booking has resumed the normal flow (SUBMITTED with a partner linked).
|
||||||
|
* Reassures the customer the wait ended; the booking proceeds independently.
|
||||||
|
*/
|
||||||
|
export function ConsolidationPairedNotice() {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex items-start gap-3 rounded-[14px] border p-4"
|
||||||
|
style={{ borderColor: "#BFE6C9", backgroundColor: "#EAF7EE", color: "#1B7A3D" }}
|
||||||
|
>
|
||||||
|
<CheckCircle2 size={18} className="mt-0.5 shrink-0" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<Text fz="13.5px" fw={800} c="#1B7A3D">
|
||||||
|
Consolidation partner found
|
||||||
|
</Text>
|
||||||
|
<Text fz="13px" c="#2E6B43" className="leading-[1.45]">
|
||||||
|
A matching shipment was found to share the wagon, so your booking is
|
||||||
|
back on track and now moving through review and approval as usual.
|
||||||
|
Nothing more is needed from you for now.
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function MutationErrors({
|
export function MutationErrors({
|
||||||
mutations,
|
mutations,
|
||||||
}: {
|
}: {
|
||||||
|
|||||||
@@ -69,11 +69,18 @@ export function PaymentDeadlineCard({
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [deadlineMs]);
|
}, [deadlineMs]);
|
||||||
|
|
||||||
const accentBg = remaining.expired ? "#FBEAE7" : "#FDF3E0";
|
const accentBg = remaining.expired ? "#FBEAE7" : "#FEF6E6";
|
||||||
const accentFg = remaining.expired ? "#C0392B" : "#9A5B00";
|
const accentFg = remaining.expired ? "#C0392B" : "#B07D14";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionCard p={22}>
|
<SectionCard
|
||||||
|
p={22}
|
||||||
|
style={
|
||||||
|
remaining.expired
|
||||||
|
? undefined
|
||||||
|
: { borderColor: "#F2E4C4", boxShadow: "0 0 0 1px #FBEAC2" }
|
||||||
|
}
|
||||||
|
>
|
||||||
<Group justify="space-between" align="center">
|
<Group justify="space-between" align="center">
|
||||||
<CardTitle>Payment deadline</CardTitle>
|
<CardTitle>Payment deadline</CardTitle>
|
||||||
<Group
|
<Group
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Box, Button, Group, Modal, Stack, Text } from "@mantine/core";
|
import { Box, Button, Group, Image, Modal, Stack, Text } from "@mantine/core";
|
||||||
import { Smartphone, type LucideIcon } from "lucide-react";
|
import { Check, ShieldCheck } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
import type { PaymentMethod } from "@/services/payments.service";
|
import type { PaymentMethod } from "@/services/payments.service";
|
||||||
|
|
||||||
@@ -8,7 +8,10 @@ interface ProviderOption {
|
|||||||
method: PaymentMethod;
|
method: PaymentMethod;
|
||||||
label: string;
|
label: string;
|
||||||
description: string;
|
description: string;
|
||||||
icon: LucideIcon;
|
logo: string;
|
||||||
|
/** Currencies this provider settles in. */
|
||||||
|
currencies: string[];
|
||||||
|
accent: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only Telebirr and Waafi are enabled for now.
|
// Only Telebirr and Waafi are enabled for now.
|
||||||
@@ -16,17 +19,32 @@ const PROVIDERS: ProviderOption[] = [
|
|||||||
{
|
{
|
||||||
method: "TELEBIRR",
|
method: "TELEBIRR",
|
||||||
label: "telebirr",
|
label: "telebirr",
|
||||||
description: "Ethiopian mobile money",
|
description: "Ethiopian mobile money · ETB",
|
||||||
icon: Smartphone,
|
logo: "/assets/telebirr.jpeg",
|
||||||
|
currencies: ["ETB"],
|
||||||
|
accent: "#0A6F4D",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: "WAAFI",
|
method: "WAAFI",
|
||||||
label: "Waafi",
|
label: "Waafi",
|
||||||
description: "Djibouti mobile money",
|
description: "Djibouti mobile money · USD",
|
||||||
icon: Smartphone,
|
logo: "/assets/waafi.jpeg",
|
||||||
|
currencies: ["USD"],
|
||||||
|
accent: "#2E5B96",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick the provider that settles in the booking's currency. USD → Waafi,
|
||||||
|
* ETB → Telebirr. Falls back to the first provider when unknown.
|
||||||
|
*/
|
||||||
|
function providersForCurrency(currency?: string | null): ProviderOption[] {
|
||||||
|
const cur = currency?.trim().toUpperCase();
|
||||||
|
if (!cur) return PROVIDERS;
|
||||||
|
const matched = PROVIDERS.filter((p) => p.currencies.includes(cur));
|
||||||
|
return matched.length > 0 ? matched : PROVIDERS;
|
||||||
|
}
|
||||||
|
|
||||||
function ProviderRow({
|
function ProviderRow({
|
||||||
option,
|
option,
|
||||||
selected,
|
selected,
|
||||||
@@ -36,55 +54,75 @@ function ProviderRow({
|
|||||||
selected: boolean;
|
selected: boolean;
|
||||||
onSelect: () => void;
|
onSelect: () => void;
|
||||||
}) {
|
}) {
|
||||||
const Icon = option.icon;
|
|
||||||
return (
|
return (
|
||||||
<Group
|
<Group
|
||||||
onClick={onSelect}
|
onClick={onSelect}
|
||||||
gap={12}
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
onSelect();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
gap={14}
|
||||||
wrap="nowrap"
|
wrap="nowrap"
|
||||||
|
align="center"
|
||||||
style={{
|
style={{
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
borderRadius: 12,
|
borderRadius: 14,
|
||||||
padding: "13px 14px",
|
padding: "14px 16px",
|
||||||
border: `1.5px solid ${selected ? "#0A6F4D" : "#E6ECF1"}`,
|
border: `1.5px solid ${selected ? option.accent : "#E6ECF1"}`,
|
||||||
backgroundColor: selected ? "#ECF6F1" : "#fff",
|
backgroundColor: selected ? "#F6FBF8" : "#fff",
|
||||||
transition: "border-color .12s, background-color .12s",
|
boxShadow: selected
|
||||||
|
? `0 0 0 1px ${option.accent}, 0 6px 18px rgba(16,24,40,0.06)`
|
||||||
|
: "none",
|
||||||
|
transition: "border-color .14s, box-shadow .14s, background-color .14s",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box
|
<Box
|
||||||
style={{
|
style={{
|
||||||
width: 40,
|
width: 52,
|
||||||
height: 40,
|
height: 52,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
display: "flex",
|
borderRadius: 12,
|
||||||
alignItems: "center",
|
overflow: "hidden",
|
||||||
justifyContent: "center",
|
border: "1px solid #EEF2F6",
|
||||||
borderRadius: 10,
|
backgroundColor: "#fff",
|
||||||
backgroundColor: selected ? "#0A6F4D" : "#F1F4F7",
|
|
||||||
color: selected ? "#fff" : "#475569",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Icon size={19} />
|
<Image
|
||||||
|
src={option.logo}
|
||||||
|
alt={`${option.label} logo`}
|
||||||
|
w={52}
|
||||||
|
h={52}
|
||||||
|
fit="cover"
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
<Box style={{ flex: 1 }}>
|
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||||
<Text fz="14px" fw={700} c="#10202F">
|
<Text fz="15px" fw={800} c="#10202F" tt="capitalize">
|
||||||
{option.label}
|
{option.label}
|
||||||
</Text>
|
</Text>
|
||||||
<Text fz="12.5px" c="#9AA8B5">
|
<Text fz="12.5px" c="#7A8794" truncate>
|
||||||
{option.description}
|
{option.description}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Box
|
<Box
|
||||||
style={{
|
style={{
|
||||||
width: 18,
|
width: 22,
|
||||||
height: 18,
|
height: 22,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
borderRadius: "50%",
|
borderRadius: "50%",
|
||||||
border: `2px solid ${selected ? "#0A6F4D" : "#CBD5E1"}`,
|
border: `2px solid ${selected ? option.accent : "#CBD5E1"}`,
|
||||||
backgroundColor: selected ? "#0A6F4D" : "transparent",
|
backgroundColor: selected ? option.accent : "transparent",
|
||||||
boxShadow: selected ? "inset 0 0 0 3px #fff" : undefined,
|
transition: "all .14s",
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
|
{selected && <Check size={13} color="#fff" strokeWidth={3} />}
|
||||||
|
</Box>
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -93,6 +131,7 @@ export function PaymentMethodModal({
|
|||||||
opened,
|
opened,
|
||||||
onClose,
|
onClose,
|
||||||
amountLabel,
|
amountLabel,
|
||||||
|
currency,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
processing,
|
processing,
|
||||||
error,
|
error,
|
||||||
@@ -101,67 +140,126 @@ export function PaymentMethodModal({
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
/** Human-readable total, e.g. "ETB 12,500". */
|
/** Human-readable total, e.g. "ETB 12,500". */
|
||||||
amountLabel?: string;
|
amountLabel?: string;
|
||||||
|
/** Booking payment currency — drives which provider is shown (USD → Waafi, ETB → Telebirr). */
|
||||||
|
currency?: string | null;
|
||||||
onConfirm: (method: PaymentMethod) => void;
|
onConfirm: (method: PaymentMethod) => void;
|
||||||
processing?: boolean;
|
processing?: boolean;
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
}) {
|
}) {
|
||||||
const [method, setMethod] = useState<PaymentMethod>(PROVIDERS[0].method);
|
const providers = useMemo(() => providersForCurrency(currency), [currency]);
|
||||||
|
const [method, setMethod] = useState<PaymentMethod>(providers[0].method);
|
||||||
|
|
||||||
|
// Keep the selection valid when the currency (and therefore provider list) changes.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!providers.some((p) => p.method === method)) {
|
||||||
|
setMethod(providers[0].method);
|
||||||
|
}
|
||||||
|
}, [providers, method]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
opened={opened}
|
opened={opened}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
centered
|
centered
|
||||||
radius="lg"
|
radius={18}
|
||||||
size={460}
|
size={480}
|
||||||
title={
|
padding={0}
|
||||||
<Stack gap={2}>
|
withCloseButton={false}
|
||||||
<Text fw={800} fz="17px" c="#10202F">
|
overlayProps={{ backgroundOpacity: 0.45, blur: 3 }}
|
||||||
Choose a payment method
|
|
||||||
</Text>
|
|
||||||
{amountLabel && (
|
|
||||||
<Text fz="12.5px" c="#9AA8B5">
|
|
||||||
Amount due: {amountLabel}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Stack gap={10}>
|
{/* Header */}
|
||||||
{PROVIDERS.map((option) => (
|
<Box px={24} pt={24} pb={18}>
|
||||||
<ProviderRow
|
<Text fw={800} fz="19px" c="#10202F" lh={1.2}>
|
||||||
key={option.method}
|
Complete your payment
|
||||||
option={option}
|
</Text>
|
||||||
selected={method === option.method}
|
<Text mt={4} fz="13px" c="#7A8794">
|
||||||
onSelect={() => setMethod(option.method)}
|
Choose how you'd like to pay for this booking.
|
||||||
/>
|
</Text>
|
||||||
))}
|
|
||||||
|
|
||||||
|
{amountLabel && (
|
||||||
|
<Group
|
||||||
|
mt={16}
|
||||||
|
justify="space-between"
|
||||||
|
align="center"
|
||||||
|
px={16}
|
||||||
|
py={13}
|
||||||
|
style={{
|
||||||
|
borderRadius: 12,
|
||||||
|
background:
|
||||||
|
"linear-gradient(135deg, #FEF8EC 0%, #F4FAF7 100%)",
|
||||||
|
border: "1px solid #F2E4C4",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text fz="12.5px" fw={700} c="#B07D14" tt="uppercase" style={{ letterSpacing: 0.5 }}>
|
||||||
|
Amount due
|
||||||
|
</Text>
|
||||||
|
<Text fz="20px" fw={800} c="#10202F">
|
||||||
|
{amountLabel}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Provider options */}
|
||||||
|
<Box px={24} pb={4}>
|
||||||
|
<Text fz="11.5px" fw={700} c="#9AA8B5" tt="uppercase" mb={10} style={{ letterSpacing: 0.6 }}>
|
||||||
|
Payment method
|
||||||
|
</Text>
|
||||||
|
<Stack gap={10}>
|
||||||
|
{providers.map((option) => (
|
||||||
|
<ProviderRow
|
||||||
|
key={option.method}
|
||||||
|
option={option}
|
||||||
|
selected={method === option.method}
|
||||||
|
onSelect={() => setMethod(option.method)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<Box px={24} pt={16} pb={22}>
|
||||||
{error && (
|
{error && (
|
||||||
<Text fz="12.5px" c="#C0392B" fw={600}>
|
<Text fz="12.5px" c="#C0392B" fw={600} mb={10}>
|
||||||
{error}
|
{error}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button
|
<Group gap={6} align="center" justify="center" mb={12}>
|
||||||
fullWidth
|
<ShieldCheck size={14} color="#0A8A5F" />
|
||||||
mt={6}
|
<Text fz="11.5px" c="#7A8794">
|
||||||
radius={10}
|
Secured · you'll be redirected to your provider to pay
|
||||||
color="edr-green"
|
</Text>
|
||||||
disabled={processing}
|
</Group>
|
||||||
loading={processing}
|
|
||||||
onClick={() => onConfirm(method)}
|
<Group gap={10} wrap="nowrap">
|
||||||
styles={{
|
<Button
|
||||||
root: { height: 46 },
|
variant="default"
|
||||||
label: { fontSize: 14, fontWeight: 800 },
|
radius={12}
|
||||||
}}
|
onClick={onClose}
|
||||||
>
|
disabled={processing}
|
||||||
{processing ? "Redirecting…" : "Continue to payment"}
|
styles={{
|
||||||
</Button>
|
root: { height: 48, flex: "0 0 38%" },
|
||||||
<Text fz="11.5px" c="#9AA8B5" ta="center">
|
label: { fontSize: 14, fontWeight: 700, color: "#475569" },
|
||||||
You'll be redirected to your provider to complete payment securely.
|
}}
|
||||||
</Text>
|
>
|
||||||
</Stack>
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
radius={12}
|
||||||
|
color="edr-green"
|
||||||
|
disabled={processing}
|
||||||
|
loading={processing}
|
||||||
|
onClick={() => onConfirm(method)}
|
||||||
|
styles={{
|
||||||
|
root: { height: 48, flex: 1 },
|
||||||
|
label: { fontSize: 14, fontWeight: 800 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{processing ? "Redirecting…" : "Continue to payment"}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,7 +61,6 @@ export function ScheduleCard({
|
|||||||
: "Rail only";
|
: "Rail only";
|
||||||
const equipmentReturn =
|
const equipmentReturn =
|
||||||
booking.equipmentReturn === "WITH_RETURN" ? "With return" : "Without return";
|
booking.equipmentReturn === "WITH_RETURN" ? "With return" : "Without return";
|
||||||
const consolidation = booking.allowConsolidation ? "Allowed" : "Not allowed";
|
|
||||||
const assignedTrain: Row = {
|
const assignedTrain: Row = {
|
||||||
label: "Assigned train",
|
label: "Assigned train",
|
||||||
value: booking.trainId ?? "Not yet assigned",
|
value: booking.trainId ?? "Not yet assigned",
|
||||||
@@ -80,7 +79,6 @@ export function ScheduleCard({
|
|||||||
{ label: "Equipment return", value: equipmentReturn },
|
{ label: "Equipment return", value: equipmentReturn },
|
||||||
assignedTrain,
|
assignedTrain,
|
||||||
{ label: "Scheduled", value: fmtDate(booking.scheduledDate) },
|
{ label: "Scheduled", value: fmtDate(booking.scheduledDate) },
|
||||||
{ label: "Consolidation", value: consolidation },
|
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
statusRow,
|
statusRow,
|
||||||
@@ -88,7 +86,6 @@ export function ScheduleCard({
|
|||||||
{ label: "Equipment return", value: equipmentReturn },
|
{ label: "Equipment return", value: equipmentReturn },
|
||||||
{ label: "Proposed date", value: fmtDate(booking.scheduledDate) },
|
{ label: "Proposed date", value: fmtDate(booking.scheduledDate) },
|
||||||
assignedTrain,
|
assignedTrain,
|
||||||
{ label: "Consolidation", value: consolidation },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -44,10 +44,7 @@ export function ShipmentDetailsCard({ booking }: { booking: Freight.IBooking })
|
|||||||
],
|
],
|
||||||
["Scheduled date", fmtDate(booking.scheduledDate)],
|
["Scheduled date", fmtDate(booking.scheduledDate)],
|
||||||
],
|
],
|
||||||
[
|
[["Assigned train", booking.trainId ?? "Not yet assigned"]],
|
||||||
["Consolidation", booking.allowConsolidation ? "Allowed" : "Not allowed"],
|
|
||||||
["Assigned train", booking.trainId ?? "Not yet assigned"],
|
|
||||||
],
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,12 +1,92 @@
|
|||||||
import { Box, Group, Text } from "@mantine/core";
|
import { Box, Group, Text } from "@mantine/core";
|
||||||
import { AlertTriangle, Check, FileText, History } from "lucide-react";
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
Check,
|
||||||
|
FileText,
|
||||||
|
History,
|
||||||
|
MapPin,
|
||||||
|
MoveRight,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
import { PROGRESS_STAGES, STATUS_MAP } from "../constants";
|
import { PROGRESS_STAGES, STATUS_MAP } from "../constants";
|
||||||
import { fmtDate, isDraftLike, isNegative } from "../utils";
|
import { fmtDate, isDraftLike, isNegative, yardLabel } from "../utils";
|
||||||
import { SectionCard } from "./layout";
|
import { SectionCard } from "./layout";
|
||||||
|
|
||||||
|
const ACCENT = "#F2A516";
|
||||||
|
|
||||||
|
/** Origin → destination strip rendered above the progress tracker. */
|
||||||
|
function RouteStrip({ booking }: { booking: Freight.IBooking }) {
|
||||||
|
const origin = yardLabel(booking.originYard);
|
||||||
|
const destination = yardLabel(booking.destinationYard);
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
mb={22}
|
||||||
|
px={18}
|
||||||
|
py={14}
|
||||||
|
className="rounded-2xl"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
"linear-gradient(135deg, #FEF8EC 0%, #FBFCFD 60%, #F4FAF7 100%)",
|
||||||
|
border: "1px solid #F2E4C4",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group justify="space-between" align="center" wrap="nowrap" gap="md">
|
||||||
|
<RouteEndpoint label="Origin" value={origin} />
|
||||||
|
<Box
|
||||||
|
className="flex items-center justify-center rounded-full shrink-0"
|
||||||
|
style={{
|
||||||
|
width: 34,
|
||||||
|
height: 34,
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
border: `1px solid ${ACCENT}33`,
|
||||||
|
color: ACCENT,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MoveRight size={18} />
|
||||||
|
</Box>
|
||||||
|
<RouteEndpoint label="Destination" value={destination} alignRight />
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RouteEndpoint({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
alignRight,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
alignRight?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Box miw={0} style={{ textAlign: alignRight ? "right" : "left", flex: 1 }}>
|
||||||
|
<Group
|
||||||
|
gap={5}
|
||||||
|
align="center"
|
||||||
|
wrap="nowrap"
|
||||||
|
justify={alignRight ? "flex-end" : "flex-start"}
|
||||||
|
>
|
||||||
|
<MapPin size={12} color={ACCENT} />
|
||||||
|
<Text
|
||||||
|
fz="10.5px"
|
||||||
|
fw={700}
|
||||||
|
c="#B07D14"
|
||||||
|
tt="uppercase"
|
||||||
|
className="tracking-[0.6px]"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Text mt={3} fz="15px" fw={800} c="#10202F" truncate>
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function StatusHero({
|
export function StatusHero({
|
||||||
booking,
|
booking,
|
||||||
children,
|
children,
|
||||||
@@ -91,6 +171,8 @@ export function StatusHero({
|
|||||||
|
|
||||||
<Box my={26} h={1} w="100%" bg="#EEF2F6" />
|
<Box my={26} h={1} w="100%" bg="#EEF2F6" />
|
||||||
|
|
||||||
|
{!negative && <RouteStrip booking={booking} />}
|
||||||
|
|
||||||
{children ?? (
|
{children ?? (
|
||||||
<ProgressTracker
|
<ProgressTracker
|
||||||
current={cfg.stage}
|
current={cfg.stage}
|
||||||
|
|||||||
@@ -70,11 +70,11 @@ export function EstimateCard({
|
|||||||
mb={4}
|
mb={4}
|
||||||
style={{
|
style={{
|
||||||
borderRadius: 6,
|
borderRadius: 6,
|
||||||
backgroundColor: "#F1F4F7",
|
backgroundColor: "#FEF6E6",
|
||||||
padding: "3px 7px",
|
padding: "3px 7px",
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
color: "#6B7C8E",
|
color: "#B07D14",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
est.
|
est.
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
SimpleGrid,
|
SimpleGrid,
|
||||||
Stack,
|
Stack,
|
||||||
Switch,
|
Switch,
|
||||||
|
Tabs,
|
||||||
Text,
|
Text,
|
||||||
Textarea,
|
Textarea,
|
||||||
TextInput,
|
TextInput,
|
||||||
@@ -36,7 +37,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useMemo, useRef, type ReactNode } from "react";
|
import { useMemo, useRef, type ReactNode } from "react";
|
||||||
import { Controller, useForm } from "react-hook-form";
|
import { Controller, useForm } from "react-hook-form";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
CountChip,
|
CountChip,
|
||||||
DocRow,
|
DocRow,
|
||||||
@@ -52,12 +53,33 @@ import {
|
|||||||
type BookingFormValues,
|
type BookingFormValues,
|
||||||
} from "./new-booking-form/schema";
|
} from "./new-booking-form/schema";
|
||||||
import { SelectField } from "./new-booking-form/shared";
|
import { SelectField } from "./new-booking-form/shared";
|
||||||
import { Step5CargoDetails } from "./new-booking-form/steps";
|
import { PaymentCurrencyField } from "./new-booking-form/payment-currency-field";
|
||||||
|
import { Step5CargoDetails, StepScheduling } from "./new-booking-form/steps";
|
||||||
|
|
||||||
function yardNameFromBooking(
|
const EDIT_SECTIONS = [
|
||||||
yard: { label?: string; code?: string; name?: string } | undefined | null,
|
"service",
|
||||||
|
"route",
|
||||||
|
"cargo",
|
||||||
|
"schedule",
|
||||||
|
"documents",
|
||||||
|
"notes",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type EditSection = (typeof EDIT_SECTIONS)[number];
|
||||||
|
|
||||||
|
function isEditSection(value: string | null): value is EditSection {
|
||||||
|
return EDIT_SECTIONS.includes(value as EditSection);
|
||||||
|
}
|
||||||
|
|
||||||
|
function yardIdFromBooking(
|
||||||
|
yard: Freight.IYard | null | undefined,
|
||||||
|
referenceData: Freight.BookingReferenceData,
|
||||||
): string {
|
): string {
|
||||||
return yard?.label ?? yard?.name ?? yard?.code ?? "";
|
if (yard?.id) return yard.id;
|
||||||
|
const label = yard?.label ?? "";
|
||||||
|
return (
|
||||||
|
referenceData.yard.find((y) => y.name === label || y.id === label)?.id ?? ""
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fallback container type for a size, used only when a booking row has no
|
/** Fallback container type for a size, used only when a booking row has no
|
||||||
@@ -108,14 +130,19 @@ function mapBookingToFormValues(
|
|||||||
booking.equipmentReturn === "WITH_RETURN"
|
booking.equipmentReturn === "WITH_RETURN"
|
||||||
? "with_return"
|
? "with_return"
|
||||||
: "without_return",
|
: "without_return",
|
||||||
originYard: yardNameFromBooking(booking.originYard),
|
originYard: yardIdFromBooking(booking.originYard, referenceData),
|
||||||
destinationYard: yardNameFromBooking(booking.destinationYard),
|
destinationYard: yardIdFromBooking(booking.destinationYard, referenceData),
|
||||||
cargoType: booking.freightType === "BULK" ? "bulk" : "container",
|
cargoType: booking.freightType === "BULK" ? "bulk" : "container",
|
||||||
cargoWeight: String(booking.cargoTotalWeightVgm ?? ""),
|
cargoWeight: String(booking.cargoTotalWeightVgm ?? ""),
|
||||||
isHazardous: booking.isHazardous ?? false,
|
isHazardous: booking.isHazardous ?? false,
|
||||||
isRefrigerated: booking.isRefrigerated ?? false,
|
isRefrigerated: booking.isRefrigerated ?? false,
|
||||||
shippingLine: (booking as any).shippingLine?.name ?? "",
|
shippingLine: (booking as any).shippingLine?.id ?? "",
|
||||||
consolidationEnabled: booking.allowConsolidation ?? false,
|
consolidationEnabled: booking.allowConsolidation ?? false,
|
||||||
|
paymentCurrency:
|
||||||
|
booking.paymentCurrency === "ETB" ? "ETB" : "USD",
|
||||||
|
scheduledDate: booking.scheduledDate
|
||||||
|
? new Date(booking.scheduledDate).toISOString().slice(0, 10)
|
||||||
|
: "",
|
||||||
notes: "",
|
notes: "",
|
||||||
containers: [],
|
containers: [],
|
||||||
} as BookingFormInputValues;
|
} as BookingFormInputValues;
|
||||||
@@ -237,9 +264,20 @@ const DIRECTION_LABEL: Record<string, string> = {
|
|||||||
export default function EditBookingPage() {
|
export default function EditBookingPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const docInputRefs = useRef<Record<string, HTMLInputElement | null>>({});
|
const docInputRefs = useRef<Record<string, HTMLInputElement | null>>({});
|
||||||
|
|
||||||
|
const sectionParam = searchParams.get("section");
|
||||||
|
const activeSection: EditSection = isEditSection(sectionParam)
|
||||||
|
? sectionParam
|
||||||
|
: "service";
|
||||||
|
|
||||||
|
function setSection(section: EditSection) {
|
||||||
|
setSearchParams({ section });
|
||||||
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
|
}
|
||||||
|
|
||||||
const bookingQuery = useQuery(
|
const bookingQuery = useQuery(
|
||||||
api.bookings.get.queryOptions({
|
api.bookings.get.queryOptions({
|
||||||
input: { id: id! },
|
input: { id: id! },
|
||||||
@@ -269,18 +307,17 @@ export default function EditBookingPage() {
|
|||||||
|
|
||||||
const updateMutation = useMutation({
|
const updateMutation = useMutation({
|
||||||
mutationFn: async (payload: Partial<CreateBookingPayload>) => {
|
mutationFn: async (payload: Partial<CreateBookingPayload>) => {
|
||||||
const result = await api.bookings.update.call({ id: id!, dto: payload });
|
|
||||||
|
|
||||||
// Upload any newly attached documents against the existing booking.
|
|
||||||
const documents = (form.getValues("documents") ?? {}) as BookingDocuments;
|
const documents = (form.getValues("documents") ?? {}) as BookingDocuments;
|
||||||
const hasDocuments = Object.values(documents).some((value) =>
|
const newDocuments: BookingDocuments = {};
|
||||||
Array.isArray(value) ? value.length > 0 : Boolean(value),
|
for (const [key, value] of Object.entries(documents)) {
|
||||||
);
|
if (value) newDocuments[key] = value;
|
||||||
if (hasDocuments) {
|
|
||||||
await api.bookings.uploadDocuments.call({ id: id!, files: documents });
|
|
||||||
}
|
}
|
||||||
|
return api.bookings.update.call({
|
||||||
return result;
|
id: id!,
|
||||||
|
dto: payload,
|
||||||
|
documents:
|
||||||
|
Object.keys(newDocuments).length > 0 ? newDocuments : undefined,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
|
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
|
||||||
@@ -304,9 +341,9 @@ export default function EditBookingPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const direction = useMemo(() => {
|
const direction = useMemo(() => {
|
||||||
const origin = referenceData?.yard.find((y) => y.name === originYard);
|
const origin = referenceData?.yard.find((y) => y.id === originYard);
|
||||||
const destination = referenceData?.yard.find(
|
const destination = referenceData?.yard.find(
|
||||||
(y) => y.name === destinationYard,
|
(y) => y.id === destinationYard,
|
||||||
);
|
);
|
||||||
return getRouteDirection(origin, destination);
|
return getRouteDirection(origin, destination);
|
||||||
}, [originYard, destinationYard, referenceData]);
|
}, [originYard, destinationYard, referenceData]);
|
||||||
@@ -314,7 +351,7 @@ export default function EditBookingPage() {
|
|||||||
const yardOptions = useMemo(() => {
|
const yardOptions = useMemo(() => {
|
||||||
if (!referenceData?.yard) return [];
|
if (!referenceData?.yard) return [];
|
||||||
return referenceData.yard.map((y) => ({
|
return referenceData.yard.map((y) => ({
|
||||||
value: y.name,
|
value: y.id,
|
||||||
label: y.name,
|
label: y.name,
|
||||||
country: y.country,
|
country: y.country,
|
||||||
}));
|
}));
|
||||||
@@ -322,10 +359,16 @@ export default function EditBookingPage() {
|
|||||||
|
|
||||||
const shippingLineOptions = useMemo(() => {
|
const shippingLineOptions = useMemo(() => {
|
||||||
if (!referenceData?.shipping_line) return [];
|
if (!referenceData?.shipping_line) return [];
|
||||||
return referenceData.shipping_line.map((sl) => ({
|
// Dedupe by name (the value the form keys on) so two lines sharing a name
|
||||||
value: sl.name,
|
// can't produce a duplicate Select option and crash Mantine.
|
||||||
label: sl.name,
|
const seen = new Set<string>();
|
||||||
}));
|
const options: { value: string; label: string }[] = [];
|
||||||
|
for (const sl of referenceData.shipping_line) {
|
||||||
|
if (!sl.name || seen.has(sl.name)) continue;
|
||||||
|
seen.add(sl.name);
|
||||||
|
options.push({ value: sl.name, label: sl.name });
|
||||||
|
}
|
||||||
|
return options;
|
||||||
}, [referenceData]);
|
}, [referenceData]);
|
||||||
|
|
||||||
const setDocument = (key: string, file: File | null) => {
|
const setDocument = (key: string, file: File | null) => {
|
||||||
@@ -338,17 +381,8 @@ export default function EditBookingPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = form.handleSubmit((data) => {
|
const handleSubmit = form.handleSubmit((data) => {
|
||||||
const yards = referenceData?.yard ?? [];
|
|
||||||
const services = referenceData?.service ?? [];
|
|
||||||
const shippingLines = referenceData?.shipping_line ?? [];
|
|
||||||
const containerGroups = referenceData?.containers ?? [];
|
const containerGroups = referenceData?.containers ?? [];
|
||||||
|
|
||||||
const findYardId = (name: string): string =>
|
|
||||||
yards.find((y) => y.name === name)?.id ?? "";
|
|
||||||
|
|
||||||
const findShippingLineId = (name: string): string | undefined =>
|
|
||||||
shippingLines.find((l) => l.name === name)?.id;
|
|
||||||
|
|
||||||
const cargoTypePath = data.cargoTypePath ?? [];
|
const cargoTypePath = data.cargoTypePath ?? [];
|
||||||
const cargoTypeId =
|
const cargoTypeId =
|
||||||
data.cargoType === "container" ? undefined : (cargoTypePath[1] ?? "");
|
data.cargoType === "container" ? undefined : (cargoTypePath[1] ?? "");
|
||||||
@@ -369,10 +403,16 @@ export default function EditBookingPage() {
|
|||||||
)
|
)
|
||||||
: Number(data.cargoWeight || 0);
|
: Number(data.cargoWeight || 0);
|
||||||
|
|
||||||
const selectedSvc = services.find((s) => s.id === data.serviceTypeId);
|
const selectedSvc = referenceData?.service.find(
|
||||||
|
(s) => s.id === data.serviceTypeId,
|
||||||
|
);
|
||||||
|
|
||||||
const apiPayload: Partial<CreateBookingPayload> = {
|
const apiPayload: Partial<CreateBookingPayload> = {
|
||||||
scheduledDate: new Date().toISOString().slice(0, 10),
|
scheduledDate: data.scheduledDate
|
||||||
|
? new Date(data.scheduledDate).toISOString()
|
||||||
|
: undefined,
|
||||||
|
// Day-level pool: the customer edits only the day; the engine assigns the
|
||||||
|
// train, so trainScheduleId is not sent.
|
||||||
contractType:
|
contractType:
|
||||||
data.contractType.toUpperCase() as CreateBookingPayload["contractType"],
|
data.contractType.toUpperCase() as CreateBookingPayload["contractType"],
|
||||||
serviceTypeId: data.serviceTypeId,
|
serviceTypeId: data.serviceTypeId,
|
||||||
@@ -380,8 +420,8 @@ export default function EditBookingPage() {
|
|||||||
data.equipmentReturn === "with_return"
|
data.equipmentReturn === "with_return"
|
||||||
? "WITH_RETURN"
|
? "WITH_RETURN"
|
||||||
: "WITHOUT_RETURN",
|
: "WITHOUT_RETURN",
|
||||||
originYardId: findYardId(data.originYard),
|
originYardId: data.originYard,
|
||||||
destinationYardId: findYardId(data.destinationYard),
|
destinationYardId: data.destinationYard,
|
||||||
tradeDirection:
|
tradeDirection:
|
||||||
direction === "EXPORT"
|
direction === "EXPORT"
|
||||||
? "EXPORT"
|
? "EXPORT"
|
||||||
@@ -391,9 +431,8 @@ export default function EditBookingPage() {
|
|||||||
cargoTypeId: data.cargoType === "container" ? undefined : cargoTypeId,
|
cargoTypeId: data.cargoType === "container" ? undefined : cargoTypeId,
|
||||||
cargoTotalWeightVgm: totalWeight,
|
cargoTotalWeightVgm: totalWeight,
|
||||||
isHazardous: data.isHazardous,
|
isHazardous: data.isHazardous,
|
||||||
paymentCurrency: "USD",
|
paymentCurrency: data.paymentCurrency,
|
||||||
allowConsolidation: data.consolidationEnabled,
|
allowConsolidation: data.consolidationEnabled,
|
||||||
// @ts-ignore
|
|
||||||
freightType:
|
freightType:
|
||||||
data.cargoType === "container"
|
data.cargoType === "container"
|
||||||
? ("CONTAINER" as const)
|
? ("CONTAINER" as const)
|
||||||
@@ -419,7 +458,7 @@ export default function EditBookingPage() {
|
|||||||
? { lastMileDeliveryAddress: data.lastMile.deliveryAddress }
|
? { lastMileDeliveryAddress: data.lastMile.deliveryAddress }
|
||||||
: {}),
|
: {}),
|
||||||
...(data.shippingLine
|
...(data.shippingLine
|
||||||
? { shippingLineId: findShippingLineId(data.shippingLine) }
|
? { shippingLineId: data.shippingLine }
|
||||||
: {}),
|
: {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -505,9 +544,34 @@ export default function EditBookingPage() {
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Stack gap={36} mt="xl">
|
{booking.status === "CHANGES_REQUESTED" && (
|
||||||
{/* ── Section 1: Service ── */}
|
<Alert color="orange" icon={<AlertCircle size={16} />} radius="md" mt="lg">
|
||||||
<Stack gap="md">
|
<Text size="sm" fw={600}>
|
||||||
|
Staff requested changes
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" mt={4}>
|
||||||
|
Update the sections below and save. Then return to the booking page to
|
||||||
|
resubmit for review.
|
||||||
|
</Text>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Tabs
|
||||||
|
value={activeSection}
|
||||||
|
onChange={(value) => value && setSection(value as EditSection)}
|
||||||
|
mt="xl"
|
||||||
|
>
|
||||||
|
<Tabs.List mb="lg" style={{ flexWrap: "wrap" }}>
|
||||||
|
<Tabs.Tab value="service">Service</Tabs.Tab>
|
||||||
|
<Tabs.Tab value="route">Route</Tabs.Tab>
|
||||||
|
<Tabs.Tab value="cargo">Cargo</Tabs.Tab>
|
||||||
|
<Tabs.Tab value="schedule">Schedule</Tabs.Tab>
|
||||||
|
<Tabs.Tab value="documents">Documents</Tabs.Tab>
|
||||||
|
<Tabs.Tab value="notes">Notes</Tabs.Tab>
|
||||||
|
</Tabs.List>
|
||||||
|
|
||||||
|
<Tabs.Panel value="service">
|
||||||
|
<Stack gap="md">
|
||||||
<SectionHeading
|
<SectionHeading
|
||||||
title="Service"
|
title="Service"
|
||||||
description="Select the service combination and configure trucking options."
|
description="Select the service combination and configure trucking options."
|
||||||
@@ -548,6 +612,8 @@ export default function EditBookingPage() {
|
|||||||
/>
|
/>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
|
|
||||||
|
<PaymentCurrencyField control={form.control} />
|
||||||
|
|
||||||
{(selectedService?.includesFirstMile ||
|
{(selectedService?.includesFirstMile ||
|
||||||
selectedService?.includesLastMile ||
|
selectedService?.includesLastMile ||
|
||||||
selectedService?.includesCustoms) && (
|
selectedService?.includesCustoms) && (
|
||||||
@@ -648,10 +714,9 @@ export default function EditBookingPage() {
|
|||||||
</Paper>
|
</Paper>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
</Tabs.Panel>
|
||||||
|
|
||||||
<Divider />
|
<Tabs.Panel value="route">
|
||||||
|
|
||||||
{/* ── Section 3: Route ── */}
|
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<SectionHeading
|
<SectionHeading
|
||||||
title="Route"
|
title="Route"
|
||||||
@@ -746,10 +811,9 @@ export default function EditBookingPage() {
|
|||||||
/>
|
/>
|
||||||
</Paper>
|
</Paper>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
</Tabs.Panel>
|
||||||
|
|
||||||
<Divider />
|
<Tabs.Panel value="cargo">
|
||||||
|
|
||||||
{/* ── Section 4: Cargo ── */}
|
|
||||||
<Box>
|
<Box>
|
||||||
<Step5CargoDetails
|
<Step5CargoDetails
|
||||||
form={form}
|
form={form}
|
||||||
@@ -758,10 +822,13 @@ export default function EditBookingPage() {
|
|||||||
isLoading={!referenceData}
|
isLoading={!referenceData}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
</Tabs.Panel>
|
||||||
|
|
||||||
<Divider />
|
<Tabs.Panel value="schedule">
|
||||||
|
<StepScheduling form={form} referenceData={referenceData} />
|
||||||
|
</Tabs.Panel>
|
||||||
|
|
||||||
{/* ── Section 5: Documents ── */}
|
<Tabs.Panel value="documents">
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<SectionHeading
|
<SectionHeading
|
||||||
title="Documents"
|
title="Documents"
|
||||||
@@ -855,10 +922,9 @@ export default function EditBookingPage() {
|
|||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
</Tabs.Panel>
|
||||||
|
|
||||||
<Divider />
|
<Tabs.Panel value="notes">
|
||||||
|
|
||||||
{/* ── Section 6: Notes ── */}
|
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<SectionHeading
|
<SectionHeading
|
||||||
title="Notes"
|
title="Notes"
|
||||||
@@ -878,7 +944,8 @@ export default function EditBookingPage() {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Tabs.Panel>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
{/* ── Submit ── */}
|
{/* ── Submit ── */}
|
||||||
<Group
|
<Group
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
@@ -8,14 +8,44 @@ import {
|
|||||||
Card,
|
Card,
|
||||||
Group,
|
Group,
|
||||||
Menu,
|
Menu,
|
||||||
|
Paper,
|
||||||
|
Select,
|
||||||
|
SimpleGrid,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
|
TextInput,
|
||||||
ThemeIcon,
|
ThemeIcon,
|
||||||
Title,
|
Title,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { ArrowUpDown, Download, Filter, MoreVertical, Package, Plus } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import {
|
||||||
|
ArrowRight,
|
||||||
|
CheckCircle2,
|
||||||
|
FileEdit,
|
||||||
|
LayoutList,
|
||||||
|
MoreVertical,
|
||||||
|
Package,
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
Train,
|
||||||
|
Wallet,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import { ShipmentTrackingModal } from "./tracking/ShipmentTrackingModal";
|
||||||
|
import { PayNowButton } from "./payments/PayNowButton";
|
||||||
|
|
||||||
|
// Bookings that have left (or are leaving) the yard can be tracked live.
|
||||||
|
const TRACKABLE_STATUSES = new Set([
|
||||||
|
"PAID",
|
||||||
|
"IN_TRANSIT",
|
||||||
|
"COMPLETED",
|
||||||
|
"DELIVERED",
|
||||||
|
]);
|
||||||
|
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
|
import type { BookingListFilter } from "@/services/bookings.service";
|
||||||
|
import { STATUS_CONFIG } from "@/pages/MyPortalPage/constants";
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
import {
|
import {
|
||||||
DataTable,
|
DataTable,
|
||||||
@@ -24,25 +54,86 @@ import {
|
|||||||
usePagination,
|
usePagination,
|
||||||
} from "@edr/ui-common";
|
} from "@edr/ui-common";
|
||||||
|
|
||||||
// ── Status badge ──────────────────────────────────────────────────────────────
|
// ── Status filter options (grouped by lifecycle) ──────────────────────────────
|
||||||
|
|
||||||
const STATUS_CONFIG: Record<string, { bg: string; dot: string; color: string; label: string }> = {
|
const STATUS_FILTERS = [
|
||||||
DRAFT: { bg: "#F1F4F7", dot: "#94A3B8", color: "#475569", label: "Draft" },
|
{ key: "all", label: "All bookings", statuses: undefined as string | undefined },
|
||||||
REVIEWING: { bg: "#E9F0F8", dot: "#3B6FB0", color: "#2E5B96", label: "Reviewing" },
|
{
|
||||||
AWAITING_PAYMENT: { bg: "#FDF3E0", dot: "#F2A516", color: "#9A5B00", label: "Awaiting Payment" },
|
key: "active",
|
||||||
CONFIRMED: { bg: "#ECF6F1", dot: "#0EA371", color: "#0A6F4D", label: "Confirmed" },
|
label: "In progress",
|
||||||
IN_TRANSIT: { bg: "#ECF6F1", dot: "#0EA371", color: "#0A6F4D", label: "In Transit" },
|
statuses:
|
||||||
DELIVERED: { bg: "#E9F0F8", dot: "#3B6FB0", color: "#2E5B96", label: "Delivered" },
|
"SUBMITTED,CHANGES_REQUESTED,PENDING_APPROVAL,APPROVED_PENDING_SIGNATURE,APPROVED,CONTRACT_READY,SIGNED_CUSTOMER,FULLY_EXECUTED,PENDING_CONSOLIDATION,CONSOLIDATED",
|
||||||
CANCELLED: { bg: "#FBEAE7", dot: "#C0392B", color: "#C0392B", label: "Cancelled" },
|
},
|
||||||
};
|
{ key: "draft", label: "Drafts", statuses: "DRAFT" },
|
||||||
|
{
|
||||||
|
key: "payment",
|
||||||
|
label: "Awaiting payment",
|
||||||
|
statuses:
|
||||||
|
"SELECTED_FOR_BATCH,PNR_GENERATED,PAYMENT_VERIFICATION_IN_PROGRESS,EXPIRED",
|
||||||
|
},
|
||||||
|
{ key: "transit", label: "In transit", statuses: "PAID,IN_TRANSIT" },
|
||||||
|
{ key: "done", label: "Completed", statuses: "COMPLETED,DELIVERED" },
|
||||||
|
{ key: "closed", label: "Cancelled / rejected", statuses: "CANCELLED,REJECTED" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type StatusFilterKey = (typeof STATUS_FILTERS)[number]["key"];
|
||||||
|
|
||||||
|
const SELECT_DATA = STATUS_FILTERS.map((f) => ({ value: f.key, label: f.label }));
|
||||||
|
|
||||||
|
// ── Summary stat cards (clickable lifecycle filters) ──────────────────────────
|
||||||
|
|
||||||
|
const STAT_CARDS: Array<{
|
||||||
|
key: StatusFilterKey;
|
||||||
|
label: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
iconBg: string;
|
||||||
|
iconColor: string;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
key: "all",
|
||||||
|
label: "All bookings",
|
||||||
|
icon: LayoutList,
|
||||||
|
iconBg: "#ECF6F1",
|
||||||
|
iconColor: "#0A8A5F",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "active",
|
||||||
|
label: "In progress",
|
||||||
|
icon: Package,
|
||||||
|
iconBg: "#FDF3E0",
|
||||||
|
iconColor: "#C77F09",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "payment",
|
||||||
|
label: "Awaiting payment",
|
||||||
|
icon: Wallet,
|
||||||
|
iconBg: "#FEF6E6",
|
||||||
|
iconColor: "#F2A516",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "draft",
|
||||||
|
label: "Drafts",
|
||||||
|
icon: FileEdit,
|
||||||
|
iconBg: "#F1F4F7",
|
||||||
|
iconColor: "#475569",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "done",
|
||||||
|
label: "Completed",
|
||||||
|
icon: CheckCircle2,
|
||||||
|
iconBg: "#ECF6F1",
|
||||||
|
iconColor: "#0A8A5F",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── Status badge (reuses the shared portal status config) ─────────────────────
|
||||||
|
|
||||||
function StatusBadge({ status }: { status: string }) {
|
function StatusBadge({ status }: { status: string }) {
|
||||||
const cfg = STATUS_CONFIG[status] ?? {
|
const cfg = STATUS_CONFIG[status];
|
||||||
bg: "#F1F4F7",
|
const label = cfg?.badgeLabel ?? status.replace(/_/g, " ");
|
||||||
dot: "#94A3B8",
|
const bg = cfg ? `var(--mantine-color-${cfg.badgeBg}-0, #F1F4F7)` : "#F1F4F7";
|
||||||
color: "#475569",
|
const text = cfg ? `var(--mantine-color-${cfg.badgeText}-7, #475569)` : "#475569";
|
||||||
label: status.replace(/_/g, " "),
|
const dot = cfg ? `var(--mantine-color-${cfg.badgeDot}-6, #94A3B8)` : "#94A3B8";
|
||||||
};
|
|
||||||
return (
|
return (
|
||||||
<Group
|
<Group
|
||||||
gap={6}
|
gap={6}
|
||||||
@@ -51,7 +142,7 @@ function StatusBadge({ status }: { status: string }) {
|
|||||||
style={{
|
style={{
|
||||||
display: "inline-flex",
|
display: "inline-flex",
|
||||||
borderRadius: 999,
|
borderRadius: 999,
|
||||||
backgroundColor: cfg.bg,
|
backgroundColor: bg,
|
||||||
padding: "5px 11px",
|
padding: "5px 11px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -60,12 +151,12 @@ function StatusBadge({ status }: { status: string }) {
|
|||||||
width: 6,
|
width: 6,
|
||||||
height: 6,
|
height: 6,
|
||||||
borderRadius: "50%",
|
borderRadius: "50%",
|
||||||
backgroundColor: cfg.dot,
|
backgroundColor: dot,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Text fz={11} fw={700} style={{ color: cfg.color, whiteSpace: "nowrap" }}>
|
<Text fz={11} fw={700} style={{ color: text, whiteSpace: "nowrap" }}>
|
||||||
{cfg.label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
@@ -74,14 +165,14 @@ function StatusBadge({ status }: { status: string }) {
|
|||||||
// ── Context-sensitive action button ───────────────────────────────────────────
|
// ── Context-sensitive action button ───────────────────────────────────────────
|
||||||
|
|
||||||
function PrimaryAction({
|
function PrimaryAction({
|
||||||
status,
|
booking,
|
||||||
id,
|
|
||||||
onNavigate,
|
onNavigate,
|
||||||
}: {
|
}: {
|
||||||
status: string;
|
booking: Freight.IBooking;
|
||||||
id: string;
|
|
||||||
onNavigate: (path: string) => void;
|
onNavigate: (path: string) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { status, id } = booking;
|
||||||
|
const go = () => onNavigate(`/bookings/${id}`);
|
||||||
if (status === "DRAFT") {
|
if (status === "DRAFT") {
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
@@ -89,57 +180,39 @@ function PrimaryAction({
|
|||||||
radius="md"
|
radius="md"
|
||||||
fw={700}
|
fw={700}
|
||||||
fz={13}
|
fz={13}
|
||||||
|
rightSection={<ArrowRight size={14} />}
|
||||||
style={{ backgroundColor: "var(--mantine-color-edr-ink-0)", color: "#fff" }}
|
style={{ backgroundColor: "var(--mantine-color-edr-ink-0)", color: "#fff" }}
|
||||||
onClick={() => onNavigate(`/bookings/${id}`)}
|
onClick={go}
|
||||||
>
|
>
|
||||||
Continue
|
Continue
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (status === "AWAITING_PAYMENT") {
|
if (status === "CHANGES_REQUESTED") {
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
size="xs"
|
size="xs"
|
||||||
radius="md"
|
radius="md"
|
||||||
fw={700}
|
fw={700}
|
||||||
fz={13}
|
fz={13}
|
||||||
style={{ backgroundColor: "var(--mantine-color-edr-accent-0)", color: "#fff" }}
|
color="orange"
|
||||||
onClick={() => onNavigate(`/bookings/${id}`)}
|
rightSection={<ArrowRight size={14} />}
|
||||||
|
onClick={() => onNavigate(`/bookings/${id}/edit?section=documents`)}
|
||||||
>
|
>
|
||||||
Pay
|
Review changes
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (status === "IN_TRANSIT") {
|
if (status === "SELECTED_FOR_BATCH" && booking.paymentStatus !== "PAID") {
|
||||||
return (
|
return <PayNowButton booking={booking} />;
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
radius="md"
|
|
||||||
variant="default"
|
|
||||||
fw={600}
|
|
||||||
fz={13}
|
|
||||||
onClick={() => onNavigate(`/bookings/${id}`)}
|
|
||||||
>
|
|
||||||
Track
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button size="xs" radius="md" variant="default" fw={600} fz={13} onClick={go}>
|
||||||
size="xs"
|
|
||||||
radius="md"
|
|
||||||
variant="default"
|
|
||||||
fw={600}
|
|
||||||
fz={13}
|
|
||||||
onClick={() => onNavigate(`/bookings/${id}`)}
|
|
||||||
>
|
|
||||||
View
|
View
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Column header label ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function ColHeader({ label }: { label: string }) {
|
function ColHeader({ label }: { label: string }) {
|
||||||
return (
|
return (
|
||||||
<Text
|
<Text
|
||||||
@@ -155,20 +228,164 @@ function ColHeader({ label }: { label: string }) {
|
|||||||
|
|
||||||
const hMeta = { headerClassName: "bg-[#F4F7FA]" };
|
const hMeta = { headerClassName: "bg-[#F4F7FA]" };
|
||||||
|
|
||||||
|
function fmtDate(iso?: string | null): string {
|
||||||
|
if (!iso) return "";
|
||||||
|
const d = new Date(iso);
|
||||||
|
return Number.isNaN(d.getTime())
|
||||||
|
? ""
|
||||||
|
: d.toLocaleDateString(undefined, {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ── Main component ────────────────────────────────────────────────────────────
|
// ── Main component ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Lightweight count query for a single lifecycle filter (reads only `total`).
|
||||||
|
function useStatusCount(statuses: string | undefined): number | undefined {
|
||||||
|
const { data } = useQuery(
|
||||||
|
api.bookings.list.queryOptions({
|
||||||
|
input: { statuses, page: 1, pageSize: 1 },
|
||||||
|
staleTime: 30_000,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return data?.meta?.total;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({
|
||||||
|
card,
|
||||||
|
active,
|
||||||
|
count,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
card: (typeof STAT_CARDS)[number];
|
||||||
|
active: boolean;
|
||||||
|
count: number | undefined;
|
||||||
|
onSelect: () => void;
|
||||||
|
}) {
|
||||||
|
const Icon = card.icon;
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={onSelect}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
onSelect();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
p="md"
|
||||||
|
radius="lg"
|
||||||
|
withBorder
|
||||||
|
style={{
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "box-shadow 140ms ease, border-color 140ms ease",
|
||||||
|
borderColor: active ? "#F2A516" : "var(--mantine-color-edr-border-0)",
|
||||||
|
boxShadow: active ? "0 0 0 1px #F2A516" : "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group gap={12} wrap="nowrap" align="center">
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
width: 42,
|
||||||
|
height: 42,
|
||||||
|
borderRadius: 11,
|
||||||
|
flexShrink: 0,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
backgroundColor: card.iconBg,
|
||||||
|
color: card.iconColor,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon size={20} strokeWidth={2} />
|
||||||
|
</Box>
|
||||||
|
<Box style={{ minWidth: 0 }}>
|
||||||
|
<Text fz={24} fw={800} lh={1.05} c="edr-text">
|
||||||
|
{count ?? "—"}
|
||||||
|
</Text>
|
||||||
|
<Text fz={12} fw={600} c="edr-muted" truncate>
|
||||||
|
{card.label}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function MyBookings() {
|
export default function MyBookings() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||||
|
const [statusFilter, setStatusFilter] = useState<StatusFilterKey>("all");
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [trackingBooking, setTrackingBooking] = useState<Freight.IBooking | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
const { data, isLoading, isError } = useQuery(api.bookings.list.queryOptions());
|
const statuses = STATUS_FILTERS.find((t) => t.key === statusFilter)?.statuses;
|
||||||
const bookings = data?.items ?? [];
|
|
||||||
|
|
||||||
const total = bookings.length;
|
const selectFilter = (key: StatusFilterKey) => {
|
||||||
const pageCount = Math.ceil(total / pagination.pageSize);
|
setStatusFilter(key);
|
||||||
const start = pagination.pageIndex * pagination.pageSize;
|
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||||
const end = Math.min(start + pagination.pageSize, total);
|
};
|
||||||
const paginatedData = useMemo(() => bookings.slice(start, end), [bookings, start, end]);
|
|
||||||
|
const filter: BookingListFilter = useMemo(
|
||||||
|
() => ({
|
||||||
|
statuses,
|
||||||
|
page: pagination.pageIndex + 1,
|
||||||
|
pageSize: pagination.pageSize,
|
||||||
|
}),
|
||||||
|
[statuses, pagination.pageIndex, pagination.pageSize],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data, isLoading, isError } = useQuery(
|
||||||
|
api.bookings.list.queryOptions({ input: filter }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Per-card lifecycle counts (one cheap query each, total-only).
|
||||||
|
const allCount = useStatusCount(undefined);
|
||||||
|
const activeCount = useStatusCount(
|
||||||
|
STATUS_FILTERS.find((f) => f.key === "active")!.statuses,
|
||||||
|
);
|
||||||
|
const paymentCount = useStatusCount(
|
||||||
|
STATUS_FILTERS.find((f) => f.key === "payment")!.statuses,
|
||||||
|
);
|
||||||
|
const draftCount = useStatusCount(
|
||||||
|
STATUS_FILTERS.find((f) => f.key === "draft")!.statuses,
|
||||||
|
);
|
||||||
|
const doneCount = useStatusCount(
|
||||||
|
STATUS_FILTERS.find((f) => f.key === "done")!.statuses,
|
||||||
|
);
|
||||||
|
const cardCounts: Record<StatusFilterKey, number | undefined> = {
|
||||||
|
all: allCount,
|
||||||
|
active: activeCount,
|
||||||
|
payment: paymentCount,
|
||||||
|
draft: draftCount,
|
||||||
|
done: doneCount,
|
||||||
|
transit: undefined,
|
||||||
|
closed: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const allItems = data?.items ?? [];
|
||||||
|
const total = data?.meta?.total ?? allItems.length;
|
||||||
|
|
||||||
|
// Server handles status + pagination; reference search is applied on the page.
|
||||||
|
const rows = useMemo(() => {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
if (!q) return allItems;
|
||||||
|
return allItems.filter((b) =>
|
||||||
|
[b.reference, b.originYard?.label, b.destinationYard?.label]
|
||||||
|
.filter(Boolean)
|
||||||
|
.some((v) => String(v).toLowerCase().includes(q)),
|
||||||
|
);
|
||||||
|
}, [allItems, query]);
|
||||||
|
|
||||||
|
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
||||||
|
const dataTableStatus = isLoading ? "loading" : isError ? "error" : "success";
|
||||||
|
const showEmpty =
|
||||||
|
!isLoading && !isError && rows.length === 0;
|
||||||
|
|
||||||
const columns: ColumnDef<Freight.IBooking>[] = [
|
const columns: ColumnDef<Freight.IBooking>[] = [
|
||||||
{
|
{
|
||||||
@@ -178,8 +395,7 @@ export default function MyBookings() {
|
|||||||
header: () => <ColHeader label="Booking" />,
|
header: () => <ColHeader label="Booking" />,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const b = row.original;
|
const b = row.original;
|
||||||
const cargoLabel =
|
const cargoLabel = b.freightType === "BULK" ? "Bulk cargo" : "Container";
|
||||||
b.freightType === "BULK" ? "Bulk Cargo" : "Cargo";
|
|
||||||
return (
|
return (
|
||||||
<Group gap={12} wrap="nowrap" align="center">
|
<Group gap={12} wrap="nowrap" align="center">
|
||||||
<Box
|
<Box
|
||||||
@@ -217,7 +433,7 @@ export default function MyBookings() {
|
|||||||
const b = row.original;
|
const b = row.original;
|
||||||
const origin = b.originYard?.label ?? b.originYard?.code ?? "—";
|
const origin = b.originYard?.label ?? b.originYard?.code ?? "—";
|
||||||
const dest = b.destinationYard?.label ?? b.destinationYard?.code ?? "—";
|
const dest = b.destinationYard?.label ?? b.destinationYard?.code ?? "—";
|
||||||
const sub = b.scheduledDate ?? b.createdAt ?? "";
|
const sub = fmtDate(b.scheduledDate ?? b.createdAt);
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Text fz={13} fw={600} c="edr-text">
|
<Text fz={13} fw={600} c="edr-text">
|
||||||
@@ -245,7 +461,10 @@ export default function MyBookings() {
|
|||||||
meta: hMeta,
|
meta: hMeta,
|
||||||
header: () => <ColHeader label="Amount" />,
|
header: () => <ColHeader label="Amount" />,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const b = row.original as Freight.IBooking & { totalAmount?: number; amount?: number };
|
const b = row.original as Freight.IBooking & {
|
||||||
|
totalAmount?: number;
|
||||||
|
amount?: number;
|
||||||
|
};
|
||||||
const amount = b.totalAmount ?? b.amount ?? null;
|
const amount = b.totalAmount ?? b.amount ?? null;
|
||||||
if (!amount) {
|
if (!amount) {
|
||||||
return (
|
return (
|
||||||
@@ -267,24 +486,42 @@ export default function MyBookings() {
|
|||||||
header: () => null,
|
header: () => null,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const booking = row.original;
|
const booking = row.original;
|
||||||
|
const trackable = TRACKABLE_STATUSES.has(booking.status);
|
||||||
return (
|
return (
|
||||||
<Group justify="flex-end" gap={8} wrap="nowrap" onClick={(e) => e.stopPropagation()}>
|
<Group justify="flex-end" gap={8} wrap="nowrap" onClick={(e) => e.stopPropagation()}>
|
||||||
<PrimaryAction status={booking.status} id={booking.id} onNavigate={navigate} />
|
{trackable && (
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
radius="md"
|
||||||
|
variant="light"
|
||||||
|
color="edr-green"
|
||||||
|
fw={700}
|
||||||
|
fz={13}
|
||||||
|
leftSection={<Train size={14} />}
|
||||||
|
onClick={() => setTrackingBooking(booking)}
|
||||||
|
>
|
||||||
|
Track
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<PrimaryAction booking={booking} onNavigate={navigate} />
|
||||||
<Menu position="bottom-end" withinPortal shadow="md" radius="md">
|
<Menu position="bottom-end" withinPortal shadow="md" radius="md">
|
||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
<ActionIcon
|
<ActionIcon variant="transparent" size={30} radius="md" aria-label="More options">
|
||||||
variant="transparent"
|
|
||||||
size={30}
|
|
||||||
radius="md"
|
|
||||||
aria-label="More options"
|
|
||||||
>
|
|
||||||
<MoreVertical size={16} color="#9AA8B5" />
|
<MoreVertical size={16} color="#9AA8B5" />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
<Menu.Dropdown>
|
<Menu.Dropdown>
|
||||||
<Menu.Item onClick={() => navigate(`/bookings/${booking.id}`)}>
|
<Menu.Item onClick={() => navigate(`/bookings/${booking.id}`)}>
|
||||||
View Details
|
View details
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
{trackable && (
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<Train size={15} />}
|
||||||
|
onClick={() => setTrackingBooking(booking)}
|
||||||
|
>
|
||||||
|
Track shipment
|
||||||
|
</Menu.Item>
|
||||||
|
)}
|
||||||
</Menu.Dropdown>
|
</Menu.Dropdown>
|
||||||
</Menu>
|
</Menu>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -293,8 +530,6 @@ export default function MyBookings() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const dataTableStatus = isLoading ? "loading" : isError ? "error" : "success";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box style={{ padding: "28px 32px 32px" }}>
|
<Box style={{ padding: "28px 32px 32px" }}>
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
@@ -305,81 +540,112 @@ export default function MyBookings() {
|
|||||||
Bookings
|
Bookings
|
||||||
</Title>
|
</Title>
|
||||||
<Text size="sm" c="edr-muted" mt={4}>
|
<Text size="sm" c="edr-muted" mt={4}>
|
||||||
Manage every cargo booking — from draft to delivery.
|
Track every cargo booking — from draft to delivery.
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Group gap={12}>
|
<Button
|
||||||
<Button variant="default" radius="md" leftSection={<Download size={16} />}>
|
component={Link}
|
||||||
Export
|
to="/bookings/new"
|
||||||
</Button>
|
color="edr-green"
|
||||||
<Button
|
radius="md"
|
||||||
component={Link}
|
leftSection={<Plus size={16} />}
|
||||||
to="/bookings/new"
|
>
|
||||||
color="edr-green"
|
New booking
|
||||||
radius="md"
|
</Button>
|
||||||
leftSection={<Plus size={16} />}
|
|
||||||
>
|
|
||||||
New Booking
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
|
{/* ── Summary stat cards ──────────────────────────────────────── */}
|
||||||
|
<SimpleGrid cols={{ base: 2, sm: 3, lg: 5 }} spacing="md">
|
||||||
|
{STAT_CARDS.map((card) => (
|
||||||
|
<StatCard
|
||||||
|
key={card.key}
|
||||||
|
card={card}
|
||||||
|
active={statusFilter === card.key}
|
||||||
|
count={cardCounts[card.key]}
|
||||||
|
onSelect={() => selectFilter(card.key)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
{/* ── Bookings table card ──────────────────────────────────────── */}
|
{/* ── Bookings table card ──────────────────────────────────────── */}
|
||||||
<Card p={0} style={{ overflow: "hidden" }}>
|
<Card p={0} style={{ overflow: "hidden" }}>
|
||||||
{/* Toolbar */}
|
|
||||||
<Group
|
<Group
|
||||||
justify="flex-end"
|
justify="space-between"
|
||||||
gap={8}
|
gap={12}
|
||||||
px={20}
|
px={20}
|
||||||
py={14}
|
py={14}
|
||||||
|
wrap="wrap"
|
||||||
style={{ borderBottom: "1px solid var(--mantine-color-edr-border-0)" }}
|
style={{ borderBottom: "1px solid var(--mantine-color-edr-border-0)" }}
|
||||||
>
|
>
|
||||||
<Button
|
<Group gap={10} wrap="wrap" style={{ flex: 1, minWidth: 260 }}>
|
||||||
variant="default"
|
<TextInput
|
||||||
size="sm"
|
placeholder="Search reference or route…"
|
||||||
radius="md"
|
leftSection={<Search size={16} />}
|
||||||
leftSection={<ArrowUpDown size={14} />}
|
value={query}
|
||||||
>
|
onChange={(e) => setQuery(e.currentTarget.value)}
|
||||||
Sort
|
rightSection={
|
||||||
</Button>
|
query ? (
|
||||||
<Button
|
<ActionIcon
|
||||||
variant="default"
|
size="sm"
|
||||||
size="sm"
|
variant="transparent"
|
||||||
radius="md"
|
color="gray"
|
||||||
leftSection={<Filter size={14} />}
|
onClick={() => setQuery("")}
|
||||||
>
|
>
|
||||||
Filter
|
<X size={14} />
|
||||||
</Button>
|
</ActionIcon>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
radius="md"
|
||||||
|
style={{ flex: 1, minWidth: 200, maxWidth: 340 }}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
data={SELECT_DATA}
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(value) => selectFilter((value as StatusFilterKey) ?? "all")}
|
||||||
|
allowDeselect={false}
|
||||||
|
radius="md"
|
||||||
|
checkIconPosition="right"
|
||||||
|
comboboxProps={{ withinPortal: true }}
|
||||||
|
style={{ width: 200 }}
|
||||||
|
aria-label="Filter by status"
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
<Text fz={12} c="edr-muted">
|
||||||
|
{total} booking{total !== 1 ? "s" : ""}
|
||||||
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{/* Empty state */}
|
{showEmpty ? (
|
||||||
{total === 0 && dataTableStatus === "success" ? (
|
|
||||||
<Stack align="center" gap={4} px="lg" py={64} ta="center">
|
<Stack align="center" gap={4} px="lg" py={64} ta="center">
|
||||||
<ThemeIcon size={56} radius="lg" color="edr-green" variant="light" mb="xs">
|
<ThemeIcon size={56} radius="lg" color="edr-green" variant="light" mb="xs">
|
||||||
<Package size={28} />
|
<Package size={28} />
|
||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
<Text size="sm" fw={600} c="edr-text">
|
<Text size="sm" fw={600} c="edr-text">
|
||||||
No bookings yet
|
{query ? "No bookings match your search" : "No bookings here yet"}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="xs" c="edr-muted" maw={320}>
|
<Text size="xs" c="edr-muted" maw={320}>
|
||||||
You haven't made any booking requests yet. Create your first one to get started.
|
{query
|
||||||
|
? "Try a different reference or clear the search."
|
||||||
|
: "Create your first booking to get started."}
|
||||||
</Text>
|
</Text>
|
||||||
<Button
|
{!query && (
|
||||||
component={Link}
|
<Button
|
||||||
to="/bookings/new"
|
component={Link}
|
||||||
size="sm"
|
to="/bookings/new"
|
||||||
color="edr-green"
|
size="sm"
|
||||||
radius="md"
|
color="edr-green"
|
||||||
mt="md"
|
radius="md"
|
||||||
leftSection={<Plus size={15} />}
|
mt="md"
|
||||||
>
|
leftSection={<Plus size={15} />}
|
||||||
Create first booking
|
>
|
||||||
</Button>
|
Create first booking
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
) : (
|
) : (
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
data={paginatedData}
|
data={rows}
|
||||||
status={dataTableStatus}
|
status={dataTableStatus}
|
||||||
onRowClick={(row) => navigate(`/bookings/${(row as Freight.IBooking).id}`)}
|
onRowClick={(row) => navigate(`/bookings/${(row as Freight.IBooking).id}`)}
|
||||||
pagination={{
|
pagination={{
|
||||||
@@ -391,6 +657,8 @@ export default function MyBookings() {
|
|||||||
tableOptions={{
|
tableOptions={{
|
||||||
state: { pagination },
|
state: { pagination },
|
||||||
onPaginationChange: setPagination,
|
onPaginationChange: setPagination,
|
||||||
|
manualPagination: true,
|
||||||
|
pageCount,
|
||||||
}}
|
}}
|
||||||
containerClassName="border-0 shadow-none rounded-none"
|
containerClassName="border-0 shadow-none rounded-none"
|
||||||
footer={DataTableFooter}
|
footer={DataTableFooter}
|
||||||
@@ -398,6 +666,20 @@ export default function MyBookings() {
|
|||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
|
<ShipmentTrackingModal
|
||||||
|
opened={trackingBooking !== null}
|
||||||
|
onClose={() => setTrackingBooking(null)}
|
||||||
|
bookingId={trackingBooking?.id ?? ""}
|
||||||
|
bookingReference={trackingBooking?.reference ?? ""}
|
||||||
|
originLabel={
|
||||||
|
trackingBooking?.originYard?.label ?? trackingBooking?.originYard?.code
|
||||||
|
}
|
||||||
|
destinationLabel={
|
||||||
|
trackingBooking?.destinationYard?.label ??
|
||||||
|
trackingBooking?.destinationYard?.code
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
|
import { hasAllRequiredDocuments } from "@/services/booking-form-data";
|
||||||
import type {
|
import type {
|
||||||
CreateBookingPayload,
|
CreateBookingPayload,
|
||||||
GeneratePriceResponse,
|
GeneratePriceResponse,
|
||||||
|
SubmitBookingResponse,
|
||||||
} from "@/services/bookings.service";
|
} from "@/services/bookings.service";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import {
|
import {
|
||||||
@@ -35,6 +37,7 @@ import {
|
|||||||
getRouteDirection,
|
getRouteDirection,
|
||||||
initialBookingFormValues,
|
initialBookingFormValues,
|
||||||
stepFields,
|
stepFields,
|
||||||
|
type BookingDocuments,
|
||||||
type BookingFormValues,
|
type BookingFormValues,
|
||||||
} from "./new-booking-form/schema";
|
} from "./new-booking-form/schema";
|
||||||
import { StepIndicator } from "./new-booking-form/StepIndicator";
|
import { StepIndicator } from "./new-booking-form/StepIndicator";
|
||||||
@@ -48,6 +51,8 @@ import {
|
|||||||
StepScheduling,
|
StepScheduling,
|
||||||
} from "./new-booking-form/steps";
|
} from "./new-booking-form/steps";
|
||||||
|
|
||||||
|
type PriceModalMode = "submit" | "draft";
|
||||||
|
|
||||||
export default function NewBookingPage() {
|
export default function NewBookingPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -91,68 +96,61 @@ export default function NewBookingPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const createMutation = useMutation({
|
const persistAndPriceMutation = useMutation({
|
||||||
mutationFn: async (payload: CreateBookingPayload) => {
|
mutationFn: async ({
|
||||||
const booking = await api.bookings.create.call(payload);
|
payload,
|
||||||
|
mode,
|
||||||
|
existingBookingId,
|
||||||
|
}: {
|
||||||
|
payload: CreateBookingPayload;
|
||||||
|
mode: PriceModalMode;
|
||||||
|
existingBookingId: string | null;
|
||||||
|
}) => {
|
||||||
|
const documents = (form.getValues("documents") ?? {}) as BookingDocuments;
|
||||||
|
let bookingId = existingBookingId;
|
||||||
|
|
||||||
// Documents can't ride along with creation — upload them against the
|
if (bookingId) {
|
||||||
// new booking id once it exists. Optional here; the booking detail page
|
await api.bookings.update.call({ id: bookingId, dto: payload, documents });
|
||||||
// remains the catch-all for any docs the user skips.
|
} else {
|
||||||
const documents = form.getValues("documents") ?? {};
|
const booking = await api.bookings.create.call({ payload, documents });
|
||||||
const hasDocuments = Object.values(documents).some((value) =>
|
bookingId = booking.id;
|
||||||
Array.isArray(value) ? value.length > 0 : Boolean(value),
|
|
||||||
);
|
|
||||||
if (hasDocuments) {
|
|
||||||
await api.bookings.uploadDocuments.call({
|
|
||||||
id: booking.id,
|
|
||||||
files: documents,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return booking;
|
const pricing = await api.bookings.generatePrice.call({ id: bookingId });
|
||||||
|
return { bookingId, pricing, mode };
|
||||||
},
|
},
|
||||||
onSuccess: (booking) => {
|
onSuccess: ({ bookingId, pricing, mode }) => {
|
||||||
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
|
|
||||||
navigate(`/bookings/${booking.id}`);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const createAndPriceMutation = useMutation({
|
|
||||||
mutationFn: async (payload: CreateBookingPayload) => {
|
|
||||||
const booking = await api.bookings.create.call(payload);
|
|
||||||
|
|
||||||
const documents = form.getValues("documents") ?? {};
|
|
||||||
const hasDocs = Object.values(documents).some((value) =>
|
|
||||||
Array.isArray(value) ? value.length > 0 : Boolean(value),
|
|
||||||
);
|
|
||||||
if (hasDocs) {
|
|
||||||
await api.bookings.uploadDocuments.call({
|
|
||||||
id: booking.id,
|
|
||||||
files: documents,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const pricing = await api.bookings.generatePrice.call({ id: booking.id });
|
|
||||||
|
|
||||||
return { bookingId: booking.id, pricing };
|
|
||||||
},
|
|
||||||
onSuccess: ({ bookingId, pricing }) => {
|
|
||||||
setPriceBookingId(bookingId);
|
setPriceBookingId(bookingId);
|
||||||
setPricingData(pricing);
|
setPricingData(pricing);
|
||||||
setPricingPhase("ready");
|
setPriceModalMode(mode);
|
||||||
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
|
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
|
||||||
},
|
},
|
||||||
onError: () => {
|
|
||||||
setPricingPhase("idle");
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const confirmMutation = useMutation({
|
const confirmMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
if (!priceBookingId) throw new Error("No booking to confirm");
|
if (!priceBookingId) throw new Error("No booking to confirm");
|
||||||
await api.bookings.submit.call({ id: priceBookingId });
|
return api.bookings.submit.call({ id: priceBookingId });
|
||||||
|
},
|
||||||
|
onSuccess: (result) => {
|
||||||
|
if (result.priceChanged) {
|
||||||
|
setPriceChangeResult(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPriceModalMode(null);
|
||||||
|
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
|
||||||
|
navigate(`/bookings/${priceBookingId}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const confirmSubmitMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
if (!priceBookingId) throw new Error("No booking to confirm");
|
||||||
|
return api.bookings.confirmSubmit.call({ id: priceBookingId });
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
setPriceChangeResult(null);
|
||||||
|
setPriceModalMode(null);
|
||||||
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
|
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
|
||||||
navigate(`/bookings/${priceBookingId}`);
|
navigate(`/bookings/${priceBookingId}`);
|
||||||
},
|
},
|
||||||
@@ -188,22 +186,15 @@ export default function NewBookingPage() {
|
|||||||
return route;
|
return route;
|
||||||
}, [originYard, destinationYard]);
|
}, [originYard, destinationYard]);
|
||||||
|
|
||||||
const docValues = form.watch("documents") ?? {};
|
|
||||||
const hasDocuments = useMemo(
|
|
||||||
() =>
|
|
||||||
Object.values(docValues).some((value) =>
|
|
||||||
Array.isArray(value) ? value.length > 0 : Boolean(value),
|
|
||||||
),
|
|
||||||
[docValues],
|
|
||||||
);
|
|
||||||
|
|
||||||
const [pricingPhase, setPricingPhase] = useState<
|
|
||||||
"idle" | "generating" | "ready"
|
|
||||||
>("idle");
|
|
||||||
const [pricingData, setPricingData] = useState<GeneratePriceResponse | null>(
|
const [pricingData, setPricingData] = useState<GeneratePriceResponse | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const [priceBookingId, setPriceBookingId] = useState<string | null>(null);
|
const [priceBookingId, setPriceBookingId] = useState<string | null>(null);
|
||||||
|
const [priceModalMode, setPriceModalMode] = useState<PriceModalMode | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [priceChangeResult, setPriceChangeResult] =
|
||||||
|
useState<SubmitBookingResponse | null>(null);
|
||||||
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
|
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
|
||||||
const [cancelReason, setCancelReason] = useState("");
|
const [cancelReason, setCancelReason] = useState("");
|
||||||
|
|
||||||
@@ -211,6 +202,14 @@ export default function NewBookingPage() {
|
|||||||
const valid = await form.trigger(stepFields[step], { shouldFocus: true });
|
const valid = await form.trigger(stepFields[step], { shouldFocus: true });
|
||||||
if (!valid) return;
|
if (!valid) return;
|
||||||
|
|
||||||
|
if (step === 6 && !hasAllRequiredDocuments(form.getValues("documents"))) {
|
||||||
|
form.setError("documents", {
|
||||||
|
type: "manual",
|
||||||
|
message: "Upload all four required documents.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setStep((currentStep) => Math.min(STEPS.length, currentStep + 1));
|
setStep((currentStep) => Math.min(STEPS.length, currentStep + 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,13 +231,9 @@ export default function NewBookingPage() {
|
|||||||
)
|
)
|
||||||
: Number(data.cargoWeight || 0);
|
: Number(data.cargoWeight || 0);
|
||||||
|
|
||||||
const shippingLines = referenceData?.shipping_line ?? [];
|
|
||||||
const cargoTree = referenceData?.cargo_type ?? [];
|
const cargoTree = referenceData?.cargo_type ?? [];
|
||||||
const containerGroups = referenceData?.containers ?? [];
|
const containerGroups = referenceData?.containers ?? [];
|
||||||
|
|
||||||
const findShippingLineId = (name: string): string | undefined =>
|
|
||||||
shippingLines.find((l) => l.name === name)?.id;
|
|
||||||
|
|
||||||
const findContainerTypeId = (name: string): string => {
|
const findContainerTypeId = (name: string): string => {
|
||||||
for (const group of containerGroups) {
|
for (const group of containerGroups) {
|
||||||
const ct = group.types.find((t) => t.name === name);
|
const ct = group.types.find((t) => t.name === name);
|
||||||
@@ -265,7 +260,9 @@ export default function NewBookingPage() {
|
|||||||
)!;
|
)!;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
scheduledDate: new Date().toISOString(),
|
scheduledDate: data.scheduledDate
|
||||||
|
? new Date(data.scheduledDate).toISOString()
|
||||||
|
: new Date().toISOString(),
|
||||||
contractType:
|
contractType:
|
||||||
data.contractType.toUpperCase() as CreateBookingPayload["contractType"],
|
data.contractType.toUpperCase() as CreateBookingPayload["contractType"],
|
||||||
serviceTypeId: data.serviceTypeId,
|
serviceTypeId: data.serviceTypeId,
|
||||||
@@ -273,12 +270,13 @@ export default function NewBookingPage() {
|
|||||||
data.equipmentReturn === "with_return"
|
data.equipmentReturn === "with_return"
|
||||||
? "WITH_RETURN"
|
? "WITH_RETURN"
|
||||||
: "WITHOUT_RETURN",
|
: "WITHOUT_RETURN",
|
||||||
paymentCurrency: "USD",
|
paymentCurrency: data.paymentCurrency,
|
||||||
originYardId: data.originYard,
|
originYardId: data.originYard,
|
||||||
destinationYardId: data.destinationYard,
|
destinationYardId: data.destinationYard,
|
||||||
tradeDirection: direction!,
|
tradeDirection: direction!,
|
||||||
cargoTypeId,
|
cargoTypeId,
|
||||||
trainScheduleId: data.trainScheduleId,
|
// Day-level pool: the customer picks only a day (scheduledDate); the batch
|
||||||
|
// engine assigns the train, so no trainScheduleId is sent.
|
||||||
cargoTotalWeightVgm: totalWeight,
|
cargoTotalWeightVgm: totalWeight,
|
||||||
isHazardous: data.isHazardous,
|
isHazardous: data.isHazardous,
|
||||||
allowConsolidation: data.consolidationEnabled,
|
allowConsolidation: data.consolidationEnabled,
|
||||||
@@ -307,31 +305,61 @@ export default function NewBookingPage() {
|
|||||||
? { lastMileDeliveryAddress: data.lastMile.deliveryAddress }
|
? { lastMileDeliveryAddress: data.lastMile.deliveryAddress }
|
||||||
: {}),
|
: {}),
|
||||||
...(data.shippingLine
|
...(data.shippingLine
|
||||||
? { shippingLineId: findShippingLineId(data.shippingLine) }
|
? { shippingLineId: data.shippingLine }
|
||||||
: {}),
|
: {}),
|
||||||
...(cargoFreeText ? { cargoFreeText } : {}),
|
...(cargoFreeText ? { cargoFreeText } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDraftSubmit = form.handleSubmit((data) => {
|
const handleSaveDraft = form.handleSubmit((data) => {
|
||||||
try {
|
try {
|
||||||
const apiPayload = buildApiPayload(data);
|
const apiPayload = buildApiPayload(data);
|
||||||
createMutation.mutate(apiPayload);
|
persistAndPriceMutation.mutate({
|
||||||
|
payload: apiPayload,
|
||||||
|
mode: "draft",
|
||||||
|
existingBookingId: priceBookingId,
|
||||||
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// validation error already handled
|
// validation error already handled
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleGeneratePrice = form.handleSubmit((data) => {
|
const handleSubmitBooking = form.handleSubmit((data) => {
|
||||||
|
if (!hasAllRequiredDocuments(data.documents)) {
|
||||||
|
form.setError("documents", {
|
||||||
|
type: "manual",
|
||||||
|
message: "Upload all four required documents.",
|
||||||
|
});
|
||||||
|
setStep(6);
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const apiPayload = buildApiPayload(data);
|
const apiPayload = buildApiPayload(data);
|
||||||
setPricingPhase("generating");
|
persistAndPriceMutation.mutate({
|
||||||
createAndPriceMutation.mutate(apiPayload);
|
payload: apiPayload,
|
||||||
|
mode: "submit",
|
||||||
|
existingBookingId: priceBookingId,
|
||||||
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// validation error already handled
|
// validation error already handled
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isPricing =
|
||||||
|
persistAndPriceMutation.isPending || confirmMutation.isPending;
|
||||||
|
|
||||||
|
function closePriceModal() {
|
||||||
|
setPriceModalMode(null);
|
||||||
|
if (priceModalMode === "draft" && priceBookingId) {
|
||||||
|
navigate(`/bookings/${priceBookingId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDraftModalOk() {
|
||||||
|
setPriceModalMode(null);
|
||||||
|
if (priceBookingId) navigate(`/bookings/${priceBookingId}`);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
style={{
|
style={{
|
||||||
@@ -376,14 +404,14 @@ export default function NewBookingPage() {
|
|||||||
id="new-booking-form"
|
id="new-booking-form"
|
||||||
className="flex flex-col"
|
className="flex flex-col"
|
||||||
style={{ flex: 1 }}
|
style={{ flex: 1 }}
|
||||||
onSubmit={handleDraftSubmit}
|
onSubmit={(e) => e.preventDefault()}
|
||||||
>
|
>
|
||||||
<Box flex={1} p="24px">
|
<Box flex={1} p="24px">
|
||||||
<Box mb="lg">
|
<Box mb="lg">
|
||||||
<StepIndicator step={step} />
|
<StepIndicator step={step} />
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{createMutation.isError && (
|
{persistAndPriceMutation.isError && (
|
||||||
<Alert
|
<Alert
|
||||||
color="red"
|
color="red"
|
||||||
icon={<AlertCircle size={16} />}
|
icon={<AlertCircle size={16} />}
|
||||||
@@ -391,29 +419,11 @@ export default function NewBookingPage() {
|
|||||||
mb="lg"
|
mb="lg"
|
||||||
>
|
>
|
||||||
<Text size="sm" fw={600}>
|
<Text size="sm" fw={600}>
|
||||||
Failed to save draft
|
Failed to save booking or generate price
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="sm" mt={4} c="red.7">
|
<Text size="sm" mt={4} c="red.7">
|
||||||
{createMutation.error instanceof Error
|
{persistAndPriceMutation.error instanceof Error
|
||||||
? createMutation.error.message
|
? persistAndPriceMutation.error.message
|
||||||
: "An unexpected error occurred. Please try again."}
|
|
||||||
</Text>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{createAndPriceMutation.isError && (
|
|
||||||
<Alert
|
|
||||||
color="red"
|
|
||||||
icon={<AlertCircle size={16} />}
|
|
||||||
radius="md"
|
|
||||||
mb="lg"
|
|
||||||
>
|
|
||||||
<Text size="sm" fw={600}>
|
|
||||||
Failed to generate price estimate
|
|
||||||
</Text>
|
|
||||||
<Text size="sm" mt={4} c="red.7">
|
|
||||||
{createAndPriceMutation.error instanceof Error
|
|
||||||
? createAndPriceMutation.error.message
|
|
||||||
: "An unexpected error occurred. Please try again."}
|
: "An unexpected error occurred. Please try again."}
|
||||||
</Text>
|
</Text>
|
||||||
</Alert>
|
</Alert>
|
||||||
@@ -450,17 +460,16 @@ export default function NewBookingPage() {
|
|||||||
setStep={setStep}
|
setStep={setStep}
|
||||||
direction={direction!}
|
direction={direction!}
|
||||||
referenceData={referenceData}
|
referenceData={referenceData}
|
||||||
pricingPhase={pricingPhase}
|
onSaveDraft={handleSaveDraft}
|
||||||
pricingData={pricingData}
|
onSubmit={handleSubmitBooking}
|
||||||
onConfirm={() => confirmMutation.mutate()}
|
saveDraftPending={
|
||||||
onContinueLater={
|
persistAndPriceMutation.isPending &&
|
||||||
priceBookingId
|
persistAndPriceMutation.variables?.mode === "draft"
|
||||||
? () => navigate(`/bookings/${priceBookingId}`)
|
}
|
||||||
: undefined
|
submitPending={
|
||||||
|
persistAndPriceMutation.isPending &&
|
||||||
|
persistAndPriceMutation.variables?.mode === "submit"
|
||||||
}
|
}
|
||||||
onAbort={() => setCancelDialogOpen(true)}
|
|
||||||
confirmPending={confirmMutation.isPending}
|
|
||||||
abortPending={abortMutation.isPending}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -501,51 +510,151 @@ export default function NewBookingPage() {
|
|||||||
>
|
>
|
||||||
Continue
|
Continue
|
||||||
</Button>
|
</Button>
|
||||||
) : pricingPhase === "idle" ? (
|
) : (
|
||||||
<Group>
|
<Button
|
||||||
<Button
|
type="button"
|
||||||
type="submit"
|
color="edr-green"
|
||||||
form="new-booking-form"
|
radius="md"
|
||||||
variant={hasDocuments ? "outline" : "filled"}
|
leftSection={<Send size={16} />}
|
||||||
color="edr-green"
|
onClick={handleSubmitBooking}
|
||||||
radius="md"
|
loading={isPricing}
|
||||||
loading={createMutation.isPending}
|
>
|
||||||
leftSection={
|
Submit
|
||||||
createMutation.isPending ? undefined : <Check size={16} />
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{createMutation.isPending
|
|
||||||
? "Saving Draft..."
|
|
||||||
: "Save as Draft"}
|
|
||||||
</Button>
|
|
||||||
{hasDocuments && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
color="edr-green"
|
|
||||||
radius="md"
|
|
||||||
loading={createAndPriceMutation.isPending}
|
|
||||||
leftSection={
|
|
||||||
createAndPriceMutation.isPending ? undefined : (
|
|
||||||
<Send size={16} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onClick={() => handleGeneratePrice()}
|
|
||||||
>
|
|
||||||
{createAndPriceMutation.isPending
|
|
||||||
? "Generating price…"
|
|
||||||
: "Submit"}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
) : pricingPhase === "generating" ? (
|
|
||||||
<Button type="button" color="edr-green" radius="md" loading>
|
|
||||||
Generating price estimate…
|
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</Box>
|
</Box>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
opened={priceModalMode !== null && pricingData !== null}
|
||||||
|
onClose={closePriceModal}
|
||||||
|
title={
|
||||||
|
<Text fw={700}>
|
||||||
|
{priceModalMode === "submit"
|
||||||
|
? "Confirm booking submission"
|
||||||
|
: "Draft saved — price estimate"}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
radius="lg"
|
||||||
|
centered
|
||||||
|
size="md"
|
||||||
|
>
|
||||||
|
{pricingData && (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{priceModalMode === "submit"
|
||||||
|
? "Review the price estimate below. Confirm to submit your booking for EDR staff review."
|
||||||
|
: "Your booking has been saved as a draft. Here is the estimated price."}
|
||||||
|
</Text>
|
||||||
|
<Stack gap="xs">
|
||||||
|
{pricingData.lineItems.map((item) => (
|
||||||
|
<Group key={item.code} justify="space-between">
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{item.description}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
{item.amount.toLocaleString()} {item.currency}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
<Group justify="space-between" pt="xs">
|
||||||
|
<Text fw={800} size="md">
|
||||||
|
Total
|
||||||
|
</Text>
|
||||||
|
<Text fw={800} size="lg" c="edr-green">
|
||||||
|
{pricingData.totalAmount.toLocaleString()} {pricingData.currency}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
{pricingData.warnings.length > 0 && (
|
||||||
|
<Text size="xs" c="orange.7" p="xs" className="rounded bg-orange-50">
|
||||||
|
{pricingData.warnings.join(", ")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Group justify="flex-end" gap="sm" mt="md">
|
||||||
|
{priceModalMode === "submit" ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
radius="md"
|
||||||
|
onClick={closePriceModal}
|
||||||
|
disabled={confirmMutation.isPending}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<Check size={16} />}
|
||||||
|
onClick={() => confirmMutation.mutate()}
|
||||||
|
loading={confirmMutation.isPending}
|
||||||
|
>
|
||||||
|
Confirm & submit
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button color="edr-green" radius="md" onClick={handleDraftModalOk}>
|
||||||
|
OK
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
opened={priceChangeResult !== null}
|
||||||
|
onClose={() => setPriceChangeResult(null)}
|
||||||
|
title={<Text fw={700}>Price has changed</Text>}
|
||||||
|
radius="lg"
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
{priceChangeResult && (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{priceChangeResult.message ??
|
||||||
|
"The booking price has been updated. Confirm to submit with the new total."}
|
||||||
|
</Text>
|
||||||
|
{priceChangeResult.previousTotalAmount !== undefined && (
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Previous total
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" td="line-through">
|
||||||
|
{priceChangeResult.previousTotalAmount.toLocaleString()}{" "}
|
||||||
|
{priceChangeResult.currency}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text fw={700}>New total</Text>
|
||||||
|
<Text fw={800} c="edr-green">
|
||||||
|
{priceChangeResult.totalAmount.toLocaleString()}{" "}
|
||||||
|
{priceChangeResult.currency}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Group justify="flex-end" gap="sm">
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
radius="md"
|
||||||
|
onClick={() => setPriceChangeResult(null)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
radius="md"
|
||||||
|
loading={confirmSubmitMutation.isPending}
|
||||||
|
onClick={() => confirmSubmitMutation.mutate()}
|
||||||
|
>
|
||||||
|
Confirm & submit
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
opened={cancelDialogOpen}
|
opened={cancelDialogOpen}
|
||||||
onClose={() => setCancelDialogOpen(false)}
|
onClose={() => setCancelDialogOpen(false)}
|
||||||
|
|||||||
@@ -2,84 +2,88 @@ import { Check } from "lucide-react";
|
|||||||
import { Fragment } from "react";
|
import { Fragment } from "react";
|
||||||
import { STEPS } from "./schema";
|
import { STEPS } from "./schema";
|
||||||
|
|
||||||
|
const GREEN = "var(--mantine-color-edr-green-5)";
|
||||||
|
const GREEN_DEEP = "var(--mantine-color-edr-green-7)";
|
||||||
|
const BORDER = "var(--mantine-color-edr-border-0)";
|
||||||
|
const MUTED = "var(--mantine-color-edr-muted-0)";
|
||||||
|
const INK = "var(--mantine-color-edr-text-0)";
|
||||||
|
|
||||||
export function StepIndicator({ step }: { step: number }) {
|
export function StepIndicator({ step }: { step: number }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center">
|
<div className="flex items-start">
|
||||||
{STEPS.map((item, index) => (
|
{STEPS.map((item, index) => {
|
||||||
<Fragment key={item.id}>
|
const done = step > item.id;
|
||||||
<div className="flex shrink-0 flex-col items-center gap-1">
|
const active = step === item.id;
|
||||||
<div
|
return (
|
||||||
style={{
|
<Fragment key={item.id}>
|
||||||
width: 28,
|
<div className="flex shrink-0 flex-col items-center gap-2" style={{ minWidth: 34 }}>
|
||||||
height: 28,
|
<div
|
||||||
borderRadius: "50%",
|
style={{
|
||||||
display: "flex",
|
width: 34,
|
||||||
alignItems: "center",
|
height: 34,
|
||||||
justifyContent: "center",
|
borderRadius: "50%",
|
||||||
fontSize: 12,
|
display: "flex",
|
||||||
fontWeight: 600,
|
alignItems: "center",
|
||||||
flexShrink: 0,
|
justifyContent: "center",
|
||||||
transition: "all 0.2s",
|
fontSize: 13,
|
||||||
...(step > item.id
|
fontWeight: 700,
|
||||||
? {
|
flexShrink: 0,
|
||||||
backgroundColor: "var(--mantine-color-edr-green-5)",
|
transition: "all 0.2s",
|
||||||
color: "#fff",
|
...(done
|
||||||
boxShadow: "0 2px 8px rgba(14,163,113,0.4)",
|
|
||||||
}
|
|
||||||
: step === item.id
|
|
||||||
? {
|
? {
|
||||||
border: "2.5px solid var(--mantine-color-edr-green-5)",
|
background: "linear-gradient(135deg, #12B981, #0A8A5F)",
|
||||||
color: "var(--mantine-color-edr-green-7)",
|
color: "#fff",
|
||||||
backgroundColor: "#fff",
|
boxShadow: "0 4px 10px rgba(14,163,113,0.35)",
|
||||||
boxShadow: "0 0 0 3px rgba(14,163,113,0.12)",
|
|
||||||
}
|
}
|
||||||
: {
|
: active
|
||||||
backgroundColor: "#fff",
|
? {
|
||||||
color: "var(--mantine-color-edr-muted-0)",
|
border: `2.5px solid ${GREEN}`,
|
||||||
border: "2px solid var(--mantine-color-edr-border-0)",
|
color: GREEN_DEEP,
|
||||||
}),
|
backgroundColor: "#fff",
|
||||||
}}
|
boxShadow: "0 0 0 4px rgba(14,163,113,0.12)",
|
||||||
>
|
}
|
||||||
{step > item.id ? (
|
: {
|
||||||
<Check style={{ width: 13, height: 13 }} />
|
backgroundColor: "#fff",
|
||||||
) : (
|
color: MUTED,
|
||||||
item.id
|
border: `2px solid ${BORDER}`,
|
||||||
)}
|
}),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{done ? <Check style={{ width: 15, height: 15 }} strokeWidth={3} /> : item.id}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: active ? 700 : 500,
|
||||||
|
textAlign: "center",
|
||||||
|
lineHeight: 1.2,
|
||||||
|
maxWidth: 72,
|
||||||
|
display: "none",
|
||||||
|
transition: "color 0.2s",
|
||||||
|
color: step >= item.id ? INK : MUTED,
|
||||||
|
}}
|
||||||
|
className="md:!block"
|
||||||
|
>
|
||||||
|
{item.short}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span
|
{index < STEPS.length - 1 && (
|
||||||
style={{
|
<div
|
||||||
fontSize: 10,
|
style={{
|
||||||
fontWeight: 500,
|
flex: 1,
|
||||||
display: "none",
|
height: 3,
|
||||||
transition: "color 0.2s",
|
borderRadius: 999,
|
||||||
color:
|
margin: "16px 8px 0",
|
||||||
step >= item.id
|
transition: "background 0.3s",
|
||||||
? "var(--mantine-color-edr-text-0)"
|
background: done
|
||||||
: "var(--mantine-color-edr-muted-0)",
|
? "linear-gradient(90deg, #0A8A5F, #12B981)"
|
||||||
}}
|
: BORDER,
|
||||||
className="lg:!block"
|
}}
|
||||||
>
|
/>
|
||||||
{item.short}
|
)}
|
||||||
</span>
|
</Fragment>
|
||||||
</div>
|
);
|
||||||
{index < STEPS.length - 1 && (
|
})}
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
height: 2,
|
|
||||||
borderRadius: 999,
|
|
||||||
margin: "0 6px",
|
|
||||||
marginBottom: 14,
|
|
||||||
transition: "background-color 0.3s",
|
|
||||||
backgroundColor:
|
|
||||||
step > item.id
|
|
||||||
? "var(--mantine-color-edr-green-5)"
|
|
||||||
: "var(--mantine-color-edr-border-0)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Fragment>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { Box, Text } from "@mantine/core";
|
||||||
|
import { Banknote, DollarSign } from "lucide-react";
|
||||||
|
import { Controller, type Control } from "react-hook-form";
|
||||||
|
import {
|
||||||
|
PAYMENT_CURRENCY_OPTIONS,
|
||||||
|
type BookingFormInputValues,
|
||||||
|
type BookingFormValues,
|
||||||
|
type PaymentCurrency,
|
||||||
|
} from "./schema";
|
||||||
|
import { OptionCard, OptionFieldError, StepLabel } from "./shared";
|
||||||
|
|
||||||
|
const CURRENCY_ICONS: Record<
|
||||||
|
PaymentCurrency,
|
||||||
|
{ icon: typeof DollarSign; bg: string; color: string }
|
||||||
|
> = {
|
||||||
|
USD: { icon: DollarSign, bg: "#EEF0FB", color: "#4F46E5" },
|
||||||
|
ETB: { icon: Banknote, bg: "#ECF6F1", color: "#0A6F4D" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PaymentCurrencyField({
|
||||||
|
control,
|
||||||
|
}: {
|
||||||
|
control: Control<BookingFormInputValues, any, BookingFormValues>;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Box mt={24}>
|
||||||
|
<StepLabel>Payment currency</StepLabel>
|
||||||
|
<Text fz={12} c="#6B7C8E" mt={4} mb={12}>
|
||||||
|
Choose the currency for your freight quote and invoices.
|
||||||
|
</Text>
|
||||||
|
<Controller
|
||||||
|
name="paymentCurrency"
|
||||||
|
control={control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<div>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
{PAYMENT_CURRENCY_OPTIONS.map((option) => {
|
||||||
|
const Icon = CURRENCY_ICONS[option.value].icon;
|
||||||
|
return (
|
||||||
|
<OptionCard
|
||||||
|
key={option.value}
|
||||||
|
selected={field.value === option.value}
|
||||||
|
onClick={() => field.onChange(option.value)}
|
||||||
|
icon={<Icon className="h-5 w-5" />}
|
||||||
|
iconBg={CURRENCY_ICONS[option.value].bg}
|
||||||
|
iconColor={CURRENCY_ICONS[option.value].color}
|
||||||
|
title={option.label}
|
||||||
|
description={option.description}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<OptionFieldError error={fieldState.error} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,9 +14,7 @@ export const STEPS = [
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Shipment documents collected during booking creation. The fileKeys mirror
|
* Shipment documents collected during booking creation. The fileKeys mirror
|
||||||
* `REQUIRED_DOC_FIELDS` in BookingDetailPage/constants.ts so anything attached
|
* `REQUIRED_DOC_FIELDS` in BookingDetailPage/constants.ts.
|
||||||
* here shows up as "Uploaded" on the booking detail page. All optional in this
|
|
||||||
* flow — the detail page remains the catch-all for uploading them later.
|
|
||||||
*/
|
*/
|
||||||
const DOC_SETTING_TS = "2024-01-01T00:00:00.000Z";
|
const DOC_SETTING_TS = "2024-01-01T00:00:00.000Z";
|
||||||
|
|
||||||
@@ -34,7 +32,7 @@ function docField(
|
|||||||
fileKey,
|
fileKey,
|
||||||
fileLabel,
|
fileLabel,
|
||||||
helpText: null,
|
helpText: null,
|
||||||
isRequired: false,
|
isRequired: true,
|
||||||
isMultiple: false,
|
isMultiple: false,
|
||||||
maxFiles: 1,
|
maxFiles: 1,
|
||||||
allowedExtensions: ["pdf", "jpg", "jpeg", "png"],
|
allowedExtensions: ["pdf", "jpg", "jpeg", "png"],
|
||||||
@@ -51,7 +49,7 @@ export const BOOKING_DOCS_SETTING: Freight.IFileUploadSetting = {
|
|||||||
code: "booking_documents",
|
code: "booking_documents",
|
||||||
label: "Booking Documents",
|
label: "Booking Documents",
|
||||||
description:
|
description:
|
||||||
"Attach your shipment documents now, or skip and upload them later from the booking page.",
|
"Attach all four required shipment documents before submitting your booking.",
|
||||||
entity: "booking",
|
entity: "booking",
|
||||||
fields: [
|
fields: [
|
||||||
docField("commercial_invoice", "Commercial Invoice", 1),
|
docField("commercial_invoice", "Commercial Invoice", 1),
|
||||||
@@ -63,11 +61,32 @@ export const BOOKING_DOCS_SETTING: Freight.IFileUploadSetting = {
|
|||||||
|
|
||||||
export type BookingDocuments = Record<string, File | File[] | null>;
|
export type BookingDocuments = Record<string, File | File[] | null>;
|
||||||
|
|
||||||
|
export const PAYMENT_CURRENCIES = ["USD", "ETB"] as const;
|
||||||
|
export type PaymentCurrency = (typeof PAYMENT_CURRENCIES)[number];
|
||||||
|
|
||||||
|
export const PAYMENT_CURRENCY_OPTIONS: Array<{
|
||||||
|
value: PaymentCurrency;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
value: "USD",
|
||||||
|
label: "USD",
|
||||||
|
description: "US Dollar — international pricing and invoicing.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "ETB",
|
||||||
|
label: "ETB",
|
||||||
|
description: "Ethiopian Birr — local pricing and invoicing.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export const bookingFormSchema = z
|
export const bookingFormSchema = z
|
||||||
.object({
|
.object({
|
||||||
contractType: z.enum(["new", "renewal"], "Select a contract type."),
|
contractType: z.enum(["new", "renewal"], "Select a contract type."),
|
||||||
previousContractRef: z.string(),
|
previousContractRef: z.string(),
|
||||||
serviceTypeId: z.string("Select a service type."),
|
serviceTypeId: z.string("Select a service type."),
|
||||||
|
paymentCurrency: z.enum(PAYMENT_CURRENCIES, "Select a payment currency."),
|
||||||
|
|
||||||
firstMile: z
|
firstMile: z
|
||||||
.object({
|
.object({
|
||||||
@@ -94,8 +113,9 @@ export const bookingFormSchema = z
|
|||||||
originYard: z.string().min(1, "Select an origin yard."),
|
originYard: z.string().min(1, "Select an origin yard."),
|
||||||
destinationYard: z.string().min(1, "Select a destination yard."),
|
destinationYard: z.string().min(1, "Select a destination yard."),
|
||||||
shippingLine: z.string(),
|
shippingLine: z.string(),
|
||||||
|
// Day-level pool: the customer selects only a DAY. The batch engine assigns
|
||||||
|
// the specific train later, so no trainScheduleId is collected here.
|
||||||
scheduledDate: z.string().min(1, "Select a shipment date."),
|
scheduledDate: z.string().min(1, "Select a shipment date."),
|
||||||
trainScheduleId: z.string().min(1, "Select a shipment date."),
|
|
||||||
cargoType: z.enum(["container", "bulk"], "Select a cargo type."),
|
cargoType: z.enum(["container", "bulk"], "Select a cargo type."),
|
||||||
cargoWeight: z.string(),
|
cargoWeight: z.string(),
|
||||||
cargoTypePath: z.array(z.string()).default([]),
|
cargoTypePath: z.array(z.string()).default([]),
|
||||||
@@ -118,7 +138,10 @@ export const bookingFormSchema = z
|
|||||||
.refine((vgm) => Number(vgm) >= 0, "Must be greater than 0"),
|
.refine((vgm) => Number(vgm) >= 0, "Must be greater than 0"),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
consolidationEnabled: z.boolean(),
|
// Consolidation is system-managed, not a customer choice. The backend only
|
||||||
|
// consolidates partial-wagon bookings, so this is always allowed; the
|
||||||
|
// customer neither sees nor toggles it.
|
||||||
|
consolidationEnabled: z.boolean().default(true),
|
||||||
documents: z.record(z.string(), z.any()).default({}),
|
documents: z.record(z.string(), z.any()).default({}),
|
||||||
notes: z.string(),
|
notes: z.string(),
|
||||||
})
|
})
|
||||||
@@ -202,6 +225,7 @@ export const initialBookingFormValues: DeepPartial<BookingFormValues> = {
|
|||||||
previousContractRef: "",
|
previousContractRef: "",
|
||||||
|
|
||||||
serviceTypeId: "",
|
serviceTypeId: "",
|
||||||
|
paymentCurrency: "USD",
|
||||||
firstMile: {
|
firstMile: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
pickUpAddress: "",
|
pickUpAddress: "",
|
||||||
@@ -216,14 +240,13 @@ export const initialBookingFormValues: DeepPartial<BookingFormValues> = {
|
|||||||
destinationYard: "",
|
destinationYard: "",
|
||||||
shippingLine: "",
|
shippingLine: "",
|
||||||
scheduledDate: "",
|
scheduledDate: "",
|
||||||
trainScheduleId: "",
|
|
||||||
cargoWeight: "",
|
cargoWeight: "",
|
||||||
cargoTypePath: [],
|
cargoTypePath: [],
|
||||||
cargoFreeText: "",
|
cargoFreeText: "",
|
||||||
isHazardous: false,
|
isHazardous: false,
|
||||||
isRefrigerated: false,
|
isRefrigerated: false,
|
||||||
containers: [{ type: "20ft", containerType: "", qty: "1", vgm: "" }],
|
containers: [{ type: "20ft", containerType: "", qty: "1", vgm: "" }],
|
||||||
consolidationEnabled: false,
|
consolidationEnabled: true,
|
||||||
documents: {},
|
documents: {},
|
||||||
notes: "",
|
notes: "",
|
||||||
};
|
};
|
||||||
@@ -232,6 +255,7 @@ export const stepFields: Record<number, Array<Path<BookingFormValues>>> = {
|
|||||||
1: ["contractType", "previousContractRef"],
|
1: ["contractType", "previousContractRef"],
|
||||||
2: [
|
2: [
|
||||||
"serviceTypeId",
|
"serviceTypeId",
|
||||||
|
"paymentCurrency",
|
||||||
"firstMile",
|
"firstMile",
|
||||||
"lastMile",
|
"lastMile",
|
||||||
"equipmentReturn",
|
"equipmentReturn",
|
||||||
@@ -244,14 +268,8 @@ export const stepFields: Record<number, Array<Path<BookingFormValues>>> = {
|
|||||||
"isRefrigerated",
|
"isRefrigerated",
|
||||||
"shippingLine",
|
"shippingLine",
|
||||||
],
|
],
|
||||||
4: [
|
4: ["cargoType", "cargoWeight", "cargoTypePath", "containers"],
|
||||||
"cargoType",
|
5: ["scheduledDate"],
|
||||||
"cargoWeight",
|
|
||||||
"cargoTypePath",
|
|
||||||
"containers",
|
|
||||||
"consolidationEnabled",
|
|
||||||
],
|
|
||||||
5: ["scheduledDate", "trainScheduleId"],
|
|
||||||
6: ["documents"],
|
6: ["documents"],
|
||||||
7: ["notes"],
|
7: ["notes"],
|
||||||
};
|
};
|
||||||
@@ -267,22 +285,33 @@ export interface WagonConfig {
|
|||||||
type: "20ft" | "40ft";
|
type: "20ft" | "40ft";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive the trade direction from the origin/destination yard countries.
|
||||||
|
*
|
||||||
|
* Mirrors the backend's `deriveTradeDirection` exactly so the value the portal
|
||||||
|
* sends always matches what the API re-derives (the API rejects mismatches):
|
||||||
|
* - origin in Djibouti → IMPORT
|
||||||
|
* - destination in Djibouti (origin not) → EXPORT
|
||||||
|
* - everything else (e.g. Ethiopia↔Ethiopia)→ DOMESTIC
|
||||||
|
*
|
||||||
|
* Returns null only while a yard is still unselected, so the UI can wait.
|
||||||
|
*/
|
||||||
export function getRouteDirection(
|
export function getRouteDirection(
|
||||||
origin: Freight.BookingReferenceYard | null | undefined,
|
origin: Freight.BookingReferenceYard | null | undefined,
|
||||||
dest: Freight.BookingReferenceYard | null | undefined,
|
dest: Freight.BookingReferenceYard | null | undefined,
|
||||||
): Freight.ScheduleTradeDirection | null {
|
): Freight.ScheduleTradeDirection | null {
|
||||||
if (!origin || !dest) return null;
|
if (!origin || !dest) return null;
|
||||||
if (origin.country === "Ethiopia" && dest.country === "Ethiopia") {
|
|
||||||
return "DOMESTIC";
|
const originCountry = origin.country?.trim();
|
||||||
}
|
const destCountry = dest.country?.trim();
|
||||||
if (origin.country === "Ethiopia" && dest.country === "Djibouti") {
|
|
||||||
return "EXPORT";
|
if (originCountry === "Djibouti") {
|
||||||
}
|
|
||||||
if (origin.country === "Djibouti" && dest.country === "Ethiopia") {
|
|
||||||
return "IMPORT";
|
return "IMPORT";
|
||||||
}
|
}
|
||||||
|
if (destCountry === "Djibouti" && originCountry !== "Djibouti") {
|
||||||
return null;
|
return "EXPORT";
|
||||||
|
}
|
||||||
|
return "DOMESTIC";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function calcWagons(containers: ContainerConfig[]) {
|
export function calcWagons(containers: ContainerConfig[]) {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user