diff --git a/.gitignore b/.gitignore index 3f4bed8a2..0e3f0986f 100644 --- a/.gitignore +++ b/.gitignore @@ -24,11 +24,3 @@ coverage/ .idea/ .vscode/ .npmrc - -# emacs cache files -*~ -\#*\# -.\#* -branch_structure.json -temp_auto_push.bat -temp_interactive_push.bat diff --git a/WagonForm.tsx b/WagonForm.tsx deleted file mode 100644 index e69de29bb..000000000 diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index 9c2a8ad78..897a7b764 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -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:*", diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 5f7164c12..792a268ed 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -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, diff --git a/apps/edr-freight-api/src/common/validators/is-phone-number.validator.ts b/apps/edr-freight-api/src/common/validators/is-phone-number.validator.ts new file mode 100644 index 000000000..4f8dac4c7 --- /dev/null +++ b/apps/edr-freight-api/src/common/validators/is-phone-number.validator.ts @@ -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(); +} diff --git a/apps/edr-freight-api/src/config/app.config.ts b/apps/edr-freight-api/src/config/app.config.ts index fa8644945..e493cc393 100644 --- a/apps/edr-freight-api/src/config/app.config.ts +++ b/apps/edr-freight-api/src/config/app.config.ts @@ -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), }, diff --git a/apps/edr-freight-api/src/migrations/1791000000000-AddActiveModeAndOnboardingToExternalProfiles.ts b/apps/edr-freight-api/src/migrations/1791000000000-AddActiveModeAndOnboardingToExternalProfiles.ts new file mode 100644 index 000000000..8484ca0f9 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1791000000000-AddActiveModeAndOnboardingToExternalProfiles.ts @@ -0,0 +1,67 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddActiveModeAndOnboardingToExternalProfiles1791000000000 + implements MigrationInterface +{ + name = 'AddActiveModeAndOnboardingToExternalProfiles1791000000000'; + + public async up(queryRunner: QueryRunner): Promise { + 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 { + 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; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1791000000001-AddCompanyProfileIdToBookings.ts b/apps/edr-freight-api/src/migrations/1791000000001-AddCompanyProfileIdToBookings.ts new file mode 100644 index 000000000..0ca1a0d28 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1791000000001-AddCompanyProfileIdToBookings.ts @@ -0,0 +1,94 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddCompanyProfileIdToBookings1791000000001 + implements MigrationInterface +{ + name = 'AddCompanyProfileIdToBookings1791000000001'; + + public async up(queryRunner: QueryRunner): Promise { + 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 { + 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; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1791000000002-AddNationalityToCompanies.ts b/apps/edr-freight-api/src/migrations/1791000000002-AddNationalityToCompanies.ts new file mode 100644 index 000000000..1a05a9e45 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1791000000002-AddNationalityToCompanies.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddNationalityToCompanies1791000000002 + implements MigrationInterface +{ + name = "AddNationalityToCompanies1791000000002"; + + public async up(queryRunner: QueryRunner): Promise { + 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 { + await queryRunner.query(` + ALTER TABLE freight.companies + DROP COLUMN IF EXISTS nationality; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1791000000003-AddBusinessLicenseFilesToCompanyProfiles.ts b/apps/edr-freight-api/src/migrations/1791000000003-AddBusinessLicenseFilesToCompanyProfiles.ts new file mode 100644 index 000000000..d1e0412f0 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1791000000003-AddBusinessLicenseFilesToCompanyProfiles.ts @@ -0,0 +1,21 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddBusinessLicenseFilesToCompanyProfiles1791000000003 + implements MigrationInterface +{ + name = "AddBusinessLicenseFilesToCompanyProfiles1791000000003"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.company_profiles + ADD COLUMN IF NOT EXISTS business_license_files jsonb; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.company_profiles + DROP COLUMN IF EXISTS business_license_files; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1791000000003-AddETradeFieldsToCompanies.ts b/apps/edr-freight-api/src/migrations/1791000000003-AddETradeFieldsToCompanies.ts new file mode 100644 index 000000000..07f0d2555 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1791000000003-AddETradeFieldsToCompanies.ts @@ -0,0 +1,109 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddETradeFieldsToCompanies1791000000003 + implements MigrationInterface +{ + name = "AddETradeFieldsToCompanies1791000000003"; + + public async up(queryRunner: QueryRunner): Promise { + 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 { + 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; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1791999999999-CreateDropdownSettings.ts b/apps/edr-freight-api/src/migrations/1791999999999-CreateDropdownSettings.ts new file mode 100644 index 000000000..d86efa239 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1791999999999-CreateDropdownSettings.ts @@ -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 { + 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 { + await queryRunner.query( + `DROP TABLE IF EXISTS "freight"."dropdown_options";`, + ); + await queryRunner.query( + `DROP TABLE IF EXISTS "freight"."dropdown_settings";`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/1792000000000-AddUnitOfMeasureToCargoTypes.ts b/apps/edr-freight-api/src/migrations/1792000000000-AddUnitOfMeasureToCargoTypes.ts new file mode 100644 index 000000000..6ec9d60cd --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1792000000000-AddUnitOfMeasureToCargoTypes.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddUnitOfMeasureToCargoTypes1792000000000 + implements MigrationInterface +{ + name = 'AddUnitOfMeasureToCargoTypes1792000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.cargo_types ADD COLUMN IF NOT EXISTS unit_of_measure VARCHAR(16);`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.cargo_types DROP COLUMN IF EXISTS unit_of_measure;`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/1792000000001-AddBookingTypeAndContractFields.ts b/apps/edr-freight-api/src/migrations/1792000000001-AddBookingTypeAndContractFields.ts new file mode 100644 index 000000000..c55186a38 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1792000000001-AddBookingTypeAndContractFields.ts @@ -0,0 +1,39 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddBookingTypeAndContractFields1792000000001 + implements MigrationInterface +{ + name = 'AddBookingTypeAndContractFields1792000000001'; + + public async up(queryRunner: QueryRunner): Promise { + 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 { + 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;`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/1792000000002-CreateBookingOrders.ts b/apps/edr-freight-api/src/migrations/1792000000002-CreateBookingOrders.ts new file mode 100644 index 000000000..ceb98b5d1 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1792000000002-CreateBookingOrders.ts @@ -0,0 +1,74 @@ +import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm'; + +export class CreateBookingOrders1792000000002 implements MigrationInterface { + name = 'CreateBookingOrders1792000000002'; + + public async up(queryRunner: QueryRunner): Promise { + 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 { + await queryRunner.dropTable('freight.booking_order_lines', true); + await queryRunner.dropTable('freight.booking_orders', true); + } +} diff --git a/apps/edr-freight-api/src/migrations/1792000000003-SeedGeneralContractPeriod.ts b/apps/edr-freight-api/src/migrations/1792000000003-SeedGeneralContractPeriod.ts new file mode 100644 index 000000000..e6c0708d4 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1792000000003-SeedGeneralContractPeriod.ts @@ -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 { + 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 { + await queryRunner.query( + `DELETE FROM freight.dropdown_settings WHERE code = $1;`, + [this.code], + ); + } +} diff --git a/apps/edr-freight-api/src/modules/booking-orders/booking-orders.controller.ts b/apps/edr-freight-api/src/modules/booking-orders/booking-orders.controller.ts new file mode 100644 index 000000000..6821c4e7d --- /dev/null +++ b/apps/edr-freight-api/src/modules/booking-orders/booking-orders.controller.ts @@ -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); + } +} diff --git a/apps/edr-freight-api/src/modules/booking-orders/booking-orders.module.ts b/apps/edr-freight-api/src/modules/booking-orders/booking-orders.module.ts new file mode 100644 index 000000000..c8ea869be --- /dev/null +++ b/apps/edr-freight-api/src/modules/booking-orders/booking-orders.module.ts @@ -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 {} diff --git a/apps/edr-freight-api/src/modules/booking-orders/booking-orders.repository.ts b/apps/edr-freight-api/src/modules/booking-orders/booking-orders.repository.ts new file mode 100644 index 000000000..c45029b2b --- /dev/null +++ b/apps/edr-freight-api/src/modules/booking-orders/booking-orders.repository.ts @@ -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 { + constructor( + @InjectRepository(BookingOrder) + repository: Repository, + ) { + super(repository); + } + + /** Orders placed against a given contract, newest first, with their lines. */ + findByContract(contractBookingId: string): Promise { + return this.repository.find({ + where: { contractBookingId }, + relations: { lines: { containerType: true }, booking: true }, + order: { createdAt: 'DESC' }, + }); + } + + override findById(id: string): Promise { + 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 { + 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(); + } +} diff --git a/apps/edr-freight-api/src/modules/booking-orders/booking-orders.service.ts b/apps/edr-freight-api/src/modules/booking-orders/booking-orders.service.ts new file mode 100644 index 000000000..1a75c7914 --- /dev/null +++ b/apps/edr-freight-api/src/modules/booking-orders/booking-orders.service.ts @@ -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 { + return this.ordersRepository.findByContract(contractBookingId); + } + + findById(id: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + const year = new Date().getFullYear(); + const count = await this.bookingsRepository.countByYear(year); + return `BK-${year}-${String(count + 1).padStart(6, '0')}`; + } +} diff --git a/apps/edr-freight-api/src/modules/booking-orders/dto/contract-view.dto.ts b/apps/edr-freight-api/src/modules/booking-orders/dto/contract-view.dto.ts new file mode 100644 index 000000000..6c0c86669 --- /dev/null +++ b/apps/edr-freight-api/src/modules/booking-orders/dto/contract-view.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/booking-orders/dto/create-booking-order.dto.ts b/apps/edr-freight-api/src/modules/booking-orders/dto/create-booking-order.dto.ts new file mode 100644 index 000000000..7043e9704 --- /dev/null +++ b/apps/edr-freight-api/src/modules/booking-orders/dto/create-booking-order.dto.ts @@ -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[]; +} diff --git a/apps/edr-freight-api/src/modules/booking-orders/entities/booking-order-line.entity.ts b/apps/edr-freight-api/src/modules/booking-orders/entities/booking-order-line.entity.ts new file mode 100644 index 000000000..8716cd673 --- /dev/null +++ b/apps/edr-freight-api/src/modules/booking-orders/entities/booking-order-line.entity.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/booking-orders/entities/booking-order.entity.ts b/apps/edr-freight-api/src/modules/booking-orders/entities/booking-order.entity.ts new file mode 100644 index 000000000..5d6857051 --- /dev/null +++ b/apps/edr-freight-api/src/modules/booking-orders/entities/booking-order.entity.ts @@ -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[]; +} diff --git a/apps/edr-freight-api/src/modules/booking-orders/general-contract.service.ts b/apps/edr-freight-api/src/modules/booking-orders/general-contract.service.ts new file mode 100644 index 000000000..ba382d085 --- /dev/null +++ b/apps/edr-freight-api/src/modules/booking-orders/general-contract.service.ts @@ -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): boolean { + return booking.bookingType === BookingType.GeneralContract; + } + + /** The configured ordering window in months (defaults to 3). */ + async getPeriodMonths(): Promise { + 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 { + 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 { + 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> { + 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(); + 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 { + 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 { + const lines = await this.getQuantityLines(contractBookingId); + return lines.every((l) => l.remainingQuantity <= 0); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts index 4ba93626f..471fcb6f2 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts @@ -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, ); }); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts index 14d8a8dbe..fbafaa911 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -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 { @@ -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 = diff --git a/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts index 8a2d52172..e15fcba0d 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts @@ -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, }), ); diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index ae0f1765a..f3afb5722 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -132,8 +132,39 @@ 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('by-company/:companyId/customer-view') + @ApiOperation({ summary: 'List bookings for a company (customer-view shape, backoffice)' }) + findByCompanyCustomerView( + @Param('companyId', ParseUUIDPipe) companyId: string, + ) { + return this.bookingsService.findCustomerBookings(companyId); } @Get('list-summary') diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index f55a5a0f8..c318c9b40 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -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('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], }) diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index b173bfe68..55fb4d9ae 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -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 { 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 { } } - 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> { @@ -559,6 +589,11 @@ export class BookingsRepository extends BaseRepository { 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 { 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, diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 4c8ef2cab..b6f3ee220 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -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 { 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 { 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 { + 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, }; @@ -1036,4 +1118,37 @@ export class BookingsService { return this.findById(id); } + + async findCustomerBookings(companyId: string): Promise<{ + id: string; + reference: string; + status: string; + tradeDirection: string; + freightType: string; + originLabel: string; + destinationLabel: string; + totalAmount: number; + currency: string; + scheduledDate: Date | null; + createdAt: Date; + }[]> { + const { items } = await this.bookingsRepository.findAllPaginated({ + page: 1, + pageSize: 500, + companyId, + }); + return items.map((b) => ({ + id: b.id, + reference: b.reference, + status: b.status, + tradeDirection: b.tradeDirection, + freightType: b.freightType, + originLabel: b.originYard?.label ?? '', + destinationLabel: b.destinationYard?.label ?? '', + totalAmount: Number(b.totalAmount), + currency: b.paymentCurrency, + scheduledDate: b.scheduledDate ?? null, + createdAt: b.createdAt, + })); + } } diff --git a/apps/edr-freight-api/src/modules/bookings/dto/booking-reference-data.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/booking-reference-data.dto.ts index 0dc2bd255..35df9dcd3 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/booking-reference-data.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/booking-reference-data.dto.ts @@ -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 { diff --git a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts index fa3bb6f4d..b7d5ea14d 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts @@ -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]) diff --git a/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts index 9ce90d2b9..d52473813 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts @@ -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]) diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index c5dac1736..d5f358e5f 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index ac2868ec3..fff8fc7e5 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -24,15 +24,25 @@ 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 { ListCompaniesQueryDto } from "./dto/list-companies-query.dto"; +import { CompanyStatsResponseDto } from "./dto/company-stats-response.dto"; +import { UpdateCompanyProfileStatusDto } from "./dto/update-company-profile-status.dto"; +import { FetchETradeDto } from "./dto/fetch-etrade.dto"; +import { ETradeResponseDto } from "./dto/etrade-response.dto"; interface CurrentIamUser { id: string; @@ -80,6 +90,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 { + 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 +124,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 { + 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 { + 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, + ): Promise { + 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 { + 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 { + 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 { + 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 { + const { profile, company } = + await this.companiesService.markOnboardingComplete(user.id); + return new CompanyInfoResponseDto(profile, company); + } + // Used by portal @Post("create") @ApiOperation({ @@ -142,29 +268,19 @@ export class CompaniesController { return new ResponseCompanyDto(company); } + @Get("stats") + @ApiOperation({ summary: "Company counts by status (KPI strip)" }) + async getStats(): Promise { + return this.companiesService.getCompanyStats(); + } + @Get() - @ApiOperation({ summary: "List all companies" }) - async findAll(): Promise { - const companies = await this.companiesService.findAllCompanies(); - return companies.map((c) => new ResponseCompanyDto(c)); - } - - @Get("type/:type") - @ApiOperation({ summary: "Find companies by type" }) - async findByType(@Param("type") type: string): Promise { - const companies = await this.companiesService.findAllCompanies(); - return companies - .filter((c) => c.type === type) - .map((c) => new ResponseCompanyDto(c)); - } - - @Get("search") - @ApiOperation({ summary: "Search companies by name" }) - async search(@Query("name") name: string): Promise { - const companies = await this.companiesService.findAllCompanies(); - return companies - .filter((c) => c.name.toLowerCase().includes(name.toLowerCase())) - .map((c) => new ResponseCompanyDto(c)); + @ApiOperation({ summary: "List companies (paginated, filterable)" }) + async findAll( + @Query() query: ListCompaniesQueryDto, + ): Promise<{ items: ResponseCompanyDto[]; total: number }> { + const { items, total } = await this.companiesService.listCompanies(query); + return { items: items.map((c) => new ResponseCompanyDto(c)), total }; } @Get(":id") @@ -195,6 +311,23 @@ export class CompaniesController { await this.companiesService.deleteCompany(id); } + @Get(":companyId/documents") + @ApiOperation({ summary: "List documents uploaded for a company" }) + async listDocuments( + @Param("companyId", ParseUUIDPipe) companyId: string, + ) { + const files = await this.filesService.findByResource(companyId, "companies"); + return files.map((f) => ({ + id: f.id, + name: f.name, + code: f.code, + mimeType: f.mimeType, + size: f.size, + uploadedAt: f.createdAt, + url: f.url, + })); + } + @Post(":companyId/documents") @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes("multipart/form-data") @@ -206,6 +339,20 @@ export class CompaniesController { return this.filesService.uploadMany(companyId, "companies", files); } + @Patch("company-profiles/:profileId/status") + @FreightAdmin() + @ApiOperation({ summary: "Update a company profile's approval status" }) + async updateCompanyProfileStatus( + @Param("profileId", ParseUUIDPipe) profileId: string, + @Body() dto: UpdateCompanyProfileStatusDto, + ): Promise { + const profile = await this.companiesService.setCompanyProfileStatus( + profileId, + dto.status, + ); + return new ResponseCompanyProfileDto(profile); + } + @Post(":companyId/profiles") @FreightAdmin() @ApiOperation({ summary: "Add a profile (employee) to a company" }) diff --git a/apps/edr-freight-api/src/modules/companies/companies.module.ts b/apps/edr-freight-api/src/modules/companies/companies.module.ts index 53d3de4c8..d275c57a7 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.module.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.module.ts @@ -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], }) diff --git a/apps/edr-freight-api/src/modules/companies/companies.repository.ts b/apps/edr-freight-api/src/modules/companies/companies.repository.ts index 1156823f7..b31f2939d 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.repository.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.repository.ts @@ -3,6 +3,8 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { BaseRepository } from '@edr/api-common'; import { Company } from './entities/company.entity'; +import { ListCompaniesQueryDto } from './dto/list-companies-query.dto'; +import { CompanyStatsResponseDto } from './dto/company-stats-response.dto'; @Injectable() export class CompaniesRepository extends BaseRepository { @@ -32,4 +34,68 @@ export class CompaniesRepository extends BaseRepository { const count = await this.repository.count({ where: { tin } as any }); return count > 0; } + + async findPaginated( + query: ListCompaniesQueryDto, + ): Promise<{ items: Company[]; total: number }> { + const { page = 1, pageSize = 20, search, type, status } = query; + + const qb = this.repository + .createQueryBuilder('company') + .leftJoinAndSelect('company.companyProfiles', 'companyProfiles') + .where('company.deleted_at IS NULL'); + + if (type) { + qb.andWhere('company.type = :type', { type }); + } + + if (status) { + qb.andWhere('company.status = :status', { status }); + } + + if (search) { + const term = `%${search.trim()}%`; + qb.andWhere( + `(company.name ILIKE :term + OR company.tin ILIKE :term + OR company.email ILIKE :term + OR EXISTS ( + SELECT 1 FROM freight.company_profiles cp + WHERE cp.company_id = company.id + AND cp.reference ILIKE :term + AND cp.deleted_at IS NULL + ))`, + { term }, + ); + } + + const [items, total] = await qb + .orderBy('company.name', 'ASC') + .skip((page - 1) * pageSize) + .take(pageSize) + .getManyAndCount(); + + return { items, total }; + } + + async getStats(): Promise { + const rows: { status: string; count: string }[] = await this.repository + .createQueryBuilder('company') + .select('company.status', 'status') + .addSelect('COUNT(*)', 'count') + .where('company.deleted_at IS NULL') + .groupBy('company.status') + .getRawMany(); + + const map = new Map(rows.map((r) => [r.status, parseInt(r.count, 10)])); + const total = rows.reduce((sum, r) => sum + parseInt(r.count, 10), 0); + + return { + total, + active: map.get('active') ?? 0, + pending: map.get('pending') ?? 0, + suspended: map.get('suspended') ?? 0, + blacklisted: map.get('blacklisted') ?? 0, + }; + } } diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index fe1bc5598..a4466efb2 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -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,17 @@ 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 { ListCompaniesQueryDto } from "./dto/list-companies-query.dto"; +import { CompanyStatsResponseDto } from "./dto/company-stats-response.dto"; +import { + Company, + CompanyNationality, + CompanyStatus, + CompanyType, +} from "./entities/company.entity"; import { ExternalProfile } from "./entities/external-profile.entity"; import { + BusinessLicenseFile, CompanyProfile, ProfileType, ProfileStatus, @@ -38,6 +49,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 { @@ -76,20 +89,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 +150,131 @@ export class CompaniesService { return { company, profile }; } + async listCompanies( + query: ListCompaniesQueryDto, + ): Promise<{ items: Company[]; total: number }> { + return this.companiesRepo.findPaginated(query); + } + + async getCompanyStats(): Promise { + return this.companiesRepo.getStats(); + } + + /** + * 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 { + 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 { + 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 { return this.companiesRepo.findAll({ order: { name: "ASC" } }); } @@ -146,8 +298,9 @@ export class CompaniesService { `Company for profile ${profile.id} not found`, ); - company.companyProfiles = - await this.companyProfilesRepo.findByCompanyId(company.id); + company.companyProfiles = await this.companyProfilesRepo.findByCompanyId( + company.id, + ); return { profile, company }; } @@ -174,6 +327,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 +356,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 +488,27 @@ export class CompaniesService { const companyUpdates: Record = {}; const attrUpdates: Record = { ...(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 +516,45 @@ 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 +595,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": @@ -405,6 +613,19 @@ export class CompaniesService { } } + async setCompanyProfileStatus( + profileId: string, + status: ProfileStatus, + ): Promise { + const updated = await this.companyProfilesRepo.updateStatus( + profileId, + status, + ); + if (!updated) + throw new NotFoundException(`Company profile ${profileId} not found`); + return updated; + } + async createCompanyProfile( companyId: string, profileType?: ProfileType, @@ -505,4 +726,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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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); + } } diff --git a/apps/edr-freight-api/src/modules/companies/company-dashboard.repository.ts b/apps/edr-freight-api/src/modules/companies/company-dashboard.repository.ts index 365cf1daa..842a36616 100644 --- a/apps/edr-freight-api/src/modules/companies/company-dashboard.repository.ts +++ b/apps/edr-freight-api/src/modules/companies/company-dashboard.repository.ts @@ -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, + scope: DashboardScope, +): SelectQueryBuilder { + 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, ) {} - /** Count of delivered/completed bookings for a company within [from, to). */ - async countDelivered(companyId: string, from: Date, to: Date): Promise { - 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 { + 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 { - 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 { + 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 { - 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 { + 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 { - 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 { + 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 { - 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 { + 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 }) diff --git a/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts b/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts index db7427112..bc2d95224 100644 --- a/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts +++ b/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts @@ -2,7 +2,7 @@ import { Injectable } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; import { Repository } from "typeorm"; import { BaseRepository } from "@edr/api-common"; -import { CompanyProfile, ProfileType } from "./entities/company-profile.entity"; +import { CompanyProfile, ProfileStatus, ProfileType } from "./entities/company-profile.entity"; const SEQUENCE_MAP: Record = { [ProfileType.exporter]: "seq_company_profile_ex", @@ -15,7 +15,7 @@ const SEQUENCE_MAP: Record = { const PREFIX_MAP: Record = { [ProfileType.exporter]: "EX", [ProfileType.importer]: "IM", - [ProfileType.freightForwarder]: "FFE", + [ProfileType.freightForwarder]: "FF", [ProfileType.djFreightForwarder]: "FWJ", [ProfileType.transporter]: "TR", }; @@ -58,4 +58,16 @@ export class CompanyProfileRepository extends BaseRepository { async findByReference(reference: string): Promise { return this.repository.findOne({ where: { reference } }); } + + async findById(id: string): Promise { + return this.repository.findOne({ where: { id } }); + } + + async updateStatus( + id: string, + status: ProfileStatus, + ): Promise { + await this.repository.update({ id }, { status }); + return this.repository.findOne({ where: { id } }); + } } diff --git a/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts index f6ffb8296..04fd42816 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts @@ -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); } } diff --git a/apps/edr-freight-api/src/modules/companies/dto/company-stats-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/company-stats-response.dto.ts new file mode 100644 index 000000000..a6b8b3b6e --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/company-stats-response.dto.ts @@ -0,0 +1,7 @@ +export class CompanyStatsResponseDto { + total!: number; + active!: number; + pending!: number; + suspended!: number; + blacklisted!: number; +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/create-company-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/create-company-profile.dto.ts new file mode 100644 index 000000000..9ac6c13b7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/create-company-profile.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts index aa0bb72a2..eb32f72ae 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts @@ -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() diff --git a/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts index 5718f541e..0a699fe5e 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts @@ -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() diff --git a/apps/edr-freight-api/src/modules/companies/dto/create-external-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/create-external-profile.dto.ts index c694a50e0..7a9b94c44 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/create-external-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/create-external-profile.dto.ts @@ -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() diff --git a/apps/edr-freight-api/src/modules/companies/dto/etrade-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/etrade-response.dto.ts new file mode 100644 index 000000000..200b69fee --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/etrade-response.dto.ts @@ -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; + } +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/fetch-etrade.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/fetch-etrade.dto.ts new file mode 100644 index 000000000..2eb37c92d --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/fetch-etrade.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts new file mode 100644 index 000000000..c92592286 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts @@ -0,0 +1,35 @@ +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { IsIn, IsInt, IsOptional, IsString, Min } from "class-validator"; +import { Transform } from "class-transformer"; +import { CompanyStatus, CompanyType } from "../entities/company.entity"; + +export class ListCompaniesQueryDto { + @ApiPropertyOptional({ default: 1 }) + @IsOptional() + @Transform(({ value }: { value: unknown }) => parseInt(String(value), 10)) + @IsInt() + @Min(1) + page?: number = 1; + + @ApiPropertyOptional({ default: 20 }) + @IsOptional() + @Transform(({ value }: { value: unknown }) => parseInt(String(value), 10)) + @IsInt() + @Min(1) + pageSize?: number = 20; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + search?: string; + + @ApiPropertyOptional({ enum: CompanyType }) + @IsOptional() + @IsIn(Object.values(CompanyType)) + type?: CompanyType; + + @ApiPropertyOptional({ enum: CompanyStatus }) + @IsOptional() + @IsIn(Object.values(CompanyStatus)) + status?: CompanyStatus; +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts index d6744e75f..97f2d9f50 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts index cb7777e8b..b62182968 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts @@ -1,23 +1,37 @@ -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 { id: string; + companyId: string; 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 | null; createdAt: Date; updatedAt: Date; constructor(profile: CompanyProfile) { this.id = profile.id; + this.companyId = profile.companyId; this.type = profile.type; 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 +43,7 @@ export class ResponseCompanyDto { name: string; type: CompanyType; status: CompanyStatus; + nationality?: CompanyNationality | null; tin: string; vatNumber?: string | null; fanNumber?: string | null; @@ -48,6 +63,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 +74,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; } diff --git a/apps/edr-freight-api/src/modules/companies/dto/response-external-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/response-external-profile.dto.ts index a33585845..7e17bcc60 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/response-external-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/response-external-profile.dto.ts @@ -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; } diff --git a/apps/edr-freight-api/src/modules/companies/dto/set-active-mode.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/set-active-mode.dto.ts new file mode 100644 index 000000000..ac8f57a93 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/set-active-mode.dto.ts @@ -0,0 +1,7 @@ +import { IsEnum } from 'class-validator'; +import { ProfileType } from '../entities/company-profile.entity'; + +export class SetActiveModeDto { + @IsEnum(ProfileType) + type!: ProfileType; +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/set-onboarding-step.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/set-onboarding-step.dto.ts new file mode 100644 index 000000000..43967abc8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/set-onboarding-step.dto.ts @@ -0,0 +1,7 @@ +import { IsString, MaxLength } from 'class-validator'; + +export class SetOnboardingStepDto { + @IsString() + @MaxLength(40) + step!: string; +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/start-onboarding.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/start-onboarding.dto.ts new file mode 100644 index 000000000..7687faab1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/start-onboarding.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/update-company-profile-status.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/update-company-profile-status.dto.ts new file mode 100644 index 000000000..96c02d846 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/update-company-profile-status.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsIn } from "class-validator"; +import { ProfileStatus } from "../entities/company-profile.entity"; + +export class UpdateCompanyProfileStatusDto { + @ApiProperty({ enum: ProfileStatus }) + @IsIn(Object.values(ProfileStatus)) + status!: ProfileStatus; +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts index 0acdf60a1..d94cb5f35 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts @@ -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; } diff --git a/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts index 84da76135..c0cb41a63 100644 --- a/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts +++ b/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts @@ -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 | null; } diff --git a/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts index ec578a3b7..6702f9f7c 100644 --- a/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts +++ b/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts @@ -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 | 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[]; diff --git a/apps/edr-freight-api/src/modules/companies/entities/external-profile.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/external-profile.entity.ts index 91a014f10..3b1554cc9 100644 --- a/apps/edr-freight-api/src/modules/companies/entities/external-profile.entity.ts +++ b/apps/edr-freight-api/src/modules/companies/entities/external-profile.entity.ts @@ -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; } diff --git a/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts b/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts new file mode 100644 index 000000000..15054c701 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts @@ -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 { + const url = `${this.baseUrl}/Registration/GetRegistrationInfoByTin/${tin}/en`; + try { + const response = await firstValueFrom( + this.httpService.get(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 { + const url = `${this.baseUrl}/BusinessMain/GetBusinessByLicenseNo`; + try { + const response = await firstValueFrom( + this.httpService.get(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 || "", + }; + } +} diff --git a/apps/edr-freight-api/src/modules/payment/payment.controller.ts b/apps/edr-freight-api/src/modules/payment/payment.controller.ts index 14308883d..f1f34c3b1 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.controller.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.controller.ts @@ -4,6 +4,7 @@ import { Get, HttpStatus, Param, + ParseUUIDPipe, Post, Query, Res, @@ -33,6 +34,14 @@ import { export class PaymentController { constructor(private readonly paymentService: PaymentService) { } + @Get("by-company/:companyId/customer-view") + @ApiOperation({ summary: "List payments for a company (customer-view shape, backoffice)" }) + findByCompanyCustomerView( + @Param("companyId", ParseUUIDPipe) companyId: string, + ) { + return this.paymentService.findByCompanyId(companyId); + } + @Get("summary") @BookingView() @ApiOperation({ summary: "Payment count/amount summary for dashboard cards" }) diff --git a/apps/edr-freight-api/src/modules/payment/payment.module.ts b/apps/edr-freight-api/src/modules/payment/payment.module.ts index e21ea87b9..e496bb867 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.module.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.module.ts @@ -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({ diff --git a/apps/edr-freight-api/src/modules/payment/payment.repository.ts b/apps/edr-freight-api/src/modules/payment/payment.repository.ts index 8c830a20f..25c3bdd6b 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.repository.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.repository.ts @@ -61,4 +61,56 @@ export class PaymentRepository { return this.paymentRepo.createQueryBuilder(alias); } + async findByCompanyId(companyId: string): Promise<{ + id: string; + merchantOrderId: string; + bookingReference: string; + amount: number; + currency: string; + method: string; + status: string; + paidAt: Date | null; + createdAt: Date; + }[]> { + const rows: { + id: string; + merchant_order_id: string; + booking_reference: string; + amount: number; + currency: string; + method: string; + status: string; + paid_at: Date | null; + created_at: Date; + }[] = await this.dataSource.query( + `SELECT p.id, + p.merchant_order_id, + b.reference AS booking_reference, + p.amount, + p.currency, + p.method, + p.status, + p.paid_at, + p.created_at + FROM freight.payments p + JOIN freight.bookings b ON b.id = p.ref_id + WHERE b.company_id = $1 + AND p.deleted_at IS NULL + AND b.deleted_at IS NULL + ORDER BY p.created_at DESC`, + [companyId], + ); + return rows.map((r) => ({ + id: r.id, + merchantOrderId: r.merchant_order_id, + bookingReference: r.booking_reference, + amount: Number(r.amount), + currency: r.currency, + method: r.method, + status: r.status, + paidAt: r.paid_at, + createdAt: r.created_at, + })); + } + } \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index 177b7475b..1f95c6253 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -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 = { "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 { + 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) { @@ -428,4 +477,8 @@ export class PaymentService { default: return "action-required"; } } + + async findByCompanyId(companyId: string) { + return this.paymentRepo.findByCompanyId(companyId); + } } diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts index 57fe48fed..794f97f83 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts @@ -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() diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts index a0bd9ddaf..2c1ed0e22 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts index 634ac5faa..5a343f910 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts @@ -82,6 +82,7 @@ export class CargoTypesService { showFreeTextBox: dto.showFreeTextBox ?? false, requiresDirectorApproval: dto.requiresDirectorApproval ?? false, isActive: dto.isActive ?? true, + unitOfMeasure: dto.unitOfMeasure ?? null, displayOrder, }); } diff --git a/apps/edr-freight-api/src/modules/scheduling/compare-scheduling-priority.util.ts b/apps/edr-freight-api/src/modules/scheduling/compare-scheduling-priority.util.ts index a7f7350c4..fa8e79037 100644 --- a/apps/edr-freight-api/src/modules/scheduling/compare-scheduling-priority.util.ts +++ b/apps/edr-freight-api/src/modules/scheduling/compare-scheduling-priority.util.ts @@ -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; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts index eda168e03..5c9cb6119 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts @@ -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'; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts index 2e721825e..3b00abd0a 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts @@ -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; }); } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 7bfe5811e..3928376ce 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -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, }; } diff --git a/apps/edr-freight-api/src/scripts/seed-file-upload-settings.ts b/apps/edr-freight-api/src/scripts/seed-file-upload-settings.ts new file mode 100644 index 000000000..c334143b6 --- /dev/null +++ b/apps/edr-freight-api/src/scripts/seed-file-upload-settings.ts @@ -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); +}); diff --git a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts index 2ef1f79f0..340a291db 100644 --- a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts +++ b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts @@ -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, diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index e41b4d726..e91a8c810 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -1,5 +1,6 @@ import { Boxes, + Building2, Container, FileText, LayoutDashboard, @@ -27,6 +28,8 @@ import BookingContractPage from "./pages/bookings/BookingContractPage"; import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage"; import BookingRequestsPage from "./pages/bookings/BookingRequestsPage"; import NewBookingPage from "./pages/bookings/NewBookingPage"; +import CustomerDetailPage from "./pages/customers/CustomerDetailPage"; +import CustomersPage from "./pages/customers/CustomersPage"; import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page"; import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page"; import MyProfilePage from "./pages/dashboard/MyProfilePage"; @@ -89,6 +92,11 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ href: "/dashboard/booking-requests", icon: , }, + { + label: "Customers", + href: "/dashboard/customers", + icon: , + }, { label: "Payments", href: "/dashboard/payments", @@ -360,6 +368,8 @@ const App = () => { } /> + } /> + } /> } /> } /> No cargoes for this container.; @@ -34,8 +40,8 @@ export function CargoesTable({ containerId }: { containerId: string }) { {cargo.status} {cargo.status === 'PENDING' && refetch()} />} - {cargo.status === 'LOADED' && } - {cargo.status === 'LOADED' && } + {cargo.status === 'LOADED' && } + {cargo.status === 'LOADED' && } ))} diff --git a/apps/edr-freight-web/backoffice/src/components/cargoes/DeliverCargoDialog.tsx b/apps/edr-freight-web/backoffice/src/components/cargoes/DeliverCargoDialog.tsx index caa554a10..546bd0def 100644 --- a/apps/edr-freight-web/backoffice/src/components/cargoes/DeliverCargoDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/cargoes/DeliverCargoDialog.tsx @@ -6,7 +6,8 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; -import { useDeliverCargo } from '@/hooks/useCargoes'; +import { useMutation } from '@tanstack/react-query'; +import { api } from '@/services/api'; import { useToast } from '@/hooks/use-toast'; /** @@ -18,7 +19,7 @@ export function DeliverCargoDialog({ cargoId, onSuccess }: { cargoId: string; on const [receiverName, setReceiverName] = useState(''); const [pickupDate, setPickupDate] = useState(''); const [deliveryRemarks, setDeliveryRemarks] = useState(''); - const deliver = useDeliverCargo(); + const deliver = useMutation(api.cargoes.deliver.mutationOptions()); const { toast } = useToast(); const handleDeliver = async () => { diff --git a/apps/edr-freight-web/backoffice/src/components/cargoes/LoadCargoDialog.tsx b/apps/edr-freight-web/backoffice/src/components/cargoes/LoadCargoDialog.tsx index 188726352..8094bd902 100644 --- a/apps/edr-freight-web/backoffice/src/components/cargoes/LoadCargoDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/cargoes/LoadCargoDialog.tsx @@ -3,7 +3,8 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; -import { useLoadCargo } from '@/hooks/useCargoes'; +import { useMutation } from '@tanstack/react-query'; +import { api } from '@/services/api'; import { useToast } from '@/hooks/use-toast'; export function LoadCargoDialog({ cargoId, onSuccess }: { cargoId: string; onSuccess?: () => void }) { @@ -11,7 +12,7 @@ export function LoadCargoDialog({ cargoId, onSuccess }: { cargoId: string; onSuc const [quantity, setQuantity] = useState(0); const [weight, setWeight] = useState(0); const [volume, setVolume] = useState(); - const load = useLoadCargo(); + const load = useMutation(api.cargoes.load.mutationOptions()); const { toast } = useToast(); const handleLoad = async () => { diff --git a/apps/edr-freight-web/backoffice/src/components/customers/TableCard.tsx b/apps/edr-freight-web/backoffice/src/components/customers/TableCard.tsx new file mode 100644 index 000000000..b67181da5 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/customers/TableCard.tsx @@ -0,0 +1,32 @@ +import { Box, Card } from "@mantine/core"; +import type { ReactNode } from "react"; + +export interface TableCardProps { + children: ReactNode; + /** + * Minimum width (px) the table is forced to occupy. The Mantine `Table` is + * always `width: 100%`, so without a floor it can never overflow its + * container and the horizontal scroll never engages. Setting a floor lets + * columns keep a sensible width and the card scroll horizontally on narrow + * viewports instead of squishing. + */ + minWidth?: number; +} + +/** + * Flush card shell for a `DataTable`: a borderless, padding-less card whose + * single child is a horizontally scrollable region. Pair with the table's + * `containerClassName="border-0 shadow-none bg-transparent"` so every table on + * the customer pages reads identically (same surface, same scroll behaviour). + */ +export function TableCard({ children, minWidth = 860 }: TableCardProps) { + return ( + + + {children} + + + ); +} + +export default TableCard; diff --git a/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx b/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx new file mode 100644 index 000000000..e108d1b1f --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx @@ -0,0 +1,316 @@ +import { Badge, Button, Group, Tooltip } from "@mantine/core"; +import { useMutation } from "@tanstack/react-query"; +import { api } from "@/services/api"; + +import type { + CompanyProfile, + CompanyStatus, + CompanyType, + CustomerBookingStatus, + CustomerPaymentStatus, + ProfileStatus, + ProfileType, +} from "@/types/customer"; + +import { humanize } from "./format"; + +const badgeStyle = { + fontSize: "0.7rem", + letterSpacing: "0.04em", + whiteSpace: "nowrap" as const, +}; + +/** Shared status palette — active/paid green, pending amber, terminal red. */ +const STATUS_COLOR: Record = { + active: "edr-green", + pending: "yellow", + suspended: "orange", + blacklisted: "red", +}; + +const COMPANY_TYPE_COLOR: Record = { + customer: "edr-green", + freight_forwarder: "blue", + dj_freight_forwarder: "indigo", + transporter: "grape", +}; + +const PROFILE_TYPE_COLOR: Record = { + importer: "teal", + exporter: "cyan", + freight_forwarder: "blue", + dj_freight_forwarder: "indigo", + transporter: "grape", +}; + +export function CompanyStatusBadge({ status }: { status: CompanyStatus }) { + return ( + + {status} + + ); +} + +export function CompanyTypeBadge({ type }: { type: CompanyType }) { + return ( + + {humanize(type)} + + ); +} + +/** + * Profile chips for a company row: one chip per role (Importer / Exporter / …) + * carrying its reference code. Caps at three (a company has at most three + * profiles); any extra collapse into a `+N` chip. + */ +export function ProfileChips({ + profiles, + max = 3, +}: { + profiles: CompanyProfile[]; + max?: number; +}) { + if (!profiles.length) { + return ( + + No profiles + + ); + } + + const shown = profiles.slice(0, max); + const extra = profiles.length - shown.length; + + return ( + + {shown.map((profile) => ( + + + {humanize(profile.type)} · {profile.reference} + + + ))} + {extra > 0 ? ( + + +{extra} + + ) : null} + + ); +} + +export function ProfileTypeBadge({ type }: { type: ProfileType }) { + return ( + + {humanize(type)} + + ); +} + +export function ProfileStatusBadge({ status }: { status: ProfileStatus }) { + return ( + + {status} + + ); +} + +const BOOKING_STATUS_COLOR: Record = { + DRAFT: "gray", + SUBMITTED: "yellow", + PENDING_APPROVAL: "yellow", + APPROVED: "cyan", + PAID: "edr-green", + IN_TRANSIT: "blue", + COMPLETED: "indigo", + REJECTED: "red", + CANCELLED: "red", +}; + +export function BookingStatusBadge({ status }: { status: CustomerBookingStatus }) { + return ( + + {humanize(status)} + + ); +} + +const PAYMENT_STATUS_COLOR: Record = { + "action-required": "orange", + processing: "yellow", + success: "edr-green", + failed: "red", + canceled: "gray", + refunded: "grape", +}; + +export function PaymentStatusBadge({ status }: { status: CustomerPaymentStatus }) { + return ( + + {humanize(status)} + + ); +} + +/** + * Inline approval action buttons for a profile row. + * Transitions: pending → approve/reject | active → suspend | suspended → reactivate/blacklist | blacklisted → reinstate + */ +export function ProfileApprovalActions({ + profileId, + status, +}: { + profileId: string; + status: ProfileStatus; +}) { + const { mutate, isPending } = useMutation( + api.customers.setProfileStatus.mutationOptions(), + ); + + const act = (next: ProfileStatus) => + mutate({ profileId, status: next }); + + if (status === "pending") { + return ( + + + + + ); + } + + if (status === "active") { + return ( + + ); + } + + if (status === "suspended") { + return ( + + + + + ); + } + + if (status === "blacklisted") { + return ( + + ); + } + + return null; +} diff --git a/apps/edr-freight-web/backoffice/src/components/customers/format.ts b/apps/edr-freight-web/backoffice/src/components/customers/format.ts new file mode 100644 index 000000000..0397c1cee --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/customers/format.ts @@ -0,0 +1,38 @@ +/** Shared formatting helpers for the customer-management pages. */ + +/** snake_case / SCREAMING_CASE → Title Case. */ +export function humanize(value: string): string { + return value + .toLowerCase() + .split(/[_\s]+/) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + +export function formatDate(value: string | null | undefined): string { + if (!value) return "—"; + const d = new Date(value); + return Number.isNaN(d.getTime()) + ? "—" + : d.toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + }); +} + +export function formatMoney(amount: number, currency: string): string { + return new Intl.NumberFormat(undefined, { + style: "currency", + currency, + maximumFractionDigits: 0, + }).format(amount); +} + +export function formatBytes(bytes: number): string { + if (!bytes) return "0 B"; + const units = ["B", "KB", "MB", "GB"]; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + const value = bytes / Math.pow(1024, i); + return `${value.toFixed(i === 0 ? 0 : 1)} ${units[i]}`; +} diff --git a/apps/edr-freight-web/backoffice/src/components/customers/index.ts b/apps/edr-freight-web/backoffice/src/components/customers/index.ts new file mode 100644 index 000000000..61b75767a --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/customers/index.ts @@ -0,0 +1,12 @@ +export { + BookingStatusBadge, + CompanyStatusBadge, + CompanyTypeBadge, + PaymentStatusBadge, + ProfileApprovalActions, + ProfileChips, + ProfileStatusBadge, + ProfileTypeBadge, +} from "./badges"; +export { formatBytes, formatDate, formatMoney, humanize } from "./format"; +export { TableCard, type TableCardProps } from "./TableCard"; diff --git a/apps/edr-freight-web/backoffice/src/components/profile/MySignatureCard.tsx b/apps/edr-freight-web/backoffice/src/components/profile/MySignatureCard.tsx index 3ac661e1a..5f4f4d7a7 100644 --- a/apps/edr-freight-web/backoffice/src/components/profile/MySignatureCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/profile/MySignatureCard.tsx @@ -1,6 +1,9 @@ import { useState } from "react"; import { FileSignature, Loader2 } from "lucide-react"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import toast from "react-hot-toast"; +import { api } from "@/services/api"; import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad"; import { Card, @@ -10,10 +13,6 @@ import { CardTitle, } from "@/components/ui/card"; import { useAuth } from "@/auth/useAuth"; -import { - useMySignature, - useSaveSignature, -} from "@/hooks/useSavedSignature"; import { Button, Dialog, @@ -33,8 +32,10 @@ import { */ export function MySignatureCard() { const { user } = useAuth(); - const { data: saved, isLoading } = useMySignature(); - const saveMutation = useSaveSignature(); + const { data: saved, isLoading } = useQuery( + api.signatures.mySignature.queryOptions({ staleTime: 60_000 }), + ); + const saveMutation = useMutation(api.signatures.save.mutationOptions()); const [open, setOpen] = useState(false); const [signerName, setSignerName] = useState(""); @@ -56,7 +57,13 @@ export function MySignatureCard() { signerDisplayName: signerName.trim(), signatureImageBase64: signatureData, }, - { onSuccess: () => setOpen(false) }, + { + onSuccess: () => { + toast.success("Signature saved"); + setOpen(false); + }, + onError: () => toast.error("Failed to save signature"), + }, ); }; diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/AllocateBookingWizard.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/AllocateBookingWizard.tsx index 0b2e0a885..0c26c3392 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/AllocateBookingWizard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/AllocateBookingWizard.tsx @@ -31,13 +31,8 @@ import { Weight, } from "lucide-react"; -import { - useAvailableLocomotives, - useEligibleBookings, - useScheduleList, - useScheduleMutations, -} from "@/hooks/trainScheduling/useTrainScheduling"; -import { useRoutes } from "@/hooks/useRoutes"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { api } from "@/services/api"; import { useToast } from "@/hooks/use-toast"; import { trainSchedulingService } from "@/services/trainScheduling.service"; import type { BookingDetail } from "@/types/booking"; @@ -133,13 +128,27 @@ export function AllocateBookingWizard({ [originId, destinationId], ); - const eligibleQuery = useEligibleBookings(eligibleFilters, opened); - const schedulesQuery = useScheduleList(); - const routesQuery = useRoutes(); - const locomotivesQuery = useAvailableLocomotives( - scheduleMode === "new" && routeId ? routeId : undefined, + const eligibleQuery = useQuery( + api.trainScheduling.eligibleBookings.queryOptions({ + input: { filters: eligibleFilters }, + enabled: opened, + }), ); - const { create, preview, assign, finalize } = useScheduleMutations(selectedScheduleId ?? undefined); + const schedulesQuery = useQuery( + api.trainScheduling.scheduleList.queryOptions({ input: {} }), + ); + const routesQuery = useQuery(api.routes.list.queryOptions()); + const locomotivesQuery = useQuery( + api.trainScheduling.availableLocomotives.queryOptions({ + input: { + routeId: scheduleMode === "new" && routeId ? routeId : undefined, + }, + }), + ); + const create = useMutation(api.trainScheduling.createSchedule.mutationOptions()); + const preview = useMutation(api.trainScheduling.preview.mutationOptions()); + const assign = useMutation(api.trainScheduling.assignBookings.mutationOptions()); + const finalize = useMutation(api.trainScheduling.finalizeSchedule.mutationOptions()); useEffect(() => { if (scheduleMode === "new") { diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleBatchPanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleBatchPanel.tsx index 515f1e361..d44ef4540 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleBatchPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleBatchPanel.tsx @@ -13,11 +13,10 @@ import { } from "@mantine/core"; import { CheckCircle2, Layers, Lock, LockOpen, PlayCircle, Repeat, XCircle } from "lucide-react"; +import { useMutation, useQuery } from "@tanstack/react-query"; + import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge"; -import { - useBatchActions, - useBookableSchedules, -} from "@/hooks/trainScheduling/useTrainScheduling"; +import { api } from "@/services/api"; import { useToast } from "@/hooks/use-toast"; import type { TrainScheduleDetail } from "@/types/trainScheduling"; @@ -33,16 +32,31 @@ const windowColor: Record = { export function ScheduleBatchPanel({ schedule }: ScheduleBatchPanelProps) { const { toast } = useToast(); - const actions = useBatchActions(schedule.id); + const actions = { + runBatch: useMutation(api.trainScheduling.runBatch.mutationOptions()), + setWindow: useMutation(api.trainScheduling.setBookingWindow.mutationOptions()), + markPaid: useMutation(api.trainScheduling.markBookingPaid.mutationOptions()), + expire: useMutation(api.trainScheduling.expireBooking.mutationOptions()), + moveSchedule: useMutation( + api.trainScheduling.moveBookingSchedule.mutationOptions(), + ), + }; const windowStatus = (schedule as { bookingWindowStatus?: string }).bookingWindowStatus ?? "OPEN"; const locked = schedule.status === "DISPATCHED" || schedule.status === "ARRIVED"; const [moveBookingId, setMoveBookingId] = useState(null); const [moveTarget, setMoveTarget] = useState(null); - const { data: targets } = useBookableSchedules( - schedule.originStation?.id, - schedule.destinationStation?.id, + const { data: targets } = useQuery( + api.trainScheduling.bookableSchedules.queryOptions({ + input: { + originYardId: schedule.originStation?.id, + destinationYardId: schedule.destinationStation?.id, + }, + enabled: Boolean( + schedule.originStation?.id && schedule.destinationStation?.id, + ), + }), ); const moveOptions = useMemo( () => diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/AssignedBookingsPanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/AssignedBookingsPanel.tsx index c1ce3a96d..914d5cb60 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/AssignedBookingsPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/AssignedBookingsPanel.tsx @@ -4,7 +4,8 @@ import { Building2, Package, TrainFront, Weight, X } from "lucide-react"; import type { TrainScheduleDetail } from "@/types/trainScheduling"; import type { BookingDetailData } from "./BookingDetailModal"; import { RemoveBookingConfirmModal, type RemovalTarget } from "./RemoveBookingConfirmModal"; -import { useScheduleMutations } from "@/hooks/trainScheduling/useTrainScheduling"; +import { useMutation } from "@tanstack/react-query"; +import { api } from "@/services/api"; import { useToast } from "@/hooks/use-toast"; import { freightBrand } from "@/theme/freight-brand"; @@ -22,7 +23,7 @@ export const AssignedBookingsPanel = ({ onSelect, }: AssignedBookingsPanelProps) => { const { toast } = useToast(); - const unassign = useScheduleMutations(scheduleId).unassign; + const unassign = useMutation(api.trainScheduling.unassignBooking.mutationOptions()); const isDispatched = scheduleDetail.status === "DISPATCHED"; const [removalTarget, setRemovalTarget] = useState(null); diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/CompositionBookingTabs.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/CompositionBookingTabs.tsx index 5de67daa6..64cb6d5cb 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/CompositionBookingTabs.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/CompositionBookingTabs.tsx @@ -8,10 +8,8 @@ import { UnassignedBookingsPanel } from "./UnassignedBookingsPanel"; import { RemovalLogPanel } from "./RemovalLogPanel"; import { BatchBookingList } from "./BatchBookingList"; import { BookingDetailModal, type BookingDetailData } from "./BookingDetailModal"; -import { - useCompositionRemovals, - useUnassignedBookings, -} from "@/hooks/trainScheduling/useTrainScheduling"; +import { useQuery } from "@tanstack/react-query"; +import { api } from "@/services/api"; import { freightBrand } from "@/theme/freight-brand"; interface CompositionBookingTabsProps { @@ -47,8 +45,18 @@ export const CompositionBookingTabs = ({ const [detailBooking, setDetailBooking] = useState(null); const [tab, setTab] = useState("assigned"); - const unassignedQuery = useUnassignedBookings(scheduleId); - const removalsQuery = useCompositionRemovals(scheduleId); + const unassignedQuery = useQuery( + api.trainScheduling.unassignedBookings.queryOptions({ + input: { scheduleId }, + enabled: Boolean(scheduleId), + }), + ); + const removalsQuery = useQuery( + api.trainScheduling.compositionRemovals.queryOptions({ + input: { scheduleId }, + enabled: Boolean(scheduleId), + }), + ); const { assignedCount } = useMemo(() => { const wagons = scheduleDetail.trainSet?.wagons ?? []; diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/ContainerNumberInput.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/ContainerNumberInput.tsx index 5629ba698..bb25b51bb 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/ContainerNumberInput.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/ContainerNumberInput.tsx @@ -1,6 +1,7 @@ import { useState } from "react"; import { Group, TextInput, Text } from "@mantine/core"; -import { useUpdateContainerItem } from "@/hooks/trainScheduling/useTrainScheduling"; +import { useMutation } from "@tanstack/react-query"; +import { api } from "@/services/api"; interface ContainerNumberInputProps { value: string | null; @@ -19,13 +20,16 @@ export const ContainerNumberInput = ({ const [inputValue, setInputValue] = useState(value ?? ""); const [error, setError] = useState(null); - const updateMutation = useUpdateContainerItem(scheduleId); + const updateMutation = useMutation( + api.trainScheduling.updateContainerItem.mutationOptions(), + ); const isLoading = updateMutation.isPending; const handleSave = async () => { try { setError(null); await updateMutation.mutateAsync({ + scheduleId, itemId, containerNumber: inputValue || null, }); diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/RemovalLogPanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/RemovalLogPanel.tsx index 05f2a8328..6682cd795 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/RemovalLogPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/RemovalLogPanel.tsx @@ -1,13 +1,19 @@ import { Box, Card, Group, Stack, Text, ThemeIcon } from "@mantine/core"; import { History, PackageX } from "lucide-react"; -import { useCompositionRemovals } from "@/hooks/trainScheduling/useTrainScheduling"; +import { useQuery } from "@tanstack/react-query"; +import { api } from "@/services/api"; interface RemovalLogPanelProps { scheduleId: string; } export const RemovalLogPanel = ({ scheduleId }: RemovalLogPanelProps) => { - const removalQuery = useCompositionRemovals(scheduleId); + const removalQuery = useQuery( + api.trainScheduling.compositionRemovals.queryOptions({ + input: { scheduleId }, + enabled: Boolean(scheduleId), + }), + ); if (removalQuery.isLoading) { return ( diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/TrainConsistView.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/TrainConsistView.tsx index 43e8c58d2..2ebfbb8ff 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/TrainConsistView.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/TrainConsistView.tsx @@ -6,7 +6,8 @@ import { TrainStatsBar } from "./TrainStatsBar"; import { WagonCard } from "./WagonCard"; import { InteractiveTrainConsist } from "./InteractiveTrainConsist"; import { RemoveBookingModal } from "./RemoveBookingModal"; -import { useScheduleMutations, useRemoveWagonSlot } from "@/hooks/trainScheduling/useTrainScheduling"; +import { useMutation } from "@tanstack/react-query"; +import { api } from "@/services/api"; import { freightBrand } from "@/theme/freight-brand"; type Wagon = TrainScheduleDetail["trainSet"]["wagons"][number]; @@ -47,8 +48,12 @@ export const TrainConsistView = ({ const [selectedWagonId, setSelectedWagonId] = useState(null); const [removeModalOpen, setRemoveModalOpen] = useState(false); - const unassignMutation = useScheduleMutations(scheduleId).unassign; - const removeWagonMutation = useRemoveWagonSlot(scheduleId); + const unassignMutation = useMutation( + api.trainScheduling.unassignBooking.mutationOptions(), + ); + const removeWagonMutation = useMutation( + api.trainScheduling.removeWagonSlot.mutationOptions(), + ); const trainSet = scheduleDetail.trainSet; const wagons = trainSet?.wagons ?? []; @@ -83,7 +88,7 @@ export const TrainConsistView = ({ const handleRemoveWagon = async (wagonId: string) => { if (confirm("Are you sure you want to remove this wagon slot?")) { - await removeWagonMutation.mutateAsync(wagonId); + await removeWagonMutation.mutateAsync({ scheduleId, wagonId }); setSelectedWagonId(null); } }; diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/UnassignedBookingsPanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/UnassignedBookingsPanel.tsx index 48928be88..e5225f8dd 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/UnassignedBookingsPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/UnassignedBookingsPanel.tsx @@ -1,9 +1,7 @@ import { Badge, Box, Button, Card, Group, Stack, Text, ThemeIcon, Tooltip } from "@mantine/core"; import { AlertTriangle, Container as ContainerIcon, MapPin, Plus, TrainFront } from "lucide-react"; -import { - useUnassignedBookings, - useScheduleMutations, -} from "@/hooks/trainScheduling/useTrainScheduling"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { api } from "@/services/api"; import { useToast } from "@/hooks/use-toast"; import type { FleetAvailabilityRow } from "@/types/trainScheduling"; import type { BookingDetailData } from "./BookingDetailModal"; @@ -73,8 +71,15 @@ export const UnassignedBookingsPanel = ({ onSelect, }: UnassignedBookingsPanelProps) => { const { toast } = useToast(); - const unassignedQuery = useUnassignedBookings(scheduleId); - const assignMutation = useScheduleMutations(scheduleId).assignUnassigned; + const unassignedQuery = useQuery( + api.trainScheduling.unassignedBookings.queryOptions({ + input: { scheduleId }, + enabled: Boolean(scheduleId), + }), + ); + const assignMutation = useMutation( + api.trainScheduling.assignUnassignedBooking.mutationOptions(), + ); const handleAssign = async (bookingId: string, reference: string | null) => { try { diff --git a/apps/edr-freight-web/backoffice/src/components/wagons/AssignWagonDialog.tsx b/apps/edr-freight-web/backoffice/src/components/wagons/AssignWagonDialog.tsx index 07565163b..0c569e753 100644 --- a/apps/edr-freight-web/backoffice/src/components/wagons/AssignWagonDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/wagons/AssignWagonDialog.tsx @@ -4,17 +4,18 @@ import { Button, Group, Modal, NumberInput, Select, Stack, Text } from "@mantine import { Freight } from "@edr/types"; +import { useMutation, useQuery } from "@tanstack/react-query"; + +import { api } from "@/services/api"; import { useToast } from "@/hooks/use-toast"; -import { useRouteYards } from "@/hooks/useRoutes"; -import { useAssignWagonToTrain, useWagons } from "@/hooks/useWagons"; export function AssignWagonDialog({ trainId }: { trainId: string }) { const [open, setOpen] = useState(false); const [wagonId, setWagonId] = useState(null); const [sequence, setSequence] = useState(""); - const { data: wagons } = useWagons(); - const { data: yards = [] } = useRouteYards(); - const assign = useAssignWagonToTrain(); + const { data: wagons } = useQuery(api.wagons.list.queryOptions({ input: {} })); + const { data: yards = [] } = useQuery(api.routes.yards.queryOptions()); + const assign = useMutation(api.wagons.assignToTrain.mutationOptions()); const { toast } = useToast(); const available = (wagons ?? []).filter( diff --git a/apps/edr-freight-web/backoffice/src/components/wagons/WagonsTable.tsx b/apps/edr-freight-web/backoffice/src/components/wagons/WagonsTable.tsx index d9d96c117..33a7438fe 100644 --- a/apps/edr-freight-web/backoffice/src/components/wagons/WagonsTable.tsx +++ b/apps/edr-freight-web/backoffice/src/components/wagons/WagonsTable.tsx @@ -3,15 +3,22 @@ import { Trash2 } from "lucide-react"; import type { ColumnDef } from "@edr/ui-common"; import { ActionIcon, Badge, Group, Text, Tooltip } from "@mantine/core"; +import { useMutation, useQuery } from "@tanstack/react-query"; + import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles"; +import { api } from "@/services/api"; import { useToast } from "@/hooks/use-toast"; -import { useUnassignWagon, useWagonsByTrain } from "@/hooks/useWagons"; import type { Wagon } from "@/services/wagon.service"; import { DataTable } from "@edr/ui-common"; export function WagonsTable({ trainId }: { trainId: string }) { - const { data: wagons = [], isLoading, refetch } = useWagonsByTrain(trainId); - const unassign = useUnassignWagon(); + const { data: wagons = [], isLoading, refetch } = useQuery( + api.wagons.listByTrain.queryOptions({ + input: { trainId }, + enabled: !!trainId, + }), + ); + const unassign = useMutation(api.wagons.unassign.mutationOptions()); const { toast } = useToast(); const columns = useMemo((): ColumnDef[] => { diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ActivityTimeline.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ActivityTimeline.tsx index 49e8828dd..5fac23ea7 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ActivityTimeline.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ActivityTimeline.tsx @@ -9,7 +9,9 @@ import { Warehouse, } from 'lucide-react'; -import { useInventoryActivity } from '@/hooks/useWarehouses'; +import { useQuery } from '@tanstack/react-query'; + +import { api } from '@/services/api'; import type { ActivityType } from '@/types/warehouse'; import { formatDate, humanizeEnum } from './options'; @@ -24,7 +26,12 @@ const activityIcon: Record = { }; export function ActivityTimeline({ inventoryId }: { inventoryId: string }) { - const { data, isLoading } = useInventoryActivity(inventoryId); + const { data, isLoading } = useQuery( + api.warehouses.activity.queryOptions({ + input: { id: inventoryId }, + enabled: Boolean(inventoryId), + }), + ); const items = data ?? []; if (isLoading) { diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/CreateWarehouseModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/CreateWarehouseModal.tsx index 87d5f2946..ad8b4109d 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/CreateWarehouseModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/CreateWarehouseModal.tsx @@ -9,9 +9,10 @@ import { TextInput, } from '@mantine/core'; +import { useMutation, useQuery } from '@tanstack/react-query'; + +import { api } from '@/services/api'; import { useToast } from '@/hooks/use-toast'; -import { useStations } from '@/hooks/useStations'; -import { useCreateWarehouse, useUpdateWarehouse } from '@/hooks/useWarehouses'; import type { SaveWarehousePayload, Warehouse, WarehouseType } from '@/types/warehouse'; import { extractErrorMessage, statusOptions, warehouseTypeOptions } from './options'; @@ -48,9 +49,11 @@ const emptyForm = (): FormState => ({ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWarehouseModalProps) { const isEdit = Boolean(warehouse); const { toast } = useToast(); - const createMutation = useCreateWarehouse(); - const updateMutation = useUpdateWarehouse(); - const { data: stations } = useStations(); + const createMutation = useMutation(api.warehouses.create.mutationOptions()); + const updateMutation = useMutation(api.warehouses.update.mutationOptions()); + const { data: stations } = useQuery( + api.stations.list.queryOptions({ staleTime: 5 * 60 * 1000 }), + ); const [form, setForm] = useState(emptyForm()); const stationOptions = (stations ?? []).map((s) => ({ value: s.id, label: `${s.name} (${s.code})` })); diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/CreateYardModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/CreateYardModal.tsx index 9ef9ef26c..a2eb0aa0e 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/CreateYardModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/CreateYardModal.tsx @@ -1,8 +1,10 @@ import { useEffect, useState } from 'react'; import { Button, Group, Modal, NumberInput, Select, Stack, TextInput } from '@mantine/core'; +import { useMutation } from '@tanstack/react-query'; + +import { api } from '@/services/api'; import { useToast } from '@/hooks/use-toast'; -import { useCreateYard, useUpdateYard } from '@/hooks/useWarehouses'; import type { SaveYardPayload, WarehouseYard, WarehouseYardType } from '@/types/warehouse'; import { extractErrorMessage, statusOptions, yardTypeOptions } from './options'; @@ -36,8 +38,8 @@ const emptyForm = (): FormState => ({ export function CreateYardModal({ opened, onClose, warehouseId, yard }: CreateYardModalProps) { const isEdit = Boolean(yard); const { toast } = useToast(); - const createMutation = useCreateYard(); - const updateMutation = useUpdateYard(); + const createMutation = useMutation(api.warehouses.createYard.mutationOptions()); + const updateMutation = useMutation(api.warehouses.updateYard.mutationOptions()); const [form, setForm] = useState(emptyForm()); useEffect(() => { diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/CreateZoneModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/CreateZoneModal.tsx index 7a67e4ff1..05bc1ce96 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/CreateZoneModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/CreateZoneModal.tsx @@ -1,8 +1,10 @@ import { useEffect, useState } from 'react'; import { Button, Group, Modal, NumberInput, Select, Stack, TextInput } from '@mantine/core'; +import { useMutation } from '@tanstack/react-query'; + +import { api } from '@/services/api'; import { useToast } from '@/hooks/use-toast'; -import { useCreateZone, useUpdateZone } from '@/hooks/useWarehouses'; import type { SaveZonePayload, WarehouseZone, WarehouseZoneType } from '@/types/warehouse'; import { extractErrorMessage, statusOptions, zoneTypeOptions } from './options'; @@ -36,8 +38,8 @@ const emptyForm = (): FormState => ({ export function CreateZoneModal({ opened, onClose, yardId, zone }: CreateZoneModalProps) { const isEdit = Boolean(zone); const { toast } = useToast(); - const createMutation = useCreateZone(); - const updateMutation = useUpdateZone(); + const createMutation = useMutation(api.warehouses.createZone.mutationOptions()); + const updateMutation = useMutation(api.warehouses.updateZone.mutationOptions()); const [form, setForm] = useState(emptyForm()); useEffect(() => { diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/DeliverInventoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/DeliverInventoryModal.tsx index 4b5485815..d8a441de8 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/DeliverInventoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/DeliverInventoryModal.tsx @@ -2,8 +2,10 @@ import { useEffect, useState } from 'react'; import { Alert, Button, Group, Modal, Stack, Text, Textarea, TextInput } from '@mantine/core'; import { Info } from 'lucide-react'; +import { useMutation } from '@tanstack/react-query'; + +import { api } from '@/services/api'; import { useToast } from '@/hooks/use-toast'; -import { useDeliverInventory } from '@/hooks/useWarehouses'; import type { WarehouseInventoryItem } from '@/types/warehouse'; import { extractErrorMessage } from './options'; @@ -15,7 +17,7 @@ interface DeliverInventoryModalProps { export function DeliverInventoryModal({ opened, onClose, item }: DeliverInventoryModalProps) { const { toast } = useToast(); - const deliverMutation = useDeliverInventory(); + const deliverMutation = useMutation(api.warehouses.deliver.mutationOptions()); const [receiverName, setReceiverName] = useState(''); const [remarks, setRemarks] = useState(''); diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx index ce4b81c05..73ea669be 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx @@ -1,13 +1,10 @@ import { Badge, Button, Card, Divider, Group, Loader, Modal, Stack, Text } from '@mantine/core'; import { CalendarClock, Coins, DoorOpen, FileText } from 'lucide-react'; +import { useMutation, useQuery } from '@tanstack/react-query'; + +import { api } from '@/services/api'; import { useToast } from '@/hooks/use-toast'; -import { - useFeePreview, - useGateClearance, - useGenerateInvoice, - useInvoicesForInventory, -} from '@/hooks/useWarehouses'; import { extractErrorMessage } from './options'; import type { FeePreview, WarehouseInvoiceStatus } from '@/types/warehouse'; @@ -88,10 +85,20 @@ function Row({ label, value }: { label: string; value: string }) { export function FeePreviewModal({ opened, onClose, inventoryId }: FeePreviewModalProps) { const { toast } = useToast(); const enabledId = opened ? inventoryId ?? undefined : undefined; - const { data, isLoading } = useFeePreview(enabledId); - const { data: invoices } = useInvoicesForInventory(enabledId); - const generate = useGenerateInvoice(); - const gateClear = useGateClearance(); + const { data, isLoading } = useQuery( + api.warehouses.feePreview.queryOptions({ + input: { inventoryId: enabledId ?? '' }, + enabled: Boolean(enabledId), + }), + ); + const { data: invoices } = useQuery( + api.warehouses.invoicesForInventory.queryOptions({ + input: { inventoryId: enabledId ?? '' }, + enabled: Boolean(enabledId), + }), + ); + const generate = useMutation(api.warehouses.generateInvoice.mutationOptions()); + const gateClear = useMutation(api.warehouses.gateClearance.mutationOptions()); const activeInvoice = (invoices ?? []).find((i) => i.status !== 'CANCELLED'); diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/InspectionReportModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/InspectionReportModal.tsx index 15583345d..d56547455 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/InspectionReportModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/InspectionReportModal.tsx @@ -12,8 +12,10 @@ import { } from '@mantine/core'; import { Upload } from 'lucide-react'; +import { useMutation } from '@tanstack/react-query'; + +import { api } from '@/services/api'; import { useToast } from '@/hooks/use-toast'; -import { useCreateInspectionReport, useUploadInspectionAttachments } from '@/hooks/useWarehouses'; import { INSPECTION_REPORT_TYPES, INSPECTION_STATUSES, @@ -45,8 +47,12 @@ const STATUS_LABELS: Record = { /** Batch 4.5 — record an inspection / damage report with optional image upload. */ export function InspectionReportModal({ opened, onClose, inventoryId }: InspectionReportModalProps) { const { toast } = useToast(); - const createReport = useCreateInspectionReport(); - const uploadAttachments = useUploadInspectionAttachments(); + const createReport = useMutation( + api.warehouses.createInspectionReport.mutationOptions(), + ); + const uploadAttachments = useMutation( + api.warehouses.uploadInspectionAttachments.mutationOptions(), + ); const [reportType, setReportType] = useState('INSPECTION'); const [inspectionStatus, setInspectionStatus] = useState('PASSED'); diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryMovementHistoryTable.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryMovementHistoryTable.tsx index 352c6bff2..304e4ca75 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryMovementHistoryTable.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryMovementHistoryTable.tsx @@ -1,12 +1,19 @@ import { Center, Loader, Table, Text } from '@mantine/core'; -import { useInventoryMovements } from '@/hooks/useWarehouses'; +import { useQuery } from '@tanstack/react-query'; + +import { api } from '@/services/api'; import { formatDate } from './options'; const shortId = (id?: string | null) => (id ? `${id.slice(0, 8)}…` : '—'); export function InventoryMovementHistoryTable({ inventoryId }: { inventoryId: string }) { - const { data, isLoading } = useInventoryMovements(inventoryId); + const { data, isLoading } = useQuery( + api.warehouses.movements.queryOptions({ + input: { id: inventoryId }, + enabled: Boolean(inventoryId), + }), + ); const movements = data ?? []; if (isLoading) { diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx index 27ad0ebc6..b076510ab 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx @@ -2,14 +2,10 @@ import { useState } from 'react'; import { Button, Center, Group, Loader, Stack, Text } from '@mantine/core'; import { ClipboardCheck } from 'lucide-react'; +import { useMutation } from '@tanstack/react-query'; + +import { api } from '@/services/api'; import { useToast } from '@/hooks/use-toast'; -import { - useBulkMarkInspected, - useDispatchInventory, - useMarkReadyForLoading, - useMarkReadyForPickup, - useStoreInventory, -} from '@/hooks/useWarehouses'; import type { InventoryAction, WarehouseInventoryItem } from '@/types/warehouse'; import { DeliverInventoryModal } from './DeliverInventoryModal'; import { FeePreviewModal } from './FeePreviewModal'; @@ -42,11 +38,17 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo const [releaseItem, setReleaseItem] = useState(null); const [deliverItem, setDeliverItem] = useState(null); - const storeMutation = useStoreInventory(); - const readyMutation = useMarkReadyForLoading(); - const pickupMutation = useMarkReadyForPickup(); - const dispatchMutation = useDispatchInventory(); - const inspectMutation = useBulkMarkInspected(); + const storeMutation = useMutation(api.warehouses.store.mutationOptions()); + const readyMutation = useMutation( + api.warehouses.markReadyForLoading.mutationOptions(), + ); + const pickupMutation = useMutation( + api.warehouses.markReadyForPickup.mutationOptions(), + ); + const dispatchMutation = useMutation(api.warehouses.dispatch.mutationOptions()); + const inspectMutation = useMutation( + api.warehouses.bulkMarkInspected.mutationOptions(), + ); const [selected, setSelected] = useState>(new Set()); const allSelected = items.length > 0 && selected.size === items.length; @@ -66,10 +68,7 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo return; } try { - const res = (await inspectMutation.mutateAsync({ inventoryIds: [...selected] })) as { - data: { inspectedCount: number; skippedCount: number }; - }; - const r = res.data; + const r = await inspectMutation.mutateAsync({ inventoryIds: [...selected] }); toast({ title: `${r.inspectedCount} marked inspected`, description: r.skippedCount ? `${r.skippedCount} skipped` : undefined, diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/LoadInventoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/LoadInventoryModal.tsx index 8def582cb..7fc43c0d5 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/LoadInventoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/LoadInventoryModal.tsx @@ -2,8 +2,10 @@ import { useEffect, useState } from 'react'; import { Alert, Button, Group, Modal, NumberInput, Stack, Text, Textarea } from '@mantine/core'; import { Info } from 'lucide-react'; +import { useMutation } from '@tanstack/react-query'; + +import { api } from '@/services/api'; import { useToast } from '@/hooks/use-toast'; -import { useLoadInventory } from '@/hooks/useWarehouses'; import type { WarehouseInventoryItem } from '@/types/warehouse'; import { WagonSelect } from './WagonSelect'; import { extractErrorMessage } from './options'; @@ -17,7 +19,7 @@ interface LoadInventoryModalProps { /** Load READY_FOR_LOADING inventory onto a wagon (creates a loading record). */ export function LoadInventoryModal({ opened, onClose, item }: LoadInventoryModalProps) { const { toast } = useToast(); - const loadMutation = useLoadInventory(); + const loadMutation = useMutation(api.warehouses.load.mutationOptions()); const [wagonId, setWagonId] = useState(''); const [loadedWeight, setLoadedWeight] = useState(''); const [notes, setNotes] = useState(''); diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/MoveInventoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/MoveInventoryModal.tsx index b76458d5e..1ec596ffe 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/MoveInventoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/MoveInventoryModal.tsx @@ -1,8 +1,10 @@ import { useEffect, useMemo, useState } from 'react'; import { Button, Group, Modal, Select, Stack, Textarea } from '@mantine/core'; +import { useMutation, useQuery } from '@tanstack/react-query'; + +import { api } from '@/services/api'; import { useToast } from '@/hooks/use-toast'; -import { useMoveInventory, useWarehouseYards, useWarehouseZones, useWarehouses } from '@/hooks/useWarehouses'; import type { WarehouseInventoryItem } from '@/types/warehouse'; import { extractErrorMessage } from './options'; @@ -14,7 +16,7 @@ interface MoveInventoryModalProps { export function MoveInventoryModal({ opened, onClose, item }: MoveInventoryModalProps) { const { toast } = useToast(); - const moveMutation = useMoveInventory(); + const moveMutation = useMutation(api.warehouses.move.mutationOptions()); const [warehouseId, setWarehouseId] = useState(''); const [yardId, setYardId] = useState(''); const [zoneId, setZoneId] = useState(''); @@ -29,9 +31,21 @@ export function MoveInventoryModal({ opened, onClose, item }: MoveInventoryModal } }, [opened]); - const warehousesQuery = useWarehouses({ status: 'ACTIVE' }); - const yardsQuery = useWarehouseYards(warehouseId || undefined); - const zonesQuery = useWarehouseZones(yardId || undefined); + const warehousesQuery = useQuery( + api.warehouses.list.queryOptions({ input: { filter: { status: 'ACTIVE' } } }), + ); + const yardsQuery = useQuery( + api.warehouses.listYards.queryOptions({ + input: { warehouseId }, + enabled: Boolean(warehouseId), + }), + ); + const zonesQuery = useQuery( + api.warehouses.listZones.queryOptions({ + input: { yardId }, + enabled: Boolean(yardId), + }), + ); const warehouseOptions = useMemo( () => (warehousesQuery.data ?? []).map((w) => ({ value: w.id, label: `${w.name} (${w.code})` })), diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx index eab14ad63..3cd08461f 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx @@ -18,34 +18,14 @@ import { } from '@mantine/core'; import { ChevronDown, ChevronRight, ClipboardCheck, Info, PackageSearch, Train, Truck } from 'lucide-react'; +import { useMutation, useQuery } from '@tanstack/react-query'; + +import { api } from '@/services/api'; import { useToast } from '@/hooks/use-toast'; -import { - useAutoUnloadArrivedBookings, - useBulkDispatchExport, - useBulkMarkInspected, - useBulkReceive, - useEligibleBookings, - useImportArriveQueue, - useImportTrainItems, - useImportUnloadedQueue, - useLoadPassedExport, - useLoadedExport, - useReadyToLoadExport, - useReceiveInventory, - useWarehouseInventory, - useWarehouseYards, - useWarehouseZones, - useWarehouses, -} from '@/hooks/useWarehouses'; import type { - AutoUnloadArrivedResult, - BulkDispatchResult, - BulkInspectResult, - BulkReceiveResult, ImportTrain, ImportTrainItem, ImportUnloadedItem, - LoadPassedExportResult, ReadyToLoadRow, ReceiveInventoryPayload, } from '@/types/warehouse'; @@ -77,9 +57,21 @@ function LocationSelects({ value: Location; onChange: (next: Location) => void; }) { - const warehousesQuery = useWarehouses({ status: 'ACTIVE' }); - const yardsQuery = useWarehouseYards(value.warehouseId || undefined); - const zonesQuery = useWarehouseZones(value.yardId || undefined); + const warehousesQuery = useQuery( + api.warehouses.list.queryOptions({ input: { filter: { status: 'ACTIVE' } } }), + ); + const yardsQuery = useQuery( + api.warehouses.listYards.queryOptions({ + input: { warehouseId: value.warehouseId ?? '' }, + enabled: Boolean(value.warehouseId), + }), + ); + const zonesQuery = useQuery( + api.warehouses.listZones.queryOptions({ + input: { yardId: value.yardId ?? '' }, + enabled: Boolean(value.yardId), + }), + ); const warehouseOptions = useMemo( () => (warehousesQuery.data ?? []).map((w) => ({ value: w.id, label: `${w.name} (${w.code})` })), @@ -148,10 +140,12 @@ function EligibleTab({ onChanged?: () => void; }) { const { toast } = useToast(); - const { data: allRows = [], isLoading } = useEligibleBookings(enabled); + const { data: allRows = [], isLoading } = useQuery( + api.warehouses.eligibleBookings.queryOptions({ enabled }), + ); const rows = useMemo(() => allRows.filter((r) => r.direction === direction), [allRows, direction]); - const bulkReceive = useBulkReceive(); - const loadPassed = useLoadPassedExport(); + const bulkReceive = useMutation(api.warehouses.bulkReceive.mutationOptions()); + const loadPassed = useMutation(api.warehouses.loadPassedExport.mutationOptions()); const [selected, setSelected] = useState>(new Set()); const locationReady = Boolean(location.warehouseId && location.yardId && location.zoneId); @@ -177,10 +171,7 @@ function EligibleTab({ return; } try { - const res = (await bulkReceive.mutateAsync({ direction, ...location, bookingIds })) as { - data: BulkReceiveResult; - }; - const r = res.data; + const r = await bulkReceive.mutateAsync({ direction, ...location, bookingIds }); toast({ title: `${r.receivedCount} received at ${direction === 'EXPORT' ? 'facility' : 'warehouse'}`, description: r.skippedCount ? `${r.skippedCount} skipped` : undefined, @@ -194,8 +185,7 @@ function EligibleTab({ const loadPassedExport = async () => { try { - const res = (await loadPassed.mutateAsync(undefined)) as { data: LoadPassedExportResult }; - const r = res.data; + const r = await loadPassed.mutateAsync(undefined); toast({ title: `${r.loadedCount} loaded`, description: r.skippedCount ? `${r.skippedCount} skipped — inspection not passed` : undefined, @@ -353,8 +343,10 @@ function EligibleTab({ /** Export items that passed inspection and are queued to be loaded onto a train. */ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?: () => void }) { const { toast } = useToast(); - const { data: rows = [], isLoading } = useReadyToLoadExport(enabled); - const loadPassed = useLoadPassedExport(); + const { data: rows = [], isLoading } = useQuery( + api.warehouses.readyToLoadExport.queryOptions({ enabled }), + ); + const loadPassed = useMutation(api.warehouses.loadPassedExport.mutationOptions()); const [selected, setSelected] = useState>(new Set()); const allSelected = rows.length > 0 && selected.size === rows.length; @@ -369,8 +361,7 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?: const autoLoad = async () => { try { - const res = (await loadPassed.mutateAsync(undefined)) as { data: LoadPassedExportResult }; - const r = res.data; + const r = await loadPassed.mutateAsync(undefined); toast({ title: `${r.loadedCount} items loaded`, description: r.skippedCount ? `${r.skippedCount} skipped` : undefined, @@ -494,8 +485,12 @@ function LoadedExportTab({ onChanged?: () => void; }) { const { toast } = useToast(); - const { data: rows = [], isLoading } = useLoadedExport(enabled); - const bulkDispatch = useBulkDispatchExport(); + const { data: rows = [], isLoading } = useQuery( + api.warehouses.loadedExport.queryOptions({ enabled }), + ); + const bulkDispatch = useMutation( + api.warehouses.bulkDispatchExport.mutationOptions(), + ); const [selected, setSelected] = useState>(new Set()); const allSelected = rows.length > 0 && selected.size === rows.length; @@ -514,8 +509,7 @@ function LoadedExportTab({ return; } try { - const res = (await bulkDispatch.mutateAsync(inventoryIds)) as { data: BulkDispatchResult }; - const r = res.data; + const r = await bulkDispatch.mutateAsync(inventoryIds); toast({ title: `${r.dispatchedCount} dispatched`, description: r.skippedCount ? `${r.skippedCount} skipped` : undefined, @@ -659,7 +653,12 @@ function LoadedExportTab({ /** Assigned bookings/items for an arrived import train (read-only detail view). */ function ImportTrainDetailTable({ scheduleId }: { scheduleId: string }) { - const { data: items = [], isLoading } = useImportTrainItems(scheduleId); + const { data: items = [], isLoading } = useQuery( + api.warehouses.importTrainItems.queryOptions({ + input: { scheduleId }, + enabled: Boolean(scheduleId), + }), + ); if (isLoading) { return ( @@ -735,18 +734,19 @@ function ImportArriveQueueTab({ onChanged?: () => void; }) { const { toast } = useToast(); - const { data: trains = [], isLoading } = useImportArriveQueue(enabled); - const autoUnloadMutation = useAutoUnloadArrivedBookings(); + const { data: trains = [], isLoading } = useQuery( + api.warehouses.importArriveQueue.queryOptions({ enabled }), + ); + const autoUnloadMutation = useMutation( + api.warehouses.autoUnloadArrivedBookings.mutationOptions(), + ); const [openId, setOpenId] = useState(null); const [busyId, setBusyId] = useState(null); const autoUnload = async (train: ImportTrain) => { setBusyId(train.scheduleId); try { - const res = (await autoUnloadMutation.mutateAsync(train.scheduleId)) as { - data: AutoUnloadArrivedResult; - }; - const r = res.data; + const r = await autoUnloadMutation.mutateAsync(train.scheduleId); const extra = [ r.skippedCount ? `${r.skippedCount} skipped` : '', r.failedCount ? `${r.failedCount} failed` : '', @@ -866,8 +866,12 @@ function ImportArriveQueueTab({ */ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { const { toast } = useToast(); - const { data: rows = [], isLoading } = useImportUnloadedQueue(enabled); - const inspectMutation = useBulkMarkInspected(); + const { data: rows = [], isLoading } = useQuery( + api.warehouses.importUnloadedQueue.queryOptions({ enabled }), + ); + const inspectMutation = useMutation( + api.warehouses.bulkMarkInspected.mutationOptions(), + ); const [selected, setSelected] = useState>(new Set()); const [inspectId, setInspectId] = useState(null); @@ -888,10 +892,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { return; } try { - const res = (await inspectMutation.mutateAsync({ inventoryIds: [...selected] })) as { - data: BulkInspectResult; - }; - const r = res.data; + const r = await inspectMutation.mutateAsync({ inventoryIds: [...selected] }); toast({ title: `${r.inspectedCount} marked inspected`, description: r.skippedCount ? `${r.skippedCount} skipped` : undefined, @@ -1033,8 +1034,10 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { */ function ImportDispatchQueueTab({ enabled }: { enabled: boolean }) { const { toast } = useToast(); - const { data: items = [], isLoading } = useWarehouseInventory( - enabled ? { status: 'READY_FOR_PICKUP' } : undefined, + const { data: items = [], isLoading } = useQuery( + api.warehouses.listInventory.queryOptions({ + input: { filter: enabled ? { status: 'READY_FOR_PICKUP' } : undefined }, + }), ); return ( @@ -1155,7 +1158,9 @@ function SingleBookingReceiveModal({ onReceived, }: ReceiveInventoryModalProps) { const { toast } = useToast(); - const receiveMutation = useReceiveInventory(); + const receiveMutation = useMutation( + api.warehouses.receiveInventory.mutationOptions(), + ); const [selectedBooking, setSelectedBooking] = useState(bookingId ?? ''); const [form, setForm] = useState({ warehouseId: '', diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx index 851066713..f2edd620b 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx @@ -2,8 +2,10 @@ import { useEffect, useState } from 'react'; import { Alert, Button, Group, Modal, Stack, Text, TextInput } from '@mantine/core'; import { Info } from 'lucide-react'; +import { useMutation } from '@tanstack/react-query'; + +import { api } from '@/services/api'; import { useToast } from '@/hooks/use-toast'; -import { useReleaseInventory } from '@/hooks/useWarehouses'; import type { WarehouseInventoryItem } from '@/types/warehouse'; import { extractErrorMessage } from './options'; @@ -15,7 +17,7 @@ interface ReleaseOrderModalProps { export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalProps) { const { toast } = useToast(); - const releaseMutation = useReleaseInventory(); + const releaseMutation = useMutation(api.warehouses.release.mutationOptions()); const [reference, setReference] = useState(''); useEffect(() => { diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ReserveInventoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReserveInventoryModal.tsx index 7a7ebb55b..d436bb6f2 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReserveInventoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReserveInventoryModal.tsx @@ -2,8 +2,10 @@ import { useEffect, useState } from 'react'; import { Alert, Button, Group, Modal, Stack, Text } from '@mantine/core'; import { Info } from 'lucide-react'; +import { useMutation } from '@tanstack/react-query'; + +import { api } from '@/services/api'; import { useToast } from '@/hooks/use-toast'; -import { useReserveInventory } from '@/hooks/useWarehouses'; import type { WarehouseInventoryItem } from '@/types/warehouse'; import { BookingSelect } from './BookingSelect'; import { extractErrorMessage } from './options'; @@ -16,7 +18,7 @@ interface ReserveInventoryModalProps { export function ReserveInventoryModal({ opened, onClose, item }: ReserveInventoryModalProps) { const { toast } = useToast(); - const reserveMutation = useReserveInventory(); + const reserveMutation = useMutation(api.warehouses.reserve.mutationOptions()); const [bookingId, setBookingId] = useState(''); useEffect(() => { diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/WagonSelect.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/WagonSelect.tsx index d6cc8c2cd..298df191b 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/WagonSelect.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/WagonSelect.tsx @@ -1,6 +1,7 @@ import { Select } from '@mantine/core'; +import { useQuery } from '@tanstack/react-query'; -import { useLoadableWagons } from '@/hooks/useWarehouses'; +import { api } from '@/services/api'; interface WagonSelectProps { value: string; @@ -11,7 +12,9 @@ interface WagonSelectProps { /** Searchable wagon picker. Lists wagons that are loadable (read-only from scheduling). */ export function WagonSelect({ value, onChange, label = 'Wagon', required }: WagonSelectProps) { - const { data, isLoading } = useLoadableWagons(); + const { data, isLoading } = useQuery( + api.warehouses.loadableWagons.queryOptions(), + ); const options = (data ?? []).map((w) => ({ value: w.id, diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseCardView.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseCardView.tsx index 2d3dd2276..bd76b5e10 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseCardView.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseCardView.tsx @@ -2,7 +2,9 @@ import { useMemo } from 'react'; import { ActionIcon, Card, Group, SimpleGrid, Stack, Text } from '@mantine/core'; import { Building2, Eye, MapPin, Pencil } from 'lucide-react'; -import { useStations } from '@/hooks/useStations'; +import { useQuery } from '@tanstack/react-query'; + +import { api } from '@/services/api'; import type { Warehouse } from '@/types/warehouse'; import { WarehouseStatusBadge, WarehouseTypeBadge } from './badges'; import { formatCapacity } from './options'; @@ -14,7 +16,9 @@ interface WarehouseCardViewProps { } export function WarehouseCardView({ warehouses, onView, onEdit }: WarehouseCardViewProps) { - const { data: stations } = useStations(); + const { data: stations } = useQuery( + api.stations.list.queryOptions({ staleTime: 5 * 60 * 1000 }), + ); const stationNameById = useMemo( () => new Map((stations ?? []).map((s) => [s.id, s.name])), [stations], diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseDashboardCharts.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseDashboardCharts.tsx index b2a628d88..993544fe3 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseDashboardCharts.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseDashboardCharts.tsx @@ -15,7 +15,9 @@ import { YAxis, } from 'recharts'; -import { useWarehouseInventory } from '@/hooks/useWarehouses'; +import { useQuery } from '@tanstack/react-query'; + +import { api } from '@/services/api'; import type { WarehouseDashboard, WarehouseInventoryItem } from '@/types/warehouse'; interface WarehouseDashboardChartsProps { @@ -38,7 +40,9 @@ type Granularity = 'week' | 'month' | 'year'; export function WarehouseDashboardCharts({ data }: WarehouseDashboardChartsProps) { const [granularity, setGranularity] = useState('month'); - const { data: inventory } = useWarehouseInventory(); + const { data: inventory } = useQuery( + api.warehouses.listInventory.queryOptions({ input: {} }), + ); const statusData = STATUS_SERIES.map((s) => ({ name: s.label, diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInfoCard.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInfoCard.tsx index 83dcc4d63..e28513821 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInfoCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInfoCard.tsx @@ -2,7 +2,9 @@ import { useState } from 'react'; import { Badge, Button, Card, Divider, Group, Stack, Text } from '@mantine/core'; import { PackagePlus, Train as TrainIcon, Warehouse as WarehouseIcon } from 'lucide-react'; -import { useBookingSchedule, useWarehouseInventory } from '@/hooks/useWarehouses'; +import { useQuery } from '@tanstack/react-query'; + +import { api } from '@/services/api'; import { InventoryStatusBadge } from './badges'; import { FreightVisual } from './FreightVisual'; import { formatDate } from './options'; @@ -28,8 +30,15 @@ function Row({ label, value }: { label: string; value: React.ReactNode }) { export function WarehouseInfoCard({ bookingId, bookingReference }: WarehouseInfoCardProps) { const [modalOpen, setModalOpen] = useState(false); - const { data, isLoading } = useWarehouseInventory({ bookingId }); - const { data: scheduleView } = useBookingSchedule(bookingId); + const { data, isLoading } = useQuery( + api.warehouses.listInventory.queryOptions({ input: { filter: { bookingId } } }), + ); + const { data: scheduleView } = useQuery( + api.warehouses.bookingSchedule.queryOptions({ + input: { bookingId }, + enabled: Boolean(bookingId), + }), + ); const items = data ?? []; const latest = items[0]; diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseTable.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseTable.tsx index 80b85f693..7f8dea0e5 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseTable.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseTable.tsx @@ -3,7 +3,9 @@ import { ActionIcon, Group, Text } from '@mantine/core'; import { Eye, Pencil } from 'lucide-react'; import { DataTable, type ColumnDef } from '@edr/ui-common'; -import { useStations } from '@/hooks/useStations'; +import { useQuery } from '@tanstack/react-query'; + +import { api } from '@/services/api'; import type { Warehouse } from '@/types/warehouse'; import { WarehouseStatusBadge, WarehouseTypeBadge } from './badges'; import { formatCapacity } from './options'; @@ -15,7 +17,9 @@ interface WarehouseTableProps { } export function WarehouseTable({ warehouses, onView, onEdit }: WarehouseTableProps) { - const { data: stations } = useStations(); + const { data: stations } = useQuery( + api.stations.list.queryOptions({ staleTime: 5 * 60 * 1000 }), + ); const stationNameById = useMemo( () => new Map((stations ?? []).map((s) => [s.id, s.name])), [stations], diff --git a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts index e3e1921de..aced8cda4 100644 --- a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts @@ -1,8 +1,9 @@ +import type { FleetResourceSlug } from "@/pages/fleet/config/resources"; import type { BookingListFilter } from "@/services/bookings.service"; import type { RuleEngineListParams } from "@/services/ruleEngine/ruleEngine.service"; -import type { TrainScheduleFilters } from "@/types/trainScheduling"; -import type { FleetResourceSlug } from "@/pages/fleet/config/resources"; +import type { CompanyListFilter } from "@/types/customer"; import type { RuleEngineResourceSlug } from "@/types/rule-engine"; +import type { TrainScheduleFilters } from "@/types/trainScheduling"; export const QUERY_KEYS = { USERS: { @@ -26,8 +27,13 @@ export const QUERY_KEYS = { CUSTOMERS: { ROOT: ["customers"] as const, - list: () => ["customers", "list"] as const, + stats: ["customers", "stats"] as const, + list: (filter?: CompanyListFilter) => + ["customers", "list", filter ?? {}] as const, byId: (id: string) => ["customers", "detail", id] as const, + bookings: (id: string) => ["customers", "detail", id, "bookings"] as const, + documents: (id: string) => ["customers", "detail", id, "documents"] as const, + payments: (id: string) => ["customers", "detail", id, "payments"] as const, }, BOOKINGS: { diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index 832f923a0..77c4ea10e 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -71,7 +71,12 @@ export const URL_CONSTANTS = { COMPANIES: { BASE: "/companies", + STATS: "/companies/stats", BY_ID: (id: string | number) => `/companies/${id}`, + DOCUMENTS: (id: string) => `/companies/${id}/documents`, + PROFILE_STATUS: (profileId: string) => `/companies/company-profiles/${profileId}/status`, + BOOKINGS_CUSTOMER_VIEW: (id: string) => `/bookings/by-company/${id}/customer-view`, + PAYMENTS_CUSTOMER_VIEW: (id: string) => `/payments/by-company/${id}/customer-view`, }, CUSTOMERS_API: { diff --git a/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts b/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts index e96716be4..a7c8670cf 100644 --- a/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts +++ b/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts @@ -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'; diff --git a/apps/edr-freight-web/backoffice/src/hooks/fleet/useFleet.ts b/apps/edr-freight-web/backoffice/src/hooks/fleet/useFleet.ts deleted file mode 100644 index 29e7bf62b..000000000 --- a/apps/edr-freight-web/backoffice/src/hooks/fleet/useFleet.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; - -import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; -import type { FleetListFilters, FleetResourceSlug } from "@/services/fleet/fleet.service"; -import { fleetService } from "@/services/fleet/fleet.service"; - -export function useFleetList(slug: FleetResourceSlug, filters?: FleetListFilters) { - return useQuery({ - queryKey: [...QUERY_KEYS.FLEET.list(slug), filters ?? {}], - queryFn: () => fleetService.list(slug, filters), - }); -} - -export function useFleetMutations(slug: FleetResourceSlug) { - const qc = useQueryClient(); - const invalidate = () => qc.invalidateQueries({ queryKey: QUERY_KEYS.FLEET.list(slug) }); - - const create = useMutation({ - mutationFn: (data: Record) => fleetService.create(slug, data), - onSuccess: invalidate, - }); - - const update = useMutation({ - mutationFn: ({ id, data }: { id: string; data: Record }) => - fleetService.update(slug, id, data), - onSuccess: invalidate, - }); - - const remove = useMutation({ - mutationFn: (id: string) => fleetService.remove(slug, id), - onSuccess: invalidate, - }); - - return { create, update, remove }; -} diff --git a/apps/edr-freight-web/backoffice/src/hooks/trainScheduling/useTrainScheduling.ts b/apps/edr-freight-web/backoffice/src/hooks/trainScheduling/useTrainScheduling.ts deleted file mode 100644 index 823d13324..000000000 --- a/apps/edr-freight-web/backoffice/src/hooks/trainScheduling/useTrainScheduling.ts +++ /dev/null @@ -1,325 +0,0 @@ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; - -import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; -import { trainSchedulingService } from "@/services/trainScheduling.service"; -import type { - AssignBookingsPayload, - CreateTrainSchedulePayload, - FreightType, - PinWagonsPayload, - RecordCheckpointPayload, - TrainScheduleFilters, - TrainSchedulePreviewPayload, -} from "@/types/trainScheduling"; - -export const useScheduleList = (freightType?: FreightType) => - useQuery({ - queryKey: QUERY_KEYS.TRAIN_SCHEDULING.schedules(), - queryFn: () => trainSchedulingService.listSchedules(freightType), - }); - -export const useBatchBoard = () => - useQuery({ - queryKey: QUERY_KEYS.TRAIN_SCHEDULING.batchBoard(), - queryFn: () => trainSchedulingService.getBatchBoard(), - refetchInterval: 30_000, - }); - -export const useBatchBoardDetail = (scheduleId: string | undefined) => - useQuery({ - queryKey: QUERY_KEYS.TRAIN_SCHEDULING.batchBoardDetail(scheduleId ?? ""), - queryFn: () => trainSchedulingService.getBatchBoardDetail(scheduleId!), - enabled: Boolean(scheduleId), - refetchInterval: 30_000, - }); - -export const useRunAllocation = (scheduleId: string) => { - const qc = useQueryClient(); - return useMutation({ - mutationFn: () => trainSchedulingService.runAllocation(scheduleId), - onSuccess: () => { - void qc.invalidateQueries({ - queryKey: QUERY_KEYS.TRAIN_SCHEDULING.batchBoardDetail(scheduleId), - }); - void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.batchBoard() }); - }, - }); -}; - -export const useScheduleDetail = (id: string | undefined, freightType?: FreightType) => - useQuery({ - queryKey: QUERY_KEYS.TRAIN_SCHEDULING.scheduleById(id ?? ""), - queryFn: () => trainSchedulingService.getScheduleById(id!, freightType), - enabled: Boolean(id), - }); - -export const useEligibleBookings = ( - filters?: TrainScheduleFilters, - enabled = true, - freightType?: FreightType, -) => - useQuery({ - queryKey: QUERY_KEYS.TRAIN_SCHEDULING.eligible(freightType, filters), - queryFn: () => trainSchedulingService.getEligibleBookings(filters, freightType), - enabled, - }); - -export const useAvailableLocomotives = (routeId?: string) => - useQuery({ - queryKey: QUERY_KEYS.TRAIN_SCHEDULING.locomotives(routeId), - queryFn: () => trainSchedulingService.getAvailableLocomotives(routeId), - enabled: routeId ? Boolean(routeId) : true, - }); - -export const useBatchActions = (scheduleId?: string) => { - const qc = useQueryClient(); - const invalidate = () => { - void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.ROOT }); - void qc.invalidateQueries({ queryKey: QUERY_KEYS.BOOKINGS.ROOT }); - void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.batchBoard() }); - if (scheduleId) { - void qc.invalidateQueries({ - queryKey: QUERY_KEYS.TRAIN_SCHEDULING.scheduleById(scheduleId), - }); - void qc.invalidateQueries({ - queryKey: QUERY_KEYS.TRAIN_SCHEDULING.batchBoardDetail(scheduleId), - }); - } - }; - - const runBatch = useMutation({ - mutationFn: (id: string) => trainSchedulingService.runBatch(id), - onSuccess: invalidate, - }); - const setWindow = useMutation({ - mutationFn: ({ id, status }: { id: string; status: "OPEN" | "CLOSED" }) => - trainSchedulingService.setBookingWindow(id, status), - onSuccess: invalidate, - }); - const markPaid = useMutation({ - mutationFn: (bookingId: string) => trainSchedulingService.markBookingPaid(bookingId), - onSuccess: invalidate, - }); - const expire = useMutation({ - mutationFn: (bookingId: string) => trainSchedulingService.expireBooking(bookingId), - onSuccess: invalidate, - }); - const moveSchedule = useMutation({ - mutationFn: ({ bookingId, trainScheduleId }: { bookingId: string; trainScheduleId: string }) => - trainSchedulingService.moveBookingSchedule(bookingId, trainScheduleId), - onSuccess: invalidate, - }); - - return { runBatch, setWindow, markPaid, expire, moveSchedule, invalidate }; -}; - -export const useBookableSchedules = ( - originYardId?: string | null, - destinationYardId?: string | null, -) => - useQuery({ - queryKey: [ - ...QUERY_KEYS.TRAIN_SCHEDULING.ROOT, - "bookable", - originYardId ?? "", - destinationYardId ?? "", - ], - queryFn: () => - trainSchedulingService.getBookableSchedules( - originYardId ?? undefined, - destinationYardId ?? undefined, - ), - enabled: Boolean(originYardId && destinationYardId), - }); - -/** - * Day-level pool: which days have an OPEN departure on the route. Staff pick a - * day (not a train) when creating a booking; the engine assigns the train. - */ -export const useAvailableDays = ( - originYardId?: string | null, - destinationYardId?: string | null, -) => - useQuery({ - queryKey: [ - ...QUERY_KEYS.TRAIN_SCHEDULING.ROOT, - "available-days", - originYardId ?? "", - destinationYardId ?? "", - ], - queryFn: () => - trainSchedulingService.getAvailableDays( - originYardId ?? undefined, - destinationYardId ?? undefined, - ), - enabled: Boolean(originYardId && destinationYardId), - }); - -export const useTrainTrack = (id: string | undefined) => - useQuery({ - queryKey: QUERY_KEYS.TRAIN_SCHEDULING.track(id ?? ""), - queryFn: () => trainSchedulingService.getTrack(id!), - enabled: Boolean(id), - }); - -export const useScheduleMutations = (scheduleId?: string) => { - const qc = useQueryClient(); - - const invalidate = () => { - void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.ROOT }); - void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.schedules() }); - void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.locomotives() }); - if (scheduleId) { - void qc.invalidateQueries({ - queryKey: QUERY_KEYS.TRAIN_SCHEDULING.scheduleById(scheduleId), - }); - void qc.invalidateQueries({ - queryKey: QUERY_KEYS.TRAIN_SCHEDULING.track(scheduleId), - }); - void qc.invalidateQueries({ - queryKey: QUERY_KEYS.TRAIN_SCHEDULING.unassignedBookings(scheduleId), - }); - void qc.invalidateQueries({ - queryKey: QUERY_KEYS.TRAIN_SCHEDULING.compositionRemovals(scheduleId), - }); - } - void qc.invalidateQueries({ queryKey: QUERY_KEYS.BOOKINGS.ROOT }); - }; - - const create = useMutation({ - mutationFn: ({ - freightType, - payload, - }: { - freightType?: FreightType; - payload: CreateTrainSchedulePayload; - }) => trainSchedulingService.createSchedule(payload, freightType), - onSuccess: invalidate, - }); - - const preview = useMutation({ - mutationFn: ({ - freightType, - payload, - }: { - freightType?: FreightType; - payload: TrainSchedulePreviewPayload; - }) => trainSchedulingService.preview(payload, freightType), - }); - - const assign = useMutation({ - mutationFn: ({ - id, - freightType, - payload, - }: { - id: string; - freightType?: FreightType; - payload: AssignBookingsPayload; - }) => trainSchedulingService.assignBookings(id, payload, freightType), - onSuccess: invalidate, - }); - - const assignUnassigned = useMutation({ - mutationFn: ({ id, bookingId }: { id: string; bookingId: string }) => - trainSchedulingService.assignUnassignedBooking(id, bookingId), - onSuccess: invalidate, - }); - - const unassign = useMutation({ - mutationFn: ({ id, bookingId }: { id: string; bookingId: string }) => - trainSchedulingService.unassignBooking(id, bookingId), - onSuccess: invalidate, - }); - - const pin = useMutation({ - mutationFn: ({ id, payload }: { id: string; payload: PinWagonsPayload }) => - trainSchedulingService.pinWagons(id, payload), - onSuccess: invalidate, - }); - - const finalize = useMutation({ - mutationFn: (id: string) => trainSchedulingService.finalizeSchedule(id), - onSuccess: invalidate, - }); - - const dispatch = useMutation({ - mutationFn: (id: string) => trainSchedulingService.dispatchSchedule(id), - onSuccess: invalidate, - }); - - const cancel = useMutation({ - mutationFn: ({ id, freightType }: { id: string; freightType?: FreightType }) => - trainSchedulingService.cancelSchedule(id, freightType ?? "CONTAINER"), - onSuccess: invalidate, - }); - - const recordCheckpoint = useMutation({ - mutationFn: ({ id, payload }: { id: string; payload: RecordCheckpointPayload }) => - trainSchedulingService.recordCheckpoint(id, payload), - onSuccess: invalidate, - }); - - const arrive = useMutation({ - mutationFn: (id: string) => trainSchedulingService.arriveSchedule(id), - onSuccess: invalidate, - }); - - return { - create, - preview, - assign, - assignUnassigned, - unassign, - pin, - finalize, - dispatch, - cancel, - recordCheckpoint, - arrive, - invalidate, - }; -}; - -export const useUnassignedBookings = (scheduleId: string | undefined) => - useQuery({ - queryKey: QUERY_KEYS.TRAIN_SCHEDULING.unassignedBookings(scheduleId ?? ""), - queryFn: () => trainSchedulingService.getUnassignedBookings(scheduleId!), - enabled: Boolean(scheduleId), - }); - -export const useCompositionRemovals = (scheduleId: string | undefined) => - useQuery({ - queryKey: QUERY_KEYS.TRAIN_SCHEDULING.compositionRemovals(scheduleId ?? ""), - queryFn: () => trainSchedulingService.getCompositionRemovals(scheduleId!), - enabled: Boolean(scheduleId), - }); - -export const useRemoveWagonSlot = (scheduleId: string) => { - const qc = useQueryClient(); - return useMutation({ - mutationFn: (wagonId: string) => - trainSchedulingService.removeWagonSlot(scheduleId, wagonId), - onSuccess: () => { - void qc.invalidateQueries({ - queryKey: QUERY_KEYS.TRAIN_SCHEDULING.scheduleById(scheduleId), - }); - void qc.invalidateQueries({ - queryKey: QUERY_KEYS.TRAIN_SCHEDULING.batchBoardDetail(scheduleId), - }); - }, - }); -}; - -export const useUpdateContainerItem = (scheduleId: string) => { - const qc = useQueryClient(); - return useMutation({ - mutationFn: ({ itemId, containerNumber }: { itemId: string; containerNumber: string | null }) => - trainSchedulingService.updateContainerItem(scheduleId, itemId, { containerNumber }), - onSuccess: () => { - void qc.invalidateQueries({ - queryKey: QUERY_KEYS.TRAIN_SCHEDULING.scheduleById(scheduleId), - }); - }, - }); -}; diff --git a/apps/edr-freight-web/backoffice/src/hooks/use-cargo-types.ts b/apps/edr-freight-web/backoffice/src/hooks/use-cargo-types.ts deleted file mode 100644 index 864e9732c..000000000 --- a/apps/edr-freight-web/backoffice/src/hooks/use-cargo-types.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; -import { cargoTypesService } from '@/services/cargo-types.service'; - -export const CARGO_TYPES_QUERY_KEY = ['cargo-types']; - -export function useCargoTypes() { - return useQuery({ - queryKey: CARGO_TYPES_QUERY_KEY, - queryFn: () => cargoTypesService.getCargoTypes(), - staleTime: Infinity, - }); -} \ No newline at end of file diff --git a/apps/edr-freight-web/backoffice/src/hooks/use-container-types.ts b/apps/edr-freight-web/backoffice/src/hooks/use-container-types.ts deleted file mode 100644 index c216c2adf..000000000 --- a/apps/edr-freight-web/backoffice/src/hooks/use-container-types.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; -import { containerTypesService } from '@/services/container-types.service'; - -export const CONTAINER_TYPES_QUERY_KEY = ['container-types']; - -export function useContainerTypes() { - return useQuery({ - queryKey: CONTAINER_TYPES_QUERY_KEY, - queryFn: () => containerTypesService.getContainerTypes(), - staleTime: Infinity, - }); -} \ No newline at end of file diff --git a/apps/edr-freight-web/backoffice/src/hooks/use-wagon-types.ts b/apps/edr-freight-web/backoffice/src/hooks/use-wagon-types.ts deleted file mode 100644 index 88566c776..000000000 --- a/apps/edr-freight-web/backoffice/src/hooks/use-wagon-types.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { wagonTypesService } from '@/services/wagon-types.service'; - -export const WAGON_TYPES_QUERY_KEY = ['wagon-types']; - -export function useWagonTypes() { - return useQuery({ - queryKey: WAGON_TYPES_QUERY_KEY, - queryFn: () => wagonTypesService.getWagonTypes(), - }); -} - -export function useCreateWagonType() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: wagonTypesService.create, - onSuccess: () => qc.invalidateQueries({ queryKey: WAGON_TYPES_QUERY_KEY }), - }); -} - -export function useUpdateWagonType() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: ({ id, data }: { id: string; data: Record }) => - wagonTypesService.update(id, data), - onSuccess: () => qc.invalidateQueries({ queryKey: WAGON_TYPES_QUERY_KEY }), - }); -} - -export function useDeleteWagonType() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: wagonTypesService.delete, - onSuccess: () => qc.invalidateQueries({ queryKey: WAGON_TYPES_QUERY_KEY }), - }); -} diff --git a/apps/edr-freight-web/backoffice/src/hooks/useCargoes.ts b/apps/edr-freight-web/backoffice/src/hooks/useCargoes.ts deleted file mode 100644 index 3c1cdf517..000000000 --- a/apps/edr-freight-web/backoffice/src/hooks/useCargoes.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { cargoService, type DeliverCargoPayload } from '@/services/cargoService'; - -export const cargoKeys = { - all: ['cargoes'] as const, - byContainer: (containerId: string) => [...cargoKeys.all, 'container', containerId] as const, - details: () => [...cargoKeys.all, 'detail'] as const, - detail: (id: string) => [...cargoKeys.details(), id] as const, -}; - -export function useCargoes() { - return useQuery({ queryKey: cargoKeys.all, queryFn: () => cargoService.getAll().then(res => res.data) }); -} - -export const useGetCargoes = useCargoes; - -export function useCargoesByContainer(containerId: string) { - return useQuery({ queryKey: cargoKeys.byContainer(containerId), queryFn: () => cargoService.getByContainer(containerId).then(res => res.data), enabled: !!containerId }); -} - -export function useCargo(id: string) { - return useQuery({ queryKey: cargoKeys.detail(id), queryFn: () => cargoService.getById(id).then(res => res.data), enabled: !!id }); -} - -export const useGetCargo = useCargo; - -export function useCreateCargo() { - const qc = useQueryClient(); - return useMutation({ mutationFn: cargoService.create, onSuccess: () => qc.invalidateQueries({ queryKey: cargoKeys.all }) }); -} - -export function useUpdateCargo() { - const qc = useQueryClient(); - return useMutation({ mutationFn: ({ id, data }: any) => cargoService.update(id, data), onSuccess: (_, { id }) => { - qc.invalidateQueries({ queryKey: cargoKeys.all }); - qc.invalidateQueries({ queryKey: cargoKeys.detail(id) }); - } }); -} - -export function useDeleteCargo() { - const qc = useQueryClient(); - return useMutation({ mutationFn: cargoService.delete, onSuccess: () => qc.invalidateQueries({ queryKey: cargoKeys.all }) }); -} - -export function useLoadCargo() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: ({ id, quantity, weight, volume }: any) => cargoService.load(id, quantity, weight, volume), - onSuccess: (_, { id }) => qc.invalidateQueries({ queryKey: cargoKeys.all }) - }); -} - -export function useDeliverCargo() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: ({ id, payload }: { id: string; payload?: DeliverCargoPayload }) => - cargoService.deliver(id, payload), - onSuccess: () => qc.invalidateQueries({ queryKey: cargoKeys.all }) - }); -} - -export function useUnloadCargo() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: (id: string) => cargoService.unload(id), - onSuccess: () => qc.invalidateQueries({ queryKey: cargoKeys.all }) - }); -} diff --git a/apps/edr-freight-web/backoffice/src/hooks/useContainers.ts b/apps/edr-freight-web/backoffice/src/hooks/useContainers.ts deleted file mode 100644 index b14d9b5c5..000000000 --- a/apps/edr-freight-web/backoffice/src/hooks/useContainers.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { containerService } from '@/services/containerService'; - -export const containerKeys = { - all: ['containers'] as const, - byWagon: (wagonId: string) => [...containerKeys.all, 'wagon', wagonId] as const, - details: () => [...containerKeys.all, 'detail'] as const, - detail: (id: string) => [...containerKeys.details(), id] as const, -}; - -export function useContainers() { - return useQuery({ queryKey: containerKeys.all, queryFn: () => containerService.getAll().then(res => res.data) }); -} - -export const useGetContainers = useContainers; - -export function useContainersByWagon(wagonId: string) { - return useQuery({ queryKey: containerKeys.byWagon(wagonId), queryFn: () => containerService.getByWagon(wagonId).then(res => res.data), enabled: !!wagonId }); -} - -export function useContainer(id: string) { - return useQuery({ queryKey: containerKeys.detail(id), queryFn: () => containerService.getById(id).then(res => res.data), enabled: !!id }); -} - -export const useGetContainer = useContainer; - -export function useCreateContainer() { - const qc = useQueryClient(); - return useMutation({ mutationFn: containerService.create, onSuccess: () => qc.invalidateQueries({ queryKey: containerKeys.all }) }); -} - -export function useUpdateContainer() { - const qc = useQueryClient(); - return useMutation({ mutationFn: ({ id, data }: any) => containerService.update(id, data), onSuccess: (_, { id }) => { - qc.invalidateQueries({ queryKey: containerKeys.all }); - qc.invalidateQueries({ queryKey: containerKeys.detail(id) }); - } }); -} - -export function useDeleteContainer() { - const qc = useQueryClient(); - return useMutation({ mutationFn: containerService.delete, onSuccess: () => qc.invalidateQueries({ queryKey: containerKeys.all }) }); -} - -export function useAssignContainerToWagon() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: ({ containerId, wagonId, position }: any) => containerService.assignToWagon(containerId, wagonId, position), - onSuccess: (_, { wagonId }) => qc.invalidateQueries({ queryKey: containerKeys.byWagon(wagonId) }) - }); -} - -export function useUnassignContainer() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: containerService.unassign, - onSuccess: () => qc.invalidateQueries({ queryKey: containerKeys.all }) - }); -} diff --git a/apps/edr-freight-web/backoffice/src/hooks/useDropdownSettings.ts b/apps/edr-freight-web/backoffice/src/hooks/useDropdownSettings.ts deleted file mode 100644 index 256fc1702..000000000 --- a/apps/edr-freight-web/backoffice/src/hooks/useDropdownSettings.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { useMutation, useQueryClient } from "@tanstack/react-query"; - -import { api } from "@/services/api"; -import type { - CreateDropdownOptionDto, - CreateDropdownSettingDto, - UpdateDropdownOptionDto, - UpdateDropdownSettingDto, -} from "@/types/dropdownSettings"; - -/* ----------------------------- Mutations ----------------------------- */ - -export const useCreateDropdownSetting = () => { - const qc = useQueryClient(); - return useMutation({ - mutationFn: (dto: CreateDropdownSettingDto) => - api.dropdownSettings.create.call(dto), - onSuccess: () => - qc.invalidateQueries({ queryKey: api.dropdownSettings.list.queryKey() }), - }); -}; - -export const useUpdateDropdownSetting = () => { - const qc = useQueryClient(); - return useMutation({ - mutationFn: ({ - id, - dto, - }: { - id: string; - dto: UpdateDropdownSettingDto; - }) => api.dropdownSettings.update.call({ id, dto }), - onSuccess: (_data, { id }) => { - qc.invalidateQueries({ queryKey: api.dropdownSettings.list.queryKey() }); - qc.invalidateQueries({ - queryKey: api.dropdownSettings.getById.queryKey({ id }), - }); - }, - }); -}; - -export const useDeleteDropdownSetting = () => { - const qc = useQueryClient(); - return useMutation({ - mutationFn: (id: string) => api.dropdownSettings.remove.call({ id }), - onSuccess: () => - qc.invalidateQueries({ queryKey: api.dropdownSettings.list.queryKey() }), - }); -}; - -export const useReplaceDropdownOptions = () => { - const qc = useQueryClient(); - return useMutation({ - mutationFn: ({ - settingId, - options, - }: { - settingId: string; - options: CreateDropdownOptionDto[]; - }) => api.dropdownSettings.replaceOptions.call({ id: settingId, options }), - onSuccess: (_data, { settingId }) => { - qc.invalidateQueries({ queryKey: api.dropdownSettings.list.queryKey() }); - qc.invalidateQueries({ - queryKey: api.dropdownSettings.getById.queryKey({ id: settingId }), - }); - }, - }); -}; - -export const useAddDropdownOption = () => { - const qc = useQueryClient(); - return useMutation({ - mutationFn: ({ - settingId, - dto, - }: { - settingId: string; - dto: CreateDropdownOptionDto; - }) => api.dropdownSettings.addOption.call({ id: settingId, dto }), - onSuccess: (_data, { settingId }) => { - qc.invalidateQueries({ queryKey: api.dropdownSettings.list.queryKey() }); - qc.invalidateQueries({ - queryKey: api.dropdownSettings.getById.queryKey({ id: settingId }), - }); - }, - }); -}; - -export const useUpdateDropdownOption = () => { - const qc = useQueryClient(); - return useMutation({ - mutationFn: ({ - optionId, - dto, - }: { - optionId: string; - dto: UpdateDropdownOptionDto; - }) => api.dropdownSettings.updateOption.call({ optionId, dto }), - onSuccess: () => - qc.invalidateQueries({ queryKey: api.dropdownSettings.list.queryKey() }), - }); -}; - -export const useRemoveDropdownOption = () => { - const qc = useQueryClient(); - return useMutation({ - mutationFn: (optionId: string) => - api.dropdownSettings.removeOption.call({ optionId }), - onSuccess: () => - qc.invalidateQueries({ queryKey: api.dropdownSettings.list.queryKey() }), - }); -}; diff --git a/apps/edr-freight-web/backoffice/src/hooks/useFacilities.ts b/apps/edr-freight-web/backoffice/src/hooks/useFacilities.ts deleted file mode 100644 index 481378ba7..000000000 --- a/apps/edr-freight-web/backoffice/src/hooks/useFacilities.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; - -import { facilityService } from '@/services/facility.service'; - -export const facilityKeys = { - all: ['facilities'] as const, - list: () => ['facilities', 'list'] as const, - detail: (id: string) => ['facilities', 'detail', id] as const, -}; - -export function useFacilities() { - return useQuery({ - queryKey: facilityKeys.list(), - queryFn: () => facilityService.list().then((r) => r.data), - }); -} diff --git a/apps/edr-freight-web/backoffice/src/hooks/useFileUploadSettings.ts b/apps/edr-freight-web/backoffice/src/hooks/useFileUploadSettings.ts deleted file mode 100644 index 748de4f88..000000000 --- a/apps/edr-freight-web/backoffice/src/hooks/useFileUploadSettings.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { useMutation, useQueryClient } from "@tanstack/react-query"; - -import { api } from "@/services/api"; -import type { - CreateFileUploadFieldDto, - CreateFileUploadSettingDto, - UpdateFileUploadFieldDto, - UpdateFileUploadSettingDto, -} from "@/types/fileUploadSettings"; - -/* ----------------------------- Mutations ----------------------------- */ - -export const useCreateFileUploadSetting = () => { - const qc = useQueryClient(); - return useMutation({ - mutationFn: (dto: CreateFileUploadSettingDto) => - api.fileUploadSettings.create.call(dto), - onSuccess: () => - qc.invalidateQueries({ - queryKey: api.fileUploadSettings.list.queryKey(), - }), - }); -}; - -export const useUpdateFileUploadSetting = () => { - const qc = useQueryClient(); - return useMutation({ - mutationFn: ({ - id, - dto, - }: { - id: string; - dto: UpdateFileUploadSettingDto; - }) => api.fileUploadSettings.update.call({ id, dto }), - onSuccess: (_data, { id }) => { - qc.invalidateQueries({ - queryKey: api.fileUploadSettings.list.queryKey(), - }); - qc.invalidateQueries({ - queryKey: api.fileUploadSettings.getById.queryKey({ id }), - }); - }, - }); -}; - -export const useDeleteFileUploadSetting = () => { - const qc = useQueryClient(); - return useMutation({ - mutationFn: (id: string) => api.fileUploadSettings.remove.call({ id }), - onSuccess: () => - qc.invalidateQueries({ - queryKey: api.fileUploadSettings.list.queryKey(), - }), - }); -}; - -export const useReplaceFileUploadFields = () => { - const qc = useQueryClient(); - return useMutation({ - mutationFn: ({ - settingId, - fields, - }: { - settingId: string; - fields: CreateFileUploadFieldDto[]; - }) => api.fileUploadSettings.replaceFields.call({ id: settingId, fields }), - onSuccess: (_data, { settingId }) => { - qc.invalidateQueries({ - queryKey: api.fileUploadSettings.list.queryKey(), - }); - qc.invalidateQueries({ - queryKey: api.fileUploadSettings.getById.queryKey({ id: settingId }), - }); - }, - }); -}; - -export const useAddFileUploadField = () => { - const qc = useQueryClient(); - return useMutation({ - mutationFn: ({ - settingId, - dto, - }: { - settingId: string; - dto: CreateFileUploadFieldDto; - }) => api.fileUploadSettings.addField.call({ settingId, dto }), - onSuccess: (_data, { settingId }) => { - qc.invalidateQueries({ - queryKey: api.fileUploadSettings.list.queryKey(), - }); - qc.invalidateQueries({ - queryKey: api.fileUploadSettings.getById.queryKey({ id: settingId }), - }); - }, - }); -}; - -export const useUpdateFileUploadField = () => { - const qc = useQueryClient(); - return useMutation({ - mutationFn: ({ - fieldId, - dto, - }: { - fieldId: string; - dto: UpdateFileUploadFieldDto; - }) => api.fileUploadSettings.updateField.call({ fieldId, dto }), - onSuccess: () => - qc.invalidateQueries({ - queryKey: api.fileUploadSettings.list.queryKey(), - }), - }); -}; - -export const useRemoveFileUploadField = () => { - const qc = useQueryClient(); - return useMutation({ - mutationFn: (fieldId: string) => - api.fileUploadSettings.removeField.call({ fieldId }), - onSuccess: () => - qc.invalidateQueries({ - queryKey: api.fileUploadSettings.list.queryKey(), - }), - }); -}; diff --git a/apps/edr-freight-web/backoffice/src/hooks/useLocomotives.ts b/apps/edr-freight-web/backoffice/src/hooks/useLocomotives.ts deleted file mode 100644 index ee50bd59e..000000000 --- a/apps/edr-freight-web/backoffice/src/hooks/useLocomotives.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; - -import { locomotivesService } from '@/services/locomotives.service'; - -export const locomotiveKeys = { - all: ['locomotives'] as const, - details: () => [...locomotiveKeys.all, 'detail'] as const, - detail: (id: string) => [...locomotiveKeys.details(), id] as const, -}; - -export function useLocomotives() { - return useQuery({ - queryKey: locomotiveKeys.all, - queryFn: () => locomotivesService.getAll().then((response) => response.data), - }); -} - -export function useCreateLocomotive() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: locomotivesService.create, - onSuccess: () => qc.invalidateQueries({ queryKey: locomotiveKeys.all }), - }); -} - -export function useUpdateLocomotive() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: ({ id, data }: { id: string; data: Record }) => - locomotivesService.update(id, data), - onSuccess: (_, { id }) => { - qc.invalidateQueries({ queryKey: locomotiveKeys.all }); - qc.invalidateQueries({ queryKey: locomotiveKeys.detail(id) }); - }, - }); -} - -export function useDecommissionLocomotive() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: locomotivesService.decommission, - onSuccess: (_, id) => { - qc.invalidateQueries({ queryKey: locomotiveKeys.all }); - qc.invalidateQueries({ queryKey: locomotiveKeys.detail(id) }); - }, - }); -} diff --git a/apps/edr-freight-web/backoffice/src/hooks/usePayments.ts b/apps/edr-freight-web/backoffice/src/hooks/usePayments.ts deleted file mode 100644 index e9a09760b..000000000 --- a/apps/edr-freight-web/backoffice/src/hooks/usePayments.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; - -import { - paymentsService, - type PaymentListFilter, -} from "@/services/payments.service"; - -export function usePaymentList(filter?: PaymentListFilter, enabled = true) { - return useQuery({ - queryKey: ["payments", "list", filter ?? {}], - queryFn: () => paymentsService.list(filter), - enabled, - }); -} - -export function usePaymentSummary(enabled = true) { - return useQuery({ - queryKey: ["payments", "summary"], - queryFn: () => paymentsService.getSummary(), - staleTime: 30_000, - enabled, - }); -} diff --git a/apps/edr-freight-web/backoffice/src/hooks/useRoutes.ts b/apps/edr-freight-web/backoffice/src/hooks/useRoutes.ts deleted file mode 100644 index 3ae4ba924..000000000 --- a/apps/edr-freight-web/backoffice/src/hooks/useRoutes.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; - -import { routesService } from '@/services/routes.service'; - -export const routeKeys = { - all: ['routes'] as const, - yards: ['routes', 'yards'] as const, - details: () => [...routeKeys.all, 'detail'] as const, - detail: (id: string) => [...routeKeys.details(), id] as const, -}; - -export function useRoutes() { - return useQuery({ - queryKey: routeKeys.all, - queryFn: () => routesService.getAll().then((response) => response.data), - }); -} - -export function useRouteYards() { - return useQuery({ - queryKey: routeKeys.yards, - queryFn: () => routesService.getYards().then((response) => response.data.data), - }); -} - -export function useCreateRoute() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: routesService.create, - onSuccess: () => qc.invalidateQueries({ queryKey: routeKeys.all }), - }); -} - -export function useUpdateRoute() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: ({ id, data }: { id: string; data: Record }) => - routesService.update(id, data), - onSuccess: (_, { id }) => { - qc.invalidateQueries({ queryKey: routeKeys.all }); - qc.invalidateQueries({ queryKey: routeKeys.detail(id) }); - }, - }); -} - -export function useDeactivateRoute() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: routesService.deactivate, - onSuccess: (_, id) => { - qc.invalidateQueries({ queryKey: routeKeys.all }); - qc.invalidateQueries({ queryKey: routeKeys.detail(id) }); - }, - }); -} diff --git a/apps/edr-freight-web/backoffice/src/hooks/useSavedSignature.ts b/apps/edr-freight-web/backoffice/src/hooks/useSavedSignature.ts deleted file mode 100644 index b8c9480a7..000000000 --- a/apps/edr-freight-web/backoffice/src/hooks/useSavedSignature.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import toast from "react-hot-toast"; - -import { - signaturesService, - type SaveSignaturePayload, -} from "@/services/signatures.service"; - -const SAVED_SIGNATURE_KEY = ["me", "signature"] as const; - -export function useMySignature() { - return useQuery({ - queryKey: SAVED_SIGNATURE_KEY, - queryFn: () => signaturesService.getMySignature(), - staleTime: 60_000, - }); -} - -export function useSaveSignature() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: (payload: SaveSignaturePayload) => - signaturesService.saveMySignature(payload), - onSuccess: () => { - toast.success("Signature saved"); - void qc.invalidateQueries({ queryKey: SAVED_SIGNATURE_KEY }); - }, - onError: () => toast.error("Failed to save signature"), - }); -} diff --git a/apps/edr-freight-web/backoffice/src/hooks/useStations.ts b/apps/edr-freight-web/backoffice/src/hooks/useStations.ts deleted file mode 100644 index b7ab95d0a..000000000 --- a/apps/edr-freight-web/backoffice/src/hooks/useStations.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; - -import { trainSchedulingService } from '@/services/trainScheduling.service'; -import { QUERY_KEYS } from '@/constants/QUERY_KEYS'; - -/** - * The 21 network stations / yards, sourced from the existing booking - * reference-data API. Reused as the parent "Facility / Port" for warehouses. - */ -export function useStations() { - return useQuery({ - queryKey: QUERY_KEYS.TRAIN_SCHEDULING.stations(), - queryFn: () => trainSchedulingService.getStations(), - staleTime: 5 * 60 * 1000, - }); -} diff --git a/apps/edr-freight-web/backoffice/src/hooks/useTrains.ts b/apps/edr-freight-web/backoffice/src/hooks/useTrains.ts deleted file mode 100644 index c2b24d294..000000000 --- a/apps/edr-freight-web/backoffice/src/hooks/useTrains.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { trainService } from '@/services/trains.service'; - -export const trainKeys = { - all: ['trains'] as const, - lists: () => [...trainKeys.all, 'list'] as const, - details: () => [...trainKeys.all, 'detail'] as const, - detail: (id: string) => [...trainKeys.details(), id] as const, -}; - -export function useTrains() { - return useQuery({ queryKey: trainKeys.lists(), queryFn: () => trainService.getAll().then(res => res.data) }); -} - -export const useGetTrains = useTrains; - -export function useTrain(id: string) { - return useQuery({ queryKey: trainKeys.detail(id), queryFn: () => trainService.getById(id).then(res => res.data), enabled: !!id }); -} - -export const useGetTrain = useTrain; - -export function useCreateTrain() { - const qc = useQueryClient(); - return useMutation({ mutationFn: trainService.create, onSuccess: () => qc.invalidateQueries({ queryKey: trainKeys.lists() }) }); -} - -export function useUpdateTrain() { - const qc = useQueryClient(); - return useMutation({ mutationFn: ({ id, data }: any) => trainService.update(id, data), onSuccess: (_, { id }) => { - qc.invalidateQueries({ queryKey: trainKeys.lists() }); - qc.invalidateQueries({ queryKey: trainKeys.detail(id) }); - } }); -} - -export function useDeleteTrain() { - const qc = useQueryClient(); - return useMutation({ mutationFn: trainService.delete, onSuccess: () => qc.invalidateQueries({ queryKey: trainKeys.lists() }) }); -} diff --git a/apps/edr-freight-web/backoffice/src/hooks/useWagons.ts b/apps/edr-freight-web/backoffice/src/hooks/useWagons.ts deleted file mode 100644 index b672a2a84..000000000 --- a/apps/edr-freight-web/backoffice/src/hooks/useWagons.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { wagonService } from '@/services/wagon.service'; - -export type WagonListFilters = import('@/services/wagon.service').WagonListFilters; - -export const wagonKeys = { - all: ['wagons'] as const, - list: (filters?: WagonListFilters) => [...wagonKeys.all, 'list', filters ?? {}] as const, - byTrain: (trainId: string) => [...wagonKeys.all, 'train', trainId] as const, - details: () => [...wagonKeys.all, 'detail'] as const, - detail: (id: string) => [...wagonKeys.details(), id] as const, -}; - -export function useWagons(filters?: WagonListFilters) { - return useQuery({ - queryKey: wagonKeys.list(filters), - queryFn: () => wagonService.getAll(filters ?? {}).then((res) => res.data), - }); -} - -export const useGetWagons = useWagons; - -export function useWagonsByTrain(trainId: string) { - return useQuery({ queryKey: wagonKeys.byTrain(trainId), queryFn: () => wagonService.getByTrain(trainId).then(res => res.data), enabled: !!trainId }); -} - -export function useWagon(id: string) { - return useQuery({ queryKey: wagonKeys.detail(id), queryFn: () => wagonService.getById(id).then(res => res.data), enabled: !!id }); -} - -export const useGetWagon = useWagon; - -export function useAssignWagonToTrain() { - const qc = useQueryClient(); - return useMutation({ mutationFn: ({ wagonId, trainId, sequenceNumber }: any) => wagonService.assignToTrain(wagonId, trainId, sequenceNumber), onSuccess: (_, { trainId }) => qc.invalidateQueries({ queryKey: wagonKeys.byTrain(trainId) }) }); -} - -export function useUnassignWagon() { - const qc = useQueryClient(); - return useMutation({ mutationFn: wagonService.unassign, onSuccess: () => qc.invalidateQueries({ queryKey: wagonKeys.all }) }); -} - -export function useReorderWagons() { - const qc = useQueryClient(); - return useMutation({ mutationFn: ({ trainId, wagonIds }: any) => wagonService.reorder(trainId, wagonIds), onSuccess: (_, { trainId }) => qc.invalidateQueries({ queryKey: wagonKeys.byTrain(trainId) }) }); -} - -export function useCreateWagon() { - const qc = useQueryClient(); - return useMutation({ mutationFn: wagonService.create, onSuccess: () => qc.invalidateQueries({ queryKey: wagonKeys.all }) }); -} - -export function useUpdateWagon() { - const qc = useQueryClient(); - return useMutation({ mutationFn: ({ id, data }: any) => wagonService.update(id, data), onSuccess: (_, { id }) => { - qc.invalidateQueries({ queryKey: wagonKeys.all }); - qc.invalidateQueries({ queryKey: wagonKeys.detail(id) }); - } }); -} - -export function useDeleteWagon() { - const qc = useQueryClient(); - return useMutation({ mutationFn: wagonService.delete, onSuccess: () => qc.invalidateQueries({ queryKey: wagonKeys.all }) }); -} diff --git a/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts b/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts deleted file mode 100644 index 5e50c151a..000000000 --- a/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts +++ /dev/null @@ -1,505 +0,0 @@ -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; - -import { warehouseService } from '@/services/warehouse.service'; -import type { - InspectionReportPayload, - SaveAllocationRulePayload, - SaveFeeRulePayload, - WarehouseInvoiceFilter, - PayInvoicePayload, - InventoryFilter, - InventoryInquiryFilter, - LoadInventoryPayload, - MoveInventoryPayload, - ReceiveInventoryPayload, - ReleaseOrderPayload, - DeliverInventoryPayload, - BulkReceivePayload, - BulkInspectPayload, - ReserveInventoryPayload, - SaveWarehousePayload, - SaveYardPayload, - SaveZonePayload, - WarehouseFilter, -} from '@/types/warehouse'; - -export const warehouseKeys = { - all: ['warehouses'] as const, - list: (filter?: WarehouseFilter) => ['warehouses', 'list', filter ?? {}] as const, - detail: (id: string) => ['warehouses', 'detail', id] as const, - yards: (warehouseId: string) => ['warehouses', warehouseId, 'yards'] as const, - zones: (yardId: string) => ['warehouse-yards', yardId, 'zones'] as const, - inventory: (filter?: InventoryFilter) => ['warehouse-inventory', 'list', filter ?? {}] as const, - inquiry: (filter: InventoryInquiryFilter) => ['warehouse-inventory', 'inquiry', filter] as const, -}; - -// ── Warehouses ───────────────────────────────────────────────────────────── - -export function useWarehouses(filter?: WarehouseFilter) { - return useQuery({ - queryKey: warehouseKeys.list(filter), - queryFn: () => warehouseService.list(filter).then((r) => r.data), - }); -} - -export function useWarehouse(id?: string) { - return useQuery({ - queryKey: warehouseKeys.detail(id ?? ''), - queryFn: () => warehouseService.getById(id as string).then((r) => r.data), - enabled: Boolean(id), - }); -} - -export function useCreateWarehouse() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: (payload: SaveWarehousePayload) => warehouseService.create(payload), - onSuccess: () => qc.invalidateQueries({ queryKey: warehouseKeys.all }), - }); -} - -export function useUpdateWarehouse() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: ({ id, payload }: { id: string; payload: Partial }) => - warehouseService.update(id, payload), - onSuccess: (_, { id }) => { - qc.invalidateQueries({ queryKey: warehouseKeys.all }); - qc.invalidateQueries({ queryKey: warehouseKeys.detail(id) }); - }, - }); -} - -// ── Yards ──────────────────────────────────────────────────────────────── - -export function useWarehouseYards(warehouseId?: string) { - return useQuery({ - queryKey: warehouseKeys.yards(warehouseId ?? ''), - queryFn: () => warehouseService.listYards(warehouseId as string).then((r) => r.data), - enabled: Boolean(warehouseId), - }); -} - -export function useCreateYard() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: ({ warehouseId, payload }: { warehouseId: string; payload: SaveYardPayload }) => - warehouseService.createYard(warehouseId, payload), - onSuccess: (_, { warehouseId }) => { - qc.invalidateQueries({ queryKey: warehouseKeys.yards(warehouseId) }); - qc.invalidateQueries({ queryKey: warehouseKeys.detail(warehouseId) }); - }, - }); -} - -export function useUpdateYard() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: ({ id, payload }: { id: string; payload: Partial }) => - warehouseService.updateYard(id, payload), - onSuccess: () => qc.invalidateQueries({ queryKey: warehouseKeys.all }), - }); -} - -// ── Zones ────────────────────────────────────────────────────────────────── - -export function useWarehouseZones(yardId?: string) { - return useQuery({ - queryKey: warehouseKeys.zones(yardId ?? ''), - queryFn: () => warehouseService.listZones(yardId as string).then((r) => r.data), - enabled: Boolean(yardId), - }); -} - -export function useCreateZone() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: ({ yardId, payload }: { yardId: string; payload: SaveZonePayload }) => - warehouseService.createZone(yardId, payload), - onSuccess: (_, { yardId }) => qc.invalidateQueries({ queryKey: warehouseKeys.zones(yardId) }), - }); -} - -export function useUpdateZone() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: ({ id, payload }: { id: string; payload: Partial }) => - warehouseService.updateZone(id, payload), - onSuccess: () => qc.invalidateQueries({ queryKey: ['warehouse-yards'] }), - }); -} - -// ── Inventory ────────────────────────────────────────────────────────────── - -export function useWarehouseInventory(filter?: InventoryFilter) { - return useQuery({ - queryKey: warehouseKeys.inventory(filter), - queryFn: () => warehouseService.listInventory(filter).then((r) => r.data), - }); -} - -export function useReceiveInventory() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: (payload: ReceiveInventoryPayload) => warehouseService.receiveInventory(payload), - onSuccess: () => { - qc.invalidateQueries({ queryKey: ['warehouse-inventory'] }); - qc.invalidateQueries({ queryKey: warehouseKeys.all }); - }, - }); -} - -function useInventoryMutation(fn: (args: TArgs) => Promise) { - const qc = useQueryClient(); - return useMutation({ - mutationFn: fn, - onSuccess: () => { - qc.invalidateQueries({ queryKey: ['warehouse-inventory'] }); - qc.invalidateQueries({ queryKey: ['warehouse-loadings'] }); - qc.invalidateQueries({ queryKey: warehouseKeys.all }); - }, - }); -} - -export const useStoreInventory = () => useInventoryMutation((id: string) => warehouseService.store(id)); -export const useReserveInventory = () => - useInventoryMutation((payload: ReserveInventoryPayload) => warehouseService.reserve(payload)); -export const useMarkReadyForLoading = () => - useInventoryMutation((id: string) => warehouseService.markReadyForLoading(id)); -export const useLoadInventory = () => - useInventoryMutation((args: { id: string; payload: LoadInventoryPayload }) => - warehouseService.load(args.id, args.payload), - ); -export const useDispatchInventory = () => useInventoryMutation((id: string) => warehouseService.dispatch(id)); -export const useMoveInventory = () => - useInventoryMutation((args: { id: string; payload: MoveInventoryPayload }) => - warehouseService.move(args.id, args.payload), - ); - -// ── Import branch (READY_FOR_PICKUP → DELIVERED) ─────────────────────────── -export const useMarkReadyForPickup = () => - useInventoryMutation((id: string) => warehouseService.markReadyForPickup(id)); -export const useReleaseInventory = () => - useInventoryMutation((args: { id: string; payload: ReleaseOrderPayload }) => - warehouseService.release(args.id, args.payload), - ); -export const useDeliverInventory = () => - useInventoryMutation((args: { id: string; payload: DeliverInventoryPayload }) => - warehouseService.deliver(args.id, args.payload), - ); - -// ── Receive (Import/Export bulk) ─────────────────────────────────────────── -/** - * All not-yet-received PAID bookings, classified IMPORT/EXPORT by route, in one call. - * Both Receive tabs share this single query (same key) — only one HTTP request fires — - * then filter client-side by direction. - */ -export function useEligibleBookings(enabled = true) { - return useQuery({ - queryKey: ['warehouse-inventory', 'eligible-bookings'], - queryFn: () => warehouseService.eligibleBookings().then((r) => r.data), - enabled, - }); -} -export const useBulkReceive = () => - useInventoryMutation((payload: BulkReceivePayload) => warehouseService.receiveBulk(payload)); -export const useLoadPassedExport = () => - useInventoryMutation(() => warehouseService.loadPassedExport()); -export const useBulkMarkInspected = () => - useInventoryMutation((payload: BulkInspectPayload) => warehouseService.bulkMarkInspected(payload)); - -export function useReadyToLoadExport(enabled = true) { - return useQuery({ - queryKey: ['warehouse-inventory', 'ready-to-load-export'], - queryFn: () => warehouseService.readyToLoadExport().then((r) => r.data), - enabled, - }); -} - -export function useLoadedExport(enabled = true) { - return useQuery({ - queryKey: ['warehouse-inventory', 'loaded-export'], - queryFn: () => warehouseService.loadedExport().then((r) => r.data), - enabled, - }); -} - -export const useBulkDispatchExport = () => - useInventoryMutation((inventoryIds: string[]) => warehouseService.bulkDispatchExport(inventoryIds)); - -/** Arrived IMPORT trains (route-derived). Read-only. */ -export function useImportArriveQueue(enabled = true) { - return useQuery({ - queryKey: ['warehouse-inventory', 'import-arrive-queue'], - queryFn: () => warehouseService.importArriveQueue().then((r) => r.data), - enabled, - }); -} - -/** Assigned bookings/items for an arrived import train. Read-only. */ -export function useImportTrainItems(scheduleId?: string) { - return useQuery({ - queryKey: ['warehouse-inventory', 'import-train-items', scheduleId], - queryFn: () => warehouseService.importTrainItems(scheduleId as string).then((r) => r.data), - enabled: Boolean(scheduleId), - }); -} - -/** Unload all eligible assigned bookings of an ARRIVED import train (→ UNLOADED). */ -export const useAutoUnloadArrivedBookings = () => - useInventoryMutation((scheduleId: string) => warehouseService.autoUnloadArrivedBookings(scheduleId)); - -/** IMPORT inventory in the Unloaded Queue (UNLOADED / destination inspection). Read-only. */ -export function useImportUnloadedQueue(enabled = true) { - return useQuery({ - queryKey: ['warehouse-inventory', 'import-unloaded-queue'], - queryFn: () => warehouseService.importUnloadedQueue().then((r) => r.data), - enabled, - }); -} - -/** IMPORT inventory that is PICKUP_READY (READY_FOR_PICKUP) awaiting pickup/dispatch. Read-only. */ -export function useImportPickupReadyQueue(enabled = true) { - return useQuery({ - queryKey: ['warehouse-inventory', 'import-pickup-ready-queue'], - queryFn: () => warehouseService.importPickupReadyQueue().then((r) => r.data), - enabled, - }); -} - -// ── Loading (Batch 3) ──────────────────────────────────────────────────────── - -export function useLoadableWagons(enabled = true) { - return useQuery({ - queryKey: ['warehouse', 'loadable-wagons'], - queryFn: () => warehouseService.loadableWagons().then((r) => r.data), - enabled, - }); -} - -export function useWarehouseLoadings(params?: { bookingId?: string; wagonId?: string }) { - return useQuery({ - queryKey: ['warehouse-loadings', params ?? {}], - queryFn: () => warehouseService.loadings(params).then((r) => r.data), - }); -} - -export function useBookingSchedule(bookingId?: string) { - return useQuery({ - queryKey: ['warehouse', 'booking-schedule', bookingId ?? ''], - queryFn: () => warehouseService.bookingSchedule(bookingId as string).then((r) => r.data), - enabled: Boolean(bookingId), - }); -} - -export function useInventoryMovements(id?: string) { - return useQuery({ - queryKey: ['warehouse-inventory', id, 'movements'], - queryFn: () => warehouseService.movements(id as string).then((r) => r.data), - enabled: Boolean(id), - }); -} - -export function useInventoryActivity(id?: string) { - return useQuery({ - queryKey: ['warehouse-inventory', id, 'activity'], - queryFn: () => warehouseService.activity(id as string).then((r) => r.data), - enabled: Boolean(id), - }); -} - -export function useWarehouseDashboard() { - return useQuery({ - queryKey: ['warehouses', 'dashboard'], - queryFn: () => warehouseService.dashboard().then((r) => r.data), - }); -} - -export function useInventoryInquiry(filter: InventoryInquiryFilter, enabled = true) { - return useQuery({ - queryKey: warehouseKeys.inquiry(filter), - queryFn: () => warehouseService.inquiry(filter).then((r) => r.data), - enabled, - }); -} - -// ── Batch 4.5: Arrival / Unload / Inspection ──────────────────────────────── - -export function useArrivalQueue() { - return useQuery({ - queryKey: ['warehouse-inventory', 'arrival-queue'], - queryFn: () => warehouseService.arrivalQueue().then((r) => r.data), - }); -} - -function useArrivalInvalidation() { - const qc = useQueryClient(); - return () => { - qc.invalidateQueries({ queryKey: ['warehouse-inventory'] }); - qc.invalidateQueries({ queryKey: warehouseKeys.all }); - }; -} - -export function useAutoUnloadArrived() { - const onSuccess = useArrivalInvalidation(); - return useMutation({ mutationFn: () => warehouseService.autoUnloadArrived(), onSuccess }); -} - -export function useAutoLoadReady() { - const onSuccess = useArrivalInvalidation(); - return useMutation({ mutationFn: () => warehouseService.autoLoadReady(), onSuccess }); -} - -export function useUnloadBooking() { - const onSuccess = useArrivalInvalidation(); - return useMutation({ - mutationFn: (args: { bookingId: string; payload?: Record }) => - warehouseService.unloadBooking(args.bookingId, args.payload), - onSuccess, - }); -} - -export function useInspectionReports(inventoryId?: string) { - return useQuery({ - queryKey: ['warehouse-inventory', inventoryId, 'inspection-reports'], - queryFn: () => warehouseService.listInspectionReports(inventoryId as string).then((r) => r.data), - enabled: Boolean(inventoryId), - }); -} - -export function useCreateInspectionReport() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: ({ inventoryId, payload }: { inventoryId: string; payload: InspectionReportPayload }) => - warehouseService.createInspectionReport(inventoryId, payload).then((r) => r.data), - onSuccess: (_, { inventoryId }) => { - qc.invalidateQueries({ queryKey: ['warehouse-inventory', inventoryId, 'inspection-reports'] }); - qc.invalidateQueries({ queryKey: ['warehouse-inventory'] }); - }, - }); -} - -export function useUploadInspectionAttachments() { - return useMutation({ - mutationFn: ({ reportId, files }: { reportId: string; files: File[] }) => - warehouseService.uploadInspectionAttachments(reportId, files), - }); -} - -// ── Batch 5: Allocation + Fee rules / preview ─────────────────────────────── - -export function useAllocationRules() { - return useQuery({ - queryKey: ['warehouse-allocation-rules'], - queryFn: () => warehouseService.listAllocationRules().then((r) => r.data), - }); -} - -export function useFeeRules() { - return useQuery({ - queryKey: ['warehouse-fee-rules'], - queryFn: () => warehouseService.listFeeRules().then((r) => r.data), - }); -} - -function useRuleMutation(fn: (args: TArgs) => Promise, keys: string[]) { - const qc = useQueryClient(); - return useMutation({ - mutationFn: fn, - onSuccess: () => keys.forEach((k) => qc.invalidateQueries({ queryKey: [k] })), - }); -} - -export const useCreateAllocationRule = () => - useRuleMutation( - (payload: SaveAllocationRulePayload) => warehouseService.createAllocationRule(payload), - ['warehouse-allocation-rules'], - ); -export const useUpdateAllocationRule = () => - useRuleMutation( - (args: { id: string; payload: Partial }) => - warehouseService.updateAllocationRule(args.id, args.payload), - ['warehouse-allocation-rules'], - ); -export const useDeleteAllocationRule = () => - useRuleMutation((id: string) => warehouseService.deleteAllocationRule(id), ['warehouse-allocation-rules']); - -export const useCreateFeeRule = () => - useRuleMutation((payload: SaveFeeRulePayload) => warehouseService.createFeeRule(payload), ['warehouse-fee-rules']); -export const useUpdateFeeRule = () => - useRuleMutation( - (args: { id: string; payload: Partial }) => - warehouseService.updateFeeRule(args.id, args.payload), - ['warehouse-fee-rules'], - ); -export const useDeleteFeeRule = () => - useRuleMutation((id: string) => warehouseService.deleteFeeRule(id), ['warehouse-fee-rules']); - -export function useFeePreview(inventoryId?: string) { - return useQuery({ - queryKey: ['warehouse-inventory', inventoryId, 'fee-preview'], - queryFn: () => warehouseService.feePreview(inventoryId as string).then((r) => r.data), - enabled: Boolean(inventoryId), - }); -} - -// ── Batch 6: Warehouse fee invoices ───────────────────────────────────────── - -export function useWarehouseInvoices(filter?: WarehouseInvoiceFilter) { - return useQuery({ - queryKey: ['warehouse-fee-invoices', filter ?? {}], - queryFn: () => warehouseService.listInvoices(filter).then((r) => r.data), - }); -} - -export function useWarehouseInvoice(id?: string) { - return useQuery({ - queryKey: ['warehouse-fee-invoices', 'detail', id], - queryFn: () => warehouseService.getInvoice(id as string).then((r) => r.data), - enabled: Boolean(id), - }); -} - -export function useInvoicesForInventory(inventoryId?: string) { - return useQuery({ - queryKey: ['warehouse-inventory', inventoryId, 'fee-invoices'], - queryFn: () => warehouseService.invoicesForInventory(inventoryId as string).then((r) => r.data), - enabled: Boolean(inventoryId), - }); -} - -function useInvoiceInvalidation() { - const qc = useQueryClient(); - return () => { - qc.invalidateQueries({ queryKey: ['warehouse-fee-invoices'] }); - qc.invalidateQueries({ queryKey: ['warehouse-inventory'] }); - }; -} - -export function useGenerateInvoice() { - const onSuccess = useInvoiceInvalidation(); - return useMutation({ - mutationFn: ({ inventoryId, confirmZero }: { inventoryId: string; confirmZero?: boolean }) => - warehouseService.generateInvoice(inventoryId, confirmZero).then((r) => r.data), - onSuccess, - }); -} - -export function useCancelInvoice() { - const onSuccess = useInvoiceInvalidation(); - return useMutation({ mutationFn: (id: string) => warehouseService.cancelInvoice(id), onSuccess }); -} - -export function usePayInvoice() { - const onSuccess = useInvoiceInvalidation(); - return useMutation({ - mutationFn: ({ id, payload }: { id: string; payload: PayInvoicePayload }) => - warehouseService.payInvoice(id, payload), - onSuccess, - }); -} - -export function useGateClearance() { - const onSuccess = useInvoiceInvalidation(); - return useMutation({ mutationFn: (inventoryId: string) => warehouseService.gateClearance(inventoryId), onSuccess }); -} diff --git a/apps/edr-freight-web/backoffice/src/lib/queryClient.ts b/apps/edr-freight-web/backoffice/src/lib/queryClient.ts index 9ac8402d8..32b94f325 100644 --- a/apps/edr-freight-web/backoffice/src/lib/queryClient.ts +++ b/apps/edr-freight-web/backoffice/src/lib/queryClient.ts @@ -1,7 +1,29 @@ -import { QueryClient } from "@tanstack/react-query"; +import { MutationCache, QueryClient } from "@tanstack/react-query"; -/** Single app-wide React Query client (do not nest additional providers). */ +import type { InvalidatesMeta } from "@/utils/endpoint"; + +/** + * Single app-wide React Query client (do not nest additional providers). + * + * Declarative invalidation: any mutation built via `api.*.mutationOptions()` + * (see `services/api.ts` + `utils/endpoint.ts`) carries an `invalidates` + * function in its `meta`. The shared `MutationCache` below runs it on success + * and invalidates the returned query keys — so invalidation is declared once in + * the endpoint definition rather than re-wired in every component. + */ export const queryClient = new QueryClient({ + mutationCache: new MutationCache({ + onSuccess: (data, variables, _context, mutation) => { + const invalidates = mutation.meta?.invalidates as + | InvalidatesMeta + | undefined; + if (typeof invalidates !== "function") return; + + for (const queryKey of invalidates(variables, data)) { + void queryClient.invalidateQueries({ queryKey }); + } + }, + }), defaultOptions: { queries: { retry: 1, diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx index 3f5323c7e..b3c9db35d 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx @@ -1,7 +1,3 @@ -import { useEffect, useMemo, useState } from "react"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useNavigate } from "react-router-dom"; -import { isAxiosError } from "axios"; import { ActionIcon, Badge, @@ -23,6 +19,8 @@ import { ThemeIcon, Tooltip, } from "@mantine/core"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { isAxiosError } from "axios"; import { AlertTriangle, ArrowLeft, @@ -40,14 +38,16 @@ import { Trash2, Weight, } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; import toast from "react-hot-toast"; +import { useNavigate } from "react-router-dom"; -import Breadcrumbs from "@/components/ui/Breadcrumbs"; -import { bookingsService } from "@/services/bookings.service"; -import { useAvailableDays } from "@/hooks/trainScheduling/useTrainScheduling"; import { api } from "@/auth/http"; -import { unwrap } from "@/utils/endpoint"; +import Breadcrumbs from "@/components/ui/Breadcrumbs"; import { URL_CONSTANTS } from "@/constants/URLS"; +import { api as appApi } from "@/services/api"; +import { bookingsService } from "@/services/bookings.service"; +import { unwrap } from "@/utils/endpoint"; interface CompanyOption { id: string; @@ -234,9 +234,11 @@ export default function NewBookingPage() { // Day-level pool: fetch only the days that have a departure on the route (no // train, no capacity). The batch engine assigns the train after booking. - const { data: availableDays, isLoading: daysLoading } = useAvailableDays( - originYardId, - destinationYardId, + const { data: availableDays, isLoading: daysLoading } = useQuery( + appApi.trainScheduling.availableDays.queryOptions({ + input: { originYardId, destinationYardId }, + enabled: Boolean(originYardId && destinationYardId), + }), ); const dayOptions = (availableDays ?? []).map((day) => ({ value: day, @@ -392,7 +394,7 @@ export default function NewBookingPage() { - + {/* LEFT — form */} @@ -453,7 +455,6 @@ export default function NewBookingPage() { value={originYardId} onChange={(v) => { setOriginYardId(v); - setTrainScheduleId(null); }} searchable disabled={isLoading} diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx index 1c1571966..f38e8c38b 100644 --- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx @@ -1,12 +1,584 @@ -import FeaturePlaceholder from "@/components/FeaturePlaceholder"; +import { + ActionIcon, + Box, + Button, + Card, + Center, + Container, + Group, + Loader, + SimpleGrid, + Stack, + Tabs, + Text, +} from "@mantine/core"; +import { + ArrowLeft, + ArrowRight, + Banknote, + Download, + FileText, + IdCard, + LayoutGrid, + Package, +} from "lucide-react"; +import { useQuery } from "@tanstack/react-query"; +import { useMemo } from "react"; +import { useNavigate, useParams } from "react-router-dom"; -const CustomerDetailPage = () => { +import { + BookingStatusBadge, + CompanyStatusBadge, + CompanyTypeBadge, + PaymentStatusBadge, + ProfileApprovalActions, + ProfileChips, + ProfileStatusBadge, + ProfileTypeBadge, + TableCard, + formatBytes, + formatDate, + formatMoney, + humanize, +} from "@/components/customers"; +import { KpiStrip, PageContainer, PageHeader } from "@/components/page"; +import { api } from "@/services/api"; +import type { + CompanyProfile, + CustomerBooking, + CustomerDocument, + CustomerPayment, +} from "@/types/customer"; +import { DataTable, type ColumnDef } from "@edr/ui-common"; + +function InfoField({ label, value }: { label: string; value?: string | null }) { return ( - + + + {label} + + + {value && value.trim() ? value : "—"} + + ); -}; +} -export default CustomerDetailPage; +function tableStatus(query: { isLoading: boolean; isError: boolean }) { + return query.isLoading ? "loading" : query.isError ? "error" : "success"; +} + +export default function CustomerDetailPage() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + + const { data: company, isLoading } = useQuery( + api.customers.getById.queryOptions({ + input: { id: id ?? "" }, + enabled: Boolean(id), + }), + ); + const bookingsQuery = useQuery( + api.customers.bookings.queryOptions({ + input: { id: id ?? "" }, + enabled: Boolean(id), + }), + ); + const documentsQuery = useQuery( + api.customers.documents.queryOptions({ + input: { id: id ?? "" }, + enabled: Boolean(id), + }), + ); + const paymentsQuery = useQuery( + api.customers.payments.queryOptions({ + input: { id: id ?? "" }, + enabled: Boolean(id), + }), + ); + + const bookings = bookingsQuery.data ?? []; + const documents = documentsQuery.data ?? []; + const payments = paymentsQuery.data ?? []; + + const totalPaid = useMemo( + () => + payments + .filter((p) => p.status === "success") + .reduce((sum, p) => sum + p.amount, 0), + [payments], + ); + const paidCurrency = payments[0]?.currency ?? "ETB"; + + const profileColumns: ColumnDef[] = useMemo( + () => [ + { + id: "type", + header: "Role", + cell: ({ row }) => , + }, + { + id: "reference", + header: "Reference", + cell: ({ row }) => ( + + {row.original.reference} + + ), + }, + { + id: "businessLicense", + header: "Business license", + cell: ({ row }) => ( + + {row.original.businessLicense || "—"} + + ), + }, + { + id: "status", + header: "Status", + cell: ({ row }) => , + }, + { + id: "createdAt", + header: "Registered", + cell: ({ row }) => ( + + {formatDate(row.original.createdAt)} + + ), + }, + { + id: "actions", + header: "", + meta: { headerClassName: "text-right", cellClassName: "text-right" }, + cell: ({ row }) => ( + + ), + }, + ], + [], + ); + + const bookingColumns: ColumnDef[] = useMemo( + () => [ + { + id: "reference", + header: "Booking", + cell: ({ row }) => ( + + {row.original.reference} + + ), + }, + { + id: "route", + header: "Route", + cell: ({ row }) => { + const b = row.original; + return ( + + + {b.originLabel} + + + + {b.destinationLabel} + + + ); + }, + }, + { + id: "type", + header: "Type", + cell: ({ row }) => ( + + {humanize(row.original.tradeDirection)} ·{" "} + {humanize(row.original.freightType)} + + ), + }, + { + id: "status", + header: "Status", + cell: ({ row }) => , + }, + { + id: "amount", + header: "Amount", + meta: { headerClassName: "text-right", cellClassName: "text-right" }, + cell: ({ row }) => ( + + {formatMoney(row.original.totalAmount, row.original.currency)} + + ), + }, + { + id: "createdAt", + header: "Created", + meta: { headerClassName: "text-right", cellClassName: "text-right" }, + cell: ({ row }) => ( + + {formatDate(row.original.createdAt)} + + ), + }, + ], + [], + ); + + const documentColumns: ColumnDef[] = useMemo( + () => [ + { + id: "name", + header: "Document", + cell: ({ row }) => ( + + + + {row.original.name} + + + ), + }, + { + id: "code", + header: "Type", + cell: ({ row }) => ( + + {humanize(row.original.code)} + + ), + }, + { + id: "size", + header: "Size", + cell: ({ row }) => ( + + {formatBytes(row.original.size)} + + ), + }, + { + id: "uploadedAt", + header: "Uploaded", + cell: ({ row }) => ( + + {formatDate(row.original.uploadedAt)} + + ), + }, + { + id: "actions", + header: "", + meta: { headerClassName: "text-right", cellClassName: "text-right" }, + cell: ({ row }) => ( + + + + ), + }, + ], + [], + ); + + const paymentColumns: ColumnDef[] = useMemo( + () => [ + { + id: "reference", + header: "Payment", + cell: ({ row }) => ( + + {row.original.reference} + + ), + }, + { + id: "booking", + header: "Booking", + cell: ({ row }) => ( + + {row.original.bookingReference} + + ), + }, + { + id: "method", + header: "Method", + cell: ({ row }) => ( + + {humanize(row.original.method)} + + ), + }, + { + id: "status", + header: "Status", + cell: ({ row }) => , + }, + { + id: "paidAt", + header: "Paid", + cell: ({ row }) => ( + + {formatDate(row.original.paidAt)} + + ), + }, + { + id: "amount", + header: "Amount", + meta: { headerClassName: "text-right", cellClassName: "text-right" }, + cell: ({ row }) => ( + + {formatMoney(row.original.amount, row.original.currency)} + + ), + }, + ], + [], + ); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (!company) { + return ( + + + Customer not found + + + + ); + } + + return ( + + + + + + } + /> + + + + }> + Overview + + }> + Bookings + + }> + Documents + + }> + Payments + + + + {/* OVERVIEW */} + + + p.status === "pending", + ).length, + icon: IdCard, + color: "yellow", + }, + { + label: "Bookings", + value: bookings.length, + icon: Package, + color: "blue", + }, + { + label: "Total paid", + value: formatMoney(totalPaid, paidCurrency), + icon: Banknote, + color: "edr-green", + }, + ]} + /> + + + + + Company information + + + + + + + + + + + + + + + + + + + + + + + + + + Role profiles + + + + + + + + + + + + + + {/* BOOKINGS */} + + + void bookingsQuery.refetch(), + } + : undefined + } + /> + + + + {/* DOCUMENTS */} + + + void documentsQuery.refetch(), + } + : undefined + } + /> + + + + {/* PAYMENTS */} + + + void paymentsQuery.refetch(), + } + : undefined + } + /> + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx index 8d9158381..e06d310f4 100644 --- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx @@ -1,12 +1,266 @@ -import FeaturePlaceholder from "@/components/FeaturePlaceholder"; +import { + ActionIcon, + Box, + Card, + Group, + Stack, + Text, + TextInput, +} from "@mantine/core"; +import { useDebouncedValue } from "@mantine/hooks"; +import { useQuery } from "@tanstack/react-query"; +import { + Building2, + CheckCircle2, + Clock, + Mail, + Phone, + RefreshCw, + Search, + ShieldOff, + Users, + X, +} from "lucide-react"; +import { useMemo, useState } from "react"; +import { useNavigate } from "react-router-dom"; -const CustomersPage = () => { - return ( - +import { + CompanyStatusBadge, + CompanyTypeBadge, + ProfileChips, + formatDate, +} from "@/components/customers"; +import { KpiStrip, PageContainer, PageHeader } from "@/components/page"; +import { api } from "@/services/api"; +import type { Company } from "@/types/customer"; +import { + DataTable, + DataTableFooter, + usePagination, + type ColumnDef, +} from "@edr/ui-common"; + +export default function CustomersPage() { + const navigate = useNavigate(); + const { pagination, setPagination } = usePagination({ pageSize: 10 }); + const [query, setQuery] = useState(""); + const [debouncedQuery] = useDebouncedValue(query, 300); + + const filter = useMemo( + () => ({ + page: pagination.pageIndex + 1, + pageSize: pagination.pageSize, + search: debouncedQuery, + }), + [pagination.pageIndex, pagination.pageSize, debouncedQuery], ); -}; -export default CustomersPage; + const { data: stats } = useQuery(api.customers.stats.queryOptions({ input: {} })); + + const { data, isLoading, isError, refetch, isFetching } = useQuery( + api.customers.list.queryOptions({ input: { filter } }), + ); + + const rows = data?.items ?? []; + const total = data?.total ?? 0; + const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize)); + + const columns: ColumnDef[] = useMemo( + () => [ + { + id: "company", + header: "Company", + cell: ({ row }) => { + const c = row.original; + return ( + + + + +
+ + + {c.name} + + + + + TIN {c.tin} + {c.country ? ` · ${c.country}` : ""} + +
+
+ ); + }, + }, + { + id: "profiles", + header: "Profiles", + cell: ({ row }) => , + }, + { + id: "status", + header: "Status", + cell: ({ row }) => , + }, + { + id: "contact", + header: "Contact", + cell: ({ row }) => { + const c = row.original; + return ( + + {c.contactPersonName ? ( + + {c.contactPersonName} + + ) : null} + {c.phone ? ( + + {c.phone} + + ) : null} + {c.email ? ( + + {c.email} + + ) : null} + + ); + }, + }, + { + id: "created", + header: "Registered", + meta: { headerClassName: "text-right", cellClassName: "text-right" }, + cell: ({ row }) => ( + + {formatDate(row.original.createdAt)} + + ), + }, + ], + [], + ); + + return ( + + void refetch()} + > + + + } + /> + + + + + + + + } + value={query} + onChange={(e) => setQuery(e.target.value)} + rightSection={ + query ? ( + setQuery("")} + > + + + ) : null + } + style={{ flex: 1, minWidth: "240px" }} + radius="lg" + /> + + {total} record{total !== 1 ? "s" : ""} + + + + + + + navigate(`/dashboard/customers/${row.id}`)} + emptyMessage={ + debouncedQuery + ? "No companies match your search." + : "No companies yet." + } + error={ + isError + ? { + message: "Failed to load customers.", + onRetry: () => void refetch(), + } + : undefined + } + pagination={{ + pageIndex: pagination.pageIndex, + pageSize: pagination.pageSize, + pageCount, + totalCount: total, + }} + tableOptions={{ + state: { pagination }, + onPaginationChange: setPagination, + manualPagination: true, + pageCount, + }} + containerClassName="border-0 shadow-none bg-transparent" + footer={DataTableFooter} + /> + + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/documents/EditFileUploadSettingDialog.tsx b/apps/edr-freight-web/backoffice/src/pages/documents/EditFileUploadSettingDialog.tsx index 1938443ab..4370adac1 100644 --- a/apps/edr-freight-web/backoffice/src/pages/documents/EditFileUploadSettingDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/documents/EditFileUploadSettingDialog.tsx @@ -15,7 +15,8 @@ import { Label } from "@/components/ui/label"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; import { FileUploadEntity } from "@edr/types/freight"; -import { useCreateFileUploadSetting, useUpdateFileUploadSetting } from "@/hooks/useFileUploadSettings"; +import { useMutation } from "@tanstack/react-query"; +import { api } from "@/services/api"; // import type { // FileUploadEntity, @@ -61,8 +62,8 @@ export default function EditFileUploadSettingDialog({ const [description, setDescription] = useState(setting?.description ?? ""); const [error, setError] = useState(null); - const createMutation = useCreateFileUploadSetting(); - const updateMutation = useUpdateFileUploadSetting(); + const createMutation = useMutation(api.fileUploadSettings.create.mutationOptions()); + const updateMutation = useMutation(api.fileUploadSettings.update.mutationOptions()); const pending = createMutation.isPending || updateMutation.isPending; const reset = () => { diff --git a/apps/edr-freight-web/backoffice/src/pages/documents/FileUploadSettingsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/documents/FileUploadSettingsPage.tsx index acc9b91c0..24c2772aa 100644 --- a/apps/edr-freight-web/backoffice/src/pages/documents/FileUploadSettingsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/documents/FileUploadSettingsPage.tsx @@ -25,12 +25,11 @@ import { Trash2, X, } from "lucide-react"; -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { KpiStrip, PageContainer, PageHeader } from "@/components/page"; import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles"; import { api } from "@/services/api"; -import { useDeleteFileUploadSetting } from "@/hooks/useFileUploadSettings"; import { getMinFiles, type FileUploadSetting } from "@/types/fileUploadSettings"; import { DataTable, type ColumnDef } from "@edr/ui-common"; @@ -44,7 +43,7 @@ export default function FileUploadSettingsPage() { const { data, isLoading, isError, error, refetch } = useQuery( api.fileUploadSettings.list.queryOptions(), ); - const deleteMutation = useDeleteFileUploadSetting(); + const deleteMutation = useMutation(api.fileUploadSettings.remove.mutationOptions()); const fileUploadSettings = useMemo( () => (Array.isArray(data) ? data : []), @@ -202,7 +201,7 @@ export default function FileUploadSettingsPage() { deleteMutation.mutate(setting.id)} + onConfirm={() => deleteMutation.mutate({ id: setting.id })} > (seed); - const replaceMutation = useReplaceFileUploadFields(); + const replaceMutation = useMutation( + api.fileUploadSettings.replaceFields.mutationOptions(), + ); const update = (i: number, patch: Partial) => setFields((prev) => @@ -145,7 +148,7 @@ export default function ManageFileUploadFieldsDialog({ })); replaceMutation.mutate( - { settingId: setting.id, fields: payload }, + { id: setting.id, fields: payload }, { onSuccess: () => setOpen(false), onError: (err) => diff --git a/apps/edr-freight-web/backoffice/src/pages/dropdown_settings/DropdownSettingsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/dropdown_settings/DropdownSettingsPage.tsx index d2689c750..751b94886 100644 --- a/apps/edr-freight-web/backoffice/src/pages/dropdown_settings/DropdownSettingsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/dropdown_settings/DropdownSettingsPage.tsx @@ -31,9 +31,8 @@ import { KpiStrip, PageContainer, PageHeader } from "@/components/page"; import EditDropdownSettingDialog from "./EditDropdownSettingDialog"; import ManageDropdownOptionsDialog from "./ManageDropdownOptionsDialog"; import DeleteDropdownSettingDialog from "./DeleteDropdownSettingDialog"; -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { api } from "@/services/api"; -import { useDeleteDropdownSetting } from "@/hooks/useDropdownSettings"; import type { DropdownSetting } from "@/types/dropdownSettings"; import { DataTable, @@ -63,7 +62,7 @@ export default function DropdownSettingsPage() { const { data, isLoading, isError, error } = useQuery( api.dropdownSettings.list.queryOptions(), ); - const deleteMutation = useDeleteDropdownSetting(); + const deleteMutation = useMutation(api.dropdownSettings.remove.mutationOptions()); const dropdownSettings = useMemo( () => (Array.isArray(data) ? data : []), @@ -353,7 +352,7 @@ export default function DropdownSettingsPage() { key={`delete-${activeSetting.id}`} settingLabel={activeSetting.label} settingCode={activeSetting.code} - onConfirm={() => deleteMutation.mutate(activeSetting.id)} + onConfirm={() => deleteMutation.mutate({ id: activeSetting.id })} open={activeDialog === "delete"} onOpenChange={(next) => (next ? null : closeDialog())} /> diff --git a/apps/edr-freight-web/backoffice/src/pages/dropdown_settings/EditDropdownSettingDialog.tsx b/apps/edr-freight-web/backoffice/src/pages/dropdown_settings/EditDropdownSettingDialog.tsx index 14922446c..053a74867 100644 --- a/apps/edr-freight-web/backoffice/src/pages/dropdown_settings/EditDropdownSettingDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/dropdown_settings/EditDropdownSettingDialog.tsx @@ -20,10 +20,9 @@ import type { DropdownSetting, UpdateDropdownSettingDto, } from "@/types/dropdownSettings"; -import { - useCreateDropdownSetting, - useUpdateDropdownSetting, -} from "@/hooks/useDropdownSettings"; +import { useMutation } from "@tanstack/react-query"; + +import { api } from "@/services/api"; export interface EditDropdownSettingDialogProps { mode?: "create" | "edit"; @@ -76,8 +75,8 @@ export default function EditDropdownSettingDialog({ ); const [error, setError] = useState(null); - const createMutation = useCreateDropdownSetting(); - const updateMutation = useUpdateDropdownSetting(); + const createMutation = useMutation(api.dropdownSettings.create.mutationOptions()); + const updateMutation = useMutation(api.dropdownSettings.update.mutationOptions()); const pending = createMutation.isPending || updateMutation.isPending; const reset = () => { diff --git a/apps/edr-freight-web/backoffice/src/pages/dropdown_settings/ManageDropdownOptionsDialog.tsx b/apps/edr-freight-web/backoffice/src/pages/dropdown_settings/ManageDropdownOptionsDialog.tsx index 0528145ff..9f9a27057 100644 --- a/apps/edr-freight-web/backoffice/src/pages/dropdown_settings/ManageDropdownOptionsDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/dropdown_settings/ManageDropdownOptionsDialog.tsx @@ -18,7 +18,8 @@ import type { CreateDropdownOptionDto, DropdownSetting, } from "@/types/dropdownSettings"; -import { useReplaceDropdownOptions } from "@/hooks/useDropdownSettings"; +import { useMutation } from "@tanstack/react-query"; +import { api } from "@/services/api"; export interface ManageDropdownOptionsDialogProps { setting: DropdownSetting; @@ -84,7 +85,9 @@ export default function ManageDropdownOptionsDialog({ const [options, setOptions] = useState(seed); - const replaceMutation = useReplaceDropdownOptions(); + const replaceMutation = useMutation( + api.dropdownSettings.replaceOptions.mutationOptions(), + ); const update = (i: number, patch: Partial) => setOptions((prev) => @@ -147,7 +150,7 @@ export default function ManageDropdownOptionsDialog({ }); replaceMutation.mutate( - { settingId: setting.id, options: payload }, + { id: setting.id, options: payload }, { onSuccess: () => setOpen(false), onError: (err) => diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetCrudPages.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetCrudPages.tsx index 9206372e4..cd846ada7 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetCrudPages.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetCrudPages.tsx @@ -1,5 +1,8 @@ import { FormEvent, ReactNode, useMemo, useState } from 'react'; +import { useMutation, useQuery } from '@tanstack/react-query'; import { Edit, Eye, Plus, Search, Trash2 } from 'lucide-react'; + +import { api } from '@/services/api'; import { ActionIcon, Badge as MantineBadge, @@ -33,31 +36,7 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@edr/ui-common'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; -import { useCargoTypes } from '@/hooks/use-cargo-types'; -import { useContainerTypes } from '@/hooks/use-container-types'; -import { - useCreateWagonType, - useDeleteWagonType, - useUpdateWagonType, - useWagonTypes, -} from '@/hooks/use-wagon-types'; import { useToast } from '@/hooks/use-toast'; -import { useCreateCargo, useDeleteCargo, useCargoes, useUpdateCargo } from '@/hooks/useCargoes'; -import { - useContainers, - useCreateContainer, - useDeleteContainer, - useUpdateContainer, -} from '@/hooks/useContainers'; -import { useCreateTrain, useDeleteTrain, useTrains, useUpdateTrain } from '@/hooks/useTrains'; -import { useRouteYards } from '@/hooks/useRoutes'; -import { useCreateWagon, useDeleteWagon, useUpdateWagon, useWagons } from '@/hooks/useWagons'; -import { - useCreateLocomotive, - useDecommissionLocomotive, - useLocomotives, - useUpdateLocomotive, -} from '@/hooks/useLocomotives'; import type { Cargo } from '@/services/cargoService'; import { DeliverCargoDialog } from '@/components/cargoes/DeliverCargoDialog'; import type { Container } from '@/services/containerService'; @@ -511,7 +490,7 @@ const optionLabel = (options: { value: string; label: string }[], value?: string options.find((option) => option.value === value)?.label ?? value ?? '-'; export function TrainMasterDataPage() { - const query = useTrains(); + const query = useQuery(api.trains.list.queryOptions()); return ( title="Trains" @@ -519,9 +498,9 @@ export function TrainMasterDataPage() { addLabel="Add Train" data={query.data} isLoading={query.isLoading} - create={useCreateTrain()} - update={useUpdateTrain()} - remove={useDeleteTrain()} + create={useMutation(api.trains.create.mutationOptions())} + update={useMutation(api.trains.update.mutationOptions())} + remove={useMutation(api.trains.remove.mutationOptions())} searchText={(train) => [train.code, train.trainNumber, train.trainName, train.status].join(' ')} columns={[ { key: 'code', label: 'Code' }, @@ -546,10 +525,10 @@ export function TrainMasterDataPage() { } export function WagonTypesCrudPage() { - const query = useWagonTypes(); - const create = useCreateWagonType(); - const update = useUpdateWagonType(); - const remove = useDeleteWagonType(); + const query = useQuery(api.wagonTypes.list.queryOptions()); + const create = useMutation(api.wagonTypes.create.mutationOptions()); + const update = useMutation(api.wagonTypes.update.mutationOptions()); + const remove = useMutation(api.wagonTypes.remove.mutationOptions()); const { toast } = useToast(); const [search, setSearch] = useState(''); const [page, setPage] = useState(1); @@ -896,9 +875,9 @@ export function WagonTypesCrudPage() { } export function WagonsCrudPage() { - const query = useWagons(); - const { data: wagonTypes = [] } = useWagonTypes(); - const { data: yards = [] } = useRouteYards(); + const query = useQuery(api.wagons.list.queryOptions({ input: {} })); + const { data: wagonTypes = [] } = useQuery(api.wagonTypes.list.queryOptions()); + const { data: yards = [] } = useQuery(api.routes.yards.queryOptions()); const wagonTypeOptions = wagonTypes.map((type: any) => ({ value: type.id, label: `${type.code} - ${type.name}`, @@ -914,9 +893,9 @@ export function WagonsCrudPage() { addLabel="Add Wagon" data={query.data} isLoading={query.isLoading} - create={useCreateWagon()} - update={useUpdateWagon()} - remove={useDeleteWagon()} + create={useMutation(api.wagons.create.mutationOptions())} + update={useMutation(api.wagons.update.mutationOptions())} + remove={useMutation(api.wagons.remove.mutationOptions())} searchText={(wagon) => [ wagon.wagonNumber, wagon.wagonTypeId, @@ -984,9 +963,11 @@ export function WagonsCrudPage() { } export function ContainersCrudPage() { - const query = useContainers(); - const { data: containerTypes = [] } = useContainerTypes(); - const { data: wagons = [] } = useWagons(); + const query = useQuery(api.containers.list.queryOptions()); + const { data: containerTypes = [] } = useQuery( + api.containerTypes.list.queryOptions({ staleTime: Infinity }), + ); + const { data: wagons = [] } = useQuery(api.wagons.list.queryOptions({ input: {} })); const containerTypeOptions = containerTypes.map((type: any) => ({ value: type.id, label: type.label ?? type.name ?? type.code, @@ -1002,9 +983,9 @@ export function ContainersCrudPage() { addLabel="Add Container" data={query.data} isLoading={query.isLoading} - create={useCreateContainer()} - update={useUpdateContainer()} - remove={useDeleteContainer()} + create={useMutation(api.containers.create.mutationOptions())} + update={useMutation(api.containers.update.mutationOptions())} + remove={useMutation(api.containers.remove.mutationOptions())} searchText={(container) => [container.containerNumber, container.containerTypeId, container.wagonId, container.status].join(' ')} columns={[ { key: 'containerNumber', label: 'Number' }, @@ -1041,9 +1022,11 @@ export function ContainersCrudPage() { } export function CargoesCrudPage() { - const query = useCargoes(); - const { data: cargoTypes = [] } = useCargoTypes(); - const { data: containers = [] } = useContainers(); + const query = useQuery(api.cargoes.list.queryOptions()); + const { data: cargoTypes = [] } = useQuery( + api.cargoTypes.list.queryOptions({ staleTime: Infinity }), + ); + const { data: containers = [] } = useQuery(api.containers.list.queryOptions()); const cargoTypeOptions = cargoTypes.map((type: any) => ({ value: type.id, label: type.cargoTypeName ?? type.cargo_type_name ?? type.name ?? type.code, @@ -1059,9 +1042,9 @@ export function CargoesCrudPage() { addLabel="Add Cargo" data={query.data} isLoading={query.isLoading} - create={useCreateCargo()} - update={useUpdateCargo()} - remove={useDeleteCargo()} + create={useMutation(api.cargoes.create.mutationOptions())} + update={useMutation(api.cargoes.update.mutationOptions())} + remove={useMutation(api.cargoes.remove.mutationOptions())} searchText={(cargo) => [cargo.cargoReference, cargo.description, cargo.containerId, cargo.status].join(' ')} columns={[ { key: 'cargoReference', label: 'Reference' }, @@ -1110,7 +1093,7 @@ export function CargoesCrudPage() { } export function LocomotivesCrudPage() { - const query = useLocomotives(); + const query = useQuery(api.locomotives.list.queryOptions()); return ( @@ -1120,9 +1103,9 @@ export function LocomotivesCrudPage() { addLabel="Add Locomotive" data={query.data} isLoading={query.isLoading} - create={useCreateLocomotive()} - update={useUpdateLocomotive()} - remove={useDecommissionLocomotive()} + create={useMutation(api.locomotives.create.mutationOptions())} + update={useMutation(api.locomotives.update.mutationOptions())} + remove={useMutation(api.locomotives.decommission.mutationOptions())} removeActionLabel="Decommission" removeConfirmMessage="Decommission this locomotive?" removeSuccessMessage="Locomotive decommissioned" diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx index 58b5dffed..6db31e931 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx @@ -1,7 +1,8 @@ import type { ColumnDef } from "@edr/ui-common"; +import { Box, Button, Card, Group, Modal, Select, Stack, Text } from "@mantine/core"; +import { useMutation, useQuery } from "@tanstack/react-query"; -import Breadcrumbs from "@/components/ui/Breadcrumbs"; -import {Container, Title, Box, Button, Card, Group, Modal, Select, Stack, Text } from "@mantine/core"; +import { api } from "@/services/api"; import { Archive, Circle, @@ -24,14 +25,7 @@ import { formatFleetCell, registerFleetOptionLabels } from "@/components/fleet/f import { useFleetViewMode } from "@/components/fleet/useFleetViewMode"; import { KpiStrip, PageContainer, PageHeader } from "@/components/page"; import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles"; -import { useFleetList, useFleetMutations } from "@/hooks/fleet/useFleet"; -import { useCargoTypes } from "@/hooks/use-cargo-types"; -import { useContainerTypes } from "@/hooks/use-container-types"; import { useToast } from "@/hooks/use-toast"; -import { useWagonTypes } from "@/hooks/use-wagon-types"; -import { useContainers } from "@/hooks/useContainers"; -import { useRouteYards } from "@/hooks/useRoutes"; -import { useWagons } from "@/hooks/useWagons"; import { FLEET_SELECT_NONE, getFleetResource, @@ -94,16 +88,31 @@ const FleetResourcePage = () => { return filters; }, [slug, listFilterValues, search]); - const { data: allRows = [], isLoading, isError, error } = useFleetList(slug, serverListFilters); - const { data: drivers = [] } = useFleetList("drivers"); - const { create, update, remove } = useFleetMutations(slug); + const { data: allRows = [], isLoading, isError, error } = useQuery( + api.fleet.list.queryOptions({ input: { slug, filters: serverListFilters } }), + ); + const create = useMutation(api.fleet.create.mutationOptions()); + const update = useMutation(api.fleet.update.mutationOptions()); + const remove = useMutation(api.fleet.remove.mutationOptions()); - const { data: wagonTypes = [], isLoading: wagonTypesLoading } = useWagonTypes(); - const { data: containerTypes = [], isLoading: containerTypesLoading } = useContainerTypes(); - const { data: cargoTypes = [], isLoading: cargoTypesLoading } = useCargoTypes(); - const { data: wagons = [], isLoading: wagonsLoading } = useWagons(); - const { data: containers = [], isLoading: containersLoading } = useContainers(); - const { data: yards = [], isLoading: yardsLoading } = useRouteYards(); + const { data: wagonTypes = [], isLoading: wagonTypesLoading } = useQuery( + api.wagonTypes.list.queryOptions(), + ); + const { data: containerTypes = [], isLoading: containerTypesLoading } = useQuery( + api.containerTypes.list.queryOptions({ staleTime: Infinity }), + ); + const { data: cargoTypes = [], isLoading: cargoTypesLoading } = useQuery( + api.cargoTypes.list.queryOptions({ staleTime: Infinity }), + ); + const { data: wagons = [], isLoading: wagonsLoading } = useQuery( + api.wagons.list.queryOptions({ input: {} }), + ); + const { data: containers = [], isLoading: containersLoading } = useQuery( + api.containers.list.queryOptions(), + ); + const { data: yards = [], isLoading: yardsLoading } = useQuery( + api.routes.yards.queryOptions(), + ); useEffect(() => { setPagination((prev) => ({ pageIndex: 0, pageSize: prev.pageSize })); @@ -332,10 +341,10 @@ const FleetResourcePage = () => { const handleFormSubmit = async (values: Record) => { try { if (editing && "id" in editing) { - await update.mutateAsync({ id: String(editing.id), data: values }); + await update.mutateAsync({ slug, id: String(editing.id), data: values }); toast({ title: `${config.entityLabel} updated` }); } else { - await create.mutateAsync(values); + await create.mutateAsync({ slug, data: values }); toast({ title: `${config.entityLabel} created` }); } setFormOpen(false); @@ -351,7 +360,7 @@ const FleetResourcePage = () => { const handleRemove = async () => { if (!removeTarget || !("id" in removeTarget)) return; try { - await remove.mutateAsync(String(removeTarget.id)); + await remove.mutateAsync({ slug, id: String(removeTarget.id) }); toast({ title: config.removeSuccessMessage ?? `${config.entityLabel} removed`, }); diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/RoutesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/RoutesPage.tsx index 543ae26db..7d530697f 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/RoutesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/RoutesPage.tsx @@ -17,18 +17,14 @@ import { Tooltip, } from "@mantine/core"; +import { useMutation, useQuery } from "@tanstack/react-query"; + import { KpiStrip, PageContainer, PageHeader } from "@/components/page"; import FleetToolbar from "@/components/fleet/FleetToolbar"; import { useFleetViewMode } from "@/components/fleet/useFleetViewMode"; import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter"; import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles"; -import { - useCreateRoute, - useDeactivateRoute, - useRouteYards, - useRoutes, - useUpdateRoute, -} from "@/hooks/useRoutes"; +import { api } from "@/services/api"; import { useToast } from "@/hooks/use-toast"; import type { RouteRecord, YardRef } from "@/services/routes.service"; import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common"; @@ -71,11 +67,11 @@ export default function RoutesPage() { const { pagination, setPagination } = usePagination({ pageSize: 10 }); const { toast } = useToast(); - const routesQuery = useRoutes(); - const yardsQuery = useRouteYards(); - const createMutation = useCreateRoute(); - const updateMutation = useUpdateRoute(); - const deactivateMutation = useDeactivateRoute(); + const routesQuery = useQuery(api.routes.list.queryOptions()); + const yardsQuery = useQuery(api.routes.yards.queryOptions()); + const createMutation = useMutation(api.routes.create.mutationOptions()); + const updateMutation = useMutation(api.routes.update.mutationOptions()); + const deactivateMutation = useMutation(api.routes.deactivate.mutationOptions()); const filteredRoutes = useMemo(() => { const query = search.trim().toLowerCase(); diff --git a/apps/edr-freight-web/backoffice/src/pages/payments/PaymentsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/payments/PaymentsPage.tsx index e5a490927..f68ac32f4 100644 --- a/apps/edr-freight-web/backoffice/src/pages/payments/PaymentsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/payments/PaymentsPage.tsx @@ -22,8 +22,10 @@ import { } from "lucide-react"; import { useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; + import { KpiStrip, PageContainer, PageHeader } from "@/components/page"; -import { usePaymentList, usePaymentSummary } from "@/hooks/usePayments"; +import { api } from "@/services/api"; import type { PaymentMethod, PaymentRow } from "@/services/payments.service"; import { Badge, @@ -106,8 +108,12 @@ export default function PaymentsPage() { [query, statuses, method, pagination.pageIndex, pagination.pageSize], ); - const { data, isLoading, isError } = usePaymentList(filter); - const { data: summary, isLoading: summaryLoading } = usePaymentSummary(); + const { data, isLoading, isError } = useQuery( + api.payments.list.queryOptions({ input: { filter } }), + ); + const { data: summary, isLoading: summaryLoading } = useQuery( + api.payments.summary.queryOptions({ staleTime: 30_000 }), + ); const rows = data?.items ?? []; const total = data?.total ?? 0; diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchBoardPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchBoardPage.tsx index bc6e5bb15..9085f2868 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchBoardPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchBoardPage.tsx @@ -46,7 +46,8 @@ import { import { RouteCorridor } from "@/components/trainScheduling/scheduleVisuals"; import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles"; import { FREIGHT_BRAND, FREIGHT_BRAND_DARK } from "@/theme/freight-brand"; -import { useBatchBoard } from "@/hooks/trainScheduling/useTrainScheduling"; +import { useQuery } from "@tanstack/react-query"; +import { api } from "@/services/api"; import type { BatchBoardSchedule } from "@/types/trainScheduling"; const fmtTons = (n: number) => @@ -368,7 +369,9 @@ function CardSkeleton() { export default function BatchBoardPage() { const navigate = useNavigate(); - const { data, isLoading, isError, isFetching, refetch } = useBatchBoard(); + const { data, isLoading, isError, isFetching, refetch } = useQuery( + api.trainScheduling.batchBoard.queryOptions({ refetchInterval: 30_000 }), + ); const { viewMode, setViewMode } = useFleetViewMode("batch-board"); const { pagination, setPagination } = usePagination({ pageSize: 10 }); const [search, setSearch] = useState(""); diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx index f78639359..1417d738c 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx @@ -52,11 +52,8 @@ import { WindowStatusPill, } from "@/components/trainScheduling/batchVisuals"; import { RouteCorridor } from "@/components/trainScheduling/scheduleVisuals"; -import { - useBatchBoardDetail, - useRunAllocation, - useScheduleDetail, -} from "@/hooks/trainScheduling/useTrainScheduling"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { api } from "@/services/api"; import { useToast } from "@/hooks/use-toast"; import type { BatchBoardBookingDetail, @@ -429,8 +426,16 @@ export default function BatchScheduleDetailPage() { const { scheduleId } = useParams<{ scheduleId: string }>(); const navigate = useNavigate(); const { toast } = useToast(); - const { data, isLoading, isFetching, refetch } = useBatchBoardDetail(scheduleId); - const runAllocation = useRunAllocation(scheduleId ?? ""); + const { data, isLoading, isFetching, refetch } = useQuery( + api.trainScheduling.batchBoardDetail.queryOptions({ + input: { scheduleId: scheduleId ?? "" }, + enabled: Boolean(scheduleId), + refetchInterval: 30_000, + }), + ); + const runAllocation = useMutation( + api.trainScheduling.runAllocation.mutationOptions(), + ); const hasAssignedWagons = useMemo( () => @@ -443,7 +448,12 @@ export default function BatchScheduleDetailPage() { [data], ); - const scheduleDetailQuery = useScheduleDetail(scheduleId, "CONTAINER"); + const scheduleDetailQuery = useQuery( + api.trainScheduling.scheduleDetail.queryOptions({ + input: { id: scheduleId ?? "", freightType: "CONTAINER" }, + enabled: Boolean(scheduleId), + }), + ); // Batch bookings by state for the composition side panel (payment / expired lists). const batchBookings = useMemo(() => { @@ -550,7 +560,7 @@ export default function BatchScheduleDetailPage() { const handleRunAllocation = () => { runAllocation - .mutateAsync() + .mutateAsync({ scheduleId: scheduleId ?? "" }) .then((result) => { const failed = result.issues.filter((i) => i.status === "FAILED").length; const deferred = result.deferred.length; diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleTrackPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleTrackPage.tsx index caed00435..7eb995ac2 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleTrackPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleTrackPage.tsx @@ -28,7 +28,8 @@ import { PageContainer } from "@/components/page"; import { RouteCorridorTrack } from "@/components/trainScheduling/RouteCorridorTrack"; import { RouteCorridor, StatusPill } from "@/components/trainScheduling/scheduleVisuals"; import { freightBrand } from "@/theme/freight-brand"; -import { useTrainTrack, useScheduleMutations } from "@/hooks/trainScheduling/useTrainScheduling"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { api } from "@/services/api"; import { useToast } from "@/hooks/use-toast"; const parseError = (error: unknown, fallback: string) => { @@ -81,8 +82,15 @@ function MetaStat({ export default function TrainScheduleTrackPage() { const { scheduleId } = useParams<{ scheduleId: string }>(); const { toast } = useToast(); - const trackQuery = useTrainTrack(scheduleId); - const { recordCheckpoint } = useScheduleMutations(scheduleId); + const trackQuery = useQuery( + api.trainScheduling.trainTrack.queryOptions({ + input: { id: scheduleId ?? "" }, + enabled: Boolean(scheduleId), + }), + ); + const recordCheckpoint = useMutation( + api.trainScheduling.recordCheckpoint.mutationOptions(), + ); if (trackQuery.isLoading) { return ( diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx index e2618510a..8c16b8d14 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx @@ -56,11 +56,8 @@ import { shouldShowContainerPlacementStep } from "@/components/trainScheduling/s import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram"; import { WagonPlanGrid } from "@/components/trainScheduling/WagonPlanGrid"; import { WorkflowRail, WorkflowStep } from "@/components/trainScheduling/WorkflowStep"; -import { - useEligibleBookings, - useScheduleDetail, - useScheduleMutations, -} from "@/hooks/trainScheduling/useTrainScheduling"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { api } from "@/services/api"; import { useToast } from "@/hooks/use-toast"; import type { ContainerPlacement, @@ -91,7 +88,12 @@ export default function TrainScheduleV2DetailPage() { const [maintenanceOpen, setMaintenanceOpen] = useState(false); const autoPreviewedRef = useRef(false); - const detailQuery = useScheduleDetail(scheduleId); + const detailQuery = useQuery( + api.trainScheduling.scheduleDetail.queryOptions({ + input: { id: scheduleId ?? "" }, + enabled: Boolean(scheduleId), + }), + ); const schedule = detailQuery.data; const freightType: FreightType | undefined = schedule?.freightType as FreightType | undefined; @@ -111,12 +113,17 @@ export default function TrainScheduleV2DetailPage() { const eligibleFreightType = freightType === "CONTAINER" || freightType === "BULK" ? freightType : undefined; - const eligibleQuery = useEligibleBookings( - eligibleFilters, - Boolean(schedule), - eligibleFreightType, + const eligibleQuery = useQuery( + api.trainScheduling.eligibleBookings.queryOptions({ + input: { filters: eligibleFilters, freightType: eligibleFreightType }, + enabled: Boolean(schedule), + }), ); - const { preview, assign, unassign, finalize, dispatch } = useScheduleMutations(scheduleId); + const preview = useMutation(api.trainScheduling.preview.mutationOptions()); + const assign = useMutation(api.trainScheduling.assignBookings.mutationOptions()); + const unassign = useMutation(api.trainScheduling.unassignBooking.mutationOptions()); + const finalize = useMutation(api.trainScheduling.finalizeSchedule.mutationOptions()); + const dispatch = useMutation(api.trainScheduling.dispatchSchedule.mutationOptions()); const assignedIds = useMemo( () => (schedule?.bookings ?? []).map((b) => b.id), diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx index d389009aa..193caa0a5 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx @@ -39,13 +39,9 @@ import { RouteCorridor, StatusPill, } from "@/components/trainScheduling/scheduleVisuals"; -import { - useAvailableLocomotives, - useScheduleList, - useScheduleMutations, -} from "@/hooks/trainScheduling/useTrainScheduling"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { api } from "@/services/api"; import { useToast } from "@/hooks/use-toast"; -import { useRoutes } from "@/hooks/useRoutes"; import type { TrainScheduleListItem } from "@/types/trainScheduling"; import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common"; @@ -88,10 +84,17 @@ export default function TrainScheduleV2ListPage() { const [scheduleDate, setScheduleDate] = useState(""); const [locomotiveId, setLocomotiveId] = useState(""); - const schedulesQuery = useScheduleList(); - const routesQuery = useRoutes(); - const locomotivesQuery = useAvailableLocomotives(routeId || undefined); - const { create, cancel } = useScheduleMutations(); + const schedulesQuery = useQuery( + api.trainScheduling.scheduleList.queryOptions({ input: {} }), + ); + const routesQuery = useQuery(api.routes.list.queryOptions()); + const locomotivesQuery = useQuery( + api.trainScheduling.availableLocomotives.queryOptions({ + input: { routeId: routeId || undefined }, + }), + ); + const create = useMutation(api.trainScheduling.createSchedule.mutationOptions()); + const cancel = useMutation(api.trainScheduling.cancelSchedule.mutationOptions()); const activeRoutes = useMemo( () => (routesQuery.data ?? []).filter((r) => r.isActive), diff --git a/apps/edr-freight-web/backoffice/src/pages/trains/TrainDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trains/TrainDetailPage.tsx index e6c2534db..9e336fa19 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trains/TrainDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trains/TrainDetailPage.tsx @@ -2,13 +2,17 @@ import { useParams, Link } from "react-router-dom"; import { ArrowLeft } from "lucide-react"; import { Badge, Button, Card, Group, Loader, SimpleGrid, Stack, Text } from "@mantine/core"; +import { useQuery } from "@tanstack/react-query"; + import { AssignWagonDialog } from "@/components/wagons/AssignWagonDialog"; import { WagonsTable } from "@/components/wagons/WagonsTable"; -import { useTrain } from "@/hooks/useTrains"; +import { api } from "@/services/api"; export default function TrainDetailPage() { const { id } = useParams<{ id: string }>(); - const { data: train, isLoading } = useTrain(id!); + const { data: train, isLoading } = useQuery( + api.trains.getById.queryOptions({ input: { id: id ?? "" }, enabled: !!id }), + ); if (isLoading) { return ( diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/ArrivalQueuePage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/ArrivalQueuePage.tsx index 0b52d1388..317520f48 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/ArrivalQueuePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/ArrivalQueuePage.tsx @@ -16,7 +16,9 @@ import { VisualEmptyState, formatDate, } from '@/components/warehouses'; -import { useArrivalQueue, useAutoUnloadArrived, useUnloadBooking } from '@/hooks/useWarehouses'; +import { useMutation, useQuery } from '@tanstack/react-query'; + +import { api } from '@/services/api'; import { useToast } from '@/hooks/use-toast'; import type { ArrivalQueueItem } from '@/types/warehouse'; @@ -30,17 +32,16 @@ function inspectionBadge(status: string | null) { export default function ArrivalQueuePage() { const navigate = useNavigate(); const { toast } = useToast(); - const { data, isLoading } = useArrivalQueue(); - const autoUnload = useAutoUnloadArrived(); - const unloadOne = useUnloadBooking(); + const { data, isLoading } = useQuery(api.warehouses.arrivalQueue.queryOptions()); + const autoUnload = useMutation(api.warehouses.autoUnloadArrived.mutationOptions()); + const unloadOne = useMutation(api.warehouses.unloadBooking.mutationOptions()); const [inspectInventoryId, setInspectInventoryId] = useState(null); const items = data ?? []; const handleAutoUnload = async () => { try { - const res = await autoUnload.mutateAsync(); - const r = res.data; + const r = await autoUnload.mutateAsync(); toast({ title: 'Auto-unload complete', description: `Processed ${r.processedCount}, skipped ${r.skippedCount}, failed ${r.failedCount}.`, diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/DispatchQueuePage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/DispatchQueuePage.tsx index 1776ac8e0..d2786c0b9 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/DispatchQueuePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/DispatchQueuePage.tsx @@ -1,12 +1,16 @@ import { Card } from '@mantine/core'; import { PageContainer, PageHeader } from '@/components/page'; +import { useQuery } from '@tanstack/react-query'; + import { InventoryWorkbench, VisualEmptyState } from '@/components/warehouses'; -import { useWarehouseInventory } from '@/hooks/useWarehouses'; +import { api } from '@/services/api'; /** Items that are LOADED and awaiting dispatch (train departure). */ export default function DispatchQueuePage() { - const { data, isLoading } = useWarehouseInventory({ status: 'LOADED' }); + const { data, isLoading } = useQuery( + api.warehouses.listInventory.queryOptions({ input: { filter: { status: 'LOADED' } } }), + ); const items = data ?? []; return ( diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/InventoryInquiryPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/InventoryInquiryPage.tsx index eeaaa8848..582a9cedd 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/InventoryInquiryPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/InventoryInquiryPage.tsx @@ -3,24 +3,35 @@ import { Button, Card, Center, Group, Loader, Select, Stack, TextInput } from '@ import { Search } from 'lucide-react'; import { PageContainer, PageHeader } from '@/components/page'; +import { useQuery } from '@tanstack/react-query'; + import { VisualEmptyState, WarehouseInquiryTable, inventoryStatusOptions } from '@/components/warehouses'; -import { - useInventoryInquiry, - useWarehouseYards, - useWarehouseZones, - useWarehouses, -} from '@/hooks/useWarehouses'; +import { api } from '@/services/api'; import type { InventoryInquiryFilter, InventoryStatus } from '@/types/warehouse'; export default function InventoryInquiryPage() { const [draft, setDraft] = useState({}); const [applied, setApplied] = useState({}); - const warehousesQuery = useWarehouses(); - const yardsQuery = useWarehouseYards(draft.warehouseId); - const zonesQuery = useWarehouseZones(draft.yardId); + const warehousesQuery = useQuery( + api.warehouses.list.queryOptions({ input: {} }), + ); + const yardsQuery = useQuery( + api.warehouses.listYards.queryOptions({ + input: { warehouseId: draft.warehouseId ?? '' }, + enabled: Boolean(draft.warehouseId), + }), + ); + const zonesQuery = useQuery( + api.warehouses.listZones.queryOptions({ + input: { yardId: draft.yardId ?? '' }, + enabled: Boolean(draft.yardId), + }), + ); - const { data, isFetching } = useInventoryInquiry(applied); + const { data, isFetching } = useQuery( + api.warehouses.inquiry.queryOptions({ input: { filter: applied } }), + ); const results = data ?? []; const warehouseOptions = useMemo( diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/LoadedInventoryPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/LoadedInventoryPage.tsx index d5dbd0b10..b5deec80e 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/LoadedInventoryPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/LoadedInventoryPage.tsx @@ -2,10 +2,13 @@ import { Badge, Card, Group, Text } from '@mantine/core'; import { DataTable, type ColumnDef } from '@edr/ui-common'; import { PageContainer, PageHeader } from '@/components/page'; -import { FreightVisual, VisualEmptyState, formatDate, formatNumber } from '@/components/warehouses'; -import { useWarehouseLoadings } from '@/hooks/useWarehouses'; +import { useQuery } from '@tanstack/react-query'; -type Loading = NonNullable['data']>[number]; +import { FreightVisual, VisualEmptyState, formatDate, formatNumber } from '@/components/warehouses'; +import { api } from '@/services/api'; +import type { WarehouseLoading } from '@/types/warehouse'; + +type Loading = WarehouseLoading; const columns: ColumnDef[] = [ { @@ -60,7 +63,9 @@ const columns: ColumnDef[] = [ /** Record of every inventory item loaded onto a wagon. */ export default function LoadedInventoryPage() { - const { data, isLoading } = useWarehouseLoadings(); + const { data, isLoading } = useQuery( + api.warehouses.loadings.queryOptions({ input: {} }), + ); const loadings = data ?? []; return ( diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/LoadingQueuePage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/LoadingQueuePage.tsx index bb65d8fe6..f80db55f4 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/LoadingQueuePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/LoadingQueuePage.tsx @@ -10,7 +10,9 @@ import { VisualEmptyState, formatNumber, } from '@/components/warehouses'; -import { useAutoLoadReady, useWarehouseInventory } from '@/hooks/useWarehouses'; +import { useMutation, useQuery } from '@tanstack/react-query'; + +import { api } from '@/services/api'; import { useToast } from '@/hooks/use-toast'; import type { WarehouseInventoryItem } from '@/types/warehouse'; @@ -27,16 +29,19 @@ const isPaid = (item: WarehouseInventoryItem) => item.booking?.status === 'PAID' export default function LoadingQueuePage() { const navigate = useNavigate(); const { toast } = useToast(); - const autoLoad = useAutoLoadReady(); - const { data: readyData, isLoading: readyLoading } = useWarehouseInventory({ - status: 'READY_FOR_LOADING', - }); - const { data: loadedData, isLoading: loadedLoading } = useWarehouseInventory({ status: 'LOADED' }); + const autoLoad = useMutation(api.warehouses.autoLoadReady.mutationOptions()); + const { data: readyData, isLoading: readyLoading } = useQuery( + api.warehouses.listInventory.queryOptions({ + input: { filter: { status: 'READY_FOR_LOADING' } }, + }), + ); + const { data: loadedData, isLoading: loadedLoading } = useQuery( + api.warehouses.listInventory.queryOptions({ input: { filter: { status: 'LOADED' } } }), + ); const handleAutoLoad = async () => { try { - const res = await autoLoad.mutateAsync(); - const r = res.data; + const r = await autoLoad.mutateAsync(); toast({ title: 'Auto-load complete', description: `Loaded ${r.loadedCount} PAID item(s); skipped ${r.skippedCount} (unpaid stay pending).`, diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseDashboardPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseDashboardPage.tsx index 683baca15..60dbb13c4 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseDashboardPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseDashboardPage.tsx @@ -16,8 +16,10 @@ import { } from 'lucide-react'; import { PageContainer, PageHeader } from '@/components/page'; +import { useQuery } from '@tanstack/react-query'; + import { WarehouseDashboardCharts } from '@/components/warehouses'; -import { useWarehouseDashboard } from '@/hooks/useWarehouses'; +import { api } from '@/services/api'; import type { WarehouseDashboard } from '@/types/warehouse'; interface Metric { @@ -49,7 +51,7 @@ const METRICS: Metric[] = [ export default function WarehouseDashboardPage() { const navigate = useNavigate(); - const { data, isLoading } = useWarehouseDashboard(); + const { data, isLoading } = useQuery(api.warehouses.dashboard.queryOptions()); return ( diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseDetailPage.tsx index 145d3579d..31509766d 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseDetailPage.tsx @@ -27,20 +27,27 @@ import { formatCapacity, humanizeEnum, } from '@/components/warehouses'; -import { - useWarehouse, - useWarehouseInventory, - useWarehouseYards, - useWarehouseZones, -} from '@/hooks/useWarehouses'; +import { useQuery } from '@tanstack/react-query'; + +import { api } from '@/services/api'; import type { WarehouseYard, WarehouseZone } from '@/types/warehouse'; export default function WarehouseDetailPage() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); - const { data: warehouse, isLoading } = useWarehouse(id); - const yardsQuery = useWarehouseYards(id); + const { data: warehouse, isLoading } = useQuery( + api.warehouses.getById.queryOptions({ + input: { id: id ?? '' }, + enabled: Boolean(id), + }), + ); + const yardsQuery = useQuery( + api.warehouses.listYards.queryOptions({ + input: { warehouseId: id ?? '' }, + enabled: Boolean(id), + }), + ); const [yardModalOpen, setYardModalOpen] = useState(false); const [editingYard, setEditingYard] = useState(null); @@ -49,9 +56,18 @@ export default function WarehouseDetailPage() { const [editingZone, setEditingZone] = useState(null); const [selectedYardId, setSelectedYardId] = useState(null); - const zonesQuery = useWarehouseZones(selectedYardId ?? undefined); + const zonesQuery = useQuery( + api.warehouses.listZones.queryOptions({ + input: { yardId: selectedYardId ?? '' }, + enabled: Boolean(selectedYardId), + }), + ); - const inventoryQuery = useWarehouseInventory(id ? { warehouseId: id } : undefined); + const inventoryQuery = useQuery( + api.warehouses.listInventory.queryOptions({ + input: { filter: id ? { warehouseId: id } : undefined }, + }), + ); const yards = yardsQuery.data ?? []; const yardOptions = useMemo( diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInventoryPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInventoryPage.tsx index 234c37826..2ae417db1 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInventoryPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInventoryPage.tsx @@ -10,12 +10,9 @@ import { ReceiveInventoryModal, inventoryStatusOptions, } from '@/components/warehouses'; -import { - useWarehouseInventory, - useWarehouseYards, - useWarehouseZones, - useWarehouses, -} from '@/hooks/useWarehouses'; +import { useQuery } from '@tanstack/react-query'; + +import { api } from '@/services/api'; import type { InventoryFilter, InventoryStatus } from '@/types/warehouse'; export default function WarehouseInventoryPage() { @@ -33,10 +30,24 @@ export default function WarehouseInventoryPage() { [filter, debouncedSearch], ); - const warehousesQuery = useWarehouses(); - const yardsQuery = useWarehouseYards(filter.warehouseId); - const zonesQuery = useWarehouseZones(filter.yardId); - const inventoryQuery = useWarehouseInventory(queryFilter); + const warehousesQuery = useQuery( + api.warehouses.list.queryOptions({ input: {} }), + ); + const yardsQuery = useQuery( + api.warehouses.listYards.queryOptions({ + input: { warehouseId: filter.warehouseId ?? '' }, + enabled: Boolean(filter.warehouseId), + }), + ); + const zonesQuery = useQuery( + api.warehouses.listZones.queryOptions({ + input: { yardId: filter.yardId ?? '' }, + enabled: Boolean(filter.yardId), + }), + ); + const inventoryQuery = useQuery( + api.warehouses.listInventory.queryOptions({ input: { filter: queryFilter } }), + ); const warehouseOptions = useMemo( () => (warehousesQuery.data ?? []).map((w) => ({ value: w.id, label: `${w.name} (${w.code})` })), diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInvoicesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInvoicesPage.tsx index 7b1ae4d30..3f0958a7f 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInvoicesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInvoicesPage.tsx @@ -19,13 +19,10 @@ import { Ban, CreditCard, Eye, Search } from 'lucide-react'; import { DataTable, type ColumnDef } from '@edr/ui-common'; import { PageContainer, PageHeader } from '@/components/page'; +import { useMutation, useQuery } from '@tanstack/react-query'; + +import { api } from '@/services/api'; import { useToast } from '@/hooks/use-toast'; -import { - useCancelInvoice, - usePayInvoice, - useWarehouseInvoice, - useWarehouseInvoices, -} from '@/hooks/useWarehouses'; import { WAREHOUSE_INVOICE_STATUSES, type WarehouseFeeInvoice, @@ -48,7 +45,11 @@ export default function WarehouseInvoicesPage() { const [search, setSearch] = useState(''); const [detailId, setDetailId] = useState(null); - const { data, isLoading } = useWarehouseInvoices(status ? { status } : undefined); + const { data, isLoading } = useQuery( + api.warehouses.invoices.queryOptions({ + input: { filter: status ? { status } : undefined }, + }), + ); const invoices = data ?? []; const filtered = useMemo(() => { @@ -149,9 +150,14 @@ export default function WarehouseInvoicesPage() { function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () => void }) { const { toast } = useToast(); - const { data: inv, isLoading } = useWarehouseInvoice(id ?? undefined); - const pay = usePayInvoice(); - const cancel = useCancelInvoice(); + const { data: inv, isLoading } = useQuery( + api.warehouses.invoice.queryOptions({ + input: { id: id ?? '' }, + enabled: Boolean(id), + }), + ); + const pay = useMutation(api.warehouses.payInvoice.mutationOptions()); + const cancel = useMutation(api.warehouses.cancelInvoice.mutationOptions()); const [payAmount, setPayAmount] = useState(''); const canPay = inv && (inv.status === 'ISSUED' || inv.status === 'PARTIALLY_PAID'); diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseListPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseListPage.tsx index 3f9df44f6..93fcde493 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseListPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseListPage.tsx @@ -12,7 +12,9 @@ import { WarehouseTable, type WarehouseView, } from '@/components/warehouses'; -import { useWarehouses } from '@/hooks/useWarehouses'; +import { useQuery } from '@tanstack/react-query'; + +import { api } from '@/services/api'; import type { Warehouse, WarehouseFilter } from '@/types/warehouse'; export default function WarehouseListPage() { @@ -28,7 +30,9 @@ export default function WarehouseListPage() { [filter, debouncedSearch], ); - const { data, isLoading, isError } = useWarehouses(queryFilter); + const { data, isLoading, isError } = useQuery( + api.warehouses.list.queryOptions({ input: { filter: queryFilter } }), + ); const warehouses = data ?? []; const openCreate = () => { diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx index af5adb83d..81e998385 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx @@ -17,15 +17,10 @@ import { Plus, Trash2 } from 'lucide-react'; import { DataTable, type ColumnDef } from '@edr/ui-common'; import { PageContainer, PageHeader } from '@/components/page'; +import { useMutation, useQuery } from '@tanstack/react-query'; + +import { api } from '@/services/api'; import { useToast } from '@/hooks/use-toast'; -import { - useAllocationRules, - useCreateAllocationRule, - useCreateFeeRule, - useDeleteAllocationRule, - useDeleteFeeRule, - useFeeRules, -} from '@/hooks/useWarehouses'; import { FEE_RULE_TYPES, type FeeRuleType } from '@/types/warehouse'; const FREIGHT = [ @@ -67,9 +62,11 @@ export default function WarehouseRulesPage() { function AllocationRules() { const { toast } = useToast(); - const { data, isLoading } = useAllocationRules(); - const create = useCreateAllocationRule(); - const remove = useDeleteAllocationRule(); + const { data, isLoading } = useQuery( + api.warehouses.allocationRules.queryOptions(), + ); + const create = useMutation(api.warehouses.createAllocationRule.mutationOptions()); + const remove = useMutation(api.warehouses.deleteAllocationRule.mutationOptions()); const [open, setOpen] = useState(false); const [form, setForm] = useState({ name: '', @@ -181,9 +178,9 @@ function AllocationRules() { function FeeRules() { const { toast } = useToast(); - const { data, isLoading } = useFeeRules(); - const create = useCreateFeeRule(); - const remove = useDeleteFeeRule(); + const { data, isLoading } = useQuery(api.warehouses.feeRules.queryOptions()); + const create = useMutation(api.warehouses.createFeeRule.mutationOptions()); + const remove = useMutation(api.warehouses.deleteFeeRule.mutationOptions()); const [open, setOpen] = useState(false); const [form, setForm] = useState({ name: '', diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts index 14bf3454b..86d14ef7d 100644 --- a/apps/edr-freight-web/backoffice/src/services/api.ts +++ b/apps/edr-freight-web/backoffice/src/services/api.ts @@ -1,13 +1,17 @@ import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; -import { endpoint } from "@/utils/endpoint"; +import type { FleetResourceSlug } from "@/pages/fleet/config/resources"; +import type { BookingDetail } from "@/types/booking"; import type { - CreateFileUploadFieldDto, - CreateFileUploadSettingDto, - FileUploadField, - FileUploadSetting, - UpdateFileUploadFieldDto, - UpdateFileUploadSettingDto, -} from "@/types/fileUploadSettings"; + Company, + CompanyListFilter, + CompanyProfile, + CompanyStats, + CustomerBooking, + CustomerDocument, + CustomerPayment, + PaginatedCompanies, + ProfileStatus, +} from "@/types/customer"; import { CreateDropdownOptionDto, CreateDropdownSettingDto, @@ -16,29 +20,1526 @@ import { UpdateDropdownOptionDto, UpdateDropdownSettingDto, } from "@/types/dropdownSettings"; +import type { + CreateFileUploadFieldDto, + CreateFileUploadSettingDto, + FileUploadField, + FileUploadSetting, + UpdateFileUploadFieldDto, + UpdateFileUploadSettingDto, +} from "@/types/fileUploadSettings"; +import type { IOverviewDashboard, OverviewRange } from "@/types/overview"; import { RuleEngineListResult, RuleEngineRecord, RuleEngineResourceSlug, } from "@/types/rule-engine"; -import { fileUploadSettingsService } from "./fileUploadSettings.service"; -import { dropdownSettingsService } from "./dropdownSettings.service"; +import type { + AssignBookingsPayload, + BatchBoardSchedule, + BatchBoardScheduleDetail, + BookableSchedule, + CompositionRemovalEntry, + CreateTrainSchedulePayload, + EligibleContainerBookingsResponse, + FreightType, + LocomotiveRecord, + PinWagonsPayload, + RecordCheckpointPayload, + TrainScheduleDetail, + TrainScheduleFilters, + TrainScheduleListItem, + TrainSchedulePreviewPayload, + TrainSchedulePreviewResponse, + TrainTrackResponse, + UnassignedBookingsResponse, + WagonAllocationAttemptResult, + YardOption, +} from "@/types/trainScheduling"; +import type { + AllocationCriteria, + AllocationPreviewResult, + AllocationRule, + ArrivalQueueItem, + AutoLoadResult, + AutoUnloadArrivedResult, + AutoUnloadResult, + BookingScheduleView, + BulkDispatchResult, + BulkInspectPayload, + BulkInspectResult, + BulkReceivePayload, + BulkReceiveResult, + DeliverInventoryPayload, + EligibleBooking, + FeePreview, + FeeRule, + ImportTrain, + ImportTrainItem, + ImportUnloadedItem, + InspectionAttachment, + InspectionReport, + InspectionReportPayload, + InventoryFilter, + InventoryInquiryFilter, + InventoryInquiryResult, + InventoryMovement, + LoadableWagon, + LoadInventoryPayload, + LoadPassedExportResult, + MoveInventoryPayload, + PayInvoicePayload, + ReadyToLoadRow, + ReceiveInventoryPayload, + ReleaseOrderPayload, + ReserveInventoryPayload, + SaveAllocationRulePayload, + SaveFeeRulePayload, + SaveWarehousePayload, + SaveYardPayload, + SaveZonePayload, + Warehouse, + WarehouseActivityLog, + WarehouseDashboard, + WarehouseFeeInvoice, + WarehouseFilter, + WarehouseInventoryItem, + WarehouseInvoiceFilter, + WarehouseLoading, + WarehouseYard, + WarehouseZone, +} from "@/types/warehouse"; +import { endpoint } from "@/utils/endpoint"; import { - ruleEngineService, - RuleEngineListParams, -} from "./ruleEngine/ruleEngine.service"; -import { - bookingsService, BookingListFilter, + bookingsService, type ApproveStepPayload, type PaginatedBookings, type RejectStepPayload, } from "./bookings.service"; -import type { BookingDetail } from "@/types/booking"; -import type { IOverviewDashboard, OverviewRange } from "@/types/overview"; +import { cargoTypesService } from "./cargo-types.service"; +import { + cargoService, + type Cargo, + type DeliverCargoPayload, +} from "./cargoService"; +import { containerTypesService } from "./container-types.service"; +import { containerService, type Container } from "./containerService"; +import { customersService } from "./customers.service"; +import { dropdownSettingsService } from "./dropdownSettings.service"; +import { fileUploadSettingsService } from "./fileUploadSettings.service"; +import { + fleetService, + type FleetListFilters, + type FleetRecord, +} from "./fleet/fleet.service"; +import { + locomotivesService, + type Locomotive, + type SaveLocomotivePayload, +} from "./locomotives.service"; import { overviewService } from "./overview.service"; +import { + paymentsService, + type PaginatedPayments, + type PaymentListFilter, + type PaymentSummary, +} from "./payments.service"; +import { + routesService, + type RouteRecord, + type SaveRoutePayload, + type YardRef, +} from "./routes.service"; +import { + RuleEngineListParams, + ruleEngineService, +} from "./ruleEngine/ruleEngine.service"; +import { + signaturesService, + type SavedSignature, + type SaveSignaturePayload, +} from "./signatures.service"; +import { trainService, type Train } from "./trains.service"; +import { trainSchedulingService } from "./trainScheduling.service"; +import { wagonTypesService, type WagonType } from "./wagon-types.service"; +import { + wagonService, + type Wagon, + type WagonListFilters, +} from "./wagon.service"; +import { warehouseService } from "./warehouse.service"; + +/** Query keys for inventory-lifecycle mutations that ripple across views. */ +const INVENTORY_INVALIDATIONS: ReadonlyArray = [ + ["warehouse-inventory"], + ["warehouse-loadings"], + ["warehouses"], + // Singular `"warehouse"` root covers loadableWagons / bookingSchedule, which + // change when inventory is loaded/dispatched. Distinct from the `warehouse-*` + // roots above (prefix matching is element-exact, not string-prefix). + ["warehouse"], +]; + +/** + * Train-scheduling mutations broadly affect the schedule board and bookings. + * The grouped hooks invalidated TRAIN_SCHEDULING.ROOT + BOOKINGS.ROOT; since + * every train-scheduling key is prefixed with `"train-scheduling"`, the two + * roots below cover all of them via React Query's prefix matching. + */ +const TRAIN_SCHEDULING_INVALIDATIONS: ReadonlyArray = [ + QUERY_KEYS.TRAIN_SCHEDULING.ROOT, + QUERY_KEYS.BOOKINGS.ROOT, +]; export const api = { + trainScheduling: { + // ── Queries ──────────────────────────────────────────────────────────── + scheduleList: endpoint<{ freightType?: FreightType }, TrainScheduleListItem[]>( + "train-scheduling", + "schedules", + ({ freightType }) => trainSchedulingService.listSchedules(freightType), + () => QUERY_KEYS.TRAIN_SCHEDULING.schedules(), + ), + + batchBoard: endpoint( + "train-scheduling", + "batch-board", + () => trainSchedulingService.getBatchBoard(), + () => QUERY_KEYS.TRAIN_SCHEDULING.batchBoard(), + ), + + batchBoardDetail: endpoint<{ scheduleId: string }, BatchBoardScheduleDetail>( + "train-scheduling", + "batch-board-detail", + ({ scheduleId }) => trainSchedulingService.getBatchBoardDetail(scheduleId), + ({ scheduleId }) => QUERY_KEYS.TRAIN_SCHEDULING.batchBoardDetail(scheduleId), + ), + + scheduleDetail: endpoint< + { id: string; freightType?: FreightType }, + TrainScheduleDetail + >( + "train-scheduling", + "schedule-detail", + ({ id, freightType }) => + trainSchedulingService.getScheduleById(id, freightType), + ({ id }) => QUERY_KEYS.TRAIN_SCHEDULING.scheduleById(id), + ), + + eligibleBookings: endpoint< + { filters?: TrainScheduleFilters; freightType?: FreightType }, + EligibleContainerBookingsResponse + >( + "train-scheduling", + "eligible-bookings", + ({ filters, freightType }) => + trainSchedulingService.getEligibleBookings(filters, freightType), + ({ filters, freightType }) => + QUERY_KEYS.TRAIN_SCHEDULING.eligible(freightType, filters), + ), + + availableLocomotives: endpoint<{ routeId?: string }, LocomotiveRecord[]>( + "train-scheduling", + "locomotives", + ({ routeId }) => trainSchedulingService.getAvailableLocomotives(routeId), + ({ routeId }) => QUERY_KEYS.TRAIN_SCHEDULING.locomotives(routeId), + ), + + bookableSchedules: endpoint< + { originYardId?: string | null; destinationYardId?: string | null }, + BookableSchedule[] + >( + "train-scheduling", + "bookable", + ({ originYardId, destinationYardId }) => + trainSchedulingService.getBookableSchedules( + originYardId ?? undefined, + destinationYardId ?? undefined, + ), + ({ originYardId, destinationYardId }) => [ + ...QUERY_KEYS.TRAIN_SCHEDULING.ROOT, + "bookable", + originYardId ?? "", + destinationYardId ?? "", + ], + ), + + availableDays: endpoint< + { originYardId?: string | null; destinationYardId?: string | null }, + string[] + >( + "train-scheduling", + "available-days", + ({ originYardId, destinationYardId }) => + trainSchedulingService.getAvailableDays( + originYardId ?? undefined, + destinationYardId ?? undefined, + ), + ({ originYardId, destinationYardId }) => [ + ...QUERY_KEYS.TRAIN_SCHEDULING.ROOT, + "available-days", + originYardId ?? "", + destinationYardId ?? "", + ], + ), + + trainTrack: endpoint<{ id: string }, TrainTrackResponse>( + "train-scheduling", + "track", + ({ id }) => trainSchedulingService.getTrack(id), + ({ id }) => QUERY_KEYS.TRAIN_SCHEDULING.track(id), + ), + + unassignedBookings: endpoint< + { scheduleId: string }, + UnassignedBookingsResponse + >( + "train-scheduling", + "unassigned-bookings", + ({ scheduleId }) => + trainSchedulingService.getUnassignedBookings(scheduleId), + ({ scheduleId }) => + QUERY_KEYS.TRAIN_SCHEDULING.unassignedBookings(scheduleId), + ), + + compositionRemovals: endpoint< + { scheduleId: string }, + CompositionRemovalEntry[] + >( + "train-scheduling", + "composition-removals", + ({ scheduleId }) => + trainSchedulingService.getCompositionRemovals(scheduleId), + ({ scheduleId }) => + QUERY_KEYS.TRAIN_SCHEDULING.compositionRemovals(scheduleId), + ), + + // ── Mutations ────────────────────────────────────────────────────────── + runAllocation: endpoint<{ scheduleId: string }, WagonAllocationAttemptResult>( + "train-scheduling", + "run-allocation", + ({ scheduleId }) => trainSchedulingService.runAllocation(scheduleId), + undefined, + () => [QUERY_KEYS.TRAIN_SCHEDULING.ROOT], + ), + + runBatch: endpoint( + "train-scheduling", + "run-batch", + (id) => trainSchedulingService.runBatch(id), + undefined, + () => TRAIN_SCHEDULING_INVALIDATIONS, + ), + + setBookingWindow: endpoint< + { id: string; status: "OPEN" | "CLOSED" }, + TrainScheduleDetail + >( + "train-scheduling", + "set-booking-window", + ({ id, status }) => trainSchedulingService.setBookingWindow(id, status), + undefined, + () => TRAIN_SCHEDULING_INVALIDATIONS, + ), + + markBookingPaid: endpoint( + "train-scheduling", + "mark-booking-paid", + (bookingId) => trainSchedulingService.markBookingPaid(bookingId), + undefined, + () => TRAIN_SCHEDULING_INVALIDATIONS, + ), + + expireBooking: endpoint( + "train-scheduling", + "expire-booking", + (bookingId) => trainSchedulingService.expireBooking(bookingId), + undefined, + () => TRAIN_SCHEDULING_INVALIDATIONS, + ), + + moveBookingSchedule: endpoint< + { bookingId: string; trainScheduleId: string }, + void + >( + "train-scheduling", + "move-booking-schedule", + ({ bookingId, trainScheduleId }) => + trainSchedulingService.moveBookingSchedule(bookingId, trainScheduleId), + undefined, + () => TRAIN_SCHEDULING_INVALIDATIONS, + ), + + createSchedule: endpoint< + { freightType?: FreightType; payload: CreateTrainSchedulePayload }, + TrainScheduleDetail + >( + "train-scheduling", + "create-schedule", + ({ freightType, payload }) => + trainSchedulingService.createSchedule(payload, freightType), + undefined, + () => TRAIN_SCHEDULING_INVALIDATIONS, + ), + + preview: endpoint< + { freightType?: FreightType; payload: TrainSchedulePreviewPayload }, + TrainSchedulePreviewResponse + >("train-scheduling", "preview", ({ freightType, payload }) => + trainSchedulingService.preview(payload, freightType), + ), + + assignBookings: endpoint< + { id: string; freightType?: FreightType; payload: AssignBookingsPayload }, + TrainScheduleDetail + >( + "train-scheduling", + "assign-bookings", + ({ id, freightType, payload }) => + trainSchedulingService.assignBookings(id, payload, freightType), + undefined, + () => TRAIN_SCHEDULING_INVALIDATIONS, + ), + + assignUnassignedBooking: endpoint< + { id: string; bookingId: string }, + TrainScheduleDetail + >( + "train-scheduling", + "assign-unassigned-booking", + ({ id, bookingId }) => + trainSchedulingService.assignUnassignedBooking(id, bookingId), + undefined, + () => TRAIN_SCHEDULING_INVALIDATIONS, + ), + + unassignBooking: endpoint< + { id: string; bookingId: string }, + TrainScheduleDetail + >( + "train-scheduling", + "unassign-booking", + ({ id, bookingId }) => + trainSchedulingService.unassignBooking(id, bookingId), + undefined, + () => TRAIN_SCHEDULING_INVALIDATIONS, + ), + + pinWagons: endpoint<{ id: string; payload: PinWagonsPayload }, TrainScheduleDetail>( + "train-scheduling", + "pin-wagons", + ({ id, payload }) => trainSchedulingService.pinWagons(id, payload), + undefined, + () => TRAIN_SCHEDULING_INVALIDATIONS, + ), + + finalizeSchedule: endpoint( + "train-scheduling", + "finalize-schedule", + (id) => trainSchedulingService.finalizeSchedule(id), + undefined, + () => TRAIN_SCHEDULING_INVALIDATIONS, + ), + + dispatchSchedule: endpoint( + "train-scheduling", + "dispatch-schedule", + (id) => trainSchedulingService.dispatchSchedule(id), + undefined, + () => TRAIN_SCHEDULING_INVALIDATIONS, + ), + + cancelSchedule: endpoint< + { id: string; freightType?: FreightType }, + TrainScheduleDetail + >( + "train-scheduling", + "cancel-schedule", + ({ id, freightType }) => + trainSchedulingService.cancelSchedule(id, freightType ?? "CONTAINER"), + undefined, + () => TRAIN_SCHEDULING_INVALIDATIONS, + ), + + recordCheckpoint: endpoint< + { id: string; payload: RecordCheckpointPayload }, + TrainTrackResponse + >( + "train-scheduling", + "record-checkpoint", + ({ id, payload }) => trainSchedulingService.recordCheckpoint(id, payload), + undefined, + () => TRAIN_SCHEDULING_INVALIDATIONS, + ), + + arriveSchedule: endpoint( + "train-scheduling", + "arrive-schedule", + (id) => trainSchedulingService.arriveSchedule(id), + undefined, + () => TRAIN_SCHEDULING_INVALIDATIONS, + ), + + removeWagonSlot: endpoint< + { scheduleId: string; wagonId: string }, + TrainScheduleDetail + >( + "train-scheduling", + "remove-wagon-slot", + ({ scheduleId, wagonId }) => + trainSchedulingService.removeWagonSlot(scheduleId, wagonId), + undefined, + () => [QUERY_KEYS.TRAIN_SCHEDULING.ROOT], + ), + + updateContainerItem: endpoint< + { scheduleId: string; itemId: string; containerNumber: string | null }, + { id: string; containerNumber: string | null } + >( + "train-scheduling", + "update-container-item", + ({ scheduleId, itemId, containerNumber }) => + trainSchedulingService.updateContainerItem(scheduleId, itemId, { + containerNumber, + }), + undefined, + () => [QUERY_KEYS.TRAIN_SCHEDULING.ROOT], + ), + }, + + warehouses: { + // ── Warehouses ───────────────────────────────────────────────────────── + list: endpoint<{ filter?: WarehouseFilter }, Warehouse[]>( + "warehouses", + "list", + ({ filter }) => warehouseService.list(filter).then((r) => r.data), + ), + + getById: endpoint<{ id: string }, Warehouse>( + "warehouses", + "getById", + ({ id }) => warehouseService.getById(id).then((r) => r.data), + ), + + dashboard: endpoint("warehouses", "dashboard", () => + warehouseService.dashboard().then((r) => r.data), + ), + + create: endpoint( + "warehouses", + "create", + (payload) => warehouseService.create(payload).then((r) => r.data), + undefined, + () => [["warehouses"]], + ), + + update: endpoint< + { id: string; payload: Partial }, + Warehouse + >( + "warehouses", + "update", + ({ id, payload }) => + warehouseService.update(id, payload).then((r) => r.data), + undefined, + () => [["warehouses"]], + ), + + // ── Yards ────────────────────────────────────────────────────────────── + listYards: endpoint<{ warehouseId: string }, WarehouseYard[]>( + "warehouses", + "listYards", + ({ warehouseId }) => + warehouseService.listYards(warehouseId).then((r) => r.data), + ), + + createYard: endpoint< + { warehouseId: string; payload: SaveYardPayload }, + WarehouseYard + >( + "warehouses", + "createYard", + ({ warehouseId, payload }) => + warehouseService.createYard(warehouseId, payload).then((r) => r.data), + undefined, + () => [["warehouses"]], + ), + + updateYard: endpoint< + { id: string; payload: Partial }, + WarehouseYard + >( + "warehouses", + "updateYard", + ({ id, payload }) => + warehouseService.updateYard(id, payload).then((r) => r.data), + undefined, + () => [["warehouses"], ["warehouse-yards"]], + ), + + // ── Zones ────────────────────────────────────────────────────────────── + listZones: endpoint<{ yardId: string }, WarehouseZone[]>( + "warehouses", + "listZones", + ({ yardId }) => warehouseService.listZones(yardId).then((r) => r.data), + ({ yardId }) => ["warehouse-yards", yardId, "zones"], + ), + + createZone: endpoint< + { yardId: string; payload: SaveZonePayload }, + WarehouseZone + >( + "warehouses", + "createZone", + ({ yardId, payload }) => + warehouseService.createZone(yardId, payload).then((r) => r.data), + undefined, + ({ yardId }) => [["warehouse-yards", yardId, "zones"]], + ), + + updateZone: endpoint< + { id: string; payload: Partial }, + WarehouseZone + >( + "warehouses", + "updateZone", + ({ id, payload }) => + warehouseService.updateZone(id, payload).then((r) => r.data), + undefined, + () => [["warehouse-yards"]], + ), + + // ── Inventory (queries) ──────────────────────────────────────────────── + listInventory: endpoint< + { filter?: InventoryFilter }, + WarehouseInventoryItem[] + >( + "warehouse-inventory", + "list", + ({ filter }) => warehouseService.listInventory(filter).then((r) => r.data), + ), + + inquiry: endpoint< + { filter: InventoryInquiryFilter }, + InventoryInquiryResult[] + >( + "warehouse-inventory", + "inquiry", + ({ filter }) => warehouseService.inquiry(filter).then((r) => r.data), + ({ filter }) => ["warehouse-inventory", "inquiry", filter], + ), + + eligibleBookings: endpoint( + "warehouse-inventory", + "eligible-bookings", + () => warehouseService.eligibleBookings().then((r) => r.data), + () => ["warehouse-inventory", "eligible-bookings"], + ), + + readyToLoadExport: endpoint( + "warehouse-inventory", + "ready-to-load-export", + () => warehouseService.readyToLoadExport().then((r) => r.data), + () => ["warehouse-inventory", "ready-to-load-export"], + ), + + loadedExport: endpoint( + "warehouse-inventory", + "loaded-export", + () => warehouseService.loadedExport().then((r) => r.data), + () => ["warehouse-inventory", "loaded-export"], + ), + + importArriveQueue: endpoint( + "warehouse-inventory", + "import-arrive-queue", + () => warehouseService.importArriveQueue().then((r) => r.data), + () => ["warehouse-inventory", "import-arrive-queue"], + ), + + importTrainItems: endpoint<{ scheduleId: string }, ImportTrainItem[]>( + "warehouse-inventory", + "import-train-items", + ({ scheduleId }) => + warehouseService.importTrainItems(scheduleId).then((r) => r.data), + ({ scheduleId }) => ["warehouse-inventory", "import-train-items", scheduleId], + ), + + importUnloadedQueue: endpoint( + "warehouse-inventory", + "import-unloaded-queue", + () => warehouseService.importUnloadedQueue().then((r) => r.data), + () => ["warehouse-inventory", "import-unloaded-queue"], + ), + + importPickupReadyQueue: endpoint( + "warehouse-inventory", + "import-pickup-ready-queue", + () => warehouseService.importPickupReadyQueue().then((r) => r.data), + () => ["warehouse-inventory", "import-pickup-ready-queue"], + ), + + loadableWagons: endpoint( + "warehouse", + "loadable-wagons", + () => warehouseService.loadableWagons().then((r) => r.data), + () => ["warehouse", "loadable-wagons"], + ), + + loadings: endpoint< + { params?: { bookingId?: string; wagonId?: string } }, + WarehouseLoading[] + >( + "warehouse-loadings", + "list", + ({ params }) => warehouseService.loadings(params).then((r) => r.data), + ({ params }) => ["warehouse-loadings", params ?? {}], + ), + + bookingSchedule: endpoint<{ bookingId: string }, BookingScheduleView>( + "warehouse", + "booking-schedule", + ({ bookingId }) => + warehouseService.bookingSchedule(bookingId).then((r) => r.data), + ({ bookingId }) => ["warehouse", "booking-schedule", bookingId], + ), + + movements: endpoint<{ id: string }, InventoryMovement[]>( + "warehouse-inventory", + "movements", + ({ id }) => warehouseService.movements(id).then((r) => r.data), + ({ id }) => ["warehouse-inventory", id, "movements"], + ), + + activity: endpoint<{ id: string }, WarehouseActivityLog[]>( + "warehouse-inventory", + "activity", + ({ id }) => warehouseService.activity(id).then((r) => r.data), + ({ id }) => ["warehouse-inventory", id, "activity"], + ), + + arrivalQueue: endpoint( + "warehouse-inventory", + "arrival-queue", + () => warehouseService.arrivalQueue().then((r) => r.data), + () => ["warehouse-inventory", "arrival-queue"], + ), + + inspectionReports: endpoint<{ inventoryId: string }, InspectionReport[]>( + "warehouse-inventory", + "inspection-reports", + ({ inventoryId }) => + warehouseService.listInspectionReports(inventoryId).then((r) => r.data), + ({ inventoryId }) => + ["warehouse-inventory", inventoryId, "inspection-reports"], + ), + + allocationRules: endpoint( + "warehouse-allocation-rules", + "list", + () => warehouseService.listAllocationRules().then((r) => r.data), + () => ["warehouse-allocation-rules"], + ), + + feeRules: endpoint( + "warehouse-fee-rules", + "list", + () => warehouseService.listFeeRules().then((r) => r.data), + () => ["warehouse-fee-rules"], + ), + + feePreview: endpoint<{ inventoryId: string }, FeePreview[]>( + "warehouse-inventory", + "fee-preview", + ({ inventoryId }) => + warehouseService.feePreview(inventoryId).then((r) => r.data), + ({ inventoryId }) => ["warehouse-inventory", inventoryId, "fee-preview"], + ), + + invoices: endpoint<{ filter?: WarehouseInvoiceFilter }, WarehouseFeeInvoice[]>( + "warehouse-fee-invoices", + "list", + ({ filter }) => warehouseService.listInvoices(filter).then((r) => r.data), + ({ filter }) => ["warehouse-fee-invoices", filter ?? {}], + ), + + invoice: endpoint<{ id: string }, WarehouseFeeInvoice>( + "warehouse-fee-invoices", + "detail", + ({ id }) => warehouseService.getInvoice(id).then((r) => r.data), + ({ id }) => ["warehouse-fee-invoices", "detail", id], + ), + + invoicesForInventory: endpoint< + { inventoryId: string }, + WarehouseFeeInvoice[] + >( + "warehouse-inventory", + "fee-invoices", + ({ inventoryId }) => + warehouseService.invoicesForInventory(inventoryId).then((r) => r.data), + ({ inventoryId }) => ["warehouse-inventory", inventoryId, "fee-invoices"], + ), + + // ── Inventory (mutations) ────────────────────────────────────────────── + receiveInventory: endpoint( + "warehouse-inventory", + "receive", + (payload) => warehouseService.receiveInventory(payload).then((r) => r.data), + undefined, + () => [["warehouse-inventory"], ["warehouses"]], + ), + + store: endpoint( + "warehouse-inventory", + "store", + (id) => warehouseService.store(id).then((r) => r.data), + undefined, + () => INVENTORY_INVALIDATIONS, + ), + + reserve: endpoint( + "warehouse-inventory", + "reserve", + (payload) => warehouseService.reserve(payload).then((r) => r.data), + undefined, + () => INVENTORY_INVALIDATIONS, + ), + + markReadyForLoading: endpoint( + "warehouse-inventory", + "mark-ready-for-loading", + (id) => warehouseService.markReadyForLoading(id).then((r) => r.data), + undefined, + () => INVENTORY_INVALIDATIONS, + ), + + load: endpoint< + { id: string; payload: LoadInventoryPayload }, + WarehouseInventoryItem + >( + "warehouse-inventory", + "load", + ({ id, payload }) => warehouseService.load(id, payload).then((r) => r.data), + undefined, + () => INVENTORY_INVALIDATIONS, + ), + + dispatch: endpoint( + "warehouse-inventory", + "dispatch", + (id) => warehouseService.dispatch(id).then((r) => r.data), + undefined, + () => INVENTORY_INVALIDATIONS, + ), + + move: endpoint< + { id: string; payload: MoveInventoryPayload }, + WarehouseInventoryItem + >( + "warehouse-inventory", + "move", + ({ id, payload }) => warehouseService.move(id, payload).then((r) => r.data), + undefined, + () => INVENTORY_INVALIDATIONS, + ), + + markReadyForPickup: endpoint( + "warehouse-inventory", + "mark-ready-for-pickup", + (id) => warehouseService.markReadyForPickup(id).then((r) => r.data), + undefined, + () => INVENTORY_INVALIDATIONS, + ), + + release: endpoint< + { id: string; payload: ReleaseOrderPayload }, + WarehouseInventoryItem + >( + "warehouse-inventory", + "release", + ({ id, payload }) => + warehouseService.release(id, payload).then((r) => r.data), + undefined, + () => INVENTORY_INVALIDATIONS, + ), + + deliver: endpoint< + { id: string; payload: DeliverInventoryPayload }, + WarehouseInventoryItem + >( + "warehouse-inventory", + "deliver", + ({ id, payload }) => + warehouseService.deliver(id, payload).then((r) => r.data), + undefined, + () => INVENTORY_INVALIDATIONS, + ), + + bulkReceive: endpoint( + "warehouse-inventory", + "bulk-receive", + (payload) => warehouseService.receiveBulk(payload).then((r) => r.data), + undefined, + () => INVENTORY_INVALIDATIONS, + ), + + loadPassedExport: endpoint( + "warehouse-inventory", + "load-passed-export", + () => warehouseService.loadPassedExport().then((r) => r.data), + undefined, + () => INVENTORY_INVALIDATIONS, + ), + + bulkMarkInspected: endpoint( + "warehouse-inventory", + "bulk-mark-inspected", + (payload) => warehouseService.bulkMarkInspected(payload).then((r) => r.data), + undefined, + () => INVENTORY_INVALIDATIONS, + ), + + bulkDispatchExport: endpoint( + "warehouse-inventory", + "bulk-dispatch-export", + (inventoryIds) => + warehouseService.bulkDispatchExport(inventoryIds).then((r) => r.data), + undefined, + () => INVENTORY_INVALIDATIONS, + ), + + autoUnloadArrivedBookings: endpoint( + "warehouse-inventory", + "auto-unload-arrived-bookings", + (scheduleId) => + warehouseService + .autoUnloadArrivedBookings(scheduleId) + .then((r) => r.data), + undefined, + () => INVENTORY_INVALIDATIONS, + ), + + autoUnloadArrived: endpoint( + "warehouse-inventory", + "auto-unload-arrived", + () => warehouseService.autoUnloadArrived().then((r) => r.data), + undefined, + () => [["warehouse-inventory"], ["warehouses"]], + ), + + autoLoadReady: endpoint( + "warehouse-inventory", + "auto-load-ready", + () => warehouseService.autoLoadReady().then((r) => r.data), + undefined, + () => INVENTORY_INVALIDATIONS, + ), + + unloadBooking: endpoint< + { bookingId: string; payload?: Record }, + WarehouseInventoryItem + >( + "warehouse-inventory", + "unload-booking", + ({ bookingId, payload }) => + warehouseService.unloadBooking(bookingId, payload).then((r) => r.data), + undefined, + () => [["warehouse-inventory"], ["warehouses"]], + ), + + createInspectionReport: endpoint< + { inventoryId: string; payload: InspectionReportPayload }, + InspectionReport + >( + "warehouse-inventory", + "create-inspection-report", + ({ inventoryId, payload }) => + warehouseService + .createInspectionReport(inventoryId, payload) + .then((r) => r.data), + undefined, + ({ inventoryId }) => [ + ["warehouse-inventory", inventoryId, "inspection-reports"], + ["warehouse-inventory"], + ], + ), + + uploadInspectionAttachments: endpoint< + { reportId: string; files: File[] }, + InspectionAttachment[] + >( + "warehouse-inventory", + "upload-inspection-attachments", + ({ reportId, files }) => + warehouseService + .uploadInspectionAttachments(reportId, files) + .then((r) => r.data), + ), + + // ── Allocation + fee rules ───────────────────────────────────────────── + previewAllocation: endpoint( + "warehouse-allocation-rules", + "preview", + (criteria) => + warehouseService.previewAllocation(criteria).then((r) => r.data), + ), + + createAllocationRule: endpoint( + "warehouse-allocation-rules", + "create", + (payload) => + warehouseService.createAllocationRule(payload).then((r) => r.data), + undefined, + () => [["warehouse-allocation-rules"]], + ), + + updateAllocationRule: endpoint< + { id: string; payload: Partial }, + AllocationRule + >( + "warehouse-allocation-rules", + "update", + ({ id, payload }) => + warehouseService.updateAllocationRule(id, payload).then((r) => r.data), + undefined, + () => [["warehouse-allocation-rules"]], + ), + + deleteAllocationRule: endpoint( + "warehouse-allocation-rules", + "delete", + (id) => warehouseService.deleteAllocationRule(id).then(() => undefined), + undefined, + () => [["warehouse-allocation-rules"]], + ), + + createFeeRule: endpoint( + "warehouse-fee-rules", + "create", + (payload) => warehouseService.createFeeRule(payload).then((r) => r.data), + undefined, + () => [["warehouse-fee-rules"]], + ), + + updateFeeRule: endpoint< + { id: string; payload: Partial }, + FeeRule + >( + "warehouse-fee-rules", + "update", + ({ id, payload }) => + warehouseService.updateFeeRule(id, payload).then((r) => r.data), + undefined, + () => [["warehouse-fee-rules"]], + ), + + deleteFeeRule: endpoint( + "warehouse-fee-rules", + "delete", + (id) => warehouseService.deleteFeeRule(id).then(() => undefined), + undefined, + () => [["warehouse-fee-rules"]], + ), + + // ── Invoices ─────────────────────────────────────────────────────────── + generateInvoice: endpoint< + { inventoryId: string; confirmZero?: boolean }, + WarehouseFeeInvoice + >( + "warehouse-fee-invoices", + "generate", + ({ inventoryId, confirmZero }) => + warehouseService + .generateInvoice(inventoryId, confirmZero) + .then((r) => r.data), + undefined, + () => [["warehouse-fee-invoices"], ["warehouse-inventory"]], + ), + + cancelInvoice: endpoint( + "warehouse-fee-invoices", + "cancel", + (id) => warehouseService.cancelInvoice(id).then((r) => r.data), + undefined, + () => [["warehouse-fee-invoices"], ["warehouse-inventory"]], + ), + + payInvoice: endpoint< + { id: string; payload: PayInvoicePayload }, + WarehouseFeeInvoice + >( + "warehouse-fee-invoices", + "pay", + ({ id, payload }) => + warehouseService.payInvoice(id, payload).then((r) => r.data), + undefined, + () => [["warehouse-fee-invoices"], ["warehouse-inventory"]], + ), + + gateClearance: endpoint( + "warehouse-fee-invoices", + "gate-clearance", + (inventoryId) => + warehouseService.gateClearance(inventoryId).then((r) => r.data), + undefined, + () => [["warehouse-fee-invoices"], ["warehouse-inventory"]], + ), + }, + + routes: { + list: endpoint("routes", "list", () => + routesService.getAll().then((r) => r.data), + ), + + yards: endpoint( + "routes", + "yards", + () => routesService.getYards().then((r) => r.data.data), + () => ["routes", "yards"], + ), + + create: endpoint( + "routes", + "create", + (payload) => routesService.create(payload).then((r) => r.data), + undefined, + () => [["routes"]], + ), + + update: endpoint<{ id: string; data: Partial }, RouteRecord>( + "routes", + "update", + ({ id, data }) => routesService.update(id, data).then((r) => r.data), + undefined, + () => [["routes"]], + ), + + deactivate: endpoint( + "routes", + "deactivate", + (id) => routesService.deactivate(id).then(() => undefined), + undefined, + () => [["routes"]], + ), + }, + + stations: { + list: endpoint( + "train-scheduling", + "stations", + () => trainSchedulingService.getStations(), + () => QUERY_KEYS.TRAIN_SCHEDULING.stations(), + ), + }, + + containers: { + list: endpoint("containers", "list", () => + containerService.getAll().then((r) => r.data), + ), + + listByWagon: endpoint<{ wagonId: string }, Container[]>( + "containers", + "listByWagon", + ({ wagonId }) => containerService.getByWagon(wagonId).then((r) => r.data), + ({ wagonId }) => ["containers", "wagon", wagonId], + ), + + getById: endpoint<{ id: string }, Container>( + "containers", + "getById", + ({ id }) => containerService.getById(id).then((r) => r.data), + ), + + create: endpoint, Container>( + "containers", + "create", + (payload) => containerService.create(payload).then((r) => r.data), + undefined, + () => [["containers"]], + ), + + update: endpoint<{ id: string; data: Partial }, Container>( + "containers", + "update", + ({ id, data }) => containerService.update(id, data).then((r) => r.data), + undefined, + () => [["containers"]], + ), + + remove: endpoint( + "containers", + "remove", + (id) => containerService.delete(id).then(() => undefined), + undefined, + () => [["containers"]], + ), + + assignToWagon: endpoint< + { containerId: string; wagonId: string; position?: number }, + Container + >( + "containers", + "assignToWagon", + ({ containerId, wagonId, position }) => + containerService + .assignToWagon(containerId, wagonId, position) + .then((r) => r.data), + undefined, + () => [["containers"]], + ), + + unassign: endpoint( + "containers", + "unassign", + (containerId) => + containerService.unassign(containerId).then(() => undefined), + undefined, + () => [["containers"]], + ), + }, + + containerTypes: { + list: endpoint("container-types", "list", () => + containerTypesService.getContainerTypes(), + ), + }, + + wagons: { + list: endpoint<{ filters?: WagonListFilters }, Wagon[]>( + "wagons", + "list", + ({ filters }) => wagonService.getAll(filters ?? {}).then((r) => r.data), + ({ filters }) => ["wagons", "list", filters ?? {}], + ), + + listByTrain: endpoint<{ trainId: string }, Wagon[]>( + "wagons", + "listByTrain", + ({ trainId }) => wagonService.getByTrain(trainId).then((r) => r.data), + ({ trainId }) => ["wagons", "train", trainId], + ), + + getById: endpoint<{ id: string }, Wagon>( + "wagons", + "getById", + ({ id }) => wagonService.getById(id).then((r) => r.data), + ), + + assignToTrain: endpoint< + { wagonId: string; trainId: string; sequenceNumber?: number }, + Wagon + >( + "wagons", + "assignToTrain", + ({ wagonId, trainId, sequenceNumber }) => + wagonService + .assignToTrain(wagonId, trainId, sequenceNumber) + .then((r) => r.data), + undefined, + () => [["wagons"]], + ), + + unassign: endpoint( + "wagons", + "unassign", + (wagonId) => wagonService.unassign(wagonId).then(() => undefined), + undefined, + () => [["wagons"]], + ), + + reorder: endpoint<{ trainId: string; wagonIds: string[] }, Wagon[]>( + "wagons", + "reorder", + ({ trainId, wagonIds }) => + wagonService.reorder(trainId, wagonIds).then((r) => r.data), + undefined, + () => [["wagons"]], + ), + + create: endpoint, Wagon>( + "wagons", + "create", + (payload) => wagonService.create(payload).then((r) => r.data), + undefined, + () => [["wagons"]], + ), + + update: endpoint<{ id: string; data: Partial }, Wagon>( + "wagons", + "update", + ({ id, data }) => wagonService.update(id, data).then((r) => r.data), + undefined, + () => [["wagons"]], + ), + + remove: endpoint( + "wagons", + "remove", + (id) => wagonService.delete(id).then(() => undefined), + undefined, + () => [["wagons"]], + ), + }, + + trains: { + list: endpoint( + "trains", + "list", + () => trainService.getAll().then((r) => r.data), + () => ["trains", "list"], + ), + + getById: endpoint<{ id: string }, Train>( + "trains", + "getById", + ({ id }) => trainService.getById(id).then((r) => r.data), + ({ id }) => ["trains", "detail", id], + ), + + create: endpoint, Train>( + "trains", + "create", + (payload) => trainService.create(payload).then((r) => r.data), + undefined, + () => [["trains"]], + ), + + update: endpoint<{ id: string; data: Partial }, Train>( + "trains", + "update", + ({ id, data }) => trainService.update(id, data).then((r) => r.data), + undefined, + () => [["trains"]], + ), + + remove: endpoint( + "trains", + "remove", + (id) => trainService.delete(id).then(() => undefined), + undefined, + () => [["trains"]], + ), + }, + + locomotives: { + list: endpoint( + "locomotives", + "list", + () => locomotivesService.getAll().then((r) => r.data), + () => ["locomotives"], + ), + + create: endpoint, Locomotive>( + "locomotives", + "create", + (payload) => locomotivesService.create(payload).then((r) => r.data), + undefined, + () => [["locomotives"]], + ), + + update: endpoint< + { id: string; data: Partial }, + Locomotive + >( + "locomotives", + "update", + ({ id, data }) => locomotivesService.update(id, data).then((r) => r.data), + undefined, + () => [["locomotives"]], + ), + + decommission: endpoint( + "locomotives", + "decommission", + (id) => locomotivesService.decommission(id).then(() => undefined), + undefined, + () => [["locomotives"]], + ), + }, + + cargoTypes: { + list: endpoint("cargo-types", "list", () => + cargoTypesService.getCargoTypes(), + ), + }, + + payments: { + list: endpoint<{ filter?: PaymentListFilter }, PaginatedPayments>( + "payments", + "list", + ({ filter }) => paymentsService.list(filter), + ({ filter }) => ["payments", "list", filter ?? {}], + ), + + summary: endpoint( + "payments", + "summary", + () => paymentsService.getSummary(), + () => ["payments", "summary"], + ), + }, + + signatures: { + mySignature: endpoint( + "me", + "signature", + () => signaturesService.getMySignature(), + () => ["me", "signature"], + ), + + save: endpoint( + "me", + "save-signature", + (payload) => signaturesService.saveMySignature(payload), + undefined, + () => [["me", "signature"]], + ), + }, + + fleet: { + list: endpoint< + { slug: FleetResourceSlug; filters?: FleetListFilters }, + FleetRecord[] + >( + "fleet", + "list", + ({ slug, filters }) => fleetService.list(slug, filters), + ({ slug, filters }) => [...QUERY_KEYS.FLEET.list(slug), filters ?? {}], + ), + + create: endpoint< + { slug: FleetResourceSlug; data: Record }, + unknown + >( + "fleet", + "create", + ({ slug, data }) => fleetService.create(slug, data), + undefined, + ({ slug }) => [QUERY_KEYS.FLEET.list(slug)], + ), + + update: endpoint< + { slug: FleetResourceSlug; id: string; data: Record }, + unknown + >( + "fleet", + "update", + ({ slug, id, data }) => fleetService.update(slug, id, data), + undefined, + ({ slug }) => [QUERY_KEYS.FLEET.list(slug)], + ), + + remove: endpoint<{ slug: FleetResourceSlug; id: string }, unknown>( + "fleet", + "remove", + ({ slug, id }) => fleetService.remove(slug, id), + undefined, + ({ slug }) => [QUERY_KEYS.FLEET.list(slug)], + ), + }, + + wagonTypes: { + list: endpoint("wagon-types", "list", () => + wagonTypesService.getWagonTypes(), + ), + + create: endpoint, WagonType>( + "wagon-types", + "create", + (payload) => wagonTypesService.create(payload).then((r) => r.data), + undefined, + () => [["wagon-types"]], + ), + + update: endpoint<{ id: string; data: Partial }, WagonType>( + "wagon-types", + "update", + ({ id, data }) => wagonTypesService.update(id, data).then((r) => r.data), + undefined, + () => [["wagon-types"]], + ), + + remove: endpoint( + "wagon-types", + "remove", + (id) => wagonTypesService.delete(id).then(() => undefined), + undefined, + () => [["wagon-types"]], + ), + }, + + cargoes: { + list: endpoint("cargoes", "list", () => + cargoService.getAll().then((r) => r.data), + ), + + listByContainer: endpoint<{ containerId: string }, Cargo[]>( + "cargoes", + "listByContainer", + ({ containerId }) => + cargoService.getByContainer(containerId).then((r) => r.data), + ), + + getById: endpoint<{ id: string }, Cargo>("cargoes", "getById", ({ id }) => + cargoService.getById(id).then((r) => r.data), + ), + + create: endpoint, Cargo>( + "cargoes", + "create", + (payload) => cargoService.create(payload).then((r) => r.data), + undefined, + () => [["cargoes"]], + ), + + update: endpoint<{ id: string; data: Partial }, Cargo>( + "cargoes", + "update", + ({ id, data }) => cargoService.update(id, data).then((r) => r.data), + undefined, + () => [["cargoes"]], + ), + + remove: endpoint( + "cargoes", + "remove", + (id) => cargoService.delete(id).then(() => undefined), + undefined, + () => [["cargoes"]], + ), + + load: endpoint< + { id: string; quantity: number; weight: number; volume?: number }, + Cargo + >( + "cargoes", + "load", + ({ id, quantity, weight, volume }) => + cargoService.load(id, quantity, weight, volume).then((r) => r.data), + undefined, + () => [["cargoes"]], + ), + + deliver: endpoint<{ id: string; payload?: DeliverCargoPayload }, Cargo>( + "cargoes", + "deliver", + ({ id, payload }) => + cargoService.deliver(id, payload).then((r) => r.data), + undefined, + () => [["cargoes"]], + ), + + unload: endpoint<{ id: string }, Cargo>( + "cargoes", + "unload", + ({ id }) => cargoService.unload(id).then((r) => r.data), + undefined, + () => [["cargoes"]], + ), + }, + fileUploadSettings: { list: endpoint( "file-upload-settings", @@ -62,46 +1563,68 @@ export const api = { "file-upload-settings", "create", (payload) => fileUploadSettingsService.create(payload), + undefined, + () => [["file-upload-settings"]], ), update: endpoint< { id: string; dto: UpdateFileUploadSettingDto }, FileUploadSetting - >("file-upload-settings", "update", ({ id, dto }) => - fileUploadSettingsService.update(id, dto), + >( + "file-upload-settings", + "update", + ({ id, dto }) => fileUploadSettingsService.update(id, dto), + undefined, + () => [["file-upload-settings"]], ), remove: endpoint<{ id: string }, void>( "file-upload-settings", "remove", ({ id }) => fileUploadSettingsService.remove(id), + undefined, + () => [["file-upload-settings"]], ), replaceFields: endpoint< { id: string; fields: CreateFileUploadFieldDto[] }, FileUploadField[] - >("file-upload-settings", "replaceFields", ({ id, fields }) => - fileUploadSettingsService.replaceFields(id, fields), + >( + "file-upload-settings", + "replaceFields", + ({ id, fields }) => fileUploadSettingsService.replaceFields(id, fields), + undefined, + () => [["file-upload-settings"]], ), addField: endpoint< { settingId: string; dto: CreateFileUploadFieldDto }, FileUploadField - >("file-upload-settings", "addField", ({ settingId, dto }) => - fileUploadSettingsService.addField(settingId, dto), + >( + "file-upload-settings", + "addField", + ({ settingId, dto }) => fileUploadSettingsService.addField(settingId, dto), + undefined, + () => [["file-upload-settings"]], ), updateField: endpoint< { fieldId: string; dto: UpdateFileUploadFieldDto }, FileUploadField - >("file-upload-settings", "updateField", ({ fieldId, dto }) => - fileUploadSettingsService.updateField(fieldId, dto), + >( + "file-upload-settings", + "updateField", + ({ fieldId, dto }) => fileUploadSettingsService.updateField(fieldId, dto), + undefined, + () => [["file-upload-settings"]], ), removeField: endpoint<{ fieldId: string }, void>( "file-upload-settings", "removeField", ({ fieldId }) => fileUploadSettingsService.removeField(fieldId), + undefined, + () => [["file-upload-settings"]], ), }, @@ -128,46 +1651,68 @@ export const api = { "dropdown-settings", "create", (payload) => dropdownSettingsService.create(payload), + undefined, + () => [["dropdown-settings"]], ), update: endpoint< { id: string; dto: UpdateDropdownSettingDto }, DropdownSetting - >("dropdown-settings", "update", ({ id, dto }) => - dropdownSettingsService.update(id, dto), + >( + "dropdown-settings", + "update", + ({ id, dto }) => dropdownSettingsService.update(id, dto), + undefined, + () => [["dropdown-settings"]], ), remove: endpoint<{ id: string }, void>( "dropdown-settings", "remove", ({ id }) => dropdownSettingsService.remove(id), + undefined, + () => [["dropdown-settings"]], ), replaceOptions: endpoint< { id: string; options: CreateDropdownOptionDto[] }, DropdownOption[] - >("dropdown-settings", "replaceOptions", ({ id, options }) => - dropdownSettingsService.replaceOptions(id, options), + >( + "dropdown-settings", + "replaceOptions", + ({ id, options }) => dropdownSettingsService.replaceOptions(id, options), + undefined, + () => [["dropdown-settings"]], ), addOption: endpoint< { id: string; dto: CreateDropdownOptionDto }, DropdownOption - >("dropdown-settings", "addOption", ({ id, dto }) => - dropdownSettingsService.addOption(id, dto), + >( + "dropdown-settings", + "addOption", + ({ id, dto }) => dropdownSettingsService.addOption(id, dto), + undefined, + () => [["dropdown-settings"]], ), updateOption: endpoint< { optionId: string; dto: UpdateDropdownOptionDto }, DropdownOption - >("dropdown-settings", "updateOption", ({ optionId, dto }) => - dropdownSettingsService.updateOption(optionId, dto), + >( + "dropdown-settings", + "updateOption", + ({ optionId, dto }) => dropdownSettingsService.updateOption(optionId, dto), + undefined, + () => [["dropdown-settings"]], ), removeOption: endpoint<{ optionId: string }, void>( "dropdown-settings", "removeOption", ({ optionId }) => dropdownSettingsService.removeOption(optionId), + undefined, + () => [["dropdown-settings"]], ), }, @@ -346,6 +1891,64 @@ export const api = { ), }, + customers: { + stats: endpoint, CompanyStats>( + "customers", + "stats", + () => customersService.stats(), + () => QUERY_KEYS.CUSTOMERS.stats, + ), + + list: endpoint<{ filter: CompanyListFilter }, PaginatedCompanies>( + "customers", + "list", + ({ filter }) => customersService.list(filter), + ({ filter }) => QUERY_KEYS.CUSTOMERS.list(filter), + ), + + getById: endpoint<{ id: string }, Company | undefined>( + "customers", + "getById", + ({ id }) => customersService.getById(id), + ({ id }) => QUERY_KEYS.CUSTOMERS.byId(id), + ), + + bookings: endpoint<{ id: string }, CustomerBooking[]>( + "customers", + "bookings", + ({ id }) => customersService.bookingsFor(id), + ({ id }) => QUERY_KEYS.CUSTOMERS.bookings(id), + ), + + documents: endpoint<{ id: string }, CustomerDocument[]>( + "customers", + "documents", + ({ id }) => customersService.documentsFor(id), + ({ id }) => QUERY_KEYS.CUSTOMERS.documents(id), + ), + + payments: endpoint<{ id: string }, CustomerPayment[]>( + "customers", + "payments", + ({ id }) => customersService.paymentsFor(id), + ({ id }) => QUERY_KEYS.CUSTOMERS.payments(id), + ), + + setProfileStatus: endpoint< + { profileId: string; status: ProfileStatus }, + CompanyProfile + >( + "customers", + "setProfileStatus", + ({ profileId, status }) => customersService.setProfileStatus(profileId, status), + undefined, + (_input, data) => [ + QUERY_KEYS.CUSTOMERS.byId(data.companyId), + QUERY_KEYS.CUSTOMERS.ROOT, + ], + ), + }, + overview: { get: endpoint<{ range?: OverviewRange }, IOverviewDashboard>( "overview", @@ -353,4 +1956,4 @@ export const api = { ({ range }) => overviewService.getDashboard(range), ), }, -}; +}; \ No newline at end of file diff --git a/apps/edr-freight-web/backoffice/src/services/customers.service.ts b/apps/edr-freight-web/backoffice/src/services/customers.service.ts new file mode 100644 index 000000000..d375ad7a7 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/services/customers.service.ts @@ -0,0 +1,90 @@ +import { api as apiClient } from "@/auth/http"; +import { URL_CONSTANTS } from "@/constants/URLS"; +import type { + Company, + CompanyListFilter, + CompanyProfile, + CompanyStats, + CustomerBooking, + CustomerDocument, + CustomerPayment, + PaginatedCompanies, + ProfileStatus, +} from "@/types/customer"; + +const cleanParams = (params: object) => + Object.fromEntries( + Object.entries(params).filter( + ([, value]) => value !== undefined && value !== "" && value !== null, + ), + ); + +/** Lift attributes JSONB into the flat contact/manager fields the UI reads. */ +function mapCompany(dto: Record): Company { + const attrs = (dto.attributes as Record | null) ?? {}; + return { + ...(dto as unknown as Company), + contactPersonName: (attrs.contactPersonName as string | null) ?? null, + contactPersonPhone: (attrs.contactPersonPhone as string | null) ?? null, + generalManagerName: (attrs.generalManagerName as string | null) ?? null, + generalManagerEmail: (attrs.generalManagerEmail as string | null) ?? null, + generalManagerPhone: (attrs.generalManagerPhone as string | null) ?? null, + }; +} + +export const customersService = { + stats(): Promise { + return apiClient + .get(URL_CONSTANTS.COMPANIES.STATS) + .then((r) => r.data); + }, + + list(filter: CompanyListFilter): Promise { + return apiClient + .get<{ items: Record[]; total: number }>( + URL_CONSTANTS.COMPANIES.BASE, + { params: cleanParams(filter) }, + ) + .then((r) => ({ + items: r.data.items.map(mapCompany), + total: r.data.total, + })); + }, + + getById(id: string): Promise { + return apiClient + .get>(URL_CONSTANTS.COMPANIES.BY_ID(id)) + .then((r) => mapCompany(r.data)); + }, + + bookingsFor(companyId: string): Promise { + return apiClient + .get( + URL_CONSTANTS.COMPANIES.BOOKINGS_CUSTOMER_VIEW(companyId), + ) + .then((r) => r.data); + }, + + documentsFor(companyId: string): Promise { + return apiClient + .get(URL_CONSTANTS.COMPANIES.DOCUMENTS(companyId)) + .then((r) => r.data); + }, + + paymentsFor(companyId: string): Promise { + return apiClient + .get( + URL_CONSTANTS.COMPANIES.PAYMENTS_CUSTOMER_VIEW(companyId), + ) + .then((r) => r.data); + }, + + setProfileStatus(profileId: string, status: ProfileStatus): Promise { + return apiClient + .patch( + URL_CONSTANTS.COMPANIES.PROFILE_STATUS(profileId), + { status }, + ) + .then((r) => r.data); + }, +}; diff --git a/apps/edr-freight-web/backoffice/src/types/customer.ts b/apps/edr-freight-web/backoffice/src/types/customer.ts new file mode 100644 index 000000000..0cdccc10b --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/types/customer.ts @@ -0,0 +1,163 @@ +/** + * Customer-management types for the freight backoffice. + * + * These mirror the backend `Company` / `CompanyProfile` entities + * (apps/edr-freight-api/src/modules/companies/entities) plus a few lightweight + * shapes for the related data shown on the detail page (bookings / documents / + * payments). The UI is currently driven by mock data, but the shapes match the + * API so the data layer can be swapped to live endpoints with no UI changes. + */ + +/** Mirrors backend `CompanyType`. */ +export type CompanyType = + | "customer" + | "freight_forwarder" + | "dj_freight_forwarder" + | "transporter"; + +/** Mirrors backend `CompanyStatus`. */ +export type CompanyStatus = "active" | "pending" | "suspended" | "blacklisted"; + +/** Mirrors backend `ProfileType` (the role a company plays). */ +export type ProfileType = + | "importer" + | "exporter" + | "freight_forwarder" + | "dj_freight_forwarder" + | "transporter"; + +/** Mirrors backend `ProfileStatus`. */ +export type ProfileStatus = "active" | "pending" | "suspended" | "blacklisted"; + +/** A single role a company is registered for, with its reference code. */ +export interface CompanyProfile { + id: string; + companyId: string; + type: ProfileType; + reference: string; + status: ProfileStatus; + businessLicense?: string | null; + attributes?: Record | null; + createdAt: string; + updatedAt: string; +} + +/** Mirrors backend `Company` (+ its `companyProfiles`). */ +export interface Company { + id: string; + name: string; + type: CompanyType; + status: CompanyStatus; + tin: string; + vatNumber?: string | null; + fanNumber?: string | null; + country: string; + address?: string | null; + phone?: string | null; + email?: string | null; + contactPersonName?: string | null; + contactPersonPhone?: string | null; + generalManagerName?: string | null; + generalManagerEmail?: string | null; + generalManagerPhone?: string | null; + website?: string | null; + attributes?: Record | null; + companyProfiles: CompanyProfile[]; + createdAt: string; + updatedAt: string; +} + +/** Query parameters for the company list. */ +export interface CompanyListFilter { + page: number; + pageSize: number; + search?: string; + type?: CompanyType; + status?: CompanyStatus; +} + +/** Standard paginated list envelope (matches the bookings service shape). */ +export interface PaginatedCompanies { + items: Company[]; + total: number; +} + +/** KPI counts returned by GET /companies/stats. */ +export interface CompanyStats { + total: number; + active: number; + pending: number; + suspended: number; + blacklisted: number; +} + +/* ------------------------------------------------------------------ * + * Related data shown on the customer detail page (mocked for now). * + * ------------------------------------------------------------------ */ + +export type CustomerBookingStatus = + | "DRAFT" + | "SUBMITTED" + | "PENDING_APPROVAL" + | "APPROVED" + | "PAID" + | "IN_TRANSIT" + | "COMPLETED" + | "REJECTED" + | "CANCELLED"; + +export interface CustomerBooking { + id: string; + reference: string; + status: CustomerBookingStatus; + tradeDirection: "IMPORT" | "EXPORT"; + freightType: "CONTAINER" | "BULK"; + originLabel: string; + destinationLabel: string; + totalAmount: number; + currency: "ETB" | "USD"; + scheduledDate?: string | null; + createdAt: string; +} + +export interface CustomerDocument { + id: string; + name: string; + /** File-upload setting code, e.g. "business_license", "contract". */ + code: string; + mimeType: string; + /** Size in bytes. */ + size: number; + uploadedAt: string; + url?: string | null; +} + +export type CustomerPaymentStatus = + | "action-required" + | "processing" + | "success" + | "failed" + | "canceled" + | "refunded"; + +export type CustomerPaymentMethod = + | "telebirr" + | "cbe-birr" + | "ebirr" + | "waafi" + | "card" + | "dmoney" + | "cac-bank"; + +export interface CustomerPayment { + id: string; + reference: string; + /** Booking reference the payment settles. */ + bookingReference: string; + amount: number; + currency: "ETB" | "USD"; + method: CustomerPaymentMethod; + status: CustomerPaymentStatus; + paidAt?: string | null; + createdAt: string; +} diff --git a/apps/edr-freight-web/backoffice/src/utils/endpoint.ts b/apps/edr-freight-web/backoffice/src/utils/endpoint.ts index 4a4af69d6..02bc70e85 100644 --- a/apps/edr-freight-web/backoffice/src/utils/endpoint.ts +++ b/apps/edr-freight-web/backoffice/src/utils/endpoint.ts @@ -12,6 +12,27 @@ export type QueryConfig = Omit< "queryKey" | "queryFn" >; +/** + * Query keys a mutation should invalidate on success. Receives the mutation + * input and response so keys can be derived from them. Returns a list of query + * keys — each is matched as a *prefix* by React Query, so returning a service + * root (e.g. `["cargoes"]`) invalidates every query nested under it. + * + * The keys are surfaced through `mutationOptions().meta.invalidates`; the + * app-wide `MutationCache` (see `lib/queryClient.ts`) reads them and invalidates + * automatically, so components never wire `onSuccess` invalidation by hand. + */ +export type InvalidatesFn = ( + input: TInput, + data: TResponse, +) => ReadonlyArray; + +/** Shape stored in `mutation.meta.invalidates` and consumed by the MutationCache. */ +export type InvalidatesMeta = ( + variables: unknown, + data: unknown, +) => ReadonlyArray; + // --------------------------------------------------------------------------- // Endpoint interfaces // --------------------------------------------------------------------------- @@ -45,6 +66,7 @@ export function endpoint( action: string, execute: (input: TInput) => Promise, queryKeyBuilder?: (input: TInput) => readonly unknown[], + invalidates?: InvalidatesFn, ) { const buildKey = (input?: TInput): readonly unknown[] => { if (queryKeyBuilder && input !== undefined) { @@ -77,27 +99,25 @@ export function endpoint( }; const mutationOptions = ( - config?: Omit< - UseMutationOptions< - TResponse, - Error, - TInput - >, - "mutationFn" - >, -): UseMutationOptions< - TResponse, - Error, - TInput -> => { - return { - ...config, - mutationFn: ( - variables: TInput, - ): Promise => - execute(variables), + config?: Omit, "mutationFn">, + ): UseMutationOptions => { + const meta = invalidates + ? { + ...config?.meta, + invalidates: ((variables, data) => + invalidates( + variables as TInput, + data as TResponse, + )) satisfies InvalidatesMeta, + } + : config?.meta; + + return { + ...config, + meta, + mutationFn: (variables: TInput): Promise => execute(variables), + }; }; -}; return { call, diff --git a/apps/edr-freight-web/backoffice/tsconfig.app.json b/apps/edr-freight-web/backoffice/tsconfig.app.json index ff909c216..9c7064567 100644 --- a/apps/edr-freight-web/backoffice/tsconfig.app.json +++ b/apps/edr-freight-web/backoffice/tsconfig.app.json @@ -4,9 +4,8 @@ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", "useDefineForClassFields": true, "skipLibCheck": true, - "baseUrl": ".", "paths": { - "@/*": ["src/*"] + "@/*": ["./src/*"] } }, "include": ["src"] diff --git a/apps/edr-freight-web/portal/package.json b/apps/edr-freight-web/portal/package.json index 68da00b88..2ac8e84ca 100644 --- a/apps/edr-freight-web/portal/package.json +++ b/apps/edr-freight-web/portal/package.json @@ -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", diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx index bbcdba42e..15137cb26 100644 --- a/apps/edr-freight-web/portal/src/App.tsx +++ b/apps/edr-freight-web/portal/src/App.tsx @@ -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 ; } -/** 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 ; - if (customerQuery.data) return ; - return ; +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 ; + } + + return ( + <> + {needsOnboarding && !wizardOpen && ( + + )} + + + + ); +} + +/** Slim sticky prompt shown on allowed pages after the wizard is dismissed. */ +function OnboardingResumeBanner({ onResume }: { onResume: () => void }) { + return ( +
+
+ + + Finish setting up your company to unlock bookings, tracking and + billing. + +
+ +
+ ); } /** Keeps authenticated users off the login/signup pages. */ @@ -119,6 +202,11 @@ const sidebarItems: SidebarItem[] = [ href: "/bookings", icon: , }, + { + label: "General Contracts", + href: "/contracts", + icon: , + }, { label: "Tracking", href: "/tracking", @@ -129,12 +217,6 @@ const sidebarItems: SidebarItem[] = [ href: "/billing", icon: , }, - { - section: "Account", - label: "Profile", - href: "/profile", - icon: , - }, { 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 = () => { } /> }> - }> - } /> - - }> { userName={displayName} userEmail={userEmail} companyProfiles={companyProfiles} + companyType={companyType} + activeProfileType={activeProfileType} + onSwitchMode={switchMode} + onCreateProfile={createProfileAndSwitch} > - + } > @@ -206,9 +295,12 @@ const App = () => { path="/bookings/:id/contract" element={} /> + } /> + } /> } /> } /> - } /> + {/* Profile was merged into Settings — keep old links working. */} + } /> } /> } /> diff --git a/apps/edr-freight-web/portal/src/components/AppLayout.tsx b/apps/edr-freight-web/portal/src/components/AppLayout.tsx index e0de92ec5..7f86ead43 100644 --- a/apps/edr-freight-web/portal/src/components/AppLayout.tsx +++ b/apps/edr-freight-web/portal/src/components/AppLayout.tsx @@ -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 | void; + /** Create the profile of the given type (with business license) then switch. */ + onCreateProfile?: ( + type: ServiceType, + licenseFiles: File[], + ) => Promise | void; children: ReactNode; } -const PROFILE_TYPE_LABELS: Record = { - 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("importer"); + const [licenseFiles, setLicenseFiles] = useState([]); + const [createError, setCreateError] = useState(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({ - {/* Right: search + bell + avatar */} + {/* Right: switch + search + bell + avatar */} + {/* Service selector (customer companies only) */} + {canSwitch && ( + + + + + + Select service + {CUSTOMER_SERVICES.map((type) => { + const isActive = type === activeProfileType; + const exists = profileExists(type); + return ( + handleSelectService(type)} + leftSection={ + isActive ? ( + + ) : exists ? ( + + ) : ( + + ) + } + disabled={isActive} + > + {serviceLabel(type)} + {!exists && ( + + (set up) + + )} + + ); + })} + + + )} + {/* Search pill */} - {companyProfiles.map((p) => ( - - { + const isActive = p.type === activeProfileType; + return ( + - {PROFILE_TYPE_LABELS[p.type] ?? p.type} - - - {p.reference} - - - ))} + + {isActive && ( + + )} + + {PROFILE_TYPE_LABELS[p.type] ?? p.type} + + + + {p.reference} + + + ); + })}
@@ -686,6 +846,51 @@ export function AppLayout({ > {children} + + {/* Create-profile modal — opens when switching to a mode the company + doesn't have a profile for yet. */} + (switching ? undefined : setCreateOpen(false))} + title={`Set up your ${serviceLabel(createTarget)} profile`} + centered + radius="lg" + > + + + You don't have a {serviceLabel(createTarget).toLowerCase()} profile + yet. Add your business license to create one and switch to{" "} + {serviceLabel(createTarget).toLowerCase()}. + + } + placeholder="Select license file(s)" + value={licenseFiles} + onChange={(files) => setLicenseFiles(files ?? [])} + error={createError ?? undefined} + /> + + + + + + ); } diff --git a/apps/edr-freight-web/portal/src/components/ModeIndicator.tsx b/apps/edr-freight-web/portal/src/components/ModeIndicator.tsx new file mode 100644 index 000000000..ff8f33bfb --- /dev/null +++ b/apps/edr-freight-web/portal/src/components/ModeIndicator.tsx @@ -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 ( + + + ) : ( + + ) + } + styles={{ + root: { textTransform: "none", letterSpacing: 0, fontWeight: 600 }, + }} + > + Viewing: {label} + + + ); +} + +export default ModeIndicator; diff --git a/apps/edr-freight-web/portal/src/components/PhoneField.tsx b/apps/edr-freight-web/portal/src/components/PhoneField.tsx new file mode 100644 index 000000000..a50621e60 --- /dev/null +++ b/apps/edr-freight-web/portal/src/components/PhoneField.tsx @@ -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>( + function StyledInput(props, ref) { + return ; + }, +); + +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 ( + +
+ +
+
+ ); +} + +interface ControlledPhoneFieldProps { + control: Control; + name: Path; + label?: string; + required?: boolean; + disabled?: boolean; + placeholder?: string; +} + +/** RHF Controller wrapper so forms drop in one line. */ +export function ControlledPhoneField({ + control, + name, + label, + required, + disabled, + placeholder, +}: ControlledPhoneFieldProps) { + return ( + ( + field.onChange(v ?? "")} + onBlur={field.onBlur} + error={fieldState.error?.message} + /> + )} + /> + ); +} + +export default PhoneField; diff --git a/apps/edr-freight-web/portal/src/components/auth/PhoneInput.tsx b/apps/edr-freight-web/portal/src/components/auth/PhoneInput.tsx deleted file mode 100644 index 556f8e309..000000000 --- a/apps/edr-freight-web/portal/src/components/auth/PhoneInput.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { Group, Stack, Text, TextInput, type TextInputProps } from "@mantine/core"; - -type InputPassthrough = Partial; - -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 ( - - {label} - - - - - {errorMsg && ( - {errorMsg} - )} - - ); -} diff --git a/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx b/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx new file mode 100644 index 000000000..5fcfadaa1 --- /dev/null +++ b/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx @@ -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 ( + + + + + + + {errorMessage && ( + } + color="red" + title="Failed to fetch data" + > + {errorMessage} You can still fill in the details manually below. + + )} + + {hasData && ( + } + color="green" + title="Company information loaded" + > + + + License: {hasData.licenceNumber} + + + Status: {hasData.statusDescription} + + {hasData.region && ( + + Location: {hasData.kebele}, {hasData.woreda},{" "} + {hasData.zone}, {hasData.region} + + )} + + + )} + + ); +} diff --git a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx new file mode 100644 index 000000000..17fde6528 --- /dev/null +++ b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx @@ -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( + savedNationality, + ); + const [roles, setRoles] = useState( + existingProfiles.map((p) => p.type), + ); + const [documentFiles, setDocumentFiles] = useState< + Record + >({}); + // Newly-selected business-license files per company_profile id. + const [licenseFiles, setLicenseFiles] = useState>({}); + const [startError, setStartError] = useState(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, + ): 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 ( + + + Complete your onboarding + + + {titleHint} + + + } + > + {phase === "nationality" ? ( + + + + + ) : phase === "role" ? ( + + + {startError && ( + + {startError} + + )} + + + ) : ( + + )} + + ); +} + +function RoleContinueBar({ + disabled, + loading, + onClick, +}: { + disabled: boolean; + loading?: boolean; + onClick: () => void; +}) { + return ( + + ); +} diff --git a/apps/edr-freight-web/portal/src/components/onboarding/RoleLicenseStep.tsx b/apps/edr-freight-web/portal/src/components/onboarding/RoleLicenseStep.tsx new file mode 100644 index 000000000..5206a4b32 --- /dev/null +++ b/apps/edr-freight-web/portal/src/components/onboarding/RoleLicenseStep.tsx @@ -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 = { + 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; + onChange: (value: Record) => 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 ( + + + Upload the business license for each of your operational profiles. You + can attach more than one document per profile. + + + {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 ( + + + + + + +
+ + {label} — Business License + + + {profile.reference} + +
+
+ {hasAny && ( + + Provided + + )} +
+ + {profile.existingFiles.length > 0 && ( + + {profile.existingFiles.map((f) => ( + + + + {f.name} + + + ))} + + )} + + } + placeholder={ + profile.existingFiles.length > 0 + ? "Upload more / replace files" + : "Select license file(s)" + } + value={selected} + onChange={(files) => setFiles(profile.id, files ?? [])} + /> +
+ ); + })} +
+ ); +} diff --git a/apps/edr-freight-web/portal/src/components/phone-field.css b/apps/edr-freight-web/portal/src/components/phone-field.css new file mode 100644 index 000000000..2fd037b99 --- /dev/null +++ b/apps/edr-freight-web/portal/src/components/phone-field.css @@ -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); +} diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts index 08b26a255..6a22ef954 100644 --- a/apps/edr-freight-web/portal/src/constants/URLS.ts +++ b/apps/edr-freight-web/portal/src/constants/URLS.ts @@ -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: { diff --git a/apps/edr-freight-web/portal/src/constants/profileMode.ts b/apps/edr-freight-web/portal/src/constants/profileMode.ts new file mode 100644 index 000000000..a73901ccb --- /dev/null +++ b/apps/edr-freight-web/portal/src/constants/profileMode.ts @@ -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 = { + 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.`; +} diff --git a/apps/edr-freight-web/portal/src/hooks/useAuth.ts b/apps/edr-freight-web/portal/src/hooks/useAuth.ts index 06900270e..0f4f4e6a1 100644 --- a/apps/edr-freight-web/portal/src/hooks/useAuth.ts +++ b/apps/edr-freight-web/portal/src/hooks/useAuth.ts @@ -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> => { + 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> => { + 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, diff --git a/apps/edr-freight-web/portal/src/hooks/useETradeData.ts b/apps/edr-freight-web/portal/src/hooks/useETradeData.ts new file mode 100644 index 000000000..9fbce53d9 --- /dev/null +++ b/apps/edr-freight-web/portal/src/hooks/useETradeData.ts @@ -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 => { + return companiesService.fetchETradeInfo({ tin }); + }, + onError: (error) => { + const { message } = extractApiError(error); + console.error("eTrade fetch error:", message); + }, + }); +} diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/HelloSection.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/HelloSection.tsx index 9f1d726b0..3d7ca6fa5 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/HelloSection.tsx +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/HelloSection.tsx @@ -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({ {greeting} - - {companyName} 👋 - + + + {companyName} 👋 + + + diff --git a/apps/edr-freight-web/portal/src/pages/ProfilePage.tsx b/apps/edr-freight-web/portal/src/pages/ProfilePage.tsx deleted file mode 100644 index cf31d17a9..000000000 --- a/apps/edr-freight-web/portal/src/pages/ProfilePage.tsx +++ /dev/null @@ -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 ( - - {icon && ( - - {icon} - - )} - - - {label} - - - {value || "—"} - - - - ); -} - -function CardHeading({ - icon, - title, - description, -}: { - icon: React.ReactNode; - title: string; - description: string; -}) { - return ( - - - {icon} - - {title} - - - - {description} - - - ); -} - -function PersonnelGroup({ - color, - title, - children, -}: { - color: string; - title: string; - children: React.ReactNode; -}) { - return ( - - - - - {title} - - - - {children} - - - ); -} - -export default function ProfilePage() { - const { data: profile, isPending } = useQuery( - api.companies.getProfile.queryOptions(), - ); - - if (isPending) { - return ( -
- -
- ); - } - - if (!profile) { - return ( -
- No company profile found. -
- ); - } - - // 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 ( - - {/* Header */} - - - - - - - - {profile.companyName} - - - Verified - - - {activeOptions.length > 0 ? ( - - {activeOptions.map((opt) => ( - - {opt.label} · {refByType.get(opt.type)!.reference} - - ))} - - ) : ( - - - - {profile.companyType} - - - )} - - - - - - - {/* Left Column */} - - - {/* Company Details */} - - - } - title="Company Details" - description="Business registration information" - /> - - } - label="Location" - value={profile.companyLocation} - /> - } - label="Address" - value={profile.companyAddress} - /> - } - label="TIN Number" - value={profile.tinNumber} - /> - } - label="FAN Number" - value={profile.fanNumber} - /> - } - label="Email" - value={profile.companyEmail} - /> - } - label="Phone" - value={profile.companyPhone} - /> - - - - {/* Key Personnel */} - - - } - title="Key Personnel" - description="Management and contact persons" - /> - - - - - - - - - - - - - - {/* Power of Attorney */} - {profile.poaName && ( - - - } - title="Power of Attorney" - description="Authorized representative details" - /> - - - - - - - - )} - - - - {/* Right Column */} - - - {/* Operating Roles */} - - - } - title="Operating Roles" - description="Your registered freight roles and reference numbers" - /> - {roleOptions.length === 0 ? ( - - Role management for this company type is coming soon. - - ) : ( - - {roleOptions.map((opt) => { - const active = refByType.get(opt.type); - return ( - - - - {opt.icon} - - - - {opt.label} - - - {active ? active.reference : "Not registered"} - - - - {active ? ( - - {active.status} - - ) : ( - - )} - - ); - })} - - )} - - - {/* Secure Account */} - - - - - - - Secure Account - - - Your information is protected by enterprise-grade security. - Contact support for verified information updates. - - - - - - - - - ); -} diff --git a/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx b/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx index 6f0b17022..8c3204123 100644 --- a/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx @@ -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: }, + { id: "company", label: "Company", icon: }, { id: "contact", label: "Contact Person", icon: }, { id: "gm", label: "General Manager", icon: }, { id: "poa", label: "Power of Attorney", icon: }, { id: "documents", label: "Documents", icon: }, ]; +/** + * 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 ( + + + + + + + + + + + + + + {profile.companyName} + + } + > + Verified + + + + {activeRoles.length > 0 ? ( + + {activeRoles.map((opt) => ( + + {opt.label} · {refByType.get(opt.type)!.reference} + + ))} + + ) : ( + + {profile.companyType.replace(/_/g, " ")} + + )} + + + {profile.companyLocation && ( + + + {profile.companyLocation} + + )} + {profile.tinNumber && ( + + + + TIN {profile.tinNumber} + + + )} + + + + + + ); +} + 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(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 (
@@ -126,126 +210,82 @@ export default function SettingsPage() { ); } - const onboarding = isOnboarding === true; - - const renderProfileContent = (children: React.ReactNode) => { - if (onboarding && tab !== "company" && !profile) { - return ( -
- -
- ); - } - if (!profile) { - return ( - + if (!profile) { + return ( + +
- } - color="gray" - variant="light" - > - Please complete the company profile first. - + + + No company profile found. +
- ); - } - return children; - }; +
+ ); + } return ( - - + + + +
- - {onboarding ? "Complete Your Profile" : "Account Settings"} + <Title order={2} size="h3"> + Account Settings - {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.
-
- { - if (!value) return; - // if (onboarding) return; - setTab(value as SettingsTab); - }} - > - - {TABS.map((t) => ( - - ) : undefined - } - > - {t.label} - - ))} - + value && setTab(value as SettingsTab)} + variant="pills" + radius="md" + > + + {TABS.map((t) => ( + + ) : undefined + } + > + {t.label} + + ))} + - - {!profile ? ( - - ) : ( + - )} - - - - {renderProfileContent( - , - )} - - - - {renderProfileContent( - , - )} - - - - {renderProfileContent( - , - )} - - - - {renderProfileContent( - , - )} - - + + + + + + + + + + + + + + +
); } diff --git a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx index 3a26c92b2..29055a2e8 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -1,6 +1,8 @@ import { + Alert, Box, Button, + Checkbox, Divider, Group, Loader, @@ -13,6 +15,7 @@ import { import { zodResolver } from "@hookform/resolvers/zod"; import { useQuery } from "@tanstack/react-query"; import { + AlertCircle, ArrowLeft, ArrowRight, Building2, @@ -21,42 +24,87 @@ import { FileText, UploadCloud, User, + UserCheck, } 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 type { CompanyRegistrationData } from "@edr/types"; +import { + ControlledPhoneField, + isValidPhone, + toEthiopianE164, +} from "@/components/PhoneField"; import { SmartFileInput } from "@edr/ui-common"; import { api } from "@/services/api"; +import RoleLicenseStep, { + type RoleLicenseProfile, +} from "@/components/onboarding/RoleLicenseStep"; +import ETradeInfo from "@/components/onboarding/ETradeInfo"; -type CompanyStep = "company" | "personnel" | "poa" | "documents" | "confirm"; +type CompanyStep = + | "company" + | "personnel" + | "contact" + | "poa" + | "documents" + | "additional"; const onboardingSchema = z.object({ companyName: z.string().min(1, "Company name is required"), companyEmail: z.string().email("Invalid email address"), - companyPhone: z.string().min(1, "Company phone is required"), - 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"), + // Derived from the eTrade address parts (kebele/woreda/zone/region); no + // standalone input — the granular fields live in the registration section. + companyAddress: z.string().optional(), tinNumber: z.string().length(10, "TIN must be exactly 10 digits"), vatNumber: z .string() .min(1, "VAT number is required") .length(10, "VAT number must be exactly 10 digits"), fanNumber: z.string().length(16, "FAN must be exactly 16 digits"), + licenceNumber: z.string().optional(), + statusDescription: z.string().optional(), + dateRegistered: z.string().optional(), + renewedFrom: z.string().optional(), + renewalDate: z.string().optional(), + renewedTo: z.string().optional(), + region: z.string().optional(), + zone: z.string().optional(), + woreda: z.string().optional(), + kebele: z.string().optional(), + houseNo: z.string().optional(), + etradePhone: z.string().optional(), 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"), + contactPersonPosition: z.string().optional(), + contactPersonEmail: z + .string() + .email("Invalid email address") + .optional() + .or(z.literal("")), + contactPersonPhone: z + .string() + .min(1, "Contact person phone is required") + .refine(isValidPhone, "Enter a valid phone number"), generalManagerName: z.string().min(1, "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(), @@ -69,32 +117,45 @@ const stepFields: Record = { "companyName", "companyEmail", "companyPhone", - "companyPhoneCountryCode", "companyLocation", "companyAddress", "tinNumber", "vatNumber", "fanNumber", + "licenceNumber", + "statusDescription", + "dateRegistered", + "renewedFrom", + "renewalDate", + "renewedTo", + "region", + "zone", + "woreda", + "kebele", + "houseNo", + "etradePhone", ], personnel: [ - "contactPersonName", - "contactPersonPhone", - "contactPersonPhoneCountryCode", "generalManagerName", "generalManagerEmail", "generalManagerPhone", - "generalManagerPhoneCountryCode", + ], + contact: [ + "contactPersonName", + "contactPersonPosition", + "contactPersonEmail", + "contactPersonPhone", ], 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, @@ -102,15 +163,14 @@ function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload { fanNumber: data.fanNumber, attributes: { contactPersonName: data.contactPersonName, - contactPersonPhone: `${data.contactPersonPhoneCountryCode}${data.contactPersonPhone}`, + contactPersonPosition: data.contactPersonPosition || undefined, + contactPersonEmail: data.contactPersonEmail || undefined, + 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, @@ -118,6 +178,98 @@ function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload { }; } +/** Map one wizard step's form values to the profile-update payload it saves. */ +function stepPayload(step: CompanyStep, d: FormData): Partial { + switch (step) { + case "company": + return { + companyName: d.companyName, + companyEmail: d.companyEmail, + companyPhone: d.companyPhone, + companyLocation: d.companyLocation, + companyAddress: d.companyAddress, + tin: d.tinNumber, + vatNumber: d.vatNumber, + fanNumber: d.fanNumber, + licenceNumber: d.licenceNumber, + statusDescription: d.statusDescription, + dateRegistered: d.dateRegistered, + renewedFrom: d.renewedFrom, + renewalDate: d.renewalDate, + renewedTo: d.renewedTo, + region: d.region, + zone: d.zone, + woreda: d.woreda, + kebele: d.kebele, + houseNo: d.houseNo, + etradePhone: d.etradePhone, + }; + case "personnel": + return { + generalManagerName: d.generalManagerName, + generalManagerEmail: d.generalManagerEmail, + generalManagerPhone: d.generalManagerPhone, + }; + case "contact": + return { + contactPersonName: d.contactPersonName, + contactPersonPosition: d.contactPersonPosition || undefined, + contactPersonEmail: d.contactPersonEmail || undefined, + contactPersonPhone: d.contactPersonPhone, + }; + case "poa": + return { + poaName: d.poaName || undefined, + poaPhone: d.poaPhone || undefined, + poaEmail: d.poaEmail || undefined, + poaLocation: d.poaLocation || undefined, + poaAddress: d.poaAddress || undefined, + }; + default: + return {}; + } +} + +/** Seed the form from previously-saved profile data. */ +function toFormValues(p: ProfileResponse): FormData { + // The draft placeholder TIN ("D…") shouldn't show as a real value. + const tin = p.tinNumber && !p.tinNumber.startsWith("D") ? p.tinNumber : ""; + return { + companyName: p.companyName ?? "", + companyEmail: p.companyEmail ?? "", + companyPhone: p.companyPhone ?? "", + companyLocation: p.companyLocation ?? "", + companyAddress: p.companyAddress ?? "", + tinNumber: tin, + vatNumber: p.vatNumber ?? "", + fanNumber: p.fanNumber ?? "", + licenceNumber: p.licenceNumber ?? "", + statusDescription: p.statusDescription ?? "", + dateRegistered: p.dateRegistered ?? "", + renewedFrom: p.renewedFrom ?? "", + renewalDate: p.renewalDate ?? "", + renewedTo: p.renewedTo ?? "", + region: p.region ?? "", + zone: p.zone ?? "", + woreda: p.woreda ?? "", + kebele: p.kebele ?? "", + houseNo: p.houseNo ?? "", + etradePhone: p.etradePhone ?? "", + contactPersonName: p.contactPersonName ?? "", + contactPersonPosition: p.contactPersonPosition ?? "", + contactPersonEmail: p.contactPersonEmail ?? "", + contactPersonPhone: p.contactPersonPhone ?? "", + generalManagerName: p.generalManagerName ?? "", + generalManagerEmail: p.generalManagerEmail ?? "", + generalManagerPhone: p.generalManagerPhone ?? "", + poaName: p.poaName ?? "", + poaPhone: p.poaPhone ?? "", + poaAddress: p.poaAddress ?? "", + poaEmail: p.poaEmail ?? "", + poaLocation: p.poaLocation ?? "", + }; +} + export default function CompanyProfileForm({ documentSettingCode, documentFiles: controlledFiles, @@ -126,6 +278,15 @@ export default function CompanyProfileForm({ onSubmit, isPending, onBack, + initialStep, + resyncOpen, + hideFirstStepBack, + onStepChange, + onSaveStep, + rehydrate, + roleProfiles, + licenseFiles, + onLicenseChange, }: { documentSettingCode: string; documentFiles?: Record; @@ -134,8 +295,47 @@ export default function CompanyProfileForm({ onSubmit: (data: CreateCompanyPayload) => void; isPending: boolean; onBack: () => void; + /** Step to resume at (defaults to "company"). */ + initialStep?: CompanyStep; + /** 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: CompanyStep) => void; + /** Persist the current step's data before advancing; returns an error to show. */ + onSaveStep?: ( + data: Partial, + ) => 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; + onLicenseChange?: (value: Record) => void; }) { - const [step, setStep] = useState("company"); + const [step, setStep] = useState(initialStep ?? "company"); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(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 (initialStep) so progress + // never appears to reset. + 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 >({}); @@ -151,9 +351,11 @@ export default function CompanyProfileForm({ const { register, + control, handleSubmit, trigger, watch, + setValue, formState: { errors }, } = useForm({ resolver: zodResolver(onboardingSchema), @@ -161,83 +363,216 @@ export default function CompanyProfileForm({ companyName: "", companyEmail: "", companyPhone: "", - companyPhoneCountryCode: "+251", companyLocation: "", companyAddress: "", tinNumber: "", vatNumber: "", fanNumber: "", + licenceNumber: "", + statusDescription: "", + dateRegistered: "", + renewedFrom: "", + renewalDate: "", + renewedTo: "", + region: "", + zone: "", + woreda: "", + kebele: "", + houseNo: "", + etradePhone: "", contactPersonName: "", + contactPersonPosition: "", + contactPersonEmail: "", contactPersonPhone: "", - contactPersonPhoneCountryCode: "+251", generalManagerName: "", generalManagerEmail: "", generalManagerPhone: "", - generalManagerPhoneCountryCode: "+251", poaName: "", poaPhone: "", - poaPhoneCountryCode: "+251", poaAddress: "", poaEmail: "", poaLocation: "", }, + // Rehydrate from previously-saved data (RHF re-syncs when `values` change). + values: rehydrate ? toFormValues(rehydrate) : undefined, }); - const formValues = watch(); + // The business owner/manager pulled from eTrade — powers "Use owner as + // manager" on the General Manager step. Null until a TIN lookup succeeds. + const [etradeOwner, setEtradeOwner] = useState<{ + name: string; + phone: string; + } | null>(null); + + // Mirror the two "copy from previous person" checkboxes so they can be + // re-toggled (re-checking re-pulls the latest values). + const [gmIsContact, setGmIsContact] = useState(false); + const [contactIsPoa, setContactIsPoa] = useState(false); + + const handleETradeDataLoaded = (data: CompanyRegistrationData) => { + // Company name comes from the eTrade manager/owner name on the license. + if (data.managerName) { + setValue("companyName", data.managerName, { shouldValidate: true }); + } + setValue("licenceNumber", data.licenceNumber); + setValue("statusDescription", data.statusDescription); + setValue("dateRegistered", data.dateRegistered); + setValue("renewedFrom", data.renewedFrom); + setValue("renewalDate", data.renewalDate); + setValue("renewedTo", data.renewedTo); + setValue("region", data.region); + setValue("zone", data.zone); + setValue("woreda", data.woreda); + setValue("kebele", data.kebele); + setValue("houseNo", data.houseNo); + setValue( + "etradePhone", + toEthiopianE164(data.regularPhone || data.mobilePhone), + ); + + // Compose a readable company address from the granular eTrade parts. + const addressParts = [ + data.houseNo, + data.kebele, + data.woreda, + data.zone, + data.region, + ].filter((part) => part && part.trim()); + if (addressParts.length) { + setValue("companyAddress", addressParts.join(", ")); + } + + // Pre-fill the company contact phone from eTrade's mobile number. + const mobile = toEthiopianE164(data.mobilePhone || data.regularPhone); + if (mobile) { + setValue("companyPhone", mobile, { shouldValidate: true }); + } + + setEtradeOwner({ + name: data.managerName, + phone: toEthiopianE164( + data.managerPhone || data.regularPhone || data.mobilePhone, + ), + }); + }; + + /** Fill the General Manager from the eTrade business owner. */ + const useOwnerAsManager = () => { + if (!etradeOwner) return; + setValue("generalManagerName", etradeOwner.name); + setValue("generalManagerPhone", etradeOwner.phone ?? "", { + shouldValidate: true, + }); + }; + + /** Copy the General Manager into the Contact Person fields (toggleable). */ + const toggleGmAsContact = (checked: boolean) => { + setGmIsContact(checked); + if (!checked) return; + setValue("contactPersonName", watch("generalManagerName")); + setValue("contactPersonEmail", watch("generalManagerEmail")); + setValue("contactPersonPhone", watch("generalManagerPhone")); + }; + + /** Copy the Contact Person into the PoA fields (toggleable, still editable). */ + const toggleContactAsPoa = (checked: boolean) => { + setContactIsPoa(checked); + if (!checked) return; + setValue("poaName", watch("contactPersonName")); + setValue("poaEmail", watch("contactPersonEmail")); + setValue("poaPhone", watch("contactPersonPhone")); + }; + 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; - } - const isValid = await trigger(stepFields[step]); - if (!isValid) return; - setStep(step === "company" ? "personnel" : "poa"); - }; - - const prevStep = () => { - if (step === "company") onBack(); - else if (step === "personnel") setStep("company"); - else if (step === "poa") setStep("personnel"); - else if (step === "documents") setStep("poa"); - else setStep("documents"); - }; - - const STEPS: { key: CompanyStep; icon: React.ReactNode }[] = [ - { key: "company", icon: }, - { key: "personnel", icon: }, - { key: "poa", icon: }, - { key: "documents", icon: }, - { key: "confirm", icon: }, - ]; - - const STEP_LABELS: Record = { - 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`, - }; + // Single source of truth for step sequence — navigation, labels and the + // progress bar all derive from this so adding/removing a step is one edit. const stepOrder: CompanyStep[] = [ "company", "personnel", + "contact", "poa", "documents", - "confirm", + "additional", ]; + const totalSteps = stepOrder.length; const currentIdx = stepOrder.indexOf(step); + /** Validate + persist the current step, returning whether we may advance. */ + const saveCurrentStep = async (): Promise => { + setSaveError(null); + const isValid = await trigger(stepFields[step]); + 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); + } + }; + + // 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; + } + // The documents step has nothing to persist; field steps validate + save + // before advancing. + if (step !== "documents") { + const ok = await saveCurrentStep(); + if (!ok) return; + } + setStep(stepOrder[currentIdx + 1]); + }; + + const prevStep = () => { + setSaveError(null); + if (currentIdx === 0) onBack(); + else setStep(stepOrder[currentIdx - 1]); + }; + + // Back is hidden on the first step during onboarding (can't return to role + // selection); otherwise always available. + const showBack = !(hideFirstStepBack && step === "company"); + + const STEP_ICONS: Record = { + company: , + personnel: , + contact: , + poa: , + documents: , + additional: , + }; + + const STEP_TITLES: Record = { + company: "Company Information", + personnel: "General Manager", + contact: "Contact Person", + poa: "Power of Attorney (Optional)", + documents: "Upload Documents", + additional: "Business License", + }; + + const stepLabel = `Step ${currentIdx + 1} of ${totalSteps} — ${STEP_TITLES[step]}`; + return ( <> @@ -258,7 +593,7 @@ export default function CompanyProfileForm({ className="relative max-w-lg mx-auto px-2" > - {STEPS.map(({ key, icon }, i) => { + {stepOrder.map((key, i) => { const done = i < currentIdx; const active = i === currentIdx; return done || active ? ( @@ -270,7 +605,7 @@ export default function CompanyProfileForm({ color="edr-green" className="relative z-10" > - {done ? : icon} + {done ? : STEP_ICONS[key]} ) : ( - {icon} + {STEP_ICONS[key]} ); })} - {STEP_LABELS[step]} + {stepLabel} @@ -295,6 +630,18 @@ export default function CompanyProfileForm({ {step === "company" && ( <> + + Enter your TIN to auto-fill company information from eTrade + + + + + - + - - - - - + - + + <> + + + Registration Details + + + + + + + + + + + + + + + + Address Information + + + + + + + + + + + + + + )} {step === "personnel" && ( <> - - Contact Person - - - - - - - - - - General Manager - + + + General Manager + + {etradeOwner && ( + + )} + - + + + )} + + {step === "contact" && ( + <> + + Contact Person + + toggleGmAsContact(e.currentTarget.checked)} + /> + + + + + + + @@ -425,6 +873,12 @@ export default function CompanyProfileForm({ Power of Attorney details are optional. Fill them in if you have them, or skip to continue. + toggleContactAsPoa(e.currentTarget.checked)} + /> - @@ -484,117 +936,64 @@ export default function CompanyProfileForm({ )} - {step === "confirm" && ( - {})} + /> + )} + + {saveError && ( + } + title={ + step === "additional" + ? "Business license required" + : "Couldn't save this step" + } > - - Review your registration - - - Confirm the company details below before saving. - - - - - - - - - - - - - - - - - - - - - + {saveError} + )} - + {showBack ? ( + + ) : ( + + )} @@ -602,26 +1001,3 @@ export default function CompanyProfileForm({ ); } - -function ReviewRow({ label, value }: { label: string; value?: string | null }) { - return ( - - - {label} - - - {value?.trim() ? value : "Not provided"} - - - ); -} diff --git a/apps/edr-freight-web/portal/src/pages/accounts/DjiboutiAgentForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/DjiboutiAgentForm.tsx index eb58b98c1..89570c545 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/DjiboutiAgentForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/DjiboutiAgentForm.tsx @@ -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; const stepFields: Record = { - 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({ + const { register, control, handleSubmit, trigger, watch, formState: { errors } } = useForm({ 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")} /> - @@ -239,12 +242,11 @@ export default function DjiboutiAgentForm({ error={errors.repEmail?.message} {...register("repEmail")} /> - @@ -280,7 +282,7 @@ export default function DjiboutiAgentForm({ - + )} diff --git a/apps/edr-freight-web/portal/src/pages/accounts/ForwarderForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/ForwarderForm.tsx index 71db345ec..e13ec2282 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/ForwarderForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/ForwarderForm.tsx @@ -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; const stepFields: Record = { - 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 { + 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; @@ -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, + ) => 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; + onLicenseChange?: (value: Record) => void; }) { - const [step, setStep] = useState("company"); + const [step, setStep] = useState(initialStep ?? "company"); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(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>({}); 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({ + const { register, control, handleSubmit, trigger, watch, formState: { errors } } = useForm({ 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 => { + 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: }, { key: "personnel", icon: }, { key: "poa", icon: }, { key: "documents", icon: }, - { key: "confirm", icon: }, + { key: "additional", icon: }, ]; const STEP_LABELS: Record = { 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")} /> - @@ -280,12 +436,11 @@ export default function ForwarderForm({ error={errors.contactPersonName?.message} {...register("contactPersonName")} /> - @@ -306,12 +461,11 @@ export default function ForwarderForm({ error={errors.generalManagerEmail?.message} {...register("generalManagerEmail")} /> - @@ -336,11 +490,9 @@ export default function ForwarderForm({ error={errors.poaEmail?.message} {...register("poaEmail")} /> - @@ -377,52 +529,47 @@ export default function ForwarderForm({ )} - {step === "confirm" && ( - - Review your registration - - Confirm the company details below before saving. - - - - - - - - - - - - - - - - - - - - - + {step === "additional" && ( + {})} + /> + )} + + {saveError && ( + } + title={step === "additional" ? "Business license required" : "Couldn't save this step"} + > + {saveError} + )} - + {showBack ? ( + + ) : ( + + )} {step === "documents" && ( - )} @@ -431,16 +578,3 @@ export default function ForwarderForm({ ); } - -function ReviewRow({ label, value }: { label: string; value?: string | null }) { - return ( - - - {label} - - - {value?.trim() ? value : "Not provided"} - - - ); -} diff --git a/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx index 458843a57..ac13f4c59 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx @@ -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("email"); const [identifier, setIdentifier] = useState(""); - const [countryCode] = useState("+251"); const [password, setPassword] = useState(""); const [showPassword, setShowPassword] = useState(false); const [error, setError] = useState(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() {
setIdentifier(event.target.value)} - placeholder={currentMethod.placeholder} - disabled={loading} - className={fieldClass} - /> + {method === "phone" ? ( +
+ setIdentifier(v ?? "")} + /> +
+ ) : ( + setIdentifier(event.target.value)} + placeholder={currentMethod.placeholder} + disabled={loading} + className={fieldClass} + /> + )}
diff --git a/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx index 43a131e90..723591597 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx @@ -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({ 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() { - -
- - {ETHIOPIA_COUNTRY_CODE} - - { - event.target.value = event.target.value.replace(/\D/g, "").slice(0, 10); - }, - })} - /> -
+ ( +
+ field.onChange(v ?? "")} + onBlur={field.onBlur} + /> +
+ )} + /> {errorText(errors.phone?.message)}
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx index 6fb25516a..ca7346a83 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx @@ -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 && } + + + + {booking.files && booking.files.length > 0 && ( diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ContainersCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ContainersCard.tsx new file mode 100644 index 000000000..5ec72f48f --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ContainersCard.tsx @@ -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 ( + + + + + Containers + + + {totalUnits} unit{totalUnits !== 1 ? "s" : ""} + + + + + + + Type + Qty + VGM / unit + + Total VGM + + + + + {containers.map((c, i) => { + const lineVgm = Number(c.vgm || 0) * Number(c.qty || 0); + return ( + + + + {c.type} + + + + + {c.qty} + + + + + {c.vgm ? `${c.vgm} t` : "—"} + + + + + {lineVgm ? `${lineVgm.toLocaleString()} t` : "—"} + + + + ); + })} + +
+ + + + Total weight (VGM) + + + {totalVgm.toLocaleString()} t + + +
+ ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/KeyFactsStrip.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/KeyFactsStrip.tsx new file mode 100644 index 000000000..5e75d4323 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/KeyFactsStrip.tsx @@ -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 ( + + + {icon} + + + + {label} + + + {value} + + + + ); +} + +/** + * 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 ( + + + } + label="Type" + value={isContract ? "General Contract" : "One-Time"} + /> + } label="Cargo" value={freight} /> + } + label="Route" + value={`${yardLabel(booking.originYard)} → ${yardLabel(booking.destinationYard)}`} + /> + } label="Payment" value={payment} /> + } + label="Train" + value={booking.trainScheduleId ? "Assigned" : "Not assigned"} + /> + } + label={isContract ? "Ordering until" : "Scheduled"} + value={ + isContract + ? fmtDate(booking.expiresAt ?? null) + : fmtDate(booking.scheduledDate) + } + /> + + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/MyBookings.tsx b/apps/edr-freight-web/portal/src/pages/bookings/MyBookings.tsx index 78730084b..5d7200008 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/MyBookings.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/MyBookings.tsx @@ -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 ( + )}
{total} booking{total !== 1 ? "s" : ""} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx index 0a610507d..dd76d893a 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx @@ -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( + () => 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() { > - + {persistAndPriceMutation.isError && ( @@ -494,13 +529,13 @@ export default function NewBookingPage() { variant="default" radius="md" leftSection={} - onClick={() => setStep((s) => Math.max(1, s - 1))} - disabled={step === 1} + onClick={() => goToStep(-1)} + disabled={isFirstStep} > Back - {step < STEPS.length ? ( + {!isLastStep ? ( + + + ); + } + + const isContainer = contract.freightType === "CONTAINER"; + const isActive = contract.status === "CONTRACT_ACTIVE"; + const awaitingPayment = contract.status === "FULLY_EXECUTED"; + const poolLines = pool ?? []; + + return ( + + + {/* Header */} + + + + + + + +
+ + + {contract.reference} + + + + + + General contract · {isContainer ? "Containerised" : "Bulk"} + +
+
+
+ + + {awaitingPayment && } + {isActive && ( + + )} + +
+ + {/* Summary */} + + + } + value={`${contract.originYard?.label ?? "—"} → ${contract.destinationYard?.label ?? "—"}`} + /> + } + value={ + contract.expiresAt + ? new Date(contract.expiresAt).toLocaleDateString() + : "Not active yet" + } + /> + } + value={contract.tradeDirection ?? "—"} + /> + + + + {/* Drawdown pool */} + {contract.status !== "DRAFT" && ( + + + Contracted quantity + + + How much of this contract has been ordered versus what remains. + + + {poolLines.length === 0 && ( + + No quantity pool available. + + )} + {poolLines.map((line, i) => { + const pct = + line.contractedQuantity > 0 + ? Math.min( + 100, + (line.orderedQuantity / line.contractedQuantity) * 100, + ) + : 0; + const label = isContainer + ? (line.containerTypeName ?? "Containers") + : line.unitOfMeasure === "PER_ITEM" + ? "Items" + : "Tons"; + return ( +
+ + + {label} + + + + {formatQuantity( + line.remainingQuantity, + line.unitOfMeasure, + isContainer, + )} + {" "} + remaining of{" "} + {formatQuantity( + line.contractedQuantity, + line.unitOfMeasure, + isContainer, + )} + + + +
+ ); + })} +
+
+ )} + + {/* Orders */} + + + Orders ({orders?.length ?? 0}) + + {!orders || orders.length === 0 ? ( + + {isActive + ? "No orders yet. Use “Place order” to draw down from this contract." + : "Orders can be placed once the contract is active (paid)."} + + ) : ( + + {orders.map((order, idx) => ( + + +
+ + {order.reference} + + + Ship {new Date(order.scheduledDate).toLocaleDateString()} + {" · "} + {order.lines + .map( + (l) => + `${Number.isInteger(l.quantity) ? l.quantity : l.quantity.toFixed(2)}${ + l.containerTypeName ? ` ${l.containerTypeName}` : "" + }`, + ) + .join(", ")} + +
+ +
+
+ ))} +
+ )} +
+
+ + setOrderOpen(false)} + contract={contract} + pool={poolLines} + onPlaced={() => setOrderOpen(false)} + /> +
+ ); +} diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractsList.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractsList.tsx new file mode 100644 index 000000000..40ebd4f45 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractsList.tsx @@ -0,0 +1,340 @@ +import { useMemo, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; +import { + Box, + Button, + Card, + Group, + Paper, + Select, + Stack, + Text, + TextInput, + ThemeIcon, + Title, +} from "@mantine/core"; +import { Layers, Plus, Search, X } from "lucide-react"; + +import { api } from "@/services/api"; +import type { BookingListFilter } from "@/services/bookings.service"; +import type { Freight } from "@edr/types"; +import { + DataTable, + DataTableFooter, + type ColumnDef, + usePagination, +} from "@edr/ui-common"; +import { ModeIndicator } from "@/components/ModeIndicator"; +import { CargoModeCell, PaymentBadge } from "../bookings/booking-display"; +import { ContractStatusBadge, GREEN, INK, MUTED } from "./contract-ui"; + +export default function ContractsList() { + const navigate = useNavigate(); + const { pagination, setPagination } = usePagination({ pageSize: 10 }); + const [query, setQuery] = useState(""); + const [freightFilter, setFreightFilter] = useState(null); + const [createdFrom, setCreatedFrom] = useState(""); + const [createdTo, setCreatedTo] = useState(""); + + const resetPage = () => + setPagination({ pageIndex: 0, pageSize: pagination.pageSize }); + const hasExtraFilters = !!freightFilter || !!createdFrom || !!createdTo; + const clearExtraFilters = () => { + setFreightFilter(null); + setCreatedFrom(""); + setCreatedTo(""); + resetPage(); + }; + + const filter: BookingListFilter = useMemo( + () => ({ + bookingType: "GENERAL_CONTRACT", + freightType: freightFilter ?? undefined, + createdFrom: createdFrom || undefined, + createdTo: createdTo ? `${createdTo}T23:59:59.999Z` : undefined, + page: pagination.pageIndex + 1, + pageSize: pagination.pageSize, + sortBy: "createdAt", + sortOrder: "DESC", + }), + [ + freightFilter, + createdFrom, + createdTo, + pagination.pageIndex, + pagination.pageSize, + ], + ); + + const { data, isLoading, isError } = useQuery( + api.bookings.list.queryOptions({ input: filter }), + ); + + const rows = useMemo(() => { + const items = data?.items ?? []; + if (!query.trim()) return items; + const q = query.toLowerCase(); + return items.filter( + (b) => + b.reference?.toLowerCase().includes(q) || + b.originYard?.label?.toLowerCase().includes(q) || + b.destinationYard?.label?.toLowerCase().includes(q), + ); + }, [data, query]); + + const activeCount = useMemo( + () => + (data?.items ?? []).filter((b) => b.status === "CONTRACT_ACTIVE").length, + [data], + ); + + const columns: ColumnDef[] = [ + { + id: "reference", + header: () => , + cell: ({ row }) => { + const b = row.original; + return ( + + + + +
+ + {b.reference} + + + {b.freightType === "CONTAINER" ? "Containerised" : "Bulk"} + +
+
+ ); + }, + }, + { + id: "cargo", + header: () => , + cell: ({ row }) => , + }, + { + id: "route", + header: () => , + cell: ({ row }) => { + const b = row.original; + return ( + + {b.originYard?.label ?? "—"}{" "} + + → + {" "} + {b.destinationYard?.label ?? "—"} + + ); + }, + }, + { + id: "payment", + header: () => , + cell: ({ row }) => , + }, + { + id: "expires", + header: () => , + cell: ({ row }) => { + const exp = row.original.expiresAt; + return ( + + {exp ? new Date(exp).toLocaleDateString() : "—"} + + ); + }, + }, + { + id: "status", + header: () => , + cell: ({ row }) => , + }, + ]; + + const dataTableStatus = isLoading ? "loading" : isError ? "error" : "success"; + const total = data?.meta?.total ?? (data?.items?.length ?? 0); + const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize)); + + return ( + + + {/* Header */} + + + + + General Contracts + + + + + Reserve a quantity once, then place orders against it until the + contract runs out or its window closes. + + + + + + {/* Summary */} + + + {/* Search + filters */} + + } + value={query} + onChange={(e) => setQuery(e.currentTarget.value)} + radius="md" + styles={{ input: { height: 44 } }} + style={{ flex: 1, minWidth: 220, maxWidth: 360 }} + /> + } + nothingFoundMessage="No departures on this route" + searchable + comboboxProps={{ withinPortal: true }} + styles={{ input: { height: 44 } }} + /> + + + + Quantity + + {orderableLines.length === 0 && ( + }> + This contract is fully drawn down — no quantity remains. + + )} + {orderableLines.map((line) => { + const key = lineKey(line); + const label = isContainer + ? (line.containerTypeName ?? "Containers") + : line.unitOfMeasure === "PER_ITEM" + ? "Items" + : "Tons"; + return ( + +
+ + {label} + + + {formatQuantity( + line.remainingQuantity, + line.unitOfMeasure, + isContainer, + )}{" "} + remaining + +
+ + setQuantities((prev) => ({ + ...prev, + [key]: v === "" ? "" : Number(v), + })) + } + min={0} + max={line.remainingQuantity} + step={isContainer || line.unitOfMeasure === "PER_ITEM" ? 1 : 0.5} + clampBehavior="strict" + radius="md" + w={130} + placeholder="0" + /> +
+ ); + })} +
+ + {createMutation.isError && ( + }> + {createMutation.error instanceof Error + ? createMutation.error.message + : "Failed to place the order. Please try again."} + + )} + + + + + +
+ + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/contracts/contract-ui.tsx b/apps/edr-freight-web/portal/src/pages/contracts/contract-ui.tsx new file mode 100644 index 000000000..8b8a6a012 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/contracts/contract-ui.tsx @@ -0,0 +1,91 @@ +import { Badge, Group, Text } from "@mantine/core"; +import type { ReactNode } from "react"; + +// Brand palette (mirrors the booking form's shared constants). +export const INK = "#10202F"; +export const MUTED = "#6B7C8E"; +export const GREEN = "#0EA371"; +export const GREEN_DARK = "#0A6F4D"; +export const BORDER = "#E6ECF2"; + +/** Visual config for a general-contract status. */ +export const CONTRACT_STATUS_CONFIG: Record< + string, + { label: string; color: string; bg: string } +> = { + DRAFT: { label: "Draft", color: "#6B7C8E", bg: "#EEF2F6" }, + SUBMITTED: { label: "Submitted", color: "#2E5B96", bg: "#EAF1FB" }, + PENDING_APPROVAL: { label: "Pending Approval", color: "#9A6700", bg: "#FFF6E5" }, + APPROVED_PENDING_SIGNATURE: { label: "Awaiting Signature", color: "#9A6700", bg: "#FFF6E5" }, + CONTRACT_READY: { label: "Ready to Sign", color: "#2E5B96", bg: "#EAF1FB" }, + SIGNED_CUSTOMER: { label: "Signed", color: "#2E5B96", bg: "#EAF1FB" }, + FULLY_EXECUTED: { label: "Awaiting Payment", color: "#9A6700", bg: "#FFF6E5" }, + CONTRACT_ACTIVE: { label: "Active", color: "#0A6F4D", bg: "#E7F6EE" }, + CONTRACT_CLOSED: { label: "Closed", color: "#6B7C8E", bg: "#EEF2F6" }, + EXPIRED: { label: "Expired", color: "#B42318", bg: "#FEECEB" }, + CANCELLED: { label: "Cancelled", color: "#B42318", bg: "#FEECEB" }, + REJECTED: { label: "Rejected", color: "#B42318", bg: "#FEECEB" }, +}; + +export function ContractStatusBadge({ status }: { status: string }) { + const cfg = + CONTRACT_STATUS_CONFIG[status] ?? { + label: status, + color: MUTED, + bg: "#EEF2F6", + }; + return ( + + {cfg.label} + + ); +} + +/** A labelled value used across the contract detail summary cards. */ +export function MetaItem({ + label, + value, + icon, +}: { + label: string; + value: ReactNode; + icon?: ReactNode; +}) { + return ( +
+ + {label} + + + {icon} + + {value} + + +
+ ); +} + +/** Format a contracted/remaining quantity with its unit. */ +export function formatQuantity( + qty: number, + unit?: string | null, + isContainerLine?: boolean, +): string { + const rounded = Number.isInteger(qty) ? qty : Number(qty.toFixed(2)); + if (isContainerLine) return `${rounded} containers`; + if (unit === "PER_ITEM") return `${rounded} items`; + return `${rounded} tons`; +} diff --git a/apps/edr-freight-web/portal/src/pages/customers/on_boarding/DjiboutiFreightForwardingAgent.tsx b/apps/edr-freight-web/portal/src/pages/customers/on_boarding/DjiboutiFreightForwardingAgent.tsx index 93e2215a7..f7955a0c0 100644 --- a/apps/edr-freight-web/portal/src/pages/customers/on_boarding/DjiboutiFreightForwardingAgent.tsx +++ b/apps/edr-freight-web/portal/src/pages/customers/on_boarding/DjiboutiFreightForwardingAgent.tsx @@ -11,7 +11,7 @@ import { CheckCircle2, } from "lucide-react"; -import PhoneInput from "@/components/auth/PhoneInput"; +import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField"; import { Button, @@ -37,9 +37,8 @@ const schema = z.object({ phoneNumber: z .string() - .min(1, "Phone number is required"), - - phoneCountryCode: z.string().min(1), + .min(1, "Phone number is required") + .refine(isValidPhone, "Enter a valid phone number"), // COMPANY companyName: z @@ -52,9 +51,8 @@ const schema = z.object({ companyPhone: z .string() - .min(1, "Company phone is required"), - - companyPhoneCountryCode: z.string().min(1), + .min(1, "Company phone is required") + .refine(isValidPhone, "Enter a valid phone number"), companyLocation: z .string() @@ -75,9 +73,8 @@ const schema = z.object({ representativePhone: z .string() - .min(1, "Representative phone is required"), - - representativePhoneCountryCode: z.string().min(1), + .min(1, "Representative phone is required") + .refine(isValidPhone, "Enter a valid phone number"), }); type FormData = z.infer; @@ -91,14 +88,12 @@ const stepFields: Record< "lastName", "email", "phoneNumber", - "phoneCountryCode", ], company: [ "companyName", "companyEmail", "companyPhone", - "companyPhoneCountryCode", "companyLocation", "companyAddress", ], @@ -107,7 +102,6 @@ const stepFields: Record< "representativeName", "representativeEmail", "representativePhone", - "representativePhoneCountryCode", ], }; @@ -117,6 +111,7 @@ export default function DjiboutiForwardingAgentForm() { const { register, + control, handleSubmit, trigger, formState: { errors, isSubmitting }, @@ -124,10 +119,9 @@ export default function DjiboutiForwardingAgentForm() { resolver: zodResolver(schema), defaultValues: { - phoneCountryCode: "+253", - companyPhoneCountryCode: "+253", - representativePhoneCountryCode: - "+253", + phoneNumber: "", + companyPhone: "", + representativePhone: "", }, }); @@ -280,23 +274,11 @@ export default function DjiboutiForwardingAgentForm() { /> - @@ -347,25 +329,11 @@ export default function DjiboutiForwardingAgentForm() { /> - @@ -472,25 +440,11 @@ export default function DjiboutiForwardingAgentForm() { /> - diff --git a/apps/edr-freight-web/portal/src/pages/customers/on_boarding/ImportExportOnBoarding.tsx b/apps/edr-freight-web/portal/src/pages/customers/on_boarding/ImportExportOnBoarding.tsx index 92ef7bac8..33a28f58d 100644 --- a/apps/edr-freight-web/portal/src/pages/customers/on_boarding/ImportExportOnBoarding.tsx +++ b/apps/edr-freight-web/portal/src/pages/customers/on_boarding/ImportExportOnBoarding.tsx @@ -12,7 +12,7 @@ import { CheckCircle2, } from "lucide-react"; -import PhoneInput from "@/components/auth/PhoneInput"; +import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField"; import { Button, @@ -34,14 +34,18 @@ const onboardingSchema = z.object({ firstName: z.string().min(1, "First name is required"), lastName: z.string().min(1, "Last name is required"), email: z.string().email("Invalid email address"), - phoneNumber: z.string().min(1, "Phone number is required"), - phoneCountryCode: z.string().min(1), + phoneNumber: z + .string() + .min(1, "Phone number is required") + .refine(isValidPhone, "Enter a valid phone number"), // COMPANY 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), + 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"), @@ -63,9 +67,8 @@ const onboardingSchema = z.object({ contactPersonPhone: z .string() - .min(1, "Contact person phone is required"), - - contactPersonPhoneCountryCode: z.string().min(1), + .min(1, "Contact person phone is required") + .refine(isValidPhone, "Enter a valid phone number"), // GENERAL MANAGER generalManagerName: z @@ -78,14 +81,15 @@ const onboardingSchema = z.object({ generalManagerPhone: z .string() - .min(1, "General manager phone is required"), - - generalManagerPhoneCountryCode: z.string().min(1), + .min(1, "General manager phone is required") + .refine(isValidPhone, "Enter a valid phone number"), // OPTIONAL POA 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(), @@ -102,14 +106,12 @@ const stepFields: Record< "lastName", "email", "phoneNumber", - "phoneCountryCode", ], company: [ "companyName", "companyEmail", "companyPhone", - "companyPhoneCountryCode", "companyLocation", "companyAddress", "tinNumber", @@ -120,11 +122,9 @@ const stepFields: Record< personnel: [ "contactPersonName", "contactPersonPhone", - "contactPersonPhoneCountryCode", "generalManagerName", "generalManagerEmail", "generalManagerPhone", - "generalManagerPhoneCountryCode", ], poa: [], @@ -136,6 +136,7 @@ export default function ImportExportOnBoarding() { const { register, + control, handleSubmit, trigger, formState: { errors, isSubmitting }, @@ -143,11 +144,11 @@ export default function ImportExportOnBoarding() { resolver: zodResolver(onboardingSchema), defaultValues: { - phoneCountryCode: "+251", - companyPhoneCountryCode: "+251", - contactPersonPhoneCountryCode: "+251", - generalManagerPhoneCountryCode: "+251", - poaPhoneCountryCode: "+251", + phoneNumber: "", + companyPhone: "", + contactPersonPhone: "", + generalManagerPhone: "", + poaPhone: "", }, }); @@ -303,23 +304,11 @@ export default function ImportExportOnBoarding() { /> - @@ -370,25 +359,11 @@ export default function ImportExportOnBoarding() { /> - @@ -535,25 +510,11 @@ export default function ImportExportOnBoarding() { /> - @@ -614,25 +575,11 @@ export default function ImportExportOnBoarding() { /> - @@ -669,17 +616,10 @@ export default function ImportExportOnBoarding() { /> - diff --git a/apps/edr-freight-web/portal/src/pages/customers/on_boarding/TransportrOnBoarding.tsx b/apps/edr-freight-web/portal/src/pages/customers/on_boarding/TransportrOnBoarding.tsx index 53ea34260..20bd2249d 100644 --- a/apps/edr-freight-web/portal/src/pages/customers/on_boarding/TransportrOnBoarding.tsx +++ b/apps/edr-freight-web/portal/src/pages/customers/on_boarding/TransportrOnBoarding.tsx @@ -11,7 +11,7 @@ import { CheckCircle2, } from "lucide-react"; -import PhoneInput from "@/components/auth/PhoneInput"; +import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField"; import { Button, @@ -29,8 +29,10 @@ const schema = z.object({ firstName: z.string().min(1), lastName: z.string().min(1), email: z.string().email(), - phoneNumber: z.string().min(1), - phoneCountryCode: z.string().min(1), + phoneNumber: z + .string() + .min(1) + .refine(isValidPhone, "Enter a valid phone number"), // TRANSPORT fanNumber: z.string().min(1), @@ -60,7 +62,6 @@ const stepFields: Record = { "lastName", "email", "phoneNumber", - "phoneCountryCode", ], transport: [ "fanNumber", @@ -78,6 +79,7 @@ export default function TransporterOnboarding() { const { register, + control, handleSubmit, trigger, watch, @@ -85,7 +87,7 @@ export default function TransporterOnboarding() { } = useForm({ resolver: zodResolver(schema), defaultValues: { - phoneCountryCode: "+251", + phoneNumber: "", }, }); @@ -161,12 +163,11 @@ export default function TransporterOnboarding() { - diff --git a/apps/edr-freight-web/portal/src/pages/settings/CompanyRolesCard.tsx b/apps/edr-freight-web/portal/src/pages/settings/CompanyRolesCard.tsx index 000dcc25e..89fd0c3f9 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/CompanyRolesCard.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/CompanyRolesCard.tsx @@ -68,11 +68,11 @@ export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) { - Business Profile + Operating Roles {profile.companyType === "customer" - ? "Select the role(s) your company operates as — importer, exporter, or both." + ? "Select the service(s) your company operates as — importer, exporter and/or freight forwarder." : "Your company's operational role."} diff --git a/apps/edr-freight-web/portal/src/pages/settings/NationalitySelect.tsx b/apps/edr-freight-web/portal/src/pages/settings/NationalitySelect.tsx new file mode 100644 index 000000000..1d1dbb5c4 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/settings/NationalitySelect.tsx @@ -0,0 +1,50 @@ +import { Card, Group, SimpleGrid, Text, Title } from "@mantine/core"; +import { Globe2, MapPin } from "lucide-react"; + +import type { CompanyNationality } from "@/services/companies.service"; +import RoleCard from "./RoleCard"; + +interface NationalitySelectProps { + value: CompanyNationality | null; + onChange: (next: CompanyNationality) => void; +} + +/** + * First step of onboarding: is this an Ethiopian or a Foreign company? The + * choice determines which documents are requested later (TIN / Commercial + * License / National ID for Ethiopian, Passport / Investment License for + * Foreign). + */ +export default function NationalitySelect({ + value, + onChange, +}: NationalitySelectProps) { + return ( + + + + Where is your company registered? + + + This determines the documents we'll ask you to provide. + + + + } + selected={value === "ethiopian"} + onClick={() => onChange("ethiopian")} + /> + } + selected={value === "foreign"} + onClick={() => onChange("foreign")} + /> + + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/settings/OnboardingRoleSelect.tsx b/apps/edr-freight-web/portal/src/pages/settings/OnboardingRoleSelect.tsx index faa9e7377..d5196d024 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/OnboardingRoleSelect.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/OnboardingRoleSelect.tsx @@ -1,39 +1,34 @@ -import { Card, Divider, Group, SimpleGrid, Text, Title } from "@mantine/core"; +import { Card, Group, SimpleGrid, Text, Title } from "@mantine/core"; import { Building2 } from "lucide-react"; import RoleCard from "./RoleCard"; -import { CUSTOMER_ROLES, FREIGHT_FORWARDER } from "./companyRoles"; +import { CUSTOMER_ROLES } from "./companyRoles"; interface OnboardingRoleSelectProps { - /** Currently selected profile types (e.g. ["importer"], ["importer","exporter"], ["freight_forwarder"]). */ + /** Currently selected profile types (e.g. ["importer"], ["importer","exporter","freight_forwarder"]). */ value: string[]; onChange: (next: string[]) => void; } /** * First (and only) thing shown in the Company Profile tab during onboarding. - * Importer / Exporter sit side by side and can both be picked; Freight - * Forwarder is a separate, mutually-exclusive choice below them. A valid - * selection reveals the company-profile fields. + * Importer / Exporter / Freight Forwarder are independent services that can be + * picked in any combination — each becomes its own profile (with its own + * business license) under the same company. A valid selection reveals the + * company-profile fields. */ export default function OnboardingRoleSelect({ value, onChange, }: OnboardingRoleSelectProps) { const selected = new Set(value); - const isForwarder = selected.has(FREIGHT_FORWARDER.type); - // Toggling a customer role drops any forwarder selection (mutually exclusive). - const toggleCustomerRole = (type: string) => { - const next = new Set(value.filter((t) => t !== FREIGHT_FORWARDER.type)); + const toggleRole = (type: string) => { + const next = new Set(value); if (next.has(type)) next.delete(type); else next.add(type); onChange([...next]); }; - const toggleForwarder = () => { - onChange(isForwarder ? [] : [FREIGHT_FORWARDER.type]); - }; - return ( @@ -41,7 +36,8 @@ export default function OnboardingRoleSelect({ What does your company do? - Pick Importer, Exporter, or both — or register as a Freight Forwarder. + Pick any combination of Importer, Exporter and Freight Forwarder — each + is set up with its own business license. @@ -52,26 +48,10 @@ export default function OnboardingRoleSelect({ description={role.description} icon={role.icon} selected={selected.has(role.type)} - onClick={() => toggleCustomerRole(role.type)} + onClick={() => toggleRole(role.type)} /> ))} - - - - ); } diff --git a/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx b/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx index f02740664..1f49baf7c 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx @@ -15,7 +15,7 @@ import { Grid, } from "@mantine/core"; import { api } from "@/services/api"; -import PhoneInput from "@/components/auth/PhoneInput"; +import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField"; import type { ProfileResponse } from "@/types/profile"; import type { CreateCompanyPayload, @@ -27,8 +27,10 @@ import OnboardingRoleSelect from "./OnboardingRoleSelect"; export const COMPANY_PROFILE_SCHEMA = 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"), @@ -37,13 +39,6 @@ export const COMPANY_PROFILE_SCHEMA = z.object({ export type CompanyProfileFormData = z.infer; -export function splitPhone(fullPhone?: string | null) { - if (!fullPhone) return { code: "+251", number: "" }; - const match = fullPhone.match(/^(\+\d{1,3})(.*)$/); - if (match) return { code: match[1], number: match[2] }; - return { code: "+251", number: fullPhone }; -} - interface TabCompanyProfileProps { profile?: ProfileResponse; mode?: "edit" | "create"; @@ -61,12 +56,10 @@ export default function TabCompanyProfile({ const defaultValues = useMemo((): CompanyProfileFormData => { if (profile) { - const phone = splitPhone(profile.companyPhone); return { companyName: profile.companyName, companyEmail: profile.companyEmail ?? "", - companyPhone: phone.number, - companyPhoneCountryCode: phone.code, + companyPhone: profile.companyPhone ?? "", companyLocation: profile.companyLocation, companyAddress: profile.companyAddress ?? "", tinNumber: profile.tinNumber, @@ -77,7 +70,6 @@ export default function TabCompanyProfile({ companyName: "", companyEmail: "", companyPhone: "", - companyPhoneCountryCode: "+251", companyLocation: "", companyAddress: "", tinNumber: "", @@ -87,6 +79,7 @@ export default function TabCompanyProfile({ const { register, + control, handleSubmit, reset, formState: { errors, isDirty }, @@ -100,7 +93,7 @@ export default function TabCompanyProfile({ const base = { companyName: data.companyName, companyEmail: data.companyEmail, - companyPhone: `${data.companyPhoneCountryCode}${data.companyPhone}`, + companyPhone: data.companyPhone, companyLocation: data.companyLocation, companyAddress: data.companyAddress, tin: data.tinNumber, @@ -185,15 +178,11 @@ export default function TabCompanyProfile({ /> - diff --git a/apps/edr-freight-web/portal/src/pages/settings/TabContactPerson.tsx b/apps/edr-freight-web/portal/src/pages/settings/TabContactPerson.tsx index 7a2d9b931..d78add01b 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/TabContactPerson.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/TabContactPerson.tsx @@ -14,24 +14,19 @@ import { Button, } from "@mantine/core"; import { api } from "@/services/api"; -import PhoneInput from "@/components/auth/PhoneInput"; +import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField"; import type { ProfileResponse } from "@/types/profile"; const schema = z.object({ 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"), }); type FormData = z.infer; -function splitPhone(fullPhone?: string | null) { - if (!fullPhone) return { code: "+251", number: "" }; - const match = fullPhone.match(/^(\+\d{1,3})(.*)$/); - if (match) return { code: match[1], number: match[2] }; - return { code: "+251", number: fullPhone }; -} - interface TabContactPersonProps { profile: ProfileResponse; mode?: "edit" | "onboarding"; @@ -42,16 +37,15 @@ export default function TabContactPerson({ profile, mode = "edit", onContinue }: const queryClient = useQueryClient(); const defaultValues = useMemo((): FormData => { - const phone = splitPhone(profile.contactPersonPhone); return { contactPersonName: profile.contactPersonName ?? "", - contactPersonPhone: phone.number, - contactPersonPhoneCountryCode: phone.code, + contactPersonPhone: profile.contactPersonPhone ?? "", }; }, [profile]); const { register, + control, handleSubmit, reset, formState: { errors, isDirty }, @@ -64,7 +58,7 @@ export default function TabContactPerson({ profile, mode = "edit", onContinue }: mutationFn: (data: FormData) => api.companies.updateProfile.call({ contactPersonName: data.contactPersonName, - contactPersonPhone: `${data.contactPersonPhoneCountryCode}${data.contactPersonPhone}`, + contactPersonPhone: data.contactPersonPhone, }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: api.companies.getProfile.queryKey() }); @@ -93,12 +87,11 @@ export default function TabContactPerson({ profile, mode = "edit", onContinue }: {...register("contactPersonName")} /> - diff --git a/apps/edr-freight-web/portal/src/pages/settings/TabGeneralManager.tsx b/apps/edr-freight-web/portal/src/pages/settings/TabGeneralManager.tsx index 80fb5f721..be8bef02c 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/TabGeneralManager.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/TabGeneralManager.tsx @@ -15,25 +15,20 @@ import { Grid, } from "@mantine/core"; import { api } from "@/services/api"; -import PhoneInput from "@/components/auth/PhoneInput"; +import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField"; import type { ProfileResponse } from "@/types/profile"; const schema = z.object({ 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"), }); type FormData = z.infer; -function splitPhone(fullPhone?: string | null) { - if (!fullPhone) return { code: "+251", number: "" }; - const match = fullPhone.match(/^(\+\d{1,3})(.*)$/); - if (match) return { code: match[1], number: match[2] }; - return { code: "+251", number: fullPhone }; -} - interface TabGeneralManagerProps { profile: ProfileResponse; mode?: "edit" | "onboarding"; @@ -44,17 +39,16 @@ export default function TabGeneralManager({ profile, mode = "edit", onContinue } const queryClient = useQueryClient(); const defaultValues = useMemo((): FormData => { - const phone = splitPhone(profile.generalManagerPhone); return { generalManagerName: profile.generalManagerName ?? "", generalManagerEmail: profile.generalManagerEmail ?? "", - generalManagerPhone: phone.number, - generalManagerPhoneCountryCode: phone.code, + generalManagerPhone: profile.generalManagerPhone ?? "", }; }, [profile]); const { register, + control, handleSubmit, reset, formState: { errors, isDirty }, @@ -68,7 +62,7 @@ export default function TabGeneralManager({ profile, mode = "edit", onContinue } api.companies.updateProfile.call({ generalManagerName: data.generalManagerName, generalManagerEmail: data.generalManagerEmail, - generalManagerPhone: `${data.generalManagerPhoneCountryCode}${data.generalManagerPhone}`, + generalManagerPhone: data.generalManagerPhone, }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: api.companies.getProfile.queryKey() }); @@ -108,12 +102,11 @@ export default function TabGeneralManager({ profile, mode = "edit", onContinue } /> - diff --git a/apps/edr-freight-web/portal/src/pages/settings/TabPowerOfAttorney.tsx b/apps/edr-freight-web/portal/src/pages/settings/TabPowerOfAttorney.tsx index f5f9a8210..6f951ff31 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/TabPowerOfAttorney.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/TabPowerOfAttorney.tsx @@ -15,27 +15,22 @@ import { Grid, } from "@mantine/core"; import { api } from "@/services/api"; -import PhoneInput from "@/components/auth/PhoneInput"; +import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField"; import type { ProfileResponse } from "@/types/profile"; const schema = z.object({ poaName: z.string().optional(), poaEmail: 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"), poaLocation: z.string().optional(), poaAddress: z.string().optional(), }); type FormData = z.infer; -function splitPhone(fullPhone?: string | null) { - if (!fullPhone) return { code: "+251", number: "" }; - const match = fullPhone.match(/^(\+\d{1,3})(.*)$/); - if (match) return { code: match[1], number: match[2] }; - return { code: "+251", number: fullPhone }; -} - interface TabPowerOfAttorneyProps { profile: ProfileResponse; mode?: "edit" | "onboarding"; @@ -50,12 +45,10 @@ export default function TabPowerOfAttorney({ const queryClient = useQueryClient(); const defaultValues = useMemo((): FormData => { - const phone = splitPhone(profile.poaPhone); return { poaName: profile.poaName ?? "", poaEmail: profile.poaEmail ?? "", - poaPhone: phone.number, - poaPhoneCountryCode: profile.poaPhone ? phone.code : "+251", + poaPhone: profile.poaPhone ?? "", poaLocation: profile.poaLocation ?? "", poaAddress: profile.poaAddress ?? "", }; @@ -63,6 +56,7 @@ export default function TabPowerOfAttorney({ const { register, + control, handleSubmit, reset, formState: { errors, isDirty }, @@ -75,10 +69,7 @@ export default function TabPowerOfAttorney({ mutationFn: (data: FormData) => api.companies.updateProfile.call({ poaName: data.poaName || undefined, - poaPhone: - data.poaPhone && data.poaPhoneCountryCode - ? `${data.poaPhoneCountryCode}${data.poaPhone}` - : undefined, + poaPhone: data.poaPhone || undefined, poaEmail: data.poaEmail || undefined, poaLocation: data.poaLocation || undefined, poaAddress: data.poaAddress || undefined, @@ -124,12 +115,10 @@ export default function TabPowerOfAttorney({ /> - diff --git a/apps/edr-freight-web/portal/src/pages/settings/companyRoles.tsx b/apps/edr-freight-web/portal/src/pages/settings/companyRoles.tsx index d875d68af..58b9f18d1 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/companyRoles.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/companyRoles.tsx @@ -28,8 +28,12 @@ export const FREIGHT_FORWARDER: RoleMeta = { icon: , }; -/** Importer / Exporter — the two roles a "customer" company can hold. */ -export const CUSTOMER_ROLES: RoleMeta[] = [IMPORTER, EXPORTER]; +/** + * Importer / Exporter / Freight Forwarder — the services a "customer" company + * can hold. A single company may register for any combination, each getting its + * own business license. + */ +export const CUSTOMER_ROLES: RoleMeta[] = [IMPORTER, EXPORTER, FREIGHT_FORWARDER]; // dj_freight_forwarder and transporter are intentionally not exposed yet. export function rolesForCompanyType(companyType: string): RoleMeta[] { diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index 194a1dd48..9ebd1cc01 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -2,11 +2,9 @@ import type { Freight, PaginatedResponse } from "@edr/types"; import { endpoint } from "@/utils/endpoint"; import type { CreateFileUploadFieldDto, - CreateFileUploadSettingDto, FileUploadField, FileUploadSetting, UpdateFileUploadFieldDto, - UpdateFileUploadSettingDto, } from "@/types/fileUploadSettings"; import { bookingsService, @@ -15,6 +13,10 @@ import { GeneratePriceResponse, SubmitBookingResponse, } from "./bookings.service"; +import { + bookingOrdersService, + CreateBookingOrderPayload, +} from "./booking-orders.service"; import type { BookingDocuments } from "@/pages/bookings/new-booking-form/schema"; import { paymentsService, @@ -38,9 +40,11 @@ import { } from "@/types/dropdownSettings"; import type { CompanyInfoResponse, + CompanyNationality, CompanyProfileResponse, CreateCompanyPayload, DashboardSummary, + ProfileTypeValue, } from "./companies.service"; import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile"; import type { @@ -135,6 +139,38 @@ export const api = { "addCompanyProfiles", companiesService.addCompanyProfiles, ), + + createCompanyProfile: endpoint< + { type: ProfileTypeValue; businessLicense?: string }, + CompanyProfileResponse + >("companies", "createCompanyProfile", companiesService.createCompanyProfile), + + startOnboarding: endpoint< + { + companyType: string; + roles: ProfileTypeValue[]; + nationality?: CompanyNationality; + }, + CompanyInfoResponse + >("companies", "startOnboarding", companiesService.startOnboarding), + + setActiveMode: endpoint<{ type: ProfileTypeValue }, CompanyInfoResponse>( + "companies", + "setActiveMode", + companiesService.setActiveMode, + ), + + setOnboardingStep: endpoint<{ step: string }, void>( + "companies", + "setOnboardingStep", + companiesService.setOnboardingStep, + ), + + completeOnboarding: endpoint( + "companies", + "completeOnboarding", + companiesService.completeOnboarding, + ), }, bookings: { @@ -241,6 +277,28 @@ export const api = { ), }, + bookingOrders: { + listByContract: endpoint< + { contractBookingId: string }, + Freight.IBookingOrder[] + >("booking-orders", "listByContract", ({ contractBookingId }) => + bookingOrdersService.listByContract(contractBookingId), + ), + + pool: endpoint< + { contractBookingId: string }, + Freight.ContractQuantityLine[] + >("booking-orders", "pool", ({ contractBookingId }) => + bookingOrdersService.pool(contractBookingId), + ), + + create: endpoint( + "booking-orders", + "create", + (payload) => bookingOrdersService.create(payload), + ), + }, + payments: { initiate: endpoint( "payments", diff --git a/apps/edr-freight-web/portal/src/services/booking-orders.service.ts b/apps/edr-freight-web/portal/src/services/booking-orders.service.ts new file mode 100644 index 000000000..fc1490531 --- /dev/null +++ b/apps/edr-freight-web/portal/src/services/booking-orders.service.ts @@ -0,0 +1,34 @@ +import type { Freight } from "@edr/types"; +import { client } from "../utils/api"; + +export type CreateBookingOrderPayload = Freight.CreateBookingOrderDto; + +export const bookingOrdersService = { + /** Orders placed against a general contract. */ + listByContract: async ( + contractBookingId: string, + ): Promise => { + const { data } = await client.get("/api/booking-orders", { + params: { contractBookingId }, + }); + return data.data ?? data; + }, + + /** Contracted / ordered / remaining quantities for a general contract. */ + pool: async ( + contractBookingId: string, + ): Promise => { + const { data } = await client.get( + `/api/booking-orders/contract/${contractBookingId}/pool`, + ); + return data.data ?? data; + }, + + /** Place a drawdown order against a contract. */ + create: async ( + payload: CreateBookingOrderPayload, + ): Promise => { + const { data } = await client.post("/api/booking-orders", payload); + return data.data ?? data; + }, +}; diff --git a/apps/edr-freight-web/portal/src/services/bookings.service.ts b/apps/edr-freight-web/portal/src/services/bookings.service.ts index 201ad071d..a95026f93 100644 --- a/apps/edr-freight-web/portal/src/services/bookings.service.ts +++ b/apps/edr-freight-web/portal/src/services/bookings.service.ts @@ -69,6 +69,15 @@ export interface BookingListFilter { status?: string; /** Comma-separated statuses (overrides `status` when set). */ statuses?: string; + /** ONE_TIME or GENERAL_CONTRACT. */ + bookingType?: string; + /** CONTAINER or BULK. */ + freightType?: string; + /** IMPORT / EXPORT / DOMESTIC. */ + tradeDirection?: string; + /** Created-date range (ISO). */ + createdFrom?: string; + createdTo?: string; page?: number; pageSize?: number; sortBy?: string; diff --git a/apps/edr-freight-web/portal/src/services/companies.service.ts b/apps/edr-freight-web/portal/src/services/companies.service.ts index 149705ec1..791c0f8de 100644 --- a/apps/edr-freight-web/portal/src/services/companies.service.ts +++ b/apps/edr-freight-web/portal/src/services/companies.service.ts @@ -5,6 +5,22 @@ import type { ApiResponse } from "@/types/apiResponse"; import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile"; import { isAxiosError } from "axios"; +export type ProfileTypeValue = + | "importer" + | "exporter" + | "freight_forwarder" + | "dj_freight_forwarder" + | "transporter"; + +export type CompanyNationality = "ethiopian" | "foreign"; + +export interface LicenseFile { + name: string; + url: string; + size: number; + mimeType?: string; +} + export interface ExternalProfileResponse { id: string; userId: string; @@ -16,6 +32,12 @@ export interface ExternalProfileResponse { nationalId: string | null; jobTitle: string | null; isPrimaryContact: boolean; + /** The active operational mode (importer/exporter/forwarder). */ + activeProfileType: ProfileTypeValue | null; + /** Id of the company_profile matching activeProfileType (server-resolved). */ + activeCompanyProfileId: string | null; + onboardingStep: string | null; + onboardingCompleted: boolean; createdAt: string; updatedAt: string; } @@ -25,6 +47,7 @@ export interface CompanyResponse { name: string; type: string; status: string; + nationality: CompanyNationality | null; tin: string; vatNumber: string | null; businessLicense: string | null; @@ -45,7 +68,10 @@ export interface CompanyProfileResponse { type: string; reference: string; status: string; + /** @deprecated Superseded by licenseFiles (file model). */ businessLicense: string | null; + /** Business-license documents uploaded for this profile. */ + licenseFiles: LicenseFile[]; attributes: Record | null; createdAt: string; updatedAt: string; @@ -63,6 +89,7 @@ export interface CompanyProfileInput { export interface CreateCompanyPayload { companyType?: string; + nationality?: CompanyNationality; companyName: string; companyEmail?: string; companyPhone?: string; @@ -152,6 +179,53 @@ export const companiesService = { return unwrap(response.data); }, + /** Create a single operational profile and make it the active mode. */ + createCompanyProfile: async (payload: { + type: ProfileTypeValue; + businessLicense?: string; + }): Promise => { + const response = await client.post>( + URL_CONSTANTS.COMPANIES_API.COMPANY_PROFILE, + payload, + ); + return unwrap(response.data); + }, + + /** Begin onboarding — create the draft company + profile + role(s) up front. */ + startOnboarding: async (payload: { + companyType: string; + roles: ProfileTypeValue[]; + nationality?: CompanyNationality; + }): Promise => { + const response = await client.post>( + URL_CONSTANTS.COMPANIES_API.ONBOARDING_START, + payload, + ); + return unwrap(response.data); + }, + + /** Switch the active operational mode (target profile must already exist). */ + setActiveMode: async (payload: { + type: ProfileTypeValue; + }): Promise => { + const response = await client.patch>( + URL_CONSTANTS.COMPANIES_API.ACTIVE_MODE, + payload, + ); + return unwrap(response.data); + }, + + setOnboardingStep: async (payload: { step: string }): Promise => { + await client.patch(URL_CONSTANTS.COMPANIES_API.ONBOARDING_STEP, payload); + }, + + completeOnboarding: async (): Promise => { + const response = await client.post>( + URL_CONSTANTS.COMPANIES_API.ONBOARDING_COMPLETE, + ); + return unwrap(response.data); + }, + uploadDocuments: async ( companyId: string, files: Record, @@ -169,4 +243,36 @@ export const companiesService = { } await client.post(URL_CONSTANTS.COMPANIES_API.DOCUMENTS(companyId), formData); }, + + /** Upload business-license document(s) for a company profile (multi-file). */ + uploadProfileLicense: async ( + profileId: string, + files: File[], + code = "business_license", + ): Promise => { + const formData = new FormData(); + for (const f of files) formData.append(code, f); + const response = await client.post>( + URL_CONSTANTS.COMPANIES_API.PROFILE_LICENSE(profileId), + formData, + ); + return unwrap(response.data); + }, + + /** List business-license document(s) already uploaded for a company profile. */ + getProfileLicense: async (profileId: string): Promise => { + const response = await client.get>( + URL_CONSTANTS.COMPANIES_API.PROFILE_LICENSE(profileId), + ); + return unwrap(response.data); + }, + + /** Fetch company registration data from eTrade by TIN. */ + fetchETradeInfo: async (payload: { tin: string }): Promise => { + const response = await client.post>( + URL_CONSTANTS.COMPANIES_API.FETCH_ETRADE_INFO, + payload, + ); + return unwrap(response.data); + }, }; diff --git a/apps/edr-freight-web/portal/src/types/profile.ts b/apps/edr-freight-web/portal/src/types/profile.ts index ce8661828..951a1f129 100644 --- a/apps/edr-freight-web/portal/src/types/profile.ts +++ b/apps/edr-freight-web/portal/src/types/profile.ts @@ -4,6 +4,7 @@ export interface ProfileResponse { companyId: string; companyName: string; companyType: string; + nationality: string | null; companyProfiles: CompanyProfileResponse[]; companyEmail: string | null; companyPhone: string | null; @@ -12,7 +13,21 @@ export interface ProfileResponse { tinNumber: string; vatNumber: string | null; fanNumber: string | null; + 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; @@ -26,6 +41,7 @@ export interface ProfileResponse { } export interface UpdateProfilePayload { + nationality?: "ethiopian" | "foreign"; companyName?: string; companyEmail?: string; companyPhone?: string; @@ -34,7 +50,21 @@ export interface UpdateProfilePayload { tin?: string; vatNumber?: string; fanNumber?: string; + licenceNumber?: string; + statusDescription?: string; + dateRegistered?: string; + renewedFrom?: string; + renewalDate?: string; + renewedTo?: string; + region?: string; + zone?: string; + woreda?: string; + kebele?: string; + houseNo?: string; + etradePhone?: string; contactPersonName?: string; + contactPersonPosition?: string; + contactPersonEmail?: string; contactPersonPhone?: string; generalManagerName?: string; generalManagerEmail?: string; diff --git a/apps/edr-passenger-web/backoffice/tailwind.config.js b/apps/edr-passenger-web/backoffice/tailwind.config.js index 0311de963..8a5973008 100644 --- a/apps/edr-passenger-web/backoffice/tailwind.config.js +++ b/apps/edr-passenger-web/backoffice/tailwind.config.js @@ -37,4 +37,4 @@ module.exports = { }, }, plugins: [], -}; +}; diff --git a/apps/edr-passenger-web/portal/tailwind.config.js b/apps/edr-passenger-web/portal/tailwind.config.js index e59e95af0..948616110 100644 --- a/apps/edr-passenger-web/portal/tailwind.config.js +++ b/apps/edr-passenger-web/portal/tailwind.config.js @@ -89,4 +89,4 @@ export default { }, }, plugins: [], -}; +}; diff --git a/cargo-types.service.ts b/cargo-types.service.ts deleted file mode 100644 index 7e15f8e67..000000000 --- a/cargo-types.service.ts +++ /dev/null @@ -1,10 +0,0 @@ -import axios from 'axios'; - -const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001'; - -export const cargoTypesService = { - async getCargoTypes() { - const { data } = await axios.get(`${API_URL}/api/cargo-types`); - return data; - }, -}; \ No newline at end of file diff --git a/container-types.service.ts b/container-types.service.ts deleted file mode 100644 index b636da5f2..000000000 --- a/container-types.service.ts +++ /dev/null @@ -1,10 +0,0 @@ -import axios from 'axios'; - -const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001'; - -export const containerTypesService = { - async getContainerTypes() { - const { data } = await axios.get(`${API_URL}/api/container-types`); - return data; - }, -}; \ No newline at end of file diff --git a/packages/api-common/src/index.ts b/packages/api-common/src/index.ts index 55ac1c99d..f0a25158d 100644 --- a/packages/api-common/src/index.ts +++ b/packages/api-common/src/index.ts @@ -17,3 +17,6 @@ export * from "./entities/base.entity"; // Repositories export * from "./repositories/base.repository"; + +// Services +export * from "./services/exchange"; diff --git a/apps/edr-freight-api/src/modules/cbe-exchange/cbe-exchange.service.ts b/packages/api-common/src/services/exchange/cbe.provider.ts similarity index 50% rename from apps/edr-freight-api/src/modules/cbe-exchange/cbe-exchange.service.ts rename to packages/api-common/src/services/exchange/cbe.provider.ts index 27e89896b..489b1a4e3 100644 --- a/apps/edr-freight-api/src/modules/cbe-exchange/cbe-exchange.service.ts +++ b/packages/api-common/src/services/exchange/cbe.provider.ts @@ -1,41 +1,62 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; +import { Logger } from "@nestjs/common"; -const DEFAULT_SCRAPE_URL = 'https://ethio.forex/bank/CBET'; +import { EXCHANGE_DEFAULTS, ExchangeOptions } from "./exchange.options"; +import { + CurrencyPair, + ExchangeRateProvider, +} from "./exchange.types"; /** Matches USD buying/selling embedded in ethio.forex CBET page HTML (after entity unescape). */ const USD_RATE_REGEX = /currency_code":\[0,"USD"\],"currency_name":\[0,"US DOLLAR"\],"buying":\[0,([\d.]+)\],"selling":\[0,([\d.]+)\]/; -@Injectable() -export class CbeExchangeService { - private readonly logger = new Logger(CbeExchangeService.name); +/** + * Central Bank of Ethiopia (CBE) rate provider. + * + * Sources a single canonical direction — **USD→ETB** (selling rate) — by + * scraping ethio.forex, caching the result, and falling back to a configured + * rate when the scrape fails. The inverse (ETB→USD) is derived by + * {@link ExchangeService}, so this provider only ever reports USD→ETB. + */ +export class CbeExchangeProvider implements ExchangeRateProvider { + readonly name = "CBE"; + + private readonly logger = new Logger(CbeExchangeProvider.name); + private readonly options: Required; private cachedRate: number | null = null; private cacheExpiresAt = 0; - constructor(private readonly configService: ConfigService) {} + constructor(options: ExchangeOptions) { + this.options = { ...EXCHANGE_DEFAULTS, ...stripUndefined(options) }; + } + + async getBaseRate(pair: CurrencyPair): Promise { + // CBE only sources USD→ETB; everything else is derived upstream. + if (pair.from !== "USD" || pair.to !== "ETB") { + return null; + } + return this.getUsdToEtbRate(); + } /** * Returns the current CBE USD→ETB **selling** rate scraped from ethio.forex. - * Cached for CBE_EXCHANGE_CACHE_TTL_MS; falls back to CBE_EXCHANGE_FALLBACK_RATE on failure. + * Cached for `cacheTtlMs`; on failure reuses the last cached rate, else + * returns `fallbackRate`. */ - async getUsdToEtbRate(): Promise { + private async getUsdToEtbRate(): Promise { const now = Date.now(); if (this.cachedRate !== null && now < this.cacheExpiresAt) { return this.cachedRate; } - const scrapeUrl = this.getScrapeUrl(); - const fallbackRate = - this.configService.get('app.cbeExchange.fallbackRate') ?? 130; - const cacheTtlMs = - this.configService.get('app.cbeExchange.cacheTtlMs') ?? 3_600_000; + const { scrapeUrl, fallbackRate, cacheTtlMs, requestTimeoutMs } = + this.options; try { const response = await fetch(scrapeUrl, { - signal: AbortSignal.timeout(8_000), - headers: { 'User-Agent': 'Mozilla/5.0' }, + signal: AbortSignal.timeout(requestTimeoutMs), + headers: { "User-Agent": "Mozilla/5.0" }, }); if (!response.ok) { @@ -46,7 +67,7 @@ export class CbeExchangeService { const rates = this.parseScrapedRates(html); if (!rates) { - throw new Error('USD rate not found in ethio.forex page HTML'); + throw new Error("USD rate not found in ethio.forex page HTML"); } const rate = rates.selling; @@ -66,7 +87,9 @@ export class CbeExchangeService { ); if (this.cachedRate !== null) { - this.logger.warn(`Using previously cached CBE rate: ${this.cachedRate}`); + this.logger.warn( + `Using previously cached CBE rate: ${this.cachedRate}`, + ); return this.cachedRate; } @@ -74,13 +97,6 @@ export class CbeExchangeService { } } - private getScrapeUrl(): string { - const configured = - this.configService.get('app.cbeExchange.scrapeUrl') ?? - this.configService.get('app.cbeExchange.apiUrl'); - return configured?.trim() || DEFAULT_SCRAPE_URL; - } - private parseScrapedRates( html: string, ): { buying: number; selling: number } | null { @@ -99,8 +115,15 @@ export class CbeExchangeService { return html .replace(/"/g, '"') .replace(/"/g, '"') - .replace(/&/g, '&') - .replace(/</g, '<') - .replace(/>/g, '>'); + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">"); } } + +/** Drops keys whose value is `undefined` so they don't override defaults via spread. */ +function stripUndefined(options: ExchangeOptions): ExchangeOptions { + return Object.fromEntries( + Object.entries(options).filter(([, value]) => value !== undefined), + ); +} diff --git a/packages/api-common/src/services/exchange/exchange.module.ts b/packages/api-common/src/services/exchange/exchange.module.ts new file mode 100644 index 000000000..b9c8e80e1 --- /dev/null +++ b/packages/api-common/src/services/exchange/exchange.module.ts @@ -0,0 +1,52 @@ +import { DynamicModule, Module, Provider } from "@nestjs/common"; + +import { + EXCHANGE_OPTIONS, + ExchangeAsyncOptions, + ExchangeOptions, +} from "./exchange.options"; +import { ExchangeService } from "./exchange.service"; + +/** + * Provides {@link ExchangeService} (currency conversion, currently CBE-backed). + * + * Register once in the app root, then inject `ExchangeService` anywhere: + * + * ```ts + * // static config + * ExchangeModule.forRoot({ fallbackRate: 135 }) + * + * // config resolved from ConfigService + * ExchangeModule.forRootAsync({ + * inject: [ConfigService], + * useFactory: (config: ConfigService) => config.get('app.exchange'), + * }) + * ``` + */ +@Module({}) +export class ExchangeModule { + static forRoot(options: ExchangeOptions = {}): DynamicModule { + return { + module: ExchangeModule, + providers: [ + { provide: EXCHANGE_OPTIONS, useValue: options }, + ExchangeService, + ], + exports: [ExchangeService], + }; + } + + static forRootAsync(options: ExchangeAsyncOptions): DynamicModule { + const optionsProvider: Provider = { + provide: EXCHANGE_OPTIONS, + useFactory: options.useFactory, + inject: (options.inject ?? []) as never[], + }; + + return { + module: ExchangeModule, + providers: [optionsProvider, ExchangeService], + exports: [ExchangeService], + }; + } +} diff --git a/packages/api-common/src/services/exchange/exchange.options.ts b/packages/api-common/src/services/exchange/exchange.options.ts new file mode 100644 index 000000000..e009b5f99 --- /dev/null +++ b/packages/api-common/src/services/exchange/exchange.options.ts @@ -0,0 +1,46 @@ +/** Injection token carrying the resolved {@link ExchangeOptions}. */ +export const EXCHANGE_OPTIONS = Symbol("EXCHANGE_OPTIONS"); + +/** Configuration for the {@link ExchangeService} and its CBE provider. */ +export interface ExchangeOptions { + /** + * ethio.forex CBET page scraped for USD buying/selling rates. + * @default 'https://ethio.forex/bank/CBET' + */ + scrapeUrl?: string; + + /** + * Base USD→ETB rate used when scraping fails and no previously cached rate + * exists. The ETB→USD direction is derived as its inverse. + * @default 130 + */ + fallbackRate?: number; + + /** + * How long a successfully fetched rate is cached, in milliseconds. + * @default 3_600_000 (1 hour) + */ + cacheTtlMs?: number; + + /** + * Timeout for the scrape HTTP request, in milliseconds. + * @default 8_000 + */ + requestTimeoutMs?: number; +} + +/** Defaults applied to any unset {@link ExchangeOptions} field. */ +export const EXCHANGE_DEFAULTS: Required = { + scrapeUrl: "https://ethio.forex/bank/CBET", + fallbackRate: 130, + cacheTtlMs: 3_600_000, + requestTimeoutMs: 8_000, +}; + +/** Factory contract for {@link ExchangeModule.forRootAsync}. */ +export interface ExchangeAsyncOptions { + /** Providers to inject into {@link useFactory} (e.g. `[ConfigService]`). */ + inject?: unknown[]; + /** Returns the options, possibly async. */ + useFactory: (...args: never[]) => ExchangeOptions | Promise; +} diff --git a/packages/api-common/src/services/exchange/exchange.service.ts b/packages/api-common/src/services/exchange/exchange.service.ts new file mode 100644 index 000000000..c1efdd923 --- /dev/null +++ b/packages/api-common/src/services/exchange/exchange.service.ts @@ -0,0 +1,72 @@ +import { Inject, Injectable } from "@nestjs/common"; + +import { CbeExchangeProvider } from "./cbe.provider"; +import { EXCHANGE_OPTIONS, ExchangeOptions } from "./exchange.options"; +import { CurrencyCode } from "./exchange.types"; + +/** + * Currency exchange service. Resolves the rate between any supported currency + * pair and converts amounts, backed by a rate provider (currently CBE). + * + * Resolution order for `getRate(from, to)`: + * 1. `from === to` → `1`. + * 2. Provider supplies the pair directly (e.g. CBE → USD→ETB). + * 3. Provider supplies the inverse → return `1 / inverse` (e.g. ETB→USD). + * + * Configure via {@link ExchangeModule.forRoot} / `forRootAsync`. + */ +@Injectable() +export class ExchangeService { + private readonly provider: CbeExchangeProvider; + + constructor(@Inject(EXCHANGE_OPTIONS) options: ExchangeOptions) { + this.provider = new CbeExchangeProvider(options); + } + + /** + * Returns the rate to convert 1 unit of `from` into `to` + * (i.e. `amountInTo = amountInFrom * getRate(from, to)`). + */ + async getRate(from: CurrencyCode, to: CurrencyCode): Promise { + if (from === to) { + return 1; + } + + const direct = await this.provider.getBaseRate({ from, to }); + if (direct !== null) { + return direct; + } + + const inverse = await this.provider.getBaseRate({ from: to, to: from }); + if (inverse !== null && inverse > 0) { + return 1 / inverse; + } + + throw new Error( + `No exchange rate available for ${from}→${to} from provider ${this.provider.name}`, + ); + } + + /** Converts `amount` from one currency to another using {@link getRate}. */ + async convert( + amount: number, + from: CurrencyCode, + to: CurrencyCode, + ): Promise { + const rate = await this.getRate(from, to); + return amount * rate; + } + + /** + * Convenience alias for `getRate('USD', 'ETB')`. + * @deprecated Prefer {@link getRate}; kept for existing callers. + */ + getUsdToEtbRate(): Promise { + return this.getRate("USD", "ETB"); + } + + /** Convenience alias for `getRate('ETB', 'USD')`. */ + getEtbToUsdRate(): Promise { + return this.getRate("ETB", "USD"); + } +} diff --git a/packages/api-common/src/services/exchange/exchange.types.ts b/packages/api-common/src/services/exchange/exchange.types.ts new file mode 100644 index 000000000..384ad93b3 --- /dev/null +++ b/packages/api-common/src/services/exchange/exchange.types.ts @@ -0,0 +1,31 @@ +/** + * ISO-4217 currency codes the exchange service can handle. + * Extend this union as new currencies are supported. + */ +export type CurrencyCode = "USD" | "ETB"; + +/** A directional currency pair, e.g. `{ from: 'USD', to: 'ETB' }`. */ +export interface CurrencyPair { + from: CurrencyCode; + to: CurrencyCode; +} + +/** + * A source of base exchange rates. Implementations fetch (scrape/API) the rate + * for a single canonical direction; the {@link ExchangeService} derives the + * inverse and same-currency (1:1) cases on top. + * + * Today the only implementation is the CBE (Central Bank of Ethiopia) provider, + * which sources USD→ETB. New providers (other banks, other base pairs) can be + * added without touching consumers. + */ +export interface ExchangeRateProvider { + /** Human-readable provider name, used in logs (e.g. `'CBE'`). */ + readonly name: string; + + /** + * Returns the rate for `pair` (units of `pair.to` per 1 unit of `pair.from`), + * or `null` if this provider cannot supply that pair directly. + */ + getBaseRate(pair: CurrencyPair): Promise; +} diff --git a/packages/api-common/src/services/exchange/index.ts b/packages/api-common/src/services/exchange/index.ts new file mode 100644 index 000000000..c5e9c960a --- /dev/null +++ b/packages/api-common/src/services/exchange/index.ts @@ -0,0 +1,10 @@ +export { ExchangeService } from "./exchange.service"; +export { ExchangeModule } from "./exchange.module"; +export { CbeExchangeProvider } from "./cbe.provider"; +export { EXCHANGE_OPTIONS, EXCHANGE_DEFAULTS } from "./exchange.options"; +export type { ExchangeOptions, ExchangeAsyncOptions } from "./exchange.options"; +export type { + CurrencyCode, + CurrencyPair, + ExchangeRateProvider, +} from "./exchange.types"; diff --git a/packages/types/src/freight/etrade.ts b/packages/types/src/freight/etrade.ts new file mode 100644 index 000000000..9067569fb --- /dev/null +++ b/packages/types/src/freight/etrade.ts @@ -0,0 +1,79 @@ +export interface ETradeAddressInfo { + Region: string; + Zone: string; + Woreda: string; + Kebele: string; + HouseNo: string; + MobilePhone: string; + RegularPhone: string; +} + +export interface ETradeAssociateInfo { + Position: string | null; + ManagerName: string; + ManagerNameEng: string; + Photo: string | null; + MobilePhone: string | null; + RegularPhone: string | null; +} + +export interface ETradeBusinessInfo { + MainGuid: string; + OwnerTIN: string; + DateRegistered: string; + TradeName: string; + LicenceNumber: string; + Status: number; + StatusDescription: string; + Capital: number; + AssociateShortInfos: ETradeAssociateInfo[]; + AddressInfo: ETradeAddressInfo; + RenewedTo: string; + RenewedToDateString: string; + RenewalDate: string; + RenewedFrom: string; + CancellationDate: string | null; +} + +export interface ETradeCompanyInfo { + Tin: string; + LegalCondtion: string; + RegNo: string; + RegDate: string; + BusinessName: string; + BusinessNameAmh: string; + PaidUpCapital: number; + AssociateShortInfos: ETradeAssociateInfo[]; + Businesses: Array<{ + MainGuid: string; + OwnerTIN: string; + DateRegistered: string; + TradeNameAmh: string; + TradesName: string; + LicenceNumber: string; + RenewalDate: string; + RenewedFrom: string; + RenewedTo: string; + BusinessLicensingGroupMain: string | null; + SubGroups: string | null; + }>; +} + +export interface 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; +} diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index 983dc69be..4b1ef3ebd 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -3,6 +3,7 @@ import type { BaseEntity } from "../common"; export * from "./dropdown_settings"; export * from "./file_upload_settings"; export * from "./overview"; +export * from "./etrade"; export enum TradeDirection { IMPORT = "IMPORT", @@ -41,6 +42,26 @@ export enum FreightType { Bulk = "BULK", } +/** + * Distinguishes a normal one-time booking from a general contract — an umbrella + * commitment that is signed and paid once, then drawn down by many orders over + * its period. Stored on the booking row. + */ +export enum BookingType { + OneTime = "ONE_TIME", + GeneralContract = "GENERAL_CONTRACT", +} + +/** + * How a cargo type's quantity is measured. Bulk cargo is weighed in tons, + * break-bulk is counted per item. Containerised freight is always counted by + * container and carries no unit-of-measure. + */ +export enum CargoUnitOfMeasure { + PerTon = "PER_TON", + PerItem = "PER_ITEM", +} + export enum BookingStatus { Draft = "DRAFT", Submitted = "SUBMITTED", @@ -67,6 +88,10 @@ export enum BookingStatus { Cancelled = "CANCELLED", PendingConsolidation = "PENDING_CONSOLIDATION", Consolidated = "CONSOLIDATED", + /** General contract: paid umbrella contract that is accepting drawdown orders. */ + ContractActive = "CONTRACT_ACTIVE", + /** General contract: closed because its quantity was exhausted (or period elapsed). */ + ContractClosed = "CONTRACT_CLOSED", } export enum ConsignmentStatus { @@ -328,6 +353,11 @@ export interface IBooking extends BaseEntity { customerId: string; trainId?: string | null; status: BookingStatus; + /** ONE_TIME for normal bookings; GENERAL_CONTRACT for umbrella contracts. */ + bookingType?: BookingType; + /** General contracts only: when ordering closes (null until active / for one-time). */ + expiresAt?: string | null; + /** Null for general contracts at creation — the date is chosen per order. */ scheduledDate: string; totalAmount: number; paymentStatus: PaymentStatus; @@ -473,6 +503,8 @@ export interface BookingReferenceCargoTypeChild { name: string; code: string; show_free_text_box: boolean; + /** How this cargo is measured (PER_TON / PER_ITEM); null when unset. */ + unit_of_measure?: CargoUnitOfMeasure | null; } export interface BookingReferenceCargoTypeGroup { @@ -553,7 +585,10 @@ export interface CreateBookingDto { companyId?: string | undefined; trainId?: string | undefined; trainScheduleId?: string | undefined; - scheduledDate: string; + /** Optional for general contracts — they pick the date per order, not at creation. */ + scheduledDate?: string | undefined; + /** Defaults to ONE_TIME. GENERAL_CONTRACT creates an umbrella contract. */ + bookingType?: BookingType | undefined; contractType: string; previousContractId?: string | undefined; serviceTypeId: string; @@ -577,3 +612,73 @@ export interface CreateBookingDto { containers?: CreateBookingContainerDto[]; allowConsolidation?: boolean; } + +// ── General Contracts & Booking Orders ────────────────────────────────────────── + +/** + * A line of contracted quantity. For CONTAINER contracts there is one line per + * container type (each its own drawdown pool); for BULK/BREAK_BULK a single line + * with a null containerTypeId carries the total tons/items. + */ +export interface ContractQuantityLine { + containerTypeId: string | null; + containerTypeName?: string | null; + /** PER_TON / PER_ITEM for bulk-style lines; null for container lines. */ + unitOfMeasure?: CargoUnitOfMeasure | null; + /** Total contracted units on this line (containers, tons, or items). */ + contractedQuantity: number; + /** Units already drawn down by non-cancelled orders. */ + orderedQuantity: number; + /** contractedQuantity − orderedQuantity. */ + remainingQuantity: number; +} + +/** + * Customer-facing view of a general contract (a Booking with + * bookingType = GENERAL_CONTRACT) and its remaining drawdown pool. + */ +export interface IGeneralContract extends IBooking { + bookingType: BookingType; + /** When the contract becomes ACTIVE; when ordering closes. Null until active. */ + expiresAt?: string | null; + /** Per-line contracted / ordered / remaining quantities. */ + quantityLines: ContractQuantityLine[]; +} + +export interface CreateBookingOrderLineDto { + /** Null for bulk/break-bulk; the container type id for container contracts. */ + containerTypeId?: string | null; + quantity: number; +} + +export interface CreateBookingOrderDto { + /** The general contract (booking) this order draws down from. */ + contractBookingId: string; + /** The shipment day the customer wants for this order. */ + scheduledDate: string; + lines: CreateBookingOrderLineDto[]; +} + +export interface IBookingOrderLine { + id: string; + containerTypeId?: string | null; + containerTypeName?: string | null; + quantity: number; +} + +export interface IBookingOrder { + id: string; + reference: string; + contractBookingId: string; + /** The child shipment booking spawned for this order (enters scheduling). */ + bookingId?: string | null; + bookingReference?: string | null; + companyId?: string | null; + scheduledDate: string; + status: BookingStatus; + schedulingStatus: SchedulingStatus; + trainScheduleId?: string | null; + lines: IBookingOrderLine[]; + createdAt: string; + updatedAt: string; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d5676e4d5..80b94aa54 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -111,6 +111,9 @@ importers: handlebars: specifier: ^4.7.9 version: 4.7.9 + libphonenumber-js: + specifier: ^1.13.6 + version: 1.13.6 minio: specifier: 7.1.3 version: 7.1.3 @@ -356,6 +359,9 @@ importers: react-hot-toast: specifier: ^2.6.0 version: 2.6.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react-phone-number-input: + specifier: ^3.4.17 + version: 3.4.17(react-dom@19.2.6(react@19.2.6))(react@19.2.6) react-router-dom: specifier: ^6.27.0 version: 6.30.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -5405,6 +5411,9 @@ packages: class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + classnames@2.5.1: + resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} + cli-cursor@3.1.0: resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} engines: {node: '>=8'} @@ -5659,6 +5668,9 @@ packages: typescript: optional: true + country-flag-icons@1.6.17: + resolution: {integrity: sha512-Nmik0289ZVZSI3c7mJR/amg6DyY7Z59b0sTFSKayeX72mHfPzCPJygwJs2pYgQULzuAyWeCUgwAJ+Dq8OR+JFw==} + crc-32@1.2.2: resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} engines: {node: '>=0.8'} @@ -7128,6 +7140,22 @@ packages: resolution: {integrity: sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + input-format@0.3.14: + resolution: {integrity: sha512-gHMrgrbCgmT4uK5Um5eVDUohuV9lcs95ZUUN9Px2Y0VIfjTzT2wF8Q3Z4fwLFm7c5Z2OXCm53FHoovj6SlOKdg==} + peerDependencies: + react: '>=18.1.0' + react-dom: '>=18.1.0' + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + + internal-ip@1.2.0: + resolution: {integrity: sha512-DzGfTasXPmwizQP4XV2rR6r2vp8TjlOpMnJqG9Iy2i1pl1lkZdZj5rSpIc7YFGX2nS46PPgAGEyT+Q5hE2FB2g==} + engines: {node: '>=0.10.0'} + hasBin: true + internal-slot@1.1.0: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} @@ -9238,6 +9266,12 @@ packages: '@types/react': optional: true + react-phone-number-input@3.4.17: + resolution: {integrity: sha512-1wcjhBAWHgEBAGLi5/XbeZI7Q3aEHNb2z/dHY6R2Gz70TQvu0ZoOT28NTdwtZf4lyRKXWufnTzVhLPBUD8LfmQ==} + peerDependencies: + react: '>=16.8' + react-dom: '>=16.8' + react-redux@9.3.0: resolution: {integrity: sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==} peerDependencies: @@ -16924,6 +16958,8 @@ snapshots: dependencies: clsx: 2.1.1 + classnames@2.5.1: {} + cli-cursor@3.1.0: dependencies: restore-cursor: 3.1.0 @@ -17152,6 +17188,8 @@ snapshots: optionalDependencies: typescript: 5.9.3 + country-flag-icons@1.6.17: {} + crc-32@1.2.2: {} crc32-stream@4.0.3: @@ -18918,6 +18956,17 @@ snapshots: ini@4.1.1: {} + input-format@0.3.14(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + prop-types: 15.8.1 + optionalDependencies: + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + + internal-ip@1.2.0: + dependencies: + meow: 3.7.0 + internal-slot@1.1.0: dependencies: es-errors: 1.3.0 @@ -21309,6 +21358,16 @@ snapshots: optionalDependencies: '@types/react': 18.3.31 + react-phone-number-input@3.4.17(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + classnames: 2.5.1 + country-flag-icons: 1.6.17 + input-format: 0.3.14(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + libphonenumber-js: 1.13.6 + prop-types: 15.8.1 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + react-redux@9.3.0(@types/react@18.3.31)(react@19.2.6)(redux@5.0.1): dependencies: '@types/use-sync-external-store': 0.0.6 diff --git a/use-cargo-types.ts b/use-cargo-types.ts deleted file mode 100644 index 864e9732c..000000000 --- a/use-cargo-types.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; -import { cargoTypesService } from '@/services/cargo-types.service'; - -export const CARGO_TYPES_QUERY_KEY = ['cargo-types']; - -export function useCargoTypes() { - return useQuery({ - queryKey: CARGO_TYPES_QUERY_KEY, - queryFn: () => cargoTypesService.getCargoTypes(), - staleTime: Infinity, - }); -} \ No newline at end of file diff --git a/use-cargoes.ts b/use-cargoes.ts deleted file mode 100644 index e69de29bb..000000000 diff --git a/use-container-types.ts b/use-container-types.ts deleted file mode 100644 index c216c2adf..000000000 --- a/use-container-types.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; -import { containerTypesService } from '@/services/container-types.service'; - -export const CONTAINER_TYPES_QUERY_KEY = ['container-types']; - -export function useContainerTypes() { - return useQuery({ - queryKey: CONTAINER_TYPES_QUERY_KEY, - queryFn: () => containerTypesService.getContainerTypes(), - staleTime: Infinity, - }); -} \ No newline at end of file diff --git a/use-wagon-types.ts b/use-wagon-types.ts deleted file mode 100644 index 4b8019cc9..000000000 --- a/use-wagon-types.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; -import { wagonTypesService } from '@/services/wagon-types.service'; - -export const WAGON_TYPES_QUERY_KEY = ['wagon-types']; - -export function useWagonTypes() { - return useQuery({ - queryKey: WAGON_TYPES_QUERY_KEY, - queryFn: () => wagonTypesService.getWagonTypes(), - staleTime: Infinity, - }); -} \ No newline at end of file diff --git a/wagon-type.entity.ts b/wagon-type.entity.ts deleted file mode 100644 index e69de29bb..000000000 diff --git a/wagon-types.controller.ts b/wagon-types.controller.ts deleted file mode 100644 index e69de29bb..000000000 diff --git a/wagon-types.repository.ts b/wagon-types.repository.ts deleted file mode 100644 index e69de29bb..000000000 diff --git a/wagon-types.service.ts b/wagon-types.service.ts deleted file mode 100644 index e69de29bb..000000000 diff --git a/wagon.service.ts b/wagon.service.ts deleted file mode 100644 index e69de29bb..000000000 diff --git a/wagons.controller.ts b/wagons.controller.ts deleted file mode 100644 index e69de29bb..000000000