mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #243 from Tria-plc/freight_feature/profile
Freight feature/profile
This commit is contained in:
@@ -17,6 +17,7 @@
|
||||
"type-check": "tsc --noEmit",
|
||||
"seed:demo-scheduling": "ts-node -r tsconfig-paths/register src/scripts/seed-demo-scheduling.ts",
|
||||
"seed:freight-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-freight-demo.ts",
|
||||
"seed:file-upload-settings": "ts-node -r tsconfig-paths/register src/scripts/seed-file-upload-settings.ts",
|
||||
"seed:fleet-wagons": "bash ../../../docs/new/seeds/seed-fleet-wagons.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -44,13 +45,13 @@
|
||||
"class-validator": "^0.14.1",
|
||||
"dotenv": "^17.4.2",
|
||||
"handlebars": "^4.7.9",
|
||||
"libphonenumber-js": "^1.13.6",
|
||||
"minio": "7.1.3",
|
||||
"pg": "^8.13.0",
|
||||
"puppeteer": "^24.2.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"typeorm": "^0.3.30"
|
||||
|
||||
},
|
||||
"devDependencies": {
|
||||
"@edr/api-common": "workspace:*",
|
||||
|
||||
@@ -13,6 +13,7 @@ import telebirrConfig from "./config/telebirr.config";
|
||||
import rabbitmqConfig from "./config/rabbitmq.config";
|
||||
|
||||
import { BookingsModule } from "./modules/bookings/bookings.module";
|
||||
import { BookingOrdersModule } from "./modules/booking-orders/booking-orders.module";
|
||||
import { SignaturesModule } from "./modules/signatures/signatures.module";
|
||||
import { FilesModule } from "./modules/files/files.module";
|
||||
import { ConsignmentsModule } from "./modules/consignments/consignments.module";
|
||||
@@ -94,6 +95,7 @@ import { LastMileModule } from './modules/last-mile/last-mile.module';
|
||||
permissions: EDR_FREIGHT_PERMISSIONS,
|
||||
}),
|
||||
BookingsModule,
|
||||
BookingOrdersModule,
|
||||
SignaturesModule,
|
||||
FilesModule,
|
||||
ConsignmentsModule,
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
registerDecorator,
|
||||
ValidationArguments,
|
||||
ValidationOptions,
|
||||
ValidatorConstraint,
|
||||
ValidatorConstraintInterface,
|
||||
} from 'class-validator';
|
||||
import { isValidPhoneNumber, parsePhoneNumberFromString } from 'libphonenumber-js';
|
||||
|
||||
/**
|
||||
* Country-aware phone validation. The value is expected as a full international
|
||||
* number (E.164, e.g. "+251911223344"), so the country is derived from the
|
||||
* value itself — no separate country field needed.
|
||||
*/
|
||||
@ValidatorConstraint({ name: 'IsValidPhone', async: false })
|
||||
export class IsValidPhoneConstraint implements ValidatorConstraintInterface {
|
||||
validate(value: unknown): boolean {
|
||||
// Empty is allowed here; pair with @IsOptional / @IsNotEmpty as needed.
|
||||
if (value === undefined || value === null || value === '') return true;
|
||||
if (typeof value !== 'string') return false;
|
||||
return isValidPhoneNumber(value);
|
||||
}
|
||||
|
||||
defaultMessage(args: ValidationArguments): string {
|
||||
return `${args.property} must be a valid international phone number (E.164, e.g. +251911223344)`;
|
||||
}
|
||||
}
|
||||
|
||||
/** Class-validator decorator wrapping the country-aware phone constraint. */
|
||||
export function IsValidPhone(validationOptions?: ValidationOptions) {
|
||||
return function (object: object, propertyName: string) {
|
||||
registerDecorator({
|
||||
target: object.constructor,
|
||||
propertyName,
|
||||
options: validationOptions,
|
||||
constraints: [],
|
||||
validator: IsValidPhoneConstraint,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a phone string to canonical E.164. Returns the canonical form when
|
||||
* parseable, otherwise the trimmed original (tolerant — never throws), or the
|
||||
* value unchanged when empty/nullish.
|
||||
*/
|
||||
export function normalizeE164(
|
||||
value: string | null | undefined,
|
||||
): string | null | undefined {
|
||||
if (value === undefined || value === null || value === '') return value;
|
||||
const parsed = parsePhoneNumberFromString(value);
|
||||
return parsed?.isValid() ? parsed.number : value.trim();
|
||||
}
|
||||
@@ -14,17 +14,13 @@ export default registerAs("app", () => ({
|
||||
maxTrainLengthMeters: numberFromEnv("TRAIN_SCHEDULING_MAX_LENGTH_METERS", 760),
|
||||
maxWagonsPerTrain: numberFromEnv("TRAIN_SCHEDULING_MAX_WAGONS_PER_TRAIN", 53),
|
||||
},
|
||||
// Consumed by @edr/api-common ExchangeModule.forRootAsync (see bookings.module.ts).
|
||||
cbeExchange: {
|
||||
/** ethio.forex CBET page — scraped for USD buying/selling rates. */
|
||||
scrapeUrl:
|
||||
process.env.CBE_EXCHANGE_SCRAPE_URL ??
|
||||
process.env.CBE_EXCHANGE_API_URL ??
|
||||
"https://ethio.forex/bank/CBET",
|
||||
/** @deprecated use scrapeUrl — kept for backward-compatible config reads */
|
||||
apiUrl:
|
||||
process.env.CBE_EXCHANGE_SCRAPE_URL ??
|
||||
process.env.CBE_EXCHANGE_API_URL ??
|
||||
"https://ethio.forex/bank/CBET",
|
||||
fallbackRate: numberFromEnv("CBE_EXCHANGE_FALLBACK_RATE", 130),
|
||||
cacheTtlMs: numberFromEnv("CBE_EXCHANGE_CACHE_TTL_MS", 3_600_000),
|
||||
},
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddActiveModeAndOnboardingToExternalProfiles1791000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'AddActiveModeAndOnboardingToExternalProfiles1791000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.external_profiles
|
||||
ADD COLUMN IF NOT EXISTS active_profile_type varchar(32);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.external_profiles
|
||||
ADD COLUMN IF NOT EXISTS onboarding_step varchar(40);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.external_profiles
|
||||
ADD COLUMN IF NOT EXISTS onboarding_completed boolean NOT NULL DEFAULT false;
|
||||
`);
|
||||
|
||||
// Existing users already use the portal — never re-gate them behind the
|
||||
// new onboarding wizard.
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.external_profiles
|
||||
SET onboarding_completed = true
|
||||
WHERE onboarding_completed = false;
|
||||
`);
|
||||
|
||||
// Backfill the active mode for existing users from their company's
|
||||
// operational profiles. Prefer importer, then exporter, then whichever
|
||||
// single profile the company has (forwarder/dj/transporter).
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.external_profiles ep
|
||||
SET active_profile_type = cp.type
|
||||
FROM (
|
||||
SELECT DISTINCT ON (company_id) company_id, type
|
||||
FROM freight.company_profiles
|
||||
ORDER BY company_id,
|
||||
CASE type
|
||||
WHEN 'importer' THEN 0
|
||||
WHEN 'exporter' THEN 1
|
||||
ELSE 2
|
||||
END
|
||||
) cp
|
||||
WHERE ep.company_id = cp.company_id
|
||||
AND ep.active_profile_type IS NULL;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.external_profiles
|
||||
DROP COLUMN IF EXISTS onboarding_completed;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.external_profiles
|
||||
DROP COLUMN IF EXISTS onboarding_step;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.external_profiles
|
||||
DROP COLUMN IF EXISTS active_profile_type;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddCompanyProfileIdToBookings1791000000001
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'AddCompanyProfileIdToBookings1791000000001';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ADD COLUMN IF NOT EXISTS company_profile_id UUID;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_bookings_company_profile_id
|
||||
ON freight.bookings(company_profile_id);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'FK_bookings_company_profile_id'
|
||||
) THEN
|
||||
ALTER TABLE freight.bookings
|
||||
ADD CONSTRAINT "FK_bookings_company_profile_id"
|
||||
FOREIGN KEY (company_profile_id)
|
||||
REFERENCES freight.company_profiles(id);
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
// Backfill by natural mapping: IMPORT → importer profile, EXPORT → exporter
|
||||
// profile, for each booking's own company.
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.bookings b
|
||||
SET company_profile_id = cp.id
|
||||
FROM freight.company_profiles cp
|
||||
WHERE cp.company_id = b.company_id
|
||||
AND b.company_profile_id IS NULL
|
||||
AND (
|
||||
(b.trade_direction = 'IMPORT' AND cp.type = 'importer') OR
|
||||
(b.trade_direction = 'EXPORT' AND cp.type = 'exporter')
|
||||
);
|
||||
`);
|
||||
|
||||
// Forwarder / single-profile companies: one profile per company, so the
|
||||
// mapping is unambiguous regardless of trade direction.
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.bookings b
|
||||
SET company_profile_id = cp.id
|
||||
FROM freight.company_profiles cp
|
||||
JOIN freight.companies c ON c.id = cp.company_id
|
||||
WHERE cp.company_id = b.company_id
|
||||
AND c.type <> 'customer'
|
||||
AND b.company_profile_id IS NULL;
|
||||
`);
|
||||
|
||||
// Remaining customer-owned rows (e.g. DOMESTIC, or a direction with no
|
||||
// matching profile): attribute to the company's importer profile, else its
|
||||
// exporter profile, so nothing disappears from the customer's list.
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.bookings b
|
||||
SET company_profile_id = cp.id
|
||||
FROM (
|
||||
SELECT DISTINCT ON (company_id) company_id, id
|
||||
FROM freight.company_profiles
|
||||
ORDER BY company_id,
|
||||
CASE type
|
||||
WHEN 'importer' THEN 0
|
||||
WHEN 'exporter' THEN 1
|
||||
ELSE 2
|
||||
END
|
||||
) cp
|
||||
WHERE cp.company_id = b.company_id
|
||||
AND b.company_id IS NOT NULL
|
||||
AND b.company_profile_id IS NULL;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
DROP CONSTRAINT IF EXISTS "FK_bookings_company_profile_id";
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DROP INDEX IF EXISTS freight.idx_bookings_company_profile_id;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
DROP COLUMN IF EXISTS company_profile_id;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class AddNationalityToCompanies1791000000002
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = "AddNationalityToCompanies1791000000002";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.companies
|
||||
ADD COLUMN IF NOT EXISTS nationality varchar(32);
|
||||
`);
|
||||
|
||||
// Existing companies default to Ethiopian (country defaults to Ethiopia).
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.companies
|
||||
SET nationality = 'ethiopian'
|
||||
WHERE nationality IS NULL;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.companies
|
||||
DROP COLUMN IF EXISTS nationality;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class AddBusinessLicenseFilesToCompanyProfiles1791000000003
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = "AddBusinessLicenseFilesToCompanyProfiles1791000000003";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.company_profiles
|
||||
ADD COLUMN IF NOT EXISTS business_license_files jsonb;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.company_profiles
|
||||
DROP COLUMN IF EXISTS business_license_files;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class AddETradeFieldsToCompanies1791000000003
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = "AddETradeFieldsToCompanies1791000000003";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.companies
|
||||
ADD COLUMN IF NOT EXISTS licence_number varchar(100);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.companies
|
||||
ADD COLUMN IF NOT EXISTS status_description text;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.companies
|
||||
ADD COLUMN IF NOT EXISTS date_registered varchar(50);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.companies
|
||||
ADD COLUMN IF NOT EXISTS renewed_from varchar(50);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.companies
|
||||
ADD COLUMN IF NOT EXISTS renewal_date varchar(50);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.companies
|
||||
ADD COLUMN IF NOT EXISTS renewed_to varchar(50);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.companies
|
||||
ADD COLUMN IF NOT EXISTS region varchar(100);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.companies
|
||||
ADD COLUMN IF NOT EXISTS zone varchar(100);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.companies
|
||||
ADD COLUMN IF NOT EXISTS woreda varchar(100);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.companies
|
||||
ADD COLUMN IF NOT EXISTS kebele varchar(100);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.companies
|
||||
ADD COLUMN IF NOT EXISTS house_no varchar(100);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.companies
|
||||
ADD COLUMN IF NOT EXISTS etrade_phone varchar(20);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.companies
|
||||
DROP COLUMN IF EXISTS licence_number;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.companies
|
||||
DROP COLUMN IF EXISTS status_description;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.companies
|
||||
DROP COLUMN IF EXISTS date_registered;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.companies
|
||||
DROP COLUMN IF EXISTS renewed_from;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.companies
|
||||
DROP COLUMN IF EXISTS renewal_date;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.companies
|
||||
DROP COLUMN IF EXISTS renewed_to;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.companies
|
||||
DROP COLUMN IF EXISTS region;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.companies
|
||||
DROP COLUMN IF EXISTS zone;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.companies
|
||||
DROP COLUMN IF EXISTS woreda;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.companies
|
||||
DROP COLUMN IF EXISTS kebele;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.companies
|
||||
DROP COLUMN IF EXISTS house_no;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.companies
|
||||
DROP COLUMN IF EXISTS etrade_phone;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Creates the generic dropdown settings tables (freight.dropdown_settings +
|
||||
* freight.dropdown_options) backing the DropdownSetting / DropdownOption
|
||||
* entities. These tables previously only existed via `synchronize` on some
|
||||
* databases; this migration makes them part of the migration history so the
|
||||
* SeedGeneralContractPeriod migration (which inserts into them) can run on a
|
||||
* fresh database. Idempotent so it is safe on DBs where the tables already exist.
|
||||
*/
|
||||
export class CreateDropdownSettings1791999999999
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'CreateDropdownSettings1791999999999';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "freight"."dropdown_settings" (
|
||||
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||
"code" varchar(128) NOT NULL,
|
||||
"label" varchar(256) NOT NULL,
|
||||
"description" text,
|
||||
"multiple" boolean NOT NULL DEFAULT false,
|
||||
"meta" jsonb,
|
||||
"created_at" timestamptz NOT NULL DEFAULT now(),
|
||||
"updated_at" timestamptz NOT NULL DEFAULT now(),
|
||||
"deleted_at" timestamptz,
|
||||
CONSTRAINT "PK_dropdown_settings" PRIMARY KEY ("id")
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_dropdown_settings_code"
|
||||
ON "freight"."dropdown_settings" ("code");
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "freight"."dropdown_options" (
|
||||
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||
"setting_id" uuid NOT NULL,
|
||||
"value" varchar(256) NOT NULL,
|
||||
"label" varchar(256) NOT NULL,
|
||||
"note" text,
|
||||
"is_disabled" boolean NOT NULL DEFAULT false,
|
||||
"display_order" integer NOT NULL DEFAULT 0,
|
||||
"meta" jsonb,
|
||||
"created_at" timestamptz NOT NULL DEFAULT now(),
|
||||
"updated_at" timestamptz NOT NULL DEFAULT now(),
|
||||
"deleted_at" timestamptz,
|
||||
CONSTRAINT "PK_dropdown_options" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "FK_dropdown_options_setting" FOREIGN KEY ("setting_id")
|
||||
REFERENCES "freight"."dropdown_settings" ("id") ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_dropdown_options_setting_value"
|
||||
ON "freight"."dropdown_options" ("setting_id", "value");
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DROP TABLE IF EXISTS "freight"."dropdown_options";`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP TABLE IF EXISTS "freight"."dropdown_settings";`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddUnitOfMeasureToCargoTypes1792000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'AddUnitOfMeasureToCargoTypes1792000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.cargo_types ADD COLUMN IF NOT EXISTS unit_of_measure VARCHAR(16);`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.cargo_types DROP COLUMN IF EXISTS unit_of_measure;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddBookingTypeAndContractFields1792000000001
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'AddBookingTypeAndContractFields1792000000001';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS booking_type VARCHAR(20) NOT NULL DEFAULT 'ONE_TIME';`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS expires_at TIMESTAMPTZ;`,
|
||||
);
|
||||
// General contracts have no shipment date at creation — relax the NOT NULL.
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings ALTER COLUMN scheduled_date DROP NOT NULL;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX IF NOT EXISTS idx_bookings_booking_type ON freight.bookings (booking_type);`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DROP INDEX IF EXISTS freight.idx_bookings_booking_type;`,
|
||||
);
|
||||
// Reinstate NOT NULL only if no null rows exist (general contracts would block it).
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings ALTER COLUMN scheduled_date SET NOT NULL;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS expires_at;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS booking_type;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm';
|
||||
|
||||
export class CreateBookingOrders1792000000002 implements MigrationInterface {
|
||||
name = 'CreateBookingOrders1792000000002';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
schema: 'freight',
|
||||
name: 'booking_orders',
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' },
|
||||
{ name: 'reference', type: 'varchar', length: '64', isUnique: true },
|
||||
{ name: 'contract_booking_id', type: 'uuid' },
|
||||
{ name: 'booking_id', type: 'uuid', isNullable: true },
|
||||
{ name: 'company_id', type: 'uuid', isNullable: true },
|
||||
{ name: 'scheduled_date', type: 'timestamptz' },
|
||||
{ name: 'status', type: 'varchar', length: '40', default: "'PAID'" },
|
||||
{ name: 'scheduling_status', type: 'varchar', length: '30', default: "'NOT_SCHEDULED'" },
|
||||
{ name: 'train_schedule_id', type: 'uuid', isNullable: true },
|
||||
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
await queryRunner.createIndex(
|
||||
'freight.booking_orders',
|
||||
new TableIndex({ name: 'idx_booking_orders_contract', columnNames: ['contract_booking_id'] }),
|
||||
);
|
||||
await queryRunner.createIndex(
|
||||
'freight.booking_orders',
|
||||
new TableIndex({ name: 'idx_booking_orders_company', columnNames: ['company_id'] }),
|
||||
);
|
||||
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
schema: 'freight',
|
||||
name: 'booking_order_lines',
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' },
|
||||
{ name: 'order_id', type: 'uuid' },
|
||||
{ name: 'container_type_id', type: 'uuid', isNullable: true },
|
||||
{ name: 'quantity', type: 'numeric', precision: 12, scale: 3 },
|
||||
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||
],
|
||||
foreignKeys: [
|
||||
{
|
||||
columnNames: ['order_id'],
|
||||
referencedSchema: 'freight',
|
||||
referencedTableName: 'booking_orders',
|
||||
referencedColumnNames: ['id'],
|
||||
onDelete: 'CASCADE',
|
||||
},
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
await queryRunner.createIndex(
|
||||
'freight.booking_order_lines',
|
||||
new TableIndex({ name: 'idx_booking_order_lines_order', columnNames: ['order_id'] }),
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.dropTable('freight.booking_order_lines', true);
|
||||
await queryRunner.dropTable('freight.booking_orders', true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Seeds the global "general contract period" setting (months). Stored as a
|
||||
* dropdown_settings row with a single option whose `value` holds the month count
|
||||
* so backoffice can manage it through the existing settings UI later.
|
||||
*/
|
||||
export class SeedGeneralContractPeriod1792000000003
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'SeedGeneralContractPeriod1792000000003';
|
||||
private readonly code = 'general_contract_period';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
const existing = await queryRunner.query(
|
||||
`SELECT id FROM freight.dropdown_settings WHERE code = $1 LIMIT 1;`,
|
||||
[this.code],
|
||||
);
|
||||
if (existing.length > 0) return;
|
||||
|
||||
const inserted = await queryRunner.query(
|
||||
`INSERT INTO freight.dropdown_settings (code, label, description, multiple)
|
||||
VALUES ($1, $2, $3, false)
|
||||
RETURNING id;`,
|
||||
[
|
||||
this.code,
|
||||
'General Contract Period (months)',
|
||||
'How many months a general contract stays open for ordering after activation.',
|
||||
],
|
||||
);
|
||||
const settingId = inserted[0].id;
|
||||
|
||||
await queryRunner.query(
|
||||
`INSERT INTO freight.dropdown_options (setting_id, value, label, display_order)
|
||||
VALUES ($1, $2, $3, 0);`,
|
||||
[settingId, '3', '3 months'],
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DELETE FROM freight.dropdown_settings WHERE code = $1;`,
|
||||
[this.code],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { CurrentUser } from '@edr/api-common';
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { BookingOrdersService } from './booking-orders.service';
|
||||
import { CreateBookingOrderDto } from './dto/create-booking-order.dto';
|
||||
import { GeneralContractService } from './general-contract.service';
|
||||
|
||||
@ApiTags('Booking Orders')
|
||||
@Controller('booking-orders')
|
||||
export class BookingOrdersController {
|
||||
constructor(
|
||||
private readonly ordersService: BookingOrdersService,
|
||||
private readonly generalContractService: GeneralContractService,
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Place a drawdown order against a general contract' })
|
||||
async create(
|
||||
@Body() dto: CreateBookingOrderDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.ordersService.create(dto, user?.id);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List orders placed against a contract' })
|
||||
async list(@Query('contractBookingId', ParseUUIDPipe) contractBookingId: string) {
|
||||
return this.ordersService.listByContract(contractBookingId);
|
||||
}
|
||||
|
||||
@Get('contract/:id/pool')
|
||||
@ApiOperation({
|
||||
summary: 'Contracted / ordered / remaining quantities for a general contract',
|
||||
})
|
||||
async pool(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.generalContractService.getQuantityLines(id);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get a single booking order' })
|
||||
async findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.ordersService.findById(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { forwardRef, Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { BookingsModule } from '../bookings/bookings.module';
|
||||
import { CompaniesModule } from '../companies/companies.module';
|
||||
import { DropdownSettingsModule } from '../dropdown-settings/dropdown-settings.module';
|
||||
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
|
||||
import { BookingOrdersController } from './booking-orders.controller';
|
||||
import { BookingOrdersRepository } from './booking-orders.repository';
|
||||
import { BookingOrdersService } from './booking-orders.service';
|
||||
import { BookingOrder } from './entities/booking-order.entity';
|
||||
import { BookingOrderLine } from './entities/booking-order-line.entity';
|
||||
import { GeneralContractService } from './general-contract.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([BookingOrder, BookingOrderLine]),
|
||||
BookingsModule,
|
||||
CompaniesModule,
|
||||
DropdownSettingsModule,
|
||||
forwardRef(() => TrainSchedulingModule),
|
||||
],
|
||||
controllers: [BookingOrdersController],
|
||||
providers: [
|
||||
BookingOrdersService,
|
||||
BookingOrdersRepository,
|
||||
GeneralContractService,
|
||||
],
|
||||
exports: [BookingOrdersService, GeneralContractService],
|
||||
})
|
||||
export class BookingOrdersModule {}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { BookingOrder } from './entities/booking-order.entity';
|
||||
|
||||
@Injectable()
|
||||
export class BookingOrdersRepository extends BaseRepository<BookingOrder> {
|
||||
constructor(
|
||||
@InjectRepository(BookingOrder)
|
||||
repository: Repository<BookingOrder>,
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
|
||||
/** Orders placed against a given contract, newest first, with their lines. */
|
||||
findByContract(contractBookingId: string): Promise<BookingOrder[]> {
|
||||
return this.repository.find({
|
||||
where: { contractBookingId },
|
||||
relations: { lines: { containerType: true }, booking: true },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
override findById(id: string): Promise<BookingOrder | null> {
|
||||
return this.repository.findOne({
|
||||
where: { id },
|
||||
relations: { lines: { containerType: true }, booking: true, contractBooking: true },
|
||||
});
|
||||
}
|
||||
|
||||
/** Count this calendar year's orders, for reference generation. */
|
||||
async countByYear(year: number): Promise<number> {
|
||||
const start = new Date(Date.UTC(year, 0, 1));
|
||||
const end = new Date(Date.UTC(year + 1, 0, 1));
|
||||
return this.repository
|
||||
.createQueryBuilder('o')
|
||||
.where('o.createdAt >= :start AND o.createdAt < :end', { start, end })
|
||||
.getCount();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
forwardRef,
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { BookingContainer } from '../bookings/entities/booking-container.entity';
|
||||
import { CompaniesService } from '../companies/companies.service';
|
||||
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
||||
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
||||
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
|
||||
import { eatDay } from '../train-scheduling/batch-window.util';
|
||||
import { BookingOrdersRepository } from './booking-orders.repository';
|
||||
import { CreateBookingOrderDto } from './dto/create-booking-order.dto';
|
||||
import { BookingOrder } from './entities/booking-order.entity';
|
||||
import { BookingOrderLine } from './entities/booking-order-line.entity';
|
||||
import { GeneralContractService } from './general-contract.service';
|
||||
|
||||
@Injectable()
|
||||
export class BookingOrdersService {
|
||||
private readonly logger = new Logger(BookingOrdersService.name);
|
||||
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly ordersRepository: BookingOrdersRepository,
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
private readonly companiesService: CompaniesService,
|
||||
private readonly generalContractService: GeneralContractService,
|
||||
@Inject(forwardRef(() => BookingBatchService))
|
||||
private readonly bookingBatchService: BookingBatchService,
|
||||
@Inject(forwardRef(() => TrainSchedulingService))
|
||||
private readonly trainSchedulingService: TrainSchedulingService,
|
||||
) {}
|
||||
|
||||
/** Orders placed against a contract, with their lines and child booking. */
|
||||
listByContract(contractBookingId: string): Promise<BookingOrder[]> {
|
||||
return this.ordersRepository.findByContract(contractBookingId);
|
||||
}
|
||||
|
||||
findById(id: string): Promise<BookingOrder | null> {
|
||||
return this.ordersRepository.findById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Place a drawdown order against an ACTIVE general contract.
|
||||
*
|
||||
* Validates the requested quantities against the remaining pool, then spawns a
|
||||
* ONE_TIME child Booking (PAID + FULLY_EXECUTED, inheriting the contract's
|
||||
* route/cargo/service) so it flows through the existing train-scheduling
|
||||
* pipeline. The order row is the ledger entry linking contract → child booking.
|
||||
*/
|
||||
async create(
|
||||
dto: CreateBookingOrderDto,
|
||||
userId?: string,
|
||||
): Promise<BookingOrder> {
|
||||
const contract = await this.bookingsRepository.findById(dto.contractBookingId);
|
||||
if (!contract) {
|
||||
throw new NotFoundException(`Contract ${dto.contractBookingId} not found`);
|
||||
}
|
||||
if (!this.generalContractService.isGeneralContract(contract)) {
|
||||
throw new BadRequestException('Booking is not a general contract');
|
||||
}
|
||||
if (contract.status !== 'CONTRACT_ACTIVE') {
|
||||
throw new BadRequestException(
|
||||
`Contract is ${contract.status} — orders can only be placed against an ACTIVE contract`,
|
||||
);
|
||||
}
|
||||
if (contract.expiresAt && contract.expiresAt.getTime() <= Date.now()) {
|
||||
throw new BadRequestException('Contract ordering window has expired');
|
||||
}
|
||||
|
||||
// The customer placing the order must own the contract.
|
||||
if (userId && !(await this.userOwnsContract(userId, contract))) {
|
||||
throw new BadRequestException('You do not have access to this contract');
|
||||
}
|
||||
|
||||
// Validate the route has a departure on the chosen day.
|
||||
const day = eatDay(new Date(dto.scheduledDate));
|
||||
const hasDeparture =
|
||||
await this.trainSchedulingService.existsOpenScheduleOnRouteDay(
|
||||
contract.originYardId,
|
||||
contract.destinationYardId,
|
||||
day,
|
||||
);
|
||||
if (!hasDeparture) {
|
||||
throw new BadRequestException(
|
||||
'No departures available on the selected day for this route',
|
||||
);
|
||||
}
|
||||
|
||||
// Validate each line against the remaining pool.
|
||||
const poolLines = await this.generalContractService.getQuantityLines(
|
||||
contract.id,
|
||||
);
|
||||
const isContainer = contract.freightType === 'CONTAINER';
|
||||
for (const line of dto.lines) {
|
||||
if (line.quantity <= 0) {
|
||||
throw new BadRequestException('Order quantities must be greater than zero');
|
||||
}
|
||||
const key = isContainer ? (line.containerTypeId ?? '') : '';
|
||||
const poolLine = poolLines.find((p) => (p.containerTypeId ?? '') === key);
|
||||
if (!poolLine) {
|
||||
throw new BadRequestException(
|
||||
isContainer
|
||||
? `Container type ${line.containerTypeId} is not part of this contract`
|
||||
: 'This contract has no matching quantity pool',
|
||||
);
|
||||
}
|
||||
if (line.quantity > poolLine.remainingQuantity) {
|
||||
throw new BadRequestException(
|
||||
`Requested ${line.quantity} exceeds remaining ${poolLine.remainingQuantity}` +
|
||||
(poolLine.containerTypeName ? ` for ${poolLine.containerTypeName}` : ''),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Persist the order + its child shipment booking atomically.
|
||||
const order = await this.dataSource.transaction(async (manager) => {
|
||||
const childBooking = await this.spawnChildBooking(contract, dto, manager);
|
||||
|
||||
const reference = await this.generateReference();
|
||||
const orderRow = manager.create(BookingOrder, {
|
||||
reference,
|
||||
contractBookingId: contract.id,
|
||||
bookingId: childBooking.id,
|
||||
companyId: contract.companyId ?? null,
|
||||
scheduledDate: new Date(dto.scheduledDate),
|
||||
status: 'PAID',
|
||||
schedulingStatus: 'NOT_SCHEDULED',
|
||||
});
|
||||
const savedOrder = await manager.save(orderRow);
|
||||
|
||||
const lines = dto.lines.map((l) =>
|
||||
manager.create(BookingOrderLine, {
|
||||
orderId: savedOrder.id,
|
||||
containerTypeId: isContainer ? (l.containerTypeId ?? null) : null,
|
||||
quantity: l.quantity,
|
||||
}),
|
||||
);
|
||||
await manager.save(lines);
|
||||
savedOrder.lines = lines;
|
||||
return savedOrder;
|
||||
});
|
||||
|
||||
// Feed the child booking into the day-pool batch so it allocates to a train.
|
||||
try {
|
||||
await this.bookingBatchService.processRouteDay({
|
||||
originYardId: contract.originYardId,
|
||||
destinationYardId: contract.destinationYardId,
|
||||
day,
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Batch fill after order ${order.reference} failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Close the contract once its pool is exhausted.
|
||||
if (await this.generalContractService.isExhausted(contract.id)) {
|
||||
await this.dataSource
|
||||
.getRepository(Booking)
|
||||
.update(contract.id, { status: 'CONTRACT_CLOSED' });
|
||||
this.logger.log(
|
||||
`Contract ${contract.reference} CLOSED — quantity exhausted`,
|
||||
);
|
||||
}
|
||||
|
||||
return (await this.ordersRepository.findById(order.id)) ?? order;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the ONE_TIME child booking for an order, inheriting the contract's
|
||||
* shipment context and entering the queue already PAID + FULLY_EXECUTED.
|
||||
*/
|
||||
private async spawnChildBooking(
|
||||
contract: Booking,
|
||||
dto: CreateBookingOrderDto,
|
||||
manager: import('typeorm').EntityManager,
|
||||
): Promise<Booking> {
|
||||
const reference = await this.generateChildBookingReference();
|
||||
const now = new Date();
|
||||
const isContainer = contract.freightType === 'CONTAINER';
|
||||
|
||||
// Sum line quantities × the contract's per-unit weight for the child total.
|
||||
const containerByType = new Map(
|
||||
(contract.bookingContainers ?? []).map((c) => [c.containerTypeId, c]),
|
||||
);
|
||||
let totalWeight = 0;
|
||||
if (isContainer) {
|
||||
for (const line of dto.lines) {
|
||||
const src = containerByType.get(line.containerTypeId ?? '');
|
||||
const vgmPerUnit = src ? Number(src.vgmPerUnitTons) : 0;
|
||||
totalWeight += vgmPerUnit * line.quantity;
|
||||
}
|
||||
} else {
|
||||
totalWeight = dto.lines.reduce((sum, l) => sum + l.quantity, 0);
|
||||
}
|
||||
|
||||
const child = manager.create(Booking, {
|
||||
reference,
|
||||
companyId: contract.companyId ?? null,
|
||||
companyProfileId: contract.companyProfileId ?? null,
|
||||
isGovernment: contract.isGovernment,
|
||||
governmentInstitution: contract.governmentInstitution ?? null,
|
||||
contractType: contract.contractType,
|
||||
previousContractId: contract.id,
|
||||
serviceTypeId: contract.serviceTypeId,
|
||||
firstMilePickupAddress: contract.firstMilePickupAddress ?? null,
|
||||
lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null,
|
||||
equipmentReturn: contract.equipmentReturn,
|
||||
originYardId: contract.originYardId,
|
||||
destinationYardId: contract.destinationYardId,
|
||||
tradeDirection: contract.tradeDirection,
|
||||
freightType: contract.freightType,
|
||||
cargoTypeId: contract.cargoTypeId ?? null,
|
||||
cargoFreeText: contract.cargoFreeText ?? null,
|
||||
shippingLineId: contract.shippingLineId ?? null,
|
||||
cargoTotalWeightVgm: totalWeight,
|
||||
isHazardous: contract.isHazardous,
|
||||
paymentCurrency: contract.paymentCurrency,
|
||||
bookingType: 'ONE_TIME',
|
||||
scheduledDate: new Date(dto.scheduledDate),
|
||||
// Already covered by the contract's one-time payment: enter the pool ready
|
||||
// and paid so the batch engine reserves → allocates it immediately.
|
||||
status: 'FULLY_EXECUTED',
|
||||
paymentStatus: 'PAID',
|
||||
fullyExecutedAt: now,
|
||||
customerSignedAt: now,
|
||||
priorityScore: contract.priorityScore,
|
||||
totalAmount: 0,
|
||||
allowConsolidation: false,
|
||||
schedulingStatus: 'NOT_SCHEDULED',
|
||||
});
|
||||
const savedChild = await manager.save(child);
|
||||
|
||||
if (isContainer) {
|
||||
for (const line of dto.lines) {
|
||||
const src = containerByType.get(line.containerTypeId ?? '');
|
||||
const ct = line.containerTypeId
|
||||
? await manager.getRepository(ContainerType).findOne({
|
||||
where: { id: line.containerTypeId },
|
||||
})
|
||||
: null;
|
||||
const wagonsPerUnit = ct ? Number(ct.wagonsPerUnit) : 1;
|
||||
const vgmPerUnit = src ? Number(src.vgmPerUnitTons) : 0;
|
||||
const row = manager.create(BookingContainer, {
|
||||
bookingId: savedChild.id,
|
||||
containerTypeId: line.containerTypeId ?? null,
|
||||
quantity: line.quantity,
|
||||
vgmPerUnitTons: vgmPerUnit,
|
||||
totalVgmTons: vgmPerUnit * line.quantity,
|
||||
wagonsRequired: Math.ceil(line.quantity * wagonsPerUnit),
|
||||
isOverweight: false,
|
||||
});
|
||||
await manager.save(row);
|
||||
}
|
||||
}
|
||||
|
||||
return savedChild;
|
||||
}
|
||||
|
||||
private async userOwnsContract(
|
||||
userId: string,
|
||||
contract: Booking,
|
||||
): Promise<boolean> {
|
||||
if (!contract.companyId) return true; // government / staff-created
|
||||
try {
|
||||
const { company } = await this.companiesService.getCompanyInfoByUserId(
|
||||
userId,
|
||||
);
|
||||
return company.id === contract.companyId;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async generateReference(): Promise<string> {
|
||||
const year = new Date().getFullYear();
|
||||
const count = await this.ordersRepository.countByYear(year);
|
||||
return `ORD-${year}-${String(count + 1).padStart(6, '0')}`;
|
||||
}
|
||||
|
||||
private async generateChildBookingReference(): Promise<string> {
|
||||
const year = new Date().getFullYear();
|
||||
const count = await this.bookingsRepository.countByYear(year);
|
||||
return `BK-${year}-${String(count + 1).padStart(6, '0')}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { CargoUnitOfMeasure } from '@edr/types';
|
||||
|
||||
/** A single contracted/ordered/remaining pool line for a general contract. */
|
||||
export class ContractQuantityLineView {
|
||||
@ApiProperty({ nullable: true, description: 'Container type id (null for bulk/break-bulk)' })
|
||||
containerTypeId!: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
containerTypeName!: string | null;
|
||||
|
||||
@ApiProperty({ enum: CargoUnitOfMeasure, nullable: true })
|
||||
unitOfMeasure!: CargoUnitOfMeasure | null;
|
||||
|
||||
@ApiProperty()
|
||||
contractedQuantity!: number;
|
||||
|
||||
@ApiProperty()
|
||||
orderedQuantity!: number;
|
||||
|
||||
@ApiProperty()
|
||||
remainingQuantity!: number;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsDateString,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsUUID,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateBookingOrderLineDto {
|
||||
@ApiPropertyOptional({
|
||||
format: 'uuid',
|
||||
description: 'Container type for this line (CONTAINER contracts). Omit for bulk/break-bulk.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
containerTypeId?: string;
|
||||
|
||||
@ApiProperty({ description: 'Quantity to draw down (containers, tons, or items)', minimum: 0 })
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value))
|
||||
quantity!: number;
|
||||
}
|
||||
|
||||
export class CreateBookingOrderDto {
|
||||
@ApiProperty({ format: 'uuid', description: 'The general contract to draw down from' })
|
||||
@IsUUID()
|
||||
contractBookingId!: string;
|
||||
|
||||
@ApiProperty({ example: '2026-07-01T00:00:00.000Z', description: 'Shipment day for this order' })
|
||||
@IsDateString()
|
||||
scheduledDate!: string;
|
||||
|
||||
@ApiProperty({ type: [CreateBookingOrderLineDto] })
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => CreateBookingOrderLineDto)
|
||||
lines!: CreateBookingOrderLineDto[];
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, JoinColumn, ManyToOne } from 'typeorm';
|
||||
import { ContainerType } from '../../rule-engine/entities/container-type.entity';
|
||||
import { BookingOrder } from './booking-order.entity';
|
||||
|
||||
/**
|
||||
* One drawn-down quantity line of an order. For CONTAINER contracts there is one
|
||||
* line per container type (matching the contract's pools); for BULK/BREAK_BULK a
|
||||
* single line with a null containerTypeId carries the tons/items.
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'booking_order_lines' })
|
||||
export class BookingOrderLine extends BaseEntity {
|
||||
@Column({ name: 'order_id', type: 'uuid' })
|
||||
orderId!: string;
|
||||
|
||||
@ManyToOne(() => BookingOrder, (order) => order.lines, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'order_id' })
|
||||
order?: BookingOrder;
|
||||
|
||||
@Column({ name: 'container_type_id', type: 'uuid', nullable: true })
|
||||
containerTypeId?: string | null;
|
||||
|
||||
@ManyToOne(() => ContainerType, { nullable: true })
|
||||
@JoinColumn({ name: 'container_type_id' })
|
||||
containerType?: ContainerType | null;
|
||||
|
||||
/** Containers (count), tons, or items depending on the contract's freight/UoM. */
|
||||
@Column({ name: 'quantity', type: 'numeric', precision: 12, scale: 3 })
|
||||
quantity!: number;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { SchedulingStatus } from '@edr/types';
|
||||
import { Column, Entity, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
|
||||
import { Booking } from '../../bookings/entities/booking.entity';
|
||||
import { Company } from '../../companies/entities/company.entity';
|
||||
import { BookingOrderLine } from './booking-order-line.entity';
|
||||
|
||||
/**
|
||||
* A single drawdown against a general contract. Each order spawns its own
|
||||
* ONE_TIME child Booking (the shipment that enters the train scheduling
|
||||
* pipeline); this row is the ledger entry linking the contract to that
|
||||
* shipment and recording the drawn-down quantities.
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'booking_orders' })
|
||||
export class BookingOrder extends BaseEntity {
|
||||
@Column({ name: 'reference', type: 'varchar', length: 64, unique: true })
|
||||
reference!: string;
|
||||
|
||||
/** The general contract (a Booking with bookingType = GENERAL_CONTRACT). */
|
||||
@Column({ name: 'contract_booking_id', type: 'uuid' })
|
||||
contractBookingId!: string;
|
||||
|
||||
@ManyToOne(() => Booking)
|
||||
@JoinColumn({ name: 'contract_booking_id' })
|
||||
contractBooking?: Booking;
|
||||
|
||||
/** The ONE_TIME child shipment booking spawned for this order. */
|
||||
@Column({ name: 'booking_id', type: 'uuid', nullable: true })
|
||||
bookingId?: string | null;
|
||||
|
||||
@ManyToOne(() => Booking, { nullable: true })
|
||||
@JoinColumn({ name: 'booking_id' })
|
||||
booking?: Booking | null;
|
||||
|
||||
/** Denormalized from the contract for fast company-scoped filtering. */
|
||||
@Column({ name: 'company_id', type: 'uuid', nullable: true })
|
||||
companyId?: string | null;
|
||||
|
||||
@ManyToOne(() => Company, { nullable: true })
|
||||
@JoinColumn({ name: 'company_id' })
|
||||
company?: Company | null;
|
||||
|
||||
@Column({ name: 'scheduled_date', type: 'timestamptz' })
|
||||
scheduledDate!: Date;
|
||||
|
||||
@Column({ name: 'status', type: 'varchar', length: 40, default: 'PAID' })
|
||||
status!: string;
|
||||
|
||||
@Column({
|
||||
name: 'scheduling_status',
|
||||
type: 'varchar',
|
||||
length: 30,
|
||||
default: SchedulingStatus.NotScheduled,
|
||||
})
|
||||
schedulingStatus!: string;
|
||||
|
||||
@Column({ name: 'train_schedule_id', type: 'uuid', nullable: true })
|
||||
trainScheduleId?: string | null;
|
||||
|
||||
@OneToMany(() => BookingOrderLine, (line) => line.order, { cascade: true })
|
||||
lines?: BookingOrderLine[];
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { BookingType, CargoUnitOfMeasure } from '@edr/types';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { BookingOrder } from './entities/booking-order.entity';
|
||||
import { ContractQuantityLineView } from './dto/contract-view.dto';
|
||||
|
||||
/** Setting code holding the global ordering window (in months) for general contracts. */
|
||||
export const CONTRACT_PERIOD_SETTING_CODE = 'general_contract_period';
|
||||
/** Fallback when the setting is missing or unparseable. */
|
||||
export const DEFAULT_CONTRACT_PERIOD_MONTHS = 3;
|
||||
|
||||
/**
|
||||
* Owns general-contract concerns that sit alongside the generic booking flow:
|
||||
* the configurable ordering period, post-payment activation, and computing the
|
||||
* remaining drawdown pool per contract.
|
||||
*/
|
||||
@Injectable()
|
||||
export class GeneralContractService {
|
||||
private readonly logger = new Logger(GeneralContractService.name);
|
||||
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly dropdownSettings: DropdownSettingsService,
|
||||
) {}
|
||||
|
||||
isGeneralContract(booking: Pick<Booking, 'bookingType'>): boolean {
|
||||
return booking.bookingType === BookingType.GeneralContract;
|
||||
}
|
||||
|
||||
/** The configured ordering window in months (defaults to 3). */
|
||||
async getPeriodMonths(): Promise<number> {
|
||||
try {
|
||||
const setting = await this.dropdownSettings.getByCode(
|
||||
CONTRACT_PERIOD_SETTING_CODE,
|
||||
);
|
||||
const raw = setting.children?.[0]?.value;
|
||||
const months = Number(raw);
|
||||
if (Number.isFinite(months) && months > 0) return months;
|
||||
} catch {
|
||||
// Setting not seeded yet — fall back to the default.
|
||||
}
|
||||
return DEFAULT_CONTRACT_PERIOD_MONTHS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a general contract's payment succeeds: mark it ACTIVE (instead of
|
||||
* entering the train queue like a one-time booking) and stamp the ordering
|
||||
* window. Idempotent.
|
||||
*/
|
||||
async activateAfterPayment(bookingId: string): Promise<void> {
|
||||
const repo = this.dataSource.getRepository(Booking);
|
||||
const booking = await repo.findOne({ where: { id: bookingId } });
|
||||
if (!booking || !this.isGeneralContract(booking)) return;
|
||||
if (booking.status === 'CONTRACT_ACTIVE' || booking.status === 'CONTRACT_CLOSED') {
|
||||
return;
|
||||
}
|
||||
|
||||
const months = await this.getPeriodMonths();
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setMonth(expiresAt.getMonth() + months);
|
||||
|
||||
await repo.update(bookingId, {
|
||||
status: 'CONTRACT_ACTIVE',
|
||||
paymentStatus: 'PAID',
|
||||
expiresAt,
|
||||
});
|
||||
this.logger.log(
|
||||
`General contract ${booking.reference} ACTIVE — ordering window ${months} month(s) (expires ${expiresAt.toISOString()})`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The drawdown pool for a contract: contracted vs. ordered vs. remaining,
|
||||
* per container type for CONTAINER contracts, or a single total line for
|
||||
* BULK/BREAK_BULK (keyed on a null container type).
|
||||
*/
|
||||
async getQuantityLines(
|
||||
contractBookingId: string,
|
||||
): Promise<ContractQuantityLineView[]> {
|
||||
const booking = await this.dataSource.getRepository(Booking).findOne({
|
||||
where: { id: contractBookingId },
|
||||
relations: { bookingContainers: { containerType: true }, cargoType: true },
|
||||
});
|
||||
if (!booking) throw new NotFoundException(`Contract ${contractBookingId} not found`);
|
||||
|
||||
const ordered = await this.orderedByContainerType(contractBookingId);
|
||||
|
||||
if (booking.freightType === 'CONTAINER') {
|
||||
return (booking.bookingContainers ?? []).map((c) => {
|
||||
const orderedQty = ordered.get(c.containerTypeId ?? '') ?? 0;
|
||||
const contracted = Number(c.quantity);
|
||||
return {
|
||||
containerTypeId: c.containerTypeId ?? null,
|
||||
containerTypeName: c.containerType?.label ?? null,
|
||||
unitOfMeasure: null,
|
||||
contractedQuantity: contracted,
|
||||
orderedQuantity: orderedQty,
|
||||
remainingQuantity: Math.max(0, contracted - orderedQty),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// BULK / BREAK_BULK — a single pool keyed on the contracted total weight/items.
|
||||
const orderedQty = ordered.get('') ?? 0;
|
||||
const contracted = Number(booking.cargoTotalWeightVgm);
|
||||
const uom: CargoUnitOfMeasure | null =
|
||||
(booking.cargoType?.unitOfMeasure as CargoUnitOfMeasure | undefined) ??
|
||||
CargoUnitOfMeasure.PerTon;
|
||||
return [
|
||||
{
|
||||
containerTypeId: null,
|
||||
containerTypeName: null,
|
||||
unitOfMeasure: uom,
|
||||
contractedQuantity: contracted,
|
||||
orderedQuantity: orderedQty,
|
||||
remainingQuantity: Math.max(0, contracted - orderedQty),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** Sum of non-cancelled order line quantities, keyed by container type id ('' = bulk). */
|
||||
private async orderedByContainerType(
|
||||
contractBookingId: string,
|
||||
): Promise<Map<string, number>> {
|
||||
const rows = await this.dataSource
|
||||
.getRepository(BookingOrder)
|
||||
.createQueryBuilder('o')
|
||||
.innerJoin('o.lines', 'line')
|
||||
.select('COALESCE(line.container_type_id::text, :empty)', 'key')
|
||||
.addSelect('SUM(line.quantity)', 'total')
|
||||
.where('o.contract_booking_id = :contractBookingId', { contractBookingId })
|
||||
.andWhere(`o.status NOT IN ('CANCELLED', 'REJECTED')`)
|
||||
.setParameter('empty', '')
|
||||
.groupBy('key')
|
||||
.getRawMany<{ key: string; total: string }>();
|
||||
|
||||
const map = new Map<string, number>();
|
||||
for (const row of rows) map.set(row.key ?? '', Number(row.total));
|
||||
return map;
|
||||
}
|
||||
|
||||
/** Convenience: how many units remain for a given container type ('' = bulk). */
|
||||
async remainingFor(
|
||||
contractBookingId: string,
|
||||
containerTypeKey: string,
|
||||
): Promise<number> {
|
||||
const lines = await this.getQuantityLines(contractBookingId);
|
||||
const line = lines.find(
|
||||
(l) => (l.containerTypeId ?? '') === containerTypeKey,
|
||||
);
|
||||
return line?.remainingQuantity ?? 0;
|
||||
}
|
||||
|
||||
/** True once every contracted line is fully drawn down. */
|
||||
async isExhausted(contractBookingId: string): Promise<boolean> {
|
||||
const lines = await this.getQuantityLines(contractBookingId);
|
||||
return lines.every((l) => l.remainingQuantity <= 0);
|
||||
}
|
||||
}
|
||||
@@ -28,15 +28,15 @@ describe('BookingPricingService — domestic corridor', () => {
|
||||
let service: BookingPricingService;
|
||||
let bookingsRepository: { calculateWagonCount: jest.Mock };
|
||||
let ratesService: { findLiveRates: jest.Mock };
|
||||
let cbeExchangeService: { getUsdToEtbRate: jest.Mock };
|
||||
let exchangeService: { getRate: jest.Mock };
|
||||
|
||||
beforeEach(() => {
|
||||
bookingsRepository = { calculateWagonCount: jest.fn().mockResolvedValue(2) };
|
||||
ratesService = {
|
||||
findLiveRates: jest.fn().mockResolvedValue([intercityBulkUsd, intercityContainerUsd]),
|
||||
};
|
||||
cbeExchangeService = {
|
||||
getUsdToEtbRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE),
|
||||
exchangeService = {
|
||||
getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE),
|
||||
};
|
||||
|
||||
service = new BookingPricingService(
|
||||
@@ -45,7 +45,7 @@ describe('BookingPricingService — domestic corridor', () => {
|
||||
{} as never,
|
||||
ratesService as never,
|
||||
{} as never,
|
||||
cbeExchangeService as never,
|
||||
exchangeService as never,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ContainerTypesService } from '../rule-engine/services/container-types.s
|
||||
import { RatesService } from '../rule-engine/services/rates.service';
|
||||
import { ServiceTypesService } from '../rule-engine/services/service-types.service';
|
||||
import { Rate } from '../rule-engine/entities/rate.entity';
|
||||
import { CbeExchangeService } from '../cbe-exchange/cbe-exchange.service';
|
||||
import { ExchangeService } from '@edr/api-common';
|
||||
import {
|
||||
AppliedCargoModifier,
|
||||
BookingEvaluationInput,
|
||||
@@ -41,7 +41,7 @@ export class BookingPricingService {
|
||||
private readonly containerTypesService: ContainerTypesService,
|
||||
private readonly ratesService: RatesService,
|
||||
private readonly serviceTypesService: ServiceTypesService,
|
||||
private readonly cbeExchangeService: CbeExchangeService,
|
||||
private readonly exchangeService: ExchangeService,
|
||||
) {}
|
||||
|
||||
async generatePrice(bookingId: string): Promise<GeneratePriceResponseDto> {
|
||||
@@ -84,7 +84,7 @@ export class BookingPricingService {
|
||||
|
||||
const paymentCurrency = booking.paymentCurrency;
|
||||
const isEtbBooking = paymentCurrency === 'ETB';
|
||||
const usdToEtb = isEtbBooking ? await this.cbeExchangeService.getUsdToEtbRate() : 1;
|
||||
const usdToEtb = isEtbBooking ? await this.exchangeService.getRate('USD', 'ETB') : 1;
|
||||
|
||||
const lineItems: PriceLineItemDto[] = [];
|
||||
let total = 0;
|
||||
@@ -285,7 +285,7 @@ export class BookingPricingService {
|
||||
const liveRates = await this.ratesService.findLiveRates();
|
||||
const paymentCurrency = booking.paymentCurrency;
|
||||
const isEtbBooking = paymentCurrency === 'ETB';
|
||||
const usdToEtb = isEtbBooking ? await this.cbeExchangeService.getUsdToEtbRate() : 1;
|
||||
const usdToEtb = isEtbBooking ? await this.exchangeService.getRate('USD', 'ETB') : 1;
|
||||
const isBulk = booking.freightType === 'BULK';
|
||||
|
||||
const rateType =
|
||||
|
||||
@@ -59,6 +59,7 @@ export function buildCargoTypeTree(
|
||||
name: child.cargoTypeName,
|
||||
code: child.code,
|
||||
show_free_text_box: child.showFreeTextBox,
|
||||
unit_of_measure: child.unitOfMeasure ?? null,
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -132,8 +132,31 @@ export class BookingsController {
|
||||
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);
|
||||
if (!companyId) {
|
||||
const page = filter.page ?? 1;
|
||||
const pageSize = filter.pageSize ?? 20;
|
||||
return {
|
||||
items: [],
|
||||
total: 0,
|
||||
meta: {
|
||||
page,
|
||||
pageSize,
|
||||
total: 0,
|
||||
totalPages: 0,
|
||||
hasNextPage: false,
|
||||
hasPreviousPage: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
// Scope to the active operational profile (importer/exporter) when one
|
||||
// resolves; otherwise fall back to company-level scoping.
|
||||
const companyProfileId =
|
||||
await this.bookingsService.resolveActiveCompanyProfileId(userId);
|
||||
return this.bookingsService.findAll(
|
||||
filter,
|
||||
companyId,
|
||||
companyProfileId ?? undefined,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('list-summary')
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ExchangeModule, ExchangeOptions } from '@edr/api-common';
|
||||
|
||||
// import { CustomersModule } from '../customers/customers.module';
|
||||
import { CompaniesModule } from '../companies/companies.module';
|
||||
@@ -31,7 +33,6 @@ import { ContractTemplateResolver } from '../../contracts/contract-template.reso
|
||||
import { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder';
|
||||
import { PaymentModule } from '../payment/payment.module';
|
||||
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
|
||||
import { CbeExchangeService } from '../cbe-exchange/cbe-exchange.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -52,6 +53,11 @@ import { CbeExchangeService } from '../cbe-exchange/cbe-exchange.service';
|
||||
// CustomersModule,
|
||||
RuleEngineModule,
|
||||
SignaturesModule,
|
||||
ExchangeModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService): ExchangeOptions =>
|
||||
config.get<ExchangeOptions>('app.cbeExchange') ?? {},
|
||||
}),
|
||||
],
|
||||
controllers: [BookingsController, PayController],
|
||||
providers: [
|
||||
@@ -68,7 +74,6 @@ import { CbeExchangeService } from '../cbe-exchange/cbe-exchange.service';
|
||||
ContractPricingScheduleBuilder,
|
||||
ContractRendererService,
|
||||
ContractPdfService,
|
||||
CbeExchangeService,
|
||||
],
|
||||
exports: [BookingsService, BookingsRepository],
|
||||
})
|
||||
|
||||
@@ -25,14 +25,18 @@ export interface BookingListFilterOptions {
|
||||
schedulingStatuses?: string[];
|
||||
assignedToSchedule?: 'true' | 'false';
|
||||
companyId?: string;
|
||||
companyProfileId?: string;
|
||||
contractType?: string;
|
||||
serviceTypeId?: string;
|
||||
cargoTypeId?: string;
|
||||
freightType?: string;
|
||||
bookingType?: string;
|
||||
tradeDirection?: string;
|
||||
paymentCurrency?: string;
|
||||
paymentStatus?: string;
|
||||
excludePaymentStatus?: string;
|
||||
createdFrom?: string;
|
||||
createdTo?: string;
|
||||
allowConsolidation?: boolean;
|
||||
consolidationPaired?: string;
|
||||
}
|
||||
@@ -434,7 +438,18 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
pageSize: number;
|
||||
sortBy?: string;
|
||||
sortOrder?: 'ASC' | 'DESC';
|
||||
}): Promise<{ items: Booking[]; total: number }> {
|
||||
}): Promise<{
|
||||
items: Booking[];
|
||||
total: number;
|
||||
meta: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
hasNextPage: boolean;
|
||||
hasPreviousPage: boolean;
|
||||
};
|
||||
}> {
|
||||
const page = options.page;
|
||||
const pageSize = options.pageSize;
|
||||
|
||||
@@ -481,7 +496,22 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
}
|
||||
}
|
||||
|
||||
return { items, total };
|
||||
const totalPages = pageSize > 0 ? Math.ceil(total / pageSize) : 0;
|
||||
// Return both the flat `total` (consumed by the backoffice list) and a
|
||||
// `meta` block (consumed by the portal, matching PaginationMeta) so neither
|
||||
// app needs to change its read shape.
|
||||
return {
|
||||
items,
|
||||
total,
|
||||
meta: {
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
totalPages,
|
||||
hasNextPage: page < totalPages,
|
||||
hasPreviousPage: page > 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async getStatusCounts(): Promise<Record<string, number>> {
|
||||
@@ -559,6 +589,11 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
companyId: options.companyId,
|
||||
});
|
||||
}
|
||||
if (options.companyProfileId) {
|
||||
qb.andWhere('booking.company_profile_id = :companyProfileId', {
|
||||
companyProfileId: options.companyProfileId,
|
||||
});
|
||||
}
|
||||
if (options.contractType) {
|
||||
qb.andWhere('booking.contract_type = :contractType', {
|
||||
contractType: options.contractType,
|
||||
@@ -579,6 +614,22 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
freightType: options.freightType,
|
||||
});
|
||||
}
|
||||
if (options.bookingType) {
|
||||
qb.andWhere('booking.booking_type = :bookingType', {
|
||||
bookingType: options.bookingType,
|
||||
});
|
||||
}
|
||||
if (options.createdFrom) {
|
||||
qb.andWhere('booking.created_at >= :createdFrom', {
|
||||
createdFrom: options.createdFrom,
|
||||
});
|
||||
}
|
||||
if (options.createdTo) {
|
||||
// Inclusive end-of-day: callers pass a date; include the whole day.
|
||||
qb.andWhere('booking.created_at <= :createdTo', {
|
||||
createdTo: options.createdTo,
|
||||
});
|
||||
}
|
||||
if (options.tradeDirection) {
|
||||
qb.andWhere('booking.trade_direction = :tradeDirection', {
|
||||
tradeDirection: options.tradeDirection,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { Freight, SchedulingStatus } from '@edr/types';
|
||||
// import { CustomersService } from '../customers/customers.service';
|
||||
import { CompaniesService } from '../companies/companies.service';
|
||||
import { ProfileType } from '../companies/entities/company-profile.entity';
|
||||
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
|
||||
import { eatDay } from '../train-scheduling/batch-window.util';
|
||||
import { FilesService } from '../files/files.service';
|
||||
@@ -41,6 +42,20 @@ import {
|
||||
import { Booking } from './entities/booking.entity';
|
||||
import { FileRecord } from '../files/entities/file.entity';
|
||||
|
||||
/** Paginated booking list: flat `total` (backoffice) + `meta` block (portal). */
|
||||
export interface PaginatedBookings {
|
||||
items: Booking[];
|
||||
total: number;
|
||||
meta: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
hasNextPage: boolean;
|
||||
hasPreviousPage: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
const URGENT_PRIORITY_THRESHOLD = 1000;
|
||||
const NEEDS_ACTION_STATUSES = [
|
||||
'SUBMITTED',
|
||||
@@ -257,6 +272,7 @@ export class BookingsService {
|
||||
// }
|
||||
|
||||
const isGovernment = dto.isGovernment === true;
|
||||
const isGeneralContract = dto.bookingType === 'GENERAL_CONTRACT';
|
||||
|
||||
let companyId: string | null | undefined = dto.companyId;
|
||||
if (isGovernment) {
|
||||
@@ -291,11 +307,12 @@ export class BookingsService {
|
||||
) {
|
||||
throw new BadRequestException('Selected schedule is not on the booking route');
|
||||
}
|
||||
} else {
|
||||
} else if (!isGeneralContract) {
|
||||
// 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));
|
||||
// train later. General contracts skip this — they have no shipment date at
|
||||
// creation; each drawdown order validates its own day.
|
||||
const day = eatDay(new Date(dto.scheduledDate!));
|
||||
const hasDeparture =
|
||||
await this.trainSchedulingService.existsOpenScheduleOnRouteDay(
|
||||
dto.originYardId,
|
||||
@@ -323,6 +340,29 @@ export class BookingsService {
|
||||
dto.tradeDirection,
|
||||
);
|
||||
|
||||
// Stamp the operational profile this booking belongs to (importer/exporter)
|
||||
// so the customer portal can scope lists/KPIs to the active mode. Best-effort
|
||||
// for non-government bookings with a resolved company; never blocks creation.
|
||||
let companyProfileId: string | null = null;
|
||||
if (!isGovernment && companyId) {
|
||||
let fallbackType: ProfileType | null = null;
|
||||
if (userId) {
|
||||
try {
|
||||
const { profile } =
|
||||
await this.companiesService.getCompanyInfoByUserId(userId);
|
||||
fallbackType = profile.activeProfileType ?? null;
|
||||
} catch {
|
||||
// No profile (e.g. staff creating on behalf) — fall back to mapping.
|
||||
}
|
||||
}
|
||||
companyProfileId =
|
||||
await this.companiesService.resolveCompanyProfileIdForBooking(
|
||||
companyId,
|
||||
tradeDirection,
|
||||
fallbackType,
|
||||
);
|
||||
}
|
||||
|
||||
const allowConsolidation =
|
||||
dto.freightType === 'CONTAINER'
|
||||
? await this.resolveConsolidation(containers, dto.allowConsolidation)
|
||||
@@ -348,6 +388,7 @@ export class BookingsService {
|
||||
const booking = await this.bookingsRepository.create({
|
||||
reference,
|
||||
companyId: companyId ?? null,
|
||||
companyProfileId,
|
||||
isGovernment,
|
||||
governmentInstitution: isGovernment ? dto.governmentInstitution!.trim() : null,
|
||||
trainId: dto.trainId,
|
||||
@@ -370,7 +411,8 @@ export class BookingsService {
|
||||
paymentCurrency: dto.paymentCurrency,
|
||||
pnrCode: dto.pnrCode,
|
||||
financialTerms: dto.financialTerms,
|
||||
scheduledDate: new Date(dto.scheduledDate),
|
||||
bookingType: isGeneralContract ? 'GENERAL_CONTRACT' : 'ONE_TIME',
|
||||
scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null,
|
||||
startDate: dto.startDate ? new Date(dto.startDate) : undefined,
|
||||
endDate: dto.endDate ? new Date(dto.endDate) : undefined,
|
||||
status: 'DRAFT',
|
||||
@@ -504,6 +546,22 @@ export class BookingsService {
|
||||
priorityScore: ruleResult.priorityScore,
|
||||
tradeDirection,
|
||||
};
|
||||
// If the route (hence trade direction) changed, re-stamp the operational
|
||||
// profile so an edited draft doesn't get stranded under the wrong profile.
|
||||
if (
|
||||
tradeDirection !== existing.tradeDirection &&
|
||||
!existing.isGovernment &&
|
||||
existing.companyId
|
||||
) {
|
||||
updates.companyProfileId =
|
||||
await this.companiesService.resolveCompanyProfileIdForBooking(
|
||||
existing.companyId,
|
||||
tradeDirection,
|
||||
existing.companyProfileId
|
||||
? undefined
|
||||
: (existing.companyProfile?.type as ProfileType | undefined),
|
||||
);
|
||||
}
|
||||
if (dto.scheduledDate) updates.scheduledDate = new Date(dto.scheduledDate);
|
||||
if (dto.startDate) updates.startDate = new Date(dto.startDate);
|
||||
if (dto.endDate) updates.endDate = new Date(dto.endDate);
|
||||
@@ -583,7 +641,8 @@ export class BookingsService {
|
||||
async findAll(
|
||||
filter: FilterBookingDto,
|
||||
forceCompanyId?: string,
|
||||
): Promise<{ items: Booking[]; total: number }> {
|
||||
forceCompanyProfileId?: string,
|
||||
): Promise<PaginatedBookings> {
|
||||
const page = filter.page ?? 1;
|
||||
const pageSize = filter.pageSize ?? 20;
|
||||
const statusFilter = this.parseStatusFilter(filter);
|
||||
@@ -597,14 +656,20 @@ export class BookingsService {
|
||||
assignedToSchedule: filter.assignedToSchedule,
|
||||
// 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,
|
||||
// When an active profile resolves, scope to it; otherwise fall back to the
|
||||
// company so nothing breaks for not-yet-onboarded customers.
|
||||
companyId: forceCompanyProfileId ? undefined : forceCompanyId ?? filter.companyId,
|
||||
companyProfileId: forceCompanyProfileId,
|
||||
contractType: filter.contractType,
|
||||
serviceTypeId: filter.serviceTypeId,
|
||||
cargoTypeId: filter.cargoTypeId,
|
||||
freightType: filter.freightType,
|
||||
bookingType: filter.bookingType,
|
||||
tradeDirection: filter.tradeDirection,
|
||||
paymentCurrency: filter.paymentCurrency,
|
||||
paymentStatus: filter.paymentStatus,
|
||||
createdFrom: filter.createdFrom,
|
||||
createdTo: filter.createdTo,
|
||||
allowConsolidation: filter.allowConsolidation,
|
||||
consolidationPaired: filter.consolidationPaired,
|
||||
sortBy: filter.sortBy,
|
||||
@@ -627,15 +692,20 @@ export class BookingsService {
|
||||
async findMyPayable(
|
||||
userId: string,
|
||||
filter: FilterBookingDto,
|
||||
): Promise<{ items: Booking[]; total: number }> {
|
||||
): Promise<PaginatedBookings> {
|
||||
const { company } = await this.companiesService.getCompanyInfoByUserId(userId);
|
||||
// Scope to the active operational profile when one resolves; fall back to
|
||||
// company-level so not-yet-onboarded customers still see their payables.
|
||||
const companyProfileId =
|
||||
await this.companiesService.resolveActiveCompanyProfileId(userId);
|
||||
|
||||
return this.bookingsRepository.findAllPaginated({
|
||||
page: filter.page ?? 1,
|
||||
pageSize: filter.pageSize ?? 20,
|
||||
statuses: BookingsService.PAYABLE_STATUSES,
|
||||
excludePaymentStatus: 'PAID',
|
||||
companyId: company.id,
|
||||
companyId: companyProfileId ? undefined : company.id,
|
||||
companyProfileId: companyProfileId ?? undefined,
|
||||
sortBy: filter.sortBy,
|
||||
sortOrder: filter.sortOrder,
|
||||
});
|
||||
@@ -655,6 +725,15 @@ export class BookingsService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the active company_profile id a customer's bookings should be
|
||||
* scoped to (importer/exporter mode). Null when not onboarded — callers fall
|
||||
* back to company-level scoping.
|
||||
*/
|
||||
async resolveActiveCompanyProfileId(userId: string): Promise<string | null> {
|
||||
return this.companiesService.resolveActiveCompanyProfileId(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -756,9 +835,12 @@ export class BookingsService {
|
||||
serviceTypeId: filter.serviceTypeId,
|
||||
cargoTypeId: filter.cargoTypeId,
|
||||
freightType: filter.freightType,
|
||||
bookingType: filter.bookingType,
|
||||
tradeDirection: filter.tradeDirection,
|
||||
paymentCurrency: filter.paymentCurrency,
|
||||
paymentStatus: filter.paymentStatus,
|
||||
createdFrom: filter.createdFrom,
|
||||
createdTo: filter.createdTo,
|
||||
allowConsolidation: filter.allowConsolidation,
|
||||
consolidationPaired: filter.consolidationPaired,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { CargoUnitOfMeasure } from '@edr/types';
|
||||
|
||||
export class BookingReferenceYardDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@@ -73,6 +74,9 @@ export class BookingReferenceCargoTypeChildDto {
|
||||
|
||||
@ApiProperty()
|
||||
show_free_text_box!: boolean;
|
||||
|
||||
@ApiProperty({ enum: CargoUnitOfMeasure, nullable: true, required: false })
|
||||
unit_of_measure?: CargoUnitOfMeasure | null;
|
||||
}
|
||||
|
||||
export class BookingReferenceCargoTypeGroupDto {
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
ValidateIf,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { BOOKING_STATUSES, FREIGHT_TYPES } from '../entities/booking.entity';
|
||||
import { BOOKING_STATUSES, BOOKING_TYPES, FREIGHT_TYPES } from '../entities/booking.entity';
|
||||
import { BookingFreightShapeConstraint } from './validators/booking-freight.validator';
|
||||
|
||||
const CONTRACT_TYPES = ['NEW', 'RENEWAL'] as const;
|
||||
@@ -27,6 +27,7 @@ const PAYMENT_CURRENCIES = ['ETB', 'USD'] as const;
|
||||
|
||||
export {
|
||||
BOOKING_STATUSES,
|
||||
BOOKING_TYPES,
|
||||
CONTRACT_TYPES,
|
||||
EQUIPMENT_RETURNS,
|
||||
FREIGHT_TYPES,
|
||||
@@ -105,10 +106,24 @@ export class CreateBookingDto {
|
||||
@IsUUID()
|
||||
trainScheduleId?: string;
|
||||
|
||||
/** The day the customer wants to ship (the pool day key). */
|
||||
@ApiProperty({ example: '2026-06-15T00:00:00.000Z' })
|
||||
@ApiPropertyOptional({
|
||||
enum: BOOKING_TYPES,
|
||||
default: 'ONE_TIME',
|
||||
description:
|
||||
'ONE_TIME (default) for a normal booking; GENERAL_CONTRACT for an umbrella contract drawn down by orders.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn([...BOOKING_TYPES])
|
||||
bookingType?: string;
|
||||
|
||||
/**
|
||||
* The day the customer wants to ship (the pool day key). Required for one-time
|
||||
* bookings; omitted for general contracts, which pick the date per order.
|
||||
*/
|
||||
@ApiPropertyOptional({ example: '2026-06-15T00:00:00.000Z' })
|
||||
@ValidateIf((o) => o.bookingType !== 'GENERAL_CONTRACT')
|
||||
@IsDateString()
|
||||
scheduledDate!: string;
|
||||
scheduledDate?: string;
|
||||
|
||||
@ApiProperty({ enum: CONTRACT_TYPES })
|
||||
@IsIn([...CONTRACT_TYPES])
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsIn, IsOptional, IsUUID } from 'class-validator';
|
||||
import { IsDateString, IsIn, IsOptional, IsUUID } from 'class-validator';
|
||||
import {
|
||||
BOOKING_STATUSES,
|
||||
BOOKING_TYPES,
|
||||
FREIGHT_TYPES,
|
||||
PAYMENT_CURRENCIES,
|
||||
TRADE_DIRECTIONS,
|
||||
@@ -56,6 +57,21 @@ export class FilterBookingDto {
|
||||
@IsIn([...FREIGHT_TYPES])
|
||||
freightType?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: BOOKING_TYPES, description: 'ONE_TIME or GENERAL_CONTRACT' })
|
||||
@IsOptional()
|
||||
@IsIn([...BOOKING_TYPES])
|
||||
bookingType?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Filter bookings created on/after this date (ISO)' })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
createdFrom?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Filter bookings created on/before this date (ISO)' })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
createdTo?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: TRADE_DIRECTIONS })
|
||||
@IsOptional()
|
||||
@IsIn([...TRADE_DIRECTIONS])
|
||||
|
||||
@@ -3,6 +3,7 @@ import { SchedulingStatus } from '@edr/types';
|
||||
import { Column, Entity, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
|
||||
// import { Customer } from '../../customers/entities/customer.entity';
|
||||
import { Company } from '../../companies/entities/company.entity';
|
||||
import { CompanyProfile } from '../../companies/entities/company-profile.entity';
|
||||
import { CargoType } from '../../rule-engine/entities/cargo-type.entity';
|
||||
import { ServiceType } from '../../rule-engine/entities/service-type.entity';
|
||||
import { ShippingLine } from '../../rule-engine/entities/shipping-line.entity';
|
||||
@@ -40,10 +41,15 @@ export const BOOKING_STATUSES = [
|
||||
'CANCELLED',
|
||||
'PENDING_CONSOLIDATION',
|
||||
'CONSOLIDATED',
|
||||
'CONTRACT_ACTIVE',
|
||||
'CONTRACT_CLOSED',
|
||||
] as const;
|
||||
|
||||
export type BookingStatus = (typeof BOOKING_STATUSES)[number];
|
||||
|
||||
export const BOOKING_TYPES = ['ONE_TIME', 'GENERAL_CONTRACT'] as const;
|
||||
export type BookingTypeValue = (typeof BOOKING_TYPES)[number];
|
||||
|
||||
export const PAYMENT_STATUSES = [
|
||||
'PENDING',
|
||||
'PNR_GENERATED',
|
||||
@@ -92,6 +98,20 @@ export class Booking extends BaseEntity {
|
||||
@JoinColumn({ name: 'company_id' })
|
||||
company?: Company | null;
|
||||
|
||||
/**
|
||||
* The operational profile (importer/exporter/forwarder) this booking belongs
|
||||
* to. Stamped at creation from the booking's trade direction (IMPORT→importer,
|
||||
* EXPORT→exporter) or the user's active profile for DOMESTIC/forwarder.
|
||||
* Customer portal lists and dashboard KPIs are scoped by this. Nullable for
|
||||
* legacy/government/staff-created bookings.
|
||||
*/
|
||||
@Column({ name: 'company_profile_id', type: 'uuid', nullable: true })
|
||||
companyProfileId?: string | null;
|
||||
|
||||
@ManyToOne(() => CompanyProfile, { nullable: true })
|
||||
@JoinColumn({ name: 'company_profile_id' })
|
||||
companyProfile?: CompanyProfile | null;
|
||||
|
||||
@Column({ name: 'is_government', type: 'boolean', default: false })
|
||||
isGovernment!: boolean;
|
||||
|
||||
@@ -110,8 +130,28 @@ export class Booking extends BaseEntity {
|
||||
@Column({ name: 'status', type: 'varchar', length: 40, default: 'DRAFT' })
|
||||
status!: string;
|
||||
|
||||
@Column({ name: 'scheduled_date', type: 'timestamptz' })
|
||||
scheduledDate!: Date;
|
||||
/**
|
||||
* ONE_TIME for a normal single-shipment booking; GENERAL_CONTRACT for an
|
||||
* umbrella contract that is signed/paid once and then drawn down by many
|
||||
* orders (each order spawns its own ONE_TIME child booking).
|
||||
*/
|
||||
@Column({ name: 'booking_type', type: 'varchar', length: 20, default: 'ONE_TIME' })
|
||||
bookingType!: string;
|
||||
|
||||
/**
|
||||
* Nullable: general contracts have no shipment date at creation — the date is
|
||||
* chosen per drawdown order. One-time bookings always set this (the pool day key).
|
||||
*/
|
||||
@Column({ name: 'scheduled_date', type: 'timestamptz', nullable: true })
|
||||
scheduledDate?: Date | null;
|
||||
|
||||
/**
|
||||
* General contracts only: when the ordering window closes, computed from the
|
||||
* global CONTRACT_PERIOD_MONTHS setting at activation. Null for one-time
|
||||
* bookings and for contracts that are not yet active.
|
||||
*/
|
||||
@Column({ name: 'expires_at', type: 'timestamptz', nullable: true })
|
||||
expiresAt?: Date | null;
|
||||
|
||||
@Column({ name: 'total_amount', type: 'numeric', precision: 14, scale: 2, default: 0 })
|
||||
totalAmount!: number;
|
||||
|
||||
@@ -24,15 +24,22 @@ 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 { CreateCompanyProfileDto } from "./dto/create-company-profile.dto";
|
||||
import { SetActiveModeDto } from "./dto/set-active-mode.dto";
|
||||
import { SetOnboardingStepDto } from "./dto/set-onboarding-step.dto";
|
||||
import { StartOnboardingDto } from "./dto/start-onboarding.dto";
|
||||
import {
|
||||
ResponseCompanyDto,
|
||||
ResponseCompanyProfileDto,
|
||||
} from "./dto/response-company.dto";
|
||||
import { BusinessLicenseFile } from "./entities/company-profile.entity";
|
||||
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";
|
||||
import { FetchETradeDto } from "./dto/fetch-etrade.dto";
|
||||
import { ETradeResponseDto } from "./dto/etrade-response.dto";
|
||||
|
||||
interface CurrentIamUser {
|
||||
id: string;
|
||||
@@ -80,6 +87,15 @@ export class CompaniesController {
|
||||
return this.companiesService.getDashboardSummary(user.id);
|
||||
}
|
||||
|
||||
@Post("fetch-etrade-info")
|
||||
@ApiOperation({ summary: "Fetch company info from eTrade by TIN" })
|
||||
async fetchETradeInfo(
|
||||
@Body() dto: FetchETradeDto,
|
||||
): Promise<ETradeResponseDto> {
|
||||
const data = await this.companiesService.fetchETradeData(dto.tin);
|
||||
return new ETradeResponseDto(data);
|
||||
}
|
||||
|
||||
@Patch("profile")
|
||||
@ApiOperation({ summary: "Update profile (flattened settings page)" })
|
||||
async updateProfile(
|
||||
@@ -105,6 +121,113 @@ export class CompaniesController {
|
||||
return profiles.map((p) => new ResponseCompanyProfileDto(p));
|
||||
}
|
||||
|
||||
@Post("onboarding/start")
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Begin onboarding: create a draft company + profile + role(s) so later steps can save incrementally",
|
||||
})
|
||||
async startOnboarding(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Body() dto: StartOnboardingDto,
|
||||
): Promise<CompanyInfoResponseDto> {
|
||||
const nameParts = (user.name?.en ?? "").split(" ");
|
||||
const { profile, company } = await this.companiesService.startOnboarding(
|
||||
{
|
||||
userId: user.id,
|
||||
firstName: nameParts[0] || "",
|
||||
lastName: nameParts.slice(-1)[0] || "",
|
||||
email: user.email ?? "",
|
||||
phone: user.phoneNumber ?? "",
|
||||
},
|
||||
dto.companyType,
|
||||
dto.roles,
|
||||
dto.nationality,
|
||||
);
|
||||
return new CompanyInfoResponseDto(profile, company);
|
||||
}
|
||||
|
||||
@Post("company-profile")
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Create a single operational profile for the current user's company and make it the active mode",
|
||||
})
|
||||
async createCompanyProfile(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Body() dto: CreateCompanyProfileDto,
|
||||
): Promise<ResponseCompanyProfileDto> {
|
||||
const profile = await this.companiesService.createCompanyProfileForUser(
|
||||
user.id,
|
||||
dto.type,
|
||||
dto.businessLicense,
|
||||
);
|
||||
return new ResponseCompanyProfileDto(profile);
|
||||
}
|
||||
|
||||
@Post("company-profiles/:profileId/license")
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Upload business-license document(s) for one of the current user's company profiles",
|
||||
})
|
||||
async uploadProfileLicense(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Param("profileId", ParseUUIDPipe) profileId: string,
|
||||
@UploadedFiles() files: Array<Express.Multer.File>,
|
||||
): Promise<BusinessLicenseFile[]> {
|
||||
return this.companiesService.uploadProfileLicenseFiles(
|
||||
user.id,
|
||||
profileId,
|
||||
files,
|
||||
);
|
||||
}
|
||||
|
||||
@Get("company-profiles/:profileId/license")
|
||||
@ApiOperation({
|
||||
summary: "List business-license documents for a company profile",
|
||||
})
|
||||
async listProfileLicense(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Param("profileId", ParseUUIDPipe) profileId: string,
|
||||
): Promise<BusinessLicenseFile[]> {
|
||||
return this.companiesService.listProfileLicenseFiles(user.id, profileId);
|
||||
}
|
||||
|
||||
@Patch("active-mode")
|
||||
@ApiOperation({
|
||||
summary: "Switch the current user's active operational mode (importer/exporter)",
|
||||
})
|
||||
async setActiveMode(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Body() dto: SetActiveModeDto,
|
||||
): Promise<CompanyInfoResponseDto> {
|
||||
const { profile, company } = await this.companiesService.setActiveMode(
|
||||
user.id,
|
||||
dto.type,
|
||||
);
|
||||
return new CompanyInfoResponseDto(profile, company);
|
||||
}
|
||||
|
||||
@Patch("onboarding-step")
|
||||
@ApiOperation({ summary: "Persist the user's current onboarding wizard step" })
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async setOnboardingStep(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Body() dto: SetOnboardingStepDto,
|
||||
): Promise<void> {
|
||||
await this.companiesService.setOnboardingStep(user.id, dto.step);
|
||||
}
|
||||
|
||||
@Post("onboarding/complete")
|
||||
@ApiOperation({ summary: "Mark the current user's onboarding as complete" })
|
||||
async completeOnboarding(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
): Promise<CompanyInfoResponseDto> {
|
||||
const { profile, company } =
|
||||
await this.companiesService.markOnboardingComplete(user.id);
|
||||
return new CompanyInfoResponseDto(profile, company);
|
||||
}
|
||||
|
||||
// Used by portal
|
||||
@Post("create")
|
||||
@ApiOperation({
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
import { HttpModule } from "@nestjs/axios";
|
||||
import { FilesModule } from "../files/files.module";
|
||||
import { MinioModule } from "../minio/minio.module";
|
||||
import { CompaniesController } from "./companies.controller";
|
||||
import { CompaniesService } from "./companies.service";
|
||||
import { CompaniesRepository } from "./companies.repository";
|
||||
@@ -11,11 +13,14 @@ import { ExternalProfile } from "./entities/external-profile.entity";
|
||||
import { CompanyProfile } from "./entities/company-profile.entity";
|
||||
import { Booking } from "../bookings/entities/booking.entity";
|
||||
import { CompanyProfileRepository } from "./company-profile.repository";
|
||||
import { ETradeService } from "./services/etrade.service";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Company, ExternalProfile, CompanyProfile, Booking]),
|
||||
HttpModule,
|
||||
FilesModule,
|
||||
MinioModule,
|
||||
],
|
||||
controllers: [CompaniesController],
|
||||
providers: [
|
||||
@@ -24,6 +29,7 @@ import { CompanyProfileRepository } from "./company-profile.repository";
|
||||
ExternalProfileRepository,
|
||||
CompanyProfileRepository,
|
||||
CompanyDashboardRepository,
|
||||
ETradeService,
|
||||
],
|
||||
exports: [CompaniesService],
|
||||
})
|
||||
|
||||
@@ -8,6 +8,9 @@ import { CompaniesRepository } from "./companies.repository";
|
||||
import { CompanyProfileRepository } from "./company-profile.repository";
|
||||
import { ExternalProfileRepository } from "./external-profile.repository";
|
||||
import { CompanyDashboardRepository } from "./company-dashboard.repository";
|
||||
import { MinioService } from "../minio/minio.service";
|
||||
import { ETradeService } from "./services/etrade.service";
|
||||
import { normalizeE164 } from "../../common/validators/is-phone-number.validator";
|
||||
import { CreateCompanyDto } from "./dto/create-company.dto";
|
||||
import { UpdateCompanyDto } from "./dto/update-company.dto";
|
||||
import { CreateExternalProfileDto } from "./dto/create-external-profile.dto";
|
||||
@@ -15,9 +18,15 @@ import { CreateCompanyWithProfileDto } from "./dto/create-company-with-profile.d
|
||||
import { UpdateProfileDto } from "./dto/update-profile.dto";
|
||||
import { ProfileResponseDto } from "./dto/profile-response.dto";
|
||||
import { DashboardSummaryResponseDto } from "./dto/dashboard-summary-response.dto";
|
||||
import { Company } from "./entities/company.entity";
|
||||
import {
|
||||
Company,
|
||||
CompanyNationality,
|
||||
CompanyStatus,
|
||||
CompanyType,
|
||||
} from "./entities/company.entity";
|
||||
import { ExternalProfile } from "./entities/external-profile.entity";
|
||||
import {
|
||||
BusinessLicenseFile,
|
||||
CompanyProfile,
|
||||
ProfileType,
|
||||
ProfileStatus,
|
||||
@@ -38,6 +47,8 @@ export class CompaniesService {
|
||||
private readonly companyProfilesRepo: CompanyProfileRepository,
|
||||
private readonly profilesRepo: ExternalProfileRepository,
|
||||
private readonly dashboardRepo: CompanyDashboardRepository,
|
||||
private readonly minioService: MinioService,
|
||||
private readonly etradeService: ETradeService,
|
||||
) { }
|
||||
|
||||
async createCompany(dto: CreateCompanyDto): Promise<Company> {
|
||||
@@ -76,20 +87,34 @@ export class CompaniesService {
|
||||
fanNumber: dto.fanNumber ?? null,
|
||||
country: dto.companyLocation ?? "Ethiopia",
|
||||
address: dto.companyAddress ?? null,
|
||||
phone: dto.companyPhone ?? null,
|
||||
phone: normalizeE164(dto.companyPhone) ?? null,
|
||||
email: dto.companyEmail ?? null,
|
||||
attributes: dto.attributes ?? null,
|
||||
});
|
||||
|
||||
// Default active mode from the chosen role(s): importer wins when both are
|
||||
// picked, otherwise the first allowed type chosen.
|
||||
const allowedTypes = this.getProfileTypeForCompanyType(company.type);
|
||||
const chosenTypes = (dto.companyProfiles ?? [])
|
||||
.map((p) => p.type)
|
||||
.filter((t) => allowedTypes.includes(t));
|
||||
const activeProfileType =
|
||||
chosenTypes.find((t) => t === ProfileType.importer) ??
|
||||
chosenTypes[0] ??
|
||||
allowedTypes[0] ??
|
||||
null;
|
||||
|
||||
const profile = await this.profilesRepo.create({
|
||||
userId: identity.userId,
|
||||
companyId: company.id,
|
||||
firstName: identity.firstName,
|
||||
lastName: identity.lastName,
|
||||
email: identity.email,
|
||||
phone: identity.phone,
|
||||
phone: normalizeE164(identity.phone) ?? identity.phone,
|
||||
jobTitle: dto.jobTitle ?? null,
|
||||
isPrimaryContact: dto.isPrimaryContact ?? true,
|
||||
activeProfileType,
|
||||
onboardingStep: 'company',
|
||||
});
|
||||
|
||||
// Persist the operational role(s) chosen during onboarding. Types are
|
||||
@@ -123,6 +148,118 @@ export class CompaniesService {
|
||||
return { company, profile };
|
||||
}
|
||||
|
||||
/**
|
||||
* Begin onboarding: create a DRAFT company + the user's external profile + the
|
||||
* chosen operational role(s) up front, so every subsequent wizard step can
|
||||
* save incrementally (PATCH /profile, /onboarding-step) against existing rows.
|
||||
*
|
||||
* Idempotent: if the user already has a profile, returns it unchanged (only
|
||||
* adding any newly-chosen roles). The draft company carries a placeholder TIN
|
||||
* (the real one is filled on the Company Information step) and stays
|
||||
* status=pending / onboardingCompleted=false until the wizard finishes.
|
||||
*/
|
||||
async startOnboarding(
|
||||
identity: UserIdentity,
|
||||
companyType: CompanyType,
|
||||
roles: ProfileType[],
|
||||
nationality?: CompanyNationality,
|
||||
): Promise<{ profile: ExternalProfile; company: Company }> {
|
||||
// Already started — reuse the existing draft, just ensure roles exist and
|
||||
// keep the nationality up to date if it was (re)selected.
|
||||
const existing = await this.profilesRepo.findByUserId(identity.userId);
|
||||
if (existing) {
|
||||
const companyId = existing.company?.id ?? existing.companyId;
|
||||
await this.ensureCompanyProfiles(companyId, companyType, roles);
|
||||
if (nationality) {
|
||||
await this.companiesRepo.update(companyId, { nationality });
|
||||
}
|
||||
return this.getCompanyInfoByUserId(identity.userId);
|
||||
}
|
||||
|
||||
// A profile may exist for the same email under a different IAM id — block
|
||||
// duplicates as the final create does.
|
||||
const byEmail = await this.profilesRepo.findByEmail(identity.email);
|
||||
if (byEmail) {
|
||||
throw new ConflictException(
|
||||
`Profile with email ${identity.email} already exists`,
|
||||
);
|
||||
}
|
||||
|
||||
const allowedTypes = this.getProfileTypeForCompanyType(companyType);
|
||||
const chosenTypes = roles.filter((t) => allowedTypes.includes(t));
|
||||
const activeProfileType =
|
||||
chosenTypes.find((t) => t === ProfileType.importer) ??
|
||||
chosenTypes[0] ??
|
||||
allowedTypes[0] ??
|
||||
null;
|
||||
|
||||
const company = await this.companiesRepo.create({
|
||||
name: identity.firstName
|
||||
? `${identity.firstName}'s company`
|
||||
: "New company",
|
||||
type: companyType,
|
||||
tin: await this.generateDraftTin(),
|
||||
country: "Ethiopia",
|
||||
nationality: nationality ?? CompanyNationality.Ethiopian,
|
||||
status: CompanyStatus.Pending,
|
||||
});
|
||||
|
||||
await this.profilesRepo.create({
|
||||
userId: identity.userId,
|
||||
companyId: company.id,
|
||||
firstName: identity.firstName,
|
||||
lastName: identity.lastName,
|
||||
email: identity.email,
|
||||
phone: normalizeE164(identity.phone) ?? identity.phone,
|
||||
isPrimaryContact: true,
|
||||
activeProfileType,
|
||||
onboardingStep: "company",
|
||||
onboardingCompleted: false,
|
||||
});
|
||||
|
||||
await this.ensureCompanyProfiles(company.id, companyType, chosenTypes);
|
||||
|
||||
return this.getCompanyInfoByUserId(identity.userId);
|
||||
}
|
||||
|
||||
/** Create any of the requested operational profiles that don't exist yet. */
|
||||
private async ensureCompanyProfiles(
|
||||
companyId: string,
|
||||
companyType: CompanyType,
|
||||
roles: ProfileType[],
|
||||
): Promise<void> {
|
||||
const allowedTypes = this.getProfileTypeForCompanyType(companyType);
|
||||
for (const type of roles) {
|
||||
if (!allowedTypes.includes(type)) continue;
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A unique 10-char placeholder TIN for a draft company (the column is
|
||||
* NOT NULL + unique). Overwritten with the real TIN on the company step.
|
||||
*/
|
||||
private async generateDraftTin(): Promise<string> {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const candidate =
|
||||
"D" + Math.floor(Math.random() * 1_000_000_000).toString().padStart(9, "0");
|
||||
if (!(await this.companiesRepo.existsByTin(candidate))) return candidate;
|
||||
}
|
||||
// Extremely unlikely; fall back to a timestamp-derived value.
|
||||
return ("D" + Date.now().toString()).slice(0, 10);
|
||||
}
|
||||
|
||||
async findAllCompanies(): Promise<Company[]> {
|
||||
return this.companiesRepo.findAll({ order: { name: "ASC" } });
|
||||
}
|
||||
@@ -174,6 +311,18 @@ export class CompaniesService {
|
||||
const companyId = profile?.company?.id ?? profile?.companyId ?? null;
|
||||
if (!companyId) return this.emptyDashboardSummary();
|
||||
|
||||
// Scope KPIs to the active operational profile (importer/exporter mode) when
|
||||
// one resolves; otherwise aggregate across the whole company.
|
||||
const companyProfileId = profile?.activeProfileType
|
||||
? ((await this.companyProfilesRepo.findByType(
|
||||
companyId,
|
||||
profile.activeProfileType,
|
||||
)) ?? null)
|
||||
: null;
|
||||
const scope = companyProfileId
|
||||
? { companyProfileId: companyProfileId.id }
|
||||
: { companyId };
|
||||
|
||||
const now = new Date();
|
||||
const yearStart = new Date(now.getFullYear(), 0, 1);
|
||||
const prevYearStart = new Date(now.getFullYear() - 1, 0, 1);
|
||||
@@ -191,22 +340,22 @@ export class CompaniesService {
|
||||
tonnagePrev,
|
||||
monthlyRows,
|
||||
] = await Promise.all([
|
||||
this.dashboardRepo.countDelivered(companyId, yearStart, now),
|
||||
this.dashboardRepo.countCommitted(companyId, yearStart, now),
|
||||
this.dashboardRepo.sumPaidSpendByCurrency(companyId, yearStart, now),
|
||||
this.dashboardRepo.countDelivered(scope, yearStart, now),
|
||||
this.dashboardRepo.countCommitted(scope, yearStart, now),
|
||||
this.dashboardRepo.sumPaidSpendByCurrency(scope, yearStart, now),
|
||||
this.dashboardRepo.sumPaidSpendByCurrency(
|
||||
companyId,
|
||||
scope,
|
||||
prevYearStart,
|
||||
prevYearToDate,
|
||||
),
|
||||
this.dashboardRepo.sumCommittedTonnage(companyId, yearStart, now),
|
||||
this.dashboardRepo.sumCommittedTonnage(scope, yearStart, now),
|
||||
this.dashboardRepo.sumCommittedTonnage(
|
||||
companyId,
|
||||
scope,
|
||||
prevYearStart,
|
||||
prevYearToDate,
|
||||
),
|
||||
this.dashboardRepo.monthlyCommittedTonnage(
|
||||
companyId,
|
||||
scope,
|
||||
this.monthsAgo(now, 5),
|
||||
now,
|
||||
),
|
||||
@@ -323,14 +472,27 @@ export class CompaniesService {
|
||||
const companyUpdates: Record<string, any> = {};
|
||||
const attrUpdates: Record<string, any> = { ...(company.attributes ?? {}) };
|
||||
|
||||
if (dto.nationality !== undefined)
|
||||
companyUpdates.nationality = dto.nationality;
|
||||
if (dto.companyName !== undefined) companyUpdates.name = dto.companyName;
|
||||
if (dto.companyEmail !== undefined) companyUpdates.email = dto.companyEmail;
|
||||
if (dto.companyPhone !== undefined) companyUpdates.phone = dto.companyPhone;
|
||||
if (dto.companyPhone !== undefined)
|
||||
companyUpdates.phone = normalizeE164(dto.companyPhone);
|
||||
if (dto.companyLocation !== undefined)
|
||||
companyUpdates.country = dto.companyLocation;
|
||||
if (dto.companyAddress !== undefined)
|
||||
companyUpdates.address = dto.companyAddress;
|
||||
if (dto.tin !== undefined) companyUpdates.tin = dto.tin;
|
||||
if (dto.tin !== undefined && dto.tin !== company.tin) {
|
||||
// Reject a TIN already taken by a different company (the user's own draft
|
||||
// placeholder is fine to overwrite).
|
||||
const owner = await this.companiesRepo.findByTin(dto.tin);
|
||||
if (owner && owner.id !== company.id) {
|
||||
throw new ConflictException(
|
||||
`This TIN (${dto.tin}) is already registered to another company. Please check the number and try again.`,
|
||||
);
|
||||
}
|
||||
companyUpdates.tin = dto.tin;
|
||||
}
|
||||
if (dto.vatNumber !== undefined) companyUpdates.vatNumber = dto.vatNumber;
|
||||
if (dto.fanNumber !== undefined) {
|
||||
companyUpdates.fanNumber = dto.fanNumber;
|
||||
@@ -338,21 +500,51 @@ export class CompaniesService {
|
||||
|
||||
if (dto.contactPersonName !== undefined)
|
||||
attrUpdates.contactPersonName = dto.contactPersonName;
|
||||
if (dto.contactPersonPosition !== undefined)
|
||||
attrUpdates.contactPersonPosition = dto.contactPersonPosition;
|
||||
if (dto.contactPersonEmail !== undefined)
|
||||
attrUpdates.contactPersonEmail = dto.contactPersonEmail;
|
||||
if (dto.contactPersonPhone !== undefined)
|
||||
attrUpdates.contactPersonPhone = dto.contactPersonPhone;
|
||||
attrUpdates.contactPersonPhone = normalizeE164(dto.contactPersonPhone);
|
||||
if (dto.generalManagerName !== undefined)
|
||||
attrUpdates.generalManagerName = dto.generalManagerName;
|
||||
if (dto.generalManagerEmail !== undefined)
|
||||
attrUpdates.generalManagerEmail = dto.generalManagerEmail;
|
||||
if (dto.generalManagerPhone !== undefined)
|
||||
attrUpdates.generalManagerPhone = dto.generalManagerPhone;
|
||||
attrUpdates.generalManagerPhone = normalizeE164(dto.generalManagerPhone);
|
||||
if (dto.poaName !== undefined) attrUpdates.poaName = dto.poaName;
|
||||
if (dto.poaPhone !== undefined) attrUpdates.poaPhone = dto.poaPhone;
|
||||
if (dto.poaPhone !== undefined)
|
||||
attrUpdates.poaPhone = normalizeE164(dto.poaPhone);
|
||||
if (dto.poaEmail !== undefined) attrUpdates.poaEmail = dto.poaEmail;
|
||||
if (dto.poaLocation !== undefined)
|
||||
attrUpdates.poaLocation = dto.poaLocation;
|
||||
if (dto.poaAddress !== undefined) attrUpdates.poaAddress = dto.poaAddress;
|
||||
|
||||
if (dto.licenceNumber !== undefined)
|
||||
companyUpdates.licenceNumber = dto.licenceNumber;
|
||||
if (dto.statusDescription !== undefined)
|
||||
companyUpdates.statusDescription = dto.statusDescription;
|
||||
if (dto.dateRegistered !== undefined)
|
||||
companyUpdates.dateRegistered = dto.dateRegistered;
|
||||
if (dto.renewedFrom !== undefined)
|
||||
companyUpdates.renewedFrom = dto.renewedFrom;
|
||||
if (dto.renewalDate !== undefined)
|
||||
companyUpdates.renewalDate = dto.renewalDate;
|
||||
if (dto.renewedTo !== undefined)
|
||||
companyUpdates.renewedTo = dto.renewedTo;
|
||||
if (dto.region !== undefined)
|
||||
companyUpdates.region = dto.region;
|
||||
if (dto.zone !== undefined)
|
||||
companyUpdates.zone = dto.zone;
|
||||
if (dto.woreda !== undefined)
|
||||
companyUpdates.woreda = dto.woreda;
|
||||
if (dto.kebele !== undefined)
|
||||
companyUpdates.kebele = dto.kebele;
|
||||
if (dto.houseNo !== undefined)
|
||||
companyUpdates.houseNo = dto.houseNo;
|
||||
if (dto.etradePhone !== undefined)
|
||||
companyUpdates.etradePhone = normalizeE164(dto.etradePhone);
|
||||
|
||||
companyUpdates.attributes = attrUpdates;
|
||||
|
||||
const updated = await this.companiesRepo.update(company.id, companyUpdates);
|
||||
@@ -393,7 +585,13 @@ export class CompaniesService {
|
||||
private getProfileTypeForCompanyType(companyType: string): ProfileType[] {
|
||||
switch (companyType) {
|
||||
case "customer":
|
||||
return [ProfileType.importer, ProfileType.exporter];
|
||||
// A customer can operate as an importer and/or exporter, and may also
|
||||
// add a freight-forwarder service profile under the same company.
|
||||
return [
|
||||
ProfileType.importer,
|
||||
ProfileType.exporter,
|
||||
ProfileType.freightForwarder,
|
||||
];
|
||||
case "freight_forwarder":
|
||||
return [ProfileType.freightForwarder];
|
||||
case "dj_freight_forwarder":
|
||||
@@ -505,4 +703,243 @@ export class CompaniesService {
|
||||
|
||||
return this.companyProfilesRepo.findByCompanyId(companyId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a single operational profile for the current user's company and
|
||||
* make it the active mode in the same call. Powers the header "Switch to
|
||||
* Exporter/Importer" flow when the target profile doesn't exist yet.
|
||||
*/
|
||||
async createCompanyProfileForUser(
|
||||
userId: string,
|
||||
type: ProfileType,
|
||||
businessLicense?: string,
|
||||
): 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);
|
||||
if (!allowedTypes.includes(type)) {
|
||||
throw new BadRequestException(
|
||||
`Profile type "${type}" is not allowed for company type "${company.type}"`,
|
||||
);
|
||||
}
|
||||
|
||||
let created = await this.companyProfilesRepo.findByType(companyId, type);
|
||||
if (!created) {
|
||||
const reference = await this.companyProfilesRepo.generateReference(type);
|
||||
created = await this.companyProfilesRepo.create({
|
||||
companyId,
|
||||
type,
|
||||
reference,
|
||||
businessLicense: businessLicense ?? null,
|
||||
status: ProfileStatus.Active,
|
||||
});
|
||||
}
|
||||
|
||||
await this.profilesRepo.update(profile.id, { activeProfileType: type });
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch the user's active operational mode. The target profile must already
|
||||
* exist — clients create it first via createCompanyProfileForUser.
|
||||
*/
|
||||
async setActiveMode(
|
||||
userId: string,
|
||||
type: ProfileType,
|
||||
): Promise<{ profile: ExternalProfile; company: Company }> {
|
||||
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);
|
||||
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) {
|
||||
throw new ConflictException(
|
||||
`No ${type} profile exists yet — create it before switching`,
|
||||
);
|
||||
}
|
||||
|
||||
await this.profilesRepo.update(profile.id, { activeProfileType: type });
|
||||
|
||||
return this.getCompanyInfoByUserId(userId);
|
||||
}
|
||||
|
||||
async setOnboardingStep(userId: string, step: string): Promise<void> {
|
||||
const profile = await this.profilesRepo.findByUserId(userId);
|
||||
if (!profile)
|
||||
throw new NotFoundException(`Profile for user ${userId} not found`);
|
||||
await this.profilesRepo.update(profile.id, { onboardingStep: step });
|
||||
}
|
||||
|
||||
async markOnboardingComplete(
|
||||
userId: string,
|
||||
): Promise<{ profile: ExternalProfile; company: Company }> {
|
||||
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);
|
||||
|
||||
// Guard against finishing on a still-draft company (TIN never filled in).
|
||||
if (!company.tin || company.tin.startsWith("D")) {
|
||||
throw new BadRequestException(
|
||||
"Company information is incomplete — please fill in your company details before finishing.",
|
||||
);
|
||||
}
|
||||
|
||||
// Every operational profile must have at least one business-license file
|
||||
// (stored directly on the profile).
|
||||
const profiles = await this.companyProfilesRepo.findByCompanyId(companyId);
|
||||
for (const cp of profiles) {
|
||||
if (!cp.businessLicenseFiles || cp.businessLicenseFiles.length === 0) {
|
||||
throw new BadRequestException(
|
||||
`Please upload a business license for your ${cp.type.replace(/_/g, " ")} profile before finishing.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await this.profilesRepo.update(profile.id, {
|
||||
onboardingCompleted: true,
|
||||
onboardingStep: "done",
|
||||
});
|
||||
await this.companiesRepo.update(companyId, {
|
||||
status: CompanyStatus.Active,
|
||||
});
|
||||
return this.getCompanyInfoByUserId(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorize and resolve a company_profile that must belong to the current
|
||||
* user's company — used before accepting/returning its license files.
|
||||
*/
|
||||
async resolveOwnedProfile(
|
||||
userId: string,
|
||||
profileId: string,
|
||||
): Promise<CompanyProfile> {
|
||||
const { company } = await this.getCompanyInfoByUserId(userId);
|
||||
const owned = (company.companyProfiles ?? []).find(
|
||||
(p) => p.id === profileId,
|
||||
);
|
||||
if (!owned) {
|
||||
throw new NotFoundException(`Profile ${profileId} not found`);
|
||||
}
|
||||
return owned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload business-license document(s) and store them directly on the company
|
||||
* profile (multi-file). Bytes go to object storage; only metadata/URLs are
|
||||
* persisted on the profile — intentionally not via the FileRecord file model.
|
||||
* New files are appended to any already present. Returns the full list.
|
||||
*/
|
||||
async uploadProfileLicenseFiles(
|
||||
userId: string,
|
||||
profileId: string,
|
||||
files: Express.Multer.File[],
|
||||
): Promise<BusinessLicenseFile[]> {
|
||||
const profile = await this.resolveOwnedProfile(userId, profileId);
|
||||
|
||||
const uploaded: BusinessLicenseFile[] = [];
|
||||
for (const file of files) {
|
||||
const objectName = `company_profiles/${profileId}/${Date.now()}_${file.originalname}`;
|
||||
const url = await this.minioService.uploadFile(
|
||||
objectName,
|
||||
file.buffer,
|
||||
file.mimetype,
|
||||
);
|
||||
uploaded.push({
|
||||
name: file.originalname,
|
||||
url,
|
||||
size: file.size,
|
||||
mimeType: file.mimetype,
|
||||
});
|
||||
}
|
||||
|
||||
const next = [...(profile.businessLicenseFiles ?? []), ...uploaded];
|
||||
await this.companyProfilesRepo.update(profileId, {
|
||||
businessLicenseFiles: next,
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
/** The business-license files stored on a single company profile. */
|
||||
async listProfileLicenseFiles(
|
||||
userId: string,
|
||||
profileId: string,
|
||||
): Promise<BusinessLicenseFile[]> {
|
||||
const profile = await this.resolveOwnedProfile(userId, profileId);
|
||||
return profile.businessLicenseFiles ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve which company_profile a new booking belongs to, from the company
|
||||
* and the booking's trade direction. IMPORT → importer profile, EXPORT →
|
||||
* exporter profile; for DOMESTIC or a forwarder/single-profile company (or
|
||||
* when the natural profile doesn't exist) it falls back to the user's active
|
||||
* profile, then the company's first profile. Returns null when the company
|
||||
* has no profiles at all.
|
||||
*/
|
||||
async resolveCompanyProfileIdForBooking(
|
||||
companyId: string,
|
||||
tradeDirection: string,
|
||||
fallbackType?: ProfileType | null,
|
||||
): Promise<string | null> {
|
||||
const profiles = await this.companyProfilesRepo.findByCompanyId(companyId);
|
||||
if (profiles.length === 0) return null;
|
||||
|
||||
const naturalType =
|
||||
tradeDirection === 'IMPORT'
|
||||
? ProfileType.importer
|
||||
: tradeDirection === 'EXPORT'
|
||||
? ProfileType.exporter
|
||||
: null;
|
||||
|
||||
const byType = (type?: ProfileType | null) =>
|
||||
type ? profiles.find((p) => p.type === type) : undefined;
|
||||
|
||||
const match = byType(naturalType) ?? byType(fallbackType) ?? profiles[0];
|
||||
return match?.id ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the company_profile a customer's data should be scoped to, from
|
||||
* their persisted active mode. Returns null when nothing can be resolved
|
||||
* (not onboarded yet) so callers can fall back to company-level scoping.
|
||||
*/
|
||||
async resolveActiveCompanyProfileId(userId: string): Promise<string | null> {
|
||||
try {
|
||||
const { profile, company } = await this.getCompanyInfoByUserId(userId);
|
||||
const type = profile.activeProfileType;
|
||||
if (!type) return null;
|
||||
const match = company.companyProfiles?.find((p) => p.type === type);
|
||||
return match?.id ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async fetchETradeData(tin: string) {
|
||||
const { businessInfo } = await this.etradeService.resolveCompanyData(tin);
|
||||
if (!businessInfo) {
|
||||
throw new BadRequestException(
|
||||
"No business license found for this TIN. Please check the number and try again.",
|
||||
);
|
||||
}
|
||||
return this.etradeService.extractRegistrationData(businessInfo);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Repository, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
|
||||
@@ -31,6 +31,27 @@ export interface CurrencyTotal {
|
||||
total: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the dashboard is scoped to: a single operational profile (the active
|
||||
* importer/exporter mode) when one resolves, otherwise the whole company
|
||||
* (legacy / not-yet-onboarded fallback).
|
||||
*/
|
||||
export type DashboardScope =
|
||||
| { companyProfileId: string }
|
||||
| { companyId: string };
|
||||
|
||||
/** Apply the scope as a WHERE clause on a bookings query builder. */
|
||||
function applyScope(
|
||||
qb: SelectQueryBuilder<Booking>,
|
||||
scope: DashboardScope,
|
||||
): SelectQueryBuilder<Booking> {
|
||||
return 'companyProfileId' in scope
|
||||
? qb.where('b.company_profile_id = :companyProfileId', {
|
||||
companyProfileId: scope.companyProfileId,
|
||||
})
|
||||
: qb.where('b.company_id = :companyId', { companyId: scope.companyId });
|
||||
}
|
||||
|
||||
export interface MonthlyTonnage {
|
||||
year: number;
|
||||
month: number; // 1-12
|
||||
@@ -50,35 +71,33 @@ export class CompanyDashboardRepository {
|
||||
private readonly bookings: Repository<Booking>,
|
||||
) {}
|
||||
|
||||
/** Count of delivered/completed bookings for a company within [from, to). */
|
||||
async countDelivered(companyId: string, from: Date, to: Date): Promise<number> {
|
||||
return this.bookings
|
||||
.createQueryBuilder('b')
|
||||
.where('b.company_id = :companyId', { companyId })
|
||||
/** Count of delivered/completed bookings within [from, to) for the scope. */
|
||||
async countDelivered(scope: DashboardScope, from: Date, to: Date): Promise<number> {
|
||||
return applyScope(this.bookings.createQueryBuilder('b'), scope)
|
||||
.andWhere('b.deleted_at IS NULL')
|
||||
.andWhere('b.status IN (:...statuses)', { statuses: [...DELIVERED_STATUSES] })
|
||||
.andWhere('b.created_at >= :from AND b.created_at < :to', { from, to })
|
||||
.getCount();
|
||||
}
|
||||
|
||||
/** Count of committed (non-draft, non-dead) bookings for a company within [from, to). */
|
||||
async countCommitted(companyId: string, from: Date, to: Date): Promise<number> {
|
||||
return this.bookings
|
||||
.createQueryBuilder('b')
|
||||
.where('b.company_id = :companyId', { companyId })
|
||||
/** Count of committed (non-draft, non-dead) bookings within [from, to) for the scope. */
|
||||
async countCommitted(scope: DashboardScope, from: Date, to: Date): Promise<number> {
|
||||
return applyScope(this.bookings.createQueryBuilder('b'), scope)
|
||||
.andWhere('b.deleted_at IS NULL')
|
||||
.andWhere('b.status IN (:...statuses)', { statuses: [...COMMITTED_STATUSES] })
|
||||
.andWhere('b.created_at >= :from AND b.created_at < :to', { from, to })
|
||||
.getCount();
|
||||
}
|
||||
|
||||
/** Sum of paid booking totals, grouped by currency, within [from, to). */
|
||||
async sumPaidSpendByCurrency(companyId: string, from: Date, to: Date): Promise<CurrencyTotal[]> {
|
||||
const rows = await this.bookings
|
||||
.createQueryBuilder('b')
|
||||
.select('b.payment_currency', 'currency')
|
||||
.addSelect('COALESCE(SUM(b.total_amount), 0)', 'total')
|
||||
.where('b.company_id = :companyId', { companyId })
|
||||
/** Sum of paid booking totals, grouped by currency, within [from, to) for the scope. */
|
||||
async sumPaidSpendByCurrency(scope: DashboardScope, from: Date, to: Date): Promise<CurrencyTotal[]> {
|
||||
const rows = await applyScope(
|
||||
this.bookings
|
||||
.createQueryBuilder('b')
|
||||
.select('b.payment_currency', 'currency')
|
||||
.addSelect('COALESCE(SUM(b.total_amount), 0)', 'total'),
|
||||
scope,
|
||||
)
|
||||
.andWhere('b.deleted_at IS NULL')
|
||||
.andWhere("b.payment_status = 'PAID'")
|
||||
.andWhere('b.created_at >= :from AND b.created_at < :to', { from, to })
|
||||
@@ -88,12 +107,14 @@ export class CompanyDashboardRepository {
|
||||
return rows.map((r) => ({ currency: r.currency ?? 'ETB', total: Number(r.total) }));
|
||||
}
|
||||
|
||||
/** Total committed tonnage (cargo VGM) for a company within [from, to). */
|
||||
async sumCommittedTonnage(companyId: string, from: Date, to: Date): Promise<number> {
|
||||
const row = await this.bookings
|
||||
.createQueryBuilder('b')
|
||||
.select('COALESCE(SUM(b.cargo_total_weight_vgm), 0)', 'total')
|
||||
.where('b.company_id = :companyId', { companyId })
|
||||
/** Total committed tonnage (cargo VGM) within [from, to) for the scope. */
|
||||
async sumCommittedTonnage(scope: DashboardScope, from: Date, to: Date): Promise<number> {
|
||||
const row = await applyScope(
|
||||
this.bookings
|
||||
.createQueryBuilder('b')
|
||||
.select('COALESCE(SUM(b.cargo_total_weight_vgm), 0)', 'total'),
|
||||
scope,
|
||||
)
|
||||
.andWhere('b.deleted_at IS NULL')
|
||||
.andWhere('b.status IN (:...statuses)', { statuses: [...COMMITTED_STATUSES] })
|
||||
.andWhere('b.created_at >= :from AND b.created_at < :to', { from, to })
|
||||
@@ -102,14 +123,16 @@ export class CompanyDashboardRepository {
|
||||
return Number(row?.total ?? 0);
|
||||
}
|
||||
|
||||
/** Committed tonnage grouped by calendar month within [from, to). */
|
||||
async monthlyCommittedTonnage(companyId: string, from: Date, to: Date): Promise<MonthlyTonnage[]> {
|
||||
const rows = await this.bookings
|
||||
.createQueryBuilder('b')
|
||||
.select('EXTRACT(YEAR FROM b.created_at)', 'year')
|
||||
.addSelect('EXTRACT(MONTH FROM b.created_at)', 'month')
|
||||
.addSelect('COALESCE(SUM(b.cargo_total_weight_vgm), 0)', 'total')
|
||||
.where('b.company_id = :companyId', { companyId })
|
||||
/** Committed tonnage grouped by calendar month within [from, to) for the scope. */
|
||||
async monthlyCommittedTonnage(scope: DashboardScope, from: Date, to: Date): Promise<MonthlyTonnage[]> {
|
||||
const rows = await applyScope(
|
||||
this.bookings
|
||||
.createQueryBuilder('b')
|
||||
.select('EXTRACT(YEAR FROM b.created_at)', 'year')
|
||||
.addSelect('EXTRACT(MONTH FROM b.created_at)', 'month')
|
||||
.addSelect('COALESCE(SUM(b.cargo_total_weight_vgm), 0)', 'total'),
|
||||
scope,
|
||||
)
|
||||
.andWhere('b.deleted_at IS NULL')
|
||||
.andWhere('b.status IN (:...statuses)', { statuses: [...COMMITTED_STATUSES] })
|
||||
.andWhere('b.created_at >= :from AND b.created_at < :to', { from, to })
|
||||
|
||||
@@ -15,7 +15,7 @@ const SEQUENCE_MAP: Record<ProfileType, string> = {
|
||||
const PREFIX_MAP: Record<ProfileType, string> = {
|
||||
[ProfileType.exporter]: "EX",
|
||||
[ProfileType.importer]: "IM",
|
||||
[ProfileType.freightForwarder]: "FFE",
|
||||
[ProfileType.freightForwarder]: "FF",
|
||||
[ProfileType.djFreightForwarder]: "FWJ",
|
||||
[ProfileType.transporter]: "TR",
|
||||
};
|
||||
|
||||
@@ -8,7 +8,7 @@ export class CompanyInfoResponseDto {
|
||||
company: ResponseCompanyDto;
|
||||
|
||||
constructor(profile: ExternalProfile, company: Company) {
|
||||
this.profile = new ResponseExternalProfileDto(profile);
|
||||
this.profile = new ResponseExternalProfileDto(profile, company);
|
||||
this.company = new ResponseCompanyDto(company);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { IsEnum, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
import { ProfileType } from '../entities/company-profile.entity';
|
||||
|
||||
export class CreateCompanyProfileDto {
|
||||
@IsEnum(ProfileType)
|
||||
type!: ProfileType;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
businessLicense?: string;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { IsString, IsNotEmpty, IsOptional, IsEmail, MaxLength, IsBoolean, IsEnum
|
||||
import { Type } from 'class-transformer';
|
||||
import { CompanyType } from '../entities/company.entity';
|
||||
import { ProfileType } from '../entities/company-profile.entity';
|
||||
import { IsValidPhone } from '../../../common/validators/is-phone-number.validator';
|
||||
|
||||
export class CompanyProfileInputDto {
|
||||
@IsEnum(ProfileType)
|
||||
@@ -30,6 +31,7 @@ export class CreateCompanyWithProfileDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
@IsValidPhone()
|
||||
companyPhone?: string;
|
||||
|
||||
@IsOptional()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { IsString, IsNotEmpty, IsOptional, IsEnum, MaxLength, Length, Matches, IsEmail } from 'class-validator';
|
||||
import { CompanyType, CompanyStatus } from '../entities/company.entity';
|
||||
import { IsValidPhone } from '../../../common/validators/is-phone-number.validator';
|
||||
|
||||
export class CreateCompanyDto {
|
||||
@IsString()
|
||||
@@ -37,6 +38,7 @@ export class CreateCompanyDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
@IsValidPhone()
|
||||
phone?: string;
|
||||
|
||||
@IsOptional()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { IsString, IsNotEmpty, IsOptional, IsEmail, MaxLength, IsBoolean, IsUUID } from 'class-validator';
|
||||
import { IsValidPhone } from '../../../common/validators/is-phone-number.validator';
|
||||
|
||||
export class CreateExternalProfileDto {
|
||||
@IsUUID()
|
||||
@@ -26,6 +27,7 @@ export class CreateExternalProfileDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
@IsValidPhone()
|
||||
phone?: string;
|
||||
|
||||
@IsOptional()
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { CompanyRegistrationData } from "@edr/types";
|
||||
|
||||
export class ETradeResponseDto implements CompanyRegistrationData {
|
||||
licenceNumber!: string;
|
||||
statusDescription!: string;
|
||||
dateRegistered!: string;
|
||||
renewedFrom!: string;
|
||||
renewalDate!: string;
|
||||
renewedTo!: string;
|
||||
region!: string;
|
||||
zone!: string;
|
||||
woreda!: string;
|
||||
kebele!: string;
|
||||
houseNo!: string;
|
||||
mobilePhone!: string;
|
||||
regularPhone!: string;
|
||||
managerName!: string;
|
||||
managerEmail?: string;
|
||||
managerPhone!: string;
|
||||
|
||||
constructor(data: CompanyRegistrationData) {
|
||||
this.licenceNumber = data.licenceNumber;
|
||||
this.statusDescription = data.statusDescription;
|
||||
this.dateRegistered = data.dateRegistered;
|
||||
this.renewedFrom = data.renewedFrom;
|
||||
this.renewalDate = data.renewalDate;
|
||||
this.renewedTo = data.renewedTo;
|
||||
this.region = data.region;
|
||||
this.zone = data.zone;
|
||||
this.woreda = data.woreda;
|
||||
this.kebele = data.kebele;
|
||||
this.houseNo = data.houseNo;
|
||||
this.mobilePhone = data.mobilePhone;
|
||||
this.regularPhone = data.regularPhone;
|
||||
this.managerName = data.managerName;
|
||||
this.managerEmail = data.managerEmail;
|
||||
this.managerPhone = data.managerPhone;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { IsString, IsNotEmpty, Length } from "class-validator";
|
||||
|
||||
export class FetchETradeDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Length(10, 10, { message: "TIN must be exactly 10 digits" })
|
||||
tin!: string;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ export class ProfileResponseDto {
|
||||
companyId: string;
|
||||
companyName: string;
|
||||
companyType: string;
|
||||
nationality: string | null;
|
||||
companyEmail: string | null;
|
||||
companyPhone: string | null;
|
||||
companyLocation: string;
|
||||
@@ -16,7 +17,22 @@ export class ProfileResponseDto {
|
||||
|
||||
companyProfiles: ResponseCompanyProfileDto[];
|
||||
|
||||
licenceNumber: string | null;
|
||||
statusDescription: string | null;
|
||||
dateRegistered: string | null;
|
||||
renewedFrom: string | null;
|
||||
renewalDate: string | null;
|
||||
renewedTo: string | null;
|
||||
region: string | null;
|
||||
zone: string | null;
|
||||
woreda: string | null;
|
||||
kebele: string | null;
|
||||
houseNo: string | null;
|
||||
etradePhone: string | null;
|
||||
|
||||
contactPersonName: string | null;
|
||||
contactPersonPosition: string | null;
|
||||
contactPersonEmail: string | null;
|
||||
contactPersonPhone: string | null;
|
||||
generalManagerName: string | null;
|
||||
generalManagerEmail: string | null;
|
||||
@@ -34,6 +50,7 @@ export class ProfileResponseDto {
|
||||
this.companyId = company.id;
|
||||
this.companyName = company.name;
|
||||
this.companyType = company.type;
|
||||
this.nationality = company.nationality ?? null;
|
||||
this.companyProfiles =
|
||||
company.companyProfiles?.map((p) => new ResponseCompanyProfileDto(p)) ??
|
||||
[];
|
||||
@@ -46,8 +63,23 @@ export class ProfileResponseDto {
|
||||
this.fanNumber = company.fanNumber ?? null;
|
||||
this.profileId = profile.id;
|
||||
|
||||
this.licenceNumber = company.licenceNumber ?? null;
|
||||
this.statusDescription = company.statusDescription ?? null;
|
||||
this.dateRegistered = company.dateRegistered ?? null;
|
||||
this.renewedFrom = company.renewedFrom ?? null;
|
||||
this.renewalDate = company.renewalDate ?? null;
|
||||
this.renewedTo = company.renewedTo ?? null;
|
||||
this.region = company.region ?? null;
|
||||
this.zone = company.zone ?? null;
|
||||
this.woreda = company.woreda ?? null;
|
||||
this.kebele = company.kebele ?? null;
|
||||
this.houseNo = company.houseNo ?? null;
|
||||
this.etradePhone = company.etradePhone ?? null;
|
||||
|
||||
const attrs = company.attributes ?? {};
|
||||
this.contactPersonName = attrs.contactPersonName ?? null;
|
||||
this.contactPersonPosition = attrs.contactPersonPosition ?? null;
|
||||
this.contactPersonEmail = attrs.contactPersonEmail ?? null;
|
||||
this.contactPersonPhone = attrs.contactPersonPhone ?? null;
|
||||
this.generalManagerName = attrs.generalManagerName ?? null;
|
||||
this.generalManagerEmail = attrs.generalManagerEmail ?? null;
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { Company, CompanyType, CompanyStatus } from '../entities/company.entity';
|
||||
import { CompanyProfile } from '../entities/company-profile.entity';
|
||||
import {
|
||||
Company,
|
||||
CompanyType,
|
||||
CompanyStatus,
|
||||
CompanyNationality,
|
||||
} from '../entities/company.entity';
|
||||
import {
|
||||
BusinessLicenseFile,
|
||||
CompanyProfile,
|
||||
} from '../entities/company-profile.entity';
|
||||
import { ResponseExternalProfileDto } from './response-external-profile.dto';
|
||||
|
||||
export class ResponseCompanyProfileDto {
|
||||
@@ -7,7 +15,10 @@ export class ResponseCompanyProfileDto {
|
||||
type: string;
|
||||
reference: string;
|
||||
status: string;
|
||||
/** @deprecated Superseded by licenseFiles. Kept for back-compat. */
|
||||
businessLicense?: string | null;
|
||||
/** Business-license documents stored on the profile (multi-file). */
|
||||
licenseFiles: BusinessLicenseFile[];
|
||||
attributes?: Record<string, any> | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
@@ -18,6 +29,7 @@ export class ResponseCompanyProfileDto {
|
||||
this.reference = profile.reference;
|
||||
this.status = profile.status;
|
||||
this.businessLicense = profile.businessLicense;
|
||||
this.licenseFiles = profile.businessLicenseFiles ?? [];
|
||||
this.attributes = profile.attributes;
|
||||
this.createdAt = profile.createdAt;
|
||||
this.updatedAt = profile.updatedAt;
|
||||
@@ -29,6 +41,7 @@ export class ResponseCompanyDto {
|
||||
name: string;
|
||||
type: CompanyType;
|
||||
status: CompanyStatus;
|
||||
nationality?: CompanyNationality | null;
|
||||
tin: string;
|
||||
vatNumber?: string | null;
|
||||
fanNumber?: string | null;
|
||||
@@ -48,6 +61,7 @@ export class ResponseCompanyDto {
|
||||
this.name = company.name;
|
||||
this.type = company.type;
|
||||
this.status = company.status;
|
||||
this.nationality = company.nationality ?? null;
|
||||
this.tin = company.tin;
|
||||
this.vatNumber = company.vatNumber;
|
||||
this.fanNumber = company.fanNumber;
|
||||
@@ -58,7 +72,9 @@ export class ResponseCompanyDto {
|
||||
this.website = company.website;
|
||||
this.attributes = company.attributes;
|
||||
this.profiles = company.profiles?.map((p) => new ResponseExternalProfileDto(p));
|
||||
this.companyProfiles = company.companyProfiles?.map((p) => new ResponseCompanyProfileDto(p));
|
||||
this.companyProfiles = company.companyProfiles?.map(
|
||||
(p) => new ResponseCompanyProfileDto(p),
|
||||
);
|
||||
this.createdAt = company.createdAt;
|
||||
this.updatedAt = company.updatedAt;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { ExternalProfile } from '../entities/external-profile.entity';
|
||||
import { Company } from '../entities/company.entity';
|
||||
import {
|
||||
ExternalProfile,
|
||||
} from '../entities/external-profile.entity';
|
||||
import { ProfileType } from '../entities/company-profile.entity';
|
||||
|
||||
export class ResponseExternalProfileDto {
|
||||
id: string;
|
||||
@@ -11,10 +15,20 @@ export class ResponseExternalProfileDto {
|
||||
nationalId?: string | null;
|
||||
jobTitle?: string | null;
|
||||
isPrimaryContact: boolean;
|
||||
/** The active operational mode (importer/exporter/forwarder). */
|
||||
activeProfileType?: ProfileType | null;
|
||||
/**
|
||||
* The id of the company_profile matching activeProfileType, resolved
|
||||
* server-side so the client never re-derives it. Null until a company
|
||||
* (with profiles) is loaded and a matching profile exists.
|
||||
*/
|
||||
activeCompanyProfileId?: string | null;
|
||||
onboardingStep?: string | null;
|
||||
onboardingCompleted: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
|
||||
constructor(profile: ExternalProfile) {
|
||||
constructor(profile: ExternalProfile, company?: Company) {
|
||||
this.id = profile.id;
|
||||
this.userId = profile.userId;
|
||||
this.companyId = profile.companyId;
|
||||
@@ -25,6 +39,13 @@ export class ResponseExternalProfileDto {
|
||||
this.nationalId = profile.nationalId;
|
||||
this.jobTitle = profile.jobTitle;
|
||||
this.isPrimaryContact = profile.isPrimaryContact;
|
||||
this.activeProfileType = profile.activeProfileType ?? null;
|
||||
this.onboardingStep = profile.onboardingStep ?? null;
|
||||
this.onboardingCompleted = profile.onboardingCompleted ?? false;
|
||||
this.activeCompanyProfileId =
|
||||
company?.companyProfiles?.find(
|
||||
(p) => p.type === profile.activeProfileType,
|
||||
)?.id ?? null;
|
||||
this.createdAt = profile.createdAt;
|
||||
this.updatedAt = profile.updatedAt;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { IsEnum } from 'class-validator';
|
||||
import { ProfileType } from '../entities/company-profile.entity';
|
||||
|
||||
export class SetActiveModeDto {
|
||||
@IsEnum(ProfileType)
|
||||
type!: ProfileType;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { IsString, MaxLength } from 'class-validator';
|
||||
|
||||
export class SetOnboardingStepDto {
|
||||
@IsString()
|
||||
@MaxLength(40)
|
||||
step!: string;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { ArrayMinSize, IsArray, IsEnum, IsOptional } from "class-validator";
|
||||
import { CompanyNationality, CompanyType } from "../entities/company.entity";
|
||||
import { ProfileType } from "../entities/company-profile.entity";
|
||||
|
||||
export class StartOnboardingDto {
|
||||
@IsEnum(CompanyType)
|
||||
companyType!: CompanyType;
|
||||
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@IsEnum(ProfileType, { each: true })
|
||||
roles!: ProfileType[];
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(CompanyNationality)
|
||||
nationality?: CompanyNationality;
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
import { IsString, IsOptional, IsEmail, MaxLength, Length, Matches } from 'class-validator';
|
||||
import { IsString, IsOptional, IsEmail, MaxLength, Length, Matches, IsEnum } from 'class-validator';
|
||||
import { CompanyNationality } from '../entities/company.entity';
|
||||
import { IsValidPhone } from '../../../common/validators/is-phone-number.validator';
|
||||
|
||||
export class UpdateProfileDto {
|
||||
@IsOptional()
|
||||
@IsEnum(CompanyNationality)
|
||||
nationality?: CompanyNationality;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
@@ -14,6 +20,7 @@ export class UpdateProfileDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
@IsValidPhone()
|
||||
companyPhone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@@ -47,6 +54,15 @@ export class UpdateProfileDto {
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
contactPersonPosition?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
contactPersonEmail?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsValidPhone()
|
||||
contactPersonPhone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@@ -59,6 +75,7 @@ export class UpdateProfileDto {
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsValidPhone()
|
||||
generalManagerPhone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@@ -67,6 +84,7 @@ export class UpdateProfileDto {
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsValidPhone()
|
||||
poaPhone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@@ -80,4 +98,64 @@ export class UpdateProfileDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
poaAddress?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
licenceNumber?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
statusDescription?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
dateRegistered?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
renewedFrom?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
renewalDate?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
renewedTo?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
region?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
zone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
woreda?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
kebele?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
houseNo?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
@IsValidPhone()
|
||||
etradePhone?: string;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,14 @@ export enum ProfileStatus {
|
||||
Blacklisted = "blacklisted",
|
||||
}
|
||||
|
||||
/** A business-license document stored directly on the company profile. */
|
||||
export interface BusinessLicenseFile {
|
||||
name: string;
|
||||
url: string;
|
||||
size: number;
|
||||
mimeType?: string;
|
||||
}
|
||||
|
||||
@Entity({ schema: "freight", name: "company_profiles" })
|
||||
@Index(["reference"], { unique: true })
|
||||
@Index(["type"])
|
||||
@@ -57,6 +65,14 @@ export class CompanyProfile extends BaseEntity {
|
||||
})
|
||||
businessLicense?: string | null;
|
||||
|
||||
/**
|
||||
* Business-license documents for this profile, stored directly on the profile
|
||||
* (multi-file). The bytes live in object storage; only the metadata/URLs are
|
||||
* persisted here — this is intentionally NOT modelled via the FileRecord table.
|
||||
*/
|
||||
@Column({ name: "business_license_files", type: "jsonb", nullable: true })
|
||||
businessLicenseFiles?: BusinessLicenseFile[] | null;
|
||||
|
||||
@Column({ name: "attributes", type: "jsonb", nullable: true })
|
||||
attributes?: Record<string, any> | null;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,11 @@ export enum CompanyStatus {
|
||||
Blacklisted = "blacklisted",
|
||||
}
|
||||
|
||||
export enum CompanyNationality {
|
||||
Ethiopian = "ethiopian",
|
||||
Foreign = "foreign",
|
||||
}
|
||||
|
||||
@Entity({ schema: "freight", name: "companies" })
|
||||
@Index(["tin"])
|
||||
@Index(["type"])
|
||||
@@ -47,6 +52,16 @@ export class Company extends BaseEntity {
|
||||
@Column({ name: "country", type: "varchar", length: 32, default: "Ethiopia" })
|
||||
country!: string;
|
||||
|
||||
/** Whether the company is Ethiopian or Foreign — drives the required onboarding documents. */
|
||||
@Column({
|
||||
name: "nationality",
|
||||
type: "varchar",
|
||||
length: 32,
|
||||
nullable: true,
|
||||
enum: CompanyNationality,
|
||||
})
|
||||
nationality?: CompanyNationality | null;
|
||||
|
||||
@Column({ name: "address", type: "text", nullable: true })
|
||||
address?: string | null;
|
||||
|
||||
@@ -102,6 +117,67 @@ export class Company extends BaseEntity {
|
||||
@Column({ name: "attributes", type: "jsonb", nullable: true })
|
||||
attributes?: Record<string, any> | null;
|
||||
|
||||
@Column({
|
||||
name: "licence_number",
|
||||
type: "varchar",
|
||||
length: 100,
|
||||
nullable: true,
|
||||
})
|
||||
licenceNumber?: string | null;
|
||||
|
||||
@Column({ name: "status_description", type: "text", nullable: true })
|
||||
statusDescription?: string | null;
|
||||
|
||||
@Column({
|
||||
name: "date_registered",
|
||||
type: "varchar",
|
||||
length: 50,
|
||||
nullable: true,
|
||||
})
|
||||
dateRegistered?: string | null;
|
||||
|
||||
@Column({
|
||||
name: "renewed_from",
|
||||
type: "varchar",
|
||||
length: 50,
|
||||
nullable: true,
|
||||
})
|
||||
renewedFrom?: string | null;
|
||||
|
||||
@Column({
|
||||
name: "renewal_date",
|
||||
type: "varchar",
|
||||
length: 50,
|
||||
nullable: true,
|
||||
})
|
||||
renewalDate?: string | null;
|
||||
|
||||
@Column({
|
||||
name: "renewed_to",
|
||||
type: "varchar",
|
||||
length: 50,
|
||||
nullable: true,
|
||||
})
|
||||
renewedTo?: string | null;
|
||||
|
||||
@Column({ name: "region", type: "varchar", length: 100, nullable: true })
|
||||
region?: string | null;
|
||||
|
||||
@Column({ name: "zone", type: "varchar", length: 100, nullable: true })
|
||||
zone?: string | null;
|
||||
|
||||
@Column({ name: "woreda", type: "varchar", length: 100, nullable: true })
|
||||
woreda?: string | null;
|
||||
|
||||
@Column({ name: "kebele", type: "varchar", length: 100, nullable: true })
|
||||
kebele?: string | null;
|
||||
|
||||
@Column({ name: "house_no", type: "varchar", length: 100, nullable: true })
|
||||
houseNo?: string | null;
|
||||
|
||||
@Column({ name: "etrade_phone", type: "varchar", length: 20, nullable: true })
|
||||
etradePhone?: string | null;
|
||||
|
||||
@OneToMany(() => ExternalProfile, (profile) => profile.company)
|
||||
profiles?: ExternalProfile[];
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, ManyToOne, JoinColumn } from 'typeorm';
|
||||
import { Company } from './company.entity';
|
||||
import { ProfileType } from './company-profile.entity';
|
||||
|
||||
@Entity({ schema: 'freight', name: 'external_profiles' })
|
||||
@Index(['userId'])
|
||||
@@ -36,4 +37,31 @@ export class ExternalProfile extends BaseEntity {
|
||||
|
||||
@Column({ name: 'is_primary_contact', type: 'boolean', default: false })
|
||||
isPrimaryContact!: boolean;
|
||||
|
||||
/**
|
||||
* The operational profile the user is currently "in" (importer vs exporter,
|
||||
* or the single forwarder profile). Drives header switching and scopes the
|
||||
* customer's bookings / dashboard to that company_profile. Nullable for
|
||||
* users who haven't picked a role yet.
|
||||
*/
|
||||
@Column({
|
||||
name: 'active_profile_type',
|
||||
type: 'varchar',
|
||||
length: 32,
|
||||
nullable: true,
|
||||
enum: ProfileType,
|
||||
})
|
||||
activeProfileType?: ProfileType | null;
|
||||
|
||||
/** Coarse resume point for the onboarding wizard (e.g. 'role', 'company', 'documents', 'done'). */
|
||||
@Column({
|
||||
name: 'onboarding_step',
|
||||
type: 'varchar',
|
||||
length: 40,
|
||||
nullable: true,
|
||||
})
|
||||
onboardingStep?: string | null;
|
||||
|
||||
@Column({ name: 'onboarding_completed', type: 'boolean', default: false })
|
||||
onboardingCompleted!: boolean;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { Injectable, BadRequestException } from "@nestjs/common";
|
||||
import { HttpService } from "@nestjs/axios";
|
||||
import { Agent } from "https";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
import {
|
||||
ETradeCompanyInfo,
|
||||
ETradeBusinessInfo,
|
||||
CompanyRegistrationData,
|
||||
} from "@edr/types";
|
||||
|
||||
@Injectable()
|
||||
export class ETradeService {
|
||||
private readonly baseUrl = "https://etrade.gov.et/api";
|
||||
private readonly referer = "https://etrade.gov.et/business-license-checker";
|
||||
|
||||
/**
|
||||
* The eTrade server serves an incomplete TLS chain (it omits the intermediate
|
||||
* CA cert), so Node rejects the handshake with UNABLE_TO_GET_ISSUER_CERT.
|
||||
* Scope a relaxed agent to these outbound calls only — the rest of the app
|
||||
* keeps full certificate verification.
|
||||
*/
|
||||
private readonly httpsAgent = new Agent({ rejectUnauthorized: false });
|
||||
|
||||
constructor(private readonly httpService: HttpService) {}
|
||||
|
||||
async getCompanyInfoByTin(tin: string): Promise<ETradeCompanyInfo> {
|
||||
const url = `${this.baseUrl}/Registration/GetRegistrationInfoByTin/${tin}/en`;
|
||||
try {
|
||||
const response = await firstValueFrom(
|
||||
this.httpService.get<ETradeCompanyInfo>(url, {
|
||||
headers: { Referer: this.referer },
|
||||
httpsAgent: this.httpsAgent,
|
||||
}),
|
||||
);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
throw new BadRequestException(
|
||||
`Failed to fetch company info from eTrade: ${error.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async getBusinessByLicenseNo(
|
||||
licenseNo: string,
|
||||
tin: string,
|
||||
): Promise<ETradeBusinessInfo> {
|
||||
const url = `${this.baseUrl}/BusinessMain/GetBusinessByLicenseNo`;
|
||||
try {
|
||||
const response = await firstValueFrom(
|
||||
this.httpService.get<ETradeBusinessInfo>(url, {
|
||||
params: {
|
||||
LicenseNo: licenseNo,
|
||||
Tin: tin,
|
||||
Lang: "en",
|
||||
},
|
||||
headers: { Referer: this.referer },
|
||||
httpsAgent: this.httpsAgent,
|
||||
}),
|
||||
);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
throw new BadRequestException(
|
||||
`Failed to fetch business info from eTrade: ${error.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async resolveCompanyData(tin: string): Promise<{
|
||||
companyInfo: ETradeCompanyInfo;
|
||||
businessInfo: ETradeBusinessInfo | null;
|
||||
}> {
|
||||
const companyInfo = await this.getCompanyInfoByTin(tin);
|
||||
|
||||
if (!companyInfo.Businesses || companyInfo.Businesses.length === 0) {
|
||||
return { companyInfo, businessInfo: null };
|
||||
}
|
||||
|
||||
const latestBusiness = companyInfo.Businesses[0];
|
||||
try {
|
||||
const businessInfo = await this.getBusinessByLicenseNo(
|
||||
latestBusiness.LicenceNumber,
|
||||
tin,
|
||||
);
|
||||
return { companyInfo, businessInfo };
|
||||
} catch {
|
||||
return { companyInfo, businessInfo: null };
|
||||
}
|
||||
}
|
||||
|
||||
extractRegistrationData(
|
||||
businessInfo: ETradeBusinessInfo,
|
||||
): CompanyRegistrationData {
|
||||
const primaryManager = businessInfo.AssociateShortInfos?.[0];
|
||||
|
||||
return {
|
||||
licenceNumber: businessInfo.LicenceNumber,
|
||||
statusDescription: businessInfo.StatusDescription,
|
||||
dateRegistered: businessInfo.DateRegistered,
|
||||
renewedFrom: businessInfo.RenewedFrom,
|
||||
renewalDate: businessInfo.RenewalDate,
|
||||
renewedTo: businessInfo.RenewedTo,
|
||||
region: businessInfo.AddressInfo?.Region || "",
|
||||
zone: businessInfo.AddressInfo?.Zone || "",
|
||||
woreda: businessInfo.AddressInfo?.Woreda || "",
|
||||
kebele: businessInfo.AddressInfo?.Kebele || "",
|
||||
houseNo: businessInfo.AddressInfo?.HouseNo || "",
|
||||
mobilePhone: businessInfo.AddressInfo?.MobilePhone || "",
|
||||
regularPhone: businessInfo.AddressInfo?.RegularPhone || "",
|
||||
managerName: primaryManager?.ManagerNameEng || "",
|
||||
managerPhone: primaryManager?.RegularPhone || "",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import { PaymentEventsConsumer } from "./payment-events.consumer";
|
||||
import { InternalPaymentController } from "./internal-payment.controller";
|
||||
import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
|
||||
import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.module";
|
||||
import { DropdownSettingsModule } from "../dropdown-settings/dropdown-settings.module";
|
||||
import { PaymentWebhookEventEntity } from "./entities/payment-webhook-event.entity";
|
||||
import { PaymentRefundEntity } from "./entities/payment-refund.entity";
|
||||
|
||||
@@ -27,6 +28,7 @@ const FREIGHT_QUEUE = PAYMENT_QUEUES[PaymentServiceEnum.FREIGHT];
|
||||
imports: [
|
||||
HttpModule.register({ timeout: 10_000 }),
|
||||
ConfigModule,
|
||||
DropdownSettingsModule,
|
||||
forwardRef(() => TrainSchedulingModule),
|
||||
TypeOrmModule.forFeature([PaymentWebhookEventEntity, PaymentRefundEntity]),
|
||||
RabbitMQModule.forRootAsync({
|
||||
|
||||
@@ -34,6 +34,11 @@ import {
|
||||
RefundDto,
|
||||
} from "./payments.dto";
|
||||
import { BookingBatchService } from "../train-scheduling/booking-batch.service";
|
||||
import { DropdownSettingsService } from "../dropdown-settings/dropdown-settings.service";
|
||||
|
||||
/** Setting code holding the global ordering window (months) for general contracts. */
|
||||
const CONTRACT_PERIOD_SETTING_CODE = "general_contract_period";
|
||||
const DEFAULT_CONTRACT_PERIOD_MONTHS = 3;
|
||||
|
||||
const STATUS_MAP: Record<string, ProviderPaymentStatus> = {
|
||||
"action-required": ProviderPaymentStatus.REQUIRES_ACTION,
|
||||
@@ -54,8 +59,23 @@ export class PaymentService {
|
||||
private readonly paymentClient: PaymentClientService,
|
||||
@Inject(forwardRef(() => BookingBatchService))
|
||||
private readonly bookingBatchService: BookingBatchService,
|
||||
private readonly dropdownSettings: DropdownSettingsService,
|
||||
) { }
|
||||
|
||||
/** Configured general-contract ordering window in months (defaults to 3). */
|
||||
private async contractPeriodMonths(): Promise<number> {
|
||||
try {
|
||||
const setting = await this.dropdownSettings.getByCode(
|
||||
CONTRACT_PERIOD_SETTING_CODE,
|
||||
);
|
||||
const months = Number(setting.children?.[0]?.value);
|
||||
if (Number.isFinite(months) && months > 0) return months;
|
||||
} catch {
|
||||
// Setting not seeded — fall back to the default.
|
||||
}
|
||||
return DEFAULT_CONTRACT_PERIOD_MONTHS;
|
||||
}
|
||||
|
||||
async getAll(filters: {
|
||||
search?: string;
|
||||
status?: string;
|
||||
@@ -293,15 +313,44 @@ export class PaymentService {
|
||||
|
||||
const paidAt = input.paidAt ?? new Date();
|
||||
|
||||
// A general contract is paid once, up front; it does NOT enter the train
|
||||
// queue (nothing has been ordered yet). Instead it becomes ACTIVE and
|
||||
// opens its ordering window. Orders placed later spawn their own paid
|
||||
// child bookings that go through the normal pipeline.
|
||||
const booking = await this.datasource
|
||||
.getRepository(Booking)
|
||||
.findOne({ where: { id: input.bookingId } });
|
||||
const isGeneralContract = booking?.bookingType === "GENERAL_CONTRACT";
|
||||
|
||||
let contractExpiresAt: Date | null = null;
|
||||
if (isGeneralContract) {
|
||||
const months = await this.contractPeriodMonths();
|
||||
contractExpiresAt = new Date(paidAt);
|
||||
contractExpiresAt.setMonth(contractExpiresAt.getMonth() + months);
|
||||
}
|
||||
|
||||
await this.datasource.transaction(async (mg) => {
|
||||
await mg.update(
|
||||
PaymentEntity,
|
||||
{ id: intent.id },
|
||||
{ status: "success", paidAt, transactionId: input.providerTxnId ?? intent.transactionId },
|
||||
);
|
||||
await mg.update(Booking, { id: input.bookingId }, { paymentStatus: "PAID" ,status:"PAID"});
|
||||
await mg.update(
|
||||
Booking,
|
||||
{ id: input.bookingId },
|
||||
isGeneralContract
|
||||
? { paymentStatus: "PAID", status: "CONTRACT_ACTIVE", expiresAt: contractExpiresAt }
|
||||
: { paymentStatus: "PAID", status: "PAID" },
|
||||
);
|
||||
});
|
||||
|
||||
if (isGeneralContract) {
|
||||
this.logger.log(
|
||||
`General contract ${booking?.reference ?? input.bookingId} ACTIVE — ordering open until ${contractExpiresAt?.toISOString()}`,
|
||||
);
|
||||
return { alreadyFinalized: false };
|
||||
}
|
||||
|
||||
try {
|
||||
await this.bookingBatchService.ensurePaidBookingAllocated(input.bookingId);
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsBoolean, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
|
||||
import { CargoUnitOfMeasure } from '@edr/types';
|
||||
import { IsBoolean, IsEnum, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
|
||||
|
||||
export class CreateCargoTypeDto {
|
||||
@ApiProperty({ description: 'Cargo type display name', maxLength: 255 })
|
||||
@@ -7,6 +8,14 @@ export class CreateCargoTypeDto {
|
||||
@MaxLength(255)
|
||||
cargoTypeName!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: CargoUnitOfMeasure,
|
||||
description: 'How this cargo is measured (PER_TON for bulk, PER_ITEM for break-bulk)',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsEnum(CargoUnitOfMeasure)
|
||||
unitOfMeasure?: CargoUnitOfMeasure;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Parent group ID for hierarchical cargo types' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { CargoUnitOfMeasure } from '@edr/types';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
|
||||
|
||||
@Entity({ schema: 'freight', name: 'cargo_types' })
|
||||
@@ -19,6 +20,14 @@ export class CargoType extends BaseEntity {
|
||||
@Column({ name: 'show_free_text_box', type: 'boolean', default: false })
|
||||
showFreeTextBox!: boolean;
|
||||
|
||||
/**
|
||||
* How this cargo's quantity is measured: PER_TON (bulk) or PER_ITEM
|
||||
* (break-bulk). Nullable for container/legacy cargo, which is counted by
|
||||
* container. Drives the unit shown when ordering against a general contract.
|
||||
*/
|
||||
@Column({ name: 'unit_of_measure', type: 'varchar', length: 16, nullable: true })
|
||||
unitOfMeasure?: CargoUnitOfMeasure | null;
|
||||
|
||||
@Column({ name: 'requires_director_approval', type: 'boolean', default: false })
|
||||
requiresDirectorApproval!: boolean;
|
||||
|
||||
|
||||
@@ -82,6 +82,7 @@ export class CargoTypesService {
|
||||
showFreeTextBox: dto.showFreeTextBox ?? false,
|
||||
requiresDirectorApproval: dto.requiresDirectorApproval ?? false,
|
||||
isActive: dto.isActive ?? true,
|
||||
unitOfMeasure: dto.unitOfMeasure ?? null,
|
||||
displayOrder,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
export interface SchedulingPriorityBooking {
|
||||
isGovernment?: boolean;
|
||||
priorityScore?: number | null;
|
||||
scheduledDate: Date | string;
|
||||
// One-time bookings always carry a date; general contracts (never scheduled)
|
||||
// may be null — treated as epoch 0 so they sort last.
|
||||
scheduledDate?: Date | string | null;
|
||||
}
|
||||
|
||||
/** Government first, then priority score, then earliest scheduled date. */
|
||||
@@ -15,5 +17,7 @@ export function compareSchedulingPriority(
|
||||
const priorityDiff = (b.priorityScore ?? 0) - (a.priorityScore ?? 0);
|
||||
if (priorityDiff !== 0) return priorityDiff;
|
||||
|
||||
return new Date(a.scheduledDate).getTime() - new Date(b.scheduledDate).getTime();
|
||||
const aTime = a.scheduledDate ? new Date(a.scheduledDate).getTime() : 0;
|
||||
const bTime = b.scheduledDate ? new Date(b.scheduledDate).getTime() : 0;
|
||||
return aTime - bTime;
|
||||
}
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
* Times run in EAT so the 07:00/10:00/… boundaries match the local operating clock.
|
||||
*/
|
||||
|
||||
/** Batch boundaries — every 3h from 07:00 (the 07:00–10:00 intake settles at 10:00, etc.). */
|
||||
/** Batch boundaries — every 3h from 00:00 (00–03, 03–06, … 21–24), matching the board windows. */
|
||||
// export const BATCH_CRON = '0 7,10,13,16,19,22 * * *';
|
||||
// export const BATCH_CRON = '*/3 * * * *';
|
||||
export const BATCH_CRON = '*/5 * * * *';
|
||||
// export const BATCH_CRON = '0 */3 * * *';//
|
||||
|
||||
export const BATCH_TIMEZONE = 'Africa/Addis_Ababa';
|
||||
|
||||
|
||||
@@ -30,7 +30,9 @@ export function sortBookingsForScheduling(bookings: Booking[]): Booking[] {
|
||||
const priorityDiff = (b.priorityScore ?? 0) - (a.priorityScore ?? 0);
|
||||
if (priorityDiff !== 0) return priorityDiff;
|
||||
|
||||
return new Date(a.scheduledDate).getTime() - new Date(b.scheduledDate).getTime();
|
||||
const aTime = a.scheduledDate ? new Date(a.scheduledDate).getTime() : 0;
|
||||
const bTime = b.scheduledDate ? new Date(b.scheduledDate).getTime() : 0;
|
||||
return aTime - bTime;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1880,7 +1880,7 @@ export class TrainSchedulingService {
|
||||
origin: booking.originYard?.label ?? booking.originYard?.code ?? 'Unknown origin',
|
||||
destination:
|
||||
booking.destinationYard?.label ?? booking.destinationYard?.code ?? 'Unknown destination',
|
||||
preferredDepartureDate: booking.scheduledDate.toISOString(),
|
||||
preferredDepartureDate: booking.scheduledDate?.toISOString() ?? null,
|
||||
status: booking.status,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { AppDataSource } from "../data-source";
|
||||
import { FileUploadSettingsSeeder } from "../seed/file-upload-settings.seeder";
|
||||
|
||||
/**
|
||||
* Idempotently (re)seed the company onboarding file-upload settings, including
|
||||
* the nationality-based document sets (ethiopian / foreign). Run on demand:
|
||||
* pnpm --filter @edr/freight-api seed:file-upload-settings
|
||||
*/
|
||||
async function run() {
|
||||
await AppDataSource.initialize();
|
||||
try {
|
||||
const seeder = new FileUploadSettingsSeeder(AppDataSource);
|
||||
await seeder.run();
|
||||
console.log("Seeded company onboarding file-upload settings.");
|
||||
} finally {
|
||||
await AppDataSource.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
run().catch((error) => {
|
||||
console.error("Failed to seed file-upload settings:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -4,33 +4,107 @@ import { DataSource } from "typeorm";
|
||||
import { FileUploadField } from "../modules/file-upload-settings/entities/file-upload-field.entity";
|
||||
import { FileUploadSetting } from "../modules/file-upload-settings/entities/file-upload-setting.entity";
|
||||
|
||||
const COMPANY_ONBOARDING_DOCUMENTS = [
|
||||
{
|
||||
code: "company_onboarding_documents_customer",
|
||||
label: "Customer onboarding documents",
|
||||
entity: "customer",
|
||||
},
|
||||
{
|
||||
code: "company_onboarding_documents_forwarder",
|
||||
label: "Forwarder onboarding documents",
|
||||
entity: "other",
|
||||
},
|
||||
{
|
||||
code: "company_onboarding_documents_transporter",
|
||||
label: "Transporter onboarding documents",
|
||||
entity: "other",
|
||||
},
|
||||
{
|
||||
code: "company_onboarding_documents_forwarder_dj",
|
||||
label: "Djibouti forwarder onboarding documents",
|
||||
entity: "other",
|
||||
},
|
||||
] as const;
|
||||
interface OnboardingField {
|
||||
fileKey: string;
|
||||
fileLabel: string;
|
||||
helpText: string;
|
||||
isRequired: boolean;
|
||||
isMultiple: boolean;
|
||||
maxFiles: number;
|
||||
allowedExtensions: string[];
|
||||
maxSizeMb: number;
|
||||
displayOrder: number;
|
||||
}
|
||||
|
||||
const COMPANY_ONBOARDING_DESCRIPTION =
|
||||
"Required documents for external company onboarding. The same set applies to customers, forwarders, transporters, and brokers.";
|
||||
const DOC_EXTENSIONS = ["pdf", "jpg", "jpeg", "png"];
|
||||
|
||||
const COMPANY_ONBOARDING_FIELDS = [
|
||||
/** Documents required from an Ethiopian company at onboarding. */
|
||||
const ETHIOPIAN_ONBOARDING_FIELDS: OnboardingField[] = [
|
||||
{
|
||||
fileKey: "tin_certificate",
|
||||
fileLabel: "TIN Certificate",
|
||||
helpText: "Verified against the TIN registry during registration.",
|
||||
isRequired: true,
|
||||
isMultiple: false,
|
||||
maxFiles: 1,
|
||||
allowedExtensions: DOC_EXTENSIONS,
|
||||
maxSizeMb: 10,
|
||||
displayOrder: 1,
|
||||
},
|
||||
{
|
||||
fileKey: "commercial_license",
|
||||
fileLabel: "Commercial License",
|
||||
helpText: "Verified against the government trade system during registration.",
|
||||
isRequired: true,
|
||||
isMultiple: false,
|
||||
maxFiles: 1,
|
||||
allowedExtensions: DOC_EXTENSIONS,
|
||||
maxSizeMb: 10,
|
||||
displayOrder: 2,
|
||||
},
|
||||
{
|
||||
fileKey: "national_id",
|
||||
fileLabel: "National ID",
|
||||
helpText: "Verified against the National ID API during registration.",
|
||||
isRequired: true,
|
||||
isMultiple: false,
|
||||
maxFiles: 1,
|
||||
allowedExtensions: DOC_EXTENSIONS,
|
||||
maxSizeMb: 10,
|
||||
displayOrder: 3,
|
||||
},
|
||||
];
|
||||
|
||||
/** Documents required from a Foreign company at onboarding. */
|
||||
const FOREIGN_ONBOARDING_FIELDS: OnboardingField[] = [
|
||||
{
|
||||
fileKey: "tin_certificate",
|
||||
fileLabel: "TIN Certificate",
|
||||
helpText: "Verified against the TIN registry during registration.",
|
||||
isRequired: true,
|
||||
isMultiple: false,
|
||||
maxFiles: 1,
|
||||
allowedExtensions: DOC_EXTENSIONS,
|
||||
maxSizeMb: 10,
|
||||
displayOrder: 1,
|
||||
},
|
||||
{
|
||||
fileKey: "investment_license",
|
||||
fileLabel: "Investment License",
|
||||
helpText: "Investment license issued for operating in Ethiopia.",
|
||||
isRequired: true,
|
||||
isMultiple: false,
|
||||
maxFiles: 1,
|
||||
allowedExtensions: DOC_EXTENSIONS,
|
||||
maxSizeMb: 10,
|
||||
displayOrder: 2,
|
||||
},
|
||||
{
|
||||
fileKey: "national_id",
|
||||
fileLabel: "National ID",
|
||||
helpText: "National ID of the company's authorized representative.",
|
||||
isRequired: true,
|
||||
isMultiple: false,
|
||||
maxFiles: 1,
|
||||
allowedExtensions: DOC_EXTENSIONS,
|
||||
maxSizeMb: 10,
|
||||
displayOrder: 3,
|
||||
},
|
||||
{
|
||||
fileKey: "passport",
|
||||
fileLabel: "Passport",
|
||||
helpText: "Passport of the company's authorized representative.",
|
||||
isRequired: true,
|
||||
isMultiple: false,
|
||||
maxFiles: 1,
|
||||
allowedExtensions: DOC_EXTENSIONS,
|
||||
maxSizeMb: 10,
|
||||
displayOrder: 4,
|
||||
},
|
||||
];
|
||||
|
||||
/** Legacy combined set, kept for the older per-company-type codes. */
|
||||
const LEGACY_ONBOARDING_FIELDS: OnboardingField[] = [
|
||||
{
|
||||
fileKey: "business_license",
|
||||
fileLabel: "Business License / Trade License",
|
||||
@@ -38,7 +112,7 @@ const COMPANY_ONBOARDING_FIELDS = [
|
||||
isRequired: true,
|
||||
isMultiple: false,
|
||||
maxFiles: 1,
|
||||
allowedExtensions: ["pdf", "jpg", "jpeg", "png"],
|
||||
allowedExtensions: DOC_EXTENSIONS,
|
||||
maxSizeMb: 10,
|
||||
displayOrder: 1,
|
||||
},
|
||||
@@ -49,7 +123,7 @@ const COMPANY_ONBOARDING_FIELDS = [
|
||||
isRequired: true,
|
||||
isMultiple: false,
|
||||
maxFiles: 1,
|
||||
allowedExtensions: ["pdf", "jpg", "jpeg", "png"],
|
||||
allowedExtensions: DOC_EXTENSIONS,
|
||||
maxSizeMb: 10,
|
||||
displayOrder: 2,
|
||||
},
|
||||
@@ -60,11 +134,63 @@ const COMPANY_ONBOARDING_FIELDS = [
|
||||
isRequired: true,
|
||||
isMultiple: false,
|
||||
maxFiles: 1,
|
||||
allowedExtensions: ["pdf", "jpg", "jpeg", "png"],
|
||||
allowedExtensions: DOC_EXTENSIONS,
|
||||
maxSizeMb: 10,
|
||||
displayOrder: 3,
|
||||
},
|
||||
] as const;
|
||||
];
|
||||
|
||||
interface OnboardingDocumentSetting {
|
||||
code: string;
|
||||
label: string;
|
||||
entity: string;
|
||||
fields: OnboardingField[];
|
||||
}
|
||||
|
||||
const COMPANY_ONBOARDING_DOCUMENTS: OnboardingDocumentSetting[] = [
|
||||
// Nationality-based sets — the document requirements depend only on whether
|
||||
// the company is Ethiopian or Foreign (same for importer/exporter/forwarder).
|
||||
{
|
||||
code: "company_onboarding_documents_ethiopian",
|
||||
label: "Ethiopian company onboarding documents",
|
||||
entity: "customer",
|
||||
fields: ETHIOPIAN_ONBOARDING_FIELDS,
|
||||
},
|
||||
{
|
||||
code: "company_onboarding_documents_foreign",
|
||||
label: "Foreign company onboarding documents",
|
||||
entity: "customer",
|
||||
fields: FOREIGN_ONBOARDING_FIELDS,
|
||||
},
|
||||
// Legacy per-company-type codes (kept for back-compat; no longer used by the portal).
|
||||
{
|
||||
code: "company_onboarding_documents_customer",
|
||||
label: "Customer onboarding documents",
|
||||
entity: "customer",
|
||||
fields: LEGACY_ONBOARDING_FIELDS,
|
||||
},
|
||||
{
|
||||
code: "company_onboarding_documents_forwarder",
|
||||
label: "Forwarder onboarding documents",
|
||||
entity: "other",
|
||||
fields: LEGACY_ONBOARDING_FIELDS,
|
||||
},
|
||||
{
|
||||
code: "company_onboarding_documents_transporter",
|
||||
label: "Transporter onboarding documents",
|
||||
entity: "other",
|
||||
fields: LEGACY_ONBOARDING_FIELDS,
|
||||
},
|
||||
{
|
||||
code: "company_onboarding_documents_forwarder_dj",
|
||||
label: "Djibouti forwarder onboarding documents",
|
||||
entity: "other",
|
||||
fields: LEGACY_ONBOARDING_FIELDS,
|
||||
},
|
||||
];
|
||||
|
||||
const COMPANY_ONBOARDING_DESCRIPTION =
|
||||
"Required documents for external company onboarding, by company nationality.";
|
||||
|
||||
@Injectable()
|
||||
export class FileUploadSettingsSeeder {
|
||||
@@ -102,7 +228,7 @@ export class FileUploadSettingsSeeder {
|
||||
await fieldRepository.delete({ settingId: setting.id });
|
||||
|
||||
await fieldRepository.insert(
|
||||
COMPANY_ONBOARDING_FIELDS.map((field, index) => ({
|
||||
documentSetting.fields.map((field, index) => ({
|
||||
settingId: setting.id,
|
||||
fileKey: field.fileKey,
|
||||
fileLabel: field.fileLabel,
|
||||
|
||||
@@ -1,10 +1,3 @@
|
||||
// Accept either a host-only URL or one that already ends with `/api`.
|
||||
// The HTTP client appends `/api` itself, so we normalize here to avoid
|
||||
// accidental `/api/api/...` requests from env values.
|
||||
const rawApiBaseUrl =
|
||||
(import.meta.env.VITE_API_URL as string | undefined) ?? "http://localhost:3001";
|
||||
// export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
|
||||
|
||||
export const API_BASE_URL = rawApiBaseUrl
|
||||
.trim()
|
||||
.replace(/\/+$/, "")
|
||||
.replace(/\/api$/, "");
|
||||
export const API_BASE_URL = 'http://localhost:3001';
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"react-dom": "19.2.6",
|
||||
"react-hook-form": "^7.76.0",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-phone-number-input": "^3.4.17",
|
||||
"react-router-dom": "^6.27.0",
|
||||
"recharts": "^3.8.1",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
|
||||
@@ -2,12 +2,14 @@ import { AppLayout, type SidebarItem } from "@/components/AppLayout";
|
||||
import {
|
||||
CalendarCheck,
|
||||
Home,
|
||||
Layers,
|
||||
Loader2,
|
||||
MapPin,
|
||||
Receipt,
|
||||
Settings,
|
||||
User,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { useEffect, useRef } from "react";
|
||||
import {
|
||||
Navigate,
|
||||
@@ -19,13 +21,12 @@ import {
|
||||
} from "react-router-dom";
|
||||
|
||||
import useAuth from "./hooks/useAuth";
|
||||
import OnboardingWizardDialog from "./components/onboarding/OnboardingWizardDialog";
|
||||
import EDRFreightLandingPage from "./pages/EDRFreightLandingPage";
|
||||
import MyPortalPage from "./pages/MyPortalPage";
|
||||
import ProfilePage from "./pages/ProfilePage";
|
||||
import MySignaturePage from "./pages/MySignaturePage";
|
||||
import SettingsPage from "./pages/SettingsPage";
|
||||
import LoginPage from "./pages/accounts/LoginPage";
|
||||
import OnboardingPage from "./pages/accounts/OnboardingPage";
|
||||
import SetPasswordPage from "./pages/accounts/SetPasswordPage";
|
||||
import SignupPage from "./pages/accounts/SignupPage";
|
||||
import VerificationOtpPage from "./pages/accounts/VerificationOtpPage";
|
||||
@@ -35,6 +36,8 @@ import BookingDetailPage from "./pages/bookings/BookingDetailPage";
|
||||
import EditBookingPage from "./pages/bookings/EditBookingPage";
|
||||
import MyBookings from "./pages/bookings/MyBookings";
|
||||
import NewBookingPage from "./pages/bookings/NewBookingPage";
|
||||
import ContractsList from "./pages/contracts/ContractsList";
|
||||
import ContractDetailPage from "./pages/contracts/ContractDetailPage";
|
||||
import CheckPaymentPage from "./pages/payments/CheckPaymentPage";
|
||||
import PaymentSuccessPage from "./pages/payments/PaymentSuccessPage";
|
||||
import PaymentFailurePage from "./pages/payments/PaymentFailurePage";
|
||||
@@ -74,9 +77,8 @@ function RequireAuth() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends authenticated users without a company to onboarding.
|
||||
* Only redirects on a confirmed "no company" response — never on a
|
||||
* transient query error.
|
||||
* Waits for the company query so downstream routes can rely on it being
|
||||
* resolved. Onboarding is enforced by OnboardingGate, not here.
|
||||
*/
|
||||
function RequireCompany() {
|
||||
const { customerQuery } = useAuth();
|
||||
@@ -85,13 +87,94 @@ function RequireCompany() {
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
/** Keeps already-onboarded users out of the onboarding flow. */
|
||||
function RequireNoCompany() {
|
||||
const { customerQuery } = useAuth();
|
||||
/**
|
||||
* Routes an un-onboarded user may still visit. The wizard auto-opens but is
|
||||
* dismissable, so they can browse these freely; any other route forces the
|
||||
* wizard back open and bounces them home.
|
||||
*/
|
||||
const ONBOARDING_ALLOWED_PATHS = ["/portal", "/signature"];
|
||||
|
||||
if (customerQuery.isPending) return <FullScreenSpinner />;
|
||||
if (customerQuery.data) return <Navigate to="/portal" replace />;
|
||||
return <Outlet />;
|
||||
function isOnboardingAllowedPath(pathname: string): boolean {
|
||||
const path = pathname.toLowerCase();
|
||||
return ONBOARDING_ALLOWED_PATHS.some(
|
||||
(p) => path === p || path.startsWith(p + "/"),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforces first-run onboarding. The home (dashboard) and signature pages stay
|
||||
* reachable while onboarding is incomplete; the wizard auto-opens on login but
|
||||
* can be dismissed to use those pages. Visiting any other page bounces back to
|
||||
* home and re-opens the wizard. New users (no company yet) are treated the same
|
||||
* as users who haven't completed onboarding.
|
||||
*/
|
||||
function OnboardingGate() {
|
||||
const { company, onboardingCompleted } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
const needsOnboarding = !company || !onboardingCompleted;
|
||||
const allowedHere = isOnboardingAllowedPath(location.pathname);
|
||||
|
||||
// Open by default while onboarding is pending (covers the login case).
|
||||
const [wizardOpen, { open: openWizard, close: closeWizard }] =
|
||||
useDisclosure(false);
|
||||
|
||||
// Re-evaluate on every navigation: force the wizard open on blocked routes,
|
||||
// and auto-open on first arrival while onboarding is pending.
|
||||
useEffect(() => {
|
||||
if (needsOnboarding && !allowedHere) {
|
||||
openWizard();
|
||||
}
|
||||
}, [needsOnboarding, allowedHere, location.pathname, openWizard]);
|
||||
|
||||
// Auto-open once when onboarding becomes/loads as pending (login).
|
||||
const autoOpenedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (needsOnboarding && !autoOpenedRef.current) {
|
||||
autoOpenedRef.current = true;
|
||||
openWizard();
|
||||
}
|
||||
if (!needsOnboarding) autoOpenedRef.current = false;
|
||||
}, [needsOnboarding, openWizard]);
|
||||
|
||||
if (needsOnboarding && !allowedHere) {
|
||||
return <Navigate to="/portal" replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{needsOnboarding && !wizardOpen && (
|
||||
<OnboardingResumeBanner onResume={openWizard} />
|
||||
)}
|
||||
<Outlet />
|
||||
<OnboardingWizardDialog
|
||||
opened={needsOnboarding && wizardOpen}
|
||||
onClose={closeWizard}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Slim sticky prompt shown on allowed pages after the wizard is dismissed. */
|
||||
function OnboardingResumeBanner({ onResume }: { onResume: () => void }) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-[#0EA371]/20 bg-[#ECF6F1] px-6 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles size={16} className="text-[#0A6F4D]" />
|
||||
<span className="text-sm font-medium text-[#0A6F4D]">
|
||||
Finish setting up your company to unlock bookings, tracking and
|
||||
billing.
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onResume}
|
||||
className="rounded-lg bg-[#0EA371] px-4 py-2 text-sm font-semibold text-white transition-opacity hover:opacity-90"
|
||||
>
|
||||
Continue onboarding
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Keeps authenticated users off the login/signup pages. */
|
||||
@@ -119,6 +202,11 @@ const sidebarItems: SidebarItem[] = [
|
||||
href: "/bookings",
|
||||
icon: <CalendarCheck size={18} />,
|
||||
},
|
||||
{
|
||||
label: "General Contracts",
|
||||
href: "/contracts",
|
||||
icon: <Layers size={18} />,
|
||||
},
|
||||
{
|
||||
label: "Tracking",
|
||||
href: "/tracking",
|
||||
@@ -129,12 +217,6 @@ const sidebarItems: SidebarItem[] = [
|
||||
href: "/billing",
|
||||
icon: <Receipt size={18} />,
|
||||
},
|
||||
{
|
||||
section: "Account",
|
||||
label: "Profile",
|
||||
href: "/profile",
|
||||
icon: <User size={18} />,
|
||||
},
|
||||
{
|
||||
section: "Account",
|
||||
label: "Settings",
|
||||
@@ -146,7 +228,14 @@ const sidebarItems: SidebarItem[] = [
|
||||
const App = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { user, company } = useAuth();
|
||||
const {
|
||||
user,
|
||||
company,
|
||||
activeProfileType,
|
||||
companyType,
|
||||
switchMode,
|
||||
createProfileAndSwitch,
|
||||
} = useAuth();
|
||||
|
||||
const displayName = user?.name?.en || user?.username || user?.email || "User";
|
||||
const userEmail = user?.email;
|
||||
@@ -176,10 +265,6 @@ const App = () => {
|
||||
<Route path="/set-password" element={<SetPasswordPage />} />
|
||||
|
||||
<Route element={<RequireAuth />}>
|
||||
<Route element={<RequireNoCompany />}>
|
||||
<Route path="/onboarding" element={<OnboardingPage />} />
|
||||
</Route>
|
||||
|
||||
<Route element={<RequireCompany />}>
|
||||
<Route
|
||||
element={
|
||||
@@ -192,8 +277,12 @@ const App = () => {
|
||||
userName={displayName}
|
||||
userEmail={userEmail}
|
||||
companyProfiles={companyProfiles}
|
||||
companyType={companyType}
|
||||
activeProfileType={activeProfileType}
|
||||
onSwitchMode={switchMode}
|
||||
onCreateProfile={createProfileAndSwitch}
|
||||
>
|
||||
<Outlet />
|
||||
<OnboardingGate />
|
||||
</AppLayout>
|
||||
}
|
||||
>
|
||||
@@ -206,9 +295,12 @@ const App = () => {
|
||||
path="/bookings/:id/contract"
|
||||
element={<BookingContractPage />}
|
||||
/>
|
||||
<Route path="/contracts" element={<ContractsList />} />
|
||||
<Route path="/contracts/:id" element={<ContractDetailPage />} />
|
||||
<Route path="/tracking" element={<TrackingPage />} />
|
||||
<Route path="/billing" element={<BillingPage />} />
|
||||
<Route path="/profile" element={<ProfilePage />} />
|
||||
{/* Profile was merged into Settings — keep old links working. */}
|
||||
<Route path="/profile" element={<Navigate to="/settings" replace />} />
|
||||
<Route path="/signature" element={<MySignaturePage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Route>
|
||||
|
||||
@@ -2,9 +2,12 @@ import {
|
||||
AppShell,
|
||||
Avatar,
|
||||
Box,
|
||||
Button,
|
||||
Divider,
|
||||
FileInput,
|
||||
Group,
|
||||
Menu,
|
||||
Modal,
|
||||
NavLink,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
@@ -16,7 +19,9 @@ import {
|
||||
} from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import {
|
||||
ArrowLeftRight,
|
||||
Bell,
|
||||
Check,
|
||||
ChevronDown,
|
||||
FileSignature,
|
||||
LogOut,
|
||||
@@ -26,10 +31,17 @@ import {
|
||||
Search,
|
||||
Settings,
|
||||
Sun,
|
||||
Upload,
|
||||
User,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { type CSSProperties, Fragment, type ReactNode } from "react";
|
||||
import {
|
||||
type CSSProperties,
|
||||
Fragment,
|
||||
type ReactNode,
|
||||
useState,
|
||||
} from "react";
|
||||
import { PROFILE_TYPE_LABELS } from "@/constants/profileMode";
|
||||
|
||||
export interface SidebarItem {
|
||||
label: string;
|
||||
@@ -49,16 +61,32 @@ export interface AppLayoutProps {
|
||||
userEmail?: string;
|
||||
/** Operational profiles for the company — surfaced as reference chips in the account menu. */
|
||||
companyProfiles?: { type: string; reference: string; status?: string }[];
|
||||
/** Company type (e.g. "customer", "forwarder") — gates the importer/exporter switch. */
|
||||
companyType?: string | null;
|
||||
/** The active operational mode (importer/exporter/...). */
|
||||
activeProfileType?: string | null;
|
||||
/** Switch to an existing profile of the given type. */
|
||||
onSwitchMode?: (type: ServiceType) => Promise<SwitchResult> | void;
|
||||
/** Create the profile of the given type (with business license) then switch. */
|
||||
onCreateProfile?: (
|
||||
type: ServiceType,
|
||||
licenseFiles: File[],
|
||||
) => Promise<SwitchResult> | void;
|
||||
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",
|
||||
};
|
||||
/** Service profiles a customer company can operate under and switch between. */
|
||||
type ServiceType = "importer" | "exporter" | "freight_forwarder";
|
||||
|
||||
/** Services a customer company can select in the header. */
|
||||
const CUSTOMER_SERVICES: ServiceType[] = [
|
||||
"importer",
|
||||
"exporter",
|
||||
"freight_forwarder",
|
||||
];
|
||||
type SwitchResult =
|
||||
| { success: true; data?: unknown }
|
||||
| { success: false; error?: { message?: string } };
|
||||
|
||||
function getInitials(name: string): string {
|
||||
return name
|
||||
@@ -117,6 +145,10 @@ export function AppLayout({
|
||||
userName = "User",
|
||||
userEmail,
|
||||
companyProfiles = [],
|
||||
companyType,
|
||||
activeProfileType,
|
||||
onSwitchMode,
|
||||
onCreateProfile,
|
||||
children,
|
||||
}: AppLayoutProps) {
|
||||
const [mobileOpen, { toggle: toggleMobile }] = useDisclosure();
|
||||
@@ -142,6 +174,62 @@ export function AppLayout({
|
||||
const initials = getInitials(userName);
|
||||
const activePage = getActivePage(sidebarItems, activePath);
|
||||
|
||||
// ── Service selection (customer companies only) ──
|
||||
// A customer can operate as importer, exporter and/or freight forwarder,
|
||||
// and switch between whichever service profiles their company has.
|
||||
const isCustomer = companyType === "customer";
|
||||
const canSwitch =
|
||||
isCustomer &&
|
||||
CUSTOMER_SERVICES.includes(activeProfileType as ServiceType);
|
||||
|
||||
const profileExists = (type: ServiceType) =>
|
||||
companyProfiles.some((p) => p.type === type);
|
||||
|
||||
const [switching, setSwitching] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [createTarget, setCreateTarget] = useState<ServiceType>("importer");
|
||||
const [licenseFiles, setLicenseFiles] = useState<File[]>([]);
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
|
||||
const handleSelectService = async (type: ServiceType) => {
|
||||
if (type === activeProfileType) return;
|
||||
if (profileExists(type)) {
|
||||
setSwitching(true);
|
||||
try {
|
||||
await onSwitchMode?.(type);
|
||||
} finally {
|
||||
setSwitching(false);
|
||||
}
|
||||
} else {
|
||||
// No profile yet — collect a business license, then create + switch.
|
||||
setCreateTarget(type);
|
||||
setLicenseFiles([]);
|
||||
setCreateError(null);
|
||||
setCreateOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateConfirm = async () => {
|
||||
if (licenseFiles.length === 0) {
|
||||
setCreateError("Please upload at least one business license file.");
|
||||
return;
|
||||
}
|
||||
setSwitching(true);
|
||||
setCreateError(null);
|
||||
try {
|
||||
const res = await onCreateProfile?.(createTarget, licenseFiles);
|
||||
if (res && !res.success) {
|
||||
setCreateError(res.error?.message ?? "Failed to create profile");
|
||||
return;
|
||||
}
|
||||
setCreateOpen(false);
|
||||
} finally {
|
||||
setSwitching(false);
|
||||
}
|
||||
};
|
||||
|
||||
const serviceLabel = (m: ServiceType) => PROFILE_TYPE_LABELS[m] ?? m;
|
||||
|
||||
const isItemActive = (item: SidebarItem) =>
|
||||
activePath === item.href.toLowerCase() ||
|
||||
activePath.startsWith(item.href.toLowerCase() + "/");
|
||||
@@ -212,8 +300,66 @@ export function AppLayout({
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{/* Right: search + bell + avatar */}
|
||||
{/* Right: switch + search + bell + avatar */}
|
||||
<Group gap={10} wrap="nowrap" align="center">
|
||||
{/* Service selector (customer companies only) */}
|
||||
{canSwitch && (
|
||||
<Menu
|
||||
width={220}
|
||||
position="bottom-end"
|
||||
withinPortal
|
||||
shadow="md"
|
||||
offset={8}
|
||||
radius="md"
|
||||
>
|
||||
<Menu.Target>
|
||||
<Button
|
||||
loading={switching}
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius={999}
|
||||
size="sm"
|
||||
leftSection={<ArrowLeftRight size={15} strokeWidth={1.8} />}
|
||||
rightSection={<ChevronDown size={14} strokeWidth={1.8} />}
|
||||
styles={{ root: { height: 36 } }}
|
||||
visibleFrom="xs"
|
||||
>
|
||||
{serviceLabel(activeProfileType as ServiceType)}
|
||||
</Button>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Label>Select service</Menu.Label>
|
||||
{CUSTOMER_SERVICES.map((type) => {
|
||||
const isActive = type === activeProfileType;
|
||||
const exists = profileExists(type);
|
||||
return (
|
||||
<Menu.Item
|
||||
key={type}
|
||||
onClick={() => handleSelectService(type)}
|
||||
leftSection={
|
||||
isActive ? (
|
||||
<Check size={15} strokeWidth={2} />
|
||||
) : exists ? (
|
||||
<ArrowLeftRight size={15} strokeWidth={1.8} />
|
||||
) : (
|
||||
<Plus size={15} strokeWidth={1.8} />
|
||||
)
|
||||
}
|
||||
disabled={isActive}
|
||||
>
|
||||
{serviceLabel(type)}
|
||||
{!exists && (
|
||||
<Text span size="xs" c="dimmed" ml={6}>
|
||||
(set up)
|
||||
</Text>
|
||||
)}
|
||||
</Menu.Item>
|
||||
);
|
||||
})}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
)}
|
||||
|
||||
{/* Search pill */}
|
||||
<Group
|
||||
gap={8}
|
||||
@@ -322,25 +468,39 @@ export function AppLayout({
|
||||
<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 }}
|
||||
{companyProfiles.map((p) => {
|
||||
const isActive = p.type === activeProfileType;
|
||||
return (
|
||||
<Group
|
||||
key={p.reference}
|
||||
justify="space-between"
|
||||
gap="sm"
|
||||
wrap="nowrap"
|
||||
>
|
||||
{PROFILE_TYPE_LABELS[p.type] ?? p.type}
|
||||
</Text>
|
||||
<Text size="xs" ff="monospace" c="dimmed">
|
||||
{p.reference}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{isActive && (
|
||||
<Check
|
||||
size={13}
|
||||
color={primaryDarkColor}
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
)}
|
||||
<Text
|
||||
size="xs"
|
||||
fw={isActive ? 700 : 600}
|
||||
style={{
|
||||
color: isActive ? primaryDarkColor : textColor,
|
||||
}}
|
||||
>
|
||||
{PROFILE_TYPE_LABELS[p.type] ?? p.type}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="xs" ff="monospace" c="dimmed">
|
||||
{p.reference}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Box>
|
||||
</>
|
||||
@@ -686,6 +846,51 @@ export function AppLayout({
|
||||
>
|
||||
{children}
|
||||
</AppShell.Main>
|
||||
|
||||
{/* Create-profile modal — opens when switching to a mode the company
|
||||
doesn't have a profile for yet. */}
|
||||
<Modal
|
||||
opened={createOpen}
|
||||
onClose={() => (switching ? undefined : setCreateOpen(false))}
|
||||
title={`Set up your ${serviceLabel(createTarget)} profile`}
|
||||
centered
|
||||
radius="lg"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
You don't have a {serviceLabel(createTarget).toLowerCase()} profile
|
||||
yet. Add your business license to create one and switch to{" "}
|
||||
{serviceLabel(createTarget).toLowerCase()}.
|
||||
</Text>
|
||||
<FileInput
|
||||
label="Business license"
|
||||
multiple
|
||||
clearable
|
||||
accept="application/pdf,image/png,image/jpeg"
|
||||
leftSection={<Upload size={16} />}
|
||||
placeholder="Select license file(s)"
|
||||
value={licenseFiles}
|
||||
onChange={(files) => setLicenseFiles(files ?? [])}
|
||||
error={createError ?? undefined}
|
||||
/>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => setCreateOpen(false)}
|
||||
disabled={switching}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
onClick={handleCreateConfirm}
|
||||
loading={switching}
|
||||
>
|
||||
Create & switch
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
55
apps/edr-freight-web/portal/src/components/ModeIndicator.tsx
Normal file
55
apps/edr-freight-web/portal/src/components/ModeIndicator.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { Badge, Tooltip } from "@mantine/core";
|
||||
import { ArrowDownToLine, ArrowUpFromLine } from "lucide-react";
|
||||
|
||||
import useAuth from "@/hooks/useAuth";
|
||||
import { modeDataDescription, modeDataLabel } from "@/constants/profileMode";
|
||||
|
||||
interface ModeIndicatorProps {
|
||||
/** Mantine size token for the badge. */
|
||||
size?: "sm" | "md" | "lg";
|
||||
}
|
||||
|
||||
/**
|
||||
* Small pill showing which operational mode's data is currently on screen
|
||||
* (Import / Export). The data itself is scoped server-side by the active
|
||||
* profile; this just makes the scope visible. Switching is done via the header
|
||||
* button — this is read-only.
|
||||
*
|
||||
* Renders nothing for non-customer companies or when no import/export mode is
|
||||
* active, so it never interferes with forwarders or not-yet-onboarded users.
|
||||
*/
|
||||
export function ModeIndicator({ size = "md" }: ModeIndicatorProps) {
|
||||
const { companyType, activeProfileType } = useAuth();
|
||||
|
||||
if (companyType !== "customer") return null;
|
||||
|
||||
const label = modeDataLabel(activeProfileType);
|
||||
if (!label) return null;
|
||||
|
||||
const isImport = activeProfileType === "importer";
|
||||
|
||||
return (
|
||||
<Tooltip label={modeDataDescription(activeProfileType)} withArrow>
|
||||
<Badge
|
||||
size={size}
|
||||
radius="sm"
|
||||
variant="light"
|
||||
color={isImport ? "edr-green" : "blue"}
|
||||
leftSection={
|
||||
isImport ? (
|
||||
<ArrowDownToLine size={13} />
|
||||
) : (
|
||||
<ArrowUpFromLine size={13} />
|
||||
)
|
||||
}
|
||||
styles={{
|
||||
root: { textTransform: "none", letterSpacing: 0, fontWeight: 600 },
|
||||
}}
|
||||
>
|
||||
Viewing: {label}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export default ModeIndicator;
|
||||
137
apps/edr-freight-web/portal/src/components/PhoneField.tsx
Normal file
137
apps/edr-freight-web/portal/src/components/PhoneField.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { Input } from "@mantine/core";
|
||||
import { forwardRef } from "react";
|
||||
import {
|
||||
Controller,
|
||||
type Control,
|
||||
type FieldValues,
|
||||
type Path,
|
||||
} from "react-hook-form";
|
||||
import RPNInput, { isValidPhoneNumber } from "react-phone-number-input";
|
||||
import "react-phone-number-input/style.css";
|
||||
import "./phone-field.css";
|
||||
|
||||
/** Re-exported for zod `.refine()` checks on phone fields. */
|
||||
export const isValidPhone = (value?: string | null): boolean =>
|
||||
!!value && isValidPhoneNumber(value);
|
||||
|
||||
/**
|
||||
* Normalize a raw (often eTrade) phone string to Ethiopian E.164 (+251…).
|
||||
* eTrade returns local numbers like "0912345678" / "0355235416"; the phone
|
||||
* input needs +251… to parse, so we drop a leading 0 and prepend +251. Numbers
|
||||
* already in +… form, or that can't be coerced, are returned trimmed/as-is.
|
||||
*/
|
||||
export const toEthiopianE164 = (raw?: string | null): string => {
|
||||
if (!raw) return "";
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed.startsWith("+")) return trimmed.replace(/[^\d+]/g, "");
|
||||
// Keep digits only, drop a single leading zero (national trunk prefix).
|
||||
const digits = trimmed.replace(/\D/g, "").replace(/^0/, "");
|
||||
if (!digits) return "";
|
||||
// Already includes the 251 country code.
|
||||
if (digits.startsWith("251")) return `+${digits}`;
|
||||
return `+251${digits}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* The text input rendered inside react-phone-number-input, styled to match the
|
||||
* portal's Mantine fields (44px height, 10px radius, edr border). Must forward
|
||||
* the ref and accept native input props for the library to drive it.
|
||||
*/
|
||||
const StyledInput = forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
|
||||
function StyledInput(props, ref) {
|
||||
return <input {...props} ref={ref} className="edr-phone-input" />;
|
||||
},
|
||||
);
|
||||
|
||||
export interface PhoneFieldProps {
|
||||
label?: string;
|
||||
value?: string;
|
||||
onChange: (value: string | undefined) => void;
|
||||
onBlur?: () => void;
|
||||
error?: string;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Professional phone input: searchable country selector (all countries, default
|
||||
* Ethiopia), live formatting, emits a single E.164 value (e.g. +251912345678).
|
||||
* Visually aligned with the portal's Mantine form fields.
|
||||
*/
|
||||
export function PhoneField({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
onBlur,
|
||||
error,
|
||||
required,
|
||||
disabled,
|
||||
placeholder = "912 345 678",
|
||||
}: PhoneFieldProps) {
|
||||
return (
|
||||
<Input.Wrapper
|
||||
label={label}
|
||||
required={required}
|
||||
error={error}
|
||||
styles={{
|
||||
label: { fontWeight: 600, fontSize: 13, color: "#10202F", marginBottom: 6 },
|
||||
}}
|
||||
>
|
||||
<div className={`edr-phone-wrapper${error ? " edr-phone-wrapper--error" : ""}`}>
|
||||
<RPNInput
|
||||
international
|
||||
defaultCountry="ET"
|
||||
countryCallingCodeEditable={false}
|
||||
addInternationalOption
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onBlur={onBlur}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
inputComponent={StyledInput}
|
||||
/>
|
||||
</div>
|
||||
</Input.Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
interface ControlledPhoneFieldProps<T extends FieldValues> {
|
||||
control: Control<T>;
|
||||
name: Path<T>;
|
||||
label?: string;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
/** RHF Controller wrapper so forms drop in one line. */
|
||||
export function ControlledPhoneField<T extends FieldValues>({
|
||||
control,
|
||||
name,
|
||||
label,
|
||||
required,
|
||||
disabled,
|
||||
placeholder,
|
||||
}: ControlledPhoneFieldProps<T>) {
|
||||
return (
|
||||
<Controller
|
||||
control={control}
|
||||
name={name}
|
||||
render={({ field, fieldState }) => (
|
||||
<PhoneField
|
||||
label={label}
|
||||
required={required}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
value={(field.value as string) ?? ""}
|
||||
onChange={(v) => field.onChange(v ?? "")}
|
||||
onBlur={field.onBlur}
|
||||
error={fieldState.error?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default PhoneField;
|
||||
@@ -1,47 +0,0 @@
|
||||
import { Group, Stack, Text, TextInput, type TextInputProps } from "@mantine/core";
|
||||
|
||||
type InputPassthrough = Partial<TextInputProps>;
|
||||
|
||||
interface PhoneInputProps {
|
||||
disabled?: boolean;
|
||||
countryCode?: InputPassthrough;
|
||||
phone?: InputPassthrough;
|
||||
countryCodeError?: { message?: string };
|
||||
phoneError?: { message?: string };
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export default function PhoneInput({
|
||||
disabled,
|
||||
countryCode: countryCodeProps,
|
||||
phone: phoneProps,
|
||||
countryCodeError,
|
||||
phoneError,
|
||||
label = "Phone Number",
|
||||
}: PhoneInputProps) {
|
||||
const errorMsg = countryCodeError?.message ?? phoneError?.message;
|
||||
return (
|
||||
<Stack gap={6}>
|
||||
<Text size="sm" fw={500} c="edr-text">{label}</Text>
|
||||
<Group gap={8} wrap="nowrap" align="flex-start">
|
||||
<TextInput
|
||||
w={80}
|
||||
disabled={disabled}
|
||||
error={Boolean(countryCodeError)}
|
||||
styles={{ input: { textAlign: "center" } }}
|
||||
{...countryCodeProps}
|
||||
/>
|
||||
<TextInput
|
||||
style={{ flex: 1 }}
|
||||
placeholder="912345678"
|
||||
disabled={disabled}
|
||||
error={Boolean(phoneError)}
|
||||
{...phoneProps}
|
||||
/>
|
||||
</Group>
|
||||
{errorMsg && (
|
||||
<Text size="xs" c="red.6">{errorMsg}</Text>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Group,
|
||||
Loader,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import type { UseFormRegisterReturn } from "react-hook-form";
|
||||
import { AlertCircle, CheckCircle2, Download } from "lucide-react";
|
||||
import { useETradeData } from "@/hooks/useETradeData";
|
||||
import type { CompanyRegistrationData } from "@edr/types";
|
||||
|
||||
interface ETradeInfoProps {
|
||||
/** Current TIN value (drives button enablement). */
|
||||
tin: string;
|
||||
/** RHF registration for the TIN input — this is the form's primary TIN field. */
|
||||
register: UseFormRegisterReturn;
|
||||
/** Validation error for the TIN field, if any. */
|
||||
error?: string;
|
||||
onDataLoaded: (data: CompanyRegistrationData) => void;
|
||||
}
|
||||
|
||||
export default function ETradeInfo({
|
||||
tin,
|
||||
register,
|
||||
error,
|
||||
onDataLoaded,
|
||||
}: ETradeInfoProps) {
|
||||
const mutation = useETradeData();
|
||||
const isLoading = mutation.isPending;
|
||||
const hasData = mutation.data;
|
||||
|
||||
const handleFetch = async () => {
|
||||
if (!tin || tin.length !== 10) return;
|
||||
const result = await mutation.mutateAsync(tin);
|
||||
if (result) {
|
||||
onDataLoaded(result);
|
||||
}
|
||||
};
|
||||
|
||||
const errorMessage =
|
||||
mutation.isError && mutation.error
|
||||
? (mutation.error as any).message ||
|
||||
"Failed to fetch company information. Please try again."
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Group align="flex-start" grow>
|
||||
<TextInput
|
||||
label="TIN Number (10 digits)"
|
||||
placeholder="1234567890"
|
||||
maxLength={10}
|
||||
error={error}
|
||||
{...register}
|
||||
/>
|
||||
<Button
|
||||
variant="filled"
|
||||
color="edr-green"
|
||||
onClick={handleFetch}
|
||||
disabled={!tin || tin.length !== 10 || isLoading}
|
||||
leftSection={
|
||||
isLoading ? <Loader size={16} /> : <Download size={16} />
|
||||
}
|
||||
mt="24px"
|
||||
>
|
||||
{isLoading ? "Getting..." : "Get Data"}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{errorMessage && (
|
||||
<Alert
|
||||
icon={<AlertCircle size={16} />}
|
||||
color="red"
|
||||
title="Failed to fetch data"
|
||||
>
|
||||
{errorMessage} You can still fill in the details manually below.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{hasData && (
|
||||
<Alert
|
||||
icon={<CheckCircle2 size={16} />}
|
||||
color="green"
|
||||
title="Company information loaded"
|
||||
>
|
||||
<Stack gap={0}>
|
||||
<Text size="sm">
|
||||
<strong>License:</strong> {hasData.licenceNumber}
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
<strong>Status:</strong> {hasData.statusDescription}
|
||||
</Text>
|
||||
{hasData.region && (
|
||||
<Text size="sm">
|
||||
<strong>Location:</strong> {hasData.kebele}, {hasData.woreda},{" "}
|
||||
{hasData.zone}, {hasData.region}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
import { Modal, ScrollArea, Stack, Text } from "@mantine/core";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import useAuth from "@/hooks/useAuth";
|
||||
import { api } from "@/services/api";
|
||||
import type {
|
||||
CompanyNationality,
|
||||
CreateCompanyPayload,
|
||||
ProfileTypeValue,
|
||||
} from "@/services/companies.service";
|
||||
import { companiesService } from "@/services/companies.service";
|
||||
import type { UpdateProfilePayload } from "@/types/profile";
|
||||
import { extractApiError } from "@/utils/result";
|
||||
import CompanyProfileForm from "@/pages/accounts/CompanyProfileForm";
|
||||
import type { RoleLicenseProfile } from "@/components/onboarding/RoleLicenseStep";
|
||||
import NationalitySelect from "@/pages/settings/NationalitySelect";
|
||||
import OnboardingRoleSelect from "@/pages/settings/OnboardingRoleSelect";
|
||||
|
||||
/** Form steps shared by CompanyProfileForm and ForwarderForm. */
|
||||
type FormStep =
|
||||
| "company"
|
||||
| "personnel"
|
||||
| "contact"
|
||||
| "poa"
|
||||
| "documents"
|
||||
| "additional";
|
||||
const FORM_STEPS: FormStep[] = [
|
||||
"company",
|
||||
"personnel",
|
||||
"contact",
|
||||
"poa",
|
||||
"documents",
|
||||
"additional",
|
||||
];
|
||||
|
||||
interface OnboardingWizardDialogProps {
|
||||
opened: boolean;
|
||||
/** Dismiss the dialog (user clicked the close icon). */
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The company type for the onboarding selection. Importer / Exporter / Freight
|
||||
* Forwarder are all services a single "customer" company can hold (in any
|
||||
* combination), each with its own business license — so the company is always
|
||||
* registered as a "customer".
|
||||
*/
|
||||
function companyTypeForRoles(_roles: string[]): string {
|
||||
return "customer";
|
||||
}
|
||||
|
||||
/** Document upload setting code per company nationality. */
|
||||
function documentSettingCode(nationality: CompanyNationality): string {
|
||||
return nationality === "foreign"
|
||||
? "company_onboarding_documents_foreign"
|
||||
: "company_onboarding_documents_ethiopian";
|
||||
}
|
||||
|
||||
/**
|
||||
* First-run onboarding wizard with a "draft-first" flow: picking the role(s)
|
||||
* immediately creates a draft company + profile on the backend, so every
|
||||
* subsequent step saves its data incrementally (PATCH /profile, /onboarding-step)
|
||||
* against existing rows. The final step uploads documents and marks onboarding
|
||||
* complete. Dismissable — the gate keeps it reachable until finished.
|
||||
*/
|
||||
export default function OnboardingWizardDialog({
|
||||
opened,
|
||||
onClose,
|
||||
}: OnboardingWizardDialogProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const { user, company, onboardingStep } = useAuth();
|
||||
|
||||
const existingProfiles = company?.company?.companyProfiles ?? [];
|
||||
const companyAlreadyStarted = Boolean(company?.company?.id);
|
||||
const savedNationality =
|
||||
(company?.company?.nationality as CompanyNationality | null) ?? null;
|
||||
|
||||
// Resume position from the backend-persisted step.
|
||||
const resumeFormStep: FormStep = FORM_STEPS.includes(onboardingStep as FormStep)
|
||||
? (onboardingStep as FormStep)
|
||||
: "company";
|
||||
|
||||
// Phases: nationality → role → form. If a draft already exists, resume
|
||||
// straight into the form with nationality + roles pre-selected.
|
||||
const [phase, setPhase] = useState<"nationality" | "role" | "form">(
|
||||
companyAlreadyStarted ? "form" : "nationality",
|
||||
);
|
||||
const [nationality, setNationality] = useState<CompanyNationality | null>(
|
||||
savedNationality,
|
||||
);
|
||||
const [roles, setRoles] = useState<string[]>(
|
||||
existingProfiles.map((p) => p.type),
|
||||
);
|
||||
const [documentFiles, setDocumentFiles] = useState<
|
||||
Record<string, File | File[] | null>
|
||||
>({});
|
||||
// Newly-selected business-license files per company_profile id.
|
||||
const [licenseFiles, setLicenseFiles] = useState<Record<string, File[]>>({});
|
||||
const [startError, setStartError] = useState<string | null>(null);
|
||||
|
||||
// Saved profile data, for rehydrating the form fields after a refresh.
|
||||
const profileQuery = useQuery(
|
||||
api.companies.getProfile.queryOptions({
|
||||
enabled: companyAlreadyStarted,
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
}),
|
||||
);
|
||||
|
||||
const refreshInfo = useCallback(
|
||||
() =>
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: api.companies.getInfo.queryKey(),
|
||||
}),
|
||||
[queryClient],
|
||||
);
|
||||
|
||||
// Begin onboarding: create the draft company + profile + role(s) + nationality.
|
||||
const startMutation = useMutation({
|
||||
mutationFn: (vars: {
|
||||
companyType: string;
|
||||
roles: ProfileTypeValue[];
|
||||
nationality?: CompanyNationality;
|
||||
}) => api.companies.startOnboarding.call(vars),
|
||||
onSuccess: async () => {
|
||||
await refreshInfo();
|
||||
setPhase("form");
|
||||
},
|
||||
onError: (err) => setStartError(extractApiError(err).message),
|
||||
});
|
||||
|
||||
// Finalize: upload per-role license files + company documents, then complete.
|
||||
const finishMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const companyId = company?.company?.id;
|
||||
// Per-role business licenses (file model, resource=company_profiles).
|
||||
for (const [profileId, files] of Object.entries(licenseFiles)) {
|
||||
if (files.length > 0) {
|
||||
await companiesService.uploadProfileLicense(profileId, files);
|
||||
}
|
||||
}
|
||||
// Nationality-based company documents (resource=companies).
|
||||
const hasDocs = Object.values(documentFiles).some(
|
||||
(f) => f !== null && (Array.isArray(f) ? f.length > 0 : true),
|
||||
);
|
||||
if (companyId && hasDocs) {
|
||||
await companiesService.uploadDocuments(companyId, documentFiles);
|
||||
}
|
||||
return api.companies.completeOnboarding.call();
|
||||
},
|
||||
onSuccess: refreshInfo,
|
||||
onError: (err) => setStartError(extractApiError(err).message),
|
||||
});
|
||||
|
||||
// Persist the resume step to the backend, but only ever move FORWARD — going
|
||||
// Back must never downgrade the furthest step the user reached, so reopening
|
||||
// always lands on the furthest step.
|
||||
const furthestIdxRef = useRef(FORM_STEPS.indexOf(resumeFormStep));
|
||||
const persistStep = useCallback((step: string) => {
|
||||
const idx = FORM_STEPS.indexOf(step as FormStep);
|
||||
if (idx < 0 || idx <= furthestIdxRef.current) return;
|
||||
furthestIdxRef.current = idx;
|
||||
api.companies.setOnboardingStep.call({ step }).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// The company query may resolve AFTER this dialog mounts (it's kept mounted by
|
||||
// the gate), so the phase/roles/nationality initial state can be stale — a
|
||||
// draft that already exists would otherwise leave us stuck on the first
|
||||
// (nationality) phase. Once a draft loads, jump straight into the form with
|
||||
// the persisted roles/nationality. Runs once per resumed draft.
|
||||
const resumedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!companyAlreadyStarted || resumedRef.current) return;
|
||||
resumedRef.current = true;
|
||||
setRoles(existingProfiles.map((p) => p.type));
|
||||
setNationality(savedNationality);
|
||||
setPhase("form");
|
||||
const idx = FORM_STEPS.indexOf(resumeFormStep);
|
||||
if (idx > furthestIdxRef.current) furthestIdxRef.current = idx;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [companyAlreadyStarted, resumeFormStep]);
|
||||
|
||||
const handleNationalityContinue = useCallback(() => {
|
||||
if (nationality) setPhase("role");
|
||||
}, [nationality]);
|
||||
|
||||
const handleRolesContinue = useCallback(() => {
|
||||
setStartError(null);
|
||||
startMutation.mutate({
|
||||
companyType: companyTypeForRoles(roles),
|
||||
roles: roles as ProfileTypeValue[],
|
||||
nationality: nationality ?? undefined,
|
||||
});
|
||||
}, [roles, nationality, startMutation]);
|
||||
|
||||
// Note: no "back to role selection" — once the draft is created the role(s)
|
||||
// are fixed; the form's first-step Back is a no-op so progress never resets.
|
||||
const handleBackToRoles = useCallback(() => {}, []);
|
||||
|
||||
// Save the current step's fields to the draft (PATCH /profile). Returns the
|
||||
// server error message on failure so the form can show it (e.g. duplicate TIN).
|
||||
const saveStep = useCallback(
|
||||
async (
|
||||
data: Partial<UpdateProfilePayload>,
|
||||
): Promise<{ ok: true } | { ok: false; error: string }> => {
|
||||
try {
|
||||
await api.companies.updateProfile.call(data as UpdateProfilePayload);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return { ok: false, error: extractApiError(err).message };
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Final confirm step → finalize onboarding (no company create; it already
|
||||
// exists as a draft that's been filled in step-by-step).
|
||||
const handleSubmit = useCallback(
|
||||
(_payload: CreateCompanyPayload) => {
|
||||
finishMutation.mutate();
|
||||
},
|
||||
[finishMutation],
|
||||
);
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
// Any non-empty combination of importer/exporter/freight-forwarder is valid.
|
||||
const rolesValid = roles.length > 0;
|
||||
// Documents depend on nationality; fall back to the saved one (resume) then ethiopian.
|
||||
const effectiveNationality: CompanyNationality =
|
||||
nationality ?? savedNationality ?? "ethiopian";
|
||||
|
||||
// Per-role license cards for the final step (from the created profiles).
|
||||
const roleProfiles: RoleLicenseProfile[] = existingProfiles.map((p) => ({
|
||||
id: p.id,
|
||||
type: p.type,
|
||||
reference: p.reference,
|
||||
existingFiles: p.licenseFiles ?? [],
|
||||
}));
|
||||
|
||||
const titleHint =
|
||||
phase === "nationality"
|
||||
? "Where is your company registered?"
|
||||
: phase === "role"
|
||||
? "Tell us what your company does to get started."
|
||||
: "Set up your company profile to finish.";
|
||||
|
||||
const formProps = {
|
||||
documentSettingCode: documentSettingCode(effectiveNationality),
|
||||
documentFiles,
|
||||
onDocumentFilesChange: setDocumentFiles,
|
||||
user,
|
||||
onSubmit: handleSubmit,
|
||||
isPending: finishMutation.isPending,
|
||||
onBack: handleBackToRoles,
|
||||
hideFirstStepBack: true,
|
||||
initialStep: resumeFormStep,
|
||||
resyncOpen: opened,
|
||||
onStepChange: persistStep,
|
||||
onSaveStep: saveStep,
|
||||
rehydrate: profileQuery.data ?? null,
|
||||
roleProfiles,
|
||||
licenseFiles,
|
||||
onLicenseChange: setLicenseFiles,
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
withCloseButton
|
||||
closeOnClickOutside={false}
|
||||
closeOnEscape
|
||||
size={1040}
|
||||
radius="lg"
|
||||
padding="xl"
|
||||
centered
|
||||
keepMounted
|
||||
scrollAreaComponent={ScrollArea.Autosize}
|
||||
overlayProps={{ backgroundOpacity: 0.55, blur: 4 }}
|
||||
title={
|
||||
<Stack gap={2}>
|
||||
<Text fz={20} fw={800} c="edr-text" className="tracking-tight">
|
||||
Complete your onboarding
|
||||
</Text>
|
||||
<Text size="sm" c="edr-muted">
|
||||
{titleHint}
|
||||
</Text>
|
||||
</Stack>
|
||||
}
|
||||
>
|
||||
{phase === "nationality" ? (
|
||||
<Stack gap="lg">
|
||||
<NationalitySelect value={nationality} onChange={setNationality} />
|
||||
<RoleContinueBar
|
||||
disabled={!nationality}
|
||||
onClick={handleNationalityContinue}
|
||||
/>
|
||||
</Stack>
|
||||
) : phase === "role" ? (
|
||||
<Stack gap="lg">
|
||||
<OnboardingRoleSelect value={roles} onChange={setRoles} />
|
||||
{startError && (
|
||||
<Text size="sm" c="red">
|
||||
{startError}
|
||||
</Text>
|
||||
)}
|
||||
<RoleContinueBar
|
||||
disabled={!rolesValid}
|
||||
loading={startMutation.isPending}
|
||||
onClick={handleRolesContinue}
|
||||
/>
|
||||
</Stack>
|
||||
) : (
|
||||
<CompanyProfileForm {...formProps} />
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function RoleContinueBar({
|
||||
disabled,
|
||||
loading,
|
||||
onClick,
|
||||
}: {
|
||||
disabled: boolean;
|
||||
loading?: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || loading}
|
||||
onClick={onClick}
|
||||
className="ml-auto rounded-lg bg-[var(--mantine-color-edr-green-6)] px-5 py-2.5 text-sm font-semibold text-white transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Setting up…" : "Continue"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import {
|
||||
Anchor,
|
||||
Badge,
|
||||
Card,
|
||||
FileInput,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import { FileText, Paperclip, Upload } from "lucide-react";
|
||||
|
||||
import type { LicenseFile } from "@/services/companies.service";
|
||||
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
importer: "Importer",
|
||||
exporter: "Exporter",
|
||||
freight_forwarder: "Freight Forwarder",
|
||||
dj_freight_forwarder: "DJ Freight Forwarder",
|
||||
transporter: "Transporter",
|
||||
};
|
||||
|
||||
export interface RoleLicenseProfile {
|
||||
id: string;
|
||||
type: string;
|
||||
reference: string;
|
||||
/** License files already uploaded for this profile (rehydration). */
|
||||
existingFiles: LicenseFile[];
|
||||
}
|
||||
|
||||
interface RoleLicenseStepProps {
|
||||
/** One card per operational role/profile. */
|
||||
profiles: RoleLicenseProfile[];
|
||||
/** Newly-selected files per profile id (not yet uploaded). */
|
||||
value: Record<string, File[]>;
|
||||
onChange: (value: Record<string, File[]>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Final onboarding step: collect a business license (one or more files) for
|
||||
* each operational role the company holds. Each role gets its own multi-file
|
||||
* input; already-uploaded files are listed for context.
|
||||
*/
|
||||
export default function RoleLicenseStep({
|
||||
profiles,
|
||||
value,
|
||||
onChange,
|
||||
}: RoleLicenseStepProps) {
|
||||
const setFiles = (profileId: string, files: File[]) => {
|
||||
onChange({ ...value, [profileId]: files });
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="edr-muted">
|
||||
Upload the business license for each of your operational profiles. You
|
||||
can attach more than one document per profile.
|
||||
</Text>
|
||||
|
||||
{profiles.map((profile) => {
|
||||
const label = ROLE_LABELS[profile.type] ?? profile.type;
|
||||
const selected = value[profile.id] ?? [];
|
||||
const hasAny = selected.length > 0 || profile.existingFiles.length > 0;
|
||||
|
||||
return (
|
||||
<Card key={profile.id} padding="lg" withBorder>
|
||||
<Group justify="space-between" mb="sm" wrap="nowrap">
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ThemeIcon
|
||||
size={40}
|
||||
radius="md"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
>
|
||||
<FileText size={20} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700} c="edr-text" fz={15}>
|
||||
{label} — Business License
|
||||
</Text>
|
||||
<Text size="xs" c="edr-muted" ff="monospace">
|
||||
{profile.reference}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
{hasAny && (
|
||||
<Badge color="edr-green" variant="light">
|
||||
Provided
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{profile.existingFiles.length > 0 && (
|
||||
<Stack gap={4} mb="sm">
|
||||
{profile.existingFiles.map((f) => (
|
||||
<Group key={f.url} gap={6} wrap="nowrap">
|
||||
<Paperclip size={13} className="text-edr-muted" />
|
||||
<Anchor
|
||||
href={f.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
size="xs"
|
||||
>
|
||||
{f.name}
|
||||
</Anchor>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<FileInput
|
||||
multiple
|
||||
clearable
|
||||
accept="application/pdf,image/png,image/jpeg"
|
||||
leftSection={<Upload size={16} />}
|
||||
placeholder={
|
||||
profile.existingFiles.length > 0
|
||||
? "Upload more / replace files"
|
||||
: "Select license file(s)"
|
||||
}
|
||||
value={selected}
|
||||
onChange={(files) => setFiles(profile.id, files ?? [])}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
82
apps/edr-freight-web/portal/src/components/phone-field.css
Normal file
82
apps/edr-freight-web/portal/src/components/phone-field.css
Normal file
@@ -0,0 +1,82 @@
|
||||
/* Align react-phone-number-input with the portal's Mantine field styling:
|
||||
44px height, 10px radius, edr border, brand-green focus ring. */
|
||||
|
||||
.edr-phone-wrapper .PhoneInput {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Country selector — a compact pill matching the input height/radius. */
|
||||
.edr-phone-wrapper .PhoneInputCountry {
|
||||
margin: 0;
|
||||
padding: 0 10px;
|
||||
height: 44px;
|
||||
border: 1px solid #e6ecf2;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
transition:
|
||||
border-color 120ms ease,
|
||||
box-shadow 120ms ease;
|
||||
}
|
||||
|
||||
.edr-phone-wrapper .PhoneInputCountryIcon {
|
||||
width: 22px;
|
||||
height: 16px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.edr-phone-wrapper .PhoneInputCountrySelectArrow {
|
||||
color: #6b7c8e;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* The number input itself. */
|
||||
.edr-phone-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 44px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #e6ecf2;
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
color: #10202f;
|
||||
background: #fff;
|
||||
outline: none;
|
||||
transition:
|
||||
border-color 120ms ease,
|
||||
box-shadow 120ms ease;
|
||||
}
|
||||
|
||||
.edr-phone-input::placeholder {
|
||||
color: #9aa8b5;
|
||||
}
|
||||
|
||||
.edr-phone-input:focus {
|
||||
border-color: #0ea371;
|
||||
box-shadow: 0 0 0 3px rgba(14, 163, 113, 0.15);
|
||||
}
|
||||
|
||||
.edr-phone-wrapper .PhoneInputCountry:focus-within {
|
||||
border-color: #0ea371;
|
||||
box-shadow: 0 0 0 3px rgba(14, 163, 113, 0.15);
|
||||
}
|
||||
|
||||
.edr-phone-input:disabled,
|
||||
.edr-phone-wrapper .PhoneInputCountrySelect:disabled + .PhoneInputCountryIcon {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Error state mirrors Mantine's invalid styling. */
|
||||
.edr-phone-wrapper--error .edr-phone-input,
|
||||
.edr-phone-wrapper--error .PhoneInputCountry {
|
||||
border-color: #e03131;
|
||||
}
|
||||
|
||||
.edr-phone-wrapper--error .edr-phone-input:focus {
|
||||
box-shadow: 0 0 0 3px rgba(224, 49, 49, 0.12);
|
||||
}
|
||||
@@ -84,8 +84,16 @@ export const URL_CONSTANTS = {
|
||||
CREATE: "/api/companies/create",
|
||||
PROFILE: "/api/companies/profile",
|
||||
COMPANY_PROFILES: "/api/companies/company-profiles",
|
||||
COMPANY_PROFILE: "/api/companies/company-profile",
|
||||
ACTIVE_MODE: "/api/companies/active-mode",
|
||||
ONBOARDING_START: "/api/companies/onboarding/start",
|
||||
ONBOARDING_STEP: "/api/companies/onboarding-step",
|
||||
ONBOARDING_COMPLETE: "/api/companies/onboarding/complete",
|
||||
DASHBOARD: "/api/companies/dashboard",
|
||||
FETCH_ETRADE_INFO: "/api/companies/fetch-etrade-info",
|
||||
DOCUMENTS: (id: string) => `/api/companies/${id}/documents`,
|
||||
PROFILE_LICENSE: (profileId: string) =>
|
||||
`/api/companies/company-profiles/${profileId}/license`,
|
||||
},
|
||||
|
||||
BOOKINGS: {
|
||||
|
||||
30
apps/edr-freight-web/portal/src/constants/profileMode.ts
Normal file
30
apps/edr-freight-web/portal/src/constants/profileMode.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Operational-mode (importer/exporter/…) labels and helpers, shared by the app
|
||||
* header and the per-page mode indicator so there is a single source of truth.
|
||||
*/
|
||||
|
||||
export const PROFILE_TYPE_LABELS: Record<string, string> = {
|
||||
importer: "Importer",
|
||||
exporter: "Exporter",
|
||||
freight_forwarder: "Freight Forwarder",
|
||||
dj_freight_forwarder: "DJ Freight Forwarder",
|
||||
transporter: "Transporter",
|
||||
};
|
||||
|
||||
/** The data-scope label shown to the user (importer ⇒ "Import", exporter ⇒ "Export"). */
|
||||
export function modeDataLabel(
|
||||
activeProfileType?: string | null,
|
||||
): string | null {
|
||||
if (activeProfileType === "importer") return "Import";
|
||||
if (activeProfileType === "exporter") return "Export";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Short helper sentence describing what the active mode scopes. */
|
||||
export function modeDataDescription(
|
||||
activeProfileType?: string | null,
|
||||
): string {
|
||||
const label = modeDataLabel(activeProfileType);
|
||||
if (!label) return "";
|
||||
return `Showing your ${label.toLowerCase()} data — switch in the header.`;
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import { api } from "@/services/api";
|
||||
import type { ProfileTypeValue } from "@/services/companies.service";
|
||||
import { companiesService } from "@/services/companies.service";
|
||||
import type {
|
||||
LoginPayload,
|
||||
LoginResponse,
|
||||
@@ -149,6 +151,57 @@ const useAuth = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Active-mode (importer/exporter) state, sourced from the persisted profile.
|
||||
const companyInfo = isAuthenticated ? (companyQuery.data ?? null) : null;
|
||||
const activeProfileType = companyInfo?.profile?.activeProfileType ?? null;
|
||||
const activeCompanyProfileId =
|
||||
companyInfo?.profile?.activeCompanyProfileId ?? null;
|
||||
const companyType = companyInfo?.company?.type ?? null;
|
||||
const onboardingCompleted =
|
||||
companyInfo?.profile?.onboardingCompleted ?? false;
|
||||
const onboardingStep = companyInfo?.profile?.onboardingStep ?? null;
|
||||
|
||||
/** Refetch everything scoped to the active operational profile. */
|
||||
const invalidateScopedData = async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: api.companies.getInfo.queryKey(),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: api.companies.getDashboard.queryKey(),
|
||||
}),
|
||||
queryClient.invalidateQueries({ queryKey: ["bookings"] }),
|
||||
]);
|
||||
};
|
||||
|
||||
const switchMode = async (
|
||||
type: ProfileTypeValue,
|
||||
): Promise<Result<void>> => {
|
||||
try {
|
||||
await api.companies.setActiveMode.call({ type });
|
||||
await invalidateScopedData();
|
||||
return { success: true, data: undefined };
|
||||
} catch (err) {
|
||||
return { success: false, error: extractApiError(err) };
|
||||
}
|
||||
};
|
||||
|
||||
const createProfileAndSwitch = async (
|
||||
type: ProfileTypeValue,
|
||||
licenseFiles: File[],
|
||||
): Promise<Result<void>> => {
|
||||
try {
|
||||
const created = await api.companies.createCompanyProfile.call({ type });
|
||||
if (licenseFiles.length > 0) {
|
||||
await companiesService.uploadProfileLicense(created.id, licenseFiles);
|
||||
}
|
||||
await invalidateScopedData();
|
||||
return { success: true, data: undefined };
|
||||
} catch (err) {
|
||||
return { success: false, error: extractApiError(err) };
|
||||
}
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
try {
|
||||
await api.auth.logout.call();
|
||||
@@ -174,6 +227,13 @@ const useAuth = () => {
|
||||
user: isAuthenticated ? (authQuery.data ?? null) : null,
|
||||
company: isAuthenticated ? (companyQuery.data ?? null) : null,
|
||||
customer: isAuthenticated ? (companyQuery.data ?? null) : null,
|
||||
activeProfileType,
|
||||
activeCompanyProfileId,
|
||||
companyType,
|
||||
onboardingCompleted,
|
||||
onboardingStep,
|
||||
switchMode,
|
||||
createProfileAndSwitch,
|
||||
login,
|
||||
signup,
|
||||
setPassword,
|
||||
|
||||
16
apps/edr-freight-web/portal/src/hooks/useETradeData.ts
Normal file
16
apps/edr-freight-web/portal/src/hooks/useETradeData.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { companiesService } from "@/services/companies.service";
|
||||
import { extractApiError } from "@/utils/result";
|
||||
import type { CompanyRegistrationData } from "@edr/types";
|
||||
|
||||
export function useETradeData() {
|
||||
return useMutation({
|
||||
mutationFn: async (tin: string): Promise<CompanyRegistrationData> => {
|
||||
return companiesService.fetchETradeInfo({ tin });
|
||||
},
|
||||
onError: (error) => {
|
||||
const { message } = extractApiError(error);
|
||||
console.error("eTrade fetch error:", message);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { Box, Group, Text } from "@mantine/core";
|
||||
import { ArrowRight, Truck } from "lucide-react";
|
||||
import { memo } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { ModeIndicator } from "@/components/ModeIndicator";
|
||||
import { cv } from "../constants";
|
||||
|
||||
interface HelloSectionProps {
|
||||
@@ -19,9 +20,12 @@ export const HelloSection = memo(function HelloSection({
|
||||
<Text size="sm" c="edr-muted">
|
||||
{greeting}
|
||||
</Text>
|
||||
<Text fz={26} fw={800} mt={2} c="edr-text" className="tracking-tight">
|
||||
{companyName} 👋
|
||||
</Text>
|
||||
<Group gap={12} align="center" mt={2} wrap="wrap">
|
||||
<Text fz={26} fw={800} c="edr-text" className="tracking-tight">
|
||||
{companyName} 👋
|
||||
</Text>
|
||||
<ModeIndicator />
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
<Link to="/bookings/new">
|
||||
|
||||
@@ -1,410 +0,0 @@
|
||||
import { api } from "@/services/api";
|
||||
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;
|
||||
}) {
|
||||
return (
|
||||
<Group gap="sm" align="flex-start" wrap="nowrap">
|
||||
{icon && (
|
||||
<ThemeIcon variant="light" color="edr-green" size="md" radius="md">
|
||||
{icon}
|
||||
</ThemeIcon>
|
||||
)}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProfilePage() {
|
||||
const { data: profile, isPending } = useQuery(
|
||||
api.companies.getProfile.queryOptions(),
|
||||
);
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<Center h="100%">
|
||||
<Loader color="edr-green" size="lg" />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (!profile) {
|
||||
return (
|
||||
<Center h="100%">
|
||||
<Text c="edr-muted">No company profile found.</Text>
|
||||
</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 (
|
||||
<Container size="xl" px="lg" py="xl">
|
||||
{/* Header */}
|
||||
<Group gap="lg" align="center" mb="lg">
|
||||
<ThemeIcon variant="light" color="edr-green" size={88} radius="lg">
|
||||
<User size={44} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={6}>
|
||||
<Group gap="sm" align="center">
|
||||
<Title order={1} size="h2">
|
||||
{profile.companyName}
|
||||
</Title>
|
||||
<Badge color="edr-green" variant="light">
|
||||
Verified
|
||||
</Badge>
|
||||
</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>
|
||||
))}
|
||||
</Group>
|
||||
) : (
|
||||
<Group gap={6} c="edr-muted">
|
||||
<Building size={16} />
|
||||
<Text c="edr-muted" fw={500}>
|
||||
{profile.companyType}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
<Divider mb="lg" />
|
||||
|
||||
<Grid gap="lg">
|
||||
{/* Left Column */}
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
<Stack gap="lg">
|
||||
{/* Company Details */}
|
||||
<Card>
|
||||
<CardHeading
|
||||
icon={
|
||||
<Building2
|
||||
size={20}
|
||||
color="var(--mantine-color-edr-green-6)"
|
||||
/>
|
||||
}
|
||||
title="Company Details"
|
||||
description="Business registration information"
|
||||
/>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<InfoItem
|
||||
icon={<Globe size={16} />}
|
||||
label="Location"
|
||||
value={profile.companyLocation}
|
||||
/>
|
||||
<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>
|
||||
|
||||
{/* Key Personnel */}
|
||||
<Card>
|
||||
<CardHeading
|
||||
icon={
|
||||
<Briefcase
|
||||
size={20}
|
||||
color="var(--mantine-color-edr-green-6)"
|
||||
/>
|
||||
}
|
||||
title="Key Personnel"
|
||||
description="Management and contact persons"
|
||||
/>
|
||||
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="lg">
|
||||
<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>
|
||||
|
||||
{/* Power of Attorney */}
|
||||
{profile.poaName && (
|
||||
<Card style={{ borderStyle: "dashed" }}>
|
||||
<CardHeading
|
||||
icon={
|
||||
<UserCheck
|
||||
size={20}
|
||||
color="var(--mantine-color-edr-accent-6)"
|
||||
/>
|
||||
}
|
||||
title="Power of Attorney"
|
||||
description="Authorized representative details"
|
||||
/>
|
||||
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="md">
|
||||
<InfoItem label="PoA Name" value={profile.poaName} />
|
||||
<InfoItem label="PoA Email" value={profile.poaEmail} />
|
||||
<InfoItem label="PoA Phone" value={profile.poaPhone} />
|
||||
<InfoItem label="PoA Location" value={profile.poaLocation} />
|
||||
</SimpleGrid>
|
||||
</Card>
|
||||
)}
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
|
||||
{/* Right Column */}
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<Stack gap="lg">
|
||||
{/* Operating Roles */}
|
||||
<Card>
|
||||
<CardHeading
|
||||
icon={
|
||||
<BadgeCheck
|
||||
size={20}
|
||||
color="var(--mantine-color-edr-green-6)"
|
||||
/>
|
||||
}
|
||||
title="Operating Roles"
|
||||
description="Your registered freight roles and reference numbers"
|
||||
/>
|
||||
{roleOptions.length === 0 ? (
|
||||
<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>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Secure Account */}
|
||||
<Card
|
||||
padding="xl"
|
||||
style={{
|
||||
background: "var(--mantine-color-edr-ink-6)",
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 16,
|
||||
right: 16,
|
||||
opacity: 0.1,
|
||||
}}
|
||||
>
|
||||
<ShieldCheck size={128} color="white" />
|
||||
</Box>
|
||||
<Stack gap="md" style={{ position: "relative", zIndex: 1 }}>
|
||||
<Title order={3} size="h4" c="white">
|
||||
Secure Account
|
||||
</Title>
|
||||
<Text size="sm" c="gray.4">
|
||||
Your information is protected by enterprise-grade security.
|
||||
Contact support for verified information updates.
|
||||
</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,27 +1,34 @@
|
||||
import { api } from "@/services/api";
|
||||
import type { ProfileResponse } from "@/types/profile";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Card,
|
||||
Center,
|
||||
Container,
|
||||
Group,
|
||||
Loader,
|
||||
Stack,
|
||||
Tabs,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertCircle,
|
||||
BadgeCheck,
|
||||
Briefcase,
|
||||
Building2,
|
||||
FileCheck,
|
||||
Globe,
|
||||
ShieldCheck,
|
||||
User,
|
||||
UserCheck,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { rolesForCompanyType } from "./settings/companyRoles";
|
||||
import TabCompanyProfile from "./settings/TabCompanyProfile";
|
||||
import TabContactPerson from "./settings/TabContactPerson";
|
||||
import TabDocuments from "./settings/TabDocuments";
|
||||
@@ -30,35 +37,139 @@ import TabPowerOfAttorney from "./settings/TabPowerOfAttorney";
|
||||
|
||||
type SettingsTab = "company" | "contact" | "gm" | "poa" | "documents";
|
||||
|
||||
function tabIncomplete(tabId: SettingsTab, profile?: ProfileResponse | null): boolean {
|
||||
if (!profile) return false;
|
||||
/** A section is "incomplete" when its required fields aren't filled in yet. */
|
||||
function tabIncomplete(
|
||||
tabId: SettingsTab,
|
||||
profile: ProfileResponse,
|
||||
): boolean {
|
||||
switch (tabId) {
|
||||
case "company":
|
||||
return !profile.companyEmail || !profile.companyPhone || !profile.companyAddress || !profile.fanNumber;
|
||||
return (
|
||||
!profile.companyEmail ||
|
||||
!profile.companyPhone ||
|
||||
!profile.companyAddress ||
|
||||
!profile.fanNumber
|
||||
);
|
||||
case "contact":
|
||||
return !profile.contactPersonName || !profile.contactPersonPhone;
|
||||
case "gm":
|
||||
return !profile.generalManagerName || !profile.generalManagerEmail || !profile.generalManagerPhone;
|
||||
return (
|
||||
!profile.generalManagerName ||
|
||||
!profile.generalManagerEmail ||
|
||||
!profile.generalManagerPhone
|
||||
);
|
||||
case "poa":
|
||||
return false;
|
||||
case "documents":
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const TABS: { id: SettingsTab; label: string; icon: React.ReactNode }[] = [
|
||||
{ id: "company", label: "Company Profile", icon: <Building2 size={16} /> },
|
||||
{ id: "company", label: "Company", icon: <Building2 size={16} /> },
|
||||
{ id: "contact", label: "Contact Person", icon: <User size={16} /> },
|
||||
{ id: "gm", label: "General Manager", icon: <Briefcase size={16} /> },
|
||||
{ id: "poa", label: "Power of Attorney", icon: <UserCheck size={16} /> },
|
||||
{ id: "documents", label: "Documents", icon: <FileCheck size={16} /> },
|
||||
];
|
||||
|
||||
/**
|
||||
* Polished identity banner shown above the editor tabs — company name, its
|
||||
* registered operating roles, location and verification status at a glance.
|
||||
*/
|
||||
function ProfileHeader({ profile }: { profile: ProfileResponse }) {
|
||||
const refByType = new Map(profile.companyProfiles.map((p) => [p.type, p]));
|
||||
const roleOptions = rolesForCompanyType(profile.companyType);
|
||||
const activeRoles = roleOptions.filter((o) => refByType.has(o.type));
|
||||
|
||||
return (
|
||||
<Card
|
||||
padding="xl"
|
||||
radius="lg"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(135deg, var(--mantine-color-edr-ink-6) 0%, var(--mantine-color-edr-ink-8, var(--mantine-color-edr-ink-6)) 100%)",
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{ position: "absolute", top: -24, right: -16, opacity: 0.08 }}
|
||||
>
|
||||
<Building2 size={180} color="white" />
|
||||
</Box>
|
||||
|
||||
<Group
|
||||
justify="space-between"
|
||||
align="flex-start"
|
||||
wrap="nowrap"
|
||||
style={{ position: "relative", zIndex: 1 }}
|
||||
>
|
||||
<Group gap="lg" align="center" wrap="nowrap">
|
||||
<ThemeIcon variant="white" color="edr-green" size={72} radius="lg">
|
||||
<Building2 size={36} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={8}>
|
||||
<Group gap="sm" align="center">
|
||||
<Title order={1} size="h2" c="white">
|
||||
{profile.companyName}
|
||||
</Title>
|
||||
<Badge
|
||||
color="edr-green"
|
||||
variant="filled"
|
||||
leftSection={<BadgeCheck size={13} />}
|
||||
>
|
||||
Verified
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
{activeRoles.length > 0 ? (
|
||||
<Group gap="xs">
|
||||
{activeRoles.map((opt) => (
|
||||
<Badge
|
||||
key={opt.type}
|
||||
variant="white"
|
||||
color="edr-ink"
|
||||
radius="sm"
|
||||
size="lg"
|
||||
>
|
||||
{opt.label} · {refByType.get(opt.type)!.reference}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
) : (
|
||||
<Text c="gray.4" fw={500} tt="capitalize">
|
||||
{profile.companyType.replace(/_/g, " ")}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Group gap="lg" mt={4}>
|
||||
{profile.companyLocation && (
|
||||
<Group gap={6} c="gray.4">
|
||||
<Globe size={15} />
|
||||
<Text size="sm">{profile.companyLocation}</Text>
|
||||
</Group>
|
||||
)}
|
||||
{profile.tinNumber && (
|
||||
<Group gap={6} c="gray.4">
|
||||
<ShieldCheck size={15} />
|
||||
<Text size="sm" ff="monospace">
|
||||
TIN {profile.tinNumber}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const tab = (searchParams.get("tab") as SettingsTab) || "company";
|
||||
|
||||
const setTab = useCallback(
|
||||
(t: SettingsTab) => {
|
||||
setSearchParams(
|
||||
@@ -79,9 +190,10 @@ export default function SettingsPage() {
|
||||
refetchOnWindowFocus: false,
|
||||
}),
|
||||
);
|
||||
|
||||
const profile = profileQuery.data;
|
||||
|
||||
// Keep the cached company info in sync whenever the profile changes, so the
|
||||
// header (and the rest of the app) reflect edits immediately.
|
||||
useEffect(() => {
|
||||
if (profileQuery.dataUpdatedAt > 0) {
|
||||
queryClient.invalidateQueries({
|
||||
@@ -90,34 +202,6 @@ export default function SettingsPage() {
|
||||
}
|
||||
}, [profileQuery.dataUpdatedAt, queryClient]);
|
||||
|
||||
const [isOnboarding, setIsOnboarding] = useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (profileQuery.isFetched && isOnboarding === null) {
|
||||
setIsOnboarding(!profileQuery.data);
|
||||
}
|
||||
}, [profileQuery.isFetched, profileQuery.data, isOnboarding]);
|
||||
|
||||
const handleOnboardingSuccess = useCallback(() => {
|
||||
setTab("contact");
|
||||
}, [setTab]);
|
||||
|
||||
const handleContactContinue = useCallback(() => {
|
||||
setTab("gm");
|
||||
}, [setTab]);
|
||||
|
||||
const handleGMContinue = useCallback(() => {
|
||||
setTab("poa");
|
||||
}, [setTab]);
|
||||
|
||||
const handlePOAContinue = useCallback(() => {
|
||||
setTab("documents");
|
||||
}, [setTab]);
|
||||
|
||||
const handleDocumentsContinue = useCallback(() => {
|
||||
navigate("/portal");
|
||||
}, [navigate]);
|
||||
|
||||
if (profileQuery.isPending) {
|
||||
return (
|
||||
<Center h="100%">
|
||||
@@ -126,126 +210,82 @@ export default function SettingsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const onboarding = isOnboarding === true;
|
||||
|
||||
const renderProfileContent = (children: React.ReactNode) => {
|
||||
if (onboarding && tab !== "company" && !profile) {
|
||||
return (
|
||||
<Center h={200}>
|
||||
<Loader color="edr-green" />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
if (!profile) {
|
||||
return (
|
||||
<Card padding="xl">
|
||||
if (!profile) {
|
||||
return (
|
||||
<Container size="xl" px="lg" py="xl">
|
||||
<Card padding="xl" radius="lg">
|
||||
<Center>
|
||||
<Alert
|
||||
icon={<AlertCircle size={24} />}
|
||||
color="gray"
|
||||
variant="light"
|
||||
>
|
||||
Please complete the company profile first.
|
||||
</Alert>
|
||||
<Group gap="sm" c="edr-muted">
|
||||
<AlertCircle size={20} />
|
||||
<Text>No company profile found.</Text>
|
||||
</Group>
|
||||
</Center>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
return children;
|
||||
};
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container size="xl" px="lg">
|
||||
<Group justify="space-between" mb="xl">
|
||||
<Container size="xl" px="lg" py="xl">
|
||||
<Stack gap="xl">
|
||||
<ProfileHeader profile={profile} />
|
||||
|
||||
<div>
|
||||
<Title order={1} size="h2">
|
||||
{onboarding ? "Complete Your Profile" : "Account Settings"}
|
||||
<Title order={2} size="h3">
|
||||
Account Settings
|
||||
</Title>
|
||||
<Text c="edr-muted" size="sm" mt={4}>
|
||||
{onboarding
|
||||
? "Set up your company profile, personnel, and documents to get started"
|
||||
: "Manage your company profile, personnel, and documents"}
|
||||
Manage your company profile, personnel, and documents.
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Tabs
|
||||
value={tab}
|
||||
onChange={(value) => {
|
||||
if (!value) return;
|
||||
// if (onboarding) return;
|
||||
setTab(value as SettingsTab);
|
||||
}}
|
||||
>
|
||||
<Tabs.List mb="md">
|
||||
{TABS.map((t) => (
|
||||
<Tabs.Tab
|
||||
key={t.id}
|
||||
value={t.id}
|
||||
leftSection={t.icon}
|
||||
disabled={!onboarding && !profile && t.id !== "company"}
|
||||
rightSection={
|
||||
!onboarding && profile && tabIncomplete(t.id, profile) ? (
|
||||
<AlertCircle size={14} color="red" />
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{t.label}
|
||||
</Tabs.Tab>
|
||||
))}
|
||||
</Tabs.List>
|
||||
<Tabs
|
||||
value={tab}
|
||||
onChange={(value) => value && setTab(value as SettingsTab)}
|
||||
variant="pills"
|
||||
radius="md"
|
||||
>
|
||||
<Tabs.List mb="lg">
|
||||
{TABS.map((t) => (
|
||||
<Tabs.Tab
|
||||
key={t.id}
|
||||
value={t.id}
|
||||
leftSection={t.icon}
|
||||
rightSection={
|
||||
tabIncomplete(t.id, profile) ? (
|
||||
<Box
|
||||
w={7}
|
||||
h={7}
|
||||
style={{
|
||||
borderRadius: "50%",
|
||||
background: "var(--mantine-color-red-6)",
|
||||
}}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{t.label}
|
||||
</Tabs.Tab>
|
||||
))}
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="company">
|
||||
{!profile ? (
|
||||
<TabCompanyProfile
|
||||
mode="create"
|
||||
onCreateSuccess={handleOnboardingSuccess}
|
||||
/>
|
||||
) : (
|
||||
<Tabs.Panel value="company">
|
||||
<TabCompanyProfile mode="edit" profile={profile} />
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="contact">
|
||||
{renderProfileContent(
|
||||
<TabContactPerson
|
||||
profile={profile!}
|
||||
mode={onboarding ? "onboarding" : "edit"}
|
||||
onContinue={handleContactContinue}
|
||||
/>,
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="gm">
|
||||
{renderProfileContent(
|
||||
<TabGeneralManager
|
||||
profile={profile!}
|
||||
mode={onboarding ? "onboarding" : "edit"}
|
||||
onContinue={handleGMContinue}
|
||||
/>,
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="poa">
|
||||
{renderProfileContent(
|
||||
<TabPowerOfAttorney
|
||||
profile={profile!}
|
||||
mode={onboarding ? "onboarding" : "edit"}
|
||||
onContinue={handlePOAContinue}
|
||||
/>,
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="documents">
|
||||
{renderProfileContent(
|
||||
<TabDocuments
|
||||
profile={profile!}
|
||||
mode={onboarding ? "onboarding" : "edit"}
|
||||
onContinue={handleDocumentsContinue}
|
||||
/>,
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="contact">
|
||||
<TabContactPerson profile={profile} mode="edit" />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="gm">
|
||||
<TabGeneralManager profile={profile} mode="edit" />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="poa">
|
||||
<TabPowerOfAttorney profile={profile} mode="edit" />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="documents">
|
||||
<TabDocuments profile={profile} mode="edit" />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,7 +16,7 @@ import { z } from "zod";
|
||||
|
||||
import type { AuthUser } from "@/types/auth";
|
||||
import type { CreateCompanyPayload } from "@/services/companies.service";
|
||||
import PhoneInput from "@/components/auth/PhoneInput";
|
||||
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
|
||||
import { SmartFileInput } from "@edr/ui-common";
|
||||
import { api } from "@/services/api";
|
||||
|
||||
@@ -25,21 +25,25 @@ type DjiboutiStep = "company" | "representative" | "documents" | "confirm";
|
||||
const djiboutiSchema = z.object({
|
||||
companyName: z.string().min(1, "Company name is required"),
|
||||
companyEmail: z.string().email("Invalid email address"),
|
||||
companyPhone: z.string().min(1, "Company phone is required"),
|
||||
companyPhoneCountryCode: z.string().min(1, "Country code is required"),
|
||||
companyPhone: z
|
||||
.string()
|
||||
.min(1, "Company phone is required")
|
||||
.refine(isValidPhone, "Enter a valid phone number"),
|
||||
companyLocation: z.string().min(1, "Location / Country is required"),
|
||||
companyAddress: z.string().min(1, "Address is required"),
|
||||
repName: z.string().min(1, "Representative name is required"),
|
||||
repEmail: z.string().email("Invalid representative email"),
|
||||
repPhone: z.string().min(1, "Representative phone is required"),
|
||||
repPhoneCountryCode: z.string().min(1, "Country code is required"),
|
||||
repPhone: z
|
||||
.string()
|
||||
.min(1, "Representative phone is required")
|
||||
.refine(isValidPhone, "Enter a valid phone number"),
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof djiboutiSchema>;
|
||||
|
||||
const stepFields: Record<DjiboutiStep, (keyof FormData)[]> = {
|
||||
company: ["companyName", "companyEmail", "companyPhone", "companyPhoneCountryCode", "companyLocation", "companyAddress"],
|
||||
representative: ["repName", "repEmail", "repPhone", "repPhoneCountryCode"],
|
||||
company: ["companyName", "companyEmail", "companyPhone", "companyLocation", "companyAddress"],
|
||||
representative: ["repName", "repEmail", "repPhone"],
|
||||
documents: [],
|
||||
confirm: [],
|
||||
};
|
||||
@@ -48,7 +52,7 @@ function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload {
|
||||
return {
|
||||
companyName: data.companyName,
|
||||
companyEmail: data.companyEmail,
|
||||
companyPhone: `${data.companyPhoneCountryCode}${data.companyPhone}`,
|
||||
companyPhone: data.companyPhone,
|
||||
companyLocation: data.companyLocation,
|
||||
companyAddress: data.companyAddress,
|
||||
tin: "",
|
||||
@@ -57,7 +61,7 @@ function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload {
|
||||
attributes: {
|
||||
repName: data.repName,
|
||||
repEmail: data.repEmail,
|
||||
repPhone: `${data.repPhoneCountryCode}${data.repPhone}`,
|
||||
repPhone: data.repPhone,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -88,11 +92,11 @@ export default function DjiboutiAgentForm({
|
||||
api.fileUploadSettings.getByCode.queryOptions({ input: { code: documentSettingCode }, refetchOnMount: false }),
|
||||
);
|
||||
|
||||
const { register, handleSubmit, trigger, watch, formState: { errors } } = useForm<FormData>({
|
||||
const { register, control, handleSubmit, trigger, watch, formState: { errors } } = useForm<FormData>({
|
||||
resolver: zodResolver(djiboutiSchema),
|
||||
defaultValues: {
|
||||
companyName: "", companyEmail: "", companyPhone: "", companyPhoneCountryCode: "+253",
|
||||
companyLocation: "", companyAddress: "", repName: "", repEmail: "", repPhone: "", repPhoneCountryCode: "+253",
|
||||
companyName: "", companyEmail: "", companyPhone: "",
|
||||
companyLocation: "", companyAddress: "", repName: "", repEmail: "", repPhone: "",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -195,12 +199,11 @@ export default function DjiboutiAgentForm({
|
||||
error={errors.companyEmail?.message}
|
||||
{...register("companyEmail")}
|
||||
/>
|
||||
<PhoneInput
|
||||
countryCode={{ ...register("companyPhoneCountryCode") }}
|
||||
phone={{ ...register("companyPhone"), placeholder: "12345678" }}
|
||||
countryCodeError={errors.companyPhoneCountryCode}
|
||||
phoneError={errors.companyPhone}
|
||||
<ControlledPhoneField
|
||||
control={control}
|
||||
name="companyPhone"
|
||||
label="Company Phone"
|
||||
required
|
||||
/>
|
||||
</SimpleGrid>
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
@@ -239,12 +242,11 @@ export default function DjiboutiAgentForm({
|
||||
error={errors.repEmail?.message}
|
||||
{...register("repEmail")}
|
||||
/>
|
||||
<PhoneInput
|
||||
countryCode={{ ...register("repPhoneCountryCode") }}
|
||||
phone={{ ...register("repPhone"), placeholder: "12345678" }}
|
||||
countryCodeError={errors.repPhoneCountryCode}
|
||||
phoneError={errors.repPhone}
|
||||
<ControlledPhoneField
|
||||
control={control}
|
||||
name="repPhone"
|
||||
label="Representative Phone"
|
||||
required
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</>
|
||||
@@ -280,7 +282,7 @@ export default function DjiboutiAgentForm({
|
||||
<ReviewRow label="Address" value={formValues.companyAddress} />
|
||||
<ReviewRow label="Rep. name" value={formValues.repName} />
|
||||
<ReviewRow label="Rep. email" value={formValues.repEmail} />
|
||||
<ReviewRow label="Rep. phone" value={`${formValues.repPhoneCountryCode}${formValues.repPhone}`} />
|
||||
<ReviewRow label="Rep. phone" value={formValues.repPhone} />
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Box, Button, Divider, Group, Loader, SimpleGrid, Stack, Text, TextInput, ThemeIcon } from "@mantine/core";
|
||||
import { Alert, Box, Button, Divider, Group, Loader, SimpleGrid, Stack, Text, TextInput, ThemeIcon } from "@mantine/core";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Building2,
|
||||
@@ -11,38 +12,50 @@ import {
|
||||
UploadCloud,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { AuthUser } from "@/types/auth";
|
||||
import type { CreateCompanyPayload } from "@/services/companies.service";
|
||||
import PhoneInput from "@/components/auth/PhoneInput";
|
||||
import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
|
||||
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
|
||||
import { SmartFileInput } from "@edr/ui-common";
|
||||
import { api } from "@/services/api";
|
||||
import RoleLicenseStep, {
|
||||
type RoleLicenseProfile,
|
||||
} from "@/components/onboarding/RoleLicenseStep";
|
||||
|
||||
type ForwarderStep = "company" | "personnel" | "poa" | "documents" | "confirm";
|
||||
type ForwarderStep = "company" | "personnel" | "poa" | "documents" | "additional";
|
||||
|
||||
const forwarderSchema = z.object({
|
||||
companyName: z.string().min(1, "Company name is required"),
|
||||
companyEmail: z.string().email("Invalid email address"),
|
||||
companyPhone: z.string().min(1, "Company phone is required"),
|
||||
companyPhoneCountryCode: z.string().min(1, "Country code is required"),
|
||||
companyPhone: z
|
||||
.string()
|
||||
.min(1, "Company phone is required")
|
||||
.refine(isValidPhone, "Enter a valid phone number"),
|
||||
companyLocation: z.string().min(1, "Location is required"),
|
||||
companyAddress: z.string().min(1, "Address is required"),
|
||||
tinNumber: z.string().length(10, "TIN must be exactly 10 digits"),
|
||||
vatNumber: z.string().min(1, "VAT number is required").length(10, "VAT number must be exactly 10 digits"),
|
||||
fanNumber: z.string().length(16, "FAN must be exactly 16 digits"),
|
||||
contactPersonName: z.string().min(1, "Contact person name is required"),
|
||||
contactPersonPhone: z.string().min(1, "Contact person phone is required"),
|
||||
contactPersonPhoneCountryCode: z.string().min(1, "Country code is required"),
|
||||
contactPersonPhone: z
|
||||
.string()
|
||||
.min(1, "Contact person phone is required")
|
||||
.refine(isValidPhone, "Enter a valid phone number"),
|
||||
generalManagerName: z.string().min(1, "GM name is required"),
|
||||
generalManagerEmail: z.string().email("Invalid GM email"),
|
||||
generalManagerPhone: z.string().min(1, "GM phone is required"),
|
||||
generalManagerPhoneCountryCode: z.string().min(1, "Country code is required"),
|
||||
generalManagerPhone: z
|
||||
.string()
|
||||
.min(1, "GM phone is required")
|
||||
.refine(isValidPhone, "Enter a valid phone number"),
|
||||
poaName: z.string().optional(),
|
||||
poaPhone: z.string().optional(),
|
||||
poaPhoneCountryCode: z.string().optional(),
|
||||
poaPhone: z
|
||||
.string()
|
||||
.optional()
|
||||
.refine((v) => !v || isValidPhone(v), "Enter a valid phone number"),
|
||||
poaAddress: z.string().optional(),
|
||||
poaEmail: z.string().optional(),
|
||||
poaLocation: z.string().optional(),
|
||||
@@ -51,18 +64,18 @@ const forwarderSchema = z.object({
|
||||
type FormData = z.infer<typeof forwarderSchema>;
|
||||
|
||||
const stepFields: Record<ForwarderStep, (keyof FormData)[]> = {
|
||||
company: ["companyName", "companyEmail", "companyPhone", "companyPhoneCountryCode", "companyLocation", "companyAddress", "tinNumber", "vatNumber", "fanNumber"],
|
||||
personnel: ["contactPersonName", "contactPersonPhone", "contactPersonPhoneCountryCode", "generalManagerName", "generalManagerEmail", "generalManagerPhone", "generalManagerPhoneCountryCode"],
|
||||
company: ["companyName", "companyEmail", "companyPhone", "companyLocation", "companyAddress", "tinNumber", "vatNumber", "fanNumber"],
|
||||
personnel: ["contactPersonName", "contactPersonPhone", "generalManagerName", "generalManagerEmail", "generalManagerPhone"],
|
||||
poa: [],
|
||||
documents: [],
|
||||
confirm: [],
|
||||
additional: [],
|
||||
};
|
||||
|
||||
function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload {
|
||||
return {
|
||||
companyName: data.companyName,
|
||||
companyEmail: data.companyEmail,
|
||||
companyPhone: `${data.companyPhoneCountryCode}${data.companyPhone}`,
|
||||
companyPhone: data.companyPhone,
|
||||
companyLocation: data.companyLocation,
|
||||
companyAddress: data.companyAddress,
|
||||
tin: data.tinNumber,
|
||||
@@ -70,12 +83,12 @@ function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload {
|
||||
fanNumber: data.fanNumber,
|
||||
attributes: {
|
||||
contactPersonName: data.contactPersonName,
|
||||
contactPersonPhone: `${data.contactPersonPhoneCountryCode}${data.contactPersonPhone}`,
|
||||
contactPersonPhone: data.contactPersonPhone,
|
||||
generalManagerName: data.generalManagerName,
|
||||
generalManagerEmail: data.generalManagerEmail,
|
||||
generalManagerPhone: `${data.generalManagerPhoneCountryCode}${data.generalManagerPhone}`,
|
||||
generalManagerPhone: data.generalManagerPhone,
|
||||
poaName: data.poaName || undefined,
|
||||
poaPhone: data.poaPhone && data.poaPhoneCountryCode ? `${data.poaPhoneCountryCode}${data.poaPhone}` : undefined,
|
||||
poaPhone: data.poaPhone || undefined,
|
||||
poaAddress: data.poaAddress || undefined,
|
||||
poaEmail: data.poaEmail || undefined,
|
||||
poaLocation: data.poaLocation || undefined,
|
||||
@@ -83,6 +96,66 @@ function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload {
|
||||
};
|
||||
}
|
||||
|
||||
/** Map one wizard step's form values to the profile-update payload it saves. */
|
||||
function stepPayload(step: ForwarderStep, d: FormData): Partial<UpdateProfilePayload> {
|
||||
switch (step) {
|
||||
case "company":
|
||||
return {
|
||||
companyName: d.companyName,
|
||||
companyEmail: d.companyEmail,
|
||||
companyPhone: d.companyPhone,
|
||||
companyLocation: d.companyLocation,
|
||||
companyAddress: d.companyAddress,
|
||||
tin: d.tinNumber,
|
||||
vatNumber: d.vatNumber,
|
||||
fanNumber: d.fanNumber,
|
||||
};
|
||||
case "personnel":
|
||||
return {
|
||||
contactPersonName: d.contactPersonName,
|
||||
contactPersonPhone: d.contactPersonPhone,
|
||||
generalManagerName: d.generalManagerName,
|
||||
generalManagerEmail: d.generalManagerEmail,
|
||||
generalManagerPhone: d.generalManagerPhone,
|
||||
};
|
||||
case "poa":
|
||||
return {
|
||||
poaName: d.poaName || undefined,
|
||||
poaPhone: d.poaPhone || undefined,
|
||||
poaEmail: d.poaEmail || undefined,
|
||||
poaLocation: d.poaLocation || undefined,
|
||||
poaAddress: d.poaAddress || undefined,
|
||||
};
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** Seed the form from previously-saved profile data. */
|
||||
function toFormValues(p: ProfileResponse): FormData {
|
||||
const tin = p.tinNumber && !p.tinNumber.startsWith("D") ? p.tinNumber : "";
|
||||
return {
|
||||
companyName: p.companyName ?? "",
|
||||
companyEmail: p.companyEmail ?? "",
|
||||
companyPhone: p.companyPhone ?? "",
|
||||
companyLocation: p.companyLocation ?? "",
|
||||
companyAddress: p.companyAddress ?? "",
|
||||
tinNumber: tin,
|
||||
vatNumber: p.vatNumber ?? "",
|
||||
fanNumber: p.fanNumber ?? "",
|
||||
contactPersonName: p.contactPersonName ?? "",
|
||||
contactPersonPhone: p.contactPersonPhone ?? "",
|
||||
generalManagerName: p.generalManagerName ?? "",
|
||||
generalManagerEmail: p.generalManagerEmail ?? "",
|
||||
generalManagerPhone: p.generalManagerPhone ?? "",
|
||||
poaName: p.poaName ?? "",
|
||||
poaPhone: p.poaPhone ?? "",
|
||||
poaAddress: p.poaAddress ?? "",
|
||||
poaEmail: p.poaEmail ?? "",
|
||||
poaLocation: p.poaLocation ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
export default function ForwarderForm({
|
||||
documentSettingCode,
|
||||
documentFiles: controlledFiles,
|
||||
@@ -91,6 +164,15 @@ export default function ForwarderForm({
|
||||
onSubmit,
|
||||
isPending,
|
||||
onBack,
|
||||
initialStep,
|
||||
resyncOpen,
|
||||
hideFirstStepBack,
|
||||
onStepChange,
|
||||
onSaveStep,
|
||||
rehydrate,
|
||||
roleProfiles,
|
||||
licenseFiles,
|
||||
onLicenseChange,
|
||||
}: {
|
||||
documentSettingCode: string;
|
||||
documentFiles?: Record<string, File | File[] | null>;
|
||||
@@ -99,8 +181,46 @@ export default function ForwarderForm({
|
||||
onSubmit: (data: CreateCompanyPayload) => void;
|
||||
isPending: boolean;
|
||||
onBack: () => void;
|
||||
/** Step to resume at (defaults to "company"). */
|
||||
initialStep?: ForwarderStep;
|
||||
/** When this flips true (dialog reopened), jump back to initialStep (furthest reached). */
|
||||
resyncOpen?: boolean;
|
||||
/** Hide the Back button on the first step (onboarding can't go back to role pick). */
|
||||
hideFirstStepBack?: boolean;
|
||||
/** Reports the active step so the parent can persist resume progress. */
|
||||
onStepChange?: (step: ForwarderStep) => void;
|
||||
/** Persist the current step's data before advancing; returns an error to show. */
|
||||
onSaveStep?: (
|
||||
data: Partial<UpdateProfilePayload>,
|
||||
) => Promise<{ ok: true } | { ok: false; error: string }>;
|
||||
/** Saved profile to seed the form with (rehydration after refresh). */
|
||||
rehydrate?: ProfileResponse | null;
|
||||
/** Operational profiles for the final per-role license step. */
|
||||
roleProfiles?: RoleLicenseProfile[];
|
||||
/** Newly-selected license files per profile id. */
|
||||
licenseFiles?: Record<string, File[]>;
|
||||
onLicenseChange?: (value: Record<string, File[]>) => void;
|
||||
}) {
|
||||
const [step, setStep] = useState<ForwarderStep>("company");
|
||||
const [step, setStep] = useState<ForwarderStep>(initialStep ?? "company");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
|
||||
// Report each step change up so the wizard can persist it for resume.
|
||||
useEffect(() => {
|
||||
onStepChange?.(step);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [step]);
|
||||
|
||||
// On reopen, jump to the furthest step reached so progress never resets.
|
||||
const wasOpen = useRef(resyncOpen);
|
||||
useEffect(() => {
|
||||
if (resyncOpen && !wasOpen.current && initialStep) {
|
||||
setStep(initialStep);
|
||||
setSaveError(null);
|
||||
}
|
||||
wasOpen.current = resyncOpen;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [resyncOpen]);
|
||||
const [internalFiles, setInternalFiles] = useState<Record<string, File | File[] | null>>({});
|
||||
const documentFiles = controlledFiles ?? internalFiles;
|
||||
const setDocumentFiles = onDocumentFilesChange ?? setInternalFiles;
|
||||
@@ -109,33 +229,68 @@ export default function ForwarderForm({
|
||||
api.fileUploadSettings.getByCode.queryOptions({ input: { code: documentSettingCode }, refetchOnMount: false }),
|
||||
);
|
||||
|
||||
const { register, handleSubmit, trigger, watch, formState: { errors } } = useForm<FormData>({
|
||||
const { register, control, handleSubmit, trigger, watch, formState: { errors } } = useForm<FormData>({
|
||||
resolver: zodResolver(forwarderSchema),
|
||||
defaultValues: {
|
||||
companyName: "", companyEmail: "", companyPhone: "", companyPhoneCountryCode: "+251",
|
||||
companyName: "", companyEmail: "", companyPhone: "",
|
||||
companyLocation: "", companyAddress: "", tinNumber: "", vatNumber: "", fanNumber: "",
|
||||
contactPersonName: "", contactPersonPhone: "", contactPersonPhoneCountryCode: "+251",
|
||||
generalManagerName: "", generalManagerEmail: "", generalManagerPhone: "", generalManagerPhoneCountryCode: "+251",
|
||||
poaName: "", poaPhone: "", poaPhoneCountryCode: "+251", poaAddress: "", poaEmail: "", poaLocation: "",
|
||||
contactPersonName: "", contactPersonPhone: "",
|
||||
generalManagerName: "", generalManagerEmail: "", generalManagerPhone: "",
|
||||
poaName: "", poaPhone: "", poaAddress: "", poaEmail: "", poaLocation: "",
|
||||
},
|
||||
// Rehydrate from previously-saved data (RHF re-syncs when `values` change).
|
||||
values: rehydrate ? toFormValues(rehydrate) : undefined,
|
||||
});
|
||||
|
||||
const formValues = watch();
|
||||
const hasDocuments = Boolean(uploadSetting?.fields?.length);
|
||||
const totalSteps = 5;
|
||||
|
||||
const nextStep = async () => {
|
||||
if (step === "poa") { setStep("documents"); return; }
|
||||
if (step === "documents") { setStep("confirm"); return; }
|
||||
if (step === "confirm") { handleSubmit((data) => onSubmit(buildPayload(data, user)))(); return; }
|
||||
/** Validate + persist the current step, returning whether we may advance. */
|
||||
const saveCurrentStep = async (): Promise<boolean> => {
|
||||
setSaveError(null);
|
||||
const isValid = await trigger(stepFields[step]);
|
||||
if (!isValid) return;
|
||||
setStep(step === "company" ? "personnel" : "poa");
|
||||
if (!isValid) return false;
|
||||
if (!onSaveStep) return true;
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await onSaveStep(stepPayload(step, watch()));
|
||||
if (!res.ok) {
|
||||
setSaveError(res.error);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const skipDocuments = () => setStep("confirm");
|
||||
// Every role needs at least one license file (existing or newly selected).
|
||||
const licenseComplete = (roleProfiles ?? []).every(
|
||||
(p) =>
|
||||
(licenseFiles?.[p.id]?.length ?? 0) > 0 || p.existingFiles.length > 0,
|
||||
);
|
||||
|
||||
const nextStep = async () => {
|
||||
if (step === "additional") {
|
||||
if (!licenseComplete) {
|
||||
setSaveError(
|
||||
"Please upload a business license for each of your operational profiles.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
handleSubmit((data) => onSubmit(buildPayload(data, user)))();
|
||||
return;
|
||||
}
|
||||
if (step === "documents") { setStep("additional"); return; }
|
||||
const ok = await saveCurrentStep();
|
||||
if (!ok) return;
|
||||
setStep(step === "company" ? "personnel" : step === "personnel" ? "poa" : "documents");
|
||||
};
|
||||
|
||||
const skipDocuments = () => setStep("additional");
|
||||
|
||||
const prevStep = () => {
|
||||
setSaveError(null);
|
||||
if (step === "company") onBack();
|
||||
else if (step === "personnel") setStep("company");
|
||||
else if (step === "poa") setStep("personnel");
|
||||
@@ -143,23 +298,25 @@ export default function ForwarderForm({
|
||||
else setStep("documents");
|
||||
};
|
||||
|
||||
const showBack = !(hideFirstStepBack && step === "company");
|
||||
|
||||
const STEPS: { key: ForwarderStep; icon: React.ReactNode }[] = [
|
||||
{ key: "company", icon: <Building2 size={18} /> },
|
||||
{ key: "personnel", icon: <User size={18} /> },
|
||||
{ key: "poa", icon: <FileText size={18} /> },
|
||||
{ key: "documents", icon: <UploadCloud size={18} /> },
|
||||
{ key: "confirm", icon: <CheckCircle2 size={18} /> },
|
||||
{ key: "additional", icon: <CheckCircle2 size={18} /> },
|
||||
];
|
||||
|
||||
const STEP_LABELS: Record<ForwarderStep, string> = {
|
||||
company: `Step 1 of ${totalSteps} — Company Information`,
|
||||
personnel: `Step 2 of ${totalSteps} — Personnel Details`,
|
||||
poa: `Step 3 of ${totalSteps} — Power of Attorney (Optional)`,
|
||||
documents: `Step 4 of ${totalSteps} — Upload Documents (Optional)`,
|
||||
confirm: `Step 5 of ${totalSteps} — Review & Confirm`,
|
||||
documents: `Step 4 of ${totalSteps} — Upload Documents`,
|
||||
additional: `Step 5 of ${totalSteps} — Business License`,
|
||||
};
|
||||
|
||||
const stepOrder: ForwarderStep[] = ["company", "personnel", "poa", "documents", "confirm"];
|
||||
const stepOrder: ForwarderStep[] = ["company", "personnel", "poa", "documents", "additional"];
|
||||
const currentIdx = stepOrder.indexOf(step);
|
||||
|
||||
return (
|
||||
@@ -222,12 +379,11 @@ export default function ForwarderForm({
|
||||
error={errors.companyEmail?.message}
|
||||
{...register("companyEmail")}
|
||||
/>
|
||||
<PhoneInput
|
||||
countryCode={{ ...register("companyPhoneCountryCode") }}
|
||||
phone={{ ...register("companyPhone"), placeholder: "912345678" }}
|
||||
countryCodeError={errors.companyPhoneCountryCode}
|
||||
phoneError={errors.companyPhone}
|
||||
<ControlledPhoneField
|
||||
control={control}
|
||||
name="companyPhone"
|
||||
label="Company Phone"
|
||||
required
|
||||
/>
|
||||
</SimpleGrid>
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
@@ -280,12 +436,11 @@ export default function ForwarderForm({
|
||||
error={errors.contactPersonName?.message}
|
||||
{...register("contactPersonName")}
|
||||
/>
|
||||
<PhoneInput
|
||||
countryCode={{ ...register("contactPersonPhoneCountryCode") }}
|
||||
phone={{ ...register("contactPersonPhone"), placeholder: "912345678" }}
|
||||
countryCodeError={errors.contactPersonPhoneCountryCode}
|
||||
phoneError={errors.contactPersonPhone}
|
||||
<ControlledPhoneField
|
||||
control={control}
|
||||
name="contactPersonPhone"
|
||||
label="Phone"
|
||||
required
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
@@ -306,12 +461,11 @@ export default function ForwarderForm({
|
||||
error={errors.generalManagerEmail?.message}
|
||||
{...register("generalManagerEmail")}
|
||||
/>
|
||||
<PhoneInput
|
||||
countryCode={{ ...register("generalManagerPhoneCountryCode") }}
|
||||
phone={{ ...register("generalManagerPhone"), placeholder: "912345678" }}
|
||||
countryCodeError={errors.generalManagerPhoneCountryCode}
|
||||
phoneError={errors.generalManagerPhone}
|
||||
<ControlledPhoneField
|
||||
control={control}
|
||||
name="generalManagerPhone"
|
||||
label="Phone"
|
||||
required
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</>
|
||||
@@ -336,11 +490,9 @@ export default function ForwarderForm({
|
||||
error={errors.poaEmail?.message}
|
||||
{...register("poaEmail")}
|
||||
/>
|
||||
<PhoneInput
|
||||
countryCode={{ ...register("poaPhoneCountryCode") }}
|
||||
phone={{ ...register("poaPhone"), placeholder: "912345678" }}
|
||||
countryCodeError={errors.poaPhoneCountryCode}
|
||||
phoneError={errors.poaPhone}
|
||||
<ControlledPhoneField
|
||||
control={control}
|
||||
name="poaPhone"
|
||||
label="PoA Phone"
|
||||
/>
|
||||
</SimpleGrid>
|
||||
@@ -377,52 +529,47 @@ export default function ForwarderForm({
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === "confirm" && (
|
||||
<Box p={16} className="rounded-2xl border border-edr-border bg-edr-card">
|
||||
<Text fw={600} c="edr-text">Review your registration</Text>
|
||||
<Text size="sm" c="edr-muted" mt={4} mb="md">
|
||||
Confirm the company details below before saving.
|
||||
</Text>
|
||||
<SimpleGrid cols={2} spacing="sm">
|
||||
<ReviewRow label="Company name" value={formValues.companyName} />
|
||||
<ReviewRow label="Company email" value={formValues.companyEmail} />
|
||||
<ReviewRow label="Company phone" value={formValues.companyPhone} />
|
||||
<ReviewRow label="Location" value={formValues.companyLocation} />
|
||||
<ReviewRow label="Address" value={formValues.companyAddress} />
|
||||
<ReviewRow label="TIN" value={formValues.tinNumber} />
|
||||
<ReviewRow label="VAT" value={formValues.vatNumber} />
|
||||
<ReviewRow label="FAN" value={formValues.fanNumber} />
|
||||
<ReviewRow label="Contact person" value={formValues.contactPersonName} />
|
||||
<ReviewRow label="Contact phone" value={`${formValues.contactPersonPhoneCountryCode}${formValues.contactPersonPhone}`} />
|
||||
<ReviewRow label="General manager" value={formValues.generalManagerName} />
|
||||
<ReviewRow label="GM email" value={formValues.generalManagerEmail} />
|
||||
<ReviewRow label="GM phone" value={`${formValues.generalManagerPhoneCountryCode}${formValues.generalManagerPhone}`} />
|
||||
<ReviewRow label="PoA name" value={formValues.poaName || undefined} />
|
||||
<ReviewRow label="PoA phone" value={formValues.poaPhone && formValues.poaPhoneCountryCode ? `${formValues.poaPhoneCountryCode}${formValues.poaPhone}` : undefined} />
|
||||
<ReviewRow label="PoA email" value={formValues.poaEmail || undefined} />
|
||||
<ReviewRow label="PoA location" value={formValues.poaLocation || undefined} />
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
{step === "additional" && (
|
||||
<RoleLicenseStep
|
||||
profiles={roleProfiles ?? []}
|
||||
value={licenseFiles ?? {}}
|
||||
onChange={onLicenseChange ?? (() => {})}
|
||||
/>
|
||||
)}
|
||||
|
||||
{saveError && (
|
||||
<Alert
|
||||
color="red"
|
||||
variant="light"
|
||||
icon={<AlertCircle size={18} />}
|
||||
title={step === "additional" ? "Business license required" : "Couldn't save this step"}
|
||||
>
|
||||
{saveError}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Group justify="space-between" pt="xs">
|
||||
<Button variant="default" onClick={prevStep} leftSection={<ArrowLeft size={16} />}>
|
||||
{step === "company" ? "Change Type" : step === "confirm" ? "Back to Documents" : "Back"}
|
||||
</Button>
|
||||
{showBack ? (
|
||||
<Button variant="default" onClick={prevStep} leftSection={<ArrowLeft size={16} />}>
|
||||
{step === "additional" ? "Back to Documents" : "Back"}
|
||||
</Button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<Group gap="sm">
|
||||
{step === "documents" && (
|
||||
<Button variant="default" onClick={skipDocuments} disabled={isPending}>
|
||||
<Button variant="default" onClick={skipDocuments} disabled={isPending || saving}>
|
||||
Skip for now
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
color="edr-green"
|
||||
onClick={step === "confirm" ? handleSubmit((data) => onSubmit(buildPayload(data, user))) : nextStep}
|
||||
disabled={isPending || (step === "documents" && !hasDocuments && loadingDocuments)}
|
||||
loading={isPending}
|
||||
rightSection={!isPending && step !== "confirm" && step !== "documents" ? <ArrowRight size={16} /> : undefined}
|
||||
onClick={nextStep}
|
||||
disabled={isPending || saving || (step === "documents" && !hasDocuments && loadingDocuments)}
|
||||
loading={isPending || saving}
|
||||
rightSection={!isPending && !saving && step !== "additional" && step !== "documents" ? <ArrowRight size={16} /> : undefined}
|
||||
>
|
||||
{step === "documents" ? "Continue" : step === "confirm" ? "Submit Registration" : "Next Step"}
|
||||
{step === "documents" ? "Continue" : step === "additional" ? "Finish onboarding" : "Save & Continue"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
@@ -431,16 +578,3 @@ export default function ForwarderForm({
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ReviewRow({ label, value }: { label: string; value?: string | null }) {
|
||||
return (
|
||||
<Box p={12} className="rounded-xl bg-edr-bg">
|
||||
<Text size="xs" fw={600} c="edr-muted" className="uppercase tracking-wide">
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="sm" fw={500} c={value?.trim() ? "edr-text" : "edr-muted"} mt={4}>
|
||||
{value?.trim() ? value : "Not provided"}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { type FormEvent, useState } from "react";
|
||||
import { ChevronDown, Eye, EyeOff, Mail, Smartphone } from "lucide-react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import RPNInput from "react-phone-number-input";
|
||||
import "react-phone-number-input/style.css";
|
||||
|
||||
import useAuth from "@/hooks/useAuth";
|
||||
import AuthShell, { fieldClass, primaryButtonClass } from "@/components/auth/AuthShell";
|
||||
import "@/components/phone-field.css";
|
||||
|
||||
const EDR_LOGO = "/assets/edr-logo.png";
|
||||
|
||||
@@ -25,7 +28,6 @@ export default function LoginPage() {
|
||||
const { login } = useAuth();
|
||||
const [method, setMethod] = useState<LoginMethod>("email");
|
||||
const [identifier, setIdentifier] = useState("");
|
||||
const [countryCode] = useState("+251");
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -38,11 +40,9 @@ export default function LoginPage() {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const loginId =
|
||||
method === "email"
|
||||
? identifier
|
||||
: `${countryCode}${identifier.startsWith("0") ? identifier.slice(1) : identifier}`;
|
||||
const result = await login({ email: loginId, password });
|
||||
// In phone mode the identifier is already a canonical E.164 string
|
||||
// (e.g. +251912345678) from the phone field; email mode passes through.
|
||||
const result = await login({ email: identifier, password });
|
||||
if (result.success) {
|
||||
const from = (location.state as { from?: { pathname: string } } | null)?.from
|
||||
?.pathname;
|
||||
@@ -79,7 +79,10 @@ export default function LoginPage() {
|
||||
<div className="relative">
|
||||
<select
|
||||
value={method}
|
||||
onChange={(event) => setMethod(event.target.value as LoginMethod)}
|
||||
onChange={(event) => {
|
||||
setMethod(event.target.value as LoginMethod);
|
||||
setIdentifier("");
|
||||
}}
|
||||
disabled={loading}
|
||||
className={`${fieldClass} appearance-none pr-10`}
|
||||
>
|
||||
@@ -97,13 +100,29 @@ export default function LoginPage() {
|
||||
<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}
|
||||
className={fieldClass}
|
||||
/>
|
||||
{method === "phone" ? (
|
||||
<div className="edr-phone-wrapper">
|
||||
<RPNInput
|
||||
international
|
||||
defaultCountry="ET"
|
||||
countryCallingCodeEditable={false}
|
||||
addInternationalOption
|
||||
placeholder="912 345 678"
|
||||
disabled={loading}
|
||||
value={identifier || undefined}
|
||||
onChange={(v) => setIdentifier(v ?? "")}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<input
|
||||
type="email"
|
||||
value={identifier}
|
||||
onChange={(event) => setIdentifier(event.target.value)}
|
||||
placeholder={currentMethod.placeholder}
|
||||
disabled={loading}
|
||||
className={fieldClass}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
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 { Controller, useForm } from "react-hook-form";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { z } from "zod";
|
||||
import RPNInput from "react-phone-number-input";
|
||||
import "react-phone-number-input/style.css";
|
||||
|
||||
import { userType } from "@/enums/userType";
|
||||
import useAuth from "@/hooks/useAuth";
|
||||
import type { SignupPayload } from "@/types/auth";
|
||||
import AuthShell, { fieldClass, primaryButtonClass } from "@/components/auth/AuthShell";
|
||||
import { isValidPhone } from "@/components/PhoneField";
|
||||
import "@/components/phone-field.css";
|
||||
|
||||
const EDR_LOGO = "/assets/edr-logo.png";
|
||||
|
||||
@@ -20,22 +24,13 @@ const passwordRequirements = [
|
||||
{ label: "One special character", test: (v: string) => /[^A-Za-z0-9]/.test(v) },
|
||||
] 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
|
||||
.object({
|
||||
email: z.string().email("Invalid email address"),
|
||||
countryCode: z.literal(ETHIOPIA_COUNTRY_CODE),
|
||||
phone: z
|
||||
.string()
|
||||
.min(1, "Phone number is required")
|
||||
.refine(isValidEthiopianMobile, "Enter a valid mobile number (e.g. 0912345678)"),
|
||||
.refine(isValidPhone, "Enter a valid phone number"),
|
||||
userType: z.string(),
|
||||
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() }),
|
||||
@@ -70,12 +65,12 @@ export default function SignupPage() {
|
||||
register,
|
||||
handleSubmit,
|
||||
watch,
|
||||
control,
|
||||
formState: { errors },
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(userSchema),
|
||||
defaultValues: {
|
||||
email: "",
|
||||
countryCode: ETHIOPIA_COUNTRY_CODE,
|
||||
phone: "",
|
||||
userType: userType.individual,
|
||||
firstName: { en: "", am: "" },
|
||||
@@ -89,12 +84,11 @@ export default function SignupPage() {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const digits = data.phone.replace(/\D/g, "");
|
||||
const normalizedPhone = digits.startsWith("0") ? digits.slice(1) : digits;
|
||||
const payload: SignupPayload = {
|
||||
email: data.email,
|
||||
username: data.email,
|
||||
phoneNumber: `${data.countryCode}${normalizedPhone}`,
|
||||
// Already a canonical E.164 string from the phone field (e.g. +251912345678).
|
||||
phoneNumber: data.phone,
|
||||
userType: data.userType,
|
||||
name: {
|
||||
en: `${data.firstName.en} ${data.lastName.en}`,
|
||||
@@ -183,31 +177,30 @@ export default function SignupPage() {
|
||||
<label htmlFor="signup-phone" className="text-sm font-medium text-gray-800">
|
||||
Phone <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="hidden" {...register("countryCode")} />
|
||||
<div
|
||||
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>
|
||||
<Controller
|
||||
control={control}
|
||||
name="phone"
|
||||
render={({ field }) => (
|
||||
<div
|
||||
className={`edr-phone-wrapper${
|
||||
errors.phone ? " edr-phone-wrapper--error" : ""
|
||||
}`}
|
||||
>
|
||||
<RPNInput
|
||||
international
|
||||
defaultCountry="ET"
|
||||
countryCallingCodeEditable={false}
|
||||
addInternationalOption
|
||||
id="signup-phone"
|
||||
placeholder="912 345 678"
|
||||
disabled={loading}
|
||||
value={field.value || undefined}
|
||||
onChange={(v) => field.onChange(v ?? "")}
|
||||
onBlur={field.onBlur}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{errorText(errors.phone?.message)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -9,8 +9,10 @@ import { paymentsService, type PaymentMethod } from "@/services/payments.service
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { ActivityCard } from "./components/ActivityCard";
|
||||
import { ContainersCard } from "./components/ContainersCard";
|
||||
import { ContractCard } from "./components/ContractCard";
|
||||
import { DocRow, IconSquare } from "./components/Documents";
|
||||
import { KeyFactsStrip } from "./components/KeyFactsStrip";
|
||||
import { BodyGrid, CardTitle, PageShell, SectionCard } from "./components/layout";
|
||||
import {
|
||||
CancelledBanner,
|
||||
@@ -48,8 +50,15 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
|
||||
});
|
||||
|
||||
const pricing = booking.pricingBreakdown;
|
||||
// A general contract is paid once it's FULLY_EXECUTED (signed) — it never
|
||||
// enters batch selection. A one-time booking can only pay once it's been
|
||||
// SELECTED_FOR_BATCH (assigned a slot with a pay window).
|
||||
const isGeneralContract = booking.bookingType === "GENERAL_CONTRACT";
|
||||
const canPay =
|
||||
status === "SELECTED_FOR_BATCH" && booking.paymentStatus !== "PAID";
|
||||
booking.paymentStatus !== "PAID" &&
|
||||
(isGeneralContract
|
||||
? status === "FULLY_EXECUTED"
|
||||
: status === "SELECTED_FOR_BATCH");
|
||||
const showCountdown = canPay && !!booking.paymentDeadline;
|
||||
const isExpired = status === "EXPIRED";
|
||||
const isPendingConsolidation = status === "PENDING_CONSOLIDATION";
|
||||
@@ -113,6 +122,8 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
|
||||
|
||||
{showPairedNotice && <ConsolidationPairedNotice />}
|
||||
|
||||
<KeyFactsStrip booking={booking} />
|
||||
|
||||
<ContractCard booking={booking} navigate={navigate} />
|
||||
|
||||
<BodyGrid
|
||||
@@ -120,6 +131,8 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
|
||||
<>
|
||||
<ShipmentDetailsCard booking={booking} />
|
||||
|
||||
<ContainersCard booking={booking} />
|
||||
|
||||
{booking.files && booking.files.length > 0 && (
|
||||
<SectionCard>
|
||||
<Group justify="space-between" align="center" mb="md">
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { Box, Group, Table, Text } from "@mantine/core";
|
||||
import { Boxes } from "lucide-react";
|
||||
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { CardTitle, SectionCard } from "./layout";
|
||||
|
||||
/**
|
||||
* Per-container-type breakdown for container bookings (count, type, VGM).
|
||||
* Renders nothing for bulk bookings, which have no container lines.
|
||||
*/
|
||||
export function ContainersCard({ booking }: { booking: Freight.IBooking }) {
|
||||
const containers = booking.containers ?? [];
|
||||
if (booking.freightType === "BULK" || containers.length === 0) return null;
|
||||
|
||||
const totalUnits = containers.reduce((sum, c) => sum + Number(c.qty || 0), 0);
|
||||
const totalVgm = containers.reduce(
|
||||
(sum, c) => sum + Number(c.vgm || 0) * Number(c.qty || 0),
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<SectionCard>
|
||||
<Group justify="space-between" align="center" mb="md">
|
||||
<Group gap={8} align="center">
|
||||
<Boxes size={18} color="#0A6F4D" />
|
||||
<CardTitle>Containers</CardTitle>
|
||||
</Group>
|
||||
<Text fz="12.5px" fw={600} c="#9AA8B5">
|
||||
{totalUnits} unit{totalUnits !== 1 ? "s" : ""}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Table verticalSpacing="sm" horizontalSpacing={0}>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th style={{ color: "#9AA8B5", fontSize: 11.5 }}>Type</Table.Th>
|
||||
<Table.Th style={{ color: "#9AA8B5", fontSize: 11.5 }}>Qty</Table.Th>
|
||||
<Table.Th style={{ color: "#9AA8B5", fontSize: 11.5 }}>VGM / unit</Table.Th>
|
||||
<Table.Th style={{ color: "#9AA8B5", fontSize: 11.5, textAlign: "right" }}>
|
||||
Total VGM
|
||||
</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{containers.map((c, i) => {
|
||||
const lineVgm = Number(c.vgm || 0) * Number(c.qty || 0);
|
||||
return (
|
||||
<Table.Tr key={`${c.type}-${i}`}>
|
||||
<Table.Td>
|
||||
<Text fz={14} fw={700} c="#10202F">
|
||||
{c.type}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz={14} c="#10202F">
|
||||
{c.qty}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz={14} c="#475569">
|
||||
{c.vgm ? `${c.vgm} t` : "—"}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz={14} fw={700} c="#10202F" ta="right">
|
||||
{lineVgm ? `${lineVgm.toLocaleString()} t` : "—"}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
|
||||
<Box
|
||||
mt="sm"
|
||||
pt="sm"
|
||||
style={{ borderTop: "1px solid #F2F5F8", display: "flex", justifyContent: "space-between" }}
|
||||
>
|
||||
<Text fz={13} fw={600} c="#475569">
|
||||
Total weight (VGM)
|
||||
</Text>
|
||||
<Text fz={14} fw={800} c="#0A6F4D">
|
||||
{totalVgm.toLocaleString()} t
|
||||
</Text>
|
||||
</Box>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { Box, Group, SimpleGrid, Text } from "@mantine/core";
|
||||
import {
|
||||
CalendarClock,
|
||||
CreditCard,
|
||||
MapPin,
|
||||
Package,
|
||||
Tag,
|
||||
Train,
|
||||
} from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { fmtDate, yardLabel } from "../utils";
|
||||
import { SectionCard } from "./layout";
|
||||
|
||||
type BookingLike = Freight.IBooking & {
|
||||
bookingType?: string;
|
||||
paymentStatus?: string;
|
||||
trainScheduleId?: string | null;
|
||||
};
|
||||
|
||||
function Fact({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
value: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Group gap={10} wrap="nowrap" align="flex-start">
|
||||
<Box
|
||||
style={{
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 9,
|
||||
flexShrink: 0,
|
||||
background: "#F1F6FA",
|
||||
color: "#0A6F4D",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</Box>
|
||||
<Box miw={0}>
|
||||
<Text fz="11.5px" fw={600} c="#9AA8B5">
|
||||
{label}
|
||||
</Text>
|
||||
<Text mt={2} fz="14px" fw={700} c="#10202F" truncate>
|
||||
{value}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact at-a-glance facts strip at the top of the booking detail page — gives
|
||||
* a fast scan of the key attributes before the deeper cards below.
|
||||
*/
|
||||
export function KeyFactsStrip({ booking }: { booking: BookingLike }) {
|
||||
const isContract = booking.bookingType === "GENERAL_CONTRACT";
|
||||
const freight = booking.freightType === "BULK" ? "Bulk" : "Container";
|
||||
const payment = booking.paymentStatus
|
||||
? booking.paymentStatus
|
||||
.replace(/_/g, " ")
|
||||
.toLowerCase()
|
||||
.replace(/^\w/, (c) => c.toUpperCase())
|
||||
: "—";
|
||||
|
||||
return (
|
||||
<SectionCard p="md">
|
||||
<SimpleGrid cols={{ base: 1, xs: 2, md: 3, xl: 6 }} spacing="lg">
|
||||
<Fact
|
||||
icon={<Tag size={17} />}
|
||||
label="Type"
|
||||
value={isContract ? "General Contract" : "One-Time"}
|
||||
/>
|
||||
<Fact icon={<Package size={17} />} label="Cargo" value={freight} />
|
||||
<Fact
|
||||
icon={<MapPin size={17} />}
|
||||
label="Route"
|
||||
value={`${yardLabel(booking.originYard)} → ${yardLabel(booking.destinationYard)}`}
|
||||
/>
|
||||
<Fact icon={<CreditCard size={17} />} label="Payment" value={payment} />
|
||||
<Fact
|
||||
icon={<Train size={17} />}
|
||||
label="Train"
|
||||
value={booking.trainScheduleId ? "Assigned" : "Not assigned"}
|
||||
/>
|
||||
<Fact
|
||||
icon={<CalendarClock size={17} />}
|
||||
label={isContract ? "Ordering until" : "Scheduled"}
|
||||
value={
|
||||
isContract
|
||||
? fmtDate(booking.expiresAt ?? null)
|
||||
: fmtDate(booking.scheduledDate)
|
||||
}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -34,6 +34,13 @@ import {
|
||||
|
||||
import { ShipmentTrackingModal } from "./tracking/ShipmentTrackingModal";
|
||||
import { PayNowButton } from "./payments/PayNowButton";
|
||||
import { ModeIndicator } from "@/components/ModeIndicator";
|
||||
import {
|
||||
BookingTypeBadge,
|
||||
CargoModeCell,
|
||||
PaymentBadge,
|
||||
SchedulingCell,
|
||||
} from "./booking-display";
|
||||
|
||||
// Bookings that have left (or are leaving) the yard can be tracked live.
|
||||
const TRACKABLE_STATUSES = new Set([
|
||||
@@ -173,6 +180,9 @@ function PrimaryAction({
|
||||
}) {
|
||||
const { status, id } = booking;
|
||||
const go = () => onNavigate(`/bookings/${id}`);
|
||||
// A general contract is payable as soon as it's FULLY_EXECUTED (signed); a
|
||||
// one-time booking only after it's SELECTED_FOR_BATCH.
|
||||
const isGeneralContract = booking.bookingType === "GENERAL_CONTRACT";
|
||||
if (status === "DRAFT") {
|
||||
return (
|
||||
<Button
|
||||
@@ -203,7 +213,10 @@ function PrimaryAction({
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
if (status === "SELECTED_FOR_BATCH" && booking.paymentStatus !== "PAID") {
|
||||
const payableStatus = isGeneralContract
|
||||
? "FULLY_EXECUTED"
|
||||
: "SELECTED_FOR_BATCH";
|
||||
if (status === payableStatus && booking.paymentStatus !== "PAID") {
|
||||
return <PayNowButton booking={booking} />;
|
||||
}
|
||||
return (
|
||||
@@ -320,24 +333,54 @@ export default function MyBookings() {
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilterKey>("all");
|
||||
const [query, setQuery] = useState("");
|
||||
const [typeFilter, setTypeFilter] = useState<string | null>(null);
|
||||
const [freightFilter, setFreightFilter] = useState<string | null>(null);
|
||||
const [createdFrom, setCreatedFrom] = useState<string>("");
|
||||
const [createdTo, setCreatedTo] = useState<string>("");
|
||||
const [trackingBooking, setTrackingBooking] = useState<Freight.IBooking | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const statuses = STATUS_FILTERS.find((t) => t.key === statusFilter)?.statuses;
|
||||
|
||||
const resetPage = () =>
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
|
||||
const selectFilter = (key: StatusFilterKey) => {
|
||||
setStatusFilter(key);
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
resetPage();
|
||||
};
|
||||
|
||||
const hasExtraFilters =
|
||||
!!typeFilter || !!freightFilter || !!createdFrom || !!createdTo;
|
||||
const clearExtraFilters = () => {
|
||||
setTypeFilter(null);
|
||||
setFreightFilter(null);
|
||||
setCreatedFrom("");
|
||||
setCreatedTo("");
|
||||
resetPage();
|
||||
};
|
||||
|
||||
const filter: BookingListFilter = useMemo(
|
||||
() => ({
|
||||
statuses,
|
||||
bookingType: typeFilter ?? undefined,
|
||||
freightType: freightFilter ?? undefined,
|
||||
createdFrom: createdFrom || undefined,
|
||||
// include the whole selected end day
|
||||
createdTo: createdTo ? `${createdTo}T23:59:59.999Z` : undefined,
|
||||
page: pagination.pageIndex + 1,
|
||||
pageSize: pagination.pageSize,
|
||||
}),
|
||||
[statuses, pagination.pageIndex, pagination.pageSize],
|
||||
[
|
||||
statuses,
|
||||
typeFilter,
|
||||
freightFilter,
|
||||
createdFrom,
|
||||
createdTo,
|
||||
pagination.pageIndex,
|
||||
pagination.pageSize,
|
||||
],
|
||||
);
|
||||
|
||||
const { data, isLoading, isError } = useQuery(
|
||||
@@ -358,14 +401,20 @@ export default function MyBookings() {
|
||||
const doneCount = useStatusCount(
|
||||
STATUS_FILTERS.find((f) => f.key === "done")!.statuses,
|
||||
);
|
||||
const transitCount = useStatusCount(
|
||||
STATUS_FILTERS.find((f) => f.key === "transit")!.statuses,
|
||||
);
|
||||
const closedCount = useStatusCount(
|
||||
STATUS_FILTERS.find((f) => f.key === "closed")!.statuses,
|
||||
);
|
||||
const cardCounts: Record<StatusFilterKey, number | undefined> = {
|
||||
all: allCount,
|
||||
active: activeCount,
|
||||
payment: paymentCount,
|
||||
draft: draftCount,
|
||||
done: doneCount,
|
||||
transit: undefined,
|
||||
closed: undefined,
|
||||
transit: transitCount,
|
||||
closed: closedCount,
|
||||
};
|
||||
|
||||
const allItems = data?.items ?? [];
|
||||
@@ -424,6 +473,20 @@ export default function MyBookings() {
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "type",
|
||||
size: 150,
|
||||
meta: hMeta,
|
||||
header: () => <ColHeader label="Type" />,
|
||||
cell: ({ row }) => <BookingTypeBadge booking={row.original} />,
|
||||
},
|
||||
{
|
||||
id: "cargo",
|
||||
size: 168,
|
||||
meta: hMeta,
|
||||
header: () => <ColHeader label="Cargo" />,
|
||||
cell: ({ row }) => <CargoModeCell booking={row.original} />,
|
||||
},
|
||||
{
|
||||
id: "route",
|
||||
size: 196,
|
||||
@@ -448,6 +511,20 @@ export default function MyBookings() {
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "payment",
|
||||
size: 130,
|
||||
meta: hMeta,
|
||||
header: () => <ColHeader label="Payment" />,
|
||||
cell: ({ row }) => <PaymentBadge status={row.original.paymentStatus} />,
|
||||
},
|
||||
{
|
||||
id: "scheduling",
|
||||
size: 140,
|
||||
meta: hMeta,
|
||||
header: () => <ColHeader label="Train" />,
|
||||
cell: ({ row }) => <SchedulingCell booking={row.original} />,
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
size: 190,
|
||||
@@ -536,9 +613,12 @@ export default function MyBookings() {
|
||||
{/* ── Page header ─────────────────────────────────────────────── */}
|
||||
<Group justify="space-between" align="flex-end" wrap="wrap" gap="md">
|
||||
<Box>
|
||||
<Title order={1} fw={800} fz={26} style={{ letterSpacing: "-0.01em" }}>
|
||||
Bookings
|
||||
</Title>
|
||||
<Group gap={10} align="center">
|
||||
<Title order={1} fw={800} fz={26} style={{ letterSpacing: "-0.01em" }}>
|
||||
Bookings
|
||||
</Title>
|
||||
<ModeIndicator />
|
||||
</Group>
|
||||
<Text size="sm" c="edr-muted" mt={4}>
|
||||
Track every cargo booking — from draft to delivery.
|
||||
</Text>
|
||||
@@ -606,9 +686,79 @@ export default function MyBookings() {
|
||||
radius="md"
|
||||
checkIconPosition="right"
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
style={{ width: 200 }}
|
||||
style={{ width: 190 }}
|
||||
aria-label="Filter by status"
|
||||
/>
|
||||
<Select
|
||||
placeholder="Any type"
|
||||
data={[
|
||||
{ value: "ONE_TIME", label: "One-time" },
|
||||
{ value: "GENERAL_CONTRACT", label: "General contract" },
|
||||
]}
|
||||
value={typeFilter}
|
||||
onChange={(v) => {
|
||||
setTypeFilter(v);
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
radius="md"
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
style={{ width: 170 }}
|
||||
aria-label="Filter by booking type"
|
||||
/>
|
||||
<Select
|
||||
placeholder="Any cargo"
|
||||
data={[
|
||||
{ value: "CONTAINER", label: "Container" },
|
||||
{ value: "BULK", label: "Bulk" },
|
||||
]}
|
||||
value={freightFilter}
|
||||
onChange={(v) => {
|
||||
setFreightFilter(v);
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
radius="md"
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
style={{ width: 150 }}
|
||||
aria-label="Filter by cargo type"
|
||||
/>
|
||||
<TextInput
|
||||
type="date"
|
||||
value={createdFrom}
|
||||
onChange={(e) => {
|
||||
setCreatedFrom(e.currentTarget.value);
|
||||
resetPage();
|
||||
}}
|
||||
radius="md"
|
||||
style={{ width: 150 }}
|
||||
aria-label="Created from"
|
||||
placeholder="From"
|
||||
/>
|
||||
<TextInput
|
||||
type="date"
|
||||
value={createdTo}
|
||||
onChange={(e) => {
|
||||
setCreatedTo(e.currentTarget.value);
|
||||
resetPage();
|
||||
}}
|
||||
radius="md"
|
||||
style={{ width: 150 }}
|
||||
aria-label="Created to"
|
||||
placeholder="To"
|
||||
/>
|
||||
{hasExtraFilters && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
radius="md"
|
||||
size="sm"
|
||||
leftSection={<X size={14} />}
|
||||
onClick={clearExtraFilters}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
<Text fz={12} c="edr-muted">
|
||||
{total} booking{total !== 1 ? "s" : ""}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { api } from "@/services/api";
|
||||
import { Freight } from "@edr/types";
|
||||
import { hasAllRequiredDocuments } from "@/services/booking-form-data";
|
||||
import type {
|
||||
CreateBookingPayload,
|
||||
@@ -175,6 +176,30 @@ export default function NewBookingPage() {
|
||||
|
||||
const originYard = form.watch("originYard");
|
||||
const destinationYard = form.watch("destinationYard");
|
||||
const bookingType = form.watch("bookingType");
|
||||
const isGeneralContract = bookingType === "general_contract";
|
||||
|
||||
// General contracts have no shipment date at creation — the Schedule step
|
||||
// (id 5) is skipped; the date is chosen per order against the contract later.
|
||||
const visibleSteps = useMemo(
|
||||
() => STEPS.filter((s) => !(isGeneralContract && s.id === 5)),
|
||||
[isGeneralContract],
|
||||
);
|
||||
const visibleStepIds = useMemo<number[]>(
|
||||
() => visibleSteps.map((s) => s.id),
|
||||
[visibleSteps],
|
||||
);
|
||||
const currentStepIndex = visibleStepIds.indexOf(step);
|
||||
const isLastStep = currentStepIndex === visibleStepIds.length - 1;
|
||||
const isFirstStep = currentStepIndex <= 0;
|
||||
const goToStep = (delta: number) => {
|
||||
const idx = visibleStepIds.indexOf(step);
|
||||
const nextIdx = Math.min(
|
||||
visibleStepIds.length - 1,
|
||||
Math.max(0, idx + delta),
|
||||
);
|
||||
setStep(visibleStepIds[nextIdx]);
|
||||
};
|
||||
|
||||
const direction = useMemo(() => {
|
||||
const origin = referenceData?.yard.find((y) => y.id === originYard);
|
||||
@@ -210,7 +235,7 @@ export default function NewBookingPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
setStep((currentStep) => Math.min(STEPS.length, currentStep + 1));
|
||||
goToStep(1);
|
||||
}
|
||||
|
||||
function buildApiPayload(data: BookingFormValues): CreateBookingPayload {
|
||||
@@ -259,10 +284,20 @@ export default function NewBookingPage() {
|
||||
(s) => s.id === data.serviceTypeId,
|
||||
)!;
|
||||
|
||||
const isContract = data.bookingType === "general_contract";
|
||||
|
||||
return {
|
||||
scheduledDate: data.scheduledDate
|
||||
? new Date(data.scheduledDate).toISOString()
|
||||
: new Date().toISOString(),
|
||||
bookingType: isContract
|
||||
? Freight.BookingType.GeneralContract
|
||||
: Freight.BookingType.OneTime,
|
||||
// General contracts omit the shipment date — chosen per order later.
|
||||
...(isContract
|
||||
? {}
|
||||
: {
|
||||
scheduledDate: data.scheduledDate
|
||||
? new Date(data.scheduledDate).toISOString()
|
||||
: new Date().toISOString(),
|
||||
}),
|
||||
contractType:
|
||||
data.contractType.toUpperCase() as CreateBookingPayload["contractType"],
|
||||
serviceTypeId: data.serviceTypeId,
|
||||
@@ -408,7 +443,7 @@ export default function NewBookingPage() {
|
||||
>
|
||||
<Box flex={1} p="24px">
|
||||
<Box mb="lg">
|
||||
<StepIndicator step={step} />
|
||||
<StepIndicator step={step} steps={visibleSteps} />
|
||||
</Box>
|
||||
|
||||
{persistAndPriceMutation.isError && (
|
||||
@@ -494,13 +529,13 @@ export default function NewBookingPage() {
|
||||
variant="default"
|
||||
radius="md"
|
||||
leftSection={<ChevronLeft size={16} />}
|
||||
onClick={() => setStep((s) => Math.max(1, s - 1))}
|
||||
disabled={step === 1}
|
||||
onClick={() => goToStep(-1)}
|
||||
disabled={isFirstStep}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
|
||||
{step < STEPS.length ? (
|
||||
{!isLastStep ? (
|
||||
<Button
|
||||
type="button"
|
||||
color="edr-green"
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { Badge, Group, Text } from "@mantine/core";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
/**
|
||||
* Shared presentation helpers for booking-like rows (one-time bookings AND
|
||||
* general contracts). Kept in one place so the bookings list, contracts list,
|
||||
* and detail page render type/freight/mode/payment consistently.
|
||||
*/
|
||||
|
||||
type BookingLike = Freight.IBooking & {
|
||||
bookingType?: string;
|
||||
freightType?: string;
|
||||
tradeDirection?: string;
|
||||
paymentStatus?: string;
|
||||
};
|
||||
|
||||
/** One-Time vs General Contract. */
|
||||
export function BookingTypeBadge({ booking }: { booking: BookingLike }) {
|
||||
const isContract = booking.bookingType === "GENERAL_CONTRACT";
|
||||
return (
|
||||
<Badge
|
||||
variant="light"
|
||||
radius="sm"
|
||||
color={isContract ? "violet" : "gray"}
|
||||
styles={{ root: { textTransform: "none", fontWeight: 600, letterSpacing: 0 } }}
|
||||
>
|
||||
{isContract ? "General Contract" : "One-Time"}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
/** Containerised vs Bulk, plus the trade direction (Import/Export/Domestic). */
|
||||
export function CargoModeCell({ booking }: { booking: BookingLike }) {
|
||||
const freight =
|
||||
booking.freightType === "BULK" ? "Bulk" : "Container";
|
||||
const dir = booking.tradeDirection
|
||||
? booking.tradeDirection.charAt(0) + booking.tradeDirection.slice(1).toLowerCase()
|
||||
: null;
|
||||
return (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Badge
|
||||
variant="light"
|
||||
radius="sm"
|
||||
color={booking.freightType === "BULK" ? "orange" : "teal"}
|
||||
styles={{ root: { textTransform: "none", fontWeight: 600, letterSpacing: 0 } }}
|
||||
>
|
||||
{freight}
|
||||
</Badge>
|
||||
{dir && (
|
||||
<Text fz={12} c="edr-muted">
|
||||
{dir}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
const PAYMENT_COLORS: Record<string, string> = {
|
||||
PAID: "green",
|
||||
PENDING: "gray",
|
||||
PNR_GENERATED: "blue",
|
||||
VERIFICATION_IN_PROGRESS: "yellow",
|
||||
FAILED: "red",
|
||||
};
|
||||
|
||||
const PAYMENT_LABELS: Record<string, string> = {
|
||||
PAID: "Paid",
|
||||
PENDING: "Pending",
|
||||
PNR_GENERATED: "PNR generated",
|
||||
VERIFICATION_IN_PROGRESS: "Verifying",
|
||||
FAILED: "Failed",
|
||||
};
|
||||
|
||||
/** Payment status pill. */
|
||||
export function PaymentBadge({ status }: { status?: string | null }) {
|
||||
if (!status) return <Text fz={13} c="dimmed">—</Text>;
|
||||
return (
|
||||
<Badge
|
||||
variant="light"
|
||||
radius="sm"
|
||||
color={PAYMENT_COLORS[status] ?? "gray"}
|
||||
styles={{ root: { textTransform: "none", fontWeight: 600, letterSpacing: 0 } }}
|
||||
>
|
||||
{PAYMENT_LABELS[status] ?? status.replace(/_/g, " ")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether a booking is assigned to a train yet (scheduling progress). */
|
||||
export function SchedulingCell({ booking }: { booking: BookingLike & { trainScheduleId?: string | null; schedulingStatus?: string } }) {
|
||||
const assigned = !!booking.trainScheduleId;
|
||||
const label = assigned
|
||||
? "Assigned"
|
||||
: booking.schedulingStatus === "HOLDING"
|
||||
? "Holding"
|
||||
: booking.schedulingStatus === "ELIGIBLE"
|
||||
? "Eligible"
|
||||
: "Not scheduled";
|
||||
return (
|
||||
<Badge
|
||||
variant="dot"
|
||||
radius="sm"
|
||||
color={assigned ? "green" : "gray"}
|
||||
styles={{ root: { textTransform: "none", fontWeight: 600, letterSpacing: 0 } }}
|
||||
>
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -8,10 +8,18 @@ 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 }) {
|
||||
type StepItem = (typeof STEPS)[number];
|
||||
|
||||
export function StepIndicator({
|
||||
step,
|
||||
steps = STEPS as readonly StepItem[],
|
||||
}: {
|
||||
step: number;
|
||||
steps?: readonly StepItem[];
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start">
|
||||
{STEPS.map((item, index) => {
|
||||
{steps.map((item, index) => {
|
||||
const done = step > item.id;
|
||||
const active = step === item.id;
|
||||
return (
|
||||
@@ -67,7 +75,7 @@ export function StepIndicator({ step }: { step: number }) {
|
||||
{item.short}
|
||||
</span>
|
||||
</div>
|
||||
{index < STEPS.length - 1 && (
|
||||
{index < steps.length - 1 && (
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
|
||||
@@ -81,8 +81,13 @@ export const PAYMENT_CURRENCY_OPTIONS: Array<{
|
||||
},
|
||||
];
|
||||
|
||||
export const BOOKING_TYPES = ["one_time", "general_contract"] as const;
|
||||
export type BookingTypeOption = (typeof BOOKING_TYPES)[number];
|
||||
|
||||
export const bookingFormSchema = z
|
||||
.object({
|
||||
// One-time booking vs. a general contract (umbrella, drawn down by orders).
|
||||
bookingType: z.enum(BOOKING_TYPES).default("one_time"),
|
||||
contractType: z.enum(["new", "renewal"], "Select a contract type."),
|
||||
previousContractRef: z.string(),
|
||||
serviceTypeId: z.string("Select a service type."),
|
||||
@@ -115,7 +120,9 @@ export const bookingFormSchema = z
|
||||
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."),
|
||||
// Optional in the base schema — required for one-time bookings via the
|
||||
// superRefine below; general contracts pick the date per order instead.
|
||||
scheduledDate: z.string().default(""),
|
||||
cargoType: z.enum(["container", "bulk"], "Select a cargo type."),
|
||||
cargoWeight: z.string(),
|
||||
cargoTypePath: z.array(z.string()).default([]),
|
||||
@@ -188,6 +195,14 @@ export const bookingFormSchema = z
|
||||
{ message: "Add at least one container.", path: ["containers"] },
|
||||
)
|
||||
.superRefine((data, ctx) => {
|
||||
// One-time bookings must pick a shipment date; general contracts must not.
|
||||
if (data.bookingType !== "general_contract" && !data.scheduledDate.trim()) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["scheduledDate"],
|
||||
message: "Select a shipment date.",
|
||||
});
|
||||
}
|
||||
if (data.cargoType === "bulk") {
|
||||
if (!data.cargoTypePath[0]) {
|
||||
ctx.addIssue({
|
||||
@@ -222,6 +237,7 @@ export type BookingFormValues = z.infer<typeof bookingFormSchema>;
|
||||
export type BookingFormInputValues = z.input<typeof bookingFormSchema>;
|
||||
|
||||
export const initialBookingFormValues: DeepPartial<BookingFormValues> = {
|
||||
bookingType: "one_time",
|
||||
previousContractRef: "",
|
||||
|
||||
serviceTypeId: "",
|
||||
@@ -252,7 +268,7 @@ export const initialBookingFormValues: DeepPartial<BookingFormValues> = {
|
||||
};
|
||||
|
||||
export const stepFields: Record<number, Array<Path<BookingFormValues>>> = {
|
||||
1: ["contractType", "previousContractRef"],
|
||||
1: ["bookingType", "contractType", "previousContractRef"],
|
||||
2: [
|
||||
"serviceTypeId",
|
||||
"paymentCurrency",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { api } from "@/services/api";
|
||||
import type { Freight } from "@edr/types";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { FileText, RefreshCw } from "lucide-react";
|
||||
import { CalendarClock, FileText, Layers, RefreshCw } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Controller, type UseFormReturn } from "react-hook-form";
|
||||
import { BookingFormInputValues, type BookingFormValues } from "./schema";
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
StepHeader,
|
||||
} from "./shared";
|
||||
import { FileSignature } from "lucide-react";
|
||||
import { Stack } from "@mantine/core";
|
||||
import { Divider, Stack, Text } from "@mantine/core";
|
||||
|
||||
type BookingForm = UseFormReturn<
|
||||
BookingFormInputValues,
|
||||
@@ -128,7 +128,7 @@ export function Step1ContractType({
|
||||
(sl) => sl.id === booking.shippingLineId,
|
||||
);
|
||||
if (shippingLine) {
|
||||
form.setValue("shippingLine", shippingLine.name);
|
||||
form.setValue("shippingLine", shippingLine.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,10 +187,46 @@ export function Step1ContractType({
|
||||
<StepCard>
|
||||
<StepHeader
|
||||
icon={<FileSignature size={22} />}
|
||||
title="Contract Type"
|
||||
description="Start a new contract or renew an existing one to reuse its details."
|
||||
title="Booking Type"
|
||||
description="Choose a one-time shipment or a general contract you can draw down from over time."
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="bookingType"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<OptionCard
|
||||
selected={field.value !== "general_contract"}
|
||||
icon={<CalendarClock className="h-5 w-5" />}
|
||||
iconBg="#ECF6F1"
|
||||
iconColor="#0A6F4D"
|
||||
title="One-Time Booking"
|
||||
description="A single shipment with a chosen ship date — the standard flow."
|
||||
onClick={() => field.onChange("one_time")}
|
||||
/>
|
||||
<OptionCard
|
||||
selected={field.value === "general_contract"}
|
||||
icon={<Layers className="h-5 w-5" />}
|
||||
iconBg="#F1ECFB"
|
||||
iconColor="#6A40B8"
|
||||
title="General Contract"
|
||||
description="Reserve a total quantity once, then place multiple orders against it until it runs out."
|
||||
onClick={() => field.onChange("general_contract")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Divider my={24} />
|
||||
|
||||
<Text fw={700} fz={15} mb={4} style={{ color: "#10202F" }}>
|
||||
Contract Type
|
||||
</Text>
|
||||
<Text fz={13} c="edr-muted" mb={16}>
|
||||
Start a fresh contract or renew an existing one to reuse its details.
|
||||
</Text>
|
||||
|
||||
<Controller
|
||||
name="contractType"
|
||||
control={form.control}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user