mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight_feature/usermanagement
This commit is contained in:
@@ -24,6 +24,7 @@ Monorepo for the Ethio Djibouti Railway (EDR) digital platform. Contains the Fre
|
||||
| ---------------------- | ---------------------------------------------------------------------------------- |
|
||||
| `@edr/types` | Shared TypeScript interfaces and enums |
|
||||
| `@edr/api-common` | Shared NestJS decorators, filters, interceptors, pipes, BaseEntity, BaseRepository |
|
||||
| `@edr/iam-seed` | IAM baseline seeder for the apps sharing the `iam` schema (freight + passenger) |
|
||||
| `@edr/ui-common` | Shared React components and theme |
|
||||
| `@edr/eslint-config` | Shared ESLint configurations (base/nestjs/react) |
|
||||
| `@edr/tsconfig` | Shared TypeScript configurations |
|
||||
|
||||
@@ -42,8 +42,17 @@ JWT_REFRESH_TOKEN_EXPIRES=7d
|
||||
# IAM seed defaults (used by @tria-plc/iamapi-common on first boot)
|
||||
SUPER_ADMIN_EMAIL=superadmin@tria.com
|
||||
SUPER_ADMIN_PHONE=
|
||||
# Super-admin password. Falls back to DEFAULT_PASSWORD when empty.
|
||||
SUPER_ADMIN_DEFAULT_PASSWORD=
|
||||
DEFAULT_PASSWORD=password@tria
|
||||
|
||||
# IAM baseline shared with edr-passenger-api (roles, IAM app + permissions,
|
||||
# position types, organization types + default units, org/unit settings, super
|
||||
# admin). Replaces the seeder that shipped inside @tria-plc/iamapi-common — see
|
||||
# packages/iam-seed. Seeds by DEFAULT when unset; every write is insert-only.
|
||||
# Set to false to opt out.
|
||||
SEED_IAM_BASELINE=true
|
||||
|
||||
# Freight org + staff (bookings / rule-engine IAM)
|
||||
SEED_EDR_ORG=true
|
||||
SEED_FREIGHT_STAFF=true
|
||||
@@ -84,6 +93,9 @@ FAYDA_PRIVATE_KEY_BASE64=
|
||||
FAYDA_REDIRECT_URI=http://localhost:3001/api/fayda/verification/complete
|
||||
# OAuth redirect_uri for WEB clients. Defaults to FAYDA_REDIRECT_URI when unset.
|
||||
FAYDA_WEB_REDIRECT_URI=http://localhost:3000/callback
|
||||
# OAuth redirect_uri for the customer portal (its own origin — must also be
|
||||
# registered with eSignet). Defaults to FAYDA_WEB_REDIRECT_URI when unset.
|
||||
FAYDA_PORTAL_REDIRECT_URI=http://localhost:5173/callback
|
||||
CLIENT_ASSERTION_TYPE=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
|
||||
FAYDA_SCOPE=openid profile email phone address
|
||||
FAYDA_ACR_VALUES=mosip:idp:acr:generated-code
|
||||
|
||||
@@ -35,12 +35,12 @@
|
||||
"iam:migration:run": "pnpm run iam:typeorm:cli migration:run",
|
||||
"iam:migration:revert": "pnpm run iam:typeorm:cli migration:revert",
|
||||
"iam:migration:show": "pnpm run iam:typeorm:cli migration:show",
|
||||
"iam:seed:run": "cross-env APP_MODULE_PATH=./dist/app.module dotenv -- node ./node_modules/@tria-plc/iamapi-common/dist/db/seed.cli.js",
|
||||
"migrate": "ts-node -r tsconfig-paths/register src/scripts/run-migrations.ts",
|
||||
"script": "ts-node -r tsconfig-paths/register src/scripts/main.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@edr/api-common": "workspace:*",
|
||||
"@edr/iam-seed": "workspace:*",
|
||||
"@edr/payment-providers": "workspace:*",
|
||||
"@edr/types": "workspace:*",
|
||||
"@golevelup/nestjs-rabbitmq": "^5.5.0",
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
MiddlewareConsumer,
|
||||
Module,
|
||||
OnApplicationBootstrap,
|
||||
RequestMethod,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigModule, ConfigService } from "@nestjs/config";
|
||||
import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm";
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
ensurePostgresSchemas,
|
||||
APPLICATION_SEARCH_PATH,
|
||||
} from "./config/ensure-postgres-schemas";
|
||||
import { IamBaselineSeeder, IamSeedModule } from "@edr/iam-seed";
|
||||
import { IamModule } from "@tria-plc/iamapi-common";
|
||||
import { SharedAuthModule } from "@tria-plc/api-common/modules/auth/shared-auth.module";
|
||||
|
||||
@@ -101,6 +103,7 @@ import { InterchangeDocumentsModule } from "./modules/interchange-documents/inte
|
||||
import { ImportOperationsModule } from "./modules/import-operations/import-operations.module";
|
||||
import { AiModule } from "./modules/ai/ai.module";
|
||||
import { LoggerMiddleware } from "./logger.middleware";
|
||||
import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middleware";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -154,6 +157,18 @@ import { LoggerMiddleware } from "./logger.middleware";
|
||||
applications: [EDR_FREIGHT_APPLICATION],
|
||||
permissions: EDR_FREIGHT_PERMISSIONS,
|
||||
}),
|
||||
// Replaces the package's DataSeeder. Shared with edr-passenger-api, which
|
||||
// seeds the same `iam` schema — see packages/iam-seed.
|
||||
IamSeedModule.forRoot({
|
||||
superAdmin: {
|
||||
username: "superadmin",
|
||||
name: { am: "ሱፐር አድሚን", en: "Super Admin" },
|
||||
roleKey: "super_admin",
|
||||
organizationKey: "edr_freight",
|
||||
unitKey: "edr_freight_app",
|
||||
fallbackEmail: "superadmin@tria.com",
|
||||
},
|
||||
}),
|
||||
BookingsModule,
|
||||
ContractsModule,
|
||||
SignaturesModule,
|
||||
@@ -229,11 +244,12 @@ import { LoggerMiddleware } from "./logger.middleware";
|
||||
// MarshallingDemoTrainsSeeder,
|
||||
ApprovedFirstLastMileDemoBookingsSeeder,
|
||||
PaidImportExportMileDemoSeeder,
|
||||
LoginAudienceMiddleware,
|
||||
],
|
||||
})
|
||||
export class AppModule implements OnApplicationBootstrap {
|
||||
constructor(
|
||||
// private readonly seeder: DataSeeder,
|
||||
private readonly iamBaselineSeeder: IamBaselineSeeder,
|
||||
private readonly edrOrgSeeder: EdrOrgSeeder,
|
||||
private readonly freightPositionsSeeder: FreightPositionsSeeder,
|
||||
private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder,
|
||||
@@ -263,13 +279,22 @@ export class AppModule implements OnApplicationBootstrap {
|
||||
|
||||
// Permissions foundation — keep enabled:
|
||||
// freightPermissionKeyMigration → renames legacy permission keys
|
||||
// seeder (IAM DataSeeder) → seeds the IAM app, roles, permissions
|
||||
// edrOrgSeeder → seeds org/unit + the Permission catalog
|
||||
// iamBaselineSeeder → @edr/iam-seed: IAM app, roles, permissions,
|
||||
// position types, organization types +
|
||||
// default units, org/unit settings and the
|
||||
// super-admin account. Replaces the package's
|
||||
// DataSeeder, and is shared with
|
||||
// edr-passenger-api so one writer owns the
|
||||
// `iam` schema. Runs after edrOrgSeeder
|
||||
// because the super admin attaches to the
|
||||
// edr_freight org/unit.
|
||||
// Writes nothing unless SEED_IAM_BASELINE=true.
|
||||
// freightPositionsSeeder → seeds Position + PositionPermission rows
|
||||
// (depends on edrOrgSeeder, must run after)
|
||||
await this.freightPermissionKeyMigrationSeeder.run();
|
||||
// await this.seeder.run();
|
||||
await this.edrOrgSeeder.run();
|
||||
await this.iamBaselineSeeder.run();
|
||||
await this.freightPositionsSeeder.run();
|
||||
|
||||
// File upload settings — keep enabled.
|
||||
@@ -308,5 +333,9 @@ export class AppModule implements OnApplicationBootstrap {
|
||||
|
||||
configure(consumer: MiddlewareConsumer) {
|
||||
consumer.apply(LoggerMiddleware).forRoutes("*");
|
||||
consumer.apply(LoginAudienceMiddleware).forRoutes(
|
||||
{ path: "auth/login", method: RequestMethod.POST },
|
||||
{ path: "auth/mfa-verify", method: RequestMethod.POST },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
40
apps/edr-freight-api/src/common/grn.util.spec.ts
Normal file
40
apps/edr-freight-api/src/common/grn.util.spec.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { generateGrnNumber, grnOwnerSlug } from './grn.util';
|
||||
|
||||
/**
|
||||
* The GRN is mapped to the goods owner for BOTH directions, so a note is
|
||||
* identifiable by who owns the cargo. The reference slice stays the uniqueness
|
||||
* anchor — one owner can have several bookings received the same day.
|
||||
*/
|
||||
const date = new Date('2026-07-27T09:15:00Z');
|
||||
const bookingId = '1a2b3c4d-1111-2222-3333-444455556666';
|
||||
|
||||
describe('GRN number', () => {
|
||||
it('maps an import GRN to the owner', () => {
|
||||
expect(generateGrnNumber('IMPORT', bookingId, date, 'Shafici Pharmaceutical')).toBe(
|
||||
'GRN-IMPORT-20260727-SHAFICIPHARM-1A2B3C4D',
|
||||
);
|
||||
});
|
||||
|
||||
it('maps an export GRN to the owner the same way', () => {
|
||||
expect(generateGrnNumber('EXPORT', bookingId, date, 'Tria Trading PLC')).toBe(
|
||||
'GRN-EXPORT-20260727-TRIATRADINGP-1A2B3C4D',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the owner-less format when there is no owner (manual walk-in)', () => {
|
||||
expect(generateGrnNumber('WH', bookingId, date)).toBe('GRN-WH-20260727-1A2B3C4D');
|
||||
expect(generateGrnNumber('WH', bookingId, date, ' ')).toBe('GRN-WH-20260727-1A2B3C4D');
|
||||
});
|
||||
|
||||
it('stays unique per booking for one owner on one day', () => {
|
||||
const a = generateGrnNumber('IMPORT', bookingId, date, 'Acme');
|
||||
const b = generateGrnNumber('IMPORT', 'ffffffff-9999-0000-0000-000000000000', date, 'Acme');
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it('strips punctuation and caps the owner segment', () => {
|
||||
expect(grnOwnerSlug('Ethio-Djibouti Railway S.C.')).toBe('ETHIODJIBOUT');
|
||||
expect(grnOwnerSlug('a/b c')).toBe('ABC');
|
||||
expect(grnOwnerSlug(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,41 @@
|
||||
/**
|
||||
* Goods Received Note number: `GRN-<DIRECTION>-<YYYYMMDD>-<REF8>`.
|
||||
* Goods Received Note number: `GRN-<DIRECTION>-<YYYYMMDD>-<OWNER>-<REF8>`.
|
||||
*
|
||||
* The GRN is mapped to the goods OWNER (the booking's customer / consignee) for
|
||||
* both import and export, so a note is identifiable by who owns the cargo
|
||||
* without opening it. The trailing reference slice stays as the uniqueness
|
||||
* anchor — one owner can have several bookings received on the same day.
|
||||
* Owner-less receipts (manual walk-ins with no booking) fall back to the
|
||||
* original `GRN-<DIRECTION>-<YYYYMMDD>-<REF8>` form.
|
||||
*
|
||||
* Shared so a GRN raised at a load/unload facility is indistinguishable from one
|
||||
* raised in a warehouse — the two live in different tables
|
||||
* (facility_handling_events vs warehouse_inventory), and a second generator would
|
||||
* eventually let their formats drift apart.
|
||||
*/
|
||||
export function generateGrnNumber(direction: string, referenceId: string, date: Date): string {
|
||||
export function generateGrnNumber(
|
||||
direction: string,
|
||||
referenceId: string,
|
||||
date: Date,
|
||||
ownerName?: string | null,
|
||||
): string {
|
||||
const stamp = date.toISOString().slice(0, 10).replace(/-/g, '');
|
||||
const suffix = referenceId.replace(/-/g, '').slice(0, 8).toUpperCase();
|
||||
return `GRN-${direction.toUpperCase()}-${stamp}-${suffix}`;
|
||||
const owner = grnOwnerSlug(ownerName);
|
||||
const base = `GRN-${direction.toUpperCase()}-${stamp}`;
|
||||
return owner ? `${base}-${owner}-${suffix}` : `${base}-${suffix}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owner name → GRN-safe token: letters/digits only, upper-cased, capped so a
|
||||
* long company name can't run away with the number. Null when there is nothing
|
||||
* usable, which drops the segment rather than emitting an empty `--`.
|
||||
*/
|
||||
export function grnOwnerSlug(ownerName?: string | null): string | null {
|
||||
const slug = (ownerName ?? '')
|
||||
.normalize('NFKD')
|
||||
.replace(/[^a-zA-Z0-9]+/g, '')
|
||||
.toUpperCase()
|
||||
.slice(0, 12);
|
||||
return slug || null;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ export interface FaydaJwk {
|
||||
qi?: string;
|
||||
}
|
||||
|
||||
export type FaydaPlatform = 'WEB' | 'MOBILE';
|
||||
export type FaydaPlatform = 'WEB' | 'MOBILE' | 'PORTAL';
|
||||
|
||||
export interface FaydaConfig {
|
||||
enabled: boolean;
|
||||
@@ -25,8 +25,10 @@ export interface FaydaConfig {
|
||||
userInfoEndpoint: string;
|
||||
/** OAuth redirect_uri sent to eSignet for MOBILE clients. */
|
||||
redirectUri: string;
|
||||
/** OAuth redirect_uri sent to eSignet for WEB clients. Falls back to `redirectUri`. */
|
||||
/** OAuth redirect_uri sent to eSignet for WEB (backoffice) clients. Falls back to `redirectUri`. */
|
||||
webRedirectUri: string;
|
||||
/** OAuth redirect_uri sent to eSignet for the customer portal. Falls back to `webRedirectUri`. */
|
||||
portalRedirectUri: string;
|
||||
privateJwk: FaydaJwk;
|
||||
scope: string;
|
||||
acrValues: string;
|
||||
@@ -77,6 +79,7 @@ export default registerAs('fayda', (): FaydaConfig => {
|
||||
const sessionTtl = Number.parseInt(process.env.FAYDA_SESSION_TTL_MINUTES ?? '10', 10);
|
||||
const redirectUri = process.env.FAYDA_REDIRECT_URI ?? '';
|
||||
const webRedirectUri = process.env.FAYDA_WEB_REDIRECT_URI || redirectUri;
|
||||
const portalRedirectUri = process.env.FAYDA_PORTAL_REDIRECT_URI || webRedirectUri;
|
||||
if (!enabled) {
|
||||
return {
|
||||
enabled: false,
|
||||
@@ -86,6 +89,7 @@ export default registerAs('fayda', (): FaydaConfig => {
|
||||
userInfoEndpoint: process.env.FAYDA_USERINFO_ENDPOINT ?? '',
|
||||
redirectUri,
|
||||
webRedirectUri,
|
||||
portalRedirectUri,
|
||||
privateJwk: { kty: 'RSA', n: '', e: '', d: '' },
|
||||
scope,
|
||||
acrValues,
|
||||
@@ -117,6 +121,7 @@ export default registerAs('fayda', (): FaydaConfig => {
|
||||
userInfoEndpoint: process.env.FAYDA_USERINFO_ENDPOINT!,
|
||||
redirectUri,
|
||||
webRedirectUri,
|
||||
portalRedirectUri,
|
||||
privateJwk: decodePrivateJwk(process.env.FAYDA_PRIVATE_KEY_BASE64!),
|
||||
scope,
|
||||
acrValues,
|
||||
|
||||
@@ -46,6 +46,9 @@ async function bootstrap() {
|
||||
"Accept",
|
||||
"Authorization",
|
||||
"X-Requested-With",
|
||||
// Which freight frontend is calling — /auth/login uses this to reject
|
||||
// cross-audience credentials (EDRFREIGHT-415).
|
||||
"X-Client-App",
|
||||
// IAM context headers required by @tria-plc/api-common's JwtGuard
|
||||
"organization-unit-id",
|
||||
"delegator-position-id",
|
||||
@@ -58,6 +61,13 @@ async function bootstrap() {
|
||||
"x-delegator-position-id",
|
||||
"x-current-project-id",
|
||||
"x-current-position-id",
|
||||
// Headers sent by the freight-backoffice OKR/objective-service client
|
||||
// (withHeaders.tsx, signatureAndTeeterService.ts, useIncomingReport.ts)
|
||||
// under yet another naming convention — unprefixed "tenant-key"/"unit-id",
|
||||
// and "x-delegated-position-id" (delegated, not delegator).
|
||||
"tenant-key",
|
||||
"unit-id",
|
||||
"x-delegated-position-id",
|
||||
],
|
||||
exposedHeaders: ["Content-Disposition"],
|
||||
maxAge: 86400, // cache preflight for 24h to cut chatter in dev
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Double handling becomes an explicit per-booking decision instead of an
|
||||
* implicit "every import" charge. Warehouse staff record Yes/No after
|
||||
* unloading (whether the goods actually had to be re-handled); the
|
||||
* DOUBLE_HANDLING_FEE rule only bills when the answer is Yes.
|
||||
*
|
||||
* NULL = not decided yet → no charge, and the UI shows "not set" so the
|
||||
* operator is prompted. Existing rows stay NULL deliberately: back-billing a
|
||||
* fee nobody confirmed would be wrong.
|
||||
*/
|
||||
export class AddBookingDoubleHandling2850000000000 implements MigrationInterface {
|
||||
name = 'AddBookingDoubleHandling2850000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS double_handling boolean;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS double_handling_set_at timestamptz;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS double_handling_set_by varchar(160);`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS double_handling_set_by;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS double_handling_set_at;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS double_handling;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Per-truck detention clocks. Detention was timed once per last-mile leg
|
||||
* (last_mile.arrived_at / delivered_at), so every truck on a multi-truck
|
||||
* delivery shared one window and was billed identical days — wrong the moment
|
||||
* two trucks arrive or return at different times.
|
||||
*
|
||||
* Deliberately NEW columns rather than reusing the existing per-truck
|
||||
* arrived_at / departed_at on this table: those are WAREHOUSE gate-in/gate-out
|
||||
* events stamped by release(), whereas detention runs from arrival at the
|
||||
* DESTINATION until the truck is released/returned.
|
||||
*
|
||||
* Both nullable — a truck without its own window falls back to the leg-level
|
||||
* timestamps, so legacy legs keep billing exactly as before.
|
||||
*/
|
||||
export class AddPerTruckDetentionWindow2860000000000 implements MigrationInterface {
|
||||
name = 'AddPerTruckDetentionWindow2860000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.last_mile_vehicle_assignments
|
||||
ADD COLUMN IF NOT EXISTS destination_arrived_at timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS returned_at timestamptz;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.last_mile_vehicle_assignments
|
||||
DROP COLUMN IF EXISTS returned_at,
|
||||
DROP COLUMN IF EXISTS destination_arrived_at;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Livestock is billed and counted per head, not per ton — line it up with the
|
||||
* other break-bulk cargo types (Machinery, Truck, Automobile) so bulk
|
||||
* storage/demurrage fees charge per item instead of per ton for it.
|
||||
*/
|
||||
export class LivestockPerItem2900000000000 implements MigrationInterface {
|
||||
name = "LivestockPerItem2900000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.cargo_types
|
||||
SET unit_of_measure = 'PER_ITEM'
|
||||
WHERE code = 'LIVESTOCK'
|
||||
AND unit_of_measure IS DISTINCT FROM 'PER_ITEM'
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.cargo_types
|
||||
SET unit_of_measure = 'PER_TON'
|
||||
WHERE code = 'LIVESTOCK'
|
||||
AND unit_of_measure IS DISTINCT FROM 'PER_TON'
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Indode's real 11-yard layout, plus the plumbing to auto-route a booking to
|
||||
* the right yard by cargo type (and, for container yards, trade direction):
|
||||
*
|
||||
* - `warehouse_yards.direction` — IMPORT | EXPORT | BOTH | null. Only
|
||||
* meaningful for CONTAINER_YARD, where import and export stacks are
|
||||
* physically separate (Yard 5 vs Yard 6). Everything else takes cargo
|
||||
* either way. A CONTAINER_YARD left at null/BOTH is a signal too: it means
|
||||
* "not a customer cargo yard" — Yards 10/11 (service/equipment) are
|
||||
* CONTAINER_YARD structurally but must never be offered for ordinary
|
||||
* import/export cargo, so the frontend match requires an EXACT IMPORT/
|
||||
* EXPORT direction hit for container freight rather than treating BOTH as
|
||||
* a wildcard.
|
||||
* - `warehouse_yard_cargo_types` — which cargo types a yard accepts (mirrors
|
||||
* the existing `cargo_type_wagon_types` join table). Empty = open to any
|
||||
* cargo type of the yard's structural type (additive, never restrictive
|
||||
* by default), so this cannot break a yard nobody has configured yet.
|
||||
*
|
||||
* Three cargo types didn't exist yet (Fertilizer, Coffee, Tea) — added here
|
||||
* so Yards 1 and 9 have a real mapping ready for when they reopen.
|
||||
*/
|
||||
export class IndodeYardsAndCargoRouting2990000000000 implements MigrationInterface {
|
||||
name = "IndodeYardsAndCargoRouting2990000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.warehouse_yards
|
||||
ADD COLUMN IF NOT EXISTS direction varchar(10)
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.warehouse_yard_cargo_types (
|
||||
yard_id uuid NOT NULL REFERENCES freight.warehouse_yards (id) ON DELETE CASCADE,
|
||||
cargo_type_id uuid NOT NULL REFERENCES freight.cargo_types (id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (yard_id, cargo_type_id)
|
||||
)
|
||||
`);
|
||||
|
||||
// New cargo types Indode's yard list names but the catalog didn't have yet.
|
||||
await queryRunner.query(`
|
||||
INSERT INTO freight.cargo_types (code, cargo_type_name, unit_of_measure, is_active)
|
||||
VALUES
|
||||
('FERTILIZER', 'Fertilizer', 'PER_TON', true),
|
||||
('COFFEE', 'Coffee', 'PER_TON', true),
|
||||
('TEA', 'Tea', 'PER_TON', true)
|
||||
ON CONFLICT (code) DO NOTHING
|
||||
`);
|
||||
|
||||
// The 11 real yards at Indode Open Warehouse (code 'IOW').
|
||||
await queryRunner.query(`
|
||||
INSERT INTO freight.warehouse_yards
|
||||
(warehouse_id, name, code, type, direction, status, is_active)
|
||||
SELECT w.id, y.name, y.code, y.type, y.direction, y.status, y.status = 'ACTIVE'
|
||||
FROM freight.warehouses w
|
||||
CROSS JOIN (VALUES
|
||||
('Y1', 'Bagged Cargo Discharge - Fertilizer', 'BULK_YARD', NULL, 'INACTIVE'),
|
||||
('Y2', 'Break Bulk', 'GENERAL_CARGO_YARD', NULL, 'ACTIVE'),
|
||||
('Y3', 'Ro-Ro / Pac', 'GENERAL_CARGO_YARD', NULL, 'ACTIVE'),
|
||||
('Y4', 'Dry Bulk', 'BULK_YARD', NULL, 'INACTIVE'),
|
||||
('Y5', 'Container Terminal - Import (Stack Area)', 'CONTAINER_YARD', 'IMPORT', 'ACTIVE'),
|
||||
('Y6', 'Container Terminal - Export', 'CONTAINER_YARD', 'EXPORT', 'ACTIVE'),
|
||||
('Y7', 'Cold Chain', 'COLD_STORAGE_YARD', NULL, 'INACTIVE'),
|
||||
('Y8', 'Chemical', 'HAZARDOUS_YARD', NULL, 'INACTIVE'),
|
||||
('Y9', 'Coffee and Tea', 'GENERAL_CARGO_YARD', NULL, 'INACTIVE'),
|
||||
('Y10', 'Container Service Yard - Maintenance', 'CONTAINER_YARD', 'BOTH', 'ACTIVE'),
|
||||
('Y11', 'Equipment (Empty Container)', 'CONTAINER_YARD', 'BOTH', 'ACTIVE')
|
||||
) AS y(code, name, type, direction, status)
|
||||
WHERE w.code = 'IOW'
|
||||
ON CONFLICT (warehouse_id, code) DO NOTHING
|
||||
`);
|
||||
|
||||
// One default zone per new yard, matching its yard's type — every existing
|
||||
// yard (CY-1, CY-A) already follows this one-zone-per-yard shape.
|
||||
await queryRunner.query(`
|
||||
INSERT INTO freight.warehouse_zones (yard_id, name, code, type, status, is_active)
|
||||
SELECT y.id, y.name || ' Zone 1', 'Z1',
|
||||
CASE y.type
|
||||
WHEN 'CONTAINER_YARD' THEN 'CONTAINER_ZONE'
|
||||
WHEN 'COLD_STORAGE_YARD' THEN 'COLD_STORAGE_ZONE'
|
||||
WHEN 'HAZARDOUS_YARD' THEN 'HAZARDOUS_ZONE'
|
||||
WHEN 'BULK_YARD' THEN 'BULK_ZONE'
|
||||
ELSE 'GENERAL_CARGO_ZONE'
|
||||
END,
|
||||
y.status, y.status = 'ACTIVE'
|
||||
FROM freight.warehouse_yards y
|
||||
JOIN freight.warehouses w ON w.id = y.warehouse_id
|
||||
WHERE w.code = 'IOW' AND y.code LIKE 'Y%'
|
||||
ON CONFLICT (yard_id, code) DO NOTHING
|
||||
`);
|
||||
|
||||
// Cargo-type routing. Yards 5/6/10/11 (CONTAINER_YARD) are intentionally
|
||||
// left with no rows — direction alone decides those, per the entity comment.
|
||||
await queryRunner.query(`
|
||||
INSERT INTO freight.warehouse_yard_cargo_types (yard_id, cargo_type_id)
|
||||
SELECT y.id, ct.id
|
||||
FROM freight.warehouses w
|
||||
JOIN freight.warehouse_yards y ON y.warehouse_id = w.id
|
||||
JOIN (VALUES
|
||||
('Y1', 'FERTILIZER'),
|
||||
('Y2', 'STEEL_BILLET'), ('Y2', 'PLASTIC_BARREL'), ('Y2', 'MACHINERY'), ('Y2', 'LIVESTOCK'),
|
||||
('Y3', 'AUTOMOBILE'), ('Y3', 'TRUCK'),
|
||||
('Y4', 'BARLY'), ('Y4', 'BEANS'), ('Y4', 'BULK'), ('Y4', 'CEREAL'),
|
||||
('Y4', 'EDIBLE_OIL'), ('Y4', 'RICE'), ('Y4', 'SUGAR'), ('Y4', 'WHEAT'),
|
||||
('Y7', 'PERISHABLE'),
|
||||
('Y9', 'COFFEE'), ('Y9', 'TEA')
|
||||
) AS m(yard_code, cargo_code) ON m.yard_code = y.code
|
||||
JOIN freight.cargo_types ct ON ct.code = m.cargo_code
|
||||
WHERE w.code = 'IOW'
|
||||
ON CONFLICT (yard_id, cargo_type_id) DO NOTHING
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
DELETE FROM freight.warehouse_zones z
|
||||
USING freight.warehouse_yards y, freight.warehouses w
|
||||
WHERE z.yard_id = y.id AND y.warehouse_id = w.id
|
||||
AND w.code = 'IOW' AND y.code LIKE 'Y%'
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DELETE FROM freight.warehouse_yards y
|
||||
USING freight.warehouses w
|
||||
WHERE y.warehouse_id = w.id AND w.code = 'IOW' AND y.code LIKE 'Y%'
|
||||
`);
|
||||
// Cargo types and the join table are left in place — other data may have
|
||||
// started referencing them since; dropping columns/tables is not reversible
|
||||
// once real rows exist, and leaving them is harmless.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
|
||||
import { LoginAudienceMiddleware } from './login-audience.middleware';
|
||||
|
||||
/**
|
||||
* Touches only the DataSource, so build off the prototype rather than
|
||||
* standing up a full Nest module — same pattern as
|
||||
* warehouses/receive-export-paid.spec.ts.
|
||||
*/
|
||||
function makeMiddleware(userType: string | undefined) {
|
||||
const query = jest.fn().mockResolvedValue(userType ? [{ userType }] : []);
|
||||
const middleware = Object.create(
|
||||
LoginAudienceMiddleware.prototype,
|
||||
) as LoginAudienceMiddleware;
|
||||
(middleware as unknown as { dataSource: unknown }).dataSource = { query };
|
||||
return middleware;
|
||||
}
|
||||
|
||||
function makeReq(clientApp: string | undefined, email = 'someone@example.com') {
|
||||
return {
|
||||
header: (name: string) =>
|
||||
name.toLowerCase() === 'x-client-app' ? clientApp : undefined,
|
||||
body: { email },
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe('LoginAudienceMiddleware', () => {
|
||||
it('rejects when the client app header is missing', async () => {
|
||||
const middleware = makeMiddleware('employee');
|
||||
const next = jest.fn();
|
||||
|
||||
await expect(
|
||||
middleware.use(makeReq(undefined), {} as any, next),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an unrecognized client app header', async () => {
|
||||
const middleware = makeMiddleware('employee');
|
||||
const next = jest.fn();
|
||||
|
||||
await expect(
|
||||
middleware.use(makeReq('mobile'), {} as any, next),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
it('rejects an employee account signing in through the portal client', async () => {
|
||||
const middleware = makeMiddleware('employee');
|
||||
const next = jest.fn();
|
||||
|
||||
await expect(
|
||||
middleware.use(makeReq('portal'), {} as any, next),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a customer account signing in through the backoffice client', async () => {
|
||||
const middleware = makeMiddleware('individual');
|
||||
const next = jest.fn();
|
||||
|
||||
await expect(
|
||||
middleware.use(makeReq('backoffice'), {} as any, next),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
it('allows an employee account through the backoffice client', async () => {
|
||||
const middleware = makeMiddleware('employee');
|
||||
const next = jest.fn();
|
||||
|
||||
await middleware.use(makeReq('backoffice'), {} as any, next);
|
||||
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('allows a customer account through the portal client', async () => {
|
||||
const middleware = makeMiddleware('individual');
|
||||
const next = jest.fn();
|
||||
|
||||
await middleware.use(makeReq('portal'), {} as any, next);
|
||||
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('lets an unknown identifier fall through to the login handler', async () => {
|
||||
const middleware = makeMiddleware(undefined);
|
||||
const next = jest.fn();
|
||||
|
||||
await middleware.use(makeReq('portal'), {} as any, next);
|
||||
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { ForbiddenException, Injectable, NestMiddleware } from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { NextFunction, Request, Response } from 'express';
|
||||
|
||||
export const CLIENT_APP_HEADER = 'x-client-app';
|
||||
|
||||
// EUserType values from @tria-plc/api-common, duplicated here to avoid
|
||||
// pulling in the full enum just for this string comparison.
|
||||
const ALLOWED_USER_TYPES_BY_CLIENT: Record<string, string[]> = {
|
||||
backoffice: ['employee'],
|
||||
portal: ['individual', 'external_organization'],
|
||||
};
|
||||
|
||||
/**
|
||||
* Blocks EDRFREIGHT-415: /auth/login and /auth/mfa-verify match credentials
|
||||
* against email/username/phone_number only (see vendor
|
||||
* findUserForLogin), with no check that the account's userType belongs on
|
||||
* the app that's asking. A backoffice (employee) client presenting a
|
||||
* customer's credentials — or vice versa — must not get a session.
|
||||
*/
|
||||
@Injectable()
|
||||
export class LoginAudienceMiddleware implements NestMiddleware {
|
||||
constructor(@InjectDataSource() private readonly dataSource: DataSource) {}
|
||||
|
||||
async use(req: Request, _res: Response, next: NextFunction) {
|
||||
const clientApp = req.header(CLIENT_APP_HEADER);
|
||||
const allowedUserTypes = clientApp
|
||||
? ALLOWED_USER_TYPES_BY_CLIENT[clientApp]
|
||||
: undefined;
|
||||
if (!allowedUserTypes) {
|
||||
throw new ForbiddenException(
|
||||
`Missing or unrecognized ${CLIENT_APP_HEADER} header`,
|
||||
);
|
||||
}
|
||||
|
||||
const identifier: unknown = req.body?.email;
|
||||
if (typeof identifier !== 'string' || !identifier) {
|
||||
// No identifier to look up — the vendor DTO validation rejects the
|
||||
// request on its own.
|
||||
return next();
|
||||
}
|
||||
|
||||
const [user] = await this.dataSource.query(
|
||||
`SELECT user_type AS "userType" FROM iam.users
|
||||
WHERE email = $1 OR username = $1 OR phone_number = $1 LIMIT 1`,
|
||||
[identifier],
|
||||
);
|
||||
|
||||
if (user && !allowedUserTypes.includes(user.userType)) {
|
||||
throw new ForbiddenException(
|
||||
`This account cannot sign in through the ${clientApp} application`,
|
||||
);
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
}
|
||||
@@ -302,6 +302,20 @@ export class Booking extends BaseEntity {
|
||||
@Column({ name: 'customer_truck_arrived_at', type: 'timestamptz', nullable: true })
|
||||
customerTruckArrivedAt?: Date | null;
|
||||
|
||||
/**
|
||||
* Did the goods need re-handling in the warehouse? Recorded by warehouse
|
||||
* staff after unloading. Only `true` bills the DOUBLE_HANDLING_FEE rule;
|
||||
* null = not yet decided (no charge).
|
||||
*/
|
||||
@Column({ name: 'double_handling', type: 'boolean', nullable: true })
|
||||
doubleHandling?: boolean | null;
|
||||
|
||||
@Column({ name: 'double_handling_set_at', type: 'timestamptz', nullable: true })
|
||||
doubleHandlingSetAt?: Date | null;
|
||||
|
||||
@Column({ name: 'double_handling_set_by', type: 'varchar', length: 160, nullable: true })
|
||||
doubleHandlingSetBy?: string | null;
|
||||
|
||||
@Column({ name: 'customs_clearing_enabled', type: 'boolean', default: false })
|
||||
customsClearingEnabled!: boolean;
|
||||
|
||||
|
||||
@@ -35,6 +35,10 @@ 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 {
|
||||
CompanyIdentityStateDto,
|
||||
CompleteIdentityVerificationDto,
|
||||
} from "./dto/complete-identity-verification.dto";
|
||||
import { SetOnboardingStepDto } from "./dto/set-onboarding-step.dto";
|
||||
import { StartOnboardingDto } from "./dto/start-onboarding.dto";
|
||||
import { DashboardQueryDto } from "./dto/dashboard-query.dto";
|
||||
@@ -188,9 +192,20 @@ export class CompaniesController {
|
||||
@Post("fetch-etrade-info")
|
||||
@ApiOperation({ summary: "Fetch company info from eTrade by TIN" })
|
||||
async fetchETradeInfo(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Body() dto: FetchETradeDto,
|
||||
): Promise<ETradeResponseDto> {
|
||||
const data = await this.companiesService.fetchETradeData(dto.tin);
|
||||
// Best-effort: a first-run onboarding draft may not exist yet, in which
|
||||
// case there is no company to exclude and `tinTaken` checks every row —
|
||||
// the correct behaviour for a brand-new lookup.
|
||||
const companyId = await this.companiesService
|
||||
.getCompanyInfoByUserId(user.id)
|
||||
.then(({ company }) => company.id)
|
||||
.catch(() => undefined);
|
||||
const data = await this.companiesService.fetchETradeData(
|
||||
dto.tin,
|
||||
companyId,
|
||||
);
|
||||
return new ETradeResponseDto(data);
|
||||
}
|
||||
|
||||
@@ -378,6 +393,32 @@ export class CompaniesController {
|
||||
return this.companiesService.removePoaDelegationLetter(user.id, fileId);
|
||||
}
|
||||
|
||||
@Post("identity/fayda/complete")
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Bind a completed Fayda verification to the company's owner or Power of Attorney. " +
|
||||
"Start the flow with POST /fayda/verification/start (platform=PORTAL), then post the returned code+state here. " +
|
||||
"The verified name, phone, email and address are written from the Fayda payload; on an approved company the change is staged for backoffice review.",
|
||||
})
|
||||
async completeIdentityVerification(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Body() dto: CompleteIdentityVerificationDto,
|
||||
): Promise<CompanyIdentityStateDto> {
|
||||
return this.companiesService.completeIdentityVerification(user.id, dto);
|
||||
}
|
||||
|
||||
@Delete("identity/fayda/poa")
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Remove the company's Power of Attorney — the verified identity, its details and the delegation paper together. " +
|
||||
"Refused while the company holds a freight forwarder role, which cannot operate without a representative.",
|
||||
})
|
||||
async removePoaIdentity(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
): Promise<CompanyIdentityStateDto> {
|
||||
return this.companiesService.removePoaIdentity(user.id);
|
||||
}
|
||||
|
||||
@Patch("onboarding-step")
|
||||
@ApiOperation({ summary: "Persist the user's current onboarding wizard step" })
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
import { BadRequestException } from "@nestjs/common";
|
||||
|
||||
import { CompaniesService } from "./companies.service";
|
||||
import { CompanyNationality, CompanyStatus } from "./entities/company.entity";
|
||||
import { ProfileType } from "./entities/company-profile.entity";
|
||||
import { POA_DELEGATION_FILE_KEY } from "../file-upload-settings/poa-delegation.constants";
|
||||
|
||||
/**
|
||||
* A person's identity is proved through Fayda: name, email, phone and address
|
||||
* come from the verified payload, not typed. Fayda's userinfo carries no
|
||||
* national ID number, so none is collected or derived here.
|
||||
*
|
||||
* - Ethiopian company: the owner (and its PoA, once named) is verified through
|
||||
* Fayda, and their details can't be edited afterwards.
|
||||
* - Foreign company: Fayda is an Ethiopian national ID, so the owner instead
|
||||
* supplies a typed passport number — required on its own, whether or not the
|
||||
* owner also completes a (purely optional) Fayda verification.
|
||||
*
|
||||
* The owner is NOT the general manager — GM is a separate, plain typed role
|
||||
* the portal offers a "same as owner" copy for, but it is never itself
|
||||
* Fayda-verified or gated on.
|
||||
*/
|
||||
|
||||
interface Ctx {
|
||||
attributes: Record<string, unknown>;
|
||||
files: { id: string; code: string; reviewStatus?: string | null }[];
|
||||
profileTypes: ProfileType[];
|
||||
status: CompanyStatus;
|
||||
nationality: CompanyNationality;
|
||||
verification: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const OWNER_VERIFIED = {
|
||||
ownerFaydaSub: "owner-sub",
|
||||
ownerFaydaVerifiedAt: "2026-07-01T00:00:00.000Z",
|
||||
ownerName: "Abebe Bikila",
|
||||
};
|
||||
|
||||
const POA_VERIFIED = {
|
||||
poaFaydaSub: "poa-sub",
|
||||
poaFaydaVerifiedAt: "2026-07-02T00:00:00.000Z",
|
||||
poaName: "Tirunesh Dibaba",
|
||||
poaEmail: "tirunesh@example.com",
|
||||
poaPhone: "+251911000000",
|
||||
};
|
||||
|
||||
const paper = () => ({
|
||||
id: "file-1",
|
||||
code: POA_DELEGATION_FILE_KEY,
|
||||
reviewStatus: null,
|
||||
});
|
||||
|
||||
function makeService(overrides: Partial<Ctx> = {}) {
|
||||
const ctx: Ctx = {
|
||||
attributes: {},
|
||||
files: [],
|
||||
profileTypes: [ProfileType.importer],
|
||||
status: CompanyStatus.Pending,
|
||||
nationality: CompanyNationality.Ethiopian,
|
||||
verification: {
|
||||
purpose: "VERIFY",
|
||||
verified: true,
|
||||
sub: "new-sub",
|
||||
fullName: "Haile Gebrselassie",
|
||||
email: "haile@example.com",
|
||||
phoneNumber: "+251922000000",
|
||||
address: "Addis Ababa",
|
||||
birthdate: "1973-04-18",
|
||||
gender: "Male",
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
|
||||
const company = () => ({
|
||||
id: "company-1",
|
||||
status: ctx.status,
|
||||
nationality: ctx.nationality,
|
||||
attributes: ctx.attributes,
|
||||
companyProfiles: ctx.profileTypes.map((type, i) => ({
|
||||
id: `profile-${i}`,
|
||||
type,
|
||||
})),
|
||||
type: "customer",
|
||||
});
|
||||
|
||||
const deps = {
|
||||
companiesRepo: {
|
||||
findById: jest.fn(async () => company()),
|
||||
update: jest.fn(async (_id: string, patch: Record<string, unknown>) => {
|
||||
if (patch.attributes)
|
||||
ctx.attributes = patch.attributes as Record<string, unknown>;
|
||||
return company();
|
||||
}),
|
||||
findByTin: jest.fn(async () => null),
|
||||
},
|
||||
companyProfilesRepo: {
|
||||
findByCompanyId: jest.fn(async () =>
|
||||
ctx.profileTypes.map((type, i) => ({ id: `profile-${i}`, type })),
|
||||
),
|
||||
findByType: jest.fn(async (_id: string, type: ProfileType) =>
|
||||
ctx.profileTypes.includes(type) ? { id: "existing", type } : null,
|
||||
),
|
||||
create: jest.fn(async (row: Record<string, unknown>) => ({
|
||||
id: "new",
|
||||
...row,
|
||||
})),
|
||||
},
|
||||
changeRequestRepo: {
|
||||
findPendingByCompanyId: jest.fn(async () => null),
|
||||
findByCompanyId: jest.fn(async () => []),
|
||||
create: jest.fn(async (row: Record<string, unknown>) => ({
|
||||
id: "cr-1",
|
||||
...row,
|
||||
})),
|
||||
update: jest.fn(async () => ({ id: "cr-1" })),
|
||||
},
|
||||
profilesRepo: {
|
||||
findByCompanyId: jest.fn(async () => []),
|
||||
findByUserId: jest.fn(async () => ({
|
||||
id: "external-1",
|
||||
companyId: "company-1",
|
||||
company: company(),
|
||||
onboardingCompleted: false,
|
||||
})),
|
||||
},
|
||||
filesService: {
|
||||
findByResource: jest.fn(async () => ctx.files),
|
||||
findById: jest.fn(async () => null),
|
||||
remove: jest.fn(async () => undefined),
|
||||
},
|
||||
companyNotifier: { changeRequestSubmitted: jest.fn() },
|
||||
verifayda: {
|
||||
completeVerification: jest.fn(async () => ctx.verification),
|
||||
},
|
||||
};
|
||||
|
||||
const service = new CompaniesService(
|
||||
deps.companiesRepo as never,
|
||||
deps.companyProfilesRepo as never,
|
||||
deps.changeRequestRepo as never,
|
||||
deps.profilesRepo as never,
|
||||
{} as never,
|
||||
deps.filesService as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
deps.companyNotifier as never,
|
||||
{} as never,
|
||||
deps.verifayda as never,
|
||||
);
|
||||
|
||||
jest
|
||||
.spyOn(service, "getCompanyInfoByUserId")
|
||||
.mockImplementation(
|
||||
async () =>
|
||||
({ profile: { id: "external-1" }, company: company() }) as never,
|
||||
);
|
||||
|
||||
return { service, ctx, deps, company };
|
||||
}
|
||||
|
||||
describe("Fayda identity verification binds a person to the company", () => {
|
||||
it("writes the verified identity", async () => {
|
||||
const { service, ctx } = makeService();
|
||||
|
||||
const state = await service.completeIdentityVerification("user-1", {
|
||||
subject: "owner",
|
||||
code: "c",
|
||||
state: "s",
|
||||
});
|
||||
|
||||
expect(ctx.attributes.ownerFaydaSub).toBe("new-sub");
|
||||
expect(ctx.attributes.ownerName).toBe("Haile Gebrselassie");
|
||||
expect(state.owner.verified).toBe(true);
|
||||
});
|
||||
|
||||
it("fills every PoA detail from the payload, address included", async () => {
|
||||
const { service, ctx } = makeService();
|
||||
|
||||
await service.completeIdentityVerification("user-1", {
|
||||
subject: "poa",
|
||||
code: "c",
|
||||
state: "s",
|
||||
});
|
||||
|
||||
expect(ctx.attributes.poaName).toBe("Haile Gebrselassie");
|
||||
expect(ctx.attributes.poaEmail).toBe("haile@example.com");
|
||||
expect(ctx.attributes.poaPhone).toBe("+251922000000");
|
||||
expect(ctx.attributes.poaAddress).toBe("Addis Ababa");
|
||||
});
|
||||
|
||||
it("verifies successfully even though Fayda returns no national ID number", async () => {
|
||||
// Fayda's userinfo carries no FAN/FIN claim at all — this must be the
|
||||
// normal, successful path, not an error.
|
||||
const { service } = makeService({
|
||||
verification: {
|
||||
purpose: "VERIFY",
|
||||
verified: true,
|
||||
sub: "x",
|
||||
fullName: "No Fan Here",
|
||||
},
|
||||
});
|
||||
|
||||
const state = await service.completeIdentityVerification("user-1", {
|
||||
subject: "owner",
|
||||
code: "c",
|
||||
state: "s",
|
||||
});
|
||||
|
||||
expect(state.owner.verified).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses to make one identity both owner and PoA", async () => {
|
||||
const { service } = makeService({
|
||||
attributes: { ownerFaydaSub: "same-person" },
|
||||
verification: {
|
||||
purpose: "VERIFY",
|
||||
verified: true,
|
||||
sub: "same-person",
|
||||
fullName: "Abebe Bikila",
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.completeIdentityVerification("user-1", {
|
||||
subject: "poa",
|
||||
code: "c",
|
||||
state: "s",
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it("stages the change for review on an approved company", async () => {
|
||||
// Swapping the person who can act for a live company is exactly what the
|
||||
// backoffice review exists for, so it must not rewrite the row directly.
|
||||
const { service, ctx, deps } = makeService({
|
||||
status: CompanyStatus.Active,
|
||||
});
|
||||
|
||||
await service.completeIdentityVerification("user-1", {
|
||||
subject: "poa",
|
||||
code: "c",
|
||||
state: "s",
|
||||
});
|
||||
|
||||
expect(deps.changeRequestRepo.create).toHaveBeenCalled();
|
||||
expect(ctx.attributes.poaFaydaSub).toBeUndefined();
|
||||
});
|
||||
|
||||
it("refuses to rename a verified person by hand", async () => {
|
||||
const { service } = makeService({
|
||||
attributes: { ...OWNER_VERIFIED, ...POA_VERIFIED },
|
||||
files: [paper()],
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.updateProfile("user-1", { poaName: "Someone Else" } as never),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it("never locks or gates the general manager — it is not the verified subject", async () => {
|
||||
// GM is a plain typed role; the portal offers a "same as owner" copy, but
|
||||
// the backend must not treat it as identity-owned or require it verified.
|
||||
const { service } = makeService({
|
||||
attributes: { ...OWNER_VERIFIED },
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.updateProfile("user-1", {
|
||||
generalManagerName: "Someone Else",
|
||||
generalManagerEmail: "someone@example.com",
|
||||
generalManagerPhone: "+251911223344",
|
||||
} as never),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Ethiopian companies verify with Fayda; foreign companies verify identity by passport", () => {
|
||||
// The company is applying for the forwarder role, so it must not already
|
||||
// hold it — createCompanyProfileForUser short-circuits on an existing profile
|
||||
// and would never reach the gate.
|
||||
const applyingForFf = {
|
||||
profileTypes: [ProfileType.importer],
|
||||
attributes: { ...POA_VERIFIED },
|
||||
files: [paper()],
|
||||
};
|
||||
|
||||
it("blocks the forwarder role while the owner is unverified", async () => {
|
||||
const { service } = makeService(applyingForFf);
|
||||
|
||||
await expect(
|
||||
service.createCompanyProfileForUser(
|
||||
"user-1",
|
||||
ProfileType.freightForwarder,
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it("blocks the forwarder role while the PoA is unverified", async () => {
|
||||
const { service } = makeService({
|
||||
profileTypes: [ProfileType.importer],
|
||||
attributes: {
|
||||
...OWNER_VERIFIED,
|
||||
poaName: "Tirunesh Dibaba",
|
||||
poaEmail: "t@example.com",
|
||||
poaPhone: "+251911000000",
|
||||
},
|
||||
files: [paper()],
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.createCompanyProfileForUser(
|
||||
"user-1",
|
||||
ProfileType.freightForwarder,
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it("grants the forwarder role once owner and PoA are both verified", async () => {
|
||||
const { service } = makeService({
|
||||
profileTypes: [ProfileType.importer],
|
||||
attributes: { ...OWNER_VERIFIED, ...POA_VERIFIED },
|
||||
files: [paper()],
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.createCompanyProfileForUser(
|
||||
"user-1",
|
||||
ProfileType.freightForwarder,
|
||||
),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("never asks a foreign company for Fayda, verified or not", async () => {
|
||||
const { service } = makeService({
|
||||
nationality: CompanyNationality.Foreign,
|
||||
});
|
||||
|
||||
const state = await service.completeIdentityVerification("user-1", {
|
||||
subject: "owner",
|
||||
code: "c",
|
||||
state: "s",
|
||||
});
|
||||
|
||||
// Still lets the owner verify — a foreign owner verifying is allowed, just
|
||||
// never required — but the passport is the thing that actually gates it.
|
||||
expect(state.owner.verified).toBe(true);
|
||||
expect(state.faydaRequired).toBe(false);
|
||||
expect(state.passportRequired).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks the forwarder role for a foreign company with no owner passport", async () => {
|
||||
const { service } = makeService({
|
||||
profileTypes: [ProfileType.importer],
|
||||
nationality: CompanyNationality.Foreign,
|
||||
attributes: {
|
||||
poaName: "Jean Dupont",
|
||||
poaEmail: "jean@example.com",
|
||||
poaPhone: "+33100000000",
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.createCompanyProfileForUser(
|
||||
"user-1",
|
||||
ProfileType.freightForwarder,
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it("grants the forwarder role to a foreign company with an owner passport and no Fayda at all", async () => {
|
||||
const { service } = makeService({
|
||||
profileTypes: [ProfileType.importer],
|
||||
nationality: CompanyNationality.Foreign,
|
||||
attributes: {
|
||||
ownerPassportNumber: "P1234567",
|
||||
poaName: "Jean Dupont",
|
||||
poaEmail: "jean@example.com",
|
||||
poaPhone: "+33100000000",
|
||||
},
|
||||
files: [paper()],
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.createCompanyProfileForUser(
|
||||
"user-1",
|
||||
ProfileType.freightForwarder,
|
||||
),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("still requires the passport for a foreign owner who chose to verify with Fayda too", async () => {
|
||||
// Verifying is optional for a foreign owner, but it does not waive the
|
||||
// passport requirement — the two are independent credentials.
|
||||
const { service } = makeService({
|
||||
profileTypes: [ProfileType.importer],
|
||||
nationality: CompanyNationality.Foreign,
|
||||
attributes: {
|
||||
...OWNER_VERIFIED,
|
||||
poaName: "Jean Dupont",
|
||||
poaEmail: "jean@example.com",
|
||||
poaPhone: "+33100000000",
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.createCompanyProfileForUser(
|
||||
"user-1",
|
||||
ProfileType.freightForwarder,
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,7 @@ import { CompanyProfileRepository } from "./company-profile.repository";
|
||||
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
|
||||
import { ETradeService } from "./services/etrade.service";
|
||||
import { CompanyNotifierService } from "./company-notifier.service";
|
||||
import { VerifaydaModule } from "../verifayda/verifayda.module";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -38,6 +39,8 @@ import { CompanyNotifierService } from "./company-notifier.service";
|
||||
// imports this module back for portal recipient targeting, hence forwardRef.
|
||||
NotificationsModule,
|
||||
forwardRef(() => NotificationInboxModule),
|
||||
// Fayda identity verification for the company's owner and PoA.
|
||||
VerifaydaModule,
|
||||
],
|
||||
controllers: [CompaniesController],
|
||||
providers: [
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
import { BadRequestException } from "@nestjs/common";
|
||||
|
||||
import { CompaniesService } from "./companies.service";
|
||||
import { CompanyStatus } from "./entities/company.entity";
|
||||
import { ProfileType } from "./entities/company-profile.entity";
|
||||
import { POA_DELEGATION_FILE_KEY } from "../file-upload-settings/poa-delegation.constants";
|
||||
|
||||
/**
|
||||
* EDRFREIGHT-358: a company that names a Power of Attorney must have the DARS
|
||||
* delegation paper on file. The rule used to live only in the onboarding
|
||||
* wizard's completion check, so every other write that could break the pairing
|
||||
* — saving PoA details, deleting the paper, picking up the forwarder role —
|
||||
* went unguarded. These cover those writes.
|
||||
*/
|
||||
|
||||
interface Ctx {
|
||||
attributes: Record<string, unknown>;
|
||||
files: { id: string; code: string; reviewStatus?: string | null }[];
|
||||
profileTypes: ProfileType[];
|
||||
status: CompanyStatus;
|
||||
pendingSnapshot: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
const POA = { poaName: "Abebe", poaEmail: "a@b.com", poaPhone: "+251911000000" };
|
||||
|
||||
/**
|
||||
* The forwarder role is gated on Fayda-verified identities as well as on the
|
||||
* delegation paper. These tests are about the paper, so they run against a
|
||||
* company whose identities are already verified — the identity rule itself is
|
||||
* covered in companies.fayda-identity.spec.ts.
|
||||
*/
|
||||
const VERIFIED_IDENTITIES = {
|
||||
ownerFaydaSub: "owner-sub",
|
||||
poaFaydaSub: "poa-sub",
|
||||
};
|
||||
|
||||
function makeService(overrides: Partial<Ctx> = {}) {
|
||||
const ctx: Ctx = {
|
||||
attributes: {},
|
||||
files: [],
|
||||
profileTypes: [ProfileType.importer],
|
||||
status: CompanyStatus.Pending,
|
||||
pendingSnapshot: null,
|
||||
...overrides,
|
||||
};
|
||||
|
||||
const company = () => ({
|
||||
id: "company-1",
|
||||
status: ctx.status,
|
||||
attributes: ctx.attributes,
|
||||
companyProfiles: ctx.profileTypes.map((type, i) => ({
|
||||
id: `profile-${i}`,
|
||||
type,
|
||||
})),
|
||||
type: "customer",
|
||||
});
|
||||
|
||||
const deps = {
|
||||
companiesRepo: {
|
||||
findById: jest.fn(async () => company()),
|
||||
update: jest.fn(async (_id: string, patch: Record<string, unknown>) => {
|
||||
ctx.attributes = (patch.attributes ??
|
||||
ctx.attributes) as Record<string, unknown>;
|
||||
return company();
|
||||
}),
|
||||
findByTin: jest.fn(async () => null),
|
||||
},
|
||||
companyProfilesRepo: {
|
||||
findByCompanyId: jest.fn(async () =>
|
||||
ctx.profileTypes.map((type, i) => ({ id: `profile-${i}`, type })),
|
||||
),
|
||||
findByType: jest.fn(async (_id: string, type: ProfileType) =>
|
||||
ctx.profileTypes.includes(type) ? { id: "existing", type } : null,
|
||||
),
|
||||
create: jest.fn(async (row: Record<string, unknown>) => ({
|
||||
id: "new",
|
||||
...row,
|
||||
})),
|
||||
},
|
||||
changeRequestRepo: {
|
||||
findPendingByCompanyId: jest.fn(async () =>
|
||||
ctx.pendingSnapshot ? { id: "cr-1", snapshot: ctx.pendingSnapshot } : null,
|
||||
),
|
||||
findByCompanyId: jest.fn(async () => []),
|
||||
create: jest.fn(async (row: Record<string, unknown>) => ({
|
||||
id: "cr-1",
|
||||
...row,
|
||||
})),
|
||||
update: jest.fn(async () => ({ id: "cr-1" })),
|
||||
},
|
||||
profilesRepo: {
|
||||
findByCompanyId: jest.fn(async () => []),
|
||||
findByUserId: jest.fn(async () => ({
|
||||
id: "external-1",
|
||||
companyId: "company-1",
|
||||
company: company(),
|
||||
onboardingCompleted: false,
|
||||
})),
|
||||
},
|
||||
filesService: {
|
||||
findByResource: jest.fn(async () => ctx.files),
|
||||
findById: jest.fn(async (id: string) =>
|
||||
ctx.files.find((f) => f.id === id)
|
||||
? {
|
||||
...ctx.files.find((f) => f.id === id),
|
||||
resource: "companies",
|
||||
resourceId: "company-1",
|
||||
name: "dars.pdf",
|
||||
}
|
||||
: null,
|
||||
),
|
||||
remove: jest.fn(async () => undefined),
|
||||
},
|
||||
companyNotifier: { changeRequestSubmitted: jest.fn() },
|
||||
};
|
||||
|
||||
const service = new CompaniesService(
|
||||
deps.companiesRepo as never,
|
||||
deps.companyProfilesRepo as never,
|
||||
deps.changeRequestRepo as never,
|
||||
deps.profilesRepo as never,
|
||||
{} as never,
|
||||
deps.filesService as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
deps.companyNotifier as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
// getCompanyInfoByUserId does its own lookups; the stubs above are enough for
|
||||
// the PoA paths, so short-circuit it rather than mock the whole graph.
|
||||
jest
|
||||
.spyOn(service, "getCompanyInfoByUserId")
|
||||
.mockImplementation(
|
||||
async () =>
|
||||
({ profile: { id: "external-1" }, company: company() }) as never,
|
||||
);
|
||||
|
||||
return { service, ctx, deps };
|
||||
}
|
||||
|
||||
const paper = (reviewStatus: string | null = null) => ({
|
||||
id: "file-1",
|
||||
code: POA_DELEGATION_FILE_KEY,
|
||||
reviewStatus,
|
||||
});
|
||||
|
||||
describe("PoA delegation paper is enforced wherever PoA state changes", () => {
|
||||
it("rejects PoA details saved with no paper on file", async () => {
|
||||
const { service } = makeService();
|
||||
|
||||
await expect(
|
||||
service.updateProfile("user-1", POA as never),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it("accepts PoA details once the paper is on file", async () => {
|
||||
const { service } = makeService({ files: [paper()] });
|
||||
|
||||
await expect(
|
||||
service.updateProfile("user-1", POA as never),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("rejects a paper the reviewer sent back for correction", async () => {
|
||||
const { service } = makeService({ files: [paper("change_requested")] });
|
||||
|
||||
await expect(
|
||||
service.updateProfile("user-1", POA as never),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it("leaves edits that don't touch the PoA alone", async () => {
|
||||
// A company carrying legacy details must not be locked out of every other
|
||||
// field until it produces a paper.
|
||||
const { service } = makeService({ attributes: { ...POA }, files: [] });
|
||||
|
||||
await expect(
|
||||
service.updateProfile("user-1", { companyEmail: "x@y.com" } as never),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("refuses to remove the paper while the PoA is still named", async () => {
|
||||
const { service } = makeService({
|
||||
attributes: { ...POA },
|
||||
files: [paper()],
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.removePoaDelegationLetter("user-1", "file-1"),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it("allows removing the paper once the PoA has been cleared", async () => {
|
||||
const { service } = makeService({ attributes: {}, files: [paper()] });
|
||||
|
||||
await expect(
|
||||
service.removePoaDelegationLetter("user-1", "file-1"),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("judges the removal against a staged clear, not the live row", async () => {
|
||||
// An Active company's edits are staged for review rather than written, so
|
||||
// the live attributes still carry the PoA the customer just cleared.
|
||||
const { service } = makeService({
|
||||
status: CompanyStatus.Active,
|
||||
attributes: { ...POA },
|
||||
pendingSnapshot: { poaName: "", poaEmail: "", poaPhone: "" },
|
||||
files: [paper()],
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.removePoaDelegationLetter("user-1", "file-1"),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("refuses the forwarder role to a company with no PoA", async () => {
|
||||
const { service } = makeService({ attributes: { ...VERIFIED_IDENTITIES } });
|
||||
|
||||
await expect(
|
||||
service.createCompanyProfileForUser(
|
||||
"user-1",
|
||||
ProfileType.freightForwarder,
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it("grants the forwarder role once PoA details and paper are both in place", async () => {
|
||||
const { service } = makeService({
|
||||
attributes: { ...POA, ...VERIFIED_IDENTITIES },
|
||||
files: [paper()],
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.createCompanyProfileForUser(
|
||||
"user-1",
|
||||
ProfileType.freightForwarder,
|
||||
),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -75,8 +75,14 @@ export class CompaniesRepository extends BaseRepository<Company> {
|
||||
.getMany();
|
||||
}
|
||||
|
||||
async existsByTin(tin: string): Promise<boolean> {
|
||||
const count = await this.repository.count({ where: { tin } as any });
|
||||
async existsByTin(tin: string, excludeCompanyId?: string): Promise<boolean> {
|
||||
const qb = this.repository
|
||||
.createQueryBuilder('company')
|
||||
.where('company.tin = :tin', { tin });
|
||||
if (excludeCompanyId) {
|
||||
qb.andWhere('company.id != :excludeCompanyId', { excludeCompanyId });
|
||||
}
|
||||
const count = await qb.getCount();
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { CompaniesService } from "./companies.service";
|
||||
import { CompanyType } from "./entities/company.entity";
|
||||
import { ProfileStatus, ProfileType } from "./entities/company-profile.entity";
|
||||
|
||||
/**
|
||||
* EDRFREIGHT-416: onboarding asked for a deselected role's documents.
|
||||
*
|
||||
* Re-running role selection used to only ADD operational profiles, so a role
|
||||
* the user unticked on the way back left its company_profile row behind — and
|
||||
* every role-driven requirement (business license, forwarder PoA) is derived
|
||||
* from those rows. startOnboarding now reconciles both directions.
|
||||
*/
|
||||
|
||||
interface ExistingProfile {
|
||||
id: string;
|
||||
type: ProfileType;
|
||||
status: ProfileStatus;
|
||||
}
|
||||
|
||||
function makeService(existing: ExistingProfile[]) {
|
||||
const companyProfilesRepo = {
|
||||
findByCompanyId: jest.fn(async () => existing),
|
||||
create: jest.fn(async (row: Record<string, unknown>) => ({
|
||||
id: "new",
|
||||
...row,
|
||||
})),
|
||||
softDelete: jest.fn(async () => undefined),
|
||||
};
|
||||
const companiesRepo = { update: jest.fn(async () => null) };
|
||||
const profilesRepo = {
|
||||
findByUserId: jest.fn(async () => ({
|
||||
id: "external-1",
|
||||
companyId: "company-1",
|
||||
company: { id: "company-1" },
|
||||
})),
|
||||
};
|
||||
|
||||
const service = new CompaniesService(
|
||||
companiesRepo as never,
|
||||
companyProfilesRepo as never,
|
||||
{} as never,
|
||||
profilesRepo as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
jest
|
||||
.spyOn(service, "getCompanyInfoByUserId")
|
||||
.mockImplementation(
|
||||
async () =>
|
||||
({ profile: { id: "external-1" }, company: { id: "company-1" } }) as never,
|
||||
);
|
||||
|
||||
return { service, companyProfilesRepo };
|
||||
}
|
||||
|
||||
const identity = { userId: "user-1", firstName: "Abebe", lastName: "K" };
|
||||
|
||||
const start = (service: CompaniesService, roles: ProfileType[]) =>
|
||||
service.startOnboarding(identity as never, CompanyType.Customer, roles);
|
||||
|
||||
describe("re-running role selection reconciles the operational profiles", () => {
|
||||
it("drops the profile for a role the user deselected", async () => {
|
||||
const { service, companyProfilesRepo } = makeService([
|
||||
{ id: "p-imp", type: ProfileType.importer, status: ProfileStatus.Pending },
|
||||
{
|
||||
id: "p-ff",
|
||||
type: ProfileType.freightForwarder,
|
||||
status: ProfileStatus.Pending,
|
||||
},
|
||||
]);
|
||||
|
||||
await start(service, [ProfileType.importer]);
|
||||
|
||||
expect(companyProfilesRepo.softDelete).toHaveBeenCalledWith("p-ff");
|
||||
expect(companyProfilesRepo.softDelete).toHaveBeenCalledTimes(1);
|
||||
expect(companyProfilesRepo.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps an already-approved profile even when it is unticked", async () => {
|
||||
const { service, companyProfilesRepo } = makeService([
|
||||
{ id: "p-imp", type: ProfileType.importer, status: ProfileStatus.Pending },
|
||||
{
|
||||
id: "p-exp",
|
||||
type: ProfileType.exporter,
|
||||
status: ProfileStatus.Active,
|
||||
},
|
||||
]);
|
||||
|
||||
await start(service, [ProfileType.importer]);
|
||||
|
||||
expect(companyProfilesRepo.softDelete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still adds a newly-picked role", async () => {
|
||||
const { service, companyProfilesRepo } = makeService([
|
||||
{ id: "p-imp", type: ProfileType.importer, status: ProfileStatus.Pending },
|
||||
]);
|
||||
|
||||
await start(service, [ProfileType.importer, ProfileType.exporter]);
|
||||
|
||||
expect(companyProfilesRepo.softDelete).not.toHaveBeenCalled();
|
||||
expect(companyProfilesRepo.create).toHaveBeenCalledTimes(1);
|
||||
expect(companyProfilesRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: ProfileType.exporter }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -17,10 +17,23 @@ import {
|
||||
import { FilesService } from "../files/files.service";
|
||||
import { FileRecord } from "../files/entities/file.entity";
|
||||
import { FileUploadSettingsService } from "../file-upload-settings/file-upload-settings.service";
|
||||
import {
|
||||
POA_DELEGATION_FILE_KEY,
|
||||
POA_DELEGATION_LABEL,
|
||||
POA_DELEGATION_PENDING_CODE,
|
||||
} from "../file-upload-settings/poa-delegation.constants";
|
||||
import { VerifaydaService } from "../verifayda/verifayda.service";
|
||||
import {
|
||||
buildCompanyIdentityState,
|
||||
CompanyIdentityStateDto,
|
||||
CompleteIdentityVerificationDto,
|
||||
IdentitySubject,
|
||||
} from "./dto/complete-identity-verification.dto";
|
||||
import { ETradeService } from "./services/etrade.service";
|
||||
import { CompanyNotifierService } from "./company-notifier.service";
|
||||
import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto";
|
||||
import { normalizeE164 } from "../../common/validators/is-phone-number.validator";
|
||||
import type { CompanyRegistrationData } from "@edr/types";
|
||||
import { CreateCompanyDto } from "./dto/create-company.dto";
|
||||
import { UpdateCompanyDto } from "./dto/update-company.dto";
|
||||
import { CreateExternalProfileDto } from "./dto/create-external-profile.dto";
|
||||
@@ -58,10 +71,6 @@ const LICENSE_CODE = "business_license";
|
||||
/** Code for a license file staged in an open change request (not yet live). */
|
||||
const LICENSE_PENDING_CODE = "business_license_pending";
|
||||
|
||||
/** Mirrors the field seeded in seed/file-upload-settings.seeder.ts. */
|
||||
const POA_DELEGATION_FILE_KEY = "poa_delegation_letter";
|
||||
/** Code for a PoA letter staged in an open change request (not yet live). */
|
||||
const POA_DELEGATION_PENDING_CODE = "poa_delegation_letter_pending";
|
||||
/** FileRecord resource that company-level documents are stored under. */
|
||||
const COMPANY_RESOURCE = "companies";
|
||||
/** company.attributes keys that together mean "a PoA was entered". */
|
||||
@@ -79,6 +88,62 @@ const REQUIRED_POA_FIELDS: { key: string; label: string }[] = [
|
||||
{ key: "poaPhone", label: "PoA phone" },
|
||||
];
|
||||
|
||||
/**
|
||||
* `attributes` key prefix per verifiable person. The owner is NOT the general
|
||||
* manager — GM is a plain typed role (the portal offers a "same as owner" copy
|
||||
* once the owner is verified), while the owner is who this verification
|
||||
* actually proves. They're very often the same human; that's what the copy is
|
||||
* for.
|
||||
*/
|
||||
const IDENTITY_PREFIX: Record<IdentitySubject, "owner" | "poa"> = {
|
||||
owner: "owner",
|
||||
poa: "poa",
|
||||
};
|
||||
|
||||
const IDENTITY_LABEL: Record<IdentitySubject, string> = {
|
||||
owner: "owner",
|
||||
poa: "Power of Attorney",
|
||||
};
|
||||
|
||||
/**
|
||||
* Identity fields a Fayda verification owns outright, per person. Once verified
|
||||
* these can no longer be typed — the government IdP is the source, so an edit
|
||||
* that disagrees with it is either a mistake or an attempt to launder the
|
||||
* guarantee away. The GM fields are deliberately absent: GM is never itself
|
||||
* Fayda-verified, so it stays freely editable regardless of the owner's state.
|
||||
*/
|
||||
const IDENTITY_OWNED_FIELDS: Record<IdentitySubject, string[]> = {
|
||||
owner: ["ownerName", "ownerEmail", "ownerPhone", "ownerAddress"],
|
||||
poa: ["poaName", "poaEmail", "poaPhone", "poaAddress"],
|
||||
};
|
||||
|
||||
/**
|
||||
* `UpdateProfileDto` fields eTrade is the sole source of truth for. A request
|
||||
* touching any of these must be re-checked against a fresh eTrade lookup —
|
||||
* see `assertEtradeFieldsAuthentic`.
|
||||
*/
|
||||
const ETRADE_SOURCED_FIELDS = [
|
||||
"companyName",
|
||||
"tin",
|
||||
"licenceNumber",
|
||||
"statusDescription",
|
||||
"dateRegistered",
|
||||
"renewedFrom",
|
||||
"renewalDate",
|
||||
"renewedTo",
|
||||
"region",
|
||||
"zone",
|
||||
"woreda",
|
||||
"kebele",
|
||||
"houseNo",
|
||||
"etradePhone",
|
||||
] as const satisfies readonly (keyof UpdateProfileDto)[];
|
||||
|
||||
/** The attributes a verification writes, for one person. */
|
||||
interface VerifiedIdentityAttributes {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface UserIdentity {
|
||||
userId: string;
|
||||
firstName: string;
|
||||
@@ -100,6 +165,7 @@ export class CompaniesService {
|
||||
private readonly etradeService: ETradeService,
|
||||
private readonly companyNotifier: CompanyNotifierService,
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly verifaydaService: VerifaydaService,
|
||||
) { }
|
||||
|
||||
/**
|
||||
@@ -255,8 +321,9 @@ export class CompaniesService {
|
||||
* 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
|
||||
* Idempotent: if the user already has a profile, returns it unchanged, with
|
||||
* the operational profiles reconciled against the roles just chosen (added
|
||||
* and — for still-pending ones — removed). 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.
|
||||
*/
|
||||
@@ -271,7 +338,7 @@ export class CompaniesService {
|
||||
const existing = await this.profilesRepo.findByUserId(identity.userId);
|
||||
if (existing) {
|
||||
const companyId = existing.company?.id ?? existing.companyId;
|
||||
await this.ensureCompanyProfiles(companyId, companyType, roles);
|
||||
await this.syncCompanyProfiles(companyId, companyType, roles);
|
||||
if (nationality) {
|
||||
await this.companiesRepo.update(companyId, { nationality });
|
||||
}
|
||||
@@ -302,25 +369,44 @@ export class CompaniesService {
|
||||
onboardingCompleted: false,
|
||||
});
|
||||
|
||||
await this.ensureCompanyProfiles(company.id, companyType, chosenTypes);
|
||||
await this.syncCompanyProfiles(company.id, companyType, chosenTypes);
|
||||
|
||||
return this.getCompanyInfoByUserId(identity.userId);
|
||||
}
|
||||
|
||||
/** Create any of the requested operational profiles that don't exist yet. */
|
||||
private async ensureCompanyProfiles(
|
||||
/**
|
||||
* Reconcile the company's operational profiles with the roles the user has
|
||||
* selected: create the missing ones, drop the ones they deselected.
|
||||
*
|
||||
* Dropping matters because every role-driven onboarding requirement — the
|
||||
* per-profile business license, the freight-forwarder PoA rule, the license
|
||||
* cards in the wizard — is derived from these rows. A row left behind after
|
||||
* the user went back and unticked a role keeps asking for that role's
|
||||
* documents (EDRFREIGHT-416). Only still-pending profiles are removed: an
|
||||
* approved one is live (it can carry bookings and contracts) and re-running
|
||||
* role selection must never delete it.
|
||||
*/
|
||||
private async syncCompanyProfiles(
|
||||
companyId: string,
|
||||
companyType: CompanyType,
|
||||
roles: ProfileType[],
|
||||
): Promise<void> {
|
||||
const allowedTypes = this.getProfileTypeForCompanyType(companyType);
|
||||
for (const type of roles) {
|
||||
if (!allowedTypes.includes(type)) continue;
|
||||
const existing = await this.companyProfilesRepo.findByType(
|
||||
companyId,
|
||||
type,
|
||||
);
|
||||
if (existing) continue;
|
||||
const chosen = roles.filter((t) => allowedTypes.includes(t));
|
||||
const existing = await this.companyProfilesRepo.findByCompanyId(companyId);
|
||||
|
||||
for (const profile of existing) {
|
||||
if (chosen.includes(profile.type)) continue;
|
||||
if (profile.status !== ProfileStatus.Pending) continue;
|
||||
// The license files uploaded against this profile go with it: they are
|
||||
// only ever read per company_profile id, so a soft-deleted profile
|
||||
// leaves nothing behind to prompt for. Re-picking the role creates a
|
||||
// fresh profile the user uploads against again.
|
||||
await this.companyProfilesRepo.softDelete(profile.id);
|
||||
}
|
||||
|
||||
for (const type of chosen) {
|
||||
if (existing.some((p) => p.type === type)) continue;
|
||||
// No reference yet — minted on backoffice approval (setCompanyProfileStatus).
|
||||
await this.companyProfilesRepo.create({
|
||||
companyId,
|
||||
@@ -599,7 +685,9 @@ export class CompaniesService {
|
||||
*/
|
||||
private mapProfileDtoToCompanyUpdates(
|
||||
company: Company,
|
||||
dto: Partial<UpdateProfileDto>,
|
||||
dto: Partial<UpdateProfileDto> & {
|
||||
faydaIdentity?: VerifiedIdentityAttributes;
|
||||
},
|
||||
): Record<string, any> {
|
||||
const companyUpdates: Record<string, any> = {};
|
||||
const attrUpdates: Record<string, any> = { ...(company.attributes ?? {}) };
|
||||
@@ -617,7 +705,6 @@ export class CompaniesService {
|
||||
if (dto.tin !== undefined && dto.tin !== company.tin)
|
||||
companyUpdates.tin = dto.tin;
|
||||
if (dto.vatNumber !== undefined) companyUpdates.vatNumber = dto.vatNumber;
|
||||
if (dto.fanNumber !== undefined) companyUpdates.fanNumber = dto.fanNumber;
|
||||
|
||||
if (dto.contactPersonName !== undefined)
|
||||
attrUpdates.contactPersonName = dto.contactPersonName;
|
||||
@@ -661,6 +748,72 @@ export class CompaniesService {
|
||||
if (dto.etradePhone !== undefined)
|
||||
companyUpdates.etradePhone = normalizeE164(dto.etradePhone);
|
||||
|
||||
// A plain typed field — never Fayda-verified, so no lock ever applies to
|
||||
// it. Independent of the owner's verification: still required for a
|
||||
// foreign company even if the owner also verifies with Fayda.
|
||||
if (dto.ownerPassportNumber !== undefined)
|
||||
attrUpdates.ownerPassportNumber = dto.ownerPassportNumber;
|
||||
|
||||
// A verified identity overwrites the person's details. `faydaIdentity`
|
||||
// never comes off the wire — the global validation pipe runs with
|
||||
// forbidNonWhitelisted, so a client that sends it is rejected outright; it
|
||||
// only reaches here from completeIdentityVerification, directly or through
|
||||
// a staged snapshot.
|
||||
if (dto.faydaIdentity) {
|
||||
Object.assign(attrUpdates, dto.faydaIdentity);
|
||||
}
|
||||
|
||||
// companyEmail/companyPhone are the Company-column mirrors of the owner's
|
||||
// verified contact details (the portal derives and submits them, it never
|
||||
// lets the customer type them once verified) — lock them the same way
|
||||
// ownerEmail/ownerPhone themselves are locked below, once there is a
|
||||
// verified owner to lock them to.
|
||||
if (attrUpdates.ownerFaydaSub) {
|
||||
if (
|
||||
dto.companyEmail !== undefined &&
|
||||
dto.companyEmail !== attrUpdates.ownerEmail
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
"companyEmail is set by the owner's Fayda verification and cannot be edited. Re-verify to change it.",
|
||||
);
|
||||
}
|
||||
if (
|
||||
dto.companyPhone !== undefined &&
|
||||
normalizeE164(dto.companyPhone) !==
|
||||
normalizeE164(String(attrUpdates.ownerPhone ?? ""))
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
"companyPhone is set by the owner's Fayda verification and cannot be edited. Re-verify to change it.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Renaming a Fayda-verified person by hand would launder the guarantee
|
||||
// away, so the fields the verification owns are refused once it exists.
|
||||
for (const subject of ["owner", "poa"] as IdentitySubject[]) {
|
||||
if (!attrUpdates[`${IDENTITY_PREFIX[subject]}FaydaSub`]) continue;
|
||||
for (const field of IDENTITY_OWNED_FIELDS[subject]) {
|
||||
const incoming = (dto as Record<string, unknown>)[field];
|
||||
if (incoming === undefined) continue;
|
||||
// The verification itself is allowed to write them; anything else is
|
||||
// compared against what is already stored, not against the value this
|
||||
// same call just copied into the patch. Phones are compared normalized:
|
||||
// a form that re-renders +251911000000 as 0911000000 is echoing the
|
||||
// stored value back, not trying to change it.
|
||||
if (dto.faydaIdentity && field in dto.faydaIdentity) continue;
|
||||
const stored = company.attributes?.[field];
|
||||
const same = field.endsWith("Phone")
|
||||
? normalizeE164(String(incoming)) ===
|
||||
normalizeE164(String(stored ?? ""))
|
||||
: incoming === stored;
|
||||
if (!same) {
|
||||
throw new BadRequestException(
|
||||
`${field} is set by the Fayda verification of this company's ${IDENTITY_LABEL[subject]} and cannot be edited. Re-verify to change it.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companyUpdates.attributes = attrUpdates;
|
||||
return companyUpdates;
|
||||
}
|
||||
@@ -702,6 +855,21 @@ export class CompaniesService {
|
||||
): Promise<ProfileResponseDto> {
|
||||
const { profile, company } = await this.getCompanyInfoByUserId(userId);
|
||||
|
||||
await this.assertEtradeFieldsAuthentic(company, dto);
|
||||
|
||||
// Naming (or renaming) a Power of Attorney is one of the writes that can
|
||||
// leave the company with a representative and nothing evidencing them, so
|
||||
// it is gated here. Edits that don't touch the PoA are left alone — a
|
||||
// company carrying legacy details must not be locked out of every other
|
||||
// field until it produces a paper.
|
||||
if (POA_ATTRIBUTES.some((k) => dto[k] !== undefined)) {
|
||||
const attributes = this.mapProfileDtoToCompanyUpdates(company, dto)
|
||||
.attributes as Record<string, unknown>;
|
||||
await this.assertPoaDelegationSatisfied(company.id, attributes, {
|
||||
requirePoa: await this.isFreightForwarder(company.id),
|
||||
});
|
||||
}
|
||||
|
||||
if (company.status !== CompanyStatus.Active) {
|
||||
await this.assertTinAvailable(company, dto.tin);
|
||||
const companyUpdates = this.mapProfileDtoToCompanyUpdates(company, dto);
|
||||
@@ -1127,11 +1295,24 @@ export class CompaniesService {
|
||||
// blacklist skip all this — staff must always be able to act against a bad
|
||||
// account.
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
await manager.findOne(Company, {
|
||||
const company = await manager.findOne(Company, {
|
||||
where: { id: existing.companyId },
|
||||
lock: { mode: "pessimistic_write" },
|
||||
});
|
||||
|
||||
// Putting a forwarder into service without a Power of Attorney backed by
|
||||
// a DARS paper is the thing EDRFREIGHT-358 forbids, so the approval is
|
||||
// the last place it has to be checked — the role may have been applied
|
||||
// for before the paper was withdrawn.
|
||||
if (company && existing.type === ProfileType.freightForwarder) {
|
||||
this.assertIdentityVerified(company, { requirePoa: true });
|
||||
await this.assertPoaDelegationSatisfied(
|
||||
company.id,
|
||||
company.attributes,
|
||||
{ requirePoa: true },
|
||||
);
|
||||
}
|
||||
|
||||
const [companyDocs, profileDocs] = await Promise.all([
|
||||
this.filesService.findWithOpenChangeRequest(
|
||||
[existing.companyId],
|
||||
@@ -1382,6 +1563,18 @@ export class CompaniesService {
|
||||
);
|
||||
if (existing) continue;
|
||||
|
||||
// A forwarder signs on other companies' behalf, so it cannot be taken on
|
||||
// without a Power of Attorney and its DARS paper — checked here so the
|
||||
// customer is told at the point of asking, not at review.
|
||||
if (type === ProfileType.freightForwarder) {
|
||||
this.assertIdentityVerified(company, { requirePoa: true });
|
||||
await this.assertPoaDelegationSatisfied(
|
||||
companyId,
|
||||
await this.effectivePoaAttributes(company),
|
||||
{ requirePoa: true },
|
||||
);
|
||||
}
|
||||
|
||||
// Self-service role adds start Pending and carry no reference — a reference
|
||||
// is minted only when a backoffice reviewer approves the role.
|
||||
await this.companyProfilesRepo.create({
|
||||
@@ -1419,6 +1612,14 @@ export class CompaniesService {
|
||||
}
|
||||
|
||||
let created = await this.companyProfilesRepo.findByType(companyId, type);
|
||||
if (!created && type === ProfileType.freightForwarder) {
|
||||
this.assertIdentityVerified(company, { requirePoa: true });
|
||||
await this.assertPoaDelegationSatisfied(
|
||||
companyId,
|
||||
await this.effectivePoaAttributes(company),
|
||||
{ requirePoa: true },
|
||||
);
|
||||
}
|
||||
if (!created) {
|
||||
// New self-service roles start Pending (awaiting backoffice approval) and
|
||||
// carry no reference until approved.
|
||||
@@ -1453,11 +1654,17 @@ export class CompaniesService {
|
||||
userId: string,
|
||||
): Promise<OnboardingRequirementsResponseDto> {
|
||||
const { profile, company } = await this.getCompanyInfoByUserId(userId);
|
||||
const identity = this.getCompanyIdentityState(company);
|
||||
|
||||
// 1. Required company-information fields.
|
||||
const missingInfo = this.REQUIRED_COMPANY_INFO.filter(
|
||||
(f) => !f.get(company),
|
||||
).map((f) => ({ key: f.key, label: f.label }));
|
||||
// 1. Required company-information fields. The FAN is never one of them —
|
||||
// Fayda verification doesn't produce a FAN, so it's never collected as
|
||||
// part of onboarding at all (see the identity block below).
|
||||
const requiredInfo = this.REQUIRED_COMPANY_INFO.filter(
|
||||
(f) => f.key !== "fanNumber",
|
||||
);
|
||||
const missingInfo = requiredInfo
|
||||
.filter((f) => !f.get(company))
|
||||
.map((f) => ({ key: f.key, label: f.label }));
|
||||
|
||||
// 2. Nationality-based company documents + which are already uploaded.
|
||||
const documentSettingCode = this.documentSettingCodeFor(company.nationality);
|
||||
@@ -1504,26 +1711,31 @@ export class CompaniesService {
|
||||
|
||||
// 4. Power of Attorney. Optional in general, but a freight forwarder acts on
|
||||
// other companies' behalf so its PoA is mandatory. Either way, a PoA that
|
||||
// has been entered must be evidenced by the delegation letter.
|
||||
// has been entered must be evidenced by the DARS delegation paper — a legal
|
||||
// requirement, so unlike the documents above it does not depend on the
|
||||
// upload set carrying a field for it (see poa-delegation.constants.ts).
|
||||
const poaRequired = (company.companyProfiles ?? []).some(
|
||||
(p) => p.type === ProfileType.freightForwarder,
|
||||
);
|
||||
const poaProvided = POA_ATTRIBUTES.some((k) =>
|
||||
(company.attributes?.[k] as string | undefined)?.trim(),
|
||||
);
|
||||
const missingPoaFields = poaRequired
|
||||
? REQUIRED_POA_FIELDS.filter(
|
||||
(f) => !(company.attributes?.[f.key] as string | undefined)?.trim(),
|
||||
)
|
||||
: [];
|
||||
// Only gate on the letter once the document set actually carries the field.
|
||||
const delegationField = (setting?.fields ?? []).find(
|
||||
(f) => f.fileKey === POA_DELEGATION_FILE_KEY,
|
||||
);
|
||||
const missingDelegation =
|
||||
Boolean(delegationField) &&
|
||||
(poaRequired || poaProvided) &&
|
||||
!uploadedCodes.has(POA_DELEGATION_FILE_KEY);
|
||||
// An Ethiopian company does not type its PoA details at all — they arrive
|
||||
// from the Fayda verification — so reporting them as missing fields would
|
||||
// ask for something the form no longer offers. The identity block below
|
||||
// reports "verify your PoA" instead.
|
||||
const missingPoaFields =
|
||||
poaRequired && !identity.faydaRequired
|
||||
? REQUIRED_POA_FIELDS.filter(
|
||||
(f) => !(company.attributes?.[f.key] as string | undefined)?.trim(),
|
||||
)
|
||||
: [];
|
||||
const delegation = await this.getPoaDelegationState(company.id);
|
||||
const delegationDue = poaRequired || poaProvided;
|
||||
const missingDelegation = delegationDue && !delegation.onFile;
|
||||
// A paper the reviewer sent back is not evidence — the customer has to
|
||||
// replace it before the application counts as complete.
|
||||
const flaggedDelegation = delegationDue && delegation.flagged;
|
||||
|
||||
const outstanding = [
|
||||
...missingInfo.map((f) => `Add your ${f.label.toLowerCase()}`),
|
||||
@@ -1534,29 +1746,62 @@ export class CompaniesService {
|
||||
),
|
||||
...missingPoaFields.map((f) => `Add your ${f.label.toLowerCase()}`),
|
||||
...(missingDelegation
|
||||
? ["Upload the delegation letter for your Power of Attorney"]
|
||||
? [`Upload the ${POA_DELEGATION_LABEL} for your Power of Attorney`]
|
||||
: []),
|
||||
...(flaggedDelegation
|
||||
? [`Re-upload your ${POA_DELEGATION_LABEL} — EDR asked for a correction`]
|
||||
: []),
|
||||
...(identity.faydaRequired && !identity.owner.verified
|
||||
? ["Verify the company owner's identity with Fayda"]
|
||||
: []),
|
||||
...(identity.faydaRequired &&
|
||||
(poaRequired || poaProvided) &&
|
||||
!identity.poa.verified
|
||||
? ["Verify your Power of Attorney's identity with Fayda"]
|
||||
: []),
|
||||
...(identity.passportRequired && !identity.owner.passportNumber
|
||||
? ["Add the company owner's passport number"]
|
||||
: []),
|
||||
];
|
||||
|
||||
// Progress spans every required item the user has to satisfy: company-info
|
||||
// fields, required documents, one license per operational profile, and the
|
||||
// PoA details/letter whenever those are mandatory.
|
||||
// PoA details/paper whenever those are mandatory.
|
||||
const requiredDocCount = documents.filter((d) => d.isRequired).length;
|
||||
const poaItemCount =
|
||||
(poaRequired ? REQUIRED_POA_FIELDS.length : 0) +
|
||||
(delegationField && (poaRequired || poaProvided) ? 1 : 0);
|
||||
(poaRequired && !identity.faydaRequired
|
||||
? REQUIRED_POA_FIELDS.length
|
||||
: 0) + (delegationDue ? 1 : 0);
|
||||
// One item per identity credential the company has to prove: the owner
|
||||
// always (Fayda for Ethiopian, passport for foreign), the PoA once there
|
||||
// is one and Fayda is what's mandatory here.
|
||||
const identityItemCount = identity.faydaRequired
|
||||
? delegationDue
|
||||
? 2
|
||||
: 1
|
||||
: identity.passportRequired
|
||||
? 1
|
||||
: 0;
|
||||
const missingIdentityCount = identity.faydaRequired
|
||||
? (identity.owner.verified ? 0 : 1) +
|
||||
(delegationDue && !identity.poa.verified ? 1 : 0)
|
||||
: identity.passportRequired && !identity.owner.passportNumber
|
||||
? 1
|
||||
: 0;
|
||||
const total =
|
||||
this.REQUIRED_COMPANY_INFO.length +
|
||||
requiredInfo.length +
|
||||
requiredDocCount +
|
||||
licenseProfiles.length +
|
||||
poaItemCount;
|
||||
poaItemCount +
|
||||
identityItemCount;
|
||||
const completed =
|
||||
total -
|
||||
(missingInfo.length +
|
||||
missingDocs.length +
|
||||
missingLicenses.length +
|
||||
missingPoaFields.length +
|
||||
(missingDelegation ? 1 : 0));
|
||||
(missingDelegation || flaggedDelegation ? 1 : 0) +
|
||||
missingIdentityCount);
|
||||
|
||||
return new OnboardingRequirementsResponseDto({
|
||||
documentSettingCode,
|
||||
@@ -1567,10 +1812,15 @@ export class CompaniesService {
|
||||
poa: {
|
||||
required: poaRequired,
|
||||
provided: poaProvided,
|
||||
delegationLetterUploaded: uploadedCodes.has(POA_DELEGATION_FILE_KEY),
|
||||
delegationLetterUploaded: delegation.onFile,
|
||||
delegationLetterFlagged: delegation.flagged,
|
||||
missingFields: missingPoaFields,
|
||||
complete: missingPoaFields.length === 0 && !missingDelegation,
|
||||
complete:
|
||||
missingPoaFields.length === 0 &&
|
||||
!missingDelegation &&
|
||||
!flaggedDelegation,
|
||||
},
|
||||
identity,
|
||||
progress: { completed, total },
|
||||
isComplete: outstanding.length === 0,
|
||||
onboardingCompleted: profile.onboardingCompleted,
|
||||
@@ -2044,15 +2294,348 @@ export class CompaniesService {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Power of Attorney delegation letter
|
||||
// Power of Attorney delegation paper (DARS)
|
||||
//
|
||||
// A company-level document that follows the same staged-review model as the
|
||||
// business license: on an approved (Active) company an upload lands under the
|
||||
// pending code and the live letter is flagged for removal, so the reviewer
|
||||
// pending code and the live paper is flagged for removal, so the reviewer
|
||||
// sees both and approval swaps them atomically. During onboarding it goes live.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** The company's PoA letter(s), with each file's review status resolved. */
|
||||
/**
|
||||
* What the company has on file towards its DARS delegation paper. A paper
|
||||
* staged for review counts as "on file" — it is the customer's whole
|
||||
* obligation discharged; whether it is good enough is the reviewer's call,
|
||||
* recorded as `flagged`.
|
||||
*/
|
||||
private async getPoaDelegationState(
|
||||
companyId: string,
|
||||
ignoreFileIds: string[] = [],
|
||||
): Promise<{ onFile: boolean; flagged: boolean }> {
|
||||
const records = (
|
||||
await this.filesService.findByResource(companyId, COMPANY_RESOURCE)
|
||||
).filter(
|
||||
(r) =>
|
||||
(r.code === POA_DELEGATION_FILE_KEY ||
|
||||
r.code === POA_DELEGATION_PENDING_CODE) &&
|
||||
!ignoreFileIds.includes(r.id),
|
||||
);
|
||||
return {
|
||||
onFile: records.length > 0,
|
||||
flagged: records.some((r) => r.reviewStatus === "change_requested"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The rule behind EDRFREIGHT-358: a company that names a Power of Attorney
|
||||
* must evidence it with a DARS delegation paper, and a freight forwarder —
|
||||
* which signs on other companies' behalf — must have both, verified.
|
||||
*
|
||||
* This is enforced at every write that can break the pairing (PoA details
|
||||
* saved, paper removed, forwarder role applied for or approved) rather than
|
||||
* only at onboarding submission, which is what let a company that finished
|
||||
* onboarding as an importer pick up the forwarder role with neither.
|
||||
*
|
||||
* `attributes` is the state being written, which is not always the state on
|
||||
* the row yet — a staged change request carries it, and a removal has to be
|
||||
* judged against the files that would survive it (`ignoreFileIds`).
|
||||
*/
|
||||
private async assertPoaDelegationSatisfied(
|
||||
companyId: string,
|
||||
attributes: Record<string, unknown> | null | undefined,
|
||||
opts: { requirePoa: boolean; ignoreFileIds?: string[] },
|
||||
): Promise<void> {
|
||||
const read = (key: string) =>
|
||||
(attributes?.[key] as string | undefined)?.trim();
|
||||
const poaProvided = POA_ATTRIBUTES.some((k) => read(k));
|
||||
if (!opts.requirePoa && !poaProvided) return;
|
||||
|
||||
if (opts.requirePoa) {
|
||||
const missing = REQUIRED_POA_FIELDS.filter((f) => !read(f.key));
|
||||
if (missing.length > 0) {
|
||||
throw new BadRequestException(
|
||||
`A freight forwarder acts on other companies' behalf, so a Power of Attorney is required. ` +
|
||||
`Add the ${missing.map((f) => f.label.toLowerCase()).join(", ")} first.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const { onFile, flagged } = await this.getPoaDelegationState(
|
||||
companyId,
|
||||
opts.ignoreFileIds,
|
||||
);
|
||||
if (!onFile) {
|
||||
throw new BadRequestException(
|
||||
`Upload the ${POA_DELEGATION_LABEL} for the Power of Attorney` +
|
||||
(opts.requirePoa ? " — it is required for freight forwarders." : "."),
|
||||
);
|
||||
}
|
||||
if (flagged) {
|
||||
throw new BadRequestException(
|
||||
`The ${POA_DELEGATION_LABEL} on file needs to be corrected. ` +
|
||||
`Re-upload it before continuing.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Does this company operate as a freight forwarder? */
|
||||
private async isFreightForwarder(companyId: string): Promise<boolean> {
|
||||
const profiles = await this.companyProfilesRepo.findByCompanyId(companyId);
|
||||
return profiles.some((p) => p.type === ProfileType.freightForwarder);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fayda identity verification (owner / PoA)
|
||||
//
|
||||
// A completed VeriFayda verification proves a person's name, phone, email
|
||||
// and address — Fayda's userinfo carries no national ID number, so none of
|
||||
// that is collected here. For an Ethiopian company both the owner and its
|
||||
// PoA (once named) must be verified before the company can trade. Fayda is
|
||||
// an Ethiopian national ID system, so a foreign company's owner proves
|
||||
// identity with a typed passport number instead — required on its own
|
||||
// terms, not waived by an owner who happens to verify with Fayda too.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Verification state for both people, plus whether it is mandatory here.
|
||||
* `complete` answers the gate question directly so the portal, the onboarding
|
||||
* requirements and the assertions below all read the same verdict — the
|
||||
* derivation itself is shared with ProfileResponseDto.
|
||||
*/
|
||||
getCompanyIdentityState(company: Company): CompanyIdentityStateDto {
|
||||
return buildCompanyIdentityState(company);
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete a Fayda verification and bind the identity to one of the company's
|
||||
* people. The portal starts the flow through the shared
|
||||
* `POST /fayda/verification/start` and only tells us which person it was for
|
||||
* here, at completion — so the verifayda module stays generic and its session
|
||||
* table needs no company-specific column.
|
||||
*/
|
||||
async completeIdentityVerification(
|
||||
userId: string,
|
||||
dto: CompleteIdentityVerificationDto,
|
||||
): Promise<CompanyIdentityStateDto> {
|
||||
const { company } = await this.getCompanyInfoByUserId(userId);
|
||||
const prefix = IDENTITY_PREFIX[dto.subject];
|
||||
|
||||
const result = await this.verifaydaService.completeVerification({
|
||||
code: dto.code,
|
||||
state: dto.state,
|
||||
});
|
||||
if (!result.verified || !result.sub) {
|
||||
throw new BadRequestException(
|
||||
"Fayda could not verify this identity. Start the verification again.",
|
||||
);
|
||||
}
|
||||
|
||||
// The owner delegating power of attorney to themselves is not a
|
||||
// delegation — it would let one identity satisfy both halves of the check.
|
||||
const other: IdentitySubject = dto.subject === "poa" ? "owner" : "poa";
|
||||
const otherSub = company.attributes?.[`${IDENTITY_PREFIX[other]}FaydaSub`];
|
||||
if (otherSub && otherSub === result.sub) {
|
||||
throw new BadRequestException(
|
||||
`This identity is already registered as the company's ${IDENTITY_LABEL[other]}. The Power of Attorney must be a different person from the owner.`,
|
||||
);
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const identity: VerifiedIdentityAttributes = {
|
||||
[`${prefix}FaydaSub`]: result.sub,
|
||||
[`${prefix}FaydaVerifiedAt`]: now,
|
||||
[`${prefix}Birthdate`]: result.birthdate ?? null,
|
||||
[`${prefix}Gender`]: result.gender ?? null,
|
||||
// The verified payload owns the person's details from here on.
|
||||
...(result.fullName ? { [`${prefix}Name`]: result.fullName } : {}),
|
||||
...(result.email ? { [`${prefix}Email`]: result.email } : {}),
|
||||
...(result.phoneNumber ? { [`${prefix}Phone`]: result.phoneNumber } : {}),
|
||||
...(result.address ? { [`${prefix}Address`]: result.address } : {}),
|
||||
};
|
||||
|
||||
// An approved company's profile edits are staged for backoffice review, and
|
||||
// swapping the person who can act for the company is exactly the kind of
|
||||
// edit that review exists for — so a verification lands the same way an
|
||||
// ordinary edit does, rather than quietly rewriting a live record.
|
||||
if (company.status === CompanyStatus.Active) {
|
||||
await this.stageIdentityChange(company, userId, identity);
|
||||
return this.getCompanyIdentityState(company);
|
||||
}
|
||||
|
||||
const updated = await this.companiesRepo.update(company.id, {
|
||||
attributes: { ...(company.attributes ?? {}), ...identity },
|
||||
});
|
||||
if (!updated)
|
||||
throw new NotFoundException(`Company ${company.id} not found`);
|
||||
updated.companyProfiles = company.companyProfiles;
|
||||
return this.getCompanyIdentityState(updated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the Power of Attorney entirely — the verified identity, the details it
|
||||
* wrote and the delegation paper together.
|
||||
*
|
||||
* Only the PoA can go: a company always has an owner, and a freight forwarder
|
||||
* always has a representative. Once a PoA is Fayda-verified its
|
||||
* fields are locked, so blanking the form is no longer a way out — without
|
||||
* this the customer would be stuck with a representative they cannot remove.
|
||||
*/
|
||||
async removePoaIdentity(userId: string): Promise<CompanyIdentityStateDto> {
|
||||
const { company } = await this.getCompanyInfoByUserId(userId);
|
||||
if (
|
||||
(company.companyProfiles ?? []).some(
|
||||
(p) => p.type === ProfileType.freightForwarder,
|
||||
)
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
"A freight forwarder must have a Power of Attorney. Remove the freight forwarder role first.",
|
||||
);
|
||||
}
|
||||
|
||||
const cleared: Record<string, unknown> = {};
|
||||
for (const key of [
|
||||
...POA_ATTRIBUTES,
|
||||
"poaFaydaSub",
|
||||
"poaFaydaVerifiedAt",
|
||||
"poaBirthdate",
|
||||
"poaGender",
|
||||
]) {
|
||||
cleared[key] = null;
|
||||
}
|
||||
const attributes = { ...(company.attributes ?? {}), ...cleared };
|
||||
|
||||
// The paper evidences a representative who no longer exists.
|
||||
const records = await this.filesService.findByResource(
|
||||
company.id,
|
||||
COMPANY_RESOURCE,
|
||||
);
|
||||
for (const r of records) {
|
||||
if (
|
||||
r.code === POA_DELEGATION_FILE_KEY ||
|
||||
r.code === POA_DELEGATION_PENDING_CODE
|
||||
) {
|
||||
await this.filesService.remove(r.id);
|
||||
await this.withdrawDocumentIntent(company.id, r.id);
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await this.companiesRepo.update(company.id, { attributes });
|
||||
if (!updated)
|
||||
throw new NotFoundException(`Company ${company.id} not found`);
|
||||
updated.companyProfiles = company.companyProfiles;
|
||||
return this.getCompanyIdentityState(updated);
|
||||
}
|
||||
|
||||
/** Stage a verified identity onto the company's pending change request. */
|
||||
private async stageIdentityChange(
|
||||
company: Company,
|
||||
userId: string,
|
||||
identity: VerifiedIdentityAttributes,
|
||||
): Promise<void> {
|
||||
const existing = await this.changeRequestRepo.findPendingByCompanyId(
|
||||
company.id,
|
||||
);
|
||||
const now = new Date();
|
||||
const snapshot = {
|
||||
...(existing?.snapshot ?? {}),
|
||||
faydaIdentity: {
|
||||
...(((existing?.snapshot ?? {}) as Record<string, any>)
|
||||
.faydaIdentity ?? {}),
|
||||
...identity,
|
||||
},
|
||||
};
|
||||
if (existing) {
|
||||
await this.changeRequestRepo.update(existing.id, {
|
||||
snapshot,
|
||||
submittedBy: userId,
|
||||
submittedAt: now,
|
||||
note: null,
|
||||
});
|
||||
this.companyNotifier.changeRequestSubmitted(company, existing.id, false);
|
||||
return;
|
||||
}
|
||||
const history = await this.changeRequestRepo.findByCompanyId(company.id);
|
||||
const resubmitted = history.some(
|
||||
(r) => r.status === ChangeRequestStatus.Rejected,
|
||||
);
|
||||
const request = await this.changeRequestRepo.create({
|
||||
companyId: company.id,
|
||||
snapshot,
|
||||
status: ChangeRequestStatus.Pending,
|
||||
submittedBy: userId,
|
||||
submittedAt: now,
|
||||
});
|
||||
this.companyNotifier.changeRequestSubmitted(
|
||||
company,
|
||||
request.id,
|
||||
resubmitted,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The gate: an Ethiopian company's owner must be Fayda-verified, and so must
|
||||
* its Power of Attorney once it has one; a foreign company's owner must carry
|
||||
* a passport number instead. Called from the same places as
|
||||
* `assertPoaDelegationSatisfied` — the two rules describe the same moment
|
||||
* (who may act for this company, and on what evidence) and drifting them
|
||||
* apart is how one of them ends up unenforced.
|
||||
*/
|
||||
private assertIdentityVerified(
|
||||
company: Company,
|
||||
opts: { requirePoa: boolean },
|
||||
): void {
|
||||
const state = buildCompanyIdentityState(company);
|
||||
|
||||
if (state.passportRequired) {
|
||||
if (!state.owner.passportNumber) {
|
||||
throw new BadRequestException(
|
||||
"Add the company owner's passport number before continuing.",
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!state.owner.verified) {
|
||||
throw new BadRequestException(
|
||||
"Verify the company owner's identity with Fayda before continuing.",
|
||||
);
|
||||
}
|
||||
|
||||
const poaNamed = POA_ATTRIBUTES.some((k) =>
|
||||
(company.attributes?.[k] as string | undefined)?.trim(),
|
||||
);
|
||||
if (!opts.requirePoa && !poaNamed) return;
|
||||
|
||||
if (!state.poa.verified) {
|
||||
throw new BadRequestException(
|
||||
opts.requirePoa
|
||||
? "Verify your Power of Attorney with Fayda — a freight forwarder cannot operate without one."
|
||||
: "Verify the Power of Attorney you named with Fayda, or remove the representative.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The PoA details the company is heading for: its live attributes with any
|
||||
* pending change-request snapshot laid over them. An Active company's edits
|
||||
* are staged rather than written, so the live row on its own would judge the
|
||||
* customer against details they have already asked to change.
|
||||
*/
|
||||
private async effectivePoaAttributes(
|
||||
company: Company,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const pending = await this.changeRequestRepo.findPendingByCompanyId(
|
||||
company.id,
|
||||
);
|
||||
const snapshot = (pending?.snapshot ?? {}) as Record<string, unknown>;
|
||||
const staged: Record<string, unknown> = {};
|
||||
for (const key of POA_ATTRIBUTES) {
|
||||
if (key in snapshot) staged[key] = snapshot[key];
|
||||
}
|
||||
return { ...(company.attributes ?? {}), ...staged };
|
||||
}
|
||||
|
||||
/** The company's PoA paper(s), with each file's review status resolved. */
|
||||
async listPoaDelegationFiles(
|
||||
userId: string,
|
||||
): Promise<CompanyDocumentFileView[]> {
|
||||
@@ -2149,6 +2732,18 @@ export class CompaniesService {
|
||||
throw new NotFoundException(`Delegation letter ${fileId} not found`);
|
||||
}
|
||||
|
||||
// Taking the paper away is the other half of the pairing: allowed only once
|
||||
// the representative it evidences is gone too (which, for an Active
|
||||
// company, means the clearing edit is already staged).
|
||||
await this.assertPoaDelegationSatisfied(
|
||||
company.id,
|
||||
await this.effectivePoaAttributes(company),
|
||||
{
|
||||
requirePoa: await this.isFreightForwarder(company.id),
|
||||
ignoreFileIds: [fileId],
|
||||
},
|
||||
);
|
||||
|
||||
if (record.code === POA_DELEGATION_PENDING_CODE) {
|
||||
await this.filesService.remove(fileId);
|
||||
await this.withdrawDocumentIntent(company.id, fileId);
|
||||
@@ -2328,7 +2923,10 @@ export class CompaniesService {
|
||||
return match?.id ?? null;
|
||||
}
|
||||
|
||||
async fetchETradeData(tin: string) {
|
||||
/** Resolve a TIN's live eTrade registration data. Throws when eTrade has no matching business licence. */
|
||||
private async resolveEtradeRegistration(
|
||||
tin: string,
|
||||
): Promise<CompanyRegistrationData> {
|
||||
const { businessInfo, companyInfo } =
|
||||
await this.etradeService.resolveCompanyData(tin);
|
||||
if (!businessInfo) {
|
||||
@@ -2336,11 +2934,71 @@ export class CompaniesService {
|
||||
"We couldn't find a business license for this TIN with eTrade. Please double-check the number and try again.",
|
||||
);
|
||||
}
|
||||
const registrationData = this.etradeService.extractRegistrationData(
|
||||
businessInfo,
|
||||
companyInfo,
|
||||
return this.etradeService.extractRegistrationData(businessInfo, companyInfo);
|
||||
}
|
||||
|
||||
async fetchETradeData(tin: string, excludeCompanyId?: string) {
|
||||
const registrationData = await this.resolveEtradeRegistration(tin);
|
||||
const tinTaken = await this.companiesRepo.existsByTin(
|
||||
tin,
|
||||
excludeCompanyId,
|
||||
);
|
||||
const tinTaken = await this.companiesRepo.existsByTin(tin);
|
||||
return { ...registrationData, tinTaken };
|
||||
}
|
||||
|
||||
/**
|
||||
* An eTrade-sourced field can only ever hold what a fresh eTrade lookup for
|
||||
* this TIN actually returns — the portal never lets the customer type these
|
||||
* once eTrade has supplied them, so a mismatch here means either stale
|
||||
* client state or a hand-crafted request, and either way the write is
|
||||
* refused rather than silently trusting it.
|
||||
*/
|
||||
private async assertEtradeFieldsAuthentic(
|
||||
company: Company,
|
||||
dto: UpdateProfileDto,
|
||||
): Promise<void> {
|
||||
const touched = ETRADE_SOURCED_FIELDS.some(
|
||||
(key) => dto[key] !== undefined,
|
||||
);
|
||||
if (!touched) return;
|
||||
|
||||
const tin = dto.tin ?? company.tin;
|
||||
const registration = await this.resolveEtradeRegistration(tin);
|
||||
const expected: Partial<Record<(typeof ETRADE_SOURCED_FIELDS)[number], string>> = {
|
||||
companyName: registration.companyName,
|
||||
licenceNumber: registration.licenceNumber,
|
||||
statusDescription: registration.statusDescription,
|
||||
dateRegistered: registration.dateRegistered,
|
||||
renewedFrom: registration.renewedFrom,
|
||||
renewalDate: registration.renewalDate,
|
||||
renewedTo: registration.renewedTo,
|
||||
region: registration.region,
|
||||
zone: registration.zone,
|
||||
woreda: registration.woreda,
|
||||
kebele: registration.kebele,
|
||||
houseNo: registration.houseNo,
|
||||
etradePhone:
|
||||
registration.managerPhone ||
|
||||
registration.regularPhone ||
|
||||
registration.mobilePhone,
|
||||
};
|
||||
|
||||
for (const key of ETRADE_SOURCED_FIELDS) {
|
||||
const submitted = dto[key];
|
||||
if (submitted === undefined) continue;
|
||||
const source = expected[key];
|
||||
// eTrade left this field blank — the onboarding/settings card falls back
|
||||
// to letting the customer type it directly, so nothing to check against.
|
||||
if (!source) continue;
|
||||
const same =
|
||||
key === "etradePhone"
|
||||
? normalizeE164(String(submitted)) === normalizeE164(source)
|
||||
: submitted === source;
|
||||
if (!same) {
|
||||
throw new BadRequestException(
|
||||
`${key} doesn't match eTrade's current record for this TIN. Re-verify with eTrade to pick up the latest details.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsIn, IsString, IsNotEmpty } from "class-validator";
|
||||
|
||||
import { Company, CompanyNationality } from "../entities/company.entity";
|
||||
import { ProfileType } from "../entities/company-profile.entity";
|
||||
|
||||
/**
|
||||
* The two people a company is verified through — its owner and its Power of
|
||||
* Attorney. "Owner" is not the same as the General Manager: a company's GM is
|
||||
* a plain typed role (with a "same as owner" copy the portal offers), while
|
||||
* the owner is the person this verification proves. They're very often the
|
||||
* same human, which is exactly what the copy is for.
|
||||
*/
|
||||
export const IDENTITY_SUBJECTS = ["owner", "poa"] as const;
|
||||
export type IdentitySubject = (typeof IDENTITY_SUBJECTS)[number];
|
||||
|
||||
export class CompleteIdentityVerificationDto {
|
||||
@ApiProperty({
|
||||
enum: IDENTITY_SUBJECTS,
|
||||
description: "Which of the company's people this verification is for.",
|
||||
})
|
||||
@IsIn(IDENTITY_SUBJECTS)
|
||||
subject!: IdentitySubject;
|
||||
|
||||
@ApiProperty({ description: "Authorization code from the Fayda redirect." })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
code!: string;
|
||||
|
||||
@ApiProperty({ description: "CSRF state from the Fayda redirect." })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
state!: string;
|
||||
}
|
||||
|
||||
/** One person's verification state, as reported back to the portal. */
|
||||
export class IdentityVerificationStateDto {
|
||||
@ApiProperty() verified!: boolean;
|
||||
@ApiProperty({ nullable: true }) name!: string | null;
|
||||
@ApiProperty({ nullable: true }) phone!: string | null;
|
||||
@ApiProperty({ nullable: true }) email!: string | null;
|
||||
@ApiProperty({ nullable: true }) address!: string | null;
|
||||
@ApiProperty({ nullable: true }) verifiedAt!: string | null;
|
||||
@ApiProperty({ nullable: true }) birthdate!: string | null;
|
||||
@ApiProperty({ nullable: true }) gender!: string | null;
|
||||
}
|
||||
|
||||
export class OwnerIdentityStateDto extends IdentityVerificationStateDto {
|
||||
@ApiProperty({
|
||||
nullable: true,
|
||||
description:
|
||||
"Typed passport number — the foreign-company identity credential. Independent of Fayda: never written by a verification, and still required even if the owner also verifies.",
|
||||
})
|
||||
passportNumber!: string | null;
|
||||
}
|
||||
|
||||
export class CompanyIdentityStateDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
"True when Fayda verification of the owner (and PoA, once named) is mandatory — Ethiopian companies only.",
|
||||
})
|
||||
faydaRequired!: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
"True when the owner's passport number is mandatory — foreign companies only. Independent of faydaRequired: a foreign owner may verify with Fayda too, but the passport is still required.",
|
||||
})
|
||||
passportRequired!: boolean;
|
||||
|
||||
@ApiProperty({ type: OwnerIdentityStateDto })
|
||||
owner!: OwnerIdentityStateDto;
|
||||
|
||||
@ApiProperty({ type: IdentityVerificationStateDto })
|
||||
poa!: IdentityVerificationStateDto;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
"False while a mandatory requirement (Fayda for Ethiopian, passport for foreign) is still outstanding.",
|
||||
})
|
||||
complete!: boolean;
|
||||
}
|
||||
|
||||
/** `attributes` key prefix per person. */
|
||||
const PREFIX: Record<IdentitySubject, "owner" | "poa"> = {
|
||||
owner: "owner",
|
||||
poa: "poa",
|
||||
};
|
||||
|
||||
/** company.attributes keys that together mean "a PoA was entered". */
|
||||
const POA_KEYS = [
|
||||
"poaName",
|
||||
"poaPhone",
|
||||
"poaEmail",
|
||||
"poaLocation",
|
||||
"poaAddress",
|
||||
] as const;
|
||||
|
||||
function stateFor(
|
||||
attrs: Record<string, unknown>,
|
||||
subject: IdentitySubject,
|
||||
): IdentityVerificationStateDto {
|
||||
const p = PREFIX[subject];
|
||||
const read = (key: string) => (attrs[key] as string | undefined) ?? null;
|
||||
return {
|
||||
verified: Boolean(read(`${p}FaydaSub`)),
|
||||
name: read(`${p}Name`),
|
||||
phone: read(`${p}Phone`),
|
||||
email: read(`${p}Email`),
|
||||
address: read(`${p}Address`),
|
||||
verifiedAt: read(`${p}FaydaVerifiedAt`),
|
||||
birthdate: read(`${p}Birthdate`),
|
||||
gender: read(`${p}Gender`),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive both people's verification state from the company row.
|
||||
*
|
||||
* Pure and shared: `CompaniesService` gates on it and `ProfileResponseDto`
|
||||
* renders from it, so the settings page and the onboarding wizard can never
|
||||
* disagree with the rule the API actually enforces.
|
||||
*/
|
||||
export function buildCompanyIdentityState(
|
||||
company: Company,
|
||||
): CompanyIdentityStateDto {
|
||||
const attrs = company.attributes ?? {};
|
||||
const read = (key: string) => (attrs[key] as string | undefined) ?? null;
|
||||
|
||||
// Fayda is an Ethiopian national ID — a foreign company's owner may not hold
|
||||
// one, so a typed passport number is the mandatory credential there instead.
|
||||
// The two are mutually exclusive by nationality but independently tracked,
|
||||
// since a foreign owner verifying with Fayda doesn't waive the passport.
|
||||
const foreign = company.nationality === CompanyNationality.Foreign;
|
||||
const faydaRequired = !foreign;
|
||||
const passportRequired = foreign;
|
||||
|
||||
const owner: OwnerIdentityStateDto = {
|
||||
...stateFor(attrs, "owner"),
|
||||
passportNumber: read("ownerPassportNumber"),
|
||||
};
|
||||
const poa = stateFor(attrs, "poa");
|
||||
const poaDue =
|
||||
(company.companyProfiles ?? []).some(
|
||||
(p) => p.type === ProfileType.freightForwarder,
|
||||
) || POA_KEYS.some((k) => (attrs[k] as string | undefined)?.trim());
|
||||
|
||||
const complete = faydaRequired
|
||||
? owner.verified && (!poaDue || poa.verified)
|
||||
: !passportRequired || Boolean(owner.passportNumber);
|
||||
|
||||
return { faydaRequired, passportRequired, owner, poa, complete };
|
||||
}
|
||||
@@ -8,6 +8,8 @@
|
||||
* truth the wizard uses to auto-finish.
|
||||
*/
|
||||
|
||||
import { CompanyIdentityStateDto } from "./complete-identity-verification.dto";
|
||||
|
||||
export interface OnboardingInfoField {
|
||||
key: string;
|
||||
label: string;
|
||||
@@ -40,11 +42,13 @@ export interface OnboardingPoaState {
|
||||
required: boolean;
|
||||
/** True once any PoA detail has been entered. */
|
||||
provided: boolean;
|
||||
/** True when the delegation letter is stored for the company. */
|
||||
/** True when the DARS delegation paper is stored for the company. */
|
||||
delegationLetterUploaded: boolean;
|
||||
/** True when a reviewer sent the paper back for correction. */
|
||||
delegationLetterFlagged: boolean;
|
||||
/** PoA details still missing (only populated when `required`). */
|
||||
missingFields: OnboardingInfoField[];
|
||||
/** False while the PoA step still owes details or a delegation letter. */
|
||||
/** False while the PoA step still owes details or an uncorrected paper. */
|
||||
complete: boolean;
|
||||
}
|
||||
|
||||
@@ -68,6 +72,13 @@ export class OnboardingRequirementsResponseDto {
|
||||
/** Power of Attorney state, so the wizard needn't re-derive the rule. */
|
||||
poa: OnboardingPoaState;
|
||||
|
||||
/**
|
||||
* Fayda verification state for the company's people. `required` is false for
|
||||
* a foreign company, which is never gated on it — the portal renders the
|
||||
* typed personnel forms in that case and the verify panels otherwise.
|
||||
*/
|
||||
identity: CompanyIdentityStateDto;
|
||||
|
||||
/** Overall setup progress across fields + documents + licenses. */
|
||||
progress: { completed: number; total: number };
|
||||
|
||||
@@ -87,6 +98,7 @@ export class OnboardingRequirementsResponseDto {
|
||||
this.documents = init.documents;
|
||||
this.licenseProfiles = init.licenseProfiles;
|
||||
this.poa = init.poa;
|
||||
this.identity = init.identity;
|
||||
this.progress = init.progress;
|
||||
this.isComplete = init.isComplete;
|
||||
this.onboardingCompleted = init.onboardingCompleted;
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
buildCompanyIdentityState,
|
||||
CompanyIdentityStateDto,
|
||||
} from "./complete-identity-verification.dto";
|
||||
import { Company } from '../entities/company.entity';
|
||||
import { ExternalProfile } from '../entities/external-profile.entity';
|
||||
import {
|
||||
@@ -52,6 +56,16 @@ export class ProfileResponseDto {
|
||||
|
||||
profileId: string;
|
||||
|
||||
/**
|
||||
* Fayda verification state for the company's owner and PoA — not the general
|
||||
* manager, which is a separate typed role. The settings tabs and the
|
||||
* onboarding wizard render from `identity.faydaRequired` /
|
||||
* `identity.passportRequired`: an Ethiopian company verifies the owner (and
|
||||
* PoA) instead of typing their details; a foreign one requires a typed
|
||||
* passport number instead.
|
||||
*/
|
||||
identity: CompanyIdentityStateDto;
|
||||
|
||||
/**
|
||||
* Open profile-edit review, if any. `reviewStatus === "pending"` locks the
|
||||
* settings page; `"rejected"` surfaces the note and prefills the (declined)
|
||||
@@ -124,5 +138,6 @@ export class ProfileResponseDto {
|
||||
: null;
|
||||
this.reviewNote = openReview?.note ?? null;
|
||||
this.pendingChanges = openReview?.snapshot ?? null;
|
||||
this.identity = buildCompanyIdentityState(company);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,10 @@ import {
|
||||
ProfileLicenseFileView,
|
||||
} from '../entities/company-profile.entity';
|
||||
import { ResponseExternalProfileDto } from './response-external-profile.dto';
|
||||
import {
|
||||
buildCompanyIdentityState,
|
||||
CompanyIdentityStateDto,
|
||||
} from './complete-identity-verification.dto';
|
||||
|
||||
export class ResponseCompanyProfileDto {
|
||||
id: string;
|
||||
@@ -69,6 +73,28 @@ export class ResponseCompanyDto {
|
||||
* external profiles weren't loaded.
|
||||
*/
|
||||
onboardingCompleted?: boolean;
|
||||
|
||||
// eTrade-sourced registration record — populated by the onboarding TIN
|
||||
// lookup, locked/read-only on the portal from the moment it's fetched.
|
||||
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;
|
||||
|
||||
/**
|
||||
* Owner/PoA Fayda verification state, shared with the portal
|
||||
* (`buildCompanyIdentityState`) so backoffice never re-derives — or
|
||||
* disagrees with — the rule the API actually enforces.
|
||||
*/
|
||||
identity: CompanyIdentityStateDto;
|
||||
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
|
||||
@@ -95,6 +121,18 @@ export class ResponseCompanyDto {
|
||||
? company.profiles.length === 0 ||
|
||||
company.profiles.some((p) => p.onboardingCompleted)
|
||||
: undefined;
|
||||
this.licenceNumber = company.licenceNumber;
|
||||
this.statusDescription = company.statusDescription;
|
||||
this.dateRegistered = company.dateRegistered;
|
||||
this.renewedFrom = company.renewedFrom;
|
||||
this.renewalDate = company.renewalDate;
|
||||
this.renewedTo = company.renewedTo;
|
||||
this.region = company.region;
|
||||
this.zone = company.zone;
|
||||
this.woreda = company.woreda;
|
||||
this.kebele = company.kebele;
|
||||
this.houseNo = company.houseNo;
|
||||
this.identity = buildCompanyIdentityState(company);
|
||||
this.createdAt = company.createdAt;
|
||||
this.updatedAt = company.updatedAt;
|
||||
}
|
||||
|
||||
@@ -44,10 +44,11 @@ export class UpdateProfileDto {
|
||||
@MaxLength(50)
|
||||
vatNumber?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(16)
|
||||
fanNumber?: string;
|
||||
// `fanNumber` is deliberately absent: the FAN is the Fayda number of the
|
||||
// company's PoA (or its general manager), so it is derived from a completed
|
||||
// Fayda verification rather than typed. The global validation pipe runs with
|
||||
// forbidNonWhitelisted, so a client that still sends it gets a 400 telling it
|
||||
// so — see CompaniesService.completeIdentityVerification.
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@@ -110,6 +111,16 @@ export class UpdateProfileDto {
|
||||
@IsString()
|
||||
poaAddress?: string;
|
||||
|
||||
/**
|
||||
* The owner's passport number — the identity credential for a foreign
|
||||
* company, since Fayda is an Ethiopian national ID. Plain typed field, never
|
||||
* written or locked by a Fayda verification: still required even if the
|
||||
* owner also verifies.
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
ownerPassportNumber?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
|
||||
@@ -15,6 +15,11 @@ import {
|
||||
FILE_UPLOAD_SETTINGS_REPOSITORY,
|
||||
IFileUploadSettingsRepository,
|
||||
} from "./interfaces/file-upload-settings.repository.interface";
|
||||
import {
|
||||
COMPANY_ONBOARDING_CODE_PREFIX,
|
||||
POA_DELEGATION_FILE_KEY,
|
||||
poaDelegationField,
|
||||
} from "./poa-delegation.constants";
|
||||
|
||||
@Injectable()
|
||||
export class FileUploadSettingsService {
|
||||
@@ -40,6 +45,22 @@ export class FileUploadSettingsService {
|
||||
async getByCode(code: string): Promise<FileUploadSetting> {
|
||||
const setting = await this.repository.findByCode(code);
|
||||
if (!setting) throw new NotFoundException(`Setting "${code}" not found`);
|
||||
return this.withPoaDelegationField(setting);
|
||||
}
|
||||
|
||||
/**
|
||||
* Company onboarding sets always carry the DARS delegation paper, whether or
|
||||
* not anyone configured a row for it — see poa-delegation.constants.ts. Every
|
||||
* consumer (the portal's PoA step, the onboarding gate) reads the set through
|
||||
* here, so this is the single place the field can be guaranteed.
|
||||
*/
|
||||
private withPoaDelegationField(setting: FileUploadSetting): FileUploadSetting {
|
||||
if (!setting.code.startsWith(COMPANY_ONBOARDING_CODE_PREFIX)) return setting;
|
||||
const fields = setting.fields ?? [];
|
||||
if (fields.some((f) => f.fileKey === POA_DELEGATION_FILE_KEY)) return setting;
|
||||
|
||||
const lastOrder = fields.reduce((max, f) => Math.max(max, f.displayOrder), 0);
|
||||
setting.fields = [...fields, poaDelegationField(lastOrder + 1)];
|
||||
return setting;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { FileUploadField } from "./entities/file-upload-field.entity";
|
||||
|
||||
/**
|
||||
* The DARS delegation paper — the document that evidences a company's Power of
|
||||
* Attorney (EDRFREIGHT-358).
|
||||
*
|
||||
* Every other onboarding document is admin-managed: the rows in
|
||||
* `file_upload_fields` are edited from the backoffice file-settings editor and
|
||||
* the seeder deliberately inserts none. This one is different — a company that
|
||||
* names a PoA must produce a delegation paper authenticated by the Documents
|
||||
* Authentication and Registration Service, and that is a legal requirement
|
||||
* rather than a configuration choice. So the field is defined here in code and
|
||||
* injected into the company onboarding sets on read: no row to forget to seed,
|
||||
* and deleting one in the editor cannot silently switch the requirement off.
|
||||
*/
|
||||
|
||||
/** FileRecord `code` (and upload field key) of the live delegation paper. */
|
||||
export const POA_DELEGATION_FILE_KEY = "poa_delegation_letter";
|
||||
|
||||
/** Code for a delegation paper staged in an open change request (not yet live). */
|
||||
export const POA_DELEGATION_PENDING_CODE = "poa_delegation_letter_pending";
|
||||
|
||||
/** Customer-facing name of the document, used by the API and both web apps. */
|
||||
export const POA_DELEGATION_LABEL = "DARS Delegation Paper";
|
||||
|
||||
/** Prefix of the setting codes the field is injected into. */
|
||||
export const COMPANY_ONBOARDING_CODE_PREFIX = "company_onboarding_documents_";
|
||||
|
||||
const POA_DELEGATION_HELP =
|
||||
"Delegation paper issued by the Documents Authentication and Registration " +
|
||||
"Service (DARS) delegating the representative named above. Upload the " +
|
||||
"authenticated copy — a plain letter is not accepted.";
|
||||
|
||||
/**
|
||||
* The field descriptor. `isRequired` stays false because the paper is only due
|
||||
* once a PoA has actually been named (or the company operates as a freight
|
||||
* forwarder) — a rule that spans form fields as well as files, so it is
|
||||
* enforced in CompaniesService rather than by this flag.
|
||||
*/
|
||||
export function poaDelegationField(displayOrder: number): FileUploadField {
|
||||
return {
|
||||
fileKey: POA_DELEGATION_FILE_KEY,
|
||||
fileLabel: POA_DELEGATION_LABEL,
|
||||
helpText: POA_DELEGATION_HELP,
|
||||
isRequired: false,
|
||||
isMultiple: false,
|
||||
maxFiles: 1,
|
||||
allowedExtensions: ["pdf", "jpg", "jpeg", "png"],
|
||||
maxSizeMb: 10,
|
||||
displayOrder,
|
||||
} as FileUploadField;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsArray, IsDateString, IsOptional, IsUUID, ValidateNested } from 'class-validator';
|
||||
|
||||
/**
|
||||
* One truck's detention window. Each truck reaches the destination and is
|
||||
* released at its own time, so detention days differ between trucks on the
|
||||
* same delivery. Null clears the value (falls back to the leg-level pair).
|
||||
*/
|
||||
export class TruckDetentionTimeInput {
|
||||
@IsUUID()
|
||||
vehicleId!: string;
|
||||
|
||||
/** Detention clock start — this truck reached the destination. */
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
destinationArrivedAt?: string | null;
|
||||
|
||||
/** Detention clock end — this truck was released/returned. Omit = still out. */
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
returnedAt?: string | null;
|
||||
}
|
||||
|
||||
export class SetDetentionTimesDto {
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => TruckDetentionTimeInput)
|
||||
trucks!: TruckDetentionTimeInput[];
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsArray, IsDateString, IsOptional, IsUUID, ValidateNested } from 'class-validator';
|
||||
|
||||
export class TruckWarehouseGateTimeInput {
|
||||
@IsUUID()
|
||||
vehicleId!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
arrivedAt?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
departedAt?: string | null;
|
||||
}
|
||||
|
||||
export class SetWarehouseGateTimesDto {
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => TruckWarehouseGateTimeInput)
|
||||
trucks!: TruckWarehouseGateTimeInput[];
|
||||
}
|
||||
@@ -51,6 +51,21 @@ export class LastMileVehicleAssignment extends BaseEntity {
|
||||
@Column({ name: 'departed_at', type: 'timestamptz', nullable: true })
|
||||
departedAt?: Date | null;
|
||||
|
||||
/**
|
||||
* Detention clock START for THIS truck: reached the delivery destination.
|
||||
* Distinct from `arrivedAt` (warehouse gate-in). Null falls back to the
|
||||
* leg-level `last_mile.arrived_at`.
|
||||
*/
|
||||
@Column({ name: 'destination_arrived_at', type: 'timestamptz', nullable: true })
|
||||
destinationArrivedAt?: Date | null;
|
||||
|
||||
/**
|
||||
* Detention clock END for THIS truck: released / returned by the customer.
|
||||
* Null (with no leg-level `delivered_at`) means still out — detention accrues.
|
||||
*/
|
||||
@Column({ name: 'returned_at', type: 'timestamptz', nullable: true })
|
||||
returnedAt?: Date | null;
|
||||
|
||||
/** Weighed gross on exit, in TONNES (not kg — see the migration note). */
|
||||
@Column({ name: 'gross_weight_tons', type: 'numeric', precision: 14, scale: 3, nullable: true })
|
||||
grossWeightTons?: number | null;
|
||||
|
||||
@@ -23,6 +23,8 @@ import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { CreateLastMileDto } from './dto/create-last-mile.dto';
|
||||
import { UpdateLastMileDto } from './dto/update-last-mile.dto';
|
||||
import { SetVehiclesDto } from './dto/set-vehicles.dto';
|
||||
import { SetDetentionTimesDto } from './dto/set-detention-times.dto';
|
||||
import { SetWarehouseGateTimesDto } from './dto/set-warehouse-gate-times.dto';
|
||||
import { SetDistancesDto } from './dto/set-distances.dto';
|
||||
import { RecordProofOfDeliveryDto } from './dto/record-proof-of-delivery.dto';
|
||||
import { LastMileStatus } from './entities/last-mile.entity';
|
||||
@@ -131,6 +133,30 @@ export class LastMileController {
|
||||
return this.lastMileService.setDistances(id, dto.distances, dto.remainingPayment);
|
||||
}
|
||||
|
||||
@Post(':id/detention-times')
|
||||
@BookingStaff(FREIGHT_PERMS.lastMile.update)
|
||||
@ApiOperation({
|
||||
summary: 'Set each truck\'s own detention window (arrived at destination / returned)',
|
||||
})
|
||||
async setDetentionTimes(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: SetDetentionTimesDto,
|
||||
) {
|
||||
return this.lastMileService.setDetentionTimes(id, dto.trucks);
|
||||
}
|
||||
|
||||
@Post(':id/warehouse-gate-times')
|
||||
@BookingStaff(FREIGHT_PERMS.lastMile.update)
|
||||
@ApiOperation({
|
||||
summary: 'Set each truck\'s warehouse gate arrival/departure times',
|
||||
})
|
||||
async setWarehouseGateTimes(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: SetWarehouseGateTimesDto,
|
||||
) {
|
||||
return this.lastMileService.setWarehouseGateTimes(id, dto.trucks);
|
||||
}
|
||||
|
||||
@Post(':id/proof-of-delivery')
|
||||
@BookingStaff(FREIGHT_PERMS.lastMile.update)
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
|
||||
@@ -327,6 +327,8 @@ export class LastMileService {
|
||||
*/
|
||||
async arrivalTrucksForBooking(bookingId: string): Promise<
|
||||
Array<{
|
||||
/** The last-mile leg this truck belongs to — lets a caller chain straight into truck-detention-preview without a separate lookup. */
|
||||
lastMileId: string;
|
||||
vehicleId: string;
|
||||
truckPlateNumber: string | null;
|
||||
trailerPlateNumber: string | null;
|
||||
@@ -359,6 +361,7 @@ export class LastMileService {
|
||||
: [];
|
||||
|
||||
const out: Array<{
|
||||
lastMileId: string;
|
||||
vehicleId: string;
|
||||
truckPlateNumber: string | null;
|
||||
trailerPlateNumber: string | null;
|
||||
@@ -386,6 +389,7 @@ export class LastMileService {
|
||||
}
|
||||
}
|
||||
out.push({
|
||||
lastMileId: lm.id,
|
||||
vehicleId: vehicle.id,
|
||||
truckPlateNumber: vehicle.powerPlateNo || vehicle.plateNumber || null,
|
||||
trailerPlateNumber: vehicle.trailerPlateNo || null,
|
||||
@@ -869,6 +873,81 @@ export class LastMileService {
|
||||
* sum and drives billing; `remainingPayment` (total km × rate) is recomputed
|
||||
* client-side. Does NOT generate an invoice — that's a separate explicit step.
|
||||
*/
|
||||
/**
|
||||
* Per-truck detention windows. Each truck reaches the destination and is
|
||||
* released at its own time, so every truck gets its own clock (and therefore
|
||||
* its own chargeable days). Locked once the detention invoice exists.
|
||||
*/
|
||||
async setDetentionTimes(
|
||||
id: string,
|
||||
trucks: Array<{
|
||||
vehicleId: string;
|
||||
destinationArrivedAt?: string | null;
|
||||
returnedAt?: string | null;
|
||||
}>,
|
||||
): Promise<LastMile> {
|
||||
await this.findById(id);
|
||||
|
||||
const invoices = await this.billing.findBySourceIds('last_mile', [id]);
|
||||
if (invoices.length) {
|
||||
throw new BadRequestException(
|
||||
'Detention times cannot be changed after the invoice is generated',
|
||||
);
|
||||
}
|
||||
|
||||
for (const t of trucks) {
|
||||
const start = t.destinationArrivedAt ? new Date(t.destinationArrivedAt) : null;
|
||||
const end = t.returnedAt ? new Date(t.returnedAt) : null;
|
||||
if (start && end && end.getTime() < start.getTime()) {
|
||||
throw new BadRequestException(
|
||||
'A truck cannot be returned before it arrived — check the detention times',
|
||||
);
|
||||
}
|
||||
await this.dataSource.manager.update(
|
||||
LastMileVehicleAssignment,
|
||||
{ lastMileId: id, vehicleId: t.vehicleId },
|
||||
{ destinationArrivedAt: start, returnedAt: end },
|
||||
);
|
||||
}
|
||||
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
async setWarehouseGateTimes(
|
||||
id: string,
|
||||
trucks: Array<{
|
||||
vehicleId: string;
|
||||
arrivedAt?: string | null;
|
||||
departedAt?: string | null;
|
||||
}>,
|
||||
): Promise<LastMile> {
|
||||
await this.findById(id);
|
||||
|
||||
const invoices = await this.billing.findBySourceIds('last_mile', [id]);
|
||||
if (invoices.length) {
|
||||
throw new BadRequestException(
|
||||
'Warehouse gate times cannot be changed after the invoice is generated',
|
||||
);
|
||||
}
|
||||
|
||||
for (const t of trucks) {
|
||||
const arrived = t.arrivedAt ? new Date(t.arrivedAt) : null;
|
||||
const departed = t.departedAt ? new Date(t.departedAt) : null;
|
||||
if (arrived && departed && departed.getTime() < arrived.getTime()) {
|
||||
throw new BadRequestException(
|
||||
'A truck cannot depart before it arrived — check the warehouse gate times',
|
||||
);
|
||||
}
|
||||
await this.dataSource.manager.update(
|
||||
LastMileVehicleAssignment,
|
||||
{ lastMileId: id, vehicleId: t.vehicleId },
|
||||
{ arrivedAt: arrived, departedAt: departed },
|
||||
);
|
||||
}
|
||||
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
async setDistances(
|
||||
id: string,
|
||||
distances: Array<{ vehicleId: string; distanceKm: number }>,
|
||||
|
||||
@@ -39,7 +39,11 @@ export class TrainSchedulesRepository extends BaseRepository<TrainSchedule> {
|
||||
physicalWagon: true,
|
||||
allocations: {
|
||||
booking: { company: true, bookingContainers: { containerType: true } },
|
||||
containerItems: true,
|
||||
// Both size sources loaded: the item's own container_type_id FK
|
||||
// (always set for a manually-entered item) and the booking-line
|
||||
// fallback via bookingContainer.containerType — the marshalling
|
||||
// document's 40ft/20ft tally reads whichever is present.
|
||||
containerItems: { containerType: true, bookingContainer: { containerType: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -48,10 +48,12 @@ export class FacilityHandlingService {
|
||||
if (!facility?.hasFacility) return null;
|
||||
|
||||
const occurredAt = input.occurredAt ?? new Date();
|
||||
// Mapped to the goods owner, same as every warehouse-raised GRN.
|
||||
const grnNumber = generateGrnNumber(
|
||||
booking.tradeDirection ?? 'DOMESTIC',
|
||||
booking.id,
|
||||
occurredAt,
|
||||
booking.company?.name ?? null,
|
||||
);
|
||||
|
||||
// Link the storage record when this facility keeps cargo — that link is
|
||||
@@ -67,6 +69,21 @@ export class FacilityHandlingService {
|
||||
inventoryId = inv?.id ?? null;
|
||||
}
|
||||
|
||||
// The handed-over weight: the booking's declared VGM, else what its
|
||||
// containers actually carry. A GRN without a weight is not a receipt.
|
||||
let weightTons = Number(booking.cargoTotalWeightVgm) || null;
|
||||
if (!weightTons) {
|
||||
const [sum]: Array<{ tons: string | null }> = await manager.query(
|
||||
`SELECT SUM(bcu.vgm_tons) AS tons
|
||||
FROM freight.booking_container_units bcu
|
||||
JOIN freight.booking_container bc
|
||||
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
|
||||
WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL`,
|
||||
[booking.id],
|
||||
);
|
||||
weightTons = Number(sum?.tons) || null;
|
||||
}
|
||||
|
||||
const repo = manager.getRepository(FacilityHandlingEvent);
|
||||
await repo.save(
|
||||
repo.create({
|
||||
@@ -75,7 +92,7 @@ export class FacilityHandlingService {
|
||||
trainScheduleId: input.trainScheduleId ?? null,
|
||||
eventType,
|
||||
grnNumber,
|
||||
weightTons: Number(booking.cargoTotalWeightVgm) || null,
|
||||
weightTons,
|
||||
inventoryId,
|
||||
performedBy: input.performedBy ?? null,
|
||||
occurredAt,
|
||||
|
||||
@@ -2784,11 +2784,13 @@ export class TrainSchedulingService {
|
||||
allocations: (wagon.allocations ?? []).map((allocation) => ({
|
||||
bookingId: allocation.bookingId,
|
||||
bookingReference: allocation.booking?.reference ?? null,
|
||||
booking: allocation.booking,
|
||||
loadType: allocation.loadType ?? null,
|
||||
allocatedWeightTons: Number(allocation.allocatedWeightTons) || 0,
|
||||
containerNumbers: (allocation.containerItems ?? [])
|
||||
.map((item) => item.containerNumber)
|
||||
.filter(Boolean),
|
||||
containerItems: allocation.containerItems ?? [],
|
||||
})),
|
||||
})),
|
||||
operation: await this.getImportDjiboutiOperation(schedule.id),
|
||||
@@ -2829,6 +2831,32 @@ export class TrainSchedulingService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A container item's size in feet, for the marshalling document's 40ft/20ft
|
||||
* tally. Two independent sources, since only one is populated depending on
|
||||
* how the item was created:
|
||||
* - `item.containerType` — the item's own container_type_id FK, set for
|
||||
* manually-entered items (no booking-container line behind them).
|
||||
* - `item.bookingContainer.containerType.sizeFt` / `.containerSize` — the
|
||||
* booking-line fallback for items generated from an allocation.
|
||||
* (`findByIdWithFullGraph` must load both relations or every item here
|
||||
* silently resolves to null and the tally stays zero.)
|
||||
*/
|
||||
private resolveContainerItemSize(item: {
|
||||
containerType?: { sizeFt?: number | null } | null;
|
||||
bookingContainer?: {
|
||||
containerSize?: string | null;
|
||||
containerType?: { sizeFt?: number | null } | null;
|
||||
} | null;
|
||||
}): number | null {
|
||||
const fromSizeFt = item.containerType?.sizeFt ?? item.bookingContainer?.containerType?.sizeFt;
|
||||
if (fromSizeFt === 20 || fromSizeFt === 40) return fromSizeFt;
|
||||
const label = item.bookingContainer?.containerSize;
|
||||
if (label?.includes('40')) return 40;
|
||||
if (label?.includes('20')) return 20;
|
||||
return null;
|
||||
}
|
||||
|
||||
private buildExportLoadListHtml(schedule: TrainSchedule): string {
|
||||
const esc = (value: unknown) =>
|
||||
String(value ?? '-')
|
||||
@@ -2869,6 +2897,7 @@ export class TrainSchedulingService {
|
||||
return allocations.map((allocation) => {
|
||||
const booking = allocation.booking ?? bookingById.get(allocation.bookingId);
|
||||
const cargoType = (booking as unknown as { cargoType?: { name?: string; code?: string } } | undefined)?.cargoType;
|
||||
const companyName = (booking as unknown as { company?: { name?: string } } | undefined)?.company?.name ?? '-';
|
||||
const containerItems = allocation.containerItems ?? [];
|
||||
const firstContainer = containerItems[0];
|
||||
const containerNumbers = containerItems.map((item) => item.containerNumber).filter(Boolean).join(', ');
|
||||
@@ -2877,6 +2906,7 @@ export class TrainSchedulingService {
|
||||
return `<tr>
|
||||
${wagonCells}
|
||||
<td>${esc(cargoType?.name ?? cargoType?.code ?? allocation.loadType)}</td>
|
||||
<td>${esc(companyName)}</td>
|
||||
<td>${esc(containerNumbers || firstContainer?.containerNumber)}</td>
|
||||
<td>${esc(chassisNumbers)}</td>
|
||||
<td>${esc(sealNumbers)}</td>
|
||||
@@ -2891,6 +2921,18 @@ export class TrainSchedulingService {
|
||||
0,
|
||||
);
|
||||
|
||||
// Container count summary (40ft, 20ft)
|
||||
let count40ft = 0, count20ft = 0;
|
||||
wagons.forEach((wagon) => {
|
||||
(wagon.allocations ?? []).forEach((allocation) => {
|
||||
(allocation.containerItems ?? []).forEach((item) => {
|
||||
const size = this.resolveContainerItemSize(item);
|
||||
if (size === 40) count40ft++;
|
||||
else if (size === 20) count20ft++;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
@@ -2940,6 +2982,9 @@ export class TrainSchedulingService {
|
||||
<div class="tile"><span>Departure station</span><strong>${esc(schedule.originStation?.label ?? schedule.originStation?.code)}</strong></div>
|
||||
<div class="tile"><span>Arrival station</span><strong>${esc(schedule.destinationStation?.label ?? schedule.destinationStation?.code)}</strong></div>
|
||||
<div class="tile"><span>Total loaded weight</span><strong>${esc(totalWeight.toFixed(3))} T</strong></div>
|
||||
<div class="tile"><span>Containers 40ft</span><strong>${esc(count40ft)}</strong></div>
|
||||
<div class="tile"><span>Containers 20ft</span><strong>${esc(count20ft)}</strong></div>
|
||||
<div class="tile"><span>Total containers</span><strong>${esc(count40ft + count20ft)}</strong></div>
|
||||
<div class="tile"><span>Prepared person</span><strong>${esc(schedule.preparedByUserId)}</strong></div>
|
||||
<div class="tile"><span>Check person</span><strong>${esc(schedule.checkedByUserId)}</strong></div>
|
||||
<div class="tile"><span>Wagons</span><strong>${esc(wagons.length)}${emptyWagons ? ` (${emptyWagons} empty)` : ''}</strong></div>
|
||||
@@ -2958,6 +3003,7 @@ export class TrainSchedulingService {
|
||||
<th class="num">Tare Weight</th>
|
||||
<th class="num">Load Capacity</th>
|
||||
<th>Cargo Type</th>
|
||||
<th>Company</th>
|
||||
<th>Container No</th>
|
||||
<th>Chassis No</th>
|
||||
<th>Seal No</th>
|
||||
@@ -3040,6 +3086,19 @@ export class TrainSchedulingService {
|
||||
0,
|
||||
);
|
||||
const emptyWagons = loadList.wagons.filter((wagon) => wagon.allocations.length === 0).length;
|
||||
|
||||
// Container count summary (40ft, 20ft)
|
||||
let count40ft = 0, count20ft = 0;
|
||||
loadList.wagons.forEach((wagon) => {
|
||||
wagon.allocations.forEach((allocation) => {
|
||||
(allocation.containerItems ?? []).forEach((item) => {
|
||||
const size = this.resolveContainerItemSize(item);
|
||||
if (size === 40) count40ft++;
|
||||
else if (size === 20) count20ft++;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const allocationRows = loadList.wagons
|
||||
.flatMap((wagon) => {
|
||||
const wagonCells = `<td>${esc(wagon.sequenceNo)}</td>
|
||||
@@ -3055,13 +3114,17 @@ export class TrainSchedulingService {
|
||||
];
|
||||
}
|
||||
return wagon.allocations.map(
|
||||
(allocation) => `<tr>
|
||||
(allocation) => {
|
||||
const companyName = (allocation.booking as unknown as { company?: { name?: string } } | undefined)?.company?.name ?? '-';
|
||||
return `<tr>
|
||||
${wagonCells}
|
||||
<td>${esc(allocation.bookingReference ?? allocation.bookingId)}</td>
|
||||
<td>${esc(companyName)}</td>
|
||||
<td>${esc(allocation.loadType)}</td>
|
||||
<td>${esc(allocation.containerNumbers.length ? allocation.containerNumbers.join(', ') : '-')}</td>
|
||||
<td class="num">${esc(Number(allocation.allocatedWeightTons || 0).toFixed(3))}</td>
|
||||
</tr>`,
|
||||
</tr>`;
|
||||
},
|
||||
);
|
||||
})
|
||||
.join('');
|
||||
@@ -3126,6 +3189,9 @@ export class TrainSchedulingService {
|
||||
<div class="tile"><span>Wagons</span><strong>${esc(loadList.wagons.length)}${emptyWagons ? ` (${emptyWagons} empty)` : ''}</strong></div>
|
||||
<div class="tile"><span>Allocations</span><strong>${esc(totalAllocations)}</strong></div>
|
||||
<div class="tile"><span>Total weight</span><strong>${esc(totalWeight.toFixed(3))} T</strong></div>
|
||||
<div class="tile"><span>Containers 40ft</span><strong>${esc(count40ft)}</strong></div>
|
||||
<div class="tile"><span>Containers 20ft</span><strong>${esc(count20ft)}</strong></div>
|
||||
<div class="tile"><span>Total containers</span><strong>${esc(count40ft + count20ft)}</strong></div>
|
||||
<div class="tile"><span>Gatepass granted</span><strong>${esc(date(loadList.operation.gatepassGrantedAt))}</strong></div>
|
||||
</div>
|
||||
|
||||
@@ -3145,6 +3211,7 @@ export class TrainSchedulingService {
|
||||
<th>Seq</th>
|
||||
<th>Wagon</th>
|
||||
<th>Booking</th>
|
||||
<th>Company</th>
|
||||
<th>Load</th>
|
||||
<th>Container numbers</th>
|
||||
<th class="num">Weight T</th>
|
||||
|
||||
@@ -13,14 +13,14 @@ export class StartVerificationDto {
|
||||
purpose?: 'LOGIN' | 'VERIFY';
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: ['WEB', 'MOBILE'],
|
||||
enum: ['WEB', 'MOBILE', 'PORTAL'],
|
||||
default: 'WEB',
|
||||
description:
|
||||
'Client platform. Selects which OAuth redirect_uri is sent to eSignet: WEB uses FAYDA_WEB_REDIRECT_URI, MOBILE uses FAYDA_REDIRECT_URI. Both land on the same /complete endpoint with identical handling.',
|
||||
'Client platform. Selects which OAuth redirect_uri is sent to eSignet: WEB (backoffice) uses FAYDA_WEB_REDIRECT_URI, PORTAL uses FAYDA_PORTAL_REDIRECT_URI, MOBILE uses FAYDA_REDIRECT_URI. All land on the same /complete handling.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn(['WEB', 'MOBILE'])
|
||||
platform?: 'WEB' | 'MOBILE';
|
||||
@IsIn(['WEB', 'MOBILE', 'PORTAL'])
|
||||
platform?: 'WEB' | 'MOBILE' | 'PORTAL';
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: Boolean,
|
||||
@@ -57,6 +57,12 @@ export class CompleteVerificationResultDto {
|
||||
agentId?: string;
|
||||
};
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Fayda OIDC subject — the stable key a verified identity is stored under (VERIFY flow). Pairwise pseudonymous.',
|
||||
})
|
||||
sub?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Verified full name from Fayda (VERIFY flow).' })
|
||||
fullName?: string;
|
||||
|
||||
@@ -74,6 +80,11 @@ export class CompleteVerificationResultDto {
|
||||
@ApiPropertyOptional({ description: 'Verified gender from Fayda (VERIFY flow).' })
|
||||
gender?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Verified address from Fayda, English rendering (VERIFY flow).',
|
||||
})
|
||||
address?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Whether the verified identity was saved to IAM. False if the IAM write failed.' })
|
||||
userDataSaved?: boolean;
|
||||
|
||||
|
||||
@@ -56,11 +56,15 @@ export interface CompleteVerificationResult {
|
||||
promptPasswordSetup?: boolean;
|
||||
iamUserId?: string;
|
||||
user?: FaydaUserSummary;
|
||||
/** Fayda OIDC subject — the stable key a verified identity is stored under. */
|
||||
sub?: string;
|
||||
fullName?: string;
|
||||
email?: string;
|
||||
phoneNumber?: string;
|
||||
birthdate?: string;
|
||||
gender?: string;
|
||||
/** Verified address, English rendering (falls back to Amharic). */
|
||||
address?: string;
|
||||
userDataSaved?: boolean;
|
||||
}
|
||||
|
||||
@@ -125,11 +129,15 @@ export class VerifaydaService {
|
||||
});
|
||||
}
|
||||
|
||||
/** WEB clients use `webRedirectUri`; MOBILE uses the base `redirectUri`. */
|
||||
/**
|
||||
* Each client lands on its own registered redirect_uri: MOBILE on the base
|
||||
* one, the customer portal on its own origin, everything else (backoffice) on
|
||||
* the web one. All three must be registered with eSignet.
|
||||
*/
|
||||
private redirectUriForPlatform(platform?: FaydaPlatform): string {
|
||||
return platform === 'MOBILE'
|
||||
? this.faydaConfig.redirectUri
|
||||
: this.faydaConfig.webRedirectUri;
|
||||
if (platform === 'MOBILE') return this.faydaConfig.redirectUri;
|
||||
if (platform === 'PORTAL') return this.faydaConfig.portalRedirectUri;
|
||||
return this.faydaConfig.webRedirectUri;
|
||||
}
|
||||
|
||||
async completeVerification(
|
||||
@@ -210,11 +218,13 @@ export class VerifaydaService {
|
||||
result = {
|
||||
purpose: 'VERIFY',
|
||||
verified: true,
|
||||
sub: normalized.sub,
|
||||
fullName: normalized.fullName,
|
||||
email: normalized.email,
|
||||
phoneNumber: normalized.phoneNumber,
|
||||
birthdate: normalized.birthdate,
|
||||
gender: normalized.gender,
|
||||
address: normalized.addressEn ?? normalized.addressAm,
|
||||
userDataSaved,
|
||||
iamUserId: iamUserId ?? undefined,
|
||||
token: sessionToken?.token,
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { WarehouseFeeService } from './warehouse-fee.service';
|
||||
|
||||
/**
|
||||
* Double handling bills ONLY when warehouse staff answered Yes after
|
||||
* unloading. Undecided (null) or No must produce a zero charge even when a
|
||||
* matching DOUBLE_HANDLING_FEE rule exists.
|
||||
*/
|
||||
type Item = Parameters<WarehouseFeeService['previewForInventory']> extends unknown
|
||||
? Record<string, unknown>
|
||||
: never;
|
||||
|
||||
const svc = Object.create(WarehouseFeeService.prototype) as {
|
||||
computeDoubleHandling: (
|
||||
rule: Record<string, unknown> | null,
|
||||
item: Item,
|
||||
now: Date,
|
||||
billingCurrency: string,
|
||||
) => Promise<{ amount: number; billableUnits: number }>;
|
||||
normalizeCurrency: (c?: string | null) => string;
|
||||
convertAmount: (a: number, from: string, to: string) => Promise<number>;
|
||||
resolveBulkQuantity: (item: Item) => { quantity: number; unitLabel: string };
|
||||
};
|
||||
// No exchange service on a bare prototype — bill in the rule's own currency.
|
||||
svc.normalizeCurrency = (c) => (c ? String(c).toUpperCase() : 'USD');
|
||||
svc.convertAmount = async (a) => a;
|
||||
|
||||
const rule = { basis: 'PER_CONTAINER', ratePerDay: 100, currency: 'USD', id: 'r1', name: 'DH' };
|
||||
const item = (doubleHandling: boolean | null) => ({
|
||||
tradeDirection: 'IMPORT',
|
||||
freightType: 'CONTAINER',
|
||||
inventoryQuantity: 2,
|
||||
bookingContainerCount: 3,
|
||||
inventoryWeight: 10,
|
||||
cargoUnitOfMeasure: 'PER_TON',
|
||||
doubleHandling,
|
||||
}) as unknown as Item;
|
||||
|
||||
describe('double handling gate', () => {
|
||||
it('bills rate x containers when the booking is flagged Yes', async () => {
|
||||
const out = await svc.computeDoubleHandling(rule, item(true), new Date(), 'USD');
|
||||
expect(out.billableUnits).toBe(3);
|
||||
expect(out.amount).toBe(300);
|
||||
});
|
||||
|
||||
it('charges nothing when the answer is No', async () => {
|
||||
const out = await svc.computeDoubleHandling(rule, item(false), new Date(), 'USD');
|
||||
expect(out.billableUnits).toBe(0);
|
||||
expect(out.amount).toBe(0);
|
||||
});
|
||||
|
||||
it('charges nothing while the answer is undecided', async () => {
|
||||
const out = await svc.computeDoubleHandling(rule, item(null), new Date(), 'USD');
|
||||
expect(out.amount).toBe(0);
|
||||
});
|
||||
|
||||
it('charges nothing for export even when flagged Yes', async () => {
|
||||
const exportItem = { ...(item(true) as Record<string, unknown>), tradeDirection: 'EXPORT' } as Item;
|
||||
const out = await svc.computeDoubleHandling(rule, exportItem, new Date(), 'USD');
|
||||
expect(out.amount).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,12 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEnum, IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
|
||||
import { IsArray, IsEnum, IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
|
||||
|
||||
import { WAREHOUSE_YARD_TYPES, WarehouseYardType } from '../entities/warehouse-yard.entity';
|
||||
import {
|
||||
WAREHOUSE_YARD_DIRECTIONS,
|
||||
WAREHOUSE_YARD_TYPES,
|
||||
WarehouseYardDirection,
|
||||
WarehouseYardType,
|
||||
} from '../entities/warehouse-yard.entity';
|
||||
|
||||
export class CreateWarehouseYardDto {
|
||||
@ApiPropertyOptional({ format: 'uuid', description: 'Optional — taken from the route param when omitted' })
|
||||
@@ -46,4 +51,22 @@ export class CreateWarehouseYardDto {
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
maxVolume?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: WAREHOUSE_YARD_DIRECTIONS,
|
||||
description: 'Trade direction this yard serves. Only meaningful for CONTAINER_YARD — omit/BOTH for everything else.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsEnum(WAREHOUSE_YARD_DIRECTIONS)
|
||||
direction?: WarehouseYardDirection;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: [String],
|
||||
format: 'uuid',
|
||||
description: 'Cargo types this yard accepts. Empty/omitted = open to any cargo type of this yard\'s structural type.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsUUID('4', { each: true })
|
||||
cargoTypeIds?: string[];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsBoolean } from 'class-validator';
|
||||
|
||||
/**
|
||||
* Warehouse staff's post-unloading answer: did these goods have to be
|
||||
* re-handled? Only `true` makes the DOUBLE_HANDLING_FEE rule bill the booking.
|
||||
*/
|
||||
export class SetDoubleHandlingDto {
|
||||
@ApiProperty({
|
||||
description: 'Yes (true) applies the double-handling fee rule; No (false) does not.',
|
||||
})
|
||||
@IsBoolean()
|
||||
doubleHandling!: boolean;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
|
||||
import { Column, Entity, Index, JoinColumn, JoinTable, ManyToMany, ManyToOne, OneToMany } from 'typeorm';
|
||||
|
||||
import { CargoType } from '../../rule-engine/entities/cargo-type.entity';
|
||||
import { Warehouse } from './warehouse.entity';
|
||||
import { WarehouseZone } from './warehouse-zone.entity';
|
||||
|
||||
@@ -16,6 +17,15 @@ export type WarehouseYardType = (typeof WAREHOUSE_YARD_TYPES)[number];
|
||||
export const WAREHOUSE_YARD_STATUSES = ['ACTIVE', 'INACTIVE'] as const;
|
||||
export type WarehouseYardStatus = (typeof WAREHOUSE_YARD_STATUSES)[number];
|
||||
|
||||
/**
|
||||
* Which trade direction this yard serves. Only meaningful for CONTAINER_YARD,
|
||||
* where import and export stacks are physically separate areas (e.g. Indode's
|
||||
* Yard 5 for import vs Yard 6 for export) — every other yard type takes cargo
|
||||
* either way, so BOTH/null is the right default there.
|
||||
*/
|
||||
export const WAREHOUSE_YARD_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH'] as const;
|
||||
export type WarehouseYardDirection = (typeof WAREHOUSE_YARD_DIRECTIONS)[number];
|
||||
|
||||
@Entity({ schema: 'freight', name: 'warehouse_yards' })
|
||||
@Index(['warehouseId'])
|
||||
@Index(['type'])
|
||||
@@ -64,6 +74,25 @@ export class WarehouseYard extends BaseEntity {
|
||||
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||
isActive!: boolean;
|
||||
|
||||
/** Null = BOTH (no direction restriction). Only relevant for CONTAINER_YARD. */
|
||||
@Column({ name: 'direction', type: 'varchar', length: 10, nullable: true })
|
||||
direction?: WarehouseYardDirection | null;
|
||||
|
||||
/**
|
||||
* Cargo types this yard accepts — e.g. Yard 3 (Ro-Ro) takes Automobile/Truck,
|
||||
* Yard 9 (Coffee and Tea) takes only those two. Empty/no rows = open to any
|
||||
* cargo type of the yard's structural `type` (the pre-existing behavior),
|
||||
* so this is additive and never blocks a yard that hasn't been configured.
|
||||
*/
|
||||
@ManyToMany(() => CargoType)
|
||||
@JoinTable({
|
||||
name: 'warehouse_yard_cargo_types',
|
||||
schema: 'freight',
|
||||
joinColumn: { name: 'yard_id', referencedColumnName: 'id' },
|
||||
inverseJoinColumn: { name: 'cargo_type_id', referencedColumnName: 'id' },
|
||||
})
|
||||
cargoTypes?: CargoType[];
|
||||
|
||||
@OneToMany(() => WarehouseZone, (zone) => zone.yard)
|
||||
zones?: WarehouseZone[];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { WarehouseFeeService } from './warehouse-fee.service';
|
||||
|
||||
/**
|
||||
* Detention is per truck: two trucks on the same delivery with different
|
||||
* windows must produce different chargeable days and amounts (the old
|
||||
* leg-level clock billed them identically).
|
||||
*/
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
const DAY = 24 * HOUR;
|
||||
|
||||
const svc = Object.create(WarehouseFeeService.prototype) as {
|
||||
computeTruckDetention: (
|
||||
rule: Record<string, unknown> | null,
|
||||
row: { arrivedAt: Date | string | null; deliveredAt: Date | string | null; truckCount: number },
|
||||
now: Date,
|
||||
billingCurrency: string,
|
||||
) => Promise<{ chargeableDays: number; billableUnits: number; amount: number; endIsOpen: boolean }>;
|
||||
normalizeCurrency: (c?: string | null) => string;
|
||||
convertAmount: (a: number, from: string, to: string) => Promise<number>;
|
||||
calculateTieredAmount: unknown;
|
||||
};
|
||||
svc.normalizeCurrency = (c) => (c ? String(c).toUpperCase() : 'USD');
|
||||
svc.convertAmount = async (a) => a;
|
||||
|
||||
// 3h grace, 50/truck/day, no tiers.
|
||||
const rule = { freeHours: 3, ratePerDay: 50, currency: 'USD', id: 'r1', name: 'Detention', tiers: [] };
|
||||
const now = new Date('2026-07-25T12:00:00Z');
|
||||
|
||||
describe('per-truck detention', () => {
|
||||
it('bills each truck on its own window', async () => {
|
||||
// Truck A: out ~1 day past grace. Truck B: out ~3 days past grace.
|
||||
const a = await svc.computeTruckDetention(
|
||||
rule,
|
||||
{
|
||||
arrivedAt: new Date(now.getTime() - DAY - 4 * HOUR),
|
||||
deliveredAt: now,
|
||||
truckCount: 1,
|
||||
},
|
||||
now,
|
||||
'USD',
|
||||
);
|
||||
const b = await svc.computeTruckDetention(
|
||||
rule,
|
||||
{
|
||||
arrivedAt: new Date(now.getTime() - 3 * DAY - 4 * HOUR),
|
||||
deliveredAt: now,
|
||||
truckCount: 1,
|
||||
},
|
||||
now,
|
||||
'USD',
|
||||
);
|
||||
|
||||
expect(a.chargeableDays).toBe(2);
|
||||
expect(b.chargeableDays).toBe(4);
|
||||
expect(a.amount).toBe(100);
|
||||
expect(b.amount).toBe(200);
|
||||
// The whole point: same delivery, different bills.
|
||||
expect(a.amount).not.toBe(b.amount);
|
||||
});
|
||||
|
||||
it('charges nothing inside the grace window', async () => {
|
||||
const out = await svc.computeTruckDetention(
|
||||
rule,
|
||||
{ arrivedAt: new Date(now.getTime() - 2 * HOUR), deliveredAt: now, truckCount: 1 },
|
||||
now,
|
||||
'USD',
|
||||
);
|
||||
expect(out.chargeableDays).toBe(0);
|
||||
expect(out.amount).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps accruing against now when a truck has not returned', async () => {
|
||||
const out = await svc.computeTruckDetention(
|
||||
rule,
|
||||
{ arrivedAt: new Date(now.getTime() - 2 * DAY), deliveredAt: null, truckCount: 1 },
|
||||
now,
|
||||
'USD',
|
||||
);
|
||||
expect(out.endIsOpen).toBe(true);
|
||||
expect(out.chargeableDays).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,8 @@ export interface ImportTrainRow {
|
||||
route: string | null;
|
||||
origin: string | null;
|
||||
destination: string | null;
|
||||
/** freight.yards.id the train is heading to — lets the frontend restrict the unload warehouse picker to the warehouse actually at this station, instead of listing every warehouse. */
|
||||
destinationStationId: string | null;
|
||||
arrivalTime: string | null;
|
||||
totalBookings: number;
|
||||
totalContainers: number;
|
||||
@@ -38,6 +40,8 @@ export interface ImportTrainItemRow {
|
||||
freightType: string | null;
|
||||
containerNumber: string | null;
|
||||
cargoType: string | null;
|
||||
/** Cargo type CODE (e.g. "WHEAT"), for matching against a yard's configured cargo types — `cargoType` above is the display name. */
|
||||
cargoTypeCode: string | null;
|
||||
weight: number | null;
|
||||
arrivalTime: string | null;
|
||||
currentStatus: string | null;
|
||||
@@ -205,6 +209,7 @@ export class SchedulingReadFacade {
|
||||
ts.train_number AS "trainNumber",
|
||||
oy.code AS "origin",
|
||||
dy.code AS "destination",
|
||||
dy.id AS "destinationStationId",
|
||||
oy.country AS "originCountry",
|
||||
dy.country AS "destinationCountry",
|
||||
COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) AS "arrivalTime",
|
||||
@@ -280,6 +285,7 @@ export class SchedulingReadFacade {
|
||||
WHERE c.booking_id = b.id AND c.deleted_at IS NULL
|
||||
ORDER BY c.container_number LIMIT 1) AS "containerNumber",
|
||||
COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType",
|
||||
cgt.code AS "cargoTypeCode",
|
||||
b.cargo_total_weight_vgm AS "weight",
|
||||
COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) AS "arrivalTime",
|
||||
COALESCE(inv.status, b.status) AS "currentStatus",
|
||||
@@ -376,6 +382,7 @@ export class SchedulingReadFacade {
|
||||
ts.train_number AS "trainNumber",
|
||||
oy.code AS "origin",
|
||||
dy.code AS "destination",
|
||||
dy.id AS "destinationStationId",
|
||||
dy.label AS "destinationName",
|
||||
oy.country AS "originCountry",
|
||||
dy.country AS "destinationCountry",
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import { WarehouseFeeService } from './warehouse-fee.service';
|
||||
import { WarehouseFeeRule } from './entities/warehouse-fee-rule.entity';
|
||||
|
||||
// Bulk storage/demurrage used to bill a flat rate per day regardless of cargo
|
||||
// quantity. It now scales by the cargo type's own unit of measure — tons for
|
||||
// PER_TON cargo, item count for PER_ITEM cargo (Machinery, Truck, Automobile,
|
||||
// Livestock…) — read from THIS inventory row, not the whole booking's total.
|
||||
describe('WarehouseFeeService bulk quantity billing', () => {
|
||||
const makeService = () =>
|
||||
// compute() only touches its own arguments plus this.convertAmount, which
|
||||
// short-circuits when rule.currency === billingCurrency — none of the
|
||||
// constructor deps are exercised.
|
||||
new WarehouseFeeService({} as any, {} as any, {} as any, {} as any);
|
||||
|
||||
const rule = (overrides: Partial<WarehouseFeeRule> = {}): WarehouseFeeRule =>
|
||||
({
|
||||
id: 'rule-1',
|
||||
name: 'Bulk storage',
|
||||
ruleType: 'STORAGE_FEE',
|
||||
freeDays: 0,
|
||||
ratePerDay: 10,
|
||||
currency: 'USD',
|
||||
tiers: [],
|
||||
...overrides,
|
||||
}) as WarehouseFeeRule;
|
||||
|
||||
const baseItem = (overrides: Record<string, unknown> = {}) => ({
|
||||
arrivedAt: new Date('2026-01-01T00:00:00Z'),
|
||||
gateClearedAt: null,
|
||||
releaseDate: null,
|
||||
freightType: 'BULK',
|
||||
tradeDirection: 'IMPORT',
|
||||
cargoTypeCode: 'WHEAT',
|
||||
containerTypeCode: null,
|
||||
vehicleType: null,
|
||||
inventoryQuantity: 3,
|
||||
inventoryWeight: 25,
|
||||
bookingContainerCount: 0,
|
||||
cargoUnitOfMeasure: null,
|
||||
// Double handling now bills only when staff answered Yes after unloading;
|
||||
// these quantity-basis cases assume that answer (the gate itself is covered
|
||||
// in double-handling-gate.spec.ts).
|
||||
doubleHandling: true,
|
||||
facilityId: null,
|
||||
warehouseId: null,
|
||||
yardId: null,
|
||||
zoneId: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// 5 elapsed days, 0 free days -> 5 chargeable days throughout.
|
||||
const now = new Date('2026-01-06T00:00:00Z');
|
||||
|
||||
it('bills PER_TON bulk cargo by this row\'s weight, not a flat day rate', async () => {
|
||||
const service = makeService();
|
||||
const preview = await (service as any).compute(
|
||||
'STORAGE_FEE',
|
||||
rule(),
|
||||
baseItem({ cargoUnitOfMeasure: 'PER_TON', inventoryWeight: 25 }),
|
||||
now,
|
||||
'USD',
|
||||
);
|
||||
expect(preview.unitLabel).toBe('ton');
|
||||
expect(preview.containerCount).toBe(25);
|
||||
expect(preview.billableUnits).toBe(5 * 25);
|
||||
expect(preview.amount).toBe(5 * 25 * 10);
|
||||
});
|
||||
|
||||
it('bills PER_ITEM bulk cargo (Machinery/Truck/Automobile/Livestock) by unit count', async () => {
|
||||
const service = makeService();
|
||||
const preview = await (service as any).compute(
|
||||
'STORAGE_FEE',
|
||||
rule(),
|
||||
baseItem({ cargoUnitOfMeasure: 'PER_ITEM', inventoryQuantity: 3, cargoTypeCode: 'MACHINERY' }),
|
||||
now,
|
||||
'USD',
|
||||
);
|
||||
expect(preview.unitLabel).toBe('item');
|
||||
expect(preview.containerCount).toBe(3);
|
||||
expect(preview.billableUnits).toBe(5 * 3);
|
||||
expect(preview.amount).toBe(5 * 3 * 10);
|
||||
});
|
||||
|
||||
it('defaults to PER_TON when the cargo type has no unit of measure set', async () => {
|
||||
const service = makeService();
|
||||
const preview = await (service as any).compute(
|
||||
'STORAGE_FEE',
|
||||
rule(),
|
||||
baseItem({ cargoUnitOfMeasure: null, inventoryWeight: 12 }),
|
||||
now,
|
||||
'USD',
|
||||
);
|
||||
expect(preview.unitLabel).toBe('ton');
|
||||
expect(preview.containerCount).toBe(12);
|
||||
});
|
||||
|
||||
it('charges nothing yet when the row has not been weighed/counted (0 is legitimate, not floored to 1)', async () => {
|
||||
const service = makeService();
|
||||
const preview = await (service as any).compute(
|
||||
'STORAGE_FEE',
|
||||
rule(),
|
||||
baseItem({ cargoUnitOfMeasure: 'PER_TON', inventoryWeight: 0 }),
|
||||
now,
|
||||
'USD',
|
||||
);
|
||||
expect(preview.containerCount).toBe(0);
|
||||
expect(preview.billableUnits).toBe(0);
|
||||
expect(preview.amount).toBe(0);
|
||||
});
|
||||
|
||||
it('leaves CONTAINER freight billing untouched by the new bulk fields', async () => {
|
||||
const service = makeService();
|
||||
const preview = await (service as any).compute(
|
||||
'DEMURRAGE_FEE',
|
||||
rule({ ruleType: 'DEMURRAGE_FEE' }),
|
||||
baseItem({
|
||||
freightType: 'CONTAINER',
|
||||
bookingContainerCount: 4,
|
||||
cargoUnitOfMeasure: 'PER_ITEM', // must be ignored for container freight
|
||||
inventoryWeight: 999,
|
||||
}),
|
||||
now,
|
||||
'USD',
|
||||
);
|
||||
expect(preview.unitLabel).toBe('container');
|
||||
expect(preview.containerCount).toBe(4);
|
||||
expect(preview.billableUnits).toBe(5 * 4);
|
||||
});
|
||||
|
||||
// Double handling is a flat one-time charge, but previewForInventory() calls
|
||||
// it once per warehouse_inventory ROW. Before this fix it read the whole
|
||||
// booking's total on every row, so a booking split across N rows was billed
|
||||
// N times against its full quantity. Reading each row's own weight/count
|
||||
// fixes that: summing the rows now reproduces the booking total exactly once.
|
||||
describe('double handling (row-level, not booking-wide)', () => {
|
||||
const doubleHandlingRule = (basis: 'PER_CONTAINER' | 'PER_TON' | 'PER_ITEM') =>
|
||||
rule({ ruleType: 'DOUBLE_HANDLING_FEE', basis, ratePerDay: 20 });
|
||||
|
||||
it('bills PER_TON by this row\'s own weight', async () => {
|
||||
const service = makeService();
|
||||
const preview = await (service as any).compute(
|
||||
'DOUBLE_HANDLING_FEE',
|
||||
doubleHandlingRule('PER_TON'),
|
||||
baseItem({ cargoUnitOfMeasure: 'PER_TON', inventoryWeight: 10 }),
|
||||
now,
|
||||
'USD',
|
||||
);
|
||||
expect(preview.unitLabel).toBe('ton');
|
||||
expect(preview.billableUnits).toBe(10);
|
||||
expect(preview.amount).toBe(10 * 20);
|
||||
});
|
||||
|
||||
it('bills PER_ITEM by this row\'s own unit count', async () => {
|
||||
const service = makeService();
|
||||
const preview = await (service as any).compute(
|
||||
'DOUBLE_HANDLING_FEE',
|
||||
doubleHandlingRule('PER_ITEM'),
|
||||
baseItem({ cargoUnitOfMeasure: 'PER_ITEM', inventoryQuantity: 2, cargoTypeCode: 'TRUCK' }),
|
||||
now,
|
||||
'USD',
|
||||
);
|
||||
expect(preview.unitLabel).toBe('item');
|
||||
expect(preview.billableUnits).toBe(2);
|
||||
expect(preview.amount).toBe(2 * 20);
|
||||
});
|
||||
|
||||
it('two rows of one booking sum to the booking total exactly once (no N-times overcount)', async () => {
|
||||
const service = makeService();
|
||||
const ruleDef = doubleHandlingRule('PER_TON');
|
||||
const rowA = await (service as any).compute(
|
||||
'DOUBLE_HANDLING_FEE',
|
||||
ruleDef,
|
||||
baseItem({ cargoUnitOfMeasure: 'PER_TON', inventoryWeight: 6 }),
|
||||
now,
|
||||
'USD',
|
||||
);
|
||||
const rowB = await (service as any).compute(
|
||||
'DOUBLE_HANDLING_FEE',
|
||||
ruleDef,
|
||||
baseItem({ cargoUnitOfMeasure: 'PER_TON', inventoryWeight: 4 }),
|
||||
now,
|
||||
'USD',
|
||||
);
|
||||
// Booking total is 10 tons across the two rows — billed once in total,
|
||||
// not 10 tons charged against EACH row (which the old booking-wide read did).
|
||||
expect(rowA.amount + rowB.amount).toBe(10 * 20);
|
||||
});
|
||||
|
||||
it('no charge for export/domestic regardless of basis', async () => {
|
||||
const service = makeService();
|
||||
const preview = await (service as any).compute(
|
||||
'DOUBLE_HANDLING_FEE',
|
||||
doubleHandlingRule('PER_TON'),
|
||||
baseItem({ tradeDirection: 'EXPORT', cargoUnitOfMeasure: 'PER_TON', inventoryWeight: 10 }),
|
||||
now,
|
||||
'USD',
|
||||
);
|
||||
expect(preview.amount).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -20,9 +20,13 @@ interface ItemAttributes {
|
||||
/** Vehicle type of the truck (truck detention scoping); null otherwise. */
|
||||
vehicleType: string | null;
|
||||
inventoryQuantity: number;
|
||||
/** This inventory row's own net weight (tonnes) — bulk STORAGE/DEMURRAGE for PER_TON cargo bills against this, not the booking-wide total. */
|
||||
inventoryWeight: number;
|
||||
bookingContainerCount: number;
|
||||
/** Booking cargo total in the cargo's unit of measure: tonnes (PER_TON) or item count (PER_ITEM). */
|
||||
cargoQuantity: number;
|
||||
/** This item's cargo type unit of measure (PER_TON | PER_ITEM); null defaults to PER_TON. Decides whether bulk day-based fees bill by weight or item count. */
|
||||
cargoUnitOfMeasure: string | null;
|
||||
/** Booking-level Yes/No recorded after unloading; only true bills double handling (null = undecided). */
|
||||
doubleHandling: boolean | null;
|
||||
facilityId: string | null;
|
||||
warehouseId: string | null;
|
||||
yardId: string | null;
|
||||
@@ -60,7 +64,7 @@ export interface AccrualDashboardRow {
|
||||
|
||||
export interface FeePreview {
|
||||
ruleType: FeeRuleType;
|
||||
/** Double-handling charge basis (PER_CONTAINER | PER_TON | PER_MACHINERY); null otherwise. */
|
||||
/** Double-handling charge basis (PER_CONTAINER | PER_TON | PER_ITEM); null otherwise. */
|
||||
basis: FeeRuleBasis | null;
|
||||
ruleId: string | null;
|
||||
ruleName: string | null;
|
||||
@@ -75,6 +79,8 @@ export interface FeePreview {
|
||||
elapsedDays: number;
|
||||
chargeableDays: number;
|
||||
containerCount: number;
|
||||
/** What `containerCount`/`billableUnits` are counted in — 'container' | 'truck' | 'ton' | 'item'. Bulk cargo bills by weight (ton) or item count depending on the cargo type's unit of measure. */
|
||||
unitLabel: string;
|
||||
billableUnits: number;
|
||||
amount: number;
|
||||
tiers: Array<{
|
||||
@@ -86,10 +92,20 @@ export interface FeePreview {
|
||||
ratePerDay: number;
|
||||
amount: number;
|
||||
}>;
|
||||
/** Truck detention: per-vehicle-type breakdown — each truck-type group billed by its own matching rule. */
|
||||
/**
|
||||
* Truck detention: one row PER TRUCK — each truck has its own detention
|
||||
* window (it arrives and is released at its own time) and its own matching
|
||||
* rule by truck type, so days and amount differ between trucks.
|
||||
*/
|
||||
groups?: Array<{
|
||||
assignmentId: string | null;
|
||||
vehicleId: string | null;
|
||||
plateNumber: string | null;
|
||||
vehicleType: string | null;
|
||||
truckCount: number;
|
||||
startDate: string | null;
|
||||
endDate: string | null;
|
||||
endIsOpen: boolean;
|
||||
chargeableDays: number;
|
||||
ratePerDay: number;
|
||||
amount: number;
|
||||
@@ -242,16 +258,18 @@ export class WarehouseFeeService {
|
||||
inv.gate_cleared_at AS "gateClearedAt",
|
||||
inv.release_date AS "releaseDate",
|
||||
inv.quantity AS "inventoryQuantity",
|
||||
inv.weight AS "inventoryWeight",
|
||||
inv.warehouse_id AS "warehouseId",
|
||||
inv.yard_id AS "yardId",
|
||||
inv.zone_id AS "zoneId",
|
||||
w.facility_id AS "facilityId",
|
||||
b.freight_type AS "freightType",
|
||||
b.trade_direction AS "tradeDirection",
|
||||
b.double_handling AS "doubleHandling",
|
||||
COALESCE(cgt.code, booking_cgt.code) AS "cargoTypeCode",
|
||||
COALESCE(ctt.code, booking_ctt.code) AS "containerTypeCode",
|
||||
COALESCE(container_lines.container_count, 0) AS "bookingContainerCount",
|
||||
COALESCE(b.cargo_total_weight_vgm, 0) AS "cargoQuantity"
|
||||
COALESCE(cgt.unit_of_measure, booking_cgt.unit_of_measure) AS "cargoUnitOfMeasure"
|
||||
FROM freight.warehouse_inventory inv
|
||||
LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id
|
||||
LEFT JOIN freight.bookings b ON b.id = inv.booking_id
|
||||
@@ -470,6 +488,22 @@ export class WarehouseFeeService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk's own billing quantity for THIS inventory row — weight (tons) for
|
||||
* PER_TON cargo, unit count for PER_ITEM cargo (Machinery, Truck, Automobile,
|
||||
* Livestock…). Shared by every cargo-scoped fee type (storage, demurrage,
|
||||
* double handling) so a booking split across several rows is never billed
|
||||
* more than once against its full total. 0 is a legitimate charge (nothing
|
||||
* weighed/counted yet), so no forced floor.
|
||||
*/
|
||||
private resolveBulkQuantity(item: ItemAttributes): { quantity: number; unitLabel: string } {
|
||||
const cargoUnit = (item.cargoUnitOfMeasure ?? 'PER_TON').toUpperCase();
|
||||
if (cargoUnit === 'PER_ITEM') {
|
||||
return { quantity: Math.max(0, Number(item.inventoryQuantity) || 0), unitLabel: 'item' };
|
||||
}
|
||||
return { quantity: Math.max(0, Number(item.inventoryWeight) || 0), unitLabel: 'ton' };
|
||||
}
|
||||
|
||||
private async compute(
|
||||
ruleType: FeeRuleType,
|
||||
rule: WarehouseFeeRule | null,
|
||||
@@ -490,9 +524,11 @@ export class WarehouseFeeService {
|
||||
const targetCurrency = this.normalizeCurrency(billingCurrency);
|
||||
const isContainer = (item.freightType ?? '').toUpperCase() === 'CONTAINER';
|
||||
const inventoryQuantity = Math.max(1, Math.round(Number(item.inventoryQuantity) || 1));
|
||||
const bulk = this.resolveBulkQuantity(item);
|
||||
const containerCount = isContainer
|
||||
? Math.max(1, Math.round(Number(item.bookingContainerCount) || inventoryQuantity))
|
||||
: 1;
|
||||
: bulk.quantity;
|
||||
const unitLabel = isContainer ? 'container' : bulk.unitLabel;
|
||||
|
||||
const elapsedDays = start
|
||||
? Math.max(0, Math.ceil((new Date(endDate).getTime() - start.getTime()) / MS_PER_DAY))
|
||||
@@ -533,6 +569,7 @@ export class WarehouseFeeService {
|
||||
elapsedDays,
|
||||
chargeableDays,
|
||||
containerCount,
|
||||
unitLabel,
|
||||
billableUnits,
|
||||
amount,
|
||||
tiers: hasTiers ? convertedTiers : [],
|
||||
@@ -560,12 +597,19 @@ export class WarehouseFeeService {
|
||||
const containerCount = isContainer
|
||||
? Math.max(1, Math.round(Number(item.bookingContainerCount) || inventoryQuantity))
|
||||
: 1;
|
||||
// PER_TON (tonnes) and PER_ITEM (piece count) both read the cargo total,
|
||||
// which is stored in the cargo's own unit of measure.
|
||||
const cargoQuantity = Math.max(0, Number(item.cargoQuantity) || 0);
|
||||
// Double handling applies to IMPORT only — no charge for export/domestic.
|
||||
// PER_TON (tonnes) and PER_ITEM (piece count) both read THIS row's own
|
||||
// weight/count — never the whole booking's total. previewForInventory()
|
||||
// computes double handling once per inventory row, so a booking-wide total
|
||||
// would double- (or triple-) bill a booking split across several rows.
|
||||
const bulk = this.resolveBulkQuantity(item);
|
||||
// Double handling applies to IMPORT only — no charge for export/domestic —
|
||||
// AND only when warehouse staff recorded that the goods were actually
|
||||
// re-handled (booking flag = Yes after unloading). Undecided (null) or No
|
||||
// means no charge, so the rule can exist without billing every import.
|
||||
const isImport = (item.tradeDirection ?? '').toUpperCase() === 'IMPORT';
|
||||
const quantity = !isImport ? 0 : basis === 'PER_CONTAINER' ? containerCount : cargoQuantity;
|
||||
const applies = isImport && item.doubleHandling === true;
|
||||
const quantity = !applies ? 0 : basis === 'PER_CONTAINER' ? containerCount : bulk.quantity;
|
||||
const unitLabel = basis === 'PER_CONTAINER' ? 'container' : bulk.unitLabel;
|
||||
const sourceAmount = Math.round(rate * quantity * 100) / 100;
|
||||
const amount = ruleCurrency ? await this.convertAmount(sourceAmount, ruleCurrency, targetCurrency) : 0;
|
||||
const convertedRate = ruleCurrency ? await this.convertAmount(rate, ruleCurrency, targetCurrency) : 0;
|
||||
@@ -586,6 +630,7 @@ export class WarehouseFeeService {
|
||||
elapsedDays: 0,
|
||||
chargeableDays: 0,
|
||||
containerCount,
|
||||
unitLabel,
|
||||
billableUnits: quantity,
|
||||
amount,
|
||||
tiers: [],
|
||||
@@ -771,6 +816,7 @@ export class WarehouseFeeService {
|
||||
return {
|
||||
ruleType: 'TRUCK_DETENTION_FEE',
|
||||
basis: null,
|
||||
unitLabel: 'truck',
|
||||
ruleId: null,
|
||||
ruleName: null,
|
||||
freeDays: 0,
|
||||
@@ -791,25 +837,48 @@ export class WarehouseFeeService {
|
||||
};
|
||||
}
|
||||
|
||||
// Group the leg's vehicles by CANONICAL truck type so each type is billed
|
||||
// by its own matching rule (rates differ by truck type). The FK to
|
||||
// truck_types is the source of truth — renaming a type's label no longer
|
||||
// silently unmatches its rule; the normalized legacy vehicle_type code is
|
||||
// only a fallback for vehicles without the FK (LEFT JOIN keeps them billed
|
||||
// instead of dropping them). Falls back to one untyped group.
|
||||
const groupRows: Array<{ vehicleType: string | null; truckCount: number | string }> =
|
||||
await this.dataSource.query(
|
||||
`SELECT COALESCE(t.code, NULLIF(UPPER(TRIM(v.vehicle_type)), '')) AS "vehicleType",
|
||||
count(*)::int AS "truckCount"
|
||||
FROM freight.last_mile_vehicle_assignments va
|
||||
JOIN freight.vehicles v ON v.id = va.vehicle_id AND v.deleted_at IS NULL
|
||||
LEFT JOIN freight.truck_types t
|
||||
ON t.id = v.truck_type_id AND t.deleted_at IS NULL
|
||||
WHERE va.last_mile_id = $1 AND va.deleted_at IS NULL
|
||||
GROUP BY 1`,
|
||||
[lastMileId],
|
||||
);
|
||||
const groups = groupRows.length ? groupRows : [{ vehicleType: null, truckCount: 1 }];
|
||||
// One row PER TRUCK: each truck has its own detention window (it reaches the
|
||||
// destination and is released at its own time) and resolves its own rule by
|
||||
// CANONICAL truck type — the truck_types FK is the source of truth, with the
|
||||
// normalized legacy vehicle_type code as fallback so FK-less vehicles keep
|
||||
// billing. Per-truck timestamps fall back to the leg-level pair for legacy
|
||||
// legs recorded before per-truck tracking.
|
||||
const truckRows: Array<{
|
||||
assignmentId: string;
|
||||
vehicleId: string;
|
||||
plateNumber: string | null;
|
||||
vehicleType: string | null;
|
||||
startAt: Date | string | null;
|
||||
endAt: Date | string | null;
|
||||
}> = await this.dataSource.query(
|
||||
`SELECT va.id AS "assignmentId",
|
||||
va.vehicle_id AS "vehicleId",
|
||||
COALESCE(v.power_plate_no, v.plate_number) AS "plateNumber",
|
||||
COALESCE(t.code, NULLIF(UPPER(TRIM(v.vehicle_type)), '')) AS "vehicleType",
|
||||
COALESCE(va.destination_arrived_at, $2::timestamptz) AS "startAt",
|
||||
COALESCE(va.returned_at, $3::timestamptz) AS "endAt"
|
||||
FROM freight.last_mile_vehicle_assignments va
|
||||
JOIN freight.vehicles v ON v.id = va.vehicle_id AND v.deleted_at IS NULL
|
||||
LEFT JOIN freight.truck_types t
|
||||
ON t.id = v.truck_type_id AND t.deleted_at IS NULL
|
||||
WHERE va.last_mile_id = $1 AND va.deleted_at IS NULL
|
||||
ORDER BY va.created_at ASC`,
|
||||
[lastMileId, leg.arrivedAt ?? null, leg.deliveredAt ?? null],
|
||||
);
|
||||
// No trucks assigned yet: keep the leg-level single-truck estimate so the
|
||||
// preview still tells the operator what detention would cost.
|
||||
const trucks = truckRows.length
|
||||
? truckRows
|
||||
: [
|
||||
{
|
||||
assignmentId: null as string | null,
|
||||
vehicleId: null as string | null,
|
||||
plateNumber: null as string | null,
|
||||
vehicleType: null as string | null,
|
||||
startAt: leg.arrivedAt ?? null,
|
||||
endAt: leg.deliveredAt ?? null,
|
||||
},
|
||||
];
|
||||
|
||||
const rules = await this.feeRuleRepository.findAll({ where: { isActive: true } });
|
||||
const detentionRules = rules.filter((r) => r.ruleType === 'TRUCK_DETENTION_FEE');
|
||||
@@ -817,7 +886,7 @@ export class WarehouseFeeService {
|
||||
const targetCurrency = this.normalizeCurrency(billingCurrency);
|
||||
|
||||
const computed = await Promise.all(
|
||||
groups.map(async (g) => {
|
||||
trucks.map(async (t) => {
|
||||
const item: ItemAttributes = {
|
||||
arrivedAt: null,
|
||||
gateClearedAt: null,
|
||||
@@ -826,46 +895,60 @@ export class WarehouseFeeService {
|
||||
tradeDirection: leg.tradeDirection ?? null,
|
||||
cargoTypeCode: null,
|
||||
containerTypeCode: null,
|
||||
vehicleType: g.vehicleType ?? null,
|
||||
vehicleType: t.vehicleType ?? null,
|
||||
inventoryQuantity: 1,
|
||||
inventoryWeight: 0,
|
||||
bookingContainerCount: 1,
|
||||
cargoQuantity: 0,
|
||||
cargoUnitOfMeasure: null,
|
||||
// Irrelevant to detention (truck-time based, never double handling).
|
||||
doubleHandling: null,
|
||||
facilityId: null,
|
||||
warehouseId: null,
|
||||
yardId: null,
|
||||
zoneId: null,
|
||||
};
|
||||
const rule = this.bestRule(detentionRules, item);
|
||||
// truckCount 1 — this row IS one truck.
|
||||
const c = await this.computeTruckDetention(
|
||||
rule,
|
||||
{ arrivedAt: leg.arrivedAt, deliveredAt: leg.deliveredAt, truckCount: g.truckCount },
|
||||
{ arrivedAt: t.startAt, deliveredAt: t.endAt, truckCount: 1 },
|
||||
now,
|
||||
billingCurrency,
|
||||
);
|
||||
return { vehicleType: g.vehicleType ?? null, truckCount: Math.max(1, Math.round(Number(g.truckCount) || 1)), c };
|
||||
return { ...t, c };
|
||||
}),
|
||||
);
|
||||
|
||||
const totalAmount = Math.round(computed.reduce((s, x) => s + x.c.amount, 0) * 100) / 100;
|
||||
const totalTrucks = computed.reduce((s, x) => s + x.truckCount, 0);
|
||||
const totalTrucks = computed.length;
|
||||
const totalBillable = computed.reduce((s, x) => s + x.c.billableUnits, 0);
|
||||
const chargeableDays = computed[0]?.c.chargeableDays ?? 0;
|
||||
// Header days: the worst truck — a single number can't represent per-truck
|
||||
// windows, and the longest detention is the one operations must act on.
|
||||
const chargeableDays = computed.reduce((m, x) => Math.max(m, x.c.chargeableDays), 0);
|
||||
const single = computed.length === 1 ? computed[0].c : null;
|
||||
const anyRuleName = computed.find((x) => x.c.ruleId)?.c.ruleName ?? null;
|
||||
const earliestStart = computed
|
||||
.map((x) => (x.startAt ? new Date(x.startAt).getTime() : null))
|
||||
.filter((n): n is number => n != null)
|
||||
.sort((a, b) => a - b)[0];
|
||||
const anyOpen = computed.some((x) => x.c.endIsOpen);
|
||||
|
||||
return {
|
||||
ruleType: 'TRUCK_DETENTION_FEE',
|
||||
basis: null,
|
||||
unitLabel: 'truck',
|
||||
ruleId: single?.ruleId ?? null,
|
||||
ruleName: single ? single.ruleName : computed.length > 1 && anyRuleName ? 'Per truck-type rules' : anyRuleName,
|
||||
ruleName: single ? single.ruleName : computed.length > 1 && anyRuleName ? 'Per truck rules' : anyRuleName,
|
||||
freeDays: 0,
|
||||
ratePerDay: single?.ratePerDay ?? 0,
|
||||
currency: targetCurrency,
|
||||
ruleCurrency: single?.ruleCurrency ?? null,
|
||||
billingCurrency: targetCurrency,
|
||||
startDate: leg.arrivedAt ? new Date(leg.arrivedAt).toISOString() : null,
|
||||
endDate: (leg.deliveredAt ? new Date(leg.deliveredAt) : now).toISOString(),
|
||||
endIsOpen: !leg.deliveredAt,
|
||||
startDate: earliestStart != null ? new Date(earliestStart).toISOString() : null,
|
||||
endDate: (anyOpen ? now : new Date(Math.max(
|
||||
...computed.map((x) => (x.endAt ? new Date(x.endAt).getTime() : now.getTime())),
|
||||
))).toISOString(),
|
||||
endIsOpen: anyOpen,
|
||||
elapsedDays: chargeableDays,
|
||||
chargeableDays,
|
||||
containerCount: totalTrucks,
|
||||
@@ -873,8 +956,14 @@ export class WarehouseFeeService {
|
||||
amount: totalAmount,
|
||||
tiers: single ? single.tiers : [],
|
||||
groups: computed.map((x) => ({
|
||||
assignmentId: x.assignmentId,
|
||||
vehicleId: x.vehicleId,
|
||||
plateNumber: x.plateNumber,
|
||||
vehicleType: x.vehicleType,
|
||||
truckCount: x.truckCount,
|
||||
truckCount: 1,
|
||||
startDate: x.startAt ? new Date(x.startAt).toISOString() : null,
|
||||
endDate: x.c.endDate,
|
||||
endIsOpen: x.c.endIsOpen,
|
||||
chargeableDays: x.c.chargeableDays,
|
||||
ratePerDay: x.c.ratePerDay,
|
||||
amount: x.c.amount,
|
||||
@@ -927,6 +1016,7 @@ export class WarehouseFeeService {
|
||||
return {
|
||||
ruleType: 'TRUCK_DETENTION_FEE',
|
||||
basis: null,
|
||||
unitLabel: 'truck',
|
||||
ruleId: rule?.id ?? null,
|
||||
ruleName: rule?.name ?? null,
|
||||
freeDays: 0,
|
||||
|
||||
@@ -17,6 +17,7 @@ import { MoveInventoryDto } from './dto/move-inventory.dto';
|
||||
import { StoreInventoryDto } from './dto/store-inventory.dto';
|
||||
import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto';
|
||||
import { ApproveDeliveryDto } from './dto/approve-delivery.dto';
|
||||
import { SetDoubleHandlingDto } from './dto/double-handling.dto';
|
||||
import { ReleaseOrderDto } from './dto/release-order.dto';
|
||||
import { ReserveInventoryDto } from './dto/reserve-inventory.dto';
|
||||
import { UnloadBookingDto } from './dto/unload-booking.dto';
|
||||
@@ -552,6 +553,23 @@ export class WarehouseInventoryController {
|
||||
return res.send(buffer);
|
||||
}
|
||||
|
||||
@Patch('bookings/:bookingId/double-handling')
|
||||
@BookingStaff([FREIGHT_PERMS.warehouseInventory.unload, FREIGHT_PERMS.warehouseInventory.inspect])
|
||||
@ApiOperation({
|
||||
summary: 'Record Yes/No double handling after unloading (Yes applies the double-handling fee rule)',
|
||||
})
|
||||
setDoubleHandling(
|
||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||
@Body() dto: SetDoubleHandlingDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.inventoryService.setDoubleHandling(
|
||||
bookingId,
|
||||
dto.doubleHandling,
|
||||
actorLabel(user),
|
||||
);
|
||||
}
|
||||
|
||||
@Get('bookings/:bookingId/container-items')
|
||||
@StaffReference()
|
||||
@ApiOperation({ summary: 'Per-container/bulk items of a booking with lifecycle stage + refs' })
|
||||
|
||||
@@ -382,6 +382,8 @@ export interface ImportUnloadedRow {
|
||||
customerTruckContainerNumber: string | null;
|
||||
customerTruckAssignedAt: string | null;
|
||||
hasAssignedTruck: boolean;
|
||||
/** Post-unloading Yes/No; null = not recorded yet (no double-handling charge). */
|
||||
doubleHandling: boolean | null;
|
||||
currentStatus: string;
|
||||
releaseDate: string | null;
|
||||
releaseOrderReference: string | null;
|
||||
@@ -1139,17 +1141,31 @@ export class WarehouseInventoryService {
|
||||
async autoUnloadArrived(): Promise<AutoUnloadResult> {
|
||||
const arrived: {
|
||||
id: string;
|
||||
/** Goods owner (company) — the GRN number is mapped to it. */
|
||||
customer: string | null;
|
||||
weight: string | null;
|
||||
freightType: string | null;
|
||||
tradeDirection: string | null;
|
||||
cargoTypeCode: string | null;
|
||||
}[] = await this.dataSource.query(
|
||||
`SELECT b.id, b.cargo_total_weight_vgm AS weight,
|
||||
`SELECT b.id,
|
||||
-- Received weight must land on the inventory row: a booking with no
|
||||
-- declared VGM still has per-container VGM to record.
|
||||
COALESCE(
|
||||
NULLIF(b.cargo_total_weight_vgm, 0),
|
||||
(SELECT SUM(bcu.vgm_tons)
|
||||
FROM freight.booking_container_units bcu
|
||||
JOIN freight.booking_container bc2
|
||||
ON bc2.id = bcu.booking_container_id AND bc2.deleted_at IS NULL
|
||||
WHERE bc2.booking_id = b.id AND bcu.deleted_at IS NULL)
|
||||
) AS weight,
|
||||
b.freight_type AS "freightType", b.trade_direction AS "tradeDirection",
|
||||
cgt.code AS "cargoTypeCode"
|
||||
cgt.code AS "cargoTypeCode",
|
||||
company.name AS customer
|
||||
FROM freight.bookings b
|
||||
LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL
|
||||
LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id
|
||||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||
WHERE b.status = ANY($1) AND b.deleted_at IS NULL AND inv.id IS NULL`,
|
||||
[this.ARRIVED_BOOKING_STATUSES],
|
||||
);
|
||||
@@ -1186,7 +1202,7 @@ export class WarehouseInventoryService {
|
||||
status: 'RECEIVED',
|
||||
arrivedAt: new Date(),
|
||||
...(booking.tradeDirection === 'EXPORT'
|
||||
? { grnNumber: this.generateGrnNumber('EXPORT', booking.id, new Date()) }
|
||||
? { grnNumber: this.generateGrnNumber('EXPORT', booking.id, new Date(), booking.customer) }
|
||||
: {}),
|
||||
notes: allocated?.rule ? `Auto-unloaded → ${allocated.path}` : 'Auto-unloaded from arrival queue',
|
||||
});
|
||||
@@ -1211,12 +1227,19 @@ export class WarehouseInventoryService {
|
||||
// A GRN is the receipt for cargo entering the warehouse, so every booking
|
||||
// gets one on unload — import as well as export. The direction only decides
|
||||
// the GRN prefix, not whether one is issued.
|
||||
const [bookingRow]: Array<{ tradeDirection: string | null }> = await this.dataSource.query(
|
||||
`SELECT trade_direction AS "tradeDirection"
|
||||
FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
|
||||
[bookingId],
|
||||
);
|
||||
// The GRN is mapped to the goods owner (the booking's company), so pull it
|
||||
// alongside the direction rather than issuing an owner-less number.
|
||||
const [bookingRow]: Array<{ tradeDirection: string | null; ownerName: string | null }> =
|
||||
await this.dataSource.query(
|
||||
`SELECT b.trade_direction AS "tradeDirection",
|
||||
company.name AS "ownerName"
|
||||
FROM freight.bookings b
|
||||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||
WHERE b.id = $1 AND b.deleted_at IS NULL`,
|
||||
[bookingId],
|
||||
);
|
||||
const grnDirection = bookingRow?.tradeDirection ?? 'WH';
|
||||
const ownerName = bookingRow?.ownerName ?? null;
|
||||
|
||||
let location: DefaultLocation | null =
|
||||
dto.warehouseId && dto.yardId && dto.zoneId
|
||||
@@ -1240,7 +1263,7 @@ export class WarehouseInventoryService {
|
||||
// Keep an already-issued GRN rather than reissuing; mint one otherwise.
|
||||
...(existing[0].grnNumber
|
||||
? {}
|
||||
: { grnNumber: this.generateGrnNumber(grnDirection, bookingId, arrivedAt) }),
|
||||
: { grnNumber: this.generateGrnNumber(grnDirection, bookingId, arrivedAt, ownerName) }),
|
||||
notes: dto.notes ?? existing[0].notes ?? 'Unloaded',
|
||||
});
|
||||
return this.findById(existing[0].id);
|
||||
@@ -1255,7 +1278,7 @@ export class WarehouseInventoryService {
|
||||
weight: 0,
|
||||
status: 'RECEIVED',
|
||||
arrivedAt,
|
||||
grnNumber: this.generateGrnNumber(grnDirection, bookingId, arrivedAt),
|
||||
grnNumber: this.generateGrnNumber(grnDirection, bookingId, arrivedAt, ownerName),
|
||||
notes: dto.notes ?? 'Unloaded',
|
||||
});
|
||||
return this.findById(saved.id);
|
||||
@@ -1565,7 +1588,7 @@ export class WarehouseInventoryService {
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const grnNumber = this.generateGrnNumber(dto.direction, bookingId, now);
|
||||
const grnNumber = this.generateGrnNumber(dto.direction, bookingId, now, booking.customer);
|
||||
const truckEntrance = dto.truckEntrance
|
||||
? this.mergeSystemTruckEntrance(dto.truckEntrance, booking)
|
||||
: undefined;
|
||||
@@ -1981,6 +2004,7 @@ export class WarehouseInventoryService {
|
||||
WHERE lm.booking_id = b.id
|
||||
AND lm.vehicle_id IS NOT NULL
|
||||
AND lm.deleted_at IS NULL)) AS "hasAssignedTruck",
|
||||
b.double_handling AS "doubleHandling",
|
||||
inv.status AS "currentStatus",
|
||||
inv.release_date AS "releaseDate",
|
||||
inv.release_order_reference AS "releaseOrderReference",
|
||||
@@ -2130,6 +2154,8 @@ export class WarehouseInventoryService {
|
||||
const bookings: {
|
||||
id: string;
|
||||
status: string;
|
||||
/** Goods owner (company) — the GRN number is mapped to it. */
|
||||
customer: string | null;
|
||||
weight: string | null;
|
||||
freightType: string | null;
|
||||
tradeDirection: string | null;
|
||||
@@ -2140,12 +2166,24 @@ export class WarehouseInventoryService {
|
||||
// at an intermediate yard was already unloaded there by the checkpoint
|
||||
// auto-unload; without this filter it would be mis-located into the final
|
||||
// yard's inventory too.
|
||||
`SELECT b.id, b.status, b.cargo_total_weight_vgm AS weight,
|
||||
`SELECT b.id, b.status,
|
||||
-- Same fallback as autoUnloadArrived: never land a 0 t receipt when
|
||||
-- the booking's containers carry a VGM.
|
||||
COALESCE(
|
||||
NULLIF(b.cargo_total_weight_vgm, 0),
|
||||
(SELECT SUM(bcu.vgm_tons)
|
||||
FROM freight.booking_container_units bcu
|
||||
JOIN freight.booking_container bc2
|
||||
ON bc2.id = bcu.booking_container_id AND bc2.deleted_at IS NULL
|
||||
WHERE bc2.booking_id = b.id AND bcu.deleted_at IS NULL)
|
||||
) AS weight,
|
||||
b.freight_type AS "freightType", b.trade_direction AS "tradeDirection",
|
||||
cgt.code AS "cargoTypeCode"
|
||||
cgt.code AS "cargoTypeCode",
|
||||
company.name AS customer
|
||||
FROM freight.train_schedule_bookings tsb
|
||||
JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL
|
||||
LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id
|
||||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||
WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL
|
||||
AND b.destination_yard_id = $2`,
|
||||
[scheduleId, schedule.destinationStationId],
|
||||
@@ -2203,11 +2241,16 @@ export class WarehouseInventoryService {
|
||||
zoneId: unloadLocation.zoneId,
|
||||
}
|
||||
: {}),
|
||||
// Record the received weight on a row that never carried one — the
|
||||
// GRN prints this, and an existing non-zero weight is left alone.
|
||||
...(Number(existing.weight) > 0 || !(Number(booking.weight) > 0)
|
||||
? {}
|
||||
: { weight: Number(booking.weight) }),
|
||||
status: 'UNLOADED',
|
||||
unloadedAt: now,
|
||||
arrivedAt: existing.arrivedAt ?? now,
|
||||
// Import GRN is issued automatically at train unload.
|
||||
...(existing.grnNumber ? {} : { grnNumber: this.generateGrnNumber('IMPORT', booking.id, now) }),
|
||||
...(existing.grnNumber ? {} : { grnNumber: this.generateGrnNumber('IMPORT', booking.id, now, booking.customer) }),
|
||||
});
|
||||
await this.activityLog.record({
|
||||
activityType: 'INVENTORY_UNLOADED',
|
||||
@@ -2216,6 +2259,22 @@ export class WarehouseInventoryService {
|
||||
description: 'Unloaded from arrived import train',
|
||||
performedBy,
|
||||
});
|
||||
// Capacity follows the recorded weight: deliver() decrements by the
|
||||
// item's weight, so a weight written here must be counted here too.
|
||||
const addedWeight = Number(booking.weight) - Number(existing.weight ?? 0);
|
||||
if (addedWeight > 0) {
|
||||
await this.applyCapacityDelta(
|
||||
this.dataSource.manager,
|
||||
{
|
||||
warehouseId: unloadLocation?.warehouseId ?? existing.warehouseId,
|
||||
yardId: unloadLocation?.yardId ?? existing.yardId,
|
||||
zoneId: unloadLocation?.zoneId ?? existing.zoneId,
|
||||
},
|
||||
addedWeight,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
}
|
||||
result.unloadedCount += 1;
|
||||
result.results.push({ bookingId: booking.id, inventoryId: existing.id, status: 'UNLOADED' });
|
||||
continue;
|
||||
@@ -2241,7 +2300,7 @@ export class WarehouseInventoryService {
|
||||
quantity: 1,
|
||||
weight: Number(booking.weight) || 0,
|
||||
status: 'UNLOADED',
|
||||
grnNumber: this.generateGrnNumber('IMPORT', booking.id, now),
|
||||
grnNumber: this.generateGrnNumber('IMPORT', booking.id, now, booking.customer),
|
||||
arrivedAt: now,
|
||||
unloadedAt: now,
|
||||
notes: allocated?.rule ? `Unloaded → ${allocated.path}` : 'Unloaded from arrived import train',
|
||||
@@ -2253,6 +2312,11 @@ export class WarehouseInventoryService {
|
||||
description: 'Unloaded from arrived import train',
|
||||
performedBy,
|
||||
});
|
||||
// New goods physically in the warehouse — count them, or deliver() would
|
||||
// later free capacity that was never taken.
|
||||
if (Number(saved.weight) > 0) {
|
||||
await this.applyCapacityDelta(this.dataSource.manager, location, Number(saved.weight), 0, 0);
|
||||
}
|
||||
result.unloadedCount += 1;
|
||||
result.results.push({ bookingId: booking.id, inventoryId: saved.id, status: 'UNLOADED' });
|
||||
} catch (error) {
|
||||
@@ -2670,7 +2734,12 @@ export class WarehouseInventoryService {
|
||||
this.assertCapacity('Zone', zone, weight, volume, containerCount);
|
||||
|
||||
const now = new Date();
|
||||
const grnNumber = this.generateGrnNumber(bookingDirection ?? 'WH', dto.bookingId ?? 'MANUAL', now);
|
||||
const grnNumber = this.generateGrnNumber(
|
||||
bookingDirection ?? 'WH',
|
||||
dto.bookingId ?? 'MANUAL',
|
||||
now,
|
||||
truckEntrance?.ownerName ?? bookingSource?.customer,
|
||||
);
|
||||
const receiveNote = this.buildReceiveNote({
|
||||
grnNumber,
|
||||
notes: dto.notes?.trim() || 'Single booking received',
|
||||
@@ -3610,6 +3679,7 @@ export class WarehouseInventoryService {
|
||||
contractId: string | null;
|
||||
hasLastMile: boolean;
|
||||
handoverSigned: boolean;
|
||||
inspectionStatus: string | null;
|
||||
}>
|
||||
> {
|
||||
const rows: Array<{
|
||||
@@ -3627,6 +3697,7 @@ export class WarehouseInventoryService {
|
||||
contractId: string | null;
|
||||
hasLastMile: boolean;
|
||||
delivered: boolean;
|
||||
inspectionStatus: string | null;
|
||||
}> = await this.dataSource.query(
|
||||
`SELECT bcu.container_number AS "containerNumber",
|
||||
COALESCE(ct.cargo_type_name, b.cargo_free_text) AS goods,
|
||||
@@ -3641,7 +3712,8 @@ export class WarehouseInventoryService {
|
||||
b.reference AS "bookingReference",
|
||||
b.contract_id AS "contractId",
|
||||
(b.last_mile_delivery_address IS NOT NULL) AS "hasLastMile",
|
||||
COALESCE(inv.status = 'DELIVERED', false) AS delivered
|
||||
COALESCE(inv.status = 'DELIVERED', false) AS delivered,
|
||||
inv.inspection_status AS "inspectionStatus"
|
||||
FROM freight.booking_container_units bcu
|
||||
JOIN freight.booking_container bc
|
||||
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
|
||||
@@ -3693,6 +3765,7 @@ export class WarehouseInventoryService {
|
||||
bookingReference: r.bookingReference,
|
||||
contractId: r.contractId,
|
||||
hasLastMile: r.hasLastMile,
|
||||
inspectionStatus: r.inspectionStatus,
|
||||
handoverSigned,
|
||||
}));
|
||||
}
|
||||
@@ -3944,7 +4017,10 @@ export class WarehouseInventoryService {
|
||||
COALESCE(inv.grn_number, substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')) AS "grnNumber",
|
||||
COALESCE(inv.arrived_at, inv.created_at) AS "receivedAt",
|
||||
inv.quantity,
|
||||
inv.weight,
|
||||
-- An unweighed item still reports the cargo weight it holds: fall
|
||||
-- back to the item's container VGM, then the booking's declared
|
||||
-- weight, so a GRN never prints "0 t" for goods that are present.
|
||||
COALESCE(NULLIF(inv.weight, 0), item_vgm.tons, b.cargo_total_weight_vgm, 0) AS weight,
|
||||
inv.volume,
|
||||
inv.status,
|
||||
inv.notes,
|
||||
@@ -3992,6 +4068,16 @@ export class WarehouseInventoryService {
|
||||
WHERE bc.booking_id = b.id
|
||||
AND bc.deleted_at IS NULL
|
||||
) booking_container ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT SUM(bcu.vgm_tons) AS tons
|
||||
FROM freight.booking_container_units bcu
|
||||
JOIN freight.booking_container bc2
|
||||
ON bc2.id = bcu.booking_container_id AND bc2.deleted_at IS NULL
|
||||
WHERE bc2.booking_id = b.id
|
||||
AND bcu.deleted_at IS NULL
|
||||
AND (container.container_number IS NULL
|
||||
OR bcu.container_number = container.container_number)
|
||||
) item_vgm ON true
|
||||
LEFT JOIN freight.cargoes cargo ON cargo.id = inv.cargo_id AND cargo.deleted_at IS NULL
|
||||
LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = COALESCE(cargo.cargo_type_id, b.cargo_type_id)
|
||||
WHERE inv.id = $1 AND inv.deleted_at IS NULL
|
||||
@@ -4379,6 +4465,81 @@ export class WarehouseInventoryService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Record whether a booking's goods needed double handling. Answered by
|
||||
* warehouse staff once the goods are unloaded — only Yes bills the
|
||||
* DOUBLE_HANDLING_FEE rule (see WarehouseFeeService.computeDoubleHandling).
|
||||
* Locked once the fee has been invoiced, so a billed charge can't be
|
||||
* retro-cancelled from the operations screen.
|
||||
*/
|
||||
async setDoubleHandling(
|
||||
bookingId: string,
|
||||
doubleHandling: boolean,
|
||||
performedBy?: string,
|
||||
): Promise<{ bookingId: string; doubleHandling: boolean; setAt: string }> {
|
||||
const [booking]: Array<{ id: string; tradeDirection: string | null; reference: string | null }> =
|
||||
await this.dataSource.query(
|
||||
`SELECT id, trade_direction AS "tradeDirection", reference
|
||||
FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
|
||||
[bookingId],
|
||||
);
|
||||
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||
if ((booking.tradeDirection ?? '').toUpperCase() !== 'IMPORT') {
|
||||
throw new BadRequestException('Double handling applies to import bookings only');
|
||||
}
|
||||
|
||||
// Warehouse fees are billed per inventory row (invoices.source = 'warehouse',
|
||||
// source_id = the inventory id), with the fee type on the line's charge_type.
|
||||
const [invoiced]: Array<{ one: number }> = await this.dataSource.query(
|
||||
`SELECT 1 AS one
|
||||
FROM freight.invoices i
|
||||
JOIN freight.invoice_lines il ON il.invoice_id = i.id AND il.deleted_at IS NULL
|
||||
JOIN freight.warehouse_inventory inv
|
||||
ON inv.id::text = i.source_id AND inv.deleted_at IS NULL
|
||||
WHERE inv.booking_id = $1
|
||||
AND i.source = 'warehouse'
|
||||
AND i.deleted_at IS NULL
|
||||
AND i.status <> 'CANCELLED'
|
||||
AND il.charge_type = 'DOUBLE_HANDLING'
|
||||
LIMIT 1`,
|
||||
[bookingId],
|
||||
);
|
||||
if (invoiced) {
|
||||
throw new BadRequestException(
|
||||
'Double handling has already been invoiced for this booking — cancel the invoice to change it',
|
||||
);
|
||||
}
|
||||
|
||||
const setAt = new Date();
|
||||
await this.dataSource.query(
|
||||
`UPDATE freight.bookings
|
||||
SET double_handling = $2,
|
||||
double_handling_set_at = $3,
|
||||
double_handling_set_by = $4,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1`,
|
||||
[bookingId, doubleHandling, setAt, performedBy ?? null],
|
||||
);
|
||||
|
||||
// Audit on the booking's inventory rows so it shows in warehouse history.
|
||||
const items: Array<{ id: string; warehouseId: string | null }> = await this.dataSource.query(
|
||||
`SELECT id, warehouse_id AS "warehouseId" FROM freight.warehouse_inventory
|
||||
WHERE booking_id = $1 AND deleted_at IS NULL`,
|
||||
[bookingId],
|
||||
);
|
||||
for (const item of items) {
|
||||
await this.activityLog.record({
|
||||
activityType: 'INVENTORY_STORED',
|
||||
inventoryId: item.id,
|
||||
warehouseId: item.warehouseId,
|
||||
description: `Double handling set to ${doubleHandling ? 'YES — fee rule applies' : 'NO'}`,
|
||||
performedBy,
|
||||
});
|
||||
}
|
||||
|
||||
return { bookingId, doubleHandling, setAt: setAt.toISOString() };
|
||||
}
|
||||
|
||||
/** Resolve the primary warehouse-inventory item for a booking (most recent). */
|
||||
private async primaryInventoryIdForBooking(bookingId: string): Promise<string> {
|
||||
const [inv]: Array<{ id: string }> = await this.dataSource.query(
|
||||
@@ -5213,7 +5374,9 @@ export class WarehouseInventoryService {
|
||||
});
|
||||
const rows: Array<[string, unknown]> = [
|
||||
['Booking Reference', data.bookingReference],
|
||||
['Customer / Consignee', data.customerName],
|
||||
// The GRN is mapped to the owner (import: consignee, export: shipper) —
|
||||
// named explicitly so the note reads the same for both directions.
|
||||
["Owner's Name", data.customerName],
|
||||
['Customer TIN', data.customerTin],
|
||||
['Booking Status', data.bookingStatus],
|
||||
['Service Type', data.serviceType],
|
||||
@@ -5844,8 +6007,13 @@ export class WarehouseInventoryService {
|
||||
}
|
||||
|
||||
/** Shared with the facility handling flow — see common/grn.util.ts. */
|
||||
private generateGrnNumber(direction: string, referenceId: string, date: Date): string {
|
||||
return generateGrnNumber(direction, referenceId, date);
|
||||
private generateGrnNumber(
|
||||
direction: string,
|
||||
referenceId: string,
|
||||
date: Date,
|
||||
ownerName?: string | null,
|
||||
): string {
|
||||
return generateGrnNumber(direction, referenceId, date, ownerName);
|
||||
}
|
||||
|
||||
private async generateReleaseReference(item: WarehouseInventory): Promise<string> {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { DeepPartial, Repository } from 'typeorm';
|
||||
|
||||
import { CargoType } from '../rule-engine/entities/cargo-type.entity';
|
||||
import { WarehouseYard } from './entities/warehouse-yard.entity';
|
||||
|
||||
@Injectable()
|
||||
@@ -10,4 +11,20 @@ export class WarehouseYardsRepository extends BaseRepository<WarehouseYard> {
|
||||
constructor(@InjectRepository(WarehouseYard) repository: Repository<WarehouseYard>) {
|
||||
super(repository);
|
||||
}
|
||||
|
||||
/** The cargoTypes relation can't ride a column UPDATE — sync it via entity save, like the plain columns. */
|
||||
async update(id: string, data: DeepPartial<WarehouseYard>): Promise<WarehouseYard | null> {
|
||||
const { cargoTypes, ...columns } = data;
|
||||
if (Object.keys(columns).length) {
|
||||
await this.repository.update(id, columns as never);
|
||||
}
|
||||
if (cargoTypes) {
|
||||
const entity = await this.repository.findOne({ where: { id } as never });
|
||||
if (entity) {
|
||||
entity.cargoTypes = cargoTypes as CargoType[];
|
||||
await this.repository.save(entity);
|
||||
}
|
||||
}
|
||||
return this.findById(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
|
||||
import { CargoType } from '../rule-engine/entities/cargo-type.entity';
|
||||
import { CreateWarehouseYardDto } from './dto/create-warehouse-yard.dto';
|
||||
import { UpdateWarehouseYardDto } from './dto/update-warehouse-yard.dto';
|
||||
import { WarehouseYard } from './entities/warehouse-yard.entity';
|
||||
@@ -15,7 +16,7 @@ export class WarehouseYardsService {
|
||||
|
||||
findAll(): Promise<WarehouseYard[]> {
|
||||
return this.yardsRepository.findAll({
|
||||
relations: { warehouse: true, zones: true },
|
||||
relations: { warehouse: true, zones: true, cargoTypes: true },
|
||||
order: { code: 'ASC' },
|
||||
});
|
||||
}
|
||||
@@ -23,14 +24,14 @@ export class WarehouseYardsService {
|
||||
findByWarehouse(warehouseId: string): Promise<WarehouseYard[]> {
|
||||
return this.yardsRepository.findAll({
|
||||
where: { warehouseId },
|
||||
relations: { zones: true },
|
||||
relations: { zones: true, cargoTypes: true },
|
||||
order: { code: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<WarehouseYard> {
|
||||
const yard = await this.yardsRepository.findById(id, {
|
||||
relations: { warehouse: true, zones: true },
|
||||
relations: { warehouse: true, zones: true, cargoTypes: true },
|
||||
});
|
||||
|
||||
if (!yard) {
|
||||
@@ -51,6 +52,7 @@ export class WarehouseYardsService {
|
||||
name: dto.name.trim(),
|
||||
code: dto.code.trim(),
|
||||
type: dto.type,
|
||||
direction: dto.direction ?? null,
|
||||
capacityWeight: dto.capacityWeight ?? null,
|
||||
capacityContainers: dto.capacityContainers ?? null,
|
||||
maxWeight: dto.maxWeight ?? dto.capacityWeight ?? null,
|
||||
@@ -60,6 +62,8 @@ export class WarehouseYardsService {
|
||||
currentVolume: 0,
|
||||
status: 'ACTIVE',
|
||||
isActive: true,
|
||||
// Join rows are written by the save (RESTRICT FK rejects unknown ids).
|
||||
cargoTypes: (dto.cargoTypeIds ?? []).map((id) => ({ id }) as CargoType),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -84,12 +88,16 @@ export class WarehouseYardsService {
|
||||
name: dto.name?.trim() ?? existing.name,
|
||||
code: dto.code?.trim() ?? existing.code,
|
||||
type: dto.type ?? existing.type,
|
||||
direction: dto.direction ?? existing.direction,
|
||||
capacityWeight: newCapacityWeight,
|
||||
capacityContainers: newCapacityContainers,
|
||||
maxWeight: dto.maxWeight ?? existing.maxWeight,
|
||||
maxVolume: dto.maxVolume ?? existing.maxVolume,
|
||||
status,
|
||||
isActive: status === 'ACTIVE',
|
||||
...(dto.cargoTypeIds
|
||||
? { cargoTypes: dto.cargoTypeIds.map((cargoTypeId) => ({ id: cargoTypeId }) as CargoType) }
|
||||
: {}),
|
||||
});
|
||||
|
||||
if (!updated) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Injectable, Logger } from "@nestjs/common";
|
||||
import { DataSource } from "typeorm";
|
||||
|
||||
import { FileUploadSetting } from "../modules/file-upload-settings/entities/file-upload-setting.entity";
|
||||
import { poaDelegationField } from "../modules/file-upload-settings/poa-delegation.constants";
|
||||
|
||||
interface OnboardingField {
|
||||
fileKey: string;
|
||||
@@ -17,27 +18,14 @@ interface OnboardingField {
|
||||
|
||||
const DOC_EXTENSIONS = ["pdf", "jpg", "jpeg", "png"];
|
||||
|
||||
/** fileKey of the delegation letter attached to the Power of Attorney step. */
|
||||
export const POA_DELEGATION_FILE_KEY = "poa_delegation_letter";
|
||||
|
||||
/**
|
||||
* Seeded as optional: the delegation letter is only mandatory once a PoA has
|
||||
* been entered, or when the company operates as a freight forwarder. That rule
|
||||
* spans form fields as well as files, so it lives in the onboarding gate
|
||||
* (companies.service.getOnboardingRequirements) rather than in `isRequired`.
|
||||
* Listed in the sets below only so the reference defaults stay a complete
|
||||
* picture of a company onboarding form. Unlike every other field here, the DARS
|
||||
* delegation paper is not admin-managed: `FileUploadSettingsService.getByCode`
|
||||
* injects it from poa-delegation.constants.ts whether or not a row exists.
|
||||
*/
|
||||
const poaDelegationField = (displayOrder: number): OnboardingField => ({
|
||||
fileKey: POA_DELEGATION_FILE_KEY,
|
||||
fileLabel: "PoA Delegation Letter",
|
||||
helpText:
|
||||
"Signed letter in which the General Manager delegates the representative named above.",
|
||||
isRequired: false,
|
||||
isMultiple: false,
|
||||
maxFiles: 1,
|
||||
allowedExtensions: DOC_EXTENSIONS,
|
||||
maxSizeMb: 10,
|
||||
displayOrder,
|
||||
});
|
||||
const poaDelegationDefault = (displayOrder: number): OnboardingField =>
|
||||
poaDelegationField(displayOrder) as unknown as OnboardingField;
|
||||
|
||||
/** Documents required from an Ethiopian company at onboarding. */
|
||||
const ETHIOPIAN_ONBOARDING_FIELDS: OnboardingField[] = [
|
||||
@@ -75,7 +63,7 @@ const ETHIOPIAN_ONBOARDING_FIELDS: OnboardingField[] = [
|
||||
maxSizeMb: 10,
|
||||
displayOrder: 3,
|
||||
},
|
||||
poaDelegationField(4),
|
||||
poaDelegationDefault(4),
|
||||
];
|
||||
|
||||
/** Documents required from a Foreign company at onboarding. */
|
||||
@@ -124,7 +112,7 @@ const FOREIGN_ONBOARDING_FIELDS: OnboardingField[] = [
|
||||
maxSizeMb: 10,
|
||||
displayOrder: 4,
|
||||
},
|
||||
poaDelegationField(5),
|
||||
poaDelegationDefault(5),
|
||||
];
|
||||
|
||||
/** Legacy combined set, kept for the older per-company-type codes. */
|
||||
|
||||
@@ -121,6 +121,7 @@ import ArrivalQueuePage from "./pages/warehouses/ArrivalQueuePage";
|
||||
import DispatchQueuePage from "./pages/warehouses/DispatchQueuePage";
|
||||
import ExportDjiboutiUnloadingQueuePage from "./pages/warehouses/ExportDjiboutiUnloadingQueuePage";
|
||||
import ExportWarehouseFlowPage from "./pages/warehouses/ExportWarehouseFlowPage";
|
||||
import ImportTrucksPage from "./pages/warehouses/ImportTrucksPage";
|
||||
import ImportWarehouseFlowPage from "./pages/warehouses/ImportWarehouseFlowPage";
|
||||
import InterchangeDocumentsPage from "./pages/warehouses/InterchangeDocumentsPage";
|
||||
import InventoryInquiryPage from "./pages/warehouses/InventoryInquiryPage";
|
||||
@@ -404,6 +405,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
icon: <PackageOpen />,
|
||||
permission: FREIGHT_PERMS.warehouseInventory.view,
|
||||
},
|
||||
{
|
||||
label: "Import Trucks",
|
||||
href: "/dashboard/import-trucks",
|
||||
icon: <Truck />,
|
||||
permission: FREIGHT_PERMS.warehouseInventory.view,
|
||||
},
|
||||
{
|
||||
label: "Dispatch Queue",
|
||||
href: "/dashboard/dispatch-queue",
|
||||
@@ -1052,6 +1059,7 @@ const App = () => {
|
||||
<Route path="loading-queue" element={<LoadingQueuePage />} />
|
||||
<Route path="intercity" element={<IntercityPage />} />
|
||||
<Route path="trucks-on-site" element={<TrucksOnSitePage />} />
|
||||
<Route path="import-trucks" element={<ImportTrucksPage />} />
|
||||
<Route path="loaded-inventory" element={<LoadedInventoryPage />} />
|
||||
<Route path="dispatch-queue" element={<DispatchQueuePage />} />
|
||||
<Route
|
||||
|
||||
@@ -76,6 +76,10 @@ api.interceptors.request.use((config) => {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
// Tells the backend which app is asking, so /auth/login can reject
|
||||
// cross-audience credentials (EDRFREIGHT-415).
|
||||
config.headers["X-Client-App"] = "backoffice";
|
||||
|
||||
return config;
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useQueries, useQuery } from "@tanstack/react-query";
|
||||
import { Badge, Button, Center, Group, Loader, SimpleGrid, Stack, Table, Text } from "@mantine/core";
|
||||
import { Coins, Truck } from "lucide-react";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import { warehouseService } from "@/services/warehouse.service";
|
||||
import { lastMileService } from "@/services/last-mile.service";
|
||||
import { FeePreviewModal } from "@/components/warehouses/FeePreviewModal";
|
||||
import { TruckDetentionModal } from "@/components/operations/TruckDetentionModal";
|
||||
|
||||
import { SectionCard } from "./SectionCard";
|
||||
import { MetricTile } from "./MetricTile";
|
||||
|
||||
const money = (amount: number, currency: string) =>
|
||||
`${Number(amount).toLocaleString()} ${currency === "ETB" ? "Birr (ETB)" : currency}`;
|
||||
|
||||
const fmt = (iso: string | null | undefined) => (iso ? new Date(iso).toLocaleString() : "—");
|
||||
|
||||
function inspectionLabel(status: string | null | undefined): { text: string; color: string } {
|
||||
if (!status) return { text: "Pending", color: "gray" };
|
||||
if (status === "PASSED") return { text: "Passed", color: "edr-green" };
|
||||
if (status === "FAILED") return { text: "Failed", color: "red" };
|
||||
return { text: status, color: "gray" };
|
||||
}
|
||||
|
||||
interface TruckRow {
|
||||
key: string;
|
||||
plate: string;
|
||||
driver: string | null;
|
||||
truckType: string | null;
|
||||
containers: string[];
|
||||
warehouseArrived: string | null;
|
||||
warehouseDeparted: string | null;
|
||||
destinationArrived: string | null;
|
||||
returned: string | null;
|
||||
detentionOpen: boolean;
|
||||
detentionDays: number | null;
|
||||
detentionAmount: number | null;
|
||||
hasDetentionRule: boolean;
|
||||
inspection: { text: string; color: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Every truck tied to a booking's last mile — EDR-dispatched or customer
|
||||
* self-haul (a booking only ever uses one), each with its own warehouse-gate
|
||||
* and destination-detention clocks, plus the booking's cargo-side cost totals
|
||||
* (storage/demurrage/double handling — billed per row internally, always
|
||||
* shown here as one booking-level total). Detention stays EDR-only; customer
|
||||
* self-haul rows show "—" since EDR only bills detention on its own fleet.
|
||||
*/
|
||||
export function BookingTrucksPanel({ bookingId }: { bookingId: string }) {
|
||||
const [feeModalOpen, setFeeModalOpen] = useState(false);
|
||||
const [detentionModalOpen, setDetentionModalOpen] = useState(false);
|
||||
|
||||
const inventoryQuery = useQuery(
|
||||
api.warehouses.listInventory.queryOptions({ input: { filter: { bookingId } } }),
|
||||
);
|
||||
const inventoryItems = inventoryQuery.data ?? [];
|
||||
const latestInventory = inventoryItems[0] ?? null;
|
||||
|
||||
const edrTrucksQuery = useQuery({
|
||||
queryKey: ["booking-edr-trucks", bookingId],
|
||||
queryFn: () => warehouseService.getLastMileTrucks(bookingId),
|
||||
});
|
||||
const edrTrucks = edrTrucksQuery.data ?? [];
|
||||
|
||||
const customerTrucksQuery = useQuery({
|
||||
queryKey: ["booking-customer-trucks", bookingId],
|
||||
queryFn: () => warehouseService.getCustomerTrucks(bookingId),
|
||||
enabled: edrTrucksQuery.isSuccess && edrTrucks.length === 0,
|
||||
});
|
||||
const customerTrucks = customerTrucksQuery.data ?? [];
|
||||
|
||||
const mode: "EDR" | "CUSTOMER" | "NONE" =
|
||||
edrTrucks.length > 0 ? "EDR" : customerTrucks.length > 0 ? "CUSTOMER" : "NONE";
|
||||
|
||||
const containerItemsQuery = useQuery({
|
||||
queryKey: ["booking-container-items-for-trucks", bookingId],
|
||||
queryFn: () => warehouseService.getContainerItems(bookingId),
|
||||
});
|
||||
const inspectionByContainer = new Map(
|
||||
(containerItemsQuery.data ?? []).map((c) => [c.containerNumber, c.inspectionStatus]),
|
||||
);
|
||||
|
||||
const lastMileId = edrTrucks[0]?.lastMileId ?? null;
|
||||
|
||||
const detentionPreviewQuery = useQuery({
|
||||
queryKey: ["truck-detention-preview-for-trucks-tab", lastMileId],
|
||||
queryFn: () => lastMileService.truckDetentionPreview(lastMileId as string).then((r) => r.data),
|
||||
enabled: Boolean(lastMileId),
|
||||
});
|
||||
const detentionPreview = detentionPreviewQuery.data;
|
||||
const detentionByVehicle = new Map(
|
||||
(detentionPreview?.groups ?? []).map((g) => [g.vehicleId ?? "", g]),
|
||||
);
|
||||
|
||||
const lastMileRecordQuery = useQuery({
|
||||
queryKey: ["last-mile-record-for-trucks-tab", lastMileId],
|
||||
queryFn: () => lastMileService.getById(lastMileId as string).then((r) => r.data),
|
||||
enabled: Boolean(lastMileId),
|
||||
});
|
||||
|
||||
// Booking-level cost strip: same per-row fee preview the accrual dashboard
|
||||
// and FeePreviewModal already use, summed across every inventory row on
|
||||
// this booking rather than duplicated per row.
|
||||
const feeQueries = useQueries({
|
||||
queries: inventoryItems.map((item) =>
|
||||
api.warehouses.feePreview.queryOptions({ input: { inventoryId: item.id, billingCurrency: "USD" } }),
|
||||
),
|
||||
});
|
||||
const allFees = feeQueries.flatMap((q) => q.data ?? []);
|
||||
const feeCurrency = allFees[0]?.currency ?? "USD";
|
||||
const sumByType = (type: string) =>
|
||||
allFees.filter((f) => f.ruleType === type).reduce((sum, f) => sum + Number(f.amount || 0), 0);
|
||||
|
||||
const rows: TruckRow[] = useMemo(() => {
|
||||
if (mode === "EDR") {
|
||||
return edrTrucks.map((t) => {
|
||||
const g = detentionByVehicle.get(t.vehicleId);
|
||||
return {
|
||||
key: t.vehicleId,
|
||||
plate: [t.truckPlateNumber, t.trailerPlateNumber].filter(Boolean).join(" + ") || "—",
|
||||
driver: t.driverName,
|
||||
truckType: t.truckType,
|
||||
containers: t.containerNumber ? [t.containerNumber] : [],
|
||||
warehouseArrived: t.arrivedAt,
|
||||
warehouseDeparted: t.departedAt,
|
||||
destinationArrived: g?.startDate ?? null,
|
||||
returned: g?.endIsOpen ? null : g?.endDate ?? null,
|
||||
detentionOpen: Boolean(g?.endIsOpen),
|
||||
detentionDays: g?.chargeableDays ?? null,
|
||||
detentionAmount: g?.amount ?? null,
|
||||
hasDetentionRule: Boolean(g?.ruleId),
|
||||
inspection: inspectionLabel(t.containerNumber ? inspectionByContainer.get(t.containerNumber) : undefined),
|
||||
};
|
||||
});
|
||||
}
|
||||
if (mode === "CUSTOMER") {
|
||||
return customerTrucks.map((t) => {
|
||||
const containers = (t.containers ?? []).map((c) => c.containerNumber);
|
||||
const statuses = new Set(containers.map((cn) => inspectionByContainer.get(cn) ?? null));
|
||||
const inspection =
|
||||
containers.length === 0
|
||||
? inspectionLabel(undefined)
|
||||
: statuses.size > 1
|
||||
? { text: "Mixed", color: "yellow" }
|
||||
: inspectionLabel([...statuses][0]);
|
||||
return {
|
||||
key: t.id,
|
||||
plate: t.plateNumber,
|
||||
driver: t.driverName,
|
||||
truckType: t.truckType,
|
||||
containers,
|
||||
warehouseArrived: t.arrivedAt ?? null,
|
||||
warehouseDeparted: t.departedAt ?? null,
|
||||
destinationArrived: null,
|
||||
returned: null,
|
||||
detentionOpen: false,
|
||||
detentionDays: null,
|
||||
detentionAmount: null,
|
||||
hasDetentionRule: false,
|
||||
inspection,
|
||||
};
|
||||
});
|
||||
}
|
||||
return [];
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [mode, edrTrucks, customerTrucks, inspectionByContainer, detentionByVehicle]);
|
||||
|
||||
if (inventoryQuery.isLoading || edrTrucksQuery.isLoading) {
|
||||
return (
|
||||
<Center py={60}>
|
||||
<Group gap={10}>
|
||||
<Loader color="edr-green" />
|
||||
<Text c="dimmed">Loading trucks…</Text>
|
||||
</Group>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<SectionCard
|
||||
icon={Coins}
|
||||
title="Cargo costs"
|
||||
subtitle="Storage, demurrage & double handling — booking total"
|
||||
accent="teal"
|
||||
extra={
|
||||
latestInventory && (
|
||||
<Button size="xs" variant="light" onClick={() => setFeeModalOpen(true)}>
|
||||
View breakdown
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="sm">
|
||||
<MetricTile label="Storage" value={money(sumByType("STORAGE_FEE"), feeCurrency)} />
|
||||
<MetricTile label="Demurrage" value={money(sumByType("DEMURRAGE_FEE"), feeCurrency)} />
|
||||
<MetricTile label="Double handling" value={money(sumByType("DOUBLE_HANDLING_FEE"), feeCurrency)} />
|
||||
</SimpleGrid>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
icon={Truck}
|
||||
title="Trucks"
|
||||
subtitle={
|
||||
mode === "EDR" ? "EDR Last Mile" : mode === "CUSTOMER" ? "Customer Self-Haul" : undefined
|
||||
}
|
||||
accent="grape"
|
||||
extra={
|
||||
mode === "EDR" && (
|
||||
<Button size="xs" variant="light" onClick={() => setDetentionModalOpen(true)}>
|
||||
Detention times
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
{rows.length === 0 ? (
|
||||
<Text size="sm" c="dimmed" ta="center" py="md">
|
||||
No trucks assigned to this booking's last mile yet.
|
||||
</Text>
|
||||
) : (
|
||||
<Table.ScrollContainer minWidth={1000}>
|
||||
<Table verticalSpacing="xs" fz="xs">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Plate</Table.Th>
|
||||
<Table.Th>Driver</Table.Th>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Container(s)</Table.Th>
|
||||
<Table.Th>Wh. arrived</Table.Th>
|
||||
<Table.Th>Wh. departed</Table.Th>
|
||||
<Table.Th>Dest. arrived</Table.Th>
|
||||
<Table.Th>Returned</Table.Th>
|
||||
<Table.Th>Detention</Table.Th>
|
||||
<Table.Th>Inspection</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{rows.map((r) => (
|
||||
<Table.Tr key={r.key}>
|
||||
<Table.Td>{r.plate}</Table.Td>
|
||||
<Table.Td>{r.driver ?? "—"}</Table.Td>
|
||||
<Table.Td>{r.truckType ?? "—"}</Table.Td>
|
||||
<Table.Td>{r.containers.length ? r.containers.join(", ") : "—"}</Table.Td>
|
||||
<Table.Td>{fmt(r.warehouseArrived)}</Table.Td>
|
||||
<Table.Td>{fmt(r.warehouseDeparted)}</Table.Td>
|
||||
<Table.Td>{fmt(r.destinationArrived)}</Table.Td>
|
||||
<Table.Td>
|
||||
{r.detentionOpen ? (
|
||||
<Badge size="xs" color="orange" variant="light">
|
||||
still out
|
||||
</Badge>
|
||||
) : (
|
||||
fmt(r.returned)
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{mode !== "EDR" || r.detentionDays == null ? (
|
||||
"—"
|
||||
) : (
|
||||
<>
|
||||
{r.detentionDays}d · {money(r.detentionAmount ?? 0, detentionPreview?.currency ?? "USD")}
|
||||
{!r.hasDetentionRule && (
|
||||
<Text span size="xs" c="red">
|
||||
{" "}
|
||||
· no rule
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="xs" variant="light" color={r.inspection.color}>
|
||||
{r.inspection.text}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
<FeePreviewModal
|
||||
opened={feeModalOpen}
|
||||
onClose={() => setFeeModalOpen(false)}
|
||||
inventoryId={latestInventory?.id ?? null}
|
||||
/>
|
||||
{mode === "EDR" && (
|
||||
<TruckDetentionModal
|
||||
opened={detentionModalOpen}
|
||||
onClose={() => setDetentionModalOpen(false)}
|
||||
record={lastMileRecordQuery.data ?? null}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ export * from "./booking-detail.styles";
|
||||
export * from "./SectionCard";
|
||||
export * from "./ClearanceReviewSection";
|
||||
export * from "./BookingDocumentsPanel";
|
||||
export * from "./BookingTrucksPanel";
|
||||
export * from "./ContractOrdersPanel";
|
||||
export * from "./MetricTile";
|
||||
export * from "./BookingDetailToolbar";
|
||||
|
||||
@@ -59,6 +59,13 @@ const FIELD_LABELS: Record<string, string> = {
|
||||
woreda: "Woreda",
|
||||
kebele: "Kebele",
|
||||
houseNo: "House no.",
|
||||
statusDescription: "eTrade status",
|
||||
dateRegistered: "Date registered",
|
||||
renewedFrom: "Renewed from",
|
||||
renewalDate: "Renewal date",
|
||||
renewedTo: "Renewed to",
|
||||
etradePhone: "eTrade phone",
|
||||
ownerPassportNumber: "Owner passport number",
|
||||
};
|
||||
|
||||
/** Best-effort current value on the live company for a proposed field key. */
|
||||
@@ -85,6 +92,74 @@ function currentValue(company: Company, key: string): string {
|
||||
return v === null || v === undefined || v === "" ? "—" : String(v);
|
||||
}
|
||||
|
||||
/** Subject a staged `snapshot.faydaIdentity` blob belongs to, from which of its `*FaydaSub` keys is present. */
|
||||
function faydaIdentitySubject(
|
||||
snapshot: Record<string, unknown>,
|
||||
): "owner" | "poa" | null {
|
||||
if ("ownerFaydaSub" in snapshot) return "owner";
|
||||
if ("poaFaydaSub" in snapshot) return "poa";
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `stageIdentityChange` writes a nested `snapshot.faydaIdentity` object
|
||||
* (attrs-key names like `ownerEmail`, not top-level DTO keys), so the generic
|
||||
* `DiffRow` loop below can't render it — it would just stringify to
|
||||
* `[object Object]`. Render it as its own before/after block instead, using
|
||||
* the company's current `identity.owner`/`identity.poa` as the "before" side.
|
||||
*/
|
||||
function FaydaIdentityDiff({
|
||||
company,
|
||||
snapshot,
|
||||
}: {
|
||||
company: Company;
|
||||
snapshot: Record<string, unknown>;
|
||||
}) {
|
||||
const subject = faydaIdentitySubject(snapshot);
|
||||
if (!subject) return null;
|
||||
const current =
|
||||
subject === "owner" ? company.identity?.owner : company.identity?.poa;
|
||||
const read = (key: string) => snapshot[`${subject}${key}`] as string | undefined;
|
||||
const verifiedAt = read("FaydaVerifiedAt");
|
||||
const fields: { label: string; from?: string | null; to?: string }[] = [
|
||||
{ label: "Name", from: current?.name, to: read("Name") },
|
||||
{ label: "Email", from: current?.email, to: read("Email") },
|
||||
{ label: "Phone", from: current?.phone, to: read("Phone") },
|
||||
{ label: "Address", from: current?.address, to: read("Address") },
|
||||
].filter((f) => f.to !== undefined);
|
||||
|
||||
return (
|
||||
<Stack gap={8}>
|
||||
<Group gap={8}>
|
||||
<Text size="sm" fw={600} c="edr-text">
|
||||
{subject === "owner" ? "Owner re-verification" : "PoA re-verification"}
|
||||
</Text>
|
||||
{verifiedAt && (
|
||||
<Text size="xs" c="dimmed">
|
||||
Verified {formatDate(verifiedAt)}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
{fields.length > 0 ? (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
|
||||
{fields.map((f) => (
|
||||
<DiffRow
|
||||
key={f.label}
|
||||
label={f.label}
|
||||
from={f.from?.trim() ? f.from : "—"}
|
||||
to={f.to?.trim() ? f.to : "—"}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
Identity re-verified — no name/email/phone/address change.
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function DiffRow({
|
||||
label,
|
||||
from,
|
||||
@@ -153,8 +228,11 @@ export function ChangeRequestReview({ company }: { company: Company }) {
|
||||
if (!pending && history.length === 0) return null;
|
||||
|
||||
const proposedKeys = pending
|
||||
? Object.keys(pending.snapshot ?? {})
|
||||
? Object.keys(pending.snapshot ?? {}).filter((k) => k !== "faydaIdentity")
|
||||
: ([] as string[]);
|
||||
const faydaIdentitySnapshot = pending?.snapshot?.faydaIdentity as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const docCount = pending?.documentFileIds?.length ?? 0;
|
||||
const licenseChanges = pending?.licenseChanges ?? [];
|
||||
const documentChanges = pending?.documentChanges ?? [];
|
||||
@@ -209,10 +287,14 @@ export function ChangeRequestReview({ company }: { company: Company }) {
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
) : !faydaIdentitySnapshot ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
No field changes — document uploads only.
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{faydaIdentitySnapshot && (
|
||||
<FaydaIdentityDiff company={company} snapshot={faydaIdentitySnapshot} />
|
||||
)}
|
||||
|
||||
{documentChanges.length > 0 && (
|
||||
|
||||
@@ -43,21 +43,56 @@ function Stat({ label, value, strong }: { label: string; value: React.ReactNode;
|
||||
);
|
||||
}
|
||||
|
||||
type TruckRow = {
|
||||
vehicleId: string;
|
||||
label: string;
|
||||
arrived: Date | null;
|
||||
returned: Date | null;
|
||||
};
|
||||
|
||||
const plateOf = (a: NonNullable<LastMileRecord['vehicleAssignments']>[number]) =>
|
||||
[a.vehicle?.code, a.vehicle?.plateNumber].filter(Boolean).join(' · ') || a.vehicleId;
|
||||
|
||||
/**
|
||||
* View/override the detention clock (arrival + delivery/return) for a last-mile
|
||||
* leg, preview the per-truck-per-day charge, and generate the detention invoice.
|
||||
* Detention is PER TRUCK: every truck reaches the destination and is released at
|
||||
* its own time, so each row carries its own clock, days and amount. Legs with no
|
||||
* trucks assigned fall back to the single leg-level window.
|
||||
*/
|
||||
export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionModalProps) {
|
||||
const { toast } = useToast();
|
||||
const qc = useQueryClient();
|
||||
const id = record?.id ?? null;
|
||||
const assignments = record?.vehicleAssignments ?? [];
|
||||
const perTruck = assignments.length > 0;
|
||||
|
||||
const [rows, setRows] = useState<TruckRow[]>([]);
|
||||
// Leg-level fallback (no trucks assigned yet).
|
||||
const [arrived, setArrived] = useState<Date | null>(null);
|
||||
const [delivered, setDelivered] = useState<Date | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setRows(
|
||||
assignments.map((a) => ({
|
||||
vehicleId: a.vehicleId,
|
||||
label: plateOf(a),
|
||||
// Fall back to the leg-level pair so a truck without its own window
|
||||
// shows what it is actually being billed on today.
|
||||
arrived: a.destinationArrivedAt
|
||||
? new Date(a.destinationArrivedAt)
|
||||
: record?.arrivedAt
|
||||
? new Date(record.arrivedAt)
|
||||
: null,
|
||||
returned: a.returnedAt
|
||||
? new Date(a.returnedAt)
|
||||
: record?.deliveredAt
|
||||
? new Date(record.deliveredAt)
|
||||
: null,
|
||||
})),
|
||||
);
|
||||
setArrived(record?.arrivedAt ? new Date(record.arrivedAt) : null);
|
||||
setDelivered(record?.deliveredAt ? new Date(record.deliveredAt) : null);
|
||||
}, [record?.id, record?.arrivedAt, record?.deliveredAt, opened]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [record?.id, record?.arrivedAt, record?.deliveredAt, assignments.length, opened]);
|
||||
|
||||
const previewQuery = useQuery({
|
||||
queryKey: ['truck-detention-preview', id],
|
||||
@@ -65,19 +100,35 @@ export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionM
|
||||
enabled: opened && Boolean(id),
|
||||
});
|
||||
const preview = previewQuery.data;
|
||||
// With several trucks the header rule is null by design (each truck resolves
|
||||
// its own) — only warn when NO truck matched a rule.
|
||||
const hasAnyRule = Boolean(preview?.ruleId) || (preview?.groups ?? []).some((g) => g.ruleId);
|
||||
const byVehicle = new Map((preview?.groups ?? []).map((g) => [g.vehicleId ?? '', g]));
|
||||
|
||||
const saveTimes = useMutation({
|
||||
mutationFn: () =>
|
||||
lastMileService.update(id as string, {
|
||||
arrivedAt: arrived ? arrived.toISOString() : null,
|
||||
deliveredAt: delivered ? delivered.toISOString() : null,
|
||||
}),
|
||||
perTruck
|
||||
? lastMileService.setDetentionTimes(
|
||||
id as string,
|
||||
rows.map((r) => ({
|
||||
vehicleId: r.vehicleId,
|
||||
destinationArrivedAt: r.arrived ? r.arrived.toISOString() : null,
|
||||
returnedAt: r.returned ? r.returned.toISOString() : null,
|
||||
})),
|
||||
)
|
||||
: lastMileService.update(id as string, {
|
||||
arrivedAt: arrived ? arrived.toISOString() : null,
|
||||
deliveredAt: delivered ? delivered.toISOString() : null,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.ROOT });
|
||||
void previewQuery.refetch();
|
||||
toast({ title: 'Detention times saved' });
|
||||
},
|
||||
onError: () => toast({ title: 'Save failed', variant: 'destructive' }),
|
||||
onError: (e: unknown) => {
|
||||
const description = (e as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
toast({ title: 'Save failed', description, variant: 'destructive' });
|
||||
},
|
||||
});
|
||||
|
||||
const generate = useMutation({
|
||||
@@ -93,12 +144,40 @@ export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionM
|
||||
},
|
||||
});
|
||||
|
||||
const handleSave = () => {
|
||||
const values = perTruck
|
||||
? rows.flatMap((r) => [r.arrived, r.returned])
|
||||
: [arrived, delivered];
|
||||
// No backdating: detention times are recorded as they happen.
|
||||
if (values.some((v) => isBackdated(v))) {
|
||||
toast({ variant: 'destructive', title: 'Detention times cannot be in the past' });
|
||||
return;
|
||||
}
|
||||
const reversed = perTruck
|
||||
? rows.find((r) => r.arrived && r.returned && r.returned < r.arrived)
|
||||
: arrived && delivered && delivered < arrived
|
||||
? { label: 'this delivery' }
|
||||
: undefined;
|
||||
if (reversed) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Return time is before arrival',
|
||||
description: `Check the times for ${reversed.label}.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
saveTimes.mutate();
|
||||
};
|
||||
|
||||
const patchRow = (vehicleId: string, patch: Partial<TruckRow>) =>
|
||||
setRows((prev) => prev.map((r) => (r.vehicleId === vehicleId ? { ...r, ...patch } : r)));
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
centered
|
||||
size="lg"
|
||||
size="xl"
|
||||
title={
|
||||
<Text fw={700}>
|
||||
Truck detention{record?.booking?.reference ? ` · ${record.booking.reference}` : ''}
|
||||
@@ -106,40 +185,94 @@ export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionM
|
||||
}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Group grow align="flex-start">
|
||||
<DateTimePicker
|
||||
label="Arrived at"
|
||||
description="Detention clock start"
|
||||
value={arrived}
|
||||
onChange={(v) => setArrived(v ? new Date(v) : null)}
|
||||
minDate={new Date()}
|
||||
clearable
|
||||
/>
|
||||
<DateTimePicker
|
||||
label="Delivered / returned at"
|
||||
description="Clock end (blank = still out)"
|
||||
value={delivered}
|
||||
onChange={(v) => setDelivered(v ? new Date(v) : null)}
|
||||
minDate={new Date()}
|
||||
clearable
|
||||
/>
|
||||
</Group>
|
||||
{perTruck ? (
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" c="dimmed">
|
||||
Each truck has its own detention clock — record when it reached the destination and
|
||||
when it was released. Days and charges are calculated per truck.
|
||||
</Text>
|
||||
{rows.map((r) => {
|
||||
const g = byVehicle.get(r.vehicleId);
|
||||
return (
|
||||
<Paper key={r.vehicleId} withBorder p="sm" radius="md">
|
||||
<Group justify="space-between" mb={6} wrap="nowrap">
|
||||
<Group gap={8}>
|
||||
<Text size="sm" fw={600}>
|
||||
{r.label}
|
||||
</Text>
|
||||
{g?.vehicleType && (
|
||||
<Badge size="xs" variant="light" color="gray">
|
||||
{g.vehicleType}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
{g && (
|
||||
<Group gap={10} wrap="nowrap">
|
||||
<Text size="xs" c={g.endIsOpen ? 'orange' : 'dimmed'}>
|
||||
{g.chargeableDays} day{g.chargeableDays === 1 ? '' : 's'}
|
||||
{g.endIsOpen ? ' · still out' : ''}
|
||||
</Text>
|
||||
<Text size="sm" fw={700}>
|
||||
{money(g.amount, preview?.currency ?? 'USD')}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
<Group grow align="flex-start">
|
||||
<DateTimePicker
|
||||
label="Arrived at destination"
|
||||
description="Detention clock start"
|
||||
value={r.arrived}
|
||||
onChange={(v) => patchRow(r.vehicleId, { arrived: v ? new Date(v) : null })}
|
||||
minDate={new Date()}
|
||||
clearable
|
||||
/>
|
||||
<DateTimePicker
|
||||
label="Released / returned at"
|
||||
description="Clock end (blank = still out)"
|
||||
value={r.returned}
|
||||
onChange={(v) => patchRow(r.vehicleId, { returned: v ? new Date(v) : null })}
|
||||
minDate={new Date()}
|
||||
clearable
|
||||
/>
|
||||
</Group>
|
||||
{g && !g.ruleId && (
|
||||
<Text size="xs" c="red" mt={4}>
|
||||
No detention rule matches this truck type — it will not be billed.
|
||||
</Text>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
) : (
|
||||
<>
|
||||
<Text size="sm" c="dimmed">
|
||||
No trucks assigned yet — this records the delivery-level detention window. Assign
|
||||
trucks to track each one separately.
|
||||
</Text>
|
||||
<Group grow align="flex-start">
|
||||
<DateTimePicker
|
||||
label="Arrived at"
|
||||
description="Detention clock start"
|
||||
value={arrived}
|
||||
onChange={(v) => setArrived(v ? new Date(v) : null)}
|
||||
minDate={new Date()}
|
||||
clearable
|
||||
/>
|
||||
<DateTimePicker
|
||||
label="Delivered / returned at"
|
||||
description="Clock end (blank = still out)"
|
||||
value={delivered}
|
||||
onChange={(v) => setDelivered(v ? new Date(v) : null)}
|
||||
minDate={new Date()}
|
||||
clearable
|
||||
/>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
variant="light"
|
||||
loading={saveTimes.isPending}
|
||||
onClick={() => {
|
||||
// No backdating: detention times are recorded as they happen.
|
||||
if (isBackdated(arrived) || isBackdated(delivered)) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Detention times cannot be in the past',
|
||||
});
|
||||
return;
|
||||
}
|
||||
saveTimes.mutate();
|
||||
}}
|
||||
>
|
||||
<Button variant="light" loading={saveTimes.isPending} onClick={handleSave}>
|
||||
Save times
|
||||
</Button>
|
||||
</Group>
|
||||
@@ -154,7 +287,7 @@ export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionM
|
||||
<Alert color="gray" variant="light">
|
||||
No preview available.
|
||||
</Alert>
|
||||
) : !preview.ruleId ? (
|
||||
) : !hasAnyRule ? (
|
||||
<Alert color="orange" variant="light">
|
||||
No active Truck Detention rule matches this booking. Create one under Warehouse → Fee rules
|
||||
(rule type "Truck Detention Cost").
|
||||
@@ -162,39 +295,47 @@ export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionM
|
||||
) : (
|
||||
<Stack gap="sm">
|
||||
<Group grow>
|
||||
<Stat label="Chargeable days" value={preview.chargeableDays} />
|
||||
<Stat label="Longest detention" value={`${preview.chargeableDays} day(s)`} />
|
||||
<Stat label="Trucks" value={preview.containerCount} />
|
||||
<Stat label="Amount" value={money(preview.amount, preview.currency)} strong />
|
||||
<Stat label="Total amount" value={money(preview.amount, preview.currency)} strong />
|
||||
</Group>
|
||||
{preview.endIsOpen && (
|
||||
<Text size="xs" c="orange">
|
||||
Still accruing — no delivery/return time yet. The amount grows until the vehicle is returned.
|
||||
Still accruing — at least one truck has no release time yet. The amount grows until
|
||||
every truck is returned.
|
||||
</Text>
|
||||
)}
|
||||
{preview.groups && preview.groups.length > 1 ? (
|
||||
{preview.groups && preview.groups.length > 0 ? (
|
||||
<Table withTableBorder withColumnBorders verticalSpacing="xs" fz="xs">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Truck type</Table.Th>
|
||||
<Table.Th>Trucks</Table.Th>
|
||||
<Table.Th>Truck</Table.Th>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Days</Table.Th>
|
||||
<Table.Th ta="right">Rate / truck / day</Table.Th>
|
||||
<Table.Th ta="right">Rate / day</Table.Th>
|
||||
<Table.Th ta="right">Amount</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{preview.groups.map((g, i) => (
|
||||
<Table.Tr key={i}>
|
||||
<Table.Tr key={g.assignmentId ?? i}>
|
||||
<Table.Td>
|
||||
{g.vehicleType ?? 'Unknown'}
|
||||
{g.plateNumber ?? 'Unassigned'}
|
||||
{!g.ruleId && (
|
||||
<Text span size="xs" c="red">
|
||||
{' '}· no rule
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>{g.truckCount}</Table.Td>
|
||||
<Table.Td>{g.chargeableDays}</Table.Td>
|
||||
<Table.Td>{g.vehicleType ?? 'Unknown'}</Table.Td>
|
||||
<Table.Td>
|
||||
{g.chargeableDays}
|
||||
{g.endIsOpen && (
|
||||
<Text span size="xs" c="orange">
|
||||
{' '}· open
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">{money(g.ratePerDay, preview.currency)}</Table.Td>
|
||||
<Table.Td ta="right">{money(g.amount, preview.currency)}</Table.Td>
|
||||
</Table.Tr>
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import {
|
||||
Button,
|
||||
Divider,
|
||||
Group,
|
||||
Modal,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
} from '@mantine/core';
|
||||
import { DateTimePicker } from '@mantine/dates';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { lastMileService, type LastMileRecord } from '@/services/last-mile.service';
|
||||
|
||||
interface WarehouseGateTimesModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
record: LastMileRecord | null;
|
||||
}
|
||||
|
||||
const plateOf = (a: NonNullable<LastMileRecord['vehicleAssignments']>[number]) =>
|
||||
[a.vehicle?.code, a.vehicle?.plateNumber].filter(Boolean).join(' · ') || a.vehicleId;
|
||||
|
||||
export function WarehouseGateTimesModal({ opened, onClose, record }: WarehouseGateTimesModalProps) {
|
||||
const { toast } = useToast();
|
||||
const qc = useQueryClient();
|
||||
const id = record?.id ?? null;
|
||||
const assignments = record?.vehicleAssignments ?? [];
|
||||
|
||||
interface TruckRow {
|
||||
vehicleId: string;
|
||||
label: string;
|
||||
arrivedAt: Date | null;
|
||||
departedAt: Date | null;
|
||||
}
|
||||
|
||||
const [rows, setRows] = useState<Array<TruckRow>>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (assignments.length > 0) {
|
||||
setRows(
|
||||
assignments.map((a) => ({
|
||||
vehicleId: a.vehicleId,
|
||||
label: plateOf(a),
|
||||
arrivedAt: a.arrivedAt ? new Date(a.arrivedAt) : null,
|
||||
departedAt: a.departedAt ? new Date(a.departedAt) : null,
|
||||
})),
|
||||
);
|
||||
}
|
||||
}, [assignments, opened]);
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
if (!id) return Promise.resolve(null);
|
||||
return lastMileService.setWarehouseGateTimes(id, rows.map(r => ({
|
||||
vehicleId: r.vehicleId,
|
||||
arrivedAt: r.arrivedAt?.toISOString() ?? null,
|
||||
departedAt: r.departedAt?.toISOString() ?? null,
|
||||
})));
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: 'Warehouse gate times updated',
|
||||
});
|
||||
qc.invalidateQueries({ queryKey: ['last-mile-record-import-trucks', id] });
|
||||
onClose();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Failed to update warehouse gate times',
|
||||
description: error?.response?.data?.message || error?.message,
|
||||
});
|
||||
},
|
||||
onSettled: () => {
|
||||
setSaving(false);
|
||||
},
|
||||
});
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
await updateMutation.mutateAsync();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="Warehouse Gate Times" size="lg">
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
Set arrival (gate-in) and departure (gate-out) times for each truck.
|
||||
</Text>
|
||||
|
||||
{/* @ts-ignore - DateTimePicker type inference issue with row state */}
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Plate</Table.Th>
|
||||
<Table.Th>Arrived At (Gate-In)</Table.Th>
|
||||
<Table.Th>Departed At (Gate-Out)</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{rows.map((row: TruckRow, idx: number) => (
|
||||
<Table.Tr key={row.vehicleId}>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={600}>
|
||||
{row.label}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<DateTimePicker
|
||||
placeholder="Select arrival time"
|
||||
value={(row.arrivedAt as unknown) as Date | null}
|
||||
onChange={(date) => {
|
||||
const newRows = [...rows];
|
||||
newRows[idx] = { ...row, arrivedAt: date };
|
||||
setRows(newRows);
|
||||
}}
|
||||
clearable
|
||||
size="sm"
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<DateTimePicker
|
||||
placeholder="Select departure time"
|
||||
value={(row.departedAt as unknown) as Date | null}
|
||||
onChange={(date) => {
|
||||
const newRows = [...rows];
|
||||
newRows[idx] = { ...row, departedAt: date };
|
||||
setRows(newRows);
|
||||
}}
|
||||
clearable
|
||||
size="sm"
|
||||
/>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={onClose} disabled={saving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} loading={saving}>
|
||||
Save Times
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -37,6 +37,14 @@ function fmtDate(iso: string | null) {
|
||||
return new Date(iso).toLocaleDateString();
|
||||
}
|
||||
|
||||
const UNIT_LABEL_PLURAL: Record<string, string> = {
|
||||
container: 'Containers',
|
||||
truck: 'Trucks',
|
||||
ton: 'Tons',
|
||||
item: 'Items',
|
||||
};
|
||||
const unitLabelPlural = (unitLabel?: string) => UNIT_LABEL_PLURAL[unitLabel ?? 'container'] ?? 'Containers';
|
||||
|
||||
const money = (amount: number, currency: string) =>
|
||||
`${Number(amount).toLocaleString()} ${currency === 'ETB' ? 'Birr (ETB)' : currency}`;
|
||||
|
||||
@@ -72,8 +80,11 @@ function FeeCard({ fee }: { fee: FeePreview }) {
|
||||
<Row label="Period" value={`${fmtDate(fee.startDate)} → ${fmtDate(fee.endDate)}${fee.endIsOpen ? ' (today)' : ''}`} />
|
||||
<Row label="Elapsed days" value={String(fee.elapsedDays)} />
|
||||
<Row label="Chargeable days" value={`${fee.chargeableDays} (after ${fee.freeDays} free)`} />
|
||||
<Row label="Containers" value={String(fee.containerCount ?? 1)} />
|
||||
<Row label="Billable units" value={`${fee.billableUnits ?? fee.chargeableDays} container-day(s)`} />
|
||||
<Row label={unitLabelPlural(fee.unitLabel)} value={String(fee.containerCount ?? 1)} />
|
||||
<Row
|
||||
label="Billable units"
|
||||
value={`${fee.billableUnits ?? fee.chargeableDays} ${fee.unitLabel ?? 'container'}-day(s)`}
|
||||
/>
|
||||
{(fee.tiers ?? []).map((tier) => (
|
||||
<Row
|
||||
key={`${tier.appliedFromDay}-${tier.appliedToDay}-${tier.ratePerDay}`}
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
ArrowRightLeft,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
ClipboardCheck,
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
FileText,
|
||||
History,
|
||||
Info,
|
||||
Layers,
|
||||
MapPin,
|
||||
MoreHorizontal,
|
||||
PackageCheck,
|
||||
@@ -78,7 +80,7 @@ import { MoveInventoryModal } from './MoveInventoryModal';
|
||||
import { ReleaseOrderModal } from './ReleaseOrderModal';
|
||||
import { StoreInventoryModal } from './StoreInventoryModal';
|
||||
import { WarehouseInquiryTable } from './WarehouseInquiryTable';
|
||||
import { extractDownloadErrorMessage, extractErrorMessage, formatDate, formatNumber, inventoryStatusOptions } from './options';
|
||||
import { extractDownloadErrorMessage, extractErrorMessage, formatDate, formatNumber, inventoryStatusOptions, warehousesAtStation, yardsForBooking } from './options';
|
||||
import { openPdfBlob } from './pdf';
|
||||
import '@/components/overview/overview.css';
|
||||
|
||||
@@ -1976,14 +1978,6 @@ function LoadedExportTab({
|
||||
);
|
||||
}
|
||||
|
||||
const importLocationTypesForFreight = (freightType: string | null | undefined) => {
|
||||
const normalized = (freightType ?? '').toUpperCase();
|
||||
if (normalized === 'CONTAINER') {
|
||||
return { yardTypes: ['CONTAINER_YARD', 'GENERAL_CARGO_YARD'], zoneTypes: ['CONTAINER_ZONE', 'GENERAL_CARGO_ZONE'] };
|
||||
}
|
||||
return { yardTypes: ['BULK_YARD', 'GENERAL_CARGO_YARD'], zoneTypes: ['BULK_ZONE', 'GENERAL_CARGO_ZONE'] };
|
||||
};
|
||||
|
||||
const isImportContainerFreight = (freightType: string | null | undefined) =>
|
||||
(freightType ?? '').toUpperCase() === 'CONTAINER';
|
||||
|
||||
@@ -2037,10 +2031,60 @@ function ImportTrainDetailTable({
|
||||
enabled: Boolean(train.scheduleId),
|
||||
}),
|
||||
);
|
||||
const warehouseOptions = useMemo(
|
||||
() => warehouses.map((warehouse) => ({ value: warehouse.id, label: `${warehouse.name} (${warehouse.code})` })),
|
||||
[warehouses],
|
||||
// A train only ever unloads at the warehouse actually sitting at its
|
||||
// destination station — Indode's train never offers Sebeta's warehouse.
|
||||
const scopedWarehouses = useMemo(
|
||||
() => warehousesAtStation(warehouses, train.destinationStationId),
|
||||
[warehouses, train.destinationStationId],
|
||||
);
|
||||
const warehouseOptions = useMemo(
|
||||
() => scopedWarehouses.map((warehouse) => ({ value: warehouse.id, label: `${warehouse.name} (${warehouse.code})` })),
|
||||
[scopedWarehouses],
|
||||
);
|
||||
// With exactly one warehouse at the station there is nothing to choose —
|
||||
// pre-fill it so staff only has to pick yard/zone, not re-discover Indode.
|
||||
useEffect(() => {
|
||||
if (scopedWarehouses.length !== 1) return;
|
||||
const onlyWarehouseId = scopedWarehouses[0].id;
|
||||
items.filter(isImportUnloadPending).forEach((item) => {
|
||||
if (!assignments[item.bookingId]?.warehouseId) {
|
||||
onAssignmentChange(item.bookingId, { ...assignments[item.bookingId], warehouseId: onlyWarehouseId });
|
||||
}
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [scopedWarehouses, items]);
|
||||
|
||||
// Once a booking's warehouse is known, its yard (and then zone) follow from
|
||||
// what the cargo actually is — a Wheat booking only ever has one candidate
|
||||
// yard (Dry Bulk) once Indode's real yard layout is configured, so staff
|
||||
// never see a picker for something that isn't actually a choice.
|
||||
useEffect(() => {
|
||||
items.filter(isImportUnloadPending).forEach((item) => {
|
||||
const draft = assignments[item.bookingId];
|
||||
if (!draft?.warehouseId) return;
|
||||
|
||||
if (!draft.yardId) {
|
||||
const candidateYards = yardsForBooking(yards, {
|
||||
warehouseId: draft.warehouseId,
|
||||
freightType: item.freightType,
|
||||
tradeDirection: 'IMPORT',
|
||||
cargoTypeCode: item.cargoTypeCode,
|
||||
});
|
||||
if (candidateYards.length === 1) {
|
||||
onAssignmentChange(item.bookingId, { ...draft, yardId: candidateYards[0].id });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!draft.zoneId) {
|
||||
const candidateZones = zones.filter((zone) => zone.yardId === draft.yardId);
|
||||
if (candidateZones.length === 1) {
|
||||
onAssignmentChange(item.bookingId, { ...draft, zoneId: candidateZones[0].id });
|
||||
}
|
||||
}
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [assignments, items, yards, zones]);
|
||||
|
||||
useEffect(() => {
|
||||
const pending = items.filter(isImportUnloadPending);
|
||||
@@ -2091,12 +2135,17 @@ function ImportTrainDetailTable({
|
||||
<Table.Tbody>
|
||||
{items.map((it: ImportTrainItem) => {
|
||||
const draft = assignments[it.bookingId] ?? {};
|
||||
const { yardTypes, zoneTypes } = importLocationTypesForFreight(it.freightType);
|
||||
const yardOptions = yards
|
||||
.filter((yard) => yard.warehouseId === draft.warehouseId && yardTypes.includes(yard.type))
|
||||
.map((yard) => ({ value: yard.id, label: `${yard.name} (${yard.code})` }));
|
||||
const yardOptions = yardsForBooking(yards, {
|
||||
warehouseId: draft.warehouseId,
|
||||
freightType: it.freightType,
|
||||
tradeDirection: 'IMPORT',
|
||||
cargoTypeCode: it.cargoTypeCode,
|
||||
}).map((yard) => ({ value: yard.id, label: `${yard.name} (${yard.code})` }));
|
||||
// The yard is already scoped to what this cargo can go into — a
|
||||
// zone's own type always matches its parent yard's purpose (see the
|
||||
// Indode seed migration), so no separate zone-type filter is needed.
|
||||
const zoneOptions = zones
|
||||
.filter((zone) => zone.yardId === draft.yardId && zoneTypes.includes(zone.type))
|
||||
.filter((zone) => zone.yardId === draft.yardId)
|
||||
.map((zone) => ({ value: zone.id, label: `${zone.name} (${zone.code})` }));
|
||||
const pending = isImportUnloadPending(it);
|
||||
|
||||
@@ -2721,6 +2770,40 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Item onClick={() => setInspectId(r.id)}>Inspect / report</Menu.Item>
|
||||
{/* Double handling is decided once the goods are off
|
||||
the wagon (every row here is unloaded) — Yes is
|
||||
what makes the fee rule bill this booking. */}
|
||||
<Menu.Divider />
|
||||
<Menu.Label>
|
||||
Double handling —{' '}
|
||||
{r.doubleHandling == null ? 'not set' : r.doubleHandling ? 'Yes' : 'No'}
|
||||
</Menu.Label>
|
||||
<Menu.Item
|
||||
leftSection={
|
||||
r.doubleHandling === true ? <Check size={14} /> : <Layers size={14} />
|
||||
}
|
||||
disabled={!r.bookingId || r.doubleHandling === true}
|
||||
onClick={() =>
|
||||
runRowAction(r, 'Double handling: Yes — fee rule applies', () =>
|
||||
warehouseService.setDoubleHandling(r.bookingId as string, true),
|
||||
)
|
||||
}
|
||||
>
|
||||
Yes — apply fee
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={
|
||||
r.doubleHandling === false ? <Check size={14} /> : <Layers size={14} />
|
||||
}
|
||||
disabled={!r.bookingId || r.doubleHandling === false}
|
||||
onClick={() =>
|
||||
runRowAction(r, 'Double handling: No', () =>
|
||||
warehouseService.setDoubleHandling(r.bookingId as string, false),
|
||||
)
|
||||
}
|
||||
>
|
||||
No
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item leftSection={<PackageCheck size={14} />} onClick={() => setFeeItem(toInventoryItem(r))}>
|
||||
Storage / fee preview
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { warehousesAtStation, yardsForBooking } from "./options";
|
||||
import type { Warehouse, WarehouseYard } from "@/types/warehouse";
|
||||
|
||||
// Mirrors Indode's real 11-yard layout at a reduced scale, so these cases read
|
||||
// against the actual booking-routing decisions staff rely on.
|
||||
const yard = (overrides: Partial<WarehouseYard>): WarehouseYard =>
|
||||
({
|
||||
id: overrides.code,
|
||||
warehouseId: "indode",
|
||||
name: overrides.code,
|
||||
code: overrides.code,
|
||||
type: "GENERAL_CARGO_YARD",
|
||||
capacityWeight: null,
|
||||
capacityContainers: null,
|
||||
maxWeight: null,
|
||||
maxVolume: null,
|
||||
currentWeight: 0,
|
||||
currentContainers: 0,
|
||||
currentVolume: 0,
|
||||
status: "ACTIVE",
|
||||
isActive: true,
|
||||
...overrides,
|
||||
}) as WarehouseYard;
|
||||
|
||||
const YARDS: WarehouseYard[] = [
|
||||
yard({ code: "Y2", type: "GENERAL_CARGO_YARD", cargoTypes: [{ id: "1", code: "STEEL_BILLET" }] }),
|
||||
yard({ code: "Y3", type: "GENERAL_CARGO_YARD", cargoTypes: [{ id: "2", code: "AUTOMOBILE" }, { id: "3", code: "TRUCK" }] }),
|
||||
yard({ code: "Y4", type: "BULK_YARD", status: "INACTIVE", isActive: false, cargoTypes: [{ id: "4", code: "WHEAT" }] }),
|
||||
yard({ code: "Y5", type: "CONTAINER_YARD", direction: "IMPORT" }),
|
||||
yard({ code: "Y6", type: "CONTAINER_YARD", direction: "EXPORT" }),
|
||||
yard({ code: "Y10", type: "CONTAINER_YARD", direction: "BOTH" }), // service yard
|
||||
yard({ code: "Y11", type: "CONTAINER_YARD", direction: "BOTH" }), // equipment yard
|
||||
];
|
||||
|
||||
describe("yardsForBooking", () => {
|
||||
it("container import narrows to exactly the import stack", () => {
|
||||
const result = yardsForBooking(YARDS, {
|
||||
warehouseId: "indode",
|
||||
freightType: "CONTAINER",
|
||||
tradeDirection: "IMPORT",
|
||||
cargoTypeCode: null,
|
||||
});
|
||||
expect(result.map((y) => y.code)).toEqual(["Y5"]);
|
||||
});
|
||||
|
||||
it("container export narrows to exactly the export stack", () => {
|
||||
const result = yardsForBooking(YARDS, {
|
||||
warehouseId: "indode",
|
||||
freightType: "CONTAINER",
|
||||
tradeDirection: "EXPORT",
|
||||
cargoTypeCode: null,
|
||||
});
|
||||
expect(result.map((y) => y.code)).toEqual(["Y6"]);
|
||||
});
|
||||
|
||||
it("never offers a BOTH-direction container yard (service/equipment) for ordinary cargo", () => {
|
||||
const result = yardsForBooking(YARDS, {
|
||||
warehouseId: "indode",
|
||||
freightType: "CONTAINER",
|
||||
tradeDirection: "IMPORT",
|
||||
cargoTypeCode: null,
|
||||
});
|
||||
expect(result.map((y) => y.code)).not.toContain("Y10");
|
||||
expect(result.map((y) => y.code)).not.toContain("Y11");
|
||||
});
|
||||
|
||||
it("bulk cargo narrows to the yard configured for that exact cargo type", () => {
|
||||
const automobile = yardsForBooking(YARDS, {
|
||||
warehouseId: "indode",
|
||||
freightType: "BULK",
|
||||
tradeDirection: "IMPORT",
|
||||
cargoTypeCode: "AUTOMOBILE",
|
||||
});
|
||||
expect(automobile.map((y) => y.code)).toEqual(["Y3"]);
|
||||
|
||||
const steel = yardsForBooking(YARDS, {
|
||||
warehouseId: "indode",
|
||||
freightType: "BULK",
|
||||
tradeDirection: "IMPORT",
|
||||
cargoTypeCode: "STEEL_BILLET",
|
||||
});
|
||||
expect(steel.map((y) => y.code)).toEqual(["Y2"]);
|
||||
});
|
||||
|
||||
it("falls back to every non-container yard when the one configured for this cargo type is closed", () => {
|
||||
// Y4 (Dry Bulk, WHEAT) is inactive — never strand staff with an empty
|
||||
// picker just because the ideal yard is closed; same safety net as
|
||||
// warehousesAtStation falling back when a station has no mapped warehouse.
|
||||
const result = yardsForBooking(YARDS, {
|
||||
warehouseId: "indode",
|
||||
freightType: "BULK",
|
||||
tradeDirection: "IMPORT",
|
||||
cargoTypeCode: "WHEAT",
|
||||
});
|
||||
expect(result.map((y) => y.code).sort()).toEqual(["Y2", "Y3"]);
|
||||
});
|
||||
|
||||
it("falls back to every non-container yard when no yard is configured for that cargo type yet", () => {
|
||||
const result = yardsForBooking(YARDS, {
|
||||
warehouseId: "indode",
|
||||
freightType: "BULK",
|
||||
tradeDirection: "IMPORT",
|
||||
cargoTypeCode: "SOMETHING_UNMAPPED",
|
||||
});
|
||||
expect(result.map((y) => y.code).sort()).toEqual(["Y2", "Y3"]);
|
||||
});
|
||||
|
||||
it("a yard with no configured cargo types is open to anything (unconfigured, not restrictive)", () => {
|
||||
const openYard = yard({ code: "GENERIC", type: "BULK_YARD" });
|
||||
const result = yardsForBooking([...YARDS, openYard], {
|
||||
warehouseId: "indode",
|
||||
freightType: "BULK",
|
||||
tradeDirection: "IMPORT",
|
||||
cargoTypeCode: "STEEL_BILLET",
|
||||
});
|
||||
expect(result.map((y) => y.code).sort()).toEqual(["GENERIC", "Y2"]);
|
||||
});
|
||||
|
||||
it("only offers yards at the requested warehouse", () => {
|
||||
const otherWarehouseYard = yard({ code: "SEBETA-Y1", warehouseId: "sebeta", type: "GENERAL_CARGO_YARD" });
|
||||
const result = yardsForBooking([...YARDS, otherWarehouseYard], {
|
||||
warehouseId: "indode",
|
||||
freightType: "BULK",
|
||||
tradeDirection: "IMPORT",
|
||||
cargoTypeCode: null,
|
||||
});
|
||||
expect(result.map((y) => y.code)).not.toContain("SEBETA-Y1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("warehousesAtStation", () => {
|
||||
const warehouse = (id: string, stationId: string | null): Warehouse =>
|
||||
({ id, stationId, name: id, code: id } as Warehouse);
|
||||
|
||||
it("restricts to the warehouse at the given station", () => {
|
||||
const warehouses = [warehouse("indode", "station-a"), warehouse("sebeta", "station-b")];
|
||||
const result = warehousesAtStation(warehouses, "station-a");
|
||||
expect(result.map((w) => w.id)).toEqual(["indode"]);
|
||||
});
|
||||
|
||||
it("falls back to every warehouse when the station has no match", () => {
|
||||
const warehouses = [warehouse("indode", "station-a"), warehouse("sebeta", "station-b")];
|
||||
const result = warehousesAtStation(warehouses, "station-unknown");
|
||||
expect(result).toEqual(warehouses);
|
||||
});
|
||||
|
||||
it("falls back to every warehouse when the station is null", () => {
|
||||
const warehouses = [warehouse("indode", "station-a")];
|
||||
expect(warehousesAtStation(warehouses, null)).toEqual(warehouses);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
WAREHOUSE_ZONE_TYPES,
|
||||
WAREHOUSE_STATUSES,
|
||||
INVENTORY_STATUSES,
|
||||
type Warehouse,
|
||||
type WarehouseYard,
|
||||
} from '@/types/warehouse';
|
||||
|
||||
export const humanizeEnum = (value: string) =>
|
||||
@@ -16,6 +18,58 @@ export const humanizeEnum = (value: string) =>
|
||||
const toOptions = (values: readonly string[]) =>
|
||||
values.map((value) => ({ value, label: humanizeEnum(value) }));
|
||||
|
||||
/**
|
||||
* Warehouses actually located at a train's station — e.g. a train destined for
|
||||
* Indode should only offer Indode's own warehouse, not Sebeta's or Modjo's.
|
||||
* Falls back to every warehouse when the station is unmapped (no `stationId`
|
||||
* match anywhere), so unusual/legacy data never blocks the unload flow entirely.
|
||||
*/
|
||||
export const warehousesAtStation = (warehouses: Warehouse[], stationId: string | null | undefined) => {
|
||||
if (!stationId) return warehouses;
|
||||
const atStation = warehouses.filter((w) => w.stationId === stationId);
|
||||
return atStation.length ? atStation : warehouses;
|
||||
};
|
||||
|
||||
/**
|
||||
* Yards at ONE warehouse eligible to receive a booking, given what it actually
|
||||
* is — e.g. at Indode: container import always narrows to Yard 5, export to
|
||||
* Yard 6; a Wheat booking narrows to Yard 4 (Dry Bulk), not Break Bulk or
|
||||
* Coffee/Tea. Mirrors `warehousesAtStation`'s fallback philosophy: an
|
||||
* unconfigured yard (no cargo types set) stays open rather than disappearing,
|
||||
* but a yard that IS configured for other cargo never shows for a mismatch.
|
||||
*
|
||||
* Container yards are the one case with no such fallback: a CONTAINER_YARD
|
||||
* left at direction BOTH/null (Indode's Yard 10 service yard, Yard 11
|
||||
* equipment yard) is a service/equipment yard, not a customer cargo yard, and
|
||||
* must never be offered just because the exact-direction stack is missing.
|
||||
*/
|
||||
export const yardsForBooking = (
|
||||
yards: WarehouseYard[],
|
||||
params: {
|
||||
warehouseId: string | null | undefined;
|
||||
freightType: string | null | undefined;
|
||||
tradeDirection: string | null | undefined;
|
||||
cargoTypeCode: string | null | undefined;
|
||||
},
|
||||
): WarehouseYard[] => {
|
||||
const atWarehouse = yards.filter((y) => y.warehouseId === params.warehouseId && y.isActive);
|
||||
const isContainer = (params.freightType ?? '').toUpperCase() === 'CONTAINER';
|
||||
|
||||
if (isContainer) {
|
||||
const direction = (params.tradeDirection ?? '').toUpperCase();
|
||||
return atWarehouse.filter((y) => y.type === 'CONTAINER_YARD' && y.direction === direction);
|
||||
}
|
||||
|
||||
const nonContainer = atWarehouse.filter((y) => y.type !== 'CONTAINER_YARD');
|
||||
if (!params.cargoTypeCode) return nonContainer;
|
||||
|
||||
const cargoMatched = nonContainer.filter((y) => {
|
||||
const codes = (y.cargoTypes ?? []).map((c) => c.code);
|
||||
return codes.length === 0 || codes.includes(params.cargoTypeCode as string);
|
||||
});
|
||||
return cargoMatched.length ? cargoMatched : nonContainer;
|
||||
};
|
||||
|
||||
export const warehouseTypeOptions = toOptions(WAREHOUSE_TYPES);
|
||||
export const yardTypeOptions = toOptions(WAREHOUSE_YARD_TYPES);
|
||||
export const zoneTypeOptions = toOptions(WAREHOUSE_ZONE_TYPES);
|
||||
|
||||
@@ -1649,6 +1649,8 @@
|
||||
"notFoundError": "የፈለጉትን መረጃ አልተገኘም።",
|
||||
"fileTooLarge": "ፋይሉ በጣም ትልቅ ነው። እባክዎ ፋይሉን አሳንሰው ዳግም ይሞክሩ።",
|
||||
"serverError": "ከአገልጋይ በኩል ችግር አለ። እባክዎ ዳግመኛ ይሞክሩ።",
|
||||
"userRoleNotFound": "ይህ የአስተዳዳሪ ሚና ምደባ አልተገኘም — ቀደም ብሎ ተወግዶ ሊሆን ይችላል።",
|
||||
"unitEmployeeLimitReached": "ይህ ክፍል የ{{limit}} ሰራተኞች ገደብ ላይ ደርሷል።",
|
||||
"attachmentDeleted": "አባሪው በተሳካ ሁኔታ ተሰርዟል።",
|
||||
"replyAdded": "ምላሹ በተሳካ ሁኔታ ታክሏል!",
|
||||
"replyError": "ምላሹን በመጨመር ላይ ስህተት አጋጥሟል።",
|
||||
@@ -2653,6 +2655,7 @@
|
||||
"copyPermissionsHint": "የነበረ የቦታ አይነት ይምረጡ፤ ፍቃዶቹ አስቀድመው ይሞላሉ፣ ከታች ማስተካከል ይችላሉ።",
|
||||
"copyPermissionsFailed": "ፍቃዶችን መቅዳት አልተቻለም",
|
||||
"selectOrganizationToCopy": "መቅዳት የሚችሏቸውን የቦታ ዓይነቶች ለማየት መጀመሪያ ድርጅት ይምረጡ",
|
||||
"selectUnitToCopy": "መቅዳት የሚችሏቸውን የቦታ ዓይነቶች ለማየት መጀመሪያ ክፍል ይምረጡ",
|
||||
"cannotClearAllPermissions": "ተቀምጧል። ፍቃዶቹ አልተቀየሩም — ይህ የቦታ ዓይነት ቢያንስ አንድ ፍቃድ ሊኖረው ይገባል።",
|
||||
"permissionsSelected": "{{count}} ተመርጠዋል",
|
||||
"positionTypeCreated": "የቦታ ዓይነት ተፈጥሯል",
|
||||
@@ -7491,12 +7494,22 @@
|
||||
"loadError": "አስተዳዳሪዎችን መጫን አልተሳካም።",
|
||||
"pickerError": "ድርጅቶችን መጫን አልተሳካም።",
|
||||
"addAdmin": "አስተዳዳሪ ጨምር",
|
||||
"managePermissions": "ፍቃዶችን ያስተዳድሩ",
|
||||
"add": {
|
||||
"title": "አስተዳዳሪ ጨምር",
|
||||
"description": "የተጠቃሚ መለያ ይፍጠሩ እና በዚህ ድርጅት ውስጥ የአስተዳዳሪ መዳረሻ ይስጡ።",
|
||||
"submit": "አስተዳዳሪ ጨምር",
|
||||
"inviteNote": "ተጠቃሚው ይፈጠራል እና የይለፍ ቃሉን እንዲያዘጋጅ የኤስኤምኤስ ግብዣ ይደርሰዋል።",
|
||||
"noUnitsOrgAdmin": "ይህ ድርጅት ክፍሎች የሉትም — አስተዳዳሪው እንደ የድርጅት አስተዳዳሪ ይጨመራል።"
|
||||
},
|
||||
"permissions": {
|
||||
"title": "የድርጅት አስተዳዳሪ ፍቃዶች",
|
||||
"subtitle": "እያንዳንዱ የድርጅት አስተዳዳሪ በመድረኩ ላይ ምን ማድረግ እንደሚችል ይምረጡ።",
|
||||
"backToAdmins": "ወደ ድርጅት አስተዳዳሪዎች ይመለሱ",
|
||||
"roleNotFound": "የድርጅት አስተዳዳሪ ሚና ማግኘት አልተቻለም።",
|
||||
"saved": "የድርጅት አስተዳዳሪ ፍቃዶች ተዘምነዋል።",
|
||||
"saveFailed": "የድርጅት አስተዳዳሪ ፍቃዶችን ማዘመን አልተቻለም።",
|
||||
"cannotClearAll": "ተቀምጧል። ፍቃዶቹ አልተቀየሩም — የድርጅት አስተዳዳሪ ሚና ቢያንስ አንድ ፍቃድ ሊኖረው ይገባል።"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1669,6 +1669,8 @@
|
||||
"notFoundError": "We couldn't find what you were looking for.",
|
||||
"fileTooLarge": "The file is too large. Please reduce the file size and try again.",
|
||||
"serverError": "Something went wrong on our side. Please try again in a moment.",
|
||||
"userRoleNotFound": "This admin role assignment could not be found — it may have already been removed.",
|
||||
"unitEmployeeLimitReached": "This unit has reached its limit of {{limit}} employees.",
|
||||
"attachmentDeleted": "Attachment deleted successfully.",
|
||||
"replyAdded": "Reply added successfully!",
|
||||
"replyError": "An error occurred while adding the reply.",
|
||||
@@ -2762,6 +2764,7 @@
|
||||
"copyPermissionsHint": "Pick an existing position type to pre-fill its permissions, then edit below.",
|
||||
"copyPermissionsFailed": "Failed to copy permissions",
|
||||
"selectOrganizationToCopy": "Select an organization to see the position types you can copy from",
|
||||
"selectUnitToCopy": "Select a unit to see the position types you can copy from",
|
||||
"cannotClearAllPermissions": "Saved. Permissions were left unchanged — this position type must keep at least one permission.",
|
||||
"permissionsSelected": "{{count}} selected",
|
||||
"positionTypeCreated": "Position type created",
|
||||
@@ -7492,12 +7495,22 @@
|
||||
"loadError": "Failed to load admins.",
|
||||
"pickerError": "Failed to load organizations.",
|
||||
"addAdmin": "Add Admin",
|
||||
"managePermissions": "Manage Permissions",
|
||||
"add": {
|
||||
"title": "Add Admin",
|
||||
"description": "Create a user account and grant admin access in this organization.",
|
||||
"submit": "Add Admin",
|
||||
"inviteNote": "The user is created and receives an SMS invitation to set their password.",
|
||||
"noUnitsOrgAdmin": "This organization has no units — the admin will be added as an organization admin."
|
||||
},
|
||||
"permissions": {
|
||||
"title": "Organization Admin Permissions",
|
||||
"subtitle": "Choose what every Organization Admin can do across the platform.",
|
||||
"backToAdmins": "Back to Organization Admins",
|
||||
"roleNotFound": "Could not find the Organization Admin role.",
|
||||
"saved": "Organization Admin permissions updated.",
|
||||
"saveFailed": "Failed to update Organization Admin permissions.",
|
||||
"cannotClearAll": "Saved. Permissions were left unchanged — the Organization Admin role must keep at least one permission."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1169,6 +1169,8 @@
|
||||
"notFoundError": "Nous n’avons pas trouvé ce que vous cherchiez.",
|
||||
"fileTooLarge": "Le fichier est trop volumineux. Veuillez réduire sa taille et réessayer.",
|
||||
"serverError": "Un problème est survenu de notre côté. Veuillez réessayer dans un instant.",
|
||||
"userRoleNotFound": "Cette attribution de rôle d'administrateur est introuvable — elle a peut-être déjà été supprimée.",
|
||||
"unitEmployeeLimitReached": "Cette unité a atteint sa limite de {{limit}} employés.",
|
||||
"attachmentDeleted": "Pièce jointe supprimée avec succès.",
|
||||
"replyAdded": "Réponse ajoutée avec succès !",
|
||||
"replyError": "Une erreur s’est produite lors de l’ajout de la réponse.",
|
||||
@@ -1888,6 +1890,7 @@
|
||||
"copyPermissionsHint": "Choisissez un type de poste existant pour préremplir ses autorisations, puis modifiez ci-dessous.",
|
||||
"copyPermissionsFailed": "Échec de la copie des autorisations",
|
||||
"selectOrganizationToCopy": "Sélectionnez une organisation pour voir les types de poste que vous pouvez copier",
|
||||
"selectUnitToCopy": "Sélectionnez une unité pour voir les types de poste que vous pouvez copier",
|
||||
"cannotClearAllPermissions": "Enregistré. Les autorisations n'ont pas été modifiées — ce type de poste doit conserver au moins une autorisation.",
|
||||
"permissionsSelected": "{{count}} sélectionné(s)",
|
||||
"positionTypeCreated": "Type de poste créé",
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
LayoutGrid,
|
||||
Milestone,
|
||||
Package,
|
||||
Truck,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Container,
|
||||
@@ -36,6 +37,7 @@ import {
|
||||
BookingContractSummaryCard,
|
||||
BookingContainerUnitsCard,
|
||||
BookingDocumentsPanel,
|
||||
BookingTrucksPanel,
|
||||
ContractOrdersPanel,
|
||||
} from "@/components/bookings/detail";
|
||||
import { WarehouseInfoCard } from "@/components/warehouses";
|
||||
@@ -141,7 +143,9 @@ export default function BookingRequestDetailPage() {
|
||||
? "orders"
|
||||
: requestedTab === "documents"
|
||||
? "documents"
|
||||
: "overview";
|
||||
: requestedTab === "trucks"
|
||||
? "trucks"
|
||||
: "overview";
|
||||
const setActiveTab = (tab: string | null) => {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
if (tab && tab !== "overview") next.set("tab", tab);
|
||||
@@ -207,6 +211,9 @@ export default function BookingRequestDetailPage() {
|
||||
>
|
||||
Documents
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="trucks" leftSection={<Truck size={16} />}>
|
||||
Trucks
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="overview">
|
||||
@@ -223,6 +230,9 @@ export default function BookingRequestDetailPage() {
|
||||
<Tabs.Panel value="documents">
|
||||
<BookingDocumentsPanel bookingId={booking.id} />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="trucks">
|
||||
<BookingTrucksPanel bookingId={booking.id} />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</Grid.Col>
|
||||
|
||||
|
||||
@@ -607,8 +607,13 @@ export default function CustomerDetailPage() {
|
||||
{ label: "PoA address", value: company?.poaAddress },
|
||||
];
|
||||
const hasPoaDetails = poaFields.some((f) => f.value?.trim());
|
||||
// Shared with the portal (buildCompanyIdentityState) — same derivation, so
|
||||
// this page can never disagree with the rule the API actually enforces.
|
||||
const ownerIdentity = company?.identity?.owner;
|
||||
const poaIdentity = company?.identity?.poa;
|
||||
const hasEtradeRecord = Boolean(company?.licenceNumber?.trim());
|
||||
// A freight forwarder acts on other companies' behalf, so its PoA — details
|
||||
// and delegation letter both — is mandatory rather than optional.
|
||||
// and DARS delegation paper both — is mandatory rather than optional.
|
||||
const poaMandatory = (company?.companyProfiles ?? []).some(
|
||||
(p) => p.type === "freight_forwarder",
|
||||
);
|
||||
@@ -752,6 +757,16 @@ export default function CustomerDetailPage() {
|
||||
<InfoField label="TIN" value={company.tin} />
|
||||
<InfoField label="VAT number" value={company.vatNumber} />
|
||||
<InfoField label="FAN number" value={company.fanNumber} />
|
||||
<InfoField
|
||||
label="Owner identity"
|
||||
value={
|
||||
ownerIdentity?.verified
|
||||
? "Fayda verified"
|
||||
: ownerIdentity?.passportNumber
|
||||
? `Passport ${ownerIdentity.passportNumber}`
|
||||
: "Not verified"
|
||||
}
|
||||
/>
|
||||
<InfoField label="Country" value={company.country} />
|
||||
<InfoField label="Address" value={company.address} />
|
||||
<InfoField label="Website" value={company.website} />
|
||||
@@ -783,6 +798,97 @@ export default function CustomerDetailPage() {
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Stack gap="lg">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Text fw={600} c="edr-text">
|
||||
eTrade registration
|
||||
</Text>
|
||||
{hasEtradeRecord ? (
|
||||
<Badge size="sm" color="edr-green" variant="light">
|
||||
Verified with eTrade
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge size="sm" color="gray" variant="light">
|
||||
No eTrade record
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
{hasEtradeRecord ? (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
|
||||
<InfoField
|
||||
label="License number"
|
||||
value={company.licenceNumber}
|
||||
/>
|
||||
<InfoField label="Status" value={company.statusDescription} />
|
||||
<InfoField
|
||||
label="Date registered"
|
||||
value={company.dateRegistered}
|
||||
/>
|
||||
<InfoField label="Renewed from" value={company.renewedFrom} />
|
||||
<InfoField label="Renewal date" value={company.renewalDate} />
|
||||
<InfoField label="Renewed to" value={company.renewedTo} />
|
||||
<InfoField label="Region" value={company.region} />
|
||||
<InfoField label="Zone" value={company.zone} />
|
||||
<InfoField label="Woreda" value={company.woreda} />
|
||||
<InfoField label="Kebele" value={company.kebele} />
|
||||
<InfoField label="House No" value={company.houseNo} />
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
No eTrade registration record on file for this customer's
|
||||
TIN.
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Stack gap="lg">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Text fw={600} c="edr-text">
|
||||
Owner identity
|
||||
</Text>
|
||||
{ownerIdentity?.verified ? (
|
||||
<Badge size="sm" color="edr-green" variant="light">
|
||||
Fayda verified
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge size="sm" color="gray" variant="light">
|
||||
Not verified
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
{ownerIdentity?.verified ? (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
|
||||
<InfoField label="Name" value={ownerIdentity.name} />
|
||||
<InfoField label="Phone" value={ownerIdentity.phone} />
|
||||
<InfoField label="Email" value={ownerIdentity.email} />
|
||||
<InfoField label="Address" value={ownerIdentity.address} />
|
||||
<InfoField
|
||||
label="Verified at"
|
||||
value={formatDate(ownerIdentity.verifiedAt)}
|
||||
/>
|
||||
<InfoField
|
||||
label="Birthdate"
|
||||
value={ownerIdentity.birthdate}
|
||||
/>
|
||||
<InfoField label="Gender" value={ownerIdentity.gender} />
|
||||
<InfoField
|
||||
label="Passport number"
|
||||
value={ownerIdentity.passportNumber}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
{ownerIdentity?.passportNumber
|
||||
? `Not Fayda verified — identified by passport ${ownerIdentity.passportNumber}.`
|
||||
: "The company owner has not verified their identity with Fayda."}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
@@ -798,11 +904,11 @@ export default function CustomerDetailPage() {
|
||||
</Group>
|
||||
{delegationMissing ? (
|
||||
<Badge size="sm" color="red" variant="light">
|
||||
Delegation letter missing
|
||||
DARS delegation paper missing
|
||||
</Badge>
|
||||
) : poaLive.length > 0 ? (
|
||||
<Badge size="sm" color="edr-green" variant="light">
|
||||
Delegation letter on file
|
||||
DARS delegation paper on file
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge size="sm" color="gray" variant="light">
|
||||
@@ -820,6 +926,25 @@ export default function CustomerDetailPage() {
|
||||
value={f.value}
|
||||
/>
|
||||
))}
|
||||
<InfoField
|
||||
label="PoA Fayda"
|
||||
value={
|
||||
poaIdentity?.verified ? "Verified" : "Not verified"
|
||||
}
|
||||
/>
|
||||
{poaIdentity?.verified && (
|
||||
<>
|
||||
<InfoField
|
||||
label="PoA verified at"
|
||||
value={formatDate(poaIdentity.verifiedAt)}
|
||||
/>
|
||||
<InfoField
|
||||
label="PoA birthdate"
|
||||
value={poaIdentity.birthdate}
|
||||
/>
|
||||
<InfoField label="PoA gender" value={poaIdentity.gender} />
|
||||
</>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
@@ -838,7 +963,7 @@ export default function CustomerDetailPage() {
|
||||
tt="uppercase"
|
||||
style={{ letterSpacing: "0.04em" }}
|
||||
>
|
||||
Delegation letter
|
||||
DARS delegation paper
|
||||
</Text>
|
||||
|
||||
{documentsQuery.isLoading ? (
|
||||
@@ -864,7 +989,7 @@ export default function CustomerDetailPage() {
|
||||
</Group>
|
||||
) : poaDocuments.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
No delegation letter uploaded.
|
||||
No DARS delegation paper uploaded.
|
||||
</Text>
|
||||
) : (
|
||||
poaDocuments.map((doc) => (
|
||||
|
||||
@@ -945,6 +945,45 @@ const LastMilePage = () => {
|
||||
return out.length ? out : activeRecord ? bookingContainerNumbers(activeRecord) : [];
|
||||
}, [assignBooking, activeRecord]);
|
||||
|
||||
// Container number → size ("20ft"/"40ft"), driving the per-truck cap: a 40ft
|
||||
// fills the truck alone; two 20ft may share (no size mixing).
|
||||
const sizeByNumber = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
const lines = assignBooking?.bookingContainers?.length
|
||||
? assignBooking.bookingContainers
|
||||
: activeRecord?.booking?.bookingContainers ?? [];
|
||||
for (const line of lines) {
|
||||
// The two payload shapes differ: the list record carries `containerSize`,
|
||||
// the booking detail exposes the size on its container type.
|
||||
const c = line as {
|
||||
containerSize?: string | null;
|
||||
containerNumber?: string | null;
|
||||
containerType?: { code?: string; label?: string; sizeFt?: number };
|
||||
units?: Array<{ containerNumber?: string | null }>;
|
||||
};
|
||||
const size = String(
|
||||
c.containerSize ?? c.containerType?.sizeFt ?? c.containerType?.code ?? c.containerType?.label ?? "",
|
||||
);
|
||||
for (const u of c.units ?? []) {
|
||||
if (u.containerNumber) map.set(u.containerNumber, size);
|
||||
}
|
||||
if (c.containerNumber) map.set(c.containerNumber, size);
|
||||
}
|
||||
return map;
|
||||
}, [assignBooking, activeRecord]);
|
||||
const is40 = (n: string) => (sizeByNumber.get(n) ?? "").includes("40");
|
||||
|
||||
// Trucks that already arrived/left keep their load locked — the API rejects
|
||||
// changing or removing them; the modal greys those rows out.
|
||||
const lockedVehicles = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const a of activeRecord?.vehicleAssignments ?? []) {
|
||||
if (a.departedAt) map.set(a.vehicleId, "left the warehouse");
|
||||
else if (a.arrivedAt) map.set(a.vehicleId, "arrived at the warehouse");
|
||||
}
|
||||
return map;
|
||||
}, [activeRecord]);
|
||||
|
||||
const pickupReadyByBooking = useMemo(() => {
|
||||
const map = new Map<string, ImportUnloadedItem>();
|
||||
for (const row of pickupReadyRows) {
|
||||
@@ -1101,6 +1140,22 @@ const LastMilePage = () => {
|
||||
|
||||
if (!targetIds.length) return;
|
||||
|
||||
// A 40ft container fills its truck — backstop for pre-filled reassignment
|
||||
// rows the MultiSelect guard never saw.
|
||||
const overloaded = vehicles.filter(
|
||||
(v) => v.containerNumbers.length > 1 && v.containerNumbers.some(is40),
|
||||
);
|
||||
if (overloaded.length) {
|
||||
toast({
|
||||
title: "40ft fills the truck",
|
||||
description: `${overloaded
|
||||
.map((v) => vehicleLabelFor(v.vehicleId))
|
||||
.join("; ")} — a 40ft container travels alone.`,
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Backstop for rows the Select guard never saw (pre-filled reassignments).
|
||||
const unpriced = vehicles
|
||||
.map((v) => ({ label: vehicleLabelFor(v.vehicleId), gap: pricingGapById.get(v.vehicleId) }))
|
||||
@@ -1385,7 +1440,21 @@ const LastMilePage = () => {
|
||||
status === "PAYMENT_PENDING" ||
|
||||
(status === "READY_TO_TRANSIT" && assigned) ||
|
||||
(status === "IN_TRANSIT" && hasDistance);
|
||||
const canAssignStep = !assigned && status !== "DELIVERED";
|
||||
// Assign stays active until the whole load has trucks: container
|
||||
// bookings until every container is on a truck; bulk until the
|
||||
// tonnage is drawn down (trucks depart one by one). Already-departed
|
||||
// trucks keep their rows locked in the modal.
|
||||
const totalContainers = containerCount(row.original);
|
||||
const assignedContainers = (row.original.vehicleAssignments ?? []).reduce(
|
||||
(s, a) => s + (a.containers?.length ?? (a.containerNumber ? 1 : 0)),
|
||||
0,
|
||||
);
|
||||
const containersRemain = totalContainers > 0 && assignedContainers < totalContainers;
|
||||
const bulkCargo = totalContainers === 0;
|
||||
const canAssignStep =
|
||||
status !== "DELIVERED" &&
|
||||
!row.original.invoice &&
|
||||
(!assigned || containersRemain || (bulkCargo && status !== "IN_TRANSIT"));
|
||||
const canDistance = status === "IN_TRANSIT";
|
||||
// Truck arrival/leaving are independent — each driven by its own
|
||||
// warehouse state — but both are done once the leg is IN_TRANSIT/DELIVERED.
|
||||
@@ -1822,16 +1891,22 @@ const LastMilePage = () => {
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
const ok = picked === needed;
|
||||
const coveredContainers = vehicleRows.reduce(
|
||||
(s, r) => s + (r.vehicleId ? r.containerNumbers.length : 0),
|
||||
0,
|
||||
);
|
||||
const ok = picked === needed && coveredContainers === containers;
|
||||
return (
|
||||
<Alert
|
||||
variant="light"
|
||||
color={ok ? "green" : "yellow"}
|
||||
title={`${containers} container${containers === 1 ? "" : "s"} · needs ${needed} vehicle${needed === 1 ? "" : "s"}`}
|
||||
title={`${coveredContainers} of ${containers} container${containers === 1 ? "" : "s"} on trucks · needs ${needed} vehicle${needed === 1 ? "" : "s"}`}
|
||||
>
|
||||
One truck (with trailer) carries {CONTAINERS_PER_VEHICLE} containers.
|
||||
{picked > 0 && !ok &&
|
||||
` You've selected ${picked} — ${picked < needed ? "add more" : "that's more than needed"}.`}
|
||||
One 40ft container fills a truck; two 20ft share one (no size mixing).
|
||||
{containers - coveredContainers > 0 &&
|
||||
` ${containers - coveredContainers} container${containers - coveredContainers === 1 ? "" : "s"} still unassigned — keep adding trucks.`}
|
||||
{picked > 0 && picked !== needed &&
|
||||
` You've selected ${picked} vehicle${picked === 1 ? "" : "s"} — ${picked < needed ? "add more" : "that's more than needed"}.`}
|
||||
</Alert>
|
||||
);
|
||||
})()}
|
||||
@@ -1851,7 +1926,11 @@ const LastMilePage = () => {
|
||||
)}
|
||||
<Divider />
|
||||
<Stack gap="xs">
|
||||
{vehicleRows.map((row, i) => (
|
||||
{vehicleRows.map((row, i) => {
|
||||
const lockReason = row.vehicleId ? lockedVehicles.get(row.vehicleId) : undefined;
|
||||
const rowLocked = Boolean(lockReason);
|
||||
const rowHas40 = row.containerNumbers.some(is40);
|
||||
return (
|
||||
<Group key={i} gap="xs" wrap="nowrap" align="flex-end">
|
||||
<Select
|
||||
style={{ flex: 1.4 }}
|
||||
@@ -1875,35 +1954,49 @@ const LastMilePage = () => {
|
||||
}}
|
||||
searchable
|
||||
clearable
|
||||
disabled={assignVehicleOptions.length === 0}
|
||||
disabled={assignVehicleOptions.length === 0 || rowLocked}
|
||||
/>
|
||||
<MultiSelect
|
||||
style={{ flex: 1 }}
|
||||
label={i === 0 ? "Containers (1x40ft or 2x20ft)" : undefined}
|
||||
placeholder={containerOptions.length ? "Select containers" : "No container numbers"}
|
||||
// A truck takes at most two containers; a 40ft fills it (the
|
||||
// API rejects a 40ft paired with anything).
|
||||
maxValues={2}
|
||||
description={rowLocked ? `Locked — truck ${lockReason}` : undefined}
|
||||
// A 40ft container fills the truck alone; two 20ft may share.
|
||||
maxValues={rowHas40 ? 1 : 2}
|
||||
data={[
|
||||
...containerOptions.filter(
|
||||
(n) =>
|
||||
row.containerNumbers.includes(n) ||
|
||||
// a container rides exactly one truck
|
||||
!vehicleRows.some((r, idx) => idx !== i && r.containerNumbers.includes(n)),
|
||||
// a container rides exactly one truck …
|
||||
(!vehicleRows.some((r, idx) => idx !== i && r.containerNumbers.includes(n)) &&
|
||||
// … and no size mixing: once a 20ft is picked a 40ft
|
||||
// can't join it, and a 40ft truck is already full.
|
||||
!(rowHas40 || (row.containerNumbers.length > 0 && is40(n)))),
|
||||
),
|
||||
// keep manual/legacy values selectable even if not in the booking
|
||||
...row.containerNumbers.filter((n) => !containerOptions.includes(n)),
|
||||
]}
|
||||
value={row.containerNumbers}
|
||||
onChange={(value) =>
|
||||
onChange={(value) => {
|
||||
// Guard the paste/keyboard path too — data filtering only
|
||||
// covers the dropdown.
|
||||
if (value.filter(is40).length > 0 && value.length > 1) {
|
||||
toast({
|
||||
title: "40ft fills the truck",
|
||||
description: "A 40ft container travels alone — remove the other container.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
setVehicleRows((prev) =>
|
||||
prev.map((x, idx) => (idx === i ? { ...x, containerNumbers: value } : x)),
|
||||
)
|
||||
}
|
||||
);
|
||||
}}
|
||||
searchable
|
||||
clearable
|
||||
disabled={rowLocked}
|
||||
/>
|
||||
{vehicleRows.length > 1 && (
|
||||
{vehicleRows.length > 1 && !rowLocked && (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
@@ -1914,7 +2007,8 @@ const LastMilePage = () => {
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Group>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
<Button
|
||||
variant="light"
|
||||
size="xs"
|
||||
|
||||
@@ -331,6 +331,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
columns: [
|
||||
codeColumn("code"),
|
||||
{ id: "cargoTypeName", header: "Name", accessorKey: "cargoTypeName" },
|
||||
{ id: "unitOfMeasure", header: "Billed by", accessorKey: "unitOfMeasure" },
|
||||
{
|
||||
id: "requiresDirectorApproval",
|
||||
header: "Director approval",
|
||||
@@ -342,6 +343,19 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
],
|
||||
formFields: [
|
||||
{ name: "cargoTypeName", label: "Cargo type name", type: "text", required: true },
|
||||
{
|
||||
name: "unitOfMeasure",
|
||||
label: "Billed by",
|
||||
type: "select",
|
||||
optional: true,
|
||||
placeholder: "Not set (container/legacy cargo)",
|
||||
description:
|
||||
"Bulk storage/demurrage bills per ton for Tonnage cargo, per unit for Countable cargo (e.g. Machinery, Truck, Automobile, Livestock).",
|
||||
options: [
|
||||
{ label: "Tonnage (per ton)", value: "PER_TON" },
|
||||
{ label: "Countable (per item)", value: "PER_ITEM" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "parentGroupId",
|
||||
label: "Parent group",
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
WarehouseOpsKpiStrip,
|
||||
formatDate,
|
||||
formatNumber,
|
||||
warehousesAtStation,
|
||||
yardsForBooking,
|
||||
} from '@/components/warehouses';
|
||||
import {
|
||||
useAutoUnloadArrivedBookings,
|
||||
@@ -49,14 +51,6 @@ const getPendingUnloadBookings = (train: ImportTrain) =>
|
||||
const isFullyUnloaded = (train: ImportTrain) =>
|
||||
Boolean(train.fullyUnloaded) || (train.totalBookings > 0 && getPendingUnloadBookings(train) === 0);
|
||||
|
||||
const locationTypesForFreight = (freightType: string | null | undefined) => {
|
||||
const normalized = (freightType ?? '').toUpperCase();
|
||||
if (normalized === 'CONTAINER') {
|
||||
return { yardTypes: ['CONTAINER_YARD', 'GENERAL_CARGO_YARD'], zoneTypes: ['CONTAINER_ZONE', 'GENERAL_CARGO_ZONE'] };
|
||||
}
|
||||
return { yardTypes: ['BULK_YARD', 'GENERAL_CARGO_YARD'], zoneTypes: ['BULK_ZONE', 'GENERAL_CARGO_ZONE'] };
|
||||
};
|
||||
|
||||
const isContainerFreight = (freightType: string | null | undefined) =>
|
||||
(freightType ?? '').toUpperCase() === 'CONTAINER';
|
||||
|
||||
@@ -65,7 +59,7 @@ function isUnloadPending(item: ImportTrainItem) {
|
||||
}
|
||||
|
||||
function ImportTrainDetailRows({
|
||||
scheduleId,
|
||||
train,
|
||||
warehouses,
|
||||
yards,
|
||||
zones,
|
||||
@@ -73,7 +67,7 @@ function ImportTrainDetailRows({
|
||||
onAssignmentChange,
|
||||
onReadyChange,
|
||||
}: {
|
||||
scheduleId: string;
|
||||
train: ImportTrain;
|
||||
warehouses: Warehouse[];
|
||||
yards: WarehouseYard[];
|
||||
zones: WarehouseZone[];
|
||||
@@ -81,11 +75,61 @@ function ImportTrainDetailRows({
|
||||
onAssignmentChange: (bookingId: string, draft: AssignmentDraft) => void;
|
||||
onReadyChange: (ready: boolean) => void;
|
||||
}) {
|
||||
const { data: items = [], isLoading } = useImportTrainItems(scheduleId);
|
||||
const warehouseOptions = useMemo(
|
||||
() => warehouses.map((warehouse) => ({ value: warehouse.id, label: `${warehouse.name} (${warehouse.code})` })),
|
||||
[warehouses],
|
||||
const { data: items = [], isLoading } = useImportTrainItems(train.scheduleId);
|
||||
// A train only ever unloads at the warehouse actually sitting at its
|
||||
// destination station — Indode's train never offers Sebeta's warehouse.
|
||||
const scopedWarehouses = useMemo(
|
||||
() => warehousesAtStation(warehouses, train.destinationStationId),
|
||||
[warehouses, train.destinationStationId],
|
||||
);
|
||||
const warehouseOptions = useMemo(
|
||||
() => scopedWarehouses.map((warehouse) => ({ value: warehouse.id, label: `${warehouse.name} (${warehouse.code})` })),
|
||||
[scopedWarehouses],
|
||||
);
|
||||
// With exactly one warehouse at the station there is nothing to choose —
|
||||
// pre-fill it so staff only has to pick yard/zone, not re-discover Indode.
|
||||
useEffect(() => {
|
||||
if (scopedWarehouses.length !== 1) return;
|
||||
const onlyWarehouseId = scopedWarehouses[0].id;
|
||||
items.filter(isUnloadPending).forEach((item) => {
|
||||
if (!assignments[item.bookingId]?.warehouseId) {
|
||||
onAssignmentChange(item.bookingId, { ...assignments[item.bookingId], warehouseId: onlyWarehouseId });
|
||||
}
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [scopedWarehouses, items]);
|
||||
|
||||
// Once a booking's warehouse is known, its yard (and then zone) follow from
|
||||
// what the cargo actually is — a Wheat booking only ever has one candidate
|
||||
// yard (Dry Bulk) once Indode's real yard layout is configured, so staff
|
||||
// never see a picker for something that isn't actually a choice.
|
||||
useEffect(() => {
|
||||
items.filter(isUnloadPending).forEach((item) => {
|
||||
const draft = assignments[item.bookingId];
|
||||
if (!draft?.warehouseId) return;
|
||||
|
||||
if (!draft.yardId) {
|
||||
const candidateYards = yardsForBooking(yards, {
|
||||
warehouseId: draft.warehouseId,
|
||||
freightType: item.freightType,
|
||||
tradeDirection: 'IMPORT',
|
||||
cargoTypeCode: item.cargoTypeCode,
|
||||
});
|
||||
if (candidateYards.length === 1) {
|
||||
onAssignmentChange(item.bookingId, { ...draft, yardId: candidateYards[0].id });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!draft.zoneId) {
|
||||
const candidateZones = zones.filter((zone) => zone.yardId === draft.yardId);
|
||||
if (candidateZones.length === 1) {
|
||||
onAssignmentChange(item.bookingId, { ...draft, zoneId: candidateZones[0].id });
|
||||
}
|
||||
}
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [assignments, items, yards, zones]);
|
||||
|
||||
useEffect(() => {
|
||||
const pending = items.filter(isUnloadPending);
|
||||
@@ -135,12 +179,17 @@ function ImportTrainDetailRows({
|
||||
<Table.Tbody>
|
||||
{items.map((item: ImportTrainItem) => {
|
||||
const draft = assignments[item.bookingId] ?? {};
|
||||
const { yardTypes, zoneTypes } = locationTypesForFreight(item.freightType);
|
||||
const yardOptions = yards
|
||||
.filter((yard) => yard.warehouseId === draft.warehouseId && yardTypes.includes(yard.type))
|
||||
.map((yard) => ({ value: yard.id, label: `${yard.name} (${yard.code})` }));
|
||||
const yardOptions = yardsForBooking(yards, {
|
||||
warehouseId: draft.warehouseId,
|
||||
freightType: item.freightType,
|
||||
tradeDirection: 'IMPORT',
|
||||
cargoTypeCode: item.cargoTypeCode,
|
||||
}).map((yard) => ({ value: yard.id, label: `${yard.name} (${yard.code})` }));
|
||||
// The yard is already scoped to what this cargo can go into — a
|
||||
// zone's own type always matches its parent yard's purpose (see the
|
||||
// Indode seed migration), so no separate zone-type filter is needed.
|
||||
const zoneOptions = zones
|
||||
.filter((zone) => zone.yardId === draft.yardId && zoneTypes.includes(zone.type))
|
||||
.filter((zone) => zone.yardId === draft.yardId)
|
||||
.map((zone) => ({ value: zone.id, label: `${zone.name} (${zone.code})` }));
|
||||
const pending = isUnloadPending(item);
|
||||
|
||||
@@ -395,7 +444,7 @@ export default function ArrivalQueuePage() {
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={10} bg="var(--mantine-color-gray-0)">
|
||||
<ImportTrainDetailRows
|
||||
scheduleId={train.scheduleId}
|
||||
train={train}
|
||||
warehouses={warehouses}
|
||||
yards={yards}
|
||||
zones={zones}
|
||||
|
||||
@@ -0,0 +1,528 @@
|
||||
import { Fragment, useMemo, useState } from "react";
|
||||
import { useQueries, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Group,
|
||||
Loader,
|
||||
Menu,
|
||||
Table,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
ClipboardCheck,
|
||||
FileText,
|
||||
MoreHorizontal,
|
||||
Receipt,
|
||||
} from "lucide-react";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { PageContainer, PageHeader } from "@/components/page";
|
||||
import ListControls from "@/components/common/ListControls";
|
||||
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
|
||||
import { InspectionReportModal } from "@/components/warehouses/InspectionReportModal";
|
||||
import { TruckDetentionModal } from "@/components/operations/TruckDetentionModal";
|
||||
import { WarehouseGateTimesModal } from "@/components/operations/WarehouseGateTimesModal";
|
||||
import { extractDownloadErrorMessage, formatNumber } from "@/components/warehouses/options";
|
||||
import { openPdfBlob } from "@/components/warehouses/pdf";
|
||||
import { useListControls } from "@/hooks/useListControls";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { api } from "@/services/api";
|
||||
import { lastMileService } from "@/services/last-mile.service";
|
||||
import { warehouseService, type LastMileArrivalTruck } from "@/services/warehouse.service";
|
||||
import type { ImportUnloadedItem } from "@/types/warehouse";
|
||||
|
||||
/**
|
||||
* Import trucks — the unloaded queue seen truck-first instead of item-first.
|
||||
*
|
||||
* The unloaded-queue tab lists inventory rows; the gate and the billing desk
|
||||
* ask "which truck takes this booking out, and what does it owe". So bookings
|
||||
* are the collapsed row and their trucks are the detail, each carrying its own
|
||||
* cargo costs (demurrage/storage, matched by container) and — EDR fleet only —
|
||||
* its detention clock. Customer self-haul shows "—" for detention: EDR bills
|
||||
* detention on its own trucks only.
|
||||
*
|
||||
* Per-booking truck/fee/detention queries run only while a booking is expanded;
|
||||
* the queue can hold hundreds of bookings and fetching all of them up front
|
||||
* would be several hundred requests for rows nobody opened.
|
||||
*/
|
||||
|
||||
const COLS = 11;
|
||||
|
||||
const money = (amount: number, currency: string) =>
|
||||
`${Number(amount).toLocaleString(undefined, { maximumFractionDigits: 2 })} ${currency === "ETB" ? "ETB" : currency}`;
|
||||
|
||||
const formatTime = (iso: string | null | undefined) =>
|
||||
iso ? new Date(iso).toLocaleString() : "—";
|
||||
|
||||
interface BookingGroup {
|
||||
bookingId: string;
|
||||
bookingReference: string;
|
||||
customerName: string | null;
|
||||
trainSchedule: string | null;
|
||||
status: string;
|
||||
arrivalTime: string | null;
|
||||
rows: ImportUnloadedItem[];
|
||||
}
|
||||
|
||||
/** One collapsed line per booking; its inventory rows travel with it for the fee lookup. */
|
||||
function groupByBooking(items: ImportUnloadedItem[]): BookingGroup[] {
|
||||
const groups = new Map<string, BookingGroup>();
|
||||
for (const item of items) {
|
||||
if (!item.bookingId) continue;
|
||||
const existing = groups.get(item.bookingId);
|
||||
if (existing) {
|
||||
existing.rows.push(item);
|
||||
// Mixed statuses across a booking's rows are normal mid-pickup — show the
|
||||
// least-advanced one so the row reads as "still has work on it".
|
||||
if (existing.status !== item.currentStatus) existing.status = "MIXED";
|
||||
continue;
|
||||
}
|
||||
groups.set(item.bookingId, {
|
||||
bookingId: item.bookingId,
|
||||
bookingReference: item.bookingReference ?? item.bookingId,
|
||||
customerName: item.customerName,
|
||||
trainSchedule: item.trainSchedule,
|
||||
status: item.currentStatus,
|
||||
arrivalTime: item.arrivalTime,
|
||||
rows: [item],
|
||||
});
|
||||
}
|
||||
return [...groups.values()];
|
||||
}
|
||||
|
||||
interface TruckRow {
|
||||
key: string;
|
||||
plate: string;
|
||||
driver: string | null;
|
||||
truckType: string | null;
|
||||
containers: string[];
|
||||
/** Inventory rows this truck carries — the ids the documents and fees hang off. */
|
||||
inventoryIds: string[];
|
||||
weight: number;
|
||||
demurrage: number;
|
||||
storage: number;
|
||||
vehicleId: string | null;
|
||||
arrivedAt: string | null;
|
||||
departedAt: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Haulage mode is decided by which list comes back non-empty — a booking is
|
||||
* either EDR last mile or customer self-haul, never both.
|
||||
*/
|
||||
function TruckRows({ group }: { group: BookingGroup }) {
|
||||
const { toast } = useToast();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [inspectId, setInspectId] = useState<string | null>(null);
|
||||
const [detentionOpen, setDetentionOpen] = useState(false);
|
||||
const [gateTimesOpen, setGateTimesOpen] = useState(false);
|
||||
|
||||
const edrQuery = useQuery({
|
||||
queryKey: ["booking-edr-trucks", group.bookingId],
|
||||
queryFn: () => warehouseService.getLastMileTrucks(group.bookingId),
|
||||
});
|
||||
const edrTrucks = edrQuery.data ?? [];
|
||||
|
||||
const customerQuery = useQuery({
|
||||
queryKey: ["booking-customer-trucks", group.bookingId],
|
||||
queryFn: () => warehouseService.getCustomerTrucks(group.bookingId),
|
||||
enabled: edrQuery.isSuccess && edrTrucks.length === 0,
|
||||
});
|
||||
const customerTrucks = customerQuery.data ?? [];
|
||||
const isEdr = edrTrucks.length > 0;
|
||||
|
||||
const lastMileId = edrTrucks[0]?.lastMileId ?? null;
|
||||
|
||||
const detentionQuery = useQuery({
|
||||
queryKey: ["truck-detention-preview-import-trucks", lastMileId],
|
||||
queryFn: () => lastMileService.truckDetentionPreview(lastMileId as string).then((r) => r.data),
|
||||
enabled: Boolean(lastMileId),
|
||||
});
|
||||
const detentionByVehicle = new Map(
|
||||
(detentionQuery.data?.groups ?? []).map((g) => [g.vehicleId ?? "", g]),
|
||||
);
|
||||
|
||||
const lastMileRecordQuery = useQuery({
|
||||
queryKey: ["last-mile-record-import-trucks", lastMileId],
|
||||
queryFn: () => lastMileService.getById(lastMileId as string).then((r) => r.data),
|
||||
enabled: detentionOpen && Boolean(lastMileId),
|
||||
});
|
||||
|
||||
// Same per-inventory-row preview the accrual dashboard bills off; the queue
|
||||
// rows already ARE this booking's import inventory, so no second list fetch.
|
||||
const feeQueries = useQueries({
|
||||
queries: group.rows.map((row) =>
|
||||
api.warehouses.feePreview.queryOptions({
|
||||
input: { inventoryId: row.id, billingCurrency: "USD" },
|
||||
}),
|
||||
),
|
||||
});
|
||||
const feesByInventory = new Map(group.rows.map((row, i) => [row.id, feeQueries[i]?.data ?? []]));
|
||||
const feeCurrency = feeQueries.flatMap((q) => q.data ?? [])[0]?.currency ?? "USD";
|
||||
const rowByContainer = new Map(
|
||||
group.rows.filter((r) => r.containerNumber).map((r) => [r.containerNumber as string, r]),
|
||||
);
|
||||
|
||||
/** Fees follow the container onto the truck; a bulk truck carries the whole booking. */
|
||||
const costsFor = (containers: string[]) => {
|
||||
const matched = containers.map((c) => rowByContainer.get(c)).filter(Boolean) as ImportUnloadedItem[];
|
||||
const rows = matched.length > 0 ? matched : group.rows;
|
||||
const fees = rows.flatMap((r) => feesByInventory.get(r.id) ?? []);
|
||||
const sum = (type: string) =>
|
||||
fees.filter((f) => f.ruleType === type).reduce((total, f) => total + Number(f.amount || 0), 0);
|
||||
return {
|
||||
inventoryIds: rows.map((r) => r.id),
|
||||
weight: rows.reduce((total, r) => total + (Number(r.weight) || 0), 0),
|
||||
demurrage: sum("DEMURRAGE_FEE"),
|
||||
storage: sum("STORAGE_FEE"),
|
||||
};
|
||||
};
|
||||
|
||||
const fromEdr = (t: LastMileArrivalTruck): TruckRow => {
|
||||
const containers = t.containerNumber ? [t.containerNumber] : [];
|
||||
return {
|
||||
key: t.vehicleId,
|
||||
plate: [t.truckPlateNumber, t.trailerPlateNumber].filter(Boolean).join(" + ") || "—",
|
||||
driver: t.driverName,
|
||||
truckType: t.truckType,
|
||||
containers,
|
||||
vehicleId: t.vehicleId,
|
||||
arrivedAt: t.arrivedAt,
|
||||
departedAt: t.departedAt,
|
||||
...costsFor(containers),
|
||||
};
|
||||
};
|
||||
const fromCustomer = (t: Freight.ICustomerTruck): TruckRow => {
|
||||
const containers = (t.containers ?? []).map((c) => c.containerNumber);
|
||||
return {
|
||||
key: t.id,
|
||||
plate: t.plateNumber,
|
||||
driver: t.driverName,
|
||||
truckType: t.truckType,
|
||||
containers,
|
||||
vehicleId: null,
|
||||
arrivedAt: t.arrivedAt ?? null,
|
||||
departedAt: t.departedAt ?? null,
|
||||
...costsFor(containers),
|
||||
};
|
||||
};
|
||||
const trucks: TruckRow[] = isEdr ? edrTrucks.map(fromEdr) : customerTrucks.map(fromCustomer);
|
||||
|
||||
const openDocument = async (
|
||||
kind: "release" | "handover",
|
||||
inventoryId: string,
|
||||
label: string,
|
||||
) => {
|
||||
setBusy(true);
|
||||
const pdfWindow = window.open("", "_blank");
|
||||
try {
|
||||
const response =
|
||||
kind === "release"
|
||||
? await warehouseService.downloadReleaseDocument(inventoryId)
|
||||
: await warehouseService.downloadHandoverDocument(inventoryId);
|
||||
openPdfBlob(response.data, `${kind}-${group.bookingReference}.pdf`, pdfWindow);
|
||||
} catch (error) {
|
||||
pdfWindow?.close();
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: `${label} failed`,
|
||||
description: await extractDownloadErrorMessage(error),
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (edrQuery.isLoading || (customerQuery.isFetching && customerTrucks.length === 0)) {
|
||||
return (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={COLS}>
|
||||
<Group gap="xs" justify="center" py="xs">
|
||||
<Loader size="xs" />
|
||||
<Text size="xs" c="dimmed">
|
||||
Loading trucks…
|
||||
</Text>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
}
|
||||
|
||||
if (trucks.length === 0) {
|
||||
return (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={COLS}>
|
||||
<Text size="xs" c="dimmed" ta="center" py="xs">
|
||||
No truck assigned to this booking yet.
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{trucks.map((t, idx) => {
|
||||
const detention = t.vehicleId ? detentionByVehicle.get(t.vehicleId) : undefined;
|
||||
const primaryId = t.inventoryIds[0] ?? null;
|
||||
return (
|
||||
<Table.Tr key={t.key} bg="var(--mantine-color-gray-0)">
|
||||
<Table.Td />
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={600}>
|
||||
{t.plate}
|
||||
</Text>
|
||||
{t.driver && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t.driver}
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="sm" radius="sm" variant="light" color={isEdr ? "edr-green" : "blue"}>
|
||||
{isEdr ? "EDR" : "Customer"}
|
||||
</Badge>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t.truckType ?? "—"}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{t.containers.length ? t.containers.join(", ") : "Bulk"}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs">{formatTime(t.arrivedAt)}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs">{formatTime(t.departedAt)}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{formatNumber(t.weight)}</Table.Td>
|
||||
<Table.Td>{money(t.demurrage, feeCurrency)}</Table.Td>
|
||||
<Table.Td>{money(t.storage, feeCurrency)}</Table.Td>
|
||||
<Table.Td>
|
||||
{!isEdr || !detention ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
—
|
||||
</Text>
|
||||
) : (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Text size="sm">
|
||||
{detention.chargeableDays ?? 0}d ·{" "}
|
||||
{money(Number(detention.amount ?? 0), detentionQuery.data?.currency ?? "USD")}
|
||||
</Text>
|
||||
{detention.endIsOpen && (
|
||||
<Badge size="xs" color="orange" variant="light">
|
||||
open
|
||||
</Badge>
|
||||
)}
|
||||
{!detention.ruleId && (
|
||||
<Tooltip label="No detention rule matched this truck" withArrow>
|
||||
<Text size="xs" c="red">
|
||||
no rule
|
||||
</Text>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<Menu shadow="md" width={220} position="bottom-end" withinPortal>
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle" color="gray" aria-label="Truck actions" loading={busy}>
|
||||
<MoreHorizontal size={16} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<FileText size={14} />}
|
||||
disabled={!primaryId}
|
||||
onClick={() => primaryId && openDocument("release", primaryId, "Exit paper")}
|
||||
>
|
||||
Exit paper
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<FileText size={14} />}
|
||||
disabled={!primaryId}
|
||||
onClick={() => primaryId && openDocument("handover", primaryId, "Handover")}
|
||||
>
|
||||
Handover
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<ClipboardCheck size={14} />}
|
||||
disabled={!primaryId}
|
||||
onClick={() => setInspectId(primaryId)}
|
||||
>
|
||||
Inspect / report
|
||||
</Menu.Item>
|
||||
{isEdr && (
|
||||
<>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
leftSection={<Receipt size={14} />}
|
||||
disabled={!lastMileId}
|
||||
onClick={() => setDetentionOpen(true)}
|
||||
>
|
||||
Detention times…
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<FileText size={14} />}
|
||||
disabled={!lastMileId}
|
||||
onClick={() => setGateTimesOpen(true)}
|
||||
>
|
||||
Warehouse gate times…
|
||||
</Menu.Item>
|
||||
</>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
{/* Modals portal out of the table, so one mount for the whole
|
||||
booking hangs off the first truck's cell. */}
|
||||
{idx === 0 && (
|
||||
<>
|
||||
<InspectionReportModal
|
||||
opened={Boolean(inspectId)}
|
||||
onClose={() => setInspectId(null)}
|
||||
inventoryId={inspectId}
|
||||
/>
|
||||
{isEdr && (
|
||||
<>
|
||||
<TruckDetentionModal
|
||||
opened={detentionOpen}
|
||||
onClose={() => setDetentionOpen(false)}
|
||||
record={lastMileRecordQuery.data ?? null}
|
||||
/>
|
||||
<WarehouseGateTimesModal
|
||||
opened={gateTimesOpen}
|
||||
onClose={() => setGateTimesOpen(false)}
|
||||
record={lastMileRecordQuery.data ?? null}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ImportTrucksPage() {
|
||||
const { data: items = [], isLoading } = useQuery(
|
||||
api.warehouses.importUnloadedQueue.queryOptions({}),
|
||||
);
|
||||
const [expanded, setExpanded] = useState<string | null>(null);
|
||||
|
||||
const groups = useMemo(() => groupByBooking(items), [items]);
|
||||
const controls = useListControls(groups, {
|
||||
searchKeys: ["bookingReference", "customerName", "trainSchedule"],
|
||||
dateKey: "arrivalTime",
|
||||
});
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Import trucks"
|
||||
subtitle="Unloaded import bookings and the trucks taking them out — cargo costs per truck, detention on EDR fleet."
|
||||
/>
|
||||
|
||||
<ListControls
|
||||
search={controls.search}
|
||||
onSearchChange={controls.setSearch}
|
||||
searchPlaceholder="Booking, customer, train…"
|
||||
dateFrom={controls.dateFrom}
|
||||
onDateFromChange={controls.setDateFrom}
|
||||
dateTo={controls.dateTo}
|
||||
onDateToChange={controls.setDateTo}
|
||||
dateLabel="Arrived"
|
||||
hasFilters={controls.hasFilters}
|
||||
onReset={controls.reset}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<Group justify="center" py="lg">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
) : controls.pagedRows.length === 0 ? (
|
||||
<Alert variant="light" color="gray">
|
||||
No unloaded import bookings. They appear here after Auto Unload on an arrived train.
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
<Table.ScrollContainer minWidth={1100}>
|
||||
<Table highlightOnHover verticalSpacing="xs">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th w={40} />
|
||||
<Table.Th>Plate</Table.Th>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Containers</Table.Th>
|
||||
<Table.Th>Truck Arrival</Table.Th>
|
||||
<Table.Th>Truck Leaving</Table.Th>
|
||||
<Table.Th>Weight</Table.Th>
|
||||
<Table.Th>Demurrage</Table.Th>
|
||||
<Table.Th>Storage</Table.Th>
|
||||
<Table.Th>Detention</Table.Th>
|
||||
<Table.Th ta="right">Actions</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{controls.pagedRows.map((g) => {
|
||||
const isOpen = expanded === g.bookingId;
|
||||
return (
|
||||
<Fragment key={g.bookingId}>
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label={isOpen ? "Hide trucks" : "Show trucks"}
|
||||
onClick={() => setExpanded(isOpen ? null : g.bookingId)}
|
||||
>
|
||||
{isOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</ActionIcon>
|
||||
</Table.Td>
|
||||
<Table.Td colSpan={COLS - 1}>
|
||||
<Group gap="md" wrap="wrap">
|
||||
<Text size="sm" fw={700}>
|
||||
{g.bookingReference}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{g.customerName ?? "—"}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Train: {g.trainSchedule ?? "—"}
|
||||
</Text>
|
||||
<Badge size="sm" radius="sm" variant="light" color="gray">
|
||||
{g.status.replace(/_/g, " ")}
|
||||
</Badge>
|
||||
<Text size="xs" c="dimmed">
|
||||
{g.rows.length} item{g.rows.length === 1 ? "" : "s"}
|
||||
</Text>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
{isOpen && <TruckRows group={g} />}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
<RuleEngineListFooter
|
||||
pagination={controls.pagination}
|
||||
pageCount={controls.pageCount}
|
||||
totalCount={controls.totalCount}
|
||||
itemLabel="bookings"
|
||||
onPaginationChange={controls.setPagination}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -835,6 +835,12 @@ function FeeRules() {
|
||||
{FEE_RULE_BASIS_LABELS[form.basis]}). No free days or progressive tiers.
|
||||
</Text>
|
||||
)}
|
||||
{isBulkRule && !isDoubleHandling && (
|
||||
<Text size="xs" c="dimmed">
|
||||
Bulk rate is per day, scaled by quantity — tons for tonnage cargo, item count for
|
||||
countable cargo (Machinery, Truck, Automobile, Livestock…), set on each cargo type.
|
||||
</Text>
|
||||
)}
|
||||
{!isDoubleHandling && (
|
||||
<Stack gap="xs">
|
||||
<Group justify="space-between">
|
||||
|
||||
@@ -75,6 +75,9 @@ export interface LastMileRecord {
|
||||
/** Per-truck arrival / exit, stamped by the warehouse weighing steps. */
|
||||
arrivedAt?: string | null;
|
||||
departedAt?: string | null;
|
||||
/** This truck's own detention window (destination arrival → released). */
|
||||
destinationArrivedAt?: string | null;
|
||||
returnedAt?: string | null;
|
||||
grossWeightTons?: number | null;
|
||||
netWeightTons?: number | null;
|
||||
vehicle?: LastMileVehicle | null;
|
||||
@@ -143,4 +146,22 @@ export const lastMileService = {
|
||||
/** Preview the truck-detention charge for a last-mile leg. */
|
||||
truckDetentionPreview: (id: string) =>
|
||||
api.get<FeePreview>(`${LM.BASE}/${id}/truck-detention-preview`),
|
||||
/** Per-truck detention windows — each truck has its own clock. */
|
||||
setDetentionTimes: (
|
||||
id: string,
|
||||
trucks: Array<{
|
||||
vehicleId: string;
|
||||
destinationArrivedAt?: string | null;
|
||||
returnedAt?: string | null;
|
||||
}>,
|
||||
) => api.post<LastMileRecord>(`${LM.BASE}/${id}/detention-times`, { trucks }),
|
||||
/** Set warehouse gate arrival/departure times for each truck. */
|
||||
setWarehouseGateTimes: (
|
||||
id: string,
|
||||
trucks: Array<{
|
||||
vehicleId: string;
|
||||
arrivedAt?: string | null;
|
||||
departedAt?: string | null;
|
||||
}>,
|
||||
) => api.post<LastMileRecord>(`${LM.BASE}/${id}/warehouse-gate-times`, { trucks }),
|
||||
};
|
||||
|
||||
@@ -91,6 +91,7 @@ export interface ContainerItem {
|
||||
contractId: string | null;
|
||||
hasLastMile: boolean;
|
||||
handoverSigned: boolean;
|
||||
inspectionStatus: string | null;
|
||||
}
|
||||
|
||||
/** A pre-dispatch EXPORT train that has inventory waiting to be loaded. */
|
||||
@@ -136,6 +137,8 @@ const cleanParams = (params: object) =>
|
||||
|
||||
/** An assigned EDR last-mile truck, shaped for the arrival/exit weighing prefill. */
|
||||
export interface LastMileArrivalTruck {
|
||||
/** The last-mile leg this truck belongs to — feed straight into lastMileService.truckDetentionPreview(lastMileId). */
|
||||
lastMileId: string;
|
||||
vehicleId: string;
|
||||
truckPlateNumber: string | null;
|
||||
trailerPlateNumber: string | null;
|
||||
@@ -334,6 +337,13 @@ export const warehouseService = {
|
||||
deliver: (id: string, payload: DeliverInventoryPayload) =>
|
||||
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.DELIVER(id), payload),
|
||||
|
||||
/** Post-unloading Yes/No — Yes makes the double-handling fee rule bill this booking. */
|
||||
setDoubleHandling: (bookingId: string, doubleHandling: boolean) =>
|
||||
apiClient.patch<{ bookingId: string; doubleHandling: boolean; setAt: string }>(
|
||||
`/warehouse-inventory/bookings/${bookingId}/double-handling`,
|
||||
{ doubleHandling },
|
||||
),
|
||||
|
||||
// ── Receive (Import/Export bulk) ─────────────────────────────────────────
|
||||
eligibleBookings: (direction?: 'IMPORT' | 'EXPORT') =>
|
||||
apiClient.get<EligibleBooking[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.ELIGIBLE_BOOKINGS(direction)),
|
||||
|
||||
@@ -54,6 +54,28 @@ const extractMessage = (value: unknown): string | null => {
|
||||
}
|
||||
};
|
||||
|
||||
// IAM (@tria-plc/iamapi-common) throws BadRequestException with a raw,
|
||||
// untranslated code string as the message — no i18n on that side — so it
|
||||
// would otherwise reach the UI verbatim (e.g. "user_role_not_found"). Map
|
||||
// known codes to a friendly, translated message before falling back to the
|
||||
// raw text. `unit_employee_limit_reached` carries its configured limit after
|
||||
// a colon (e.g. "unit_employee_limit_reached:5").
|
||||
const UNIT_EMPLOYEE_LIMIT_PREFIX = "unit_employee_limit_reached:";
|
||||
|
||||
const mapIamErrorCode = (
|
||||
raw: string | null,
|
||||
t: (key: string, options?: Record<string, unknown>) => string,
|
||||
): string | null => {
|
||||
if (!raw) return null;
|
||||
if (raw.startsWith(UNIT_EMPLOYEE_LIMIT_PREFIX)) {
|
||||
return t("msg.unitEmployeeLimitReached", {
|
||||
limit: raw.slice(UNIT_EMPLOYEE_LIMIT_PREFIX.length),
|
||||
});
|
||||
}
|
||||
if (raw === "user_role_not_found") return t("msg.userRoleNotFound");
|
||||
return null;
|
||||
};
|
||||
|
||||
// Maps an HTTP status code to the i18n key used when no backend message is available.
|
||||
const statusKeyFor = (status: number | undefined): string => {
|
||||
if (status === 400 || status === 422) return "msg.validationError";
|
||||
@@ -111,7 +133,7 @@ const parseBlobBody = async (blob: Blob): Promise<unknown> => {
|
||||
};
|
||||
|
||||
export const useErrorHandler = (
|
||||
t: (key: string) => string,
|
||||
t: (key: string, options?: Record<string, unknown>) => string,
|
||||
) => {
|
||||
const getErrorMessage = useCallback(
|
||||
async (err: unknown): Promise<string> => {
|
||||
@@ -134,15 +156,15 @@ export const useErrorHandler = (
|
||||
const fromException =
|
||||
extractMessage((data as any)?.exception?.response) ??
|
||||
extractMessage((data as any)?.exception);
|
||||
if (fromException) return fromException;
|
||||
if (fromException) return mapIamErrorCode(fromException, t) ?? fromException;
|
||||
|
||||
const fromData = extractMessage(data);
|
||||
if (fromData) return fromData;
|
||||
if (fromData) return mapIamErrorCode(fromData, t) ?? fromData;
|
||||
}
|
||||
|
||||
if (err instanceof Error) {
|
||||
const fromError = extractMessage(err.message);
|
||||
if (fromError) return fromError;
|
||||
if (fromError) return mapIamErrorCode(fromError, t) ?? fromError;
|
||||
}
|
||||
|
||||
return t(statusKeyFor(status));
|
||||
@@ -186,7 +208,7 @@ export const useErrorHandler = (
|
||||
};
|
||||
|
||||
export const useClientErrorHandler = (
|
||||
t: (key: string) => string,
|
||||
t: (key: string, options?: Record<string, unknown>) => string,
|
||||
) => {
|
||||
const getErrorMessage = useCallback(
|
||||
(err: unknown): string => {
|
||||
@@ -205,13 +227,13 @@ export const useClientErrorHandler = (
|
||||
const fromException =
|
||||
extractMessage(data?.exception?.response) ??
|
||||
extractMessage(data?.exception);
|
||||
if (fromException) return fromException;
|
||||
if (fromException) return mapIamErrorCode(fromException, t) ?? fromException;
|
||||
|
||||
const fromData = extractMessage(data);
|
||||
if (fromData) return fromData;
|
||||
if (fromData) return mapIamErrorCode(fromData, t) ?? fromData;
|
||||
|
||||
const fromError = extractMessage((err as any).message);
|
||||
if (fromError) return fromError;
|
||||
if (fromError) return mapIamErrorCode(fromError, t) ?? fromError;
|
||||
}
|
||||
|
||||
return t(statusKeyFor(status));
|
||||
|
||||
@@ -25,6 +25,9 @@ axiosInstance.interceptors.request.use((config) => {
|
||||
}
|
||||
// X-Requested-With prevents CSRF via browser-native form/fetch without custom headers
|
||||
config.headers["X-Requested-With"] = "XMLHttpRequest";
|
||||
// Tells the backend which app is asking, so /auth/login can reject
|
||||
// cross-audience credentials (EDRFREIGHT-415).
|
||||
config.headers["X-Client-App"] = "backoffice";
|
||||
return config;
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { Link } from "react-router-dom";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/common/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/common/ui/select";
|
||||
import { useLocalizedName } from "@/shared/common/localizedName";
|
||||
import { useApplications } from "@/user-management/hooks/useApplications";
|
||||
import { PermissionSearch } from "@/user-management/components/position-management/PermissionSearch";
|
||||
import { getRoles } from "@/super-admin/services/api/roleService";
|
||||
import {
|
||||
assignPermissionsToRole,
|
||||
getPermissionsByRoleId,
|
||||
} from "@/super-admin/services/api/rolePermissionService";
|
||||
import { ORG_ADMIN_ROLE_KEY } from "./OrgAdminsColumnDefn";
|
||||
|
||||
// The Organization Admin role is a fixed, singleton role (unlike position
|
||||
// types, which are org/unit-scoped) — so this page has no picker, just the
|
||||
// one role's permission set.
|
||||
export default function OrgAdminPermissionsPage() {
|
||||
const { t } = useTranslation();
|
||||
const localizedName = useLocalizedName();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [selectedApplicationId, setSelectedApplicationId] = useState("");
|
||||
const [permissions, setPermissions] = useState<string[]>([]);
|
||||
const hasLoadedPermissions = useRef(false);
|
||||
// Permissions the role had when the page opened. Needed because the API
|
||||
// cannot represent "no permissions" (see the save handler).
|
||||
const loadedPermissionCount = useRef(0);
|
||||
|
||||
const { applications, isLoading: isLoadingApplications } = useApplications();
|
||||
|
||||
const {
|
||||
data: rolesResponse,
|
||||
isLoading: isLoadingRoles,
|
||||
isError: isRolesError,
|
||||
} = useQuery({ queryKey: ["roles"], queryFn: getRoles });
|
||||
|
||||
const orgAdminRole = useMemo(
|
||||
() => rolesResponse?.data?.items?.find((r) => r.key === ORG_ADMIN_ROLE_KEY),
|
||||
[rolesResponse],
|
||||
);
|
||||
|
||||
const {
|
||||
data: rolePermissionsResponse,
|
||||
isSuccess: isPermissionsSuccess,
|
||||
isError: isPermissionsError,
|
||||
isLoading: isLoadingPermissions,
|
||||
} = useQuery({
|
||||
queryKey: ["role-permissions", orgAdminRole?.id],
|
||||
queryFn: () => getPermissionsByRoleId(orgAdminRole!.id),
|
||||
enabled: !!orgAdminRole?.id,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (hasLoadedPermissions.current) return;
|
||||
if (!isPermissionsSuccess && !isPermissionsError) return;
|
||||
const ids = rolePermissionsResponse?.data?.items?.map((p) => p.id) ?? [];
|
||||
loadedPermissionCount.current = ids.length;
|
||||
setPermissions(ids);
|
||||
hasLoadedPermissions.current = true;
|
||||
}, [isPermissionsSuccess, isPermissionsError, rolePermissionsResponse]);
|
||||
|
||||
const handlePermissionChange = (permissionId: string, checked: boolean) => {
|
||||
setPermissions((prev) =>
|
||||
checked ? [...prev, permissionId] : prev.filter((id) => id !== permissionId),
|
||||
);
|
||||
};
|
||||
|
||||
const mustClearAll =
|
||||
permissions.length === 0 && loadedPermissionCount.current > 0;
|
||||
|
||||
const { mutate: save, isPending: isSaving } = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!orgAdminRole?.id || permissions.length === 0) return;
|
||||
await assignPermissionsToRole({
|
||||
firstId: orgAdminRole.id,
|
||||
secondIds: permissions,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
loadedPermissionCount.current = permissions.length;
|
||||
queryClient.invalidateQueries({ queryKey: ["role-permissions"] });
|
||||
toast[mustClearAll ? "warning" : "success"](
|
||||
t(
|
||||
mustClearAll
|
||||
? "orgAdmins.permissions.cannotClearAll"
|
||||
: "orgAdmins.permissions.saved",
|
||||
),
|
||||
);
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t("orgAdmins.permissions.saveFailed"));
|
||||
},
|
||||
});
|
||||
|
||||
const selectedPermissionCount = permissions.length;
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<Card className="shadow-none border-none bg-transparent px-0">
|
||||
<CardHeader className="px-0 space-y-1">
|
||||
<Link
|
||||
to="/user-management/organization_admins"
|
||||
className="inline-flex w-fit items-center gap-1 text-sm text-muted-foreground hover:text-foreground">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
{t("orgAdmins.permissions.backToAdmins")}
|
||||
</Link>
|
||||
<CardTitle className="text-2xl font-bold text-slate-800 dark:text-slate-100">
|
||||
{t("orgAdmins.permissions.title")}
|
||||
</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("orgAdmins.permissions.subtitle")}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="px-0 space-y-6">
|
||||
{isLoadingRoles ? (
|
||||
<div className="py-8 text-center text-muted-foreground">
|
||||
{t("common.loading")}
|
||||
</div>
|
||||
) : isRolesError || !orgAdminRole ? (
|
||||
<div className="py-8 text-center text-red-500">
|
||||
{t("orgAdmins.permissions.roleNotFound")}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="w-full sm:w-1/2">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
{t("contentManagement.selectApplication")}
|
||||
</label>
|
||||
<Select
|
||||
value={selectedApplicationId}
|
||||
onValueChange={setSelectedApplicationId}
|
||||
disabled={isLoadingApplications}>
|
||||
<SelectTrigger className="mt-1 block w-full border-gray-300 rounded-md shadow-sm">
|
||||
<SelectValue
|
||||
placeholder={
|
||||
isLoadingApplications
|
||||
? t("common.loading")
|
||||
: t("contentManagement.selectApplication")
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-60 overflow-y-auto">
|
||||
{applications?.map((app) => (
|
||||
<SelectItem key={app.id} value={app.id}>
|
||||
{localizedName(app.name)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
{t("contentManagement.permission")}
|
||||
{selectedPermissionCount > 0 && (
|
||||
<span className="ml-2 font-normal text-muted-foreground">
|
||||
(
|
||||
{t("contentManagement.permissionsSelected", {
|
||||
count: selectedPermissionCount,
|
||||
})}
|
||||
)
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<PermissionSearch
|
||||
selectedPermissions={permissions}
|
||||
onPermissionChange={handlePermissionChange}
|
||||
applicationId={selectedApplicationId}
|
||||
disabled={isLoadingPermissions}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
disabled={isSaving || isLoadingPermissions}
|
||||
onClick={() => save()}>
|
||||
{isSaving ? t("common.saving") : t("delegation.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -32,13 +32,15 @@ export interface AdminRoleInfo {
|
||||
/**
|
||||
* all-admins/:id returns users who are org admins of the org OR unit admins of
|
||||
* one of its units; userRoles carries every role of the user, so match the org
|
||||
* explicitly for the org-admin grant.
|
||||
* explicitly for the org-admin grant. The unit-admin grant carries no
|
||||
* organizationId of its own, only a unitId, so orgUnitIds (every unit that
|
||||
* belongs to the selected org) is required to tell a same-org unit-admin
|
||||
* grant apart from a same-user unit-admin grant in a different org.
|
||||
*/
|
||||
// ponytail: unit relation isn't loaded, so a unit_admin grant from another org
|
||||
// can't be told apart — acceptable, the server only returns admins of this org.
|
||||
export function getAdminRoleInfo(
|
||||
admin: OrgAdminUser,
|
||||
selectedOrgId: string,
|
||||
orgUnitIds: Set<string>,
|
||||
): AdminRoleInfo {
|
||||
const roles = admin.userRoles ?? [];
|
||||
const isOrgAdmin = roles.some(
|
||||
@@ -47,7 +49,10 @@ export function getAdminRoleInfo(
|
||||
r.organizationId === selectedOrgId,
|
||||
);
|
||||
const unitRole = roles.find(
|
||||
(r) => r.role?.key === UNIT_ADMIN_ROLE_KEY && r.unitId,
|
||||
(r) =>
|
||||
r.role?.key === UNIT_ADMIN_ROLE_KEY &&
|
||||
!!r.unitId &&
|
||||
orgUnitIds.has(r.unitId),
|
||||
);
|
||||
return {
|
||||
isOrgAdmin,
|
||||
@@ -58,6 +63,7 @@ export function getAdminRoleInfo(
|
||||
|
||||
interface ColumnCallbacks {
|
||||
selectedOrgId: string;
|
||||
orgUnitIds: Set<string>;
|
||||
localizedName: (name?: { am?: string; en?: string }) => string;
|
||||
onEdit: (admin: OrgAdminUser) => void;
|
||||
onResend: (admin: OrgAdminUser) => void;
|
||||
@@ -67,6 +73,7 @@ interface ColumnCallbacks {
|
||||
|
||||
export function getOrgAdminsColumnDefn({
|
||||
selectedOrgId,
|
||||
orgUnitIds,
|
||||
localizedName,
|
||||
onEdit,
|
||||
onResend,
|
||||
@@ -118,7 +125,7 @@ export function getOrgAdminsColumnDefn({
|
||||
id: "role",
|
||||
header: () => t("orgAdmins.columns.role"),
|
||||
cell: ({ row }) => {
|
||||
const info = getAdminRoleInfo(row.original, selectedOrgId);
|
||||
const info = getAdminRoleInfo(row.original, selectedOrgId, orgUnitIds);
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{info.isOrgAdmin && (
|
||||
@@ -179,7 +186,7 @@ export function getOrgAdminsColumnDefn({
|
||||
enableHiding: false,
|
||||
cell: ({ row }) => {
|
||||
const admin = row.original;
|
||||
const roleInfo = getAdminRoleInfo(admin, selectedOrgId);
|
||||
const roleInfo = getAdminRoleInfo(admin, selectedOrgId, orgUnitIds);
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { Building2, Loader2, Plus, UserPlus, Users2 } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
Building2,
|
||||
Loader2,
|
||||
Plus,
|
||||
ShieldCheck,
|
||||
UserPlus,
|
||||
Users2,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import {
|
||||
Card,
|
||||
@@ -23,6 +31,8 @@ import { Badge } from "@/shared/common/ui/badge";
|
||||
import { AdvancedTable } from "@/shared/common/ui/table/AdvancedTable";
|
||||
import { useLocalizedName } from "@/shared/common/localizedName";
|
||||
import { OrganizationDto } from "@/shared/dto/organization/organizationDto";
|
||||
import { useUnit } from "@/user-management/hooks/useUnit";
|
||||
import { UnitDto } from "@/user-management/dto/unit/unitDto";
|
||||
import {
|
||||
OrgAdminUser,
|
||||
useOrgAdmins,
|
||||
@@ -81,6 +91,22 @@ export default function OrgAdminsPage() {
|
||||
skip: pageIndex * pageSize,
|
||||
});
|
||||
|
||||
const { getList: getUnitList } = useUnit();
|
||||
// A unit-admin grant only carries a unitId, no organizationId — this is the
|
||||
// set that tells "unit_admin of this org" apart from "unit_admin of some
|
||||
// other org the same user also administers" (see getAdminRoleInfo).
|
||||
const { data: orgUnitsResponse } = getUnitList(selectedOrg?.id ?? "", {
|
||||
take: 3000,
|
||||
skip: 0,
|
||||
});
|
||||
const orgUnitIds = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
(orgUnitsResponse?.data?.items ?? []).map((unit: UnitDto) => unit.id),
|
||||
),
|
||||
[orgUnitsResponse],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setPageIndex(0);
|
||||
}, [selectedOrg?.id, pageSize]);
|
||||
@@ -170,6 +196,7 @@ export default function OrgAdminsPage() {
|
||||
() =>
|
||||
getOrgAdminsColumnDefn({
|
||||
selectedOrgId: selectedOrg?.id ?? "",
|
||||
orgUnitIds,
|
||||
localizedName: localizedName as (name?: {
|
||||
am?: string;
|
||||
en?: string;
|
||||
@@ -183,19 +210,29 @@ export default function OrgAdminsPage() {
|
||||
onRemove: (admin, roleInfo) => setRemoveTarget({ admin, roleInfo }),
|
||||
}),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[selectedOrg?.id],
|
||||
[selectedOrg?.id, orgUnitIds],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<Card className="shadow-none border-none bg-transparent px-0">
|
||||
<CardHeader className="px-0 space-y-1">
|
||||
<CardTitle className="text-2xl font-bold text-slate-800 dark:text-slate-100">
|
||||
{t("orgAdmins.title")}
|
||||
</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("orgAdmins.subtitle")}
|
||||
</p>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-2xl font-bold text-slate-800 dark:text-slate-100">
|
||||
{t("orgAdmins.title")}
|
||||
</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("orgAdmins.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" asChild>
|
||||
<Link to="/user-management/organization_admins/permissions">
|
||||
<ShieldCheck className="h-4 w-4" />
|
||||
{t("orgAdmins.managePermissions")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-0 space-y-4">
|
||||
{/* Org selector + summary */}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { withHeaders } from "@/record-management/services/api/withHeaders";
|
||||
import axiosInstance from "@/shared/services/axiosInstance";
|
||||
import { PermissionListResponse } from "@/user-management/dto/permissions/permissonDto";
|
||||
import { AxiosResponse } from "axios";
|
||||
|
||||
export interface AssignRolePermissionsPayload {
|
||||
firstId: string;
|
||||
secondIds: string[];
|
||||
}
|
||||
|
||||
// GET /role-permissions/given-first/{roleId}
|
||||
export const getPermissionsByRoleId = async (
|
||||
roleId: string,
|
||||
): Promise<AxiosResponse<PermissionListResponse>> =>
|
||||
axiosInstance.get(`/role-permissions/given-first/${roleId}`, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
|
||||
// POST /role-permissions/assign-seconds-for-first
|
||||
export const assignPermissionsToRole = async (
|
||||
payload: AssignRolePermissionsPayload,
|
||||
): Promise<AxiosResponse<void>> =>
|
||||
axiosInstance.post("/role-permissions/assign-seconds-for-first", payload, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { withHeaders } from "@/record-management/services/api/withHeaders";
|
||||
import axiosInstance from "@/shared/services/axiosInstance";
|
||||
import { AxiosResponse } from "axios";
|
||||
|
||||
export interface RoleDto {
|
||||
id: string;
|
||||
name: { am: string; en: string };
|
||||
key: string;
|
||||
}
|
||||
|
||||
export interface RoleListResponse {
|
||||
count: number;
|
||||
items: RoleDto[];
|
||||
}
|
||||
|
||||
export const getRoles = async (): Promise<AxiosResponse<RoleListResponse>> =>
|
||||
axiosInstance.get("/roles", {
|
||||
headers: withHeaders(),
|
||||
params: { take: 100 },
|
||||
});
|
||||
@@ -135,6 +135,36 @@ export interface CustomerResetTarget {
|
||||
phoneIsDomestic: boolean | null;
|
||||
}
|
||||
|
||||
/** One person's Fayda verification state — mirrors `IdentityVerificationStateDto`. */
|
||||
export interface IdentityVerificationState {
|
||||
verified: boolean;
|
||||
name: string | null;
|
||||
phone: string | null;
|
||||
email: string | null;
|
||||
address: string | null;
|
||||
verifiedAt: string | null;
|
||||
birthdate: string | null;
|
||||
gender: string | null;
|
||||
}
|
||||
|
||||
/** Mirrors `OwnerIdentityStateDto`. */
|
||||
export interface OwnerIdentityState extends IdentityVerificationState {
|
||||
passportNumber: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owner/PoA Fayda verification, shared with the portal's derivation
|
||||
* (`buildCompanyIdentityState`) so backoffice never re-derives — or
|
||||
* disagrees with — the rule the API actually enforces.
|
||||
*/
|
||||
export interface CompanyIdentityState {
|
||||
faydaRequired: boolean;
|
||||
passportRequired: boolean;
|
||||
owner: OwnerIdentityState;
|
||||
poa: IdentityVerificationState;
|
||||
complete: boolean;
|
||||
}
|
||||
|
||||
/** Mirrors backend `Company` (+ its `companyProfiles`). */
|
||||
export interface Company {
|
||||
id: string;
|
||||
@@ -161,6 +191,20 @@ export interface Company {
|
||||
poaAddress?: string | null;
|
||||
website?: string | null;
|
||||
attributes?: Record<string, unknown> | null;
|
||||
// eTrade-sourced registration record — populated by the onboarding TIN
|
||||
// lookup, locked/read-only on the portal from the moment it's fetched.
|
||||
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;
|
||||
identity?: CompanyIdentityState;
|
||||
companyProfiles: CompanyProfile[];
|
||||
/**
|
||||
* Whether the customer submitted their onboarding application. A company row
|
||||
|
||||
@@ -118,12 +118,19 @@ export interface WarehouseZone {
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
/** IMPORT | EXPORT | BOTH | null. Only meaningful for CONTAINER_YARD — everything else takes cargo either way. */
|
||||
export type WarehouseYardDirection = 'IMPORT' | 'EXPORT' | 'BOTH';
|
||||
|
||||
export interface WarehouseYard {
|
||||
id: string;
|
||||
warehouseId: string;
|
||||
name: string;
|
||||
code: string;
|
||||
type: WarehouseYardType;
|
||||
/** For CONTAINER_YARD: which direction this stack serves. BOTH/null on a container yard means "not a customer cargo yard" (service/equipment), not "any direction". */
|
||||
direction?: WarehouseYardDirection | null;
|
||||
/** Cargo types this yard accepts. Empty/absent = open to any cargo type of this yard's structural type. */
|
||||
cargoTypes?: Array<{ id: string; code: string }>;
|
||||
capacityWeight: number | null;
|
||||
capacityContainers: number | null;
|
||||
maxWeight: number | null;
|
||||
@@ -518,6 +525,8 @@ export interface ImportTrain {
|
||||
route: string | null;
|
||||
origin: string | null;
|
||||
destination: string | null;
|
||||
/** freight.yards.id the train is heading to — matches Warehouse.stationId, so the unload picker can be scoped to the warehouse actually at this station. */
|
||||
destinationStationId: string | null;
|
||||
departureTime?: string | null;
|
||||
arrivalTime: string | null;
|
||||
totalBookings: number;
|
||||
@@ -603,6 +612,8 @@ export interface ImportUnloadedItem {
|
||||
customerTruckContainerNumber: string | null;
|
||||
customerTruckAssignedAt: string | null;
|
||||
hasAssignedTruck: boolean;
|
||||
/** Post-unloading Yes/No; null = not recorded yet (no double-handling charge). */
|
||||
doubleHandling: boolean | null;
|
||||
currentStatus: string;
|
||||
releaseDate: string | null;
|
||||
releaseOrderReference: string | null;
|
||||
@@ -630,6 +641,8 @@ export interface ImportTrainItem {
|
||||
freightType: string | null;
|
||||
containerNumber: string | null;
|
||||
cargoType: string | null;
|
||||
/** Cargo type CODE (e.g. "WHEAT"), for matching against a yard's configured cargo types — `cargoType` above is the display name. */
|
||||
cargoTypeCode: string | null;
|
||||
weight: number | null;
|
||||
arrivalTime: string | null;
|
||||
currentStatus: string | null;
|
||||
@@ -849,13 +862,21 @@ export interface FeePreview {
|
||||
elapsedDays: number;
|
||||
chargeableDays: number;
|
||||
containerCount: number;
|
||||
/** What containerCount/billableUnits are counted in: 'container' | 'truck' | 'ton' | 'item'. */
|
||||
unitLabel?: string;
|
||||
billableUnits: number;
|
||||
amount: number;
|
||||
tiers?: FeePreviewTier[];
|
||||
/** Truck detention: per-vehicle-type breakdown. */
|
||||
/** Truck detention: one row per truck — each has its own window and rule. */
|
||||
groups?: Array<{
|
||||
assignmentId?: string | null;
|
||||
vehicleId?: string | null;
|
||||
plateNumber?: string | null;
|
||||
vehicleType: string | null;
|
||||
truckCount: number;
|
||||
startDate?: string | null;
|
||||
endDate?: string | null;
|
||||
endIsOpen?: boolean;
|
||||
chargeableDays: number;
|
||||
ratePerDay: number;
|
||||
amount: number;
|
||||
|
||||
@@ -115,6 +115,7 @@ export const CreatePositionForm = ({
|
||||
});
|
||||
|
||||
const selectedOrganizationId = form.watch("organizationId");
|
||||
const selectedUnitId = form.watch("unitId");
|
||||
|
||||
const { organizationsResponse, isLoading: isLoadingOrgs } = useOrganizations(
|
||||
"Org",
|
||||
@@ -163,36 +164,32 @@ export const CreatePositionForm = ({
|
||||
enabled: mode === "edit" && !!positionTypeId,
|
||||
});
|
||||
|
||||
// A position type belongs to a unit, and a unit to an organization — IAM has
|
||||
// no organizationId on the type itself and no organization-scoped route, so
|
||||
// the picked org narrows the list through its units. isSystem types are the
|
||||
// shared "commons" and stay available to every organization.
|
||||
const orgUnitIds = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
(unitsResponse?.data?.items ?? []).map((unit: UnitDto) => unit.id),
|
||||
),
|
||||
[unitsResponse],
|
||||
);
|
||||
|
||||
// A position type belongs to a single unit — scope copy sources to the
|
||||
// selected unit, same as the "Select Unit" filter on the position list page.
|
||||
// isSystem types are the shared "commons" and stay available everywhere.
|
||||
const copyFromOptions = useMemo(() => {
|
||||
if (!selectedOrganizationId) return [];
|
||||
if (!selectedUnitId) return [];
|
||||
return positionTypes.filter(
|
||||
(type: PositionTypeDto) =>
|
||||
type.id !== positionTypeId &&
|
||||
(type.isSystem || (!!type.unitId && orgUnitIds.has(type.unitId))),
|
||||
(type.isSystem || type.unitId === selectedUnitId),
|
||||
);
|
||||
}, [positionTypes, orgUnitIds, selectedOrganizationId, positionTypeId]);
|
||||
}, [positionTypes, selectedUnitId, positionTypeId]);
|
||||
|
||||
// Reset the selected unit when the organization changes so a unit from a
|
||||
// different org can't be submitted by mistake. The copy source is cleared
|
||||
// too — it is scoped to the old organization.
|
||||
// different org can't be submitted by mistake.
|
||||
useEffect(() => {
|
||||
if (mode === "edit") return;
|
||||
form.setValue("unitId", "");
|
||||
setCopyFromPositionId("");
|
||||
}, [selectedOrganizationId, mode, form]);
|
||||
|
||||
// The copy source is scoped to the selected unit — clear it whenever the
|
||||
// unit changes (including as a side effect of the org reset above) so a
|
||||
// stale selection from a different unit can't be submitted.
|
||||
useEffect(() => {
|
||||
setCopyFromPositionId("");
|
||||
}, [selectedUnitId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== "edit" || !initialValues || !positionTypeId) return;
|
||||
if (hasLoadedEditData.current) return;
|
||||
@@ -335,11 +332,13 @@ export const CreatePositionForm = ({
|
||||
|
||||
const copyFromPlaceholder = !selectedOrganizationId
|
||||
? t("contentManagement.selectOrganizationToCopy")
|
||||
: isCopying || isLoadingPositionTypes || isLoadingUnits
|
||||
? t("common.loading")
|
||||
: isErrorPositionTypes
|
||||
? t("contentManagement.failedToLoadPositionTypes")
|
||||
: t("contentManagement.selectPositionToCopy");
|
||||
: !selectedUnitId
|
||||
? t("contentManagement.selectUnitToCopy")
|
||||
: isCopying || isLoadingPositionTypes || isLoadingUnits
|
||||
? t("common.loading")
|
||||
: isErrorPositionTypes
|
||||
? t("contentManagement.failedToLoadPositionTypes")
|
||||
: t("contentManagement.selectPositionToCopy");
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
@@ -454,6 +453,7 @@ export const CreatePositionForm = ({
|
||||
onValueChange={handleCopyFrom}
|
||||
disabled={
|
||||
!selectedOrganizationId ||
|
||||
!selectedUnitId ||
|
||||
isLoadingPositionTypes ||
|
||||
isLoadingUnits ||
|
||||
isCopying
|
||||
|
||||
@@ -19,6 +19,7 @@ import { AppLayout } from "./Applayout";
|
||||
import ActivityLogPage from "@/pages/ActivityLogPage";
|
||||
import AdminRegistrationPage from "@/pages/Organizations/AdminRegistrationPage";
|
||||
import OrganizationAdminsPage from "@/pages/OrganizationAdminsPage";
|
||||
import OrgAdminPermissionsPage from "@/super-admin/components/org-admins/OrgAdminPermissionsPage";
|
||||
import UserProfileEditPage from "@/pages/UserProfileEditPage";
|
||||
import UploadedDocumentViewPage from "@/pages/UploadedDocumentViewPage";
|
||||
import EditOrganizationPage from "@/pages/Organizations/EditOrganizationPage";
|
||||
@@ -197,6 +198,10 @@ export function UserManagementRoutes(): ReactElement {
|
||||
path="user-management/organization_admins"
|
||||
element={<OrganizationAdminsPage />}
|
||||
/>
|
||||
<Route
|
||||
path="user-management/organization_admins/permissions"
|
||||
element={<OrgAdminPermissionsPage />}
|
||||
/>
|
||||
<Route
|
||||
path="user-management/add_admin"
|
||||
element={<AdminRegistrationPage />}
|
||||
|
||||
@@ -54,6 +54,7 @@ import NewShipmentPage from "./pages/contracts/NewShipmentPage";
|
||||
import NewShipmentRequestPage from "./pages/contracts/NewShipmentRequestPage";
|
||||
import CheckPaymentPage from "./pages/payments/CheckPaymentPage";
|
||||
import PaymentFailurePage from "./pages/payments/PaymentFailurePage";
|
||||
import FaydaCallbackPage from "./pages/FaydaCallbackPage";
|
||||
import PaymentSuccessPage from "./pages/payments/PaymentSuccessPage";
|
||||
import TrackingPage from "./pages/tracking/TrackingPage";
|
||||
|
||||
@@ -261,6 +262,9 @@ const App = () => {
|
||||
element={<CheckPaymentPage />}
|
||||
/>
|
||||
{/* Payment provider browser redirects (PAYMENT_RETURN_URL / PAYMENT_FAILURE_URL) */}
|
||||
{/* Fayda (eSignet) redirect_uri — runs in the verification popup and
|
||||
relays the code/state back to the form that opened it. */}
|
||||
<Route path="/callback" element={<FaydaCallbackPage />} />
|
||||
<Route path="/payment/success" element={<PaymentSuccessPage />} />
|
||||
<Route path="/payment/failure" element={<PaymentFailurePage />} />
|
||||
|
||||
|
||||
230
apps/edr-freight-web/portal/src/components/FaydaVerifyPanel.tsx
Normal file
230
apps/edr-freight-web/portal/src/components/FaydaVerifyPanel.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { BadgeCheck, Clock, ShieldCheck, XCircle } from "lucide-react";
|
||||
|
||||
import {
|
||||
verifaydaService,
|
||||
type CompanyIdentityState,
|
||||
type FaydaCallbackMessage,
|
||||
type IdentitySubject,
|
||||
type IdentityVerificationState,
|
||||
} from "@/services/verifayda.service";
|
||||
|
||||
interface FaydaVerifyPanelProps {
|
||||
subject: IdentitySubject;
|
||||
/** Heading — "General Manager" / "Power of Attorney". */
|
||||
title: string;
|
||||
/** What this person's verification is currently known to be. */
|
||||
state?: IdentityVerificationState;
|
||||
/**
|
||||
* False for a foreign company: verification is offered but nothing is gated
|
||||
* on it, so the panel says so rather than nagging.
|
||||
*/
|
||||
required: boolean;
|
||||
/** Called with the fresh company-wide state once a verification lands. */
|
||||
onVerified: (next: CompanyIdentityState) => void;
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* True when a fresh verification for this person is already staged in a
|
||||
* pending change request. On an active company a re-verification never
|
||||
* touches the live record — it's staged for review — so `state` alone
|
||||
* would keep showing the OLD verified data with no sign anything happened.
|
||||
*/
|
||||
pendingReview?: boolean;
|
||||
}
|
||||
|
||||
function formatDate(iso: string | null): string {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(d.getTime()) ? "" : d.toLocaleDateString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify one of the company's people through Fayda and show what came back.
|
||||
*
|
||||
* The identity is proved in an eSignet popup; that popup lands on /callback,
|
||||
* which relays the code+state here by postMessage. This window then completes
|
||||
* the exchange — once, in one place — and the API writes the person's name,
|
||||
* phone, email and address from the verified payload. Nothing on this panel
|
||||
* is typed.
|
||||
*/
|
||||
export default function FaydaVerifyPanel({
|
||||
subject,
|
||||
title,
|
||||
state,
|
||||
required,
|
||||
onVerified,
|
||||
disabled,
|
||||
pendingReview,
|
||||
}: FaydaVerifyPanelProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// The listener closes over `subject`; keep it in a ref so remounting the
|
||||
// panel between steps can't complete a verification against the wrong person.
|
||||
const subjectRef = useRef(subject);
|
||||
subjectRef.current = subject;
|
||||
|
||||
useEffect(() => {
|
||||
const onMessage = async (event: MessageEvent<FaydaCallbackMessage>) => {
|
||||
if (event.origin !== window.location.origin) return;
|
||||
if (event.data?.type !== "fayda-callback") return;
|
||||
|
||||
if (event.data.error) {
|
||||
setLoading(false);
|
||||
setError(event.data.errorDescription ?? event.data.error);
|
||||
return;
|
||||
}
|
||||
if (!event.data.code || !event.data.state) return;
|
||||
|
||||
try {
|
||||
const next = await verifaydaService.completeIdentity(
|
||||
subjectRef.current,
|
||||
event.data.code,
|
||||
event.data.state,
|
||||
);
|
||||
setError(null);
|
||||
onVerified(next);
|
||||
} catch (err) {
|
||||
setError(
|
||||
(err as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ??
|
||||
(err instanceof Error ? err.message : "Verification failed"),
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener("message", onMessage);
|
||||
return () => window.removeEventListener("message", onMessage);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const startVerification = async () => {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const authorizationUrl = await verifaydaService.start();
|
||||
const popup = window.open(
|
||||
authorizationUrl,
|
||||
"fayda-verify",
|
||||
"width=480,height=760,noopener=no",
|
||||
);
|
||||
if (!popup) {
|
||||
setLoading(false);
|
||||
setError("Pop-up blocked — allow pop-ups for this site and try again.");
|
||||
}
|
||||
// Loading stays on until the popup posts back.
|
||||
} catch (err) {
|
||||
setLoading(false);
|
||||
setError(
|
||||
(err as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ??
|
||||
(err instanceof Error ? err.message : "Could not start verification"),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const verified = state?.verified ?? false;
|
||||
|
||||
return (
|
||||
<Card padding="md" radius="md" withBorder>
|
||||
<Group justify="space-between" align="center" mb={verified ? "md" : "xs"}>
|
||||
<Group gap="sm">
|
||||
<ShieldCheck size={18} />
|
||||
<Text fw={600} c="edr-text">
|
||||
{title} identity
|
||||
</Text>
|
||||
{verified ? (
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color="green"
|
||||
leftSection={<BadgeCheck size={11} />}
|
||||
>
|
||||
Fayda verified
|
||||
</Badge>
|
||||
) : (
|
||||
required && (
|
||||
<Badge size="sm" variant="light" color="amber">
|
||||
Verification required
|
||||
</Badge>
|
||||
)
|
||||
)}
|
||||
{pendingReview && (
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color="amber"
|
||||
leftSection={<Clock size={11} />}
|
||||
>
|
||||
Re-verification pending review
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Button
|
||||
type="button"
|
||||
variant="light"
|
||||
size="xs"
|
||||
loading={loading}
|
||||
disabled={disabled}
|
||||
onClick={startVerification}
|
||||
>
|
||||
{verified ? "Re-verify with Fayda" : "Verify with Fayda"}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{!verified && (
|
||||
<Text c="edr-muted" size="xs">
|
||||
{required
|
||||
? "Verify this person with Fayda. Their name, phone and address come from the verification — there is nothing to fill in by hand."
|
||||
: "Optional for a foreign company. If this person holds a Fayda ID, verifying it fills in their details."}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{verified && state && (
|
||||
<SimpleGrid cols={2} spacing="xs">
|
||||
<VerifiedField label="Name" value={state.name} />
|
||||
<VerifiedField label="Phone" value={state.phone} />
|
||||
<VerifiedField label="Email" value={state.email} />
|
||||
<VerifiedField label="Address" value={state.address} />
|
||||
<VerifiedField label="Verified" value={formatDate(state.verifiedAt)} />
|
||||
</SimpleGrid>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert mt="sm" color="red" variant="light" icon={<XCircle size={18} />}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function VerifiedField({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | null;
|
||||
}) {
|
||||
if (!value) return null;
|
||||
return (
|
||||
<Stack gap={0}>
|
||||
<Text size="xs" c="edr-muted">
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="sm" fw={500} c="edr-text">
|
||||
{value}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,19 @@
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Group,
|
||||
Loader,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { Alert, Button, Group, Loader, Stack, TextInput } from "@mantine/core";
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { UseFormRegisterReturn } from "react-hook-form";
|
||||
import { AlertCircle, CheckCircle2, Download, Info } from "lucide-react";
|
||||
import { AlertCircle, Download } from "lucide-react";
|
||||
import { useETradeData } from "@/hooks/useETradeData";
|
||||
import { extractApiError } from "@/utils/result";
|
||||
import type { CompanyRegistrationData } from "@edr/types";
|
||||
|
||||
export type ETradeStatus =
|
||||
| "idle"
|
||||
| "loading"
|
||||
| "verified"
|
||||
| "not-found"
|
||||
| "taken"
|
||||
| "error";
|
||||
|
||||
interface ETradeInfoProps {
|
||||
/** Current TIN value (drives button enablement). */
|
||||
tin: string;
|
||||
@@ -22,6 +22,8 @@ interface ETradeInfoProps {
|
||||
/** Validation error for the TIN field, if any. */
|
||||
error?: string;
|
||||
onDataLoaded: (data: CompanyRegistrationData) => void;
|
||||
/** Reports the live lookup status so the parent step can gate on it. */
|
||||
onStatusChange?: (status: ETradeStatus) => void;
|
||||
}
|
||||
|
||||
const isValidTin = (tin: string) => tin.length === 10;
|
||||
@@ -31,12 +33,11 @@ export default function ETradeInfo({
|
||||
register,
|
||||
error,
|
||||
onDataLoaded,
|
||||
onStatusChange,
|
||||
}: ETradeInfoProps) {
|
||||
const mutation = useETradeData();
|
||||
const isLoading = mutation.isPending;
|
||||
const tinTaken = mutation.data?.tinTaken;
|
||||
const hasData =
|
||||
mutation.data && !mutation.data.tinTaken ? mutation.data : null;
|
||||
|
||||
const handleFetch = async () => {
|
||||
if (!isValidTin(tin)) return;
|
||||
@@ -47,8 +48,10 @@ export default function ETradeInfo({
|
||||
};
|
||||
|
||||
// Auto-fetch as soon as the TIN reaches its full 10-digit length — only
|
||||
// once per distinct value, so retyping the same TIN doesn't refetch.
|
||||
const lastFetchedTin = useRef<string | null>(null);
|
||||
// once per distinct value, so retyping the same TIN doesn't refetch. Seeded
|
||||
// from the initial value so a resumed draft with an already-verified TIN
|
||||
// doesn't refire the lookup the moment this mounts.
|
||||
const lastFetchedTin = useRef<string | null>(tin || null);
|
||||
useEffect(() => {
|
||||
if (isValidTin(tin) && lastFetchedTin.current !== tin) {
|
||||
lastFetchedTin.current = tin;
|
||||
@@ -61,16 +64,36 @@ export default function ETradeInfo({
|
||||
mutation.isError && mutation.error
|
||||
? extractApiError(mutation.error)
|
||||
: null;
|
||||
// A 400 here means eTrade simply has no record for this TIN — not a
|
||||
// failure. Soft-pedal it as an FYI, not a red error, so filling in
|
||||
// manually doesn't feel like something went wrong.
|
||||
// A 400 here means eTrade simply has no record for this TIN.
|
||||
const notFound = apiError?.statusCode === 400;
|
||||
const errorMessage =
|
||||
apiError && !notFound
|
||||
? apiError.message ||
|
||||
"We couldn't reach eTrade to fetch your company information. Please try again, or fill in the details manually below."
|
||||
"We couldn't reach eTrade to fetch your company information. Please try again."
|
||||
: null;
|
||||
|
||||
const status: ETradeStatus = isLoading
|
||||
? "loading"
|
||||
: tinTaken
|
||||
? "taken"
|
||||
: mutation.isSuccess && mutation.data && !mutation.data.tinTaken
|
||||
? "verified"
|
||||
: notFound
|
||||
? "not-found"
|
||||
: errorMessage
|
||||
? "error"
|
||||
: "idle";
|
||||
|
||||
const lastReportedStatus = useRef<ETradeStatus | null>(null);
|
||||
useEffect(() => {
|
||||
if (lastReportedStatus.current === status) return;
|
||||
lastReportedStatus.current = status;
|
||||
onStatusChange?.(status);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [status]);
|
||||
|
||||
const showRetry = isValidTin(tin) && status !== "verified" && status !== "loading";
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Group align="flex-start" grow>
|
||||
@@ -86,7 +109,7 @@ export default function ETradeInfo({
|
||||
error={error}
|
||||
{...register}
|
||||
/>
|
||||
{errorMessage && (
|
||||
{showRetry && (
|
||||
<Button
|
||||
variant="filled"
|
||||
color="edr-green"
|
||||
@@ -103,9 +126,13 @@ export default function ETradeInfo({
|
||||
</Group>
|
||||
|
||||
{notFound && (
|
||||
<Alert icon={<Info size={16} />} color="gray">
|
||||
We couldn't find a matching business record for this TIN — no
|
||||
problem, just fill in the details below.
|
||||
<Alert
|
||||
icon={<AlertCircle size={16} />}
|
||||
color="red"
|
||||
title="No matching business record"
|
||||
>
|
||||
This TIN isn't registered with eTrade. Check the number — we can't
|
||||
continue without a matching business record.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
@@ -130,29 +157,6 @@ export default function ETradeInfo({
|
||||
mistake.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{hasData && (
|
||||
<Alert
|
||||
icon={<CheckCircle2 size={16} />}
|
||||
color="green"
|
||||
title="Company information loaded"
|
||||
>
|
||||
<Stack gap={0}>
|
||||
<Text size="sm">
|
||||
<strong>License:</strong> {hasData.licenceNumber}
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
<strong>Status:</strong> {hasData.statusDescription}
|
||||
</Text>
|
||||
{hasData.region && (
|
||||
<Text size="sm">
|
||||
<strong>Location:</strong> {hasData.kebele}, {hasData.woreda},{" "}
|
||||
{hasData.zone}, {hasData.region}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -66,7 +66,8 @@ const STEP_META: Record<
|
||||
company: {
|
||||
icon: <Building2 size={20} />,
|
||||
title: "Company Information",
|
||||
description: "Tell us about your company and its registration details.",
|
||||
description:
|
||||
"Confirm your VAT number, verify the owner's identity, and we'll pull your registration from eTrade.",
|
||||
},
|
||||
personnel: {
|
||||
icon: <User size={20} />,
|
||||
@@ -224,8 +225,12 @@ export default function OnboardingWizardDialog({
|
||||
const finishMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
// Per-role business licenses (file model, resource=company_profiles).
|
||||
// Keys for roles the user deselected on a trip back to role selection are
|
||||
// dropped — that profile no longer exists, so uploading against it would
|
||||
// 404 (and the license isn't wanted any more anyway).
|
||||
const liveProfileIds = new Set(existingProfiles.map((p) => p.id));
|
||||
for (const [profileId, files] of Object.entries(licenseFiles)) {
|
||||
if (files.length > 0) {
|
||||
if (files.length > 0 && liveProfileIds.has(profileId)) {
|
||||
await companiesService.uploadProfileLicense(profileId, files);
|
||||
}
|
||||
}
|
||||
@@ -424,6 +429,14 @@ export default function OnboardingWizardDialog({
|
||||
onLicenseChange: setLicenseFiles,
|
||||
uploadedDocumentKeys,
|
||||
onUploadDocuments: handleUploadDocuments,
|
||||
// Fayda verification state for the owner and the PoA — the general manager
|
||||
// stays a plain typed role. Mandatory (Fayda) for an Ethiopian company;
|
||||
// a foreign one requires a typed passport number for the owner instead.
|
||||
identity: requirementsQuery.data?.identity,
|
||||
onIdentityChange: () => {
|
||||
void profileQuery.refetch();
|
||||
void requirementsQuery.refetch();
|
||||
},
|
||||
// Surface a failed final submit (license/document upload or complete) inside
|
||||
// the form — otherwise the server message (e.g. a 500) would be invisible on
|
||||
// the submit step.
|
||||
|
||||
56
apps/edr-freight-web/portal/src/pages/FaydaCallbackPage.tsx
Normal file
56
apps/edr-freight-web/portal/src/pages/FaydaCallbackPage.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Center, Loader, Stack, Text } from "@mantine/core";
|
||||
|
||||
import type { FaydaCallbackMessage } from "@/services/verifayda.service";
|
||||
|
||||
/**
|
||||
* Landing page for the portal's eSignet redirect_uri
|
||||
* (FAYDA_PORTAL_REDIRECT_URI → http://localhost:5173/callback). Runs inside the
|
||||
* verification popup: relays ?code&state (or ?error) to the window that opened
|
||||
* it via postMessage, then closes itself. The opener performs the completion
|
||||
* call so the single-use session is only consumed once, in one place.
|
||||
*/
|
||||
export default function FaydaCallbackPage() {
|
||||
const [standalone, setStandalone] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const message: FaydaCallbackMessage = {
|
||||
type: "fayda-callback",
|
||||
code: params.get("code") ?? undefined,
|
||||
state: params.get("state") ?? undefined,
|
||||
error: params.get("error") ?? undefined,
|
||||
errorDescription: params.get("error_description") ?? undefined,
|
||||
};
|
||||
|
||||
if (window.opener && window.opener !== window) {
|
||||
(window.opener as Window).postMessage(message, window.location.origin);
|
||||
window.close();
|
||||
} else {
|
||||
// Opened as a full-page redirect instead of a popup — nothing to relay to.
|
||||
setStandalone(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Center h="100vh">
|
||||
<Stack align="center" gap="sm">
|
||||
{standalone ? (
|
||||
<>
|
||||
<Text fw={600}>Verification window lost its parent page</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Close this tab and start the verification again from the form.
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Loader size="sm" color="edr-green" />
|
||||
<Text size="sm" c="dimmed">
|
||||
Completing Fayda verification…
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
@@ -63,13 +63,22 @@ type SettingsTab =
|
||||
/** A section is "incomplete" when its required fields aren't filled in yet. */
|
||||
function tabIncomplete(tabId: SettingsTab, profile: ProfileResponse): boolean {
|
||||
switch (tabId) {
|
||||
case "company":
|
||||
case "company": {
|
||||
// Identity proof lives here: the owner's Fayda verification for an
|
||||
// Ethiopian company, or the owner's typed passport number for a foreign
|
||||
// one.
|
||||
const identity = profile.identity;
|
||||
const identityIncomplete = identity
|
||||
? (identity.faydaRequired && !identity.owner.verified) ||
|
||||
(identity.passportRequired && !identity.owner.passportNumber)
|
||||
: false;
|
||||
return (
|
||||
!profile.companyEmail ||
|
||||
!profile.companyPhone ||
|
||||
!profile.companyAddress ||
|
||||
!profile.fanNumber
|
||||
identityIncomplete
|
||||
);
|
||||
}
|
||||
case "contact":
|
||||
return !profile.contactPersonName || !profile.contactPersonPhone;
|
||||
case "gm":
|
||||
@@ -354,7 +363,7 @@ export default function SettingsPage() {
|
||||
enabled so the customer can still review what they submitted. */}
|
||||
<Tabs.Panel value="company">
|
||||
<Fieldset disabled={locked} variant="unstyled" p={0}>
|
||||
<TabCompanyProfile mode="edit" profile={profile} />
|
||||
<TabCompanyProfile mode="edit" profile={profile} user={user ?? undefined} />
|
||||
</Fieldset>
|
||||
<OperationalServicesCard profile={profile} />
|
||||
</Tabs.Panel>
|
||||
|
||||
@@ -4,24 +4,21 @@ import {
|
||||
Divider,
|
||||
Group,
|
||||
Loader,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { AlertCircle, ArrowLeft, ArrowRight, Info } from "lucide-react";
|
||||
import { AlertCircle, ArrowLeft, ArrowRight } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import type { AuthUser } from "@/types/auth";
|
||||
import type { CreateCompanyPayload } from "@/services/companies.service";
|
||||
import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
|
||||
import type { CompanyRegistrationData } from "@edr/types";
|
||||
import { ETHIOPIAN_REGIONS } from "@edr/types";
|
||||
import { ControlledPhoneField, toEthiopianE164 } from "@/components/PhoneField";
|
||||
import { SmartFileInput } from "@edr/ui-common";
|
||||
import { getMinFiles } from "@/types/fileUploadSettings";
|
||||
@@ -29,7 +26,9 @@ import { api } from "@/services/api";
|
||||
import RoleLicenseStep, {
|
||||
type RoleLicenseProfile,
|
||||
} from "@/components/onboarding/RoleLicenseStep";
|
||||
import ETradeInfo from "@/components/onboarding/ETradeInfo";
|
||||
import ETradeInfo, {
|
||||
type ETradeStatus,
|
||||
} from "@/components/onboarding/ETradeInfo";
|
||||
import {
|
||||
buildOnboardingSchema,
|
||||
type CompanyStep,
|
||||
@@ -43,8 +42,11 @@ import {
|
||||
stepPayload,
|
||||
toFormValues,
|
||||
} from "./companyProfileForm/helpers";
|
||||
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
|
||||
import type { CompanyIdentityState } from "@/services/verifayda.service";
|
||||
import { LinkCheckboxCard } from "./companyProfileForm/LinkCheckboxCard";
|
||||
import { ReadOnlyField } from "./companyProfileForm/ReadOnlyField";
|
||||
import ETradeCompanyCard from "./companyProfileForm/ETradeCompanyCard";
|
||||
import StepSection from "./companyProfileForm/StepSection";
|
||||
|
||||
export default function CompanyProfileForm({
|
||||
documentSettingCode,
|
||||
@@ -65,6 +67,8 @@ export default function CompanyProfileForm({
|
||||
submitError,
|
||||
uploadedDocumentKeys,
|
||||
onUploadDocuments,
|
||||
identity,
|
||||
onIdentityChange,
|
||||
}: {
|
||||
documentSettingCode: string;
|
||||
documentFiles?: Record<string, File | File[] | null>;
|
||||
@@ -102,10 +106,17 @@ export default function CompanyProfileForm({
|
||||
onUploadDocuments?: () => Promise<
|
||||
{ ok: true } | { ok: false; error: string }
|
||||
>;
|
||||
/** Fayda verification state for the owner and the PoA (undefined until loaded). */
|
||||
identity?: CompanyIdentityState;
|
||||
/** Refetch the profile + requirements once a verification lands. */
|
||||
onIdentityChange?: () => void;
|
||||
}) {
|
||||
const [step, setStep] = useState<CompanyStep>(initialStep ?? "company");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
// Live eTrade lookup status, reported up by ETradeInfo — drives the Continue
|
||||
// gate on the company step.
|
||||
const [tinStatus, setTinStatus] = useState<ETradeStatus>("idle");
|
||||
|
||||
// Report each step change up so the wizard can persist it for resume.
|
||||
useEffect(() => {
|
||||
@@ -161,10 +172,14 @@ export default function CompanyProfileForm({
|
||||
);
|
||||
|
||||
// A freight forwarder signs on other companies' behalf, so its Power of
|
||||
// Attorney (details + delegation letter) is mandatory rather than optional.
|
||||
// Attorney (details + DARS delegation paper) is mandatory rather than optional.
|
||||
const requirePoa = (roleProfiles ?? []).some(
|
||||
(p) => p.type === "freight_forwarder",
|
||||
);
|
||||
// Fayda is an Ethiopian national ID: an Ethiopian company verifies its owner
|
||||
// and PoA instead of typing their details, a foreign one keeps the typed
|
||||
// forms (plus a mandatory owner passport number).
|
||||
const verifiedIdentity = identity?.faydaRequired === true;
|
||||
|
||||
const {
|
||||
register,
|
||||
@@ -175,16 +190,21 @@ export default function CompanyProfileForm({
|
||||
setValue,
|
||||
formState: { errors },
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(buildOnboardingSchema(requirePoa)),
|
||||
resolver: zodResolver(
|
||||
buildOnboardingSchema(
|
||||
requirePoa,
|
||||
verifiedIdentity,
|
||||
identity?.passportRequired === true,
|
||||
),
|
||||
),
|
||||
defaultValues: {
|
||||
companyName: "",
|
||||
companyEmail: "",
|
||||
companyPhone: "",
|
||||
companyLocation: "",
|
||||
companyAddress: "",
|
||||
tinNumber: "",
|
||||
vatNumber: "",
|
||||
fanNumber: "",
|
||||
ownerPassportNumber: "",
|
||||
licenceNumber: "",
|
||||
statusDescription: "",
|
||||
dateRegistered: "",
|
||||
@@ -213,14 +233,9 @@ export default function CompanyProfileForm({
|
||||
values: rehydrate ? toFormValues(rehydrate) : undefined,
|
||||
});
|
||||
|
||||
// eTrade carries no email, so the company/contact email fields start blank.
|
||||
// Seed them from the registering user's account email — but only while empty,
|
||||
// so a typed or rehydrated value is never overwritten.
|
||||
// The contact person's email still just seeds from the account and stays editable.
|
||||
useEffect(() => {
|
||||
if (!user?.email) return;
|
||||
if (!watch("companyEmail")) {
|
||||
setValue("companyEmail", user.email, { shouldValidate: true });
|
||||
}
|
||||
if (!watch("contactPersonEmail")) {
|
||||
setValue("contactPersonEmail", user.email);
|
||||
}
|
||||
@@ -265,19 +280,10 @@ export default function CompanyProfileForm({
|
||||
setValue("woreda", data.woreda);
|
||||
setValue("kebele", data.kebele);
|
||||
setValue("houseNo", data.houseNo);
|
||||
setValue(
|
||||
"companyPhone",
|
||||
toEthiopianE164(data.regularPhone || data.mobilePhone),
|
||||
);
|
||||
// companyAddress is composed reactively from the address fields below, so
|
||||
// setting region/zone/woreda/kebele/houseNo above is enough — no need to
|
||||
// compose it here.
|
||||
|
||||
// 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 });
|
||||
}
|
||||
// compose it here. companyPhone is derived below (identity → eTrade →
|
||||
// account), not set directly here.
|
||||
|
||||
setEtradeOwner({
|
||||
name: data.managerName,
|
||||
@@ -287,6 +293,29 @@ export default function CompanyProfileForm({
|
||||
});
|
||||
};
|
||||
|
||||
// companyEmail/companyPhone are no longer typed — the Fayda-verified owner
|
||||
// is the highest-trust source (that's the whole point of verifying), eTrade's
|
||||
// registered number and the account email/phone are the fallbacks used
|
||||
// before verification happens.
|
||||
useEffect(() => {
|
||||
setValue("companyEmail", identity?.owner.email ?? user.email ?? "", {
|
||||
shouldValidate: true,
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [identity?.owner.email, user.email]);
|
||||
|
||||
useEffect(() => {
|
||||
setValue(
|
||||
"companyPhone",
|
||||
identity?.owner.phone ??
|
||||
etradeOwner?.phone ??
|
||||
toEthiopianE164(user.phoneNumber) ??
|
||||
"",
|
||||
{ shouldValidate: true },
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [identity?.owner.phone, etradeOwner?.phone, user.phoneNumber]);
|
||||
|
||||
// "Same as …" links. A checked card prefills the target step's fields from the
|
||||
// source step and disables them (kept mirrored while linked); unchecking clears
|
||||
// them and re-enables editing.
|
||||
@@ -302,10 +331,19 @@ export default function CompanyProfileForm({
|
||||
// field of its own, so it falls back to the registering user's account name.
|
||||
const companyEmail = watch("companyEmail");
|
||||
const companyPhone = watch("companyPhone");
|
||||
const gmSourceName = etradeOwner?.name ?? user.name?.en ?? "";
|
||||
const gmSourceEmail = companyEmail || user.email || "";
|
||||
// A Fayda-verified owner outranks eTrade's registered owner — it's the
|
||||
// higher-trust source, and the whole point of proving identity is to stop
|
||||
// trusting typed/looked-up data for this.
|
||||
const gmSourceName =
|
||||
identity?.owner.name ?? etradeOwner?.name ?? user.name?.en ?? "";
|
||||
const gmSourceEmail =
|
||||
identity?.owner.email ?? (companyEmail || user.email || "");
|
||||
const gmSourcePhone =
|
||||
companyPhone || etradeOwner?.phone || toEthiopianE164(user.phoneNumber) || "";
|
||||
identity?.owner.phone ??
|
||||
companyPhone ??
|
||||
etradeOwner?.phone ??
|
||||
toEthiopianE164(user.phoneNumber) ??
|
||||
"";
|
||||
|
||||
useEffect(() => {
|
||||
if (!gmSameAsOwner) return;
|
||||
@@ -343,10 +381,9 @@ export default function CompanyProfileForm({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [contactSameAsGm, gmName, gmEmail, gmPhone]);
|
||||
|
||||
// The contact-person step has no location/address of its own, so the linked
|
||||
// PoA takes the company's — location as entered, address as composed from the
|
||||
// company's address fields. Both stay mirrored while the link is checked.
|
||||
const companyLocation = watch("companyLocation");
|
||||
// The contact-person step has no address of its own, so the linked PoA takes
|
||||
// the company's composed address. poaLocation (the city) stays typed on the
|
||||
// PoA step — the company step no longer has a location field to mirror.
|
||||
const companyAddress = watch("companyAddress");
|
||||
|
||||
useEffect(() => {
|
||||
@@ -354,7 +391,6 @@ export default function CompanyProfileForm({
|
||||
setValue("poaName", contactName ?? "");
|
||||
setValue("poaEmail", contactEmail ?? "");
|
||||
setValue("poaPhone", contactPhone ?? "");
|
||||
setValue("poaLocation", companyLocation ?? "");
|
||||
setValue("poaAddress", companyAddress ?? "");
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
@@ -362,7 +398,6 @@ export default function CompanyProfileForm({
|
||||
contactName,
|
||||
contactEmail,
|
||||
contactPhone,
|
||||
companyLocation,
|
||||
companyAddress,
|
||||
]);
|
||||
|
||||
@@ -387,10 +422,11 @@ export default function CompanyProfileForm({
|
||||
}
|
||||
};
|
||||
|
||||
// The delegation letter is seeded into the same nationality document set as
|
||||
// the rest, but belongs on the PoA step next to the details it evidences —
|
||||
// so it's split out here and the Documents step renders the remainder. Both
|
||||
// halves share `documentFiles`, so the existing bulk upload still carries it.
|
||||
// The DARS delegation paper ships in the same nationality document set as the
|
||||
// rest (the API guarantees it is there), but belongs on the PoA step next to
|
||||
// the details it evidences — so it's split out here and the Documents step
|
||||
// renders the remainder. Both halves share `documentFiles`, so the existing
|
||||
// bulk upload still carries it.
|
||||
const poaDocumentField = uploadSetting?.fields?.find(
|
||||
(f) => f.fileKey === POA_DELEGATION_FILE_KEY,
|
||||
);
|
||||
@@ -398,11 +434,11 @@ export default function CompanyProfileForm({
|
||||
() =>
|
||||
uploadSetting
|
||||
? {
|
||||
...uploadSetting,
|
||||
fields: uploadSetting.fields.filter(
|
||||
(f) => f.fileKey !== POA_DELEGATION_FILE_KEY,
|
||||
),
|
||||
}
|
||||
...uploadSetting,
|
||||
fields: uploadSetting.fields.filter(
|
||||
(f) => f.fileKey !== POA_DELEGATION_FILE_KEY,
|
||||
),
|
||||
}
|
||||
: undefined,
|
||||
[uploadSetting],
|
||||
);
|
||||
@@ -495,6 +531,9 @@ export default function CompanyProfileForm({
|
||||
"renewedTo",
|
||||
]);
|
||||
const hasRegistrationDetails = registration.some((v) => v && v.trim());
|
||||
// A previously-saved (rehydrated) TIN counts as verified without a refetch —
|
||||
// the registration fields being populated at all is proof it passed before.
|
||||
const tinVerified = tinStatus === "verified" || hasRegistrationDetails;
|
||||
|
||||
// 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.
|
||||
@@ -507,13 +546,13 @@ export default function CompanyProfileForm({
|
||||
];
|
||||
const currentIdx = stepOrder.indexOf(step);
|
||||
|
||||
// The delegation letter is what proves the representative was actually
|
||||
// The DARS delegation paper is what proves the representative was actually
|
||||
// delegated, so it's required the moment a PoA exists — and unconditionally
|
||||
// for a freight forwarder, whose PoA itself is mandatory. Skipped entirely
|
||||
// when the document set predates the field (seeder not yet re-run).
|
||||
// for a freight forwarder, whose PoA itself is mandatory. The API enforces
|
||||
// the same rule on save, so skipping it here only costs the customer a
|
||||
// round-trip.
|
||||
const poaProvided = hasPoaDetails(watch());
|
||||
const delegationRequired =
|
||||
Boolean(poaDocumentField) && (requirePoa || poaProvided);
|
||||
const delegationRequired = requirePoa || poaProvided;
|
||||
const delegationPresent =
|
||||
(uploadedDocumentKeys ?? []).includes(POA_DELEGATION_FILE_KEY) ||
|
||||
(() => {
|
||||
@@ -548,7 +587,10 @@ export default function CompanyProfileForm({
|
||||
if (step === "documents") {
|
||||
const docErrors = validateRequiredDocuments();
|
||||
const licenseErrors = validateLicenses();
|
||||
if (Object.keys(docErrors).length > 0 || Object.keys(licenseErrors).length > 0) {
|
||||
if (
|
||||
Object.keys(docErrors).length > 0 ||
|
||||
Object.keys(licenseErrors).length > 0
|
||||
) {
|
||||
setDocumentFieldErrors(docErrors);
|
||||
setLicenseFieldErrors(licenseErrors);
|
||||
setSaveError("Please upload all required documents before continuing.");
|
||||
@@ -572,15 +614,50 @@ export default function CompanyProfileForm({
|
||||
handleSubmit((data) => onSubmit(buildPayload(data, user)))();
|
||||
return;
|
||||
}
|
||||
// The TIN must resolve to a real eTrade record before anything else on
|
||||
// this step is even worth validating — gates here rather than through zod.
|
||||
if (step === "company" && tinStatus === "taken") {
|
||||
setSaveError(
|
||||
"This TIN is already registered to another company account.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (step === "company" && !tinVerified) {
|
||||
setSaveError(
|
||||
"We need to confirm your TIN with eTrade before continuing.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Fayda verification is proved outside the form state, so it gates here
|
||||
// rather than through zod. The passport number is a plain typed field —
|
||||
// buildOnboardingSchema already requires it when passportRequired, so
|
||||
// saveCurrentStep()'s trigger() below catches that; checking the stale
|
||||
// server-side identity.owner.passportNumber here would block a value the
|
||||
// user just typed but hasn't saved yet.
|
||||
if (step === "company" && identity?.faydaRequired && !identity.owner.verified) {
|
||||
setSaveError("Verify the company owner's identity with Fayda before continuing.");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
step === "poa" &&
|
||||
verifiedIdentity &&
|
||||
requirePoa &&
|
||||
!identity?.poa.verified
|
||||
) {
|
||||
setSaveError(
|
||||
"Freight forwarders act on other companies' behalf, so the Power of Attorney's identity must be verified with Fayda.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
// The PoA step also gates on a file, which lives outside the form state.
|
||||
if (step === "poa" && delegationRequired && !delegationPresent) {
|
||||
setDocumentFieldErrors({
|
||||
[POA_DELEGATION_FILE_KEY]: "Delegation letter is required",
|
||||
[POA_DELEGATION_FILE_KEY]: "DARS delegation paper is required",
|
||||
});
|
||||
setSaveError(
|
||||
requirePoa
|
||||
? "Freight forwarders must provide Power of Attorney details and a delegation letter."
|
||||
: "Upload the delegation letter for the Power of Attorney you entered, or clear the PoA details to skip.",
|
||||
? "Freight forwarders must provide Power of Attorney details and the DARS delegation paper."
|
||||
: "Upload the DARS delegation paper for the Power of Attorney you entered, or clear the PoA details to skip.",
|
||||
);
|
||||
// Fall through to validate the text fields too, so every problem shows at once.
|
||||
await trigger(stepFields.poa);
|
||||
@@ -604,172 +681,97 @@ export default function CompanyProfileForm({
|
||||
<form onSubmit={(e) => e.preventDefault()}>
|
||||
<Stack gap="md">
|
||||
{step === "company" && (
|
||||
<Stack gap="sm">
|
||||
<ETradeInfo
|
||||
tin={watch("tinNumber")}
|
||||
register={register("tinNumber")}
|
||||
error={errors.tinNumber?.message}
|
||||
onDataLoaded={handleETradeDataLoaded}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Company Name"
|
||||
placeholder="Global Logistics Ltd"
|
||||
error={errors.companyName?.message}
|
||||
{...register("companyName")}
|
||||
/>
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
<TextInput
|
||||
label="Company Email"
|
||||
type="email"
|
||||
placeholder="ops@company.com"
|
||||
error={errors.companyEmail?.message}
|
||||
{...register("companyEmail")}
|
||||
/>
|
||||
<ControlledPhoneField
|
||||
control={control}
|
||||
name="companyPhone"
|
||||
label="Company Phone"
|
||||
required
|
||||
/>
|
||||
</SimpleGrid>
|
||||
<TextInput
|
||||
label="Location"
|
||||
placeholder="Addis Ababa, Ethiopia"
|
||||
error={errors.companyLocation?.message}
|
||||
{...register("companyLocation")}
|
||||
/>
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
<Stack gap="xl">
|
||||
<StepSection
|
||||
index={1}
|
||||
title="VAT number"
|
||||
status={
|
||||
watch("vatNumber")?.length === 10 && !errors.vatNumber
|
||||
? "done"
|
||||
: "todo"
|
||||
}
|
||||
>
|
||||
<TextInput
|
||||
label="VAT Number"
|
||||
placeholder="VAT-12345"
|
||||
placeholder="0012345678"
|
||||
maxLength={10}
|
||||
error={errors.vatNumber?.message}
|
||||
{...register("vatNumber")}
|
||||
/>
|
||||
<TextInput
|
||||
label={
|
||||
<Group gap={6} align="center" wrap="nowrap">
|
||||
<span>FAN Number (16 digits)</span>
|
||||
<Tooltip
|
||||
label="The FAN must belong to the person with power of attorney. If the company has no power of attorney, use the general manager's FAN."
|
||||
multiline
|
||||
w={260}
|
||||
withArrow
|
||||
position="top-start"
|
||||
>
|
||||
<Info
|
||||
size={14}
|
||||
color="var(--mantine-color-gray-6)"
|
||||
className="cursor-help"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
}
|
||||
placeholder="1234567890123456"
|
||||
maxLength={16}
|
||||
error={errors.fanNumber?.message}
|
||||
{...register("fanNumber")}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</StepSection>
|
||||
|
||||
{hasRegistrationDetails && (
|
||||
<>
|
||||
<Divider my="sm" />
|
||||
<Group gap="xs" align="center">
|
||||
<Text fw={600} size="sm" c="edr-text">
|
||||
Registration Details
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
from eTrade · read-only
|
||||
</Text>
|
||||
</Group>
|
||||
<SimpleGrid cols={2} spacing="sm">
|
||||
<ReadOnlyField
|
||||
label="License Number"
|
||||
value={watch("licenceNumber")}
|
||||
<StepSection
|
||||
index={2}
|
||||
title="Owner identity"
|
||||
subtitle={
|
||||
verifiedIdentity
|
||||
? "Verify the company owner with Fayda — their name, phone, email and address come from the verification."
|
||||
: "Provide the company owner's passport number."
|
||||
}
|
||||
status={
|
||||
verifiedIdentity
|
||||
? identity?.owner.verified
|
||||
? "done"
|
||||
: identity?.faydaRequired
|
||||
? "blocked"
|
||||
: "todo"
|
||||
: (watch("ownerPassportNumber")?.trim()?.length ?? 0) > 0
|
||||
? "done"
|
||||
: identity?.passportRequired
|
||||
? "blocked"
|
||||
: "todo"
|
||||
}
|
||||
>
|
||||
{identity && (
|
||||
<>
|
||||
<FaydaVerifyPanel
|
||||
subject="owner"
|
||||
title="Company owner"
|
||||
state={identity.owner}
|
||||
required={identity.faydaRequired}
|
||||
onVerified={() => onIdentityChange?.()}
|
||||
/>
|
||||
<ReadOnlyField
|
||||
label="Status"
|
||||
value={watch("statusDescription")}
|
||||
/>
|
||||
<ReadOnlyField
|
||||
label="Date Registered"
|
||||
value={watch("dateRegistered")}
|
||||
/>
|
||||
<ReadOnlyField
|
||||
label="Renewal Date"
|
||||
value={watch("renewalDate")}
|
||||
/>
|
||||
<ReadOnlyField
|
||||
label="Renewed From"
|
||||
value={watch("renewedFrom")}
|
||||
/>
|
||||
<ReadOnlyField
|
||||
label="Renewed To"
|
||||
value={watch("renewedTo")}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</>
|
||||
)}
|
||||
{identity.passportRequired && (
|
||||
<TextInput
|
||||
label="Owner Passport Number"
|
||||
placeholder="P1234567"
|
||||
error={errors.ownerPassportNumber?.message}
|
||||
{...register("ownerPassportNumber")}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</StepSection>
|
||||
|
||||
<Divider my="sm" />
|
||||
<Text fw={600} size="sm" c="edr-text">
|
||||
Address Information
|
||||
</Text>
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
<Controller
|
||||
name="region"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
label="Region"
|
||||
placeholder="Select region"
|
||||
required
|
||||
searchable
|
||||
data={ETHIOPIAN_REGIONS.map((r) => ({ value: r, label: r }))}
|
||||
error={errors.region?.message}
|
||||
// "" (unresolved eTrade value, or a legacy row whose
|
||||
// region isn't in the list) must read as "nothing picked".
|
||||
value={field.value || null}
|
||||
onChange={(v) => field.onChange(v ?? "")}
|
||||
onBlur={field.onBlur}
|
||||
/>
|
||||
)}
|
||||
<StepSection
|
||||
index={3}
|
||||
title="Company TIN"
|
||||
subtitle="We'll pull your registration straight from eTrade — nothing to type by hand once it's found."
|
||||
status={
|
||||
tinVerified
|
||||
? "done"
|
||||
: tinStatus === "taken"
|
||||
? "blocked"
|
||||
: "todo"
|
||||
}
|
||||
>
|
||||
<ETradeInfo
|
||||
tin={watch("tinNumber")}
|
||||
register={register("tinNumber")}
|
||||
error={errors.tinNumber?.message}
|
||||
onDataLoaded={handleETradeDataLoaded}
|
||||
onStatusChange={setTinStatus}
|
||||
/>
|
||||
<TextInput
|
||||
label="Zone"
|
||||
placeholder="EASTERN TIGRAY"
|
||||
required
|
||||
error={errors.zone?.message}
|
||||
{...register("zone")}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
<TextInput
|
||||
label="Woreda"
|
||||
placeholder="EROB"
|
||||
required
|
||||
error={errors.woreda?.message}
|
||||
{...register("woreda")}
|
||||
/>
|
||||
<TextInput
|
||||
label="Kebele"
|
||||
placeholder="ARAS"
|
||||
required
|
||||
error={errors.kebele?.message}
|
||||
{...register("kebele")}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
<TextInput
|
||||
label="House No"
|
||||
placeholder="House Number"
|
||||
required
|
||||
error={errors.houseNo?.message}
|
||||
{...register("houseNo")}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
{tinVerified && (
|
||||
<ETradeCompanyCard
|
||||
tin={watch("tinNumber")}
|
||||
register={register}
|
||||
watch={watch}
|
||||
errors={errors}
|
||||
control={control}
|
||||
/>
|
||||
)}
|
||||
</StepSection>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
@@ -778,14 +780,24 @@ export default function CompanyProfileForm({
|
||||
<Text fw={600} size="sm" c="edr-text">
|
||||
General Manager
|
||||
</Text>
|
||||
{/* GM is a plain typed role, not the person the Fayda
|
||||
verification proves — the owner is (see the Company step).
|
||||
They're very often the same human, which "same as owner" is
|
||||
for once the owner has verified. */}
|
||||
<LinkCheckboxCard
|
||||
checked={gmSameAsOwner}
|
||||
onToggle={toggleGmSameAsOwner}
|
||||
title="Same as business owner"
|
||||
title={
|
||||
identity?.owner.verified
|
||||
? "Same as verified owner"
|
||||
: "Same as business owner"
|
||||
}
|
||||
description={
|
||||
etradeOwner
|
||||
? "Reuse the eTrade-registered owner's name, plus the company email and phone as you entered them. Uncheck to enter different details."
|
||||
: "Reuse your account's name and the company email and phone as you entered them. Uncheck to enter different details."
|
||||
identity?.owner.verified
|
||||
? "Reuse the Fayda-verified owner's name, email and phone. Uncheck to enter different details."
|
||||
: etradeOwner
|
||||
? "Reuse the eTrade-registered owner's name, plus the company email and phone as you entered them. Uncheck to enter different details."
|
||||
: "Reuse your account's name and the company email and phone as you entered them. Uncheck to enter different details."
|
||||
}
|
||||
/>
|
||||
<TextInput
|
||||
@@ -861,10 +873,19 @@ export default function CompanyProfileForm({
|
||||
<>
|
||||
<Text size="sm" c="edr-muted">
|
||||
{requirePoa
|
||||
? "As a freight forwarder you act on other companies' behalf, so Power of Attorney details and a delegation letter are required."
|
||||
: "Power of Attorney details are optional. Fill them in if you have them, or skip to continue. If you do enter a representative, upload the delegation letter authorising them."}
|
||||
? "As a freight forwarder you act on other companies' behalf, so Power of Attorney details and the DARS delegation paper are required."
|
||||
: "Power of Attorney details are optional. Fill them in if you have them, or skip to continue. If you do enter a representative, upload the delegation paper authenticated by DARS."}
|
||||
</Text>
|
||||
{watch("contactPersonName") && (
|
||||
{identity && (
|
||||
<FaydaVerifyPanel
|
||||
subject="poa"
|
||||
title="Power of Attorney"
|
||||
state={identity.poa}
|
||||
required={identity.faydaRequired}
|
||||
onVerified={() => onIdentityChange?.()}
|
||||
/>
|
||||
)}
|
||||
{!verifiedIdentity && watch("contactPersonName") && (
|
||||
<LinkCheckboxCard
|
||||
checked={poaSameAsContact}
|
||||
onToggle={togglePoaSameAsContact}
|
||||
@@ -872,40 +893,54 @@ export default function CompanyProfileForm({
|
||||
description="Reuse the contact person's name, email and phone, plus the company's location and address. Uncheck to enter different details."
|
||||
/>
|
||||
)}
|
||||
<TextInput
|
||||
label="PoA Name"
|
||||
placeholder="Authorized Representative Name"
|
||||
error={errors.poaName?.message}
|
||||
{...register("poaName")}
|
||||
/>
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
<TextInput
|
||||
label="PoA Email"
|
||||
type="email"
|
||||
placeholder="poa@company.com"
|
||||
error={errors.poaEmail?.message}
|
||||
{...register("poaEmail")}
|
||||
/>
|
||||
<ControlledPhoneField
|
||||
control={control}
|
||||
name="poaPhone"
|
||||
label="PoA Phone"
|
||||
/>
|
||||
</SimpleGrid>
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
{!verifiedIdentity && (
|
||||
<>
|
||||
<TextInput
|
||||
label="PoA Name"
|
||||
placeholder="Authorized Representative Name"
|
||||
error={errors.poaName?.message}
|
||||
{...register("poaName")}
|
||||
/>
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
<TextInput
|
||||
label="PoA Email"
|
||||
type="email"
|
||||
placeholder="poa@company.com"
|
||||
error={errors.poaEmail?.message}
|
||||
{...register("poaEmail")}
|
||||
/>
|
||||
<ControlledPhoneField
|
||||
control={control}
|
||||
name="poaPhone"
|
||||
label="PoA Phone"
|
||||
/>
|
||||
</SimpleGrid>
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
<TextInput
|
||||
label="PoA Location"
|
||||
placeholder="City, Country"
|
||||
error={errors.poaLocation?.message}
|
||||
{...register("poaLocation")}
|
||||
/>
|
||||
<TextInput
|
||||
label="PoA Address"
|
||||
placeholder="Full Address"
|
||||
error={errors.poaAddress?.message}
|
||||
{...register("poaAddress")}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</>
|
||||
)}
|
||||
{/* The city is the one field the Fayda address claim does not
|
||||
reliably decompose into, so it stays typed either way. */}
|
||||
{verifiedIdentity && (
|
||||
<TextInput
|
||||
label="PoA Location"
|
||||
placeholder="City, Country"
|
||||
error={errors.poaLocation?.message}
|
||||
{...register("poaLocation")}
|
||||
/>
|
||||
<TextInput
|
||||
label="PoA Address"
|
||||
placeholder="Full Address"
|
||||
error={errors.poaAddress?.message}
|
||||
{...register("poaAddress")}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
)}
|
||||
|
||||
{poaDocumentSetting && (
|
||||
<>
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { Badge, Card, Group, Select, SimpleGrid, Text, TextInput } from "@mantine/core";
|
||||
import { CheckCircle2 } from "lucide-react";
|
||||
import { Controller } from "react-hook-form";
|
||||
import type {
|
||||
Control,
|
||||
FieldErrors,
|
||||
UseFormRegister,
|
||||
UseFormWatch,
|
||||
} from "react-hook-form";
|
||||
import { ETHIOPIAN_REGIONS } from "@edr/types";
|
||||
|
||||
import type { FormData } from "./schema";
|
||||
import { ReadOnlyField } from "./ReadOnlyField";
|
||||
|
||||
/**
|
||||
* One field of the verified-registration card: locked read-only once eTrade
|
||||
* supplied a value, but falls back to an editable input when eTrade left it
|
||||
* blank — otherwise a gap in eTrade's own data would leave the field
|
||||
* permanently empty and the user stuck (zod requires all of these).
|
||||
*/
|
||||
function LockedField({
|
||||
label,
|
||||
name,
|
||||
register,
|
||||
watch,
|
||||
errors,
|
||||
}: {
|
||||
label: string;
|
||||
name: keyof FormData;
|
||||
register: UseFormRegister<FormData>;
|
||||
watch: UseFormWatch<FormData>;
|
||||
errors: FieldErrors<FormData>;
|
||||
}) {
|
||||
const value = watch(name) as string | undefined;
|
||||
if (value && value.trim()) {
|
||||
return <ReadOnlyField label={label} value={value} />;
|
||||
}
|
||||
return (
|
||||
<TextInput
|
||||
label={label}
|
||||
description="eTrade didn't provide this — please confirm"
|
||||
error={errors[name]?.message as string | undefined}
|
||||
{...register(name)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ETradeCompanyCard({
|
||||
tin,
|
||||
register,
|
||||
watch,
|
||||
errors,
|
||||
control,
|
||||
}: {
|
||||
tin: string;
|
||||
register: UseFormRegister<FormData>;
|
||||
watch: UseFormWatch<FormData>;
|
||||
errors: FieldErrors<FormData>;
|
||||
control: Control<FormData>;
|
||||
}) {
|
||||
const companyName = watch("companyName");
|
||||
const region = watch("region");
|
||||
|
||||
return (
|
||||
<Card padding="md" radius="md" withBorder>
|
||||
<Group justify="space-between" align="center" mb="md">
|
||||
<Group gap="sm">
|
||||
<Text fw={600} c="edr-text">
|
||||
{companyName && companyName.trim() ? companyName : "Company record"}
|
||||
</Text>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color="green"
|
||||
leftSection={<CheckCircle2 size={11} />}
|
||||
>
|
||||
Verified with eTrade
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text size="xs" c="edr-muted">
|
||||
TIN {tin}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<SimpleGrid cols={2} spacing="sm">
|
||||
<LockedField
|
||||
label="Company Name"
|
||||
name="companyName"
|
||||
register={register}
|
||||
watch={watch}
|
||||
errors={errors}
|
||||
/>
|
||||
<ReadOnlyField label="License Number" value={watch("licenceNumber")} />
|
||||
<ReadOnlyField label="Status" value={watch("statusDescription")} />
|
||||
<ReadOnlyField label="Date Registered" value={watch("dateRegistered")} />
|
||||
<ReadOnlyField label="Renewal Date" value={watch("renewalDate")} />
|
||||
<ReadOnlyField label="Renewed From" value={watch("renewedFrom")} />
|
||||
<ReadOnlyField label="Renewed To" value={watch("renewedTo")} />
|
||||
{region && region.trim() ? (
|
||||
<ReadOnlyField label="Region" value={region} />
|
||||
) : (
|
||||
<Controller
|
||||
name="region"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
label="Region"
|
||||
description="eTrade didn't provide this — please confirm"
|
||||
placeholder="Select region"
|
||||
searchable
|
||||
data={ETHIOPIAN_REGIONS.map((r) => ({ value: r, label: r }))}
|
||||
error={errors.region?.message}
|
||||
value={field.value || null}
|
||||
onChange={(v) => field.onChange(v ?? "")}
|
||||
onBlur={field.onBlur}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<LockedField
|
||||
label="Zone"
|
||||
name="zone"
|
||||
register={register}
|
||||
watch={watch}
|
||||
errors={errors}
|
||||
/>
|
||||
<LockedField
|
||||
label="Woreda"
|
||||
name="woreda"
|
||||
register={register}
|
||||
watch={watch}
|
||||
errors={errors}
|
||||
/>
|
||||
<LockedField
|
||||
label="Kebele"
|
||||
name="kebele"
|
||||
register={register}
|
||||
watch={watch}
|
||||
errors={errors}
|
||||
/>
|
||||
<LockedField
|
||||
label="House No"
|
||||
name="houseNo"
|
||||
register={register}
|
||||
watch={watch}
|
||||
errors={errors}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Badge, Group, Stack, Text } from "@mantine/core";
|
||||
import { Check, X } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export type SectionStatus = "todo" | "done" | "blocked";
|
||||
|
||||
const STATUS_BADGE: Record<
|
||||
SectionStatus,
|
||||
{ color: string; label: string; icon?: ReactNode } | null
|
||||
> = {
|
||||
todo: null,
|
||||
done: { color: "green", label: "Done", icon: <Check size={11} /> },
|
||||
blocked: { color: "red", label: "Action needed", icon: <X size={11} /> },
|
||||
};
|
||||
|
||||
/**
|
||||
* One numbered section of the Company Information step — a title, an
|
||||
* optional subtitle, a status badge, and its content. Purely presentational;
|
||||
* the parent decides each section's status.
|
||||
*/
|
||||
export default function StepSection({
|
||||
index,
|
||||
title,
|
||||
subtitle,
|
||||
status,
|
||||
children,
|
||||
}: {
|
||||
index: number;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
status: SectionStatus;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const badge = STATUS_BADGE[status];
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" align="center">
|
||||
<Group gap="sm" align="center">
|
||||
<Text
|
||||
fw={700}
|
||||
size="sm"
|
||||
c={status === "done" ? "edr-green" : "edr-text"}
|
||||
style={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: "50%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
border: "1.5px solid var(--mantine-color-edr-border-0)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{index}
|
||||
</Text>
|
||||
<div>
|
||||
<Text fw={600} size="sm" c="edr-text">
|
||||
{title}
|
||||
</Text>
|
||||
{subtitle && (
|
||||
<Text size="xs" c="edr-muted">
|
||||
{subtitle}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</Group>
|
||||
{badge && (
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={badge.color}
|
||||
leftSection={badge.icon}
|
||||
>
|
||||
{badge.label}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<div style={{ paddingLeft: 34 }}>
|
||||
<Stack gap="sm">{children}</Stack>
|
||||
</div>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -25,12 +25,11 @@ export function buildPayload(
|
||||
companyName: data.companyName,
|
||||
companyEmail: data.companyEmail,
|
||||
companyPhone: data.companyPhone,
|
||||
companyLocation: data.companyLocation,
|
||||
companyAddress: data.companyAddress,
|
||||
tin: data.tinNumber,
|
||||
vatNumber: data.vatNumber,
|
||||
fanNumber: data.fanNumber,
|
||||
attributes: {
|
||||
ownerPassportNumber: data.ownerPassportNumber || undefined,
|
||||
contactPersonName: data.contactPersonName,
|
||||
contactPersonPosition: data.contactPersonPosition || undefined,
|
||||
contactPersonEmail: data.contactPersonEmail || undefined,
|
||||
@@ -58,11 +57,10 @@ export function stepPayload(
|
||||
companyName: d.companyName,
|
||||
companyEmail: d.companyEmail,
|
||||
companyPhone: d.companyPhone,
|
||||
companyLocation: d.companyLocation,
|
||||
companyAddress: d.companyAddress,
|
||||
tin: d.tinNumber,
|
||||
vatNumber: d.vatNumber,
|
||||
fanNumber: d.fanNumber,
|
||||
ownerPassportNumber: d.ownerPassportNumber || undefined,
|
||||
licenceNumber: d.licenceNumber,
|
||||
statusDescription: d.statusDescription,
|
||||
dateRegistered: d.dateRegistered,
|
||||
@@ -110,11 +108,10 @@ export function toFormValues(p: ProfileResponse): FormData {
|
||||
companyName: p.companyName ?? "",
|
||||
companyEmail: p.companyEmail ?? "",
|
||||
companyPhone: p.companyPhone ?? "",
|
||||
companyLocation: p.companyLocation ?? "",
|
||||
companyAddress: p.companyAddress ?? "",
|
||||
tinNumber: tin,
|
||||
vatNumber: p.vatNumber ?? "",
|
||||
fanNumber: p.fanNumber ?? "",
|
||||
ownerPassportNumber: p.identity?.owner.passportNumber ?? "",
|
||||
licenceNumber: p.licenceNumber ?? "",
|
||||
statusDescription: p.statusDescription ?? "",
|
||||
dateRegistered: p.dateRegistered ?? "",
|
||||
|
||||
@@ -18,7 +18,6 @@ export const onboardingSchema = z.object({
|
||||
.string()
|
||||
.min(1, "Company phone is required")
|
||||
.refine(isValidPhone, "Enter a valid phone number"),
|
||||
companyLocation: z.string().min(1, "Location is required"),
|
||||
// Derived from the eTrade address parts (kebele/woreda/zone/region); no
|
||||
// standalone input — the granular fields live in the registration section.
|
||||
companyAddress: z.string().optional(),
|
||||
@@ -27,7 +26,10 @@ export const onboardingSchema = z.object({
|
||||
.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"),
|
||||
// The owner's passport number — the foreign-company identity credential
|
||||
// (Fayda is an Ethiopian national ID). Required only for a foreign company;
|
||||
// enforced in buildOnboardingSchema since that depends on `nationality`.
|
||||
ownerPassportNumber: z.string().optional(),
|
||||
licenceNumber: z.string().optional(),
|
||||
statusDescription: z.string().optional(),
|
||||
dateRegistered: z.string().optional(),
|
||||
@@ -109,14 +111,35 @@ export const hasPoaDetails = (d: Partial<FormData>) =>
|
||||
* delegation-letter upload is enforced alongside this, in CompanyProfileForm,
|
||||
* since files live outside the form state).
|
||||
*/
|
||||
export function buildOnboardingSchema(requirePoa: boolean) {
|
||||
if (!requirePoa) return onboardingSchema;
|
||||
export function buildOnboardingSchema(
|
||||
requirePoa: boolean,
|
||||
/**
|
||||
* True when the PoA's identity fields come from a Fayda verification rather
|
||||
* than the form (Ethiopian companies). Requiring them here would fail
|
||||
* validation against inputs the step no longer renders — the verification
|
||||
* itself is what the step gates on instead.
|
||||
*/
|
||||
faydaOwnedPoa = false,
|
||||
/** True for a foreign company: the owner's passport number is mandatory. */
|
||||
passportRequired = false,
|
||||
) {
|
||||
const poaRequired = requirePoa && !faydaOwnedPoa;
|
||||
if (!poaRequired && !passportRequired) return onboardingSchema;
|
||||
return onboardingSchema.superRefine((d, ctx) => {
|
||||
const required: [keyof FormData, string][] = [
|
||||
["poaName", "PoA name is required for freight forwarders"],
|
||||
["poaEmail", "PoA email is required for freight forwarders"],
|
||||
["poaPhone", "PoA phone is required for freight forwarders"],
|
||||
];
|
||||
const required: [keyof FormData, string][] = [];
|
||||
if (poaRequired) {
|
||||
required.push(
|
||||
["poaName", "PoA name is required for freight forwarders"],
|
||||
["poaEmail", "PoA email is required for freight forwarders"],
|
||||
["poaPhone", "PoA phone is required for freight forwarders"],
|
||||
);
|
||||
}
|
||||
if (passportRequired) {
|
||||
required.push([
|
||||
"ownerPassportNumber",
|
||||
"The owner's passport number is required",
|
||||
]);
|
||||
}
|
||||
for (const [path, message] of required) {
|
||||
if (!d[path]?.trim()) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: [path], message });
|
||||
@@ -130,11 +153,10 @@ export const stepFields: Record<CompanyStep, (keyof FormData)[]> = {
|
||||
"companyName",
|
||||
"companyEmail",
|
||||
"companyPhone",
|
||||
"companyLocation",
|
||||
"companyAddress",
|
||||
"tinNumber",
|
||||
"vatNumber",
|
||||
"fanNumber",
|
||||
"ownerPassportNumber",
|
||||
"licenceNumber",
|
||||
"statusDescription",
|
||||
"dateRegistered",
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
|
||||
import { isValidPhone, toEthiopianE164 } from "@/components/PhoneField";
|
||||
import { api } from "@/services/api";
|
||||
import type {
|
||||
CompanyProfileInput,
|
||||
CreateCompanyPayload,
|
||||
} from "@/services/companies.service";
|
||||
import type { AuthUser } from "@/types/auth";
|
||||
import type { ProfileResponse } from "@/types/profile";
|
||||
import { extractApiError } from "@/utils/result";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Grid,
|
||||
Group,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
@@ -18,10 +21,15 @@ import {
|
||||
} from "@mantine/core";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Building2, CheckCircle2, Save, XCircle } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { ETHIOPIAN_REGIONS, type CompanyRegistrationData } from "@edr/types";
|
||||
import OnboardingRoleSelect from "./OnboardingRoleSelect";
|
||||
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
|
||||
import ETradeInfo, { type ETradeStatus } from "@/components/onboarding/ETradeInfo";
|
||||
import { ReadOnlyField } from "@/pages/accounts/companyProfileForm/ReadOnlyField";
|
||||
import StepSection from "@/pages/accounts/companyProfileForm/StepSection";
|
||||
|
||||
export const COMPANY_PROFILE_SCHEMA = z.object({
|
||||
companyName: z.string().min(1, "Company name is required"),
|
||||
@@ -31,33 +39,72 @@ export const COMPANY_PROFILE_SCHEMA = z.object({
|
||||
.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 (region/zone/woreda/kebele/houseNo);
|
||||
// no standalone input.
|
||||
companyAddress: z.string().optional(),
|
||||
tinNumber: z.string().regex(/^\d{10}$/, "TIN must be exactly 10 digits"),
|
||||
fanNumber: z.string().length(16, "FAN must be exactly 16 digits"),
|
||||
vatNumber: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(20, "VAT number is too long")
|
||||
.optional()
|
||||
.or(z.literal("")),
|
||||
ownerPassportNumber: z.string().optional(),
|
||||
// Registration/address fields are eTrade-sourced — locked once eTrade
|
||||
// supplies a value, editable only as an escape hatch when it doesn't
|
||||
// (see LockedField below). Not typed by hand in the normal case.
|
||||
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()
|
||||
.refine((v) => (ETHIOPIAN_REGIONS as readonly string[]).includes(v), {
|
||||
message: "Region is required",
|
||||
}),
|
||||
zone: z.string().min(1, "Zone is required"),
|
||||
woreda: z.string().min(1, "Woreda is required"),
|
||||
kebele: z.string().min(1, "Kebele is required"),
|
||||
houseNo: z.string().min(1, "House number is required"),
|
||||
});
|
||||
|
||||
export type CompanyProfileFormData = z.infer<typeof COMPANY_PROFILE_SCHEMA>;
|
||||
|
||||
/** UpdateProfilePayload keys eTrade owns — only resent when the customer re-verified them this session. */
|
||||
const ETRADE_BUNDLE_FIELDS = [
|
||||
"companyName",
|
||||
"licenceNumber",
|
||||
"statusDescription",
|
||||
"dateRegistered",
|
||||
"renewedFrom",
|
||||
"renewalDate",
|
||||
"renewedTo",
|
||||
"region",
|
||||
"zone",
|
||||
"woreda",
|
||||
"kebele",
|
||||
"houseNo",
|
||||
] as const satisfies readonly (keyof CompanyProfileFormData)[];
|
||||
|
||||
interface TabCompanyProfileProps {
|
||||
profile?: ProfileResponse;
|
||||
mode?: "edit" | "create";
|
||||
onCreateSuccess?: () => void;
|
||||
user?: AuthUser;
|
||||
}
|
||||
|
||||
export default function TabCompanyProfile({
|
||||
profile,
|
||||
mode = "edit",
|
||||
onCreateSuccess,
|
||||
user,
|
||||
}: TabCompanyProfileProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const isCreate = mode === "create";
|
||||
const [selectedRoles, setSelectedRoles] = useState<string[]>([]);
|
||||
const [tinStatus, setTinStatus] = useState<ETradeStatus>("idle");
|
||||
|
||||
const defaultValues = useMemo((): CompanyProfileFormData => {
|
||||
if (profile) {
|
||||
@@ -68,8 +115,19 @@ export default function TabCompanyProfile({
|
||||
companyLocation: profile.companyLocation,
|
||||
companyAddress: profile.companyAddress ?? "",
|
||||
tinNumber: profile.tinNumber,
|
||||
fanNumber: profile.fanNumber ?? "",
|
||||
vatNumber: profile.vatNumber ?? "",
|
||||
ownerPassportNumber: profile.identity?.owner.passportNumber ?? "",
|
||||
licenceNumber: profile.licenceNumber ?? "",
|
||||
statusDescription: profile.statusDescription ?? "",
|
||||
dateRegistered: profile.dateRegistered ?? "",
|
||||
renewedFrom: profile.renewedFrom ?? "",
|
||||
renewalDate: profile.renewalDate ?? "",
|
||||
renewedTo: profile.renewedTo ?? "",
|
||||
region: profile.region ?? "",
|
||||
zone: profile.zone ?? "",
|
||||
woreda: profile.woreda ?? "",
|
||||
kebele: profile.kebele ?? "",
|
||||
houseNo: profile.houseNo ?? "",
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -79,8 +137,19 @@ export default function TabCompanyProfile({
|
||||
companyLocation: "",
|
||||
companyAddress: "",
|
||||
tinNumber: "",
|
||||
fanNumber: "",
|
||||
vatNumber: "",
|
||||
ownerPassportNumber: "",
|
||||
licenceNumber: "",
|
||||
statusDescription: "",
|
||||
dateRegistered: "",
|
||||
renewedFrom: "",
|
||||
renewalDate: "",
|
||||
renewedTo: "",
|
||||
region: "",
|
||||
zone: "",
|
||||
woreda: "",
|
||||
kebele: "",
|
||||
houseNo: "",
|
||||
};
|
||||
}, [profile]);
|
||||
|
||||
@@ -89,23 +158,112 @@ export default function TabCompanyProfile({
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors, isDirty },
|
||||
watch,
|
||||
setValue,
|
||||
formState: { errors, isDirty, dirtyFields },
|
||||
} = useForm<CompanyProfileFormData>({
|
||||
resolver: zodResolver(COMPANY_PROFILE_SCHEMA),
|
||||
values: defaultValues,
|
||||
});
|
||||
|
||||
const identity = profile?.identity;
|
||||
const verifiedIdentity = identity?.faydaRequired === true;
|
||||
|
||||
// companyEmail/companyPhone are the owner's verified contact details, never
|
||||
// typed — same derivation as the onboarding wizard, just fed from the saved
|
||||
// profile instead of an in-progress form.
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
setValue("companyEmail", identity?.owner.email ?? user.email ?? "", {
|
||||
shouldValidate: true,
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [identity?.owner.email, user?.email]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
setValue(
|
||||
"companyPhone",
|
||||
identity?.owner.phone ??
|
||||
profile?.etradePhone ??
|
||||
toEthiopianE164(user.phoneNumber) ??
|
||||
"",
|
||||
{ shouldValidate: true },
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [identity?.owner.phone, profile?.etradePhone, user?.phoneNumber]);
|
||||
|
||||
// companyAddress is composed from the (locked) eTrade address parts, not
|
||||
// typed directly.
|
||||
const region = watch("region");
|
||||
const zone = watch("zone");
|
||||
const woreda = watch("woreda");
|
||||
const kebele = watch("kebele");
|
||||
const houseNo = watch("houseNo");
|
||||
useEffect(() => {
|
||||
const composed = [houseNo, kebele, woreda, zone, region]
|
||||
.filter((part) => part && part.trim())
|
||||
.join(", ");
|
||||
setValue("companyAddress", composed);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [region, zone, woreda, kebele, houseNo]);
|
||||
|
||||
const handleETradeDataLoaded = (data: CompanyRegistrationData) => {
|
||||
if (data.companyName) {
|
||||
setValue("companyName", data.companyName, {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
});
|
||||
}
|
||||
setValue("licenceNumber", data.licenceNumber, { shouldDirty: true });
|
||||
setValue("statusDescription", data.statusDescription, { shouldDirty: true });
|
||||
setValue("dateRegistered", data.dateRegistered, { shouldDirty: true });
|
||||
setValue("renewedFrom", data.renewedFrom, { shouldDirty: true });
|
||||
setValue("renewalDate", data.renewalDate, { shouldDirty: true });
|
||||
setValue("renewedTo", data.renewedTo, { shouldDirty: true });
|
||||
setValue("region", data.region, { shouldDirty: true });
|
||||
setValue("zone", data.zone, { shouldDirty: true });
|
||||
setValue("woreda", data.woreda, { shouldDirty: true });
|
||||
setValue("kebele", data.kebele, { shouldDirty: true });
|
||||
setValue("houseNo", data.houseNo, { shouldDirty: true });
|
||||
};
|
||||
|
||||
// A previously-verified TIN (every active company has one) counts as
|
||||
// verified without a refetch — the registration fields being populated at
|
||||
// all is proof it passed before.
|
||||
const registration = watch([
|
||||
"licenceNumber",
|
||||
"statusDescription",
|
||||
"dateRegistered",
|
||||
"renewalDate",
|
||||
"renewedFrom",
|
||||
"renewedTo",
|
||||
]);
|
||||
const hasRegistrationDetails = registration.some((v) => v && v.trim());
|
||||
const tinVerified = tinStatus === "verified" || hasRegistrationDetails;
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (data: CompanyProfileFormData) => {
|
||||
// eTrade-owned fields are only resent when they actually changed this
|
||||
// session (a real re-verify) — resubmitting the unchanged live values
|
||||
// on every save would otherwise trigger the server's eTrade
|
||||
// authenticity re-check for no reason.
|
||||
const etradeBundle: Record<string, string | undefined> = {};
|
||||
for (const key of ETRADE_BUNDLE_FIELDS) {
|
||||
if (dirtyFields[key]) etradeBundle[key] = data[key];
|
||||
}
|
||||
if (dirtyFields.tinNumber) etradeBundle.tin = data.tinNumber;
|
||||
|
||||
const base = {
|
||||
companyName: data.companyName,
|
||||
companyEmail: data.companyEmail,
|
||||
companyPhone: data.companyPhone,
|
||||
companyLocation: data.companyLocation,
|
||||
companyAddress: data.companyAddress,
|
||||
tin: data.tinNumber,
|
||||
fanNumber: data.fanNumber,
|
||||
vatNumber: data.vatNumber ?? "",
|
||||
...etradeBundle,
|
||||
...(data.ownerPassportNumber !== undefined
|
||||
? { ownerPassportNumber: data.ownerPassportNumber }
|
||||
: {}),
|
||||
};
|
||||
|
||||
if (isCreate) {
|
||||
@@ -116,6 +274,8 @@ export default function TabCompanyProfile({
|
||||
: "customer";
|
||||
const payload: CreateCompanyPayload = {
|
||||
...base,
|
||||
companyName: data.companyName,
|
||||
tin: data.tinNumber,
|
||||
companyType,
|
||||
companyProfiles: selectedRoles.map((type) => ({
|
||||
type: type as CompanyProfileInput["type"],
|
||||
@@ -140,6 +300,15 @@ export default function TabCompanyProfile({
|
||||
mutation.mutate(data);
|
||||
};
|
||||
|
||||
const saveErrorMessage = mutation.isError
|
||||
? extractApiError(mutation.error).message
|
||||
: null;
|
||||
|
||||
const pendingOwnerReview = Boolean(
|
||||
(profile?.pendingChanges as { faydaIdentity?: Record<string, unknown> } | null)
|
||||
?.faydaIdentity?.ownerFaydaSub,
|
||||
);
|
||||
|
||||
// During onboarding the role selection gates the form: nothing else shows
|
||||
// until the user picks Importer/Exporter or Freight Forwarder.
|
||||
const showForm = !isCreate || selectedRoles.length > 0;
|
||||
@@ -161,89 +330,109 @@ export default function TabCompanyProfile({
|
||||
<Text c="edr-muted" size="sm" mb="lg">
|
||||
{isCreate
|
||||
? "Enter your company registration details to get started"
|
||||
: "Edit your company registration details"}
|
||||
: "Your registration and identity come from eTrade and Fayda — re-verify to refresh them."}
|
||||
</Text>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label="Company Name"
|
||||
placeholder="Global Logistics Ltd"
|
||||
error={errors.companyName?.message}
|
||||
{...register("companyName")}
|
||||
/>
|
||||
<Stack gap="xl">
|
||||
<StepSection
|
||||
index={1}
|
||||
title="VAT number"
|
||||
status={watch("vatNumber") ? "done" : "todo"}
|
||||
>
|
||||
<TextInput
|
||||
label="VAT Number (optional)"
|
||||
placeholder="e.g. 0012345678"
|
||||
maxLength={20}
|
||||
error={errors.vatNumber?.message}
|
||||
{...register("vatNumber")}
|
||||
/>
|
||||
</StepSection>
|
||||
|
||||
<Grid>
|
||||
<Grid.Col span={6}>
|
||||
<TextInput
|
||||
label="Company Email"
|
||||
type="email"
|
||||
placeholder="ops@company.com"
|
||||
error={errors.companyEmail?.message}
|
||||
{...register("companyEmail")}
|
||||
{identity && (
|
||||
<StepSection
|
||||
index={2}
|
||||
title="Owner identity"
|
||||
subtitle={
|
||||
verifiedIdentity
|
||||
? "Re-verify the company owner with Fayda — their name, phone, email and address are refreshed from the verification."
|
||||
: "The company owner's passport number."
|
||||
}
|
||||
status={
|
||||
verifiedIdentity
|
||||
? identity.owner.verified
|
||||
? "done"
|
||||
: identity.faydaRequired
|
||||
? "blocked"
|
||||
: "todo"
|
||||
: (watch("ownerPassportNumber")?.trim()?.length ?? 0) > 0
|
||||
? "done"
|
||||
: identity.passportRequired
|
||||
? "blocked"
|
||||
: "todo"
|
||||
}
|
||||
>
|
||||
<FaydaVerifyPanel
|
||||
subject="owner"
|
||||
title="Company owner"
|
||||
state={identity.owner}
|
||||
required={identity.faydaRequired}
|
||||
disabled={mutation.isPending}
|
||||
pendingReview={pendingOwnerReview}
|
||||
onVerified={() =>
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: api.companies.getProfile.queryKey(),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<ControlledPhoneField
|
||||
{identity.passportRequired && (
|
||||
<TextInput
|
||||
label="Owner Passport Number"
|
||||
placeholder="P1234567"
|
||||
description="The owner's identity credential — Fayda is an Ethiopian national ID, so a foreign company's owner is identified by passport instead."
|
||||
error={errors.ownerPassportNumber?.message}
|
||||
{...register("ownerPassportNumber")}
|
||||
/>
|
||||
)}
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
<ReadOnlyField label="Company email" value={watch("companyEmail")} />
|
||||
<ReadOnlyField label="Company phone" value={watch("companyPhone")} />
|
||||
</SimpleGrid>
|
||||
</StepSection>
|
||||
)}
|
||||
|
||||
<StepSection
|
||||
index={3}
|
||||
title="Company TIN"
|
||||
subtitle="Re-verify with eTrade to refresh your registration record — nothing here is typed by hand."
|
||||
status={
|
||||
tinVerified ? "done" : tinStatus === "taken" ? "blocked" : "todo"
|
||||
}
|
||||
>
|
||||
<ETradeInfo
|
||||
tin={watch("tinNumber")}
|
||||
register={register("tinNumber")}
|
||||
error={errors.tinNumber?.message}
|
||||
onDataLoaded={handleETradeDataLoaded}
|
||||
onStatusChange={setTinStatus}
|
||||
/>
|
||||
{tinVerified && (
|
||||
<EtradeLockedCard
|
||||
tin={watch("tinNumber")}
|
||||
register={register}
|
||||
watch={watch}
|
||||
errors={errors}
|
||||
control={control}
|
||||
name="companyPhone"
|
||||
label="Company Phone"
|
||||
required
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
)}
|
||||
</StepSection>
|
||||
|
||||
<Grid>
|
||||
<Grid.Col span={6}>
|
||||
<TextInput
|
||||
label="Location"
|
||||
placeholder="Addis Ababa, Ethiopia"
|
||||
error={errors.companyLocation?.message}
|
||||
{...register("companyLocation")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<TextInput
|
||||
label="Address"
|
||||
placeholder="Bole Subcity, Woreda 03"
|
||||
error={errors.companyAddress?.message}
|
||||
{...register("companyAddress")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
<Grid>
|
||||
<Grid.Col span={6}>
|
||||
<TextInput
|
||||
label="TIN Number (10 digits)"
|
||||
placeholder="1234567890"
|
||||
maxLength={10}
|
||||
error={errors.tinNumber?.message}
|
||||
{...register("tinNumber")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<TextInput
|
||||
label="FAN Number (16 digits)"
|
||||
placeholder="1234567890123456"
|
||||
maxLength={16}
|
||||
error={errors.fanNumber?.message}
|
||||
{...register("fanNumber")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
<Grid>
|
||||
<Grid.Col span={6}>
|
||||
<TextInput
|
||||
label="VAT Number (optional)"
|
||||
placeholder="e.g. 0012345678"
|
||||
maxLength={20}
|
||||
error={errors.vatNumber?.message}
|
||||
{...register("vatNumber")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
<TextInput
|
||||
label="Location"
|
||||
placeholder="Addis Ababa, Ethiopia"
|
||||
error={errors.companyLocation?.message}
|
||||
{...register("companyLocation")}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Group
|
||||
@@ -261,11 +450,11 @@ export default function TabCompanyProfile({
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
{mutation.isError && (
|
||||
{saveErrorMessage && (
|
||||
<Group gap={6} c="red">
|
||||
<XCircle size={16} />
|
||||
<Text size="sm" fw={500}>
|
||||
{isCreate ? "Failed to create profile" : "Save failed"}
|
||||
{saveErrorMessage}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
@@ -296,3 +485,111 @@ export default function TabCompanyProfile({
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The verified eTrade record, locked read-only — same escape hatch as
|
||||
* onboarding's ETradeCompanyCard: a field eTrade left blank falls back to an
|
||||
* editable input rather than trapping the customer.
|
||||
*/
|
||||
function EtradeLockedCard({
|
||||
tin,
|
||||
register,
|
||||
watch,
|
||||
errors,
|
||||
control,
|
||||
}: {
|
||||
tin: string;
|
||||
register: ReturnType<typeof useForm<CompanyProfileFormData>>["register"];
|
||||
watch: ReturnType<typeof useForm<CompanyProfileFormData>>["watch"];
|
||||
errors: ReturnType<typeof useForm<CompanyProfileFormData>>["formState"]["errors"];
|
||||
control: ReturnType<typeof useForm<CompanyProfileFormData>>["control"];
|
||||
}) {
|
||||
const companyName = watch("companyName");
|
||||
const region = watch("region");
|
||||
|
||||
return (
|
||||
<Card padding="md" radius="md" withBorder>
|
||||
<Group justify="space-between" align="center" mb="md">
|
||||
<Text fw={600} c="edr-text">
|
||||
{companyName?.trim() ? companyName : "Company record"}
|
||||
</Text>
|
||||
<Text size="xs" c="edr-muted">
|
||||
TIN {tin}
|
||||
</Text>
|
||||
</Group>
|
||||
<SimpleGrid cols={2} spacing="sm">
|
||||
<LockedField label="Company Name" name="companyName" register={register} watch={watch} errors={errors} />
|
||||
<ReadOnlyField label="License Number" value={watch("licenceNumber")} />
|
||||
<ReadOnlyField label="Status" value={watch("statusDescription")} />
|
||||
<ReadOnlyField label="Date Registered" value={watch("dateRegistered")} />
|
||||
<ReadOnlyField label="Renewal Date" value={watch("renewalDate")} />
|
||||
<ReadOnlyField label="Renewed From" value={watch("renewedFrom")} />
|
||||
<ReadOnlyField label="Renewed To" value={watch("renewedTo")} />
|
||||
{region?.trim() ? (
|
||||
<ReadOnlyField label="Region" value={region} />
|
||||
) : (
|
||||
<RegionSelect control={control} error={errors.region?.message} />
|
||||
)}
|
||||
<LockedField label="Zone" name="zone" register={register} watch={watch} errors={errors} />
|
||||
<LockedField label="Woreda" name="woreda" register={register} watch={watch} errors={errors} />
|
||||
<LockedField label="Kebele" name="kebele" register={register} watch={watch} errors={errors} />
|
||||
<LockedField label="House No" name="houseNo" register={register} watch={watch} errors={errors} />
|
||||
</SimpleGrid>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function LockedField({
|
||||
label,
|
||||
name,
|
||||
register,
|
||||
watch,
|
||||
errors,
|
||||
}: {
|
||||
label: string;
|
||||
name: keyof CompanyProfileFormData;
|
||||
register: ReturnType<typeof useForm<CompanyProfileFormData>>["register"];
|
||||
watch: ReturnType<typeof useForm<CompanyProfileFormData>>["watch"];
|
||||
errors: ReturnType<typeof useForm<CompanyProfileFormData>>["formState"]["errors"];
|
||||
}) {
|
||||
const value = watch(name) as string | undefined;
|
||||
if (value?.trim()) {
|
||||
return <ReadOnlyField label={label} value={value} />;
|
||||
}
|
||||
return (
|
||||
<TextInput
|
||||
label={label}
|
||||
description="eTrade didn't provide this — please confirm"
|
||||
error={errors[name]?.message as string | undefined}
|
||||
{...register(name)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function RegionSelect({
|
||||
control,
|
||||
error,
|
||||
}: {
|
||||
control: ReturnType<typeof useForm<CompanyProfileFormData>>["control"];
|
||||
error?: string;
|
||||
}) {
|
||||
return (
|
||||
<Controller
|
||||
name="region"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
label="Region"
|
||||
description="eTrade didn't provide this — please confirm"
|
||||
placeholder="Select region"
|
||||
searchable
|
||||
data={ETHIOPIAN_REGIONS.map((r) => ({ value: r, label: r }))}
|
||||
error={error}
|
||||
value={field.value || null}
|
||||
onChange={(v) => field.onChange(v ?? "")}
|
||||
onBlur={field.onBlur}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ function documentSettingCode(nationality: string | null | undefined): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* The delegation letter ships in the same nationality document set, but it is
|
||||
* The DARS delegation paper ships in the same nationality document set, but it is
|
||||
* edited on the Power of Attorney tab (where it is staged for review alongside
|
||||
* the PoA details), so it is excluded from this tab's uploader.
|
||||
*/
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from "@mantine/core";
|
||||
import { api } from "@/services/api";
|
||||
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
|
||||
import { LinkCheckboxCard } from "@/pages/accounts/companyProfileForm/LinkCheckboxCard";
|
||||
import type { ProfileResponse } from "@/types/profile";
|
||||
|
||||
const schema = z.object({
|
||||
@@ -35,8 +36,17 @@ interface TabGeneralManagerProps {
|
||||
onContinue?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The general manager is a plain typed role, not the person the Fayda
|
||||
* verification proves — the owner is. They're very often the same human,
|
||||
* which is what "Same as owner" is for: once the owner has verified, this
|
||||
* copies their name/email/phone in rather than making the customer re-type
|
||||
* data the company already proved.
|
||||
*/
|
||||
export default function TabGeneralManager({ profile, mode = "edit", onContinue }: TabGeneralManagerProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const owner = profile.identity?.owner;
|
||||
const [gmSameAsOwner, setGmSameAsOwner] = useState(false);
|
||||
|
||||
const defaultValues = useMemo((): FormData => {
|
||||
return {
|
||||
@@ -51,12 +61,32 @@ export default function TabGeneralManager({ profile, mode = "edit", onContinue }
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
setValue,
|
||||
formState: { errors, isDirty },
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(schema),
|
||||
values: defaultValues,
|
||||
});
|
||||
|
||||
const toggleGmSameAsOwner = (checked: boolean) => {
|
||||
setGmSameAsOwner(checked);
|
||||
if (checked && owner) {
|
||||
setValue("generalManagerName", owner.name ?? "", { shouldValidate: true });
|
||||
setValue("generalManagerEmail", owner.email ?? "", { shouldValidate: true });
|
||||
setValue("generalManagerPhone", owner.phone ?? "", { shouldValidate: true });
|
||||
}
|
||||
};
|
||||
|
||||
// Keep the copy live while the checkbox is on — e.g. the owner re-verifies
|
||||
// with updated details.
|
||||
useEffect(() => {
|
||||
if (!gmSameAsOwner || !owner) return;
|
||||
setValue("generalManagerName", owner.name ?? "");
|
||||
setValue("generalManagerEmail", owner.email ?? "");
|
||||
setValue("generalManagerPhone", owner.phone ?? "");
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [gmSameAsOwner, owner?.name, owner?.email, owner?.phone]);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: FormData) =>
|
||||
api.companies.updateProfile.call({
|
||||
@@ -84,6 +114,14 @@ export default function TabGeneralManager({ profile, mode = "edit", onContinue }
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Stack gap="md">
|
||||
{owner?.verified && (
|
||||
<LinkCheckboxCard
|
||||
checked={gmSameAsOwner}
|
||||
onToggle={toggleGmSameAsOwner}
|
||||
title="Same as verified owner"
|
||||
description="Reuse the Fayda-verified owner's name, email and phone. Uncheck to enter different details."
|
||||
/>
|
||||
)}
|
||||
<TextInput
|
||||
label="Full Name"
|
||||
placeholder="Abebe Bikila"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user