mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 21:15:41 +00:00
@@ -36,8 +36,7 @@
|
|||||||
"iam:migration:run": "pnpm run iam:typeorm:cli migration:run",
|
"iam:migration:run": "pnpm run iam:typeorm:cli migration:run",
|
||||||
"iam:migration:revert": "pnpm run iam:typeorm:cli migration:revert",
|
"iam:migration:revert": "pnpm run iam:typeorm:cli migration:revert",
|
||||||
"iam:migration:show": "pnpm run iam:typeorm:cli migration:show",
|
"iam:migration:show": "pnpm run iam:typeorm:cli migration:show",
|
||||||
"migrate": "ts-node -r tsconfig-paths/register src/scripts/run-migrations.ts",
|
"migration:run": "nest build && node dist/scripts/migrate.js",
|
||||||
"migration:run": "node dist/scripts/migrate.js",
|
|
||||||
"script": "ts-node -r tsconfig-paths/register src/scripts/main.ts"
|
"script": "ts-node -r tsconfig-paths/register src/scripts/main.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ import { CargoesModule } from "./modules/cargoes/cargoes.module";
|
|||||||
import { RoutesModule } from "./modules/routes/routes.module";
|
import { RoutesModule } from "./modules/routes/routes.module";
|
||||||
import { WarehousesModule } from "./modules/warehouses/warehouses.module";
|
import { WarehousesModule } from "./modules/warehouses/warehouses.module";
|
||||||
import { OverviewModule } from "./modules/overview/overview.module";
|
import { OverviewModule } from "./modules/overview/overview.module";
|
||||||
|
import { UserTradeAccessModule } from "./modules/user-trade-access/user-trade-access.module";
|
||||||
import { VehiclesModule } from "./modules/vehicles/vehicles.module";
|
import { VehiclesModule } from "./modules/vehicles/vehicles.module";
|
||||||
import { DriversModule } from "./modules/drivers/drivers.module";
|
import { DriversModule } from "./modules/drivers/drivers.module";
|
||||||
import { FuelModule } from "./modules/fuel/fuel.module";
|
import { FuelModule } from "./modules/fuel/fuel.module";
|
||||||
@@ -206,6 +207,7 @@ import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middlewar
|
|||||||
RoutesModule,
|
RoutesModule,
|
||||||
WarehousesModule,
|
WarehousesModule,
|
||||||
OverviewModule,
|
OverviewModule,
|
||||||
|
UserTradeAccessModule,
|
||||||
VehiclesModule,
|
VehiclesModule,
|
||||||
DriversModule,
|
DriversModule,
|
||||||
FuelModule,
|
FuelModule,
|
||||||
|
|||||||
@@ -43,11 +43,17 @@ export function IsValidPhone(validationOptions?: ValidationOptions) {
|
|||||||
* Normalize a phone string to canonical E.164. Returns the canonical form when
|
* Normalize a phone string to canonical E.164. Returns the canonical form when
|
||||||
* parseable, otherwise the trimmed original (tolerant — never throws), or the
|
* parseable, otherwise the trimmed original (tolerant — never throws), or the
|
||||||
* value unchanged when empty/nullish.
|
* value unchanged when empty/nullish.
|
||||||
|
*
|
||||||
|
* Defaults the country to Ethiopia so bare local numbers (no "+", e.g. eTrade's
|
||||||
|
* "0355235416") resolve the same way the frontend's own toEthiopianE164 already
|
||||||
|
* assumes — without this hint, libphonenumber can't infer a country for a
|
||||||
|
* number with no "+" prefix and silently falls through to the untouched local
|
||||||
|
* string, which then never matches the "+251…" form submitted by the client.
|
||||||
*/
|
*/
|
||||||
export function normalizeE164(
|
export function normalizeE164(
|
||||||
value: string | null | undefined,
|
value: string | null | undefined,
|
||||||
): string | null | undefined {
|
): string | null | undefined {
|
||||||
if (value === undefined || value === null || value === '') return value;
|
if (value === undefined || value === null || value === '') return value;
|
||||||
const parsed = parsePhoneNumberFromString(value);
|
const parsed = parsePhoneNumberFromString(value, 'ET');
|
||||||
return parsed?.isValid() ? parsed.number : value.trim();
|
return parsed?.isValid() ? parsed.number : value.trim();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import "reflect-metadata";
|
import "reflect-metadata";
|
||||||
import * as dotenv from "dotenv";
|
import * as dotenv from "dotenv";
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
import { createRequire } from "node:module";
|
||||||
import { NestFactory } from "@nestjs/core";
|
import { NestFactory } from "@nestjs/core";
|
||||||
import type { NestExpressApplication } from "@nestjs/platform-express";
|
import type { NestExpressApplication } from "@nestjs/platform-express";
|
||||||
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
|
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
|
||||||
@@ -20,6 +21,62 @@ import { AppModule } from "./app.module";
|
|||||||
*/
|
*/
|
||||||
const JSON_BODY_LIMIT = '20mb';
|
const JSON_BODY_LIMIT = '20mb';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Static /etc/hosts-style overrides from `DNS_HOST_OVERRIDES`, formatted as
|
||||||
|
* `host=ip,host2=ip2`. Some internal hosts (MinIO) resolve only inside the
|
||||||
|
* deployment network, so dev machines get ENOTFOUND on every upload. Patching
|
||||||
|
* `dns.lookup` keeps the real hostname on the wire — the IP is used for the
|
||||||
|
* connection only — so TLS still validates against the certificate's CN.
|
||||||
|
*
|
||||||
|
* The module is loaded through `createRequire`, NOT `import * as dns`: an ESM
|
||||||
|
* namespace object is frozen, so assigning to it is silently dropped and the
|
||||||
|
* patch becomes a no-op. `require` returns the live module object every other
|
||||||
|
* caller (minio's http agent included) reads `lookup` off.
|
||||||
|
*/
|
||||||
|
function applyDnsHostOverrides(): void {
|
||||||
|
const raw = process.env.DNS_HOST_OVERRIDES?.trim();
|
||||||
|
if (!raw) return;
|
||||||
|
|
||||||
|
const overrides = new Map<string, string>();
|
||||||
|
for (const entry of raw.split(",")) {
|
||||||
|
const [host, ip] = entry.split("=").map((part) => part?.trim());
|
||||||
|
if (host && ip) overrides.set(host.toLowerCase(), ip);
|
||||||
|
}
|
||||||
|
if (overrides.size === 0) return;
|
||||||
|
|
||||||
|
const dns = createRequire(__filename)("node:dns") as typeof import("node:dns");
|
||||||
|
const originalLookup = dns.lookup.bind(dns);
|
||||||
|
// `dns.lookup` is overloaded (options optional, all/family variants); the
|
||||||
|
// cast keeps that surface intact while we intercept only mapped hostnames.
|
||||||
|
(dns as { lookup: unknown }).lookup = ((
|
||||||
|
hostname: string,
|
||||||
|
options: unknown,
|
||||||
|
callback?: unknown,
|
||||||
|
) => {
|
||||||
|
const ip = overrides.get(hostname?.toLowerCase?.());
|
||||||
|
if (!ip) return (originalLookup as Function)(hostname, options, callback);
|
||||||
|
|
||||||
|
const done = (typeof options === "function" ? options : callback) as (
|
||||||
|
err: NodeJS.ErrnoException | null,
|
||||||
|
address: string | { address: string; family: number }[],
|
||||||
|
family?: number,
|
||||||
|
) => void;
|
||||||
|
const family = ip.includes(":") ? 6 : 4;
|
||||||
|
const wantsAll =
|
||||||
|
typeof options === "object" && options !== null && (options as { all?: boolean }).all;
|
||||||
|
|
||||||
|
process.nextTick(() =>
|
||||||
|
wantsAll ? done(null, [{ address: ip, family }]) : done(null, ip, family),
|
||||||
|
);
|
||||||
|
}) as typeof dns.lookup;
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[DNS] Host overrides active: ${[...overrides].map(([h, ip]) => `${h}->${ip}`).join(", ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
applyDnsHostOverrides();
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-backoffice-user trade-direction scope (import / export / intercity).
|
||||||
|
* A user with no row (or all three directions) is unrestricted. Admins
|
||||||
|
* (super_admin / organization_admin) bypass the scope entirely.
|
||||||
|
*/
|
||||||
|
export class CreateUserTradeAccess3150000000000 implements MigrationInterface {
|
||||||
|
name = 'CreateUserTradeAccess3150000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.createTable(
|
||||||
|
new Table({
|
||||||
|
schema: 'freight',
|
||||||
|
name: 'user_trade_access',
|
||||||
|
columns: [
|
||||||
|
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' },
|
||||||
|
// IAM user id (iam.users) — no FK, iam schema is externally owned.
|
||||||
|
{ name: 'user_id', type: 'uuid', isUnique: true },
|
||||||
|
// Comma-separated subset of IMPORT,EXPORT,DOMESTIC (simple-array).
|
||||||
|
{ name: 'directions', type: 'text', default: "''" },
|
||||||
|
{ name: 'updated_by_id', type: 'uuid', isNullable: true },
|
||||||
|
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||||
|
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||||
|
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.createIndex(
|
||||||
|
'freight.user_trade_access',
|
||||||
|
new TableIndex({
|
||||||
|
name: 'idx_user_trade_access_user_id',
|
||||||
|
columnNames: ['user_id'],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.dropTable('freight.user_trade_access', true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A normalizeUserInfo bug in VerifaydaService read the raw `address#en` /
|
||||||
|
* `address#am` claim objects (e.g. `{ "zone#en": "...", "region#en": "...",
|
||||||
|
* "woreda#en": "..." }`) straight through as if they were strings, so any
|
||||||
|
* company verified before the fix has `attributes.ownerAddress` /
|
||||||
|
* `poaAddress` stored as that raw object instead of a formatted string —
|
||||||
|
* which crashes the portal when it tries to render it as text.
|
||||||
|
*
|
||||||
|
* Reformats every affected row's ownerAddress/poaAddress into
|
||||||
|
* "woreda, zone, region" (falling back to whatever #en fields are present,
|
||||||
|
* in that preferred order, then any leftover fields), mirroring
|
||||||
|
* VerifaydaService.formatFaydaAddress. Only touches rows where the field is
|
||||||
|
* still a jsonb object, so it's idempotent and a no-op once repaired.
|
||||||
|
*/
|
||||||
|
export class FixFaydaAddressShape3150000000000 implements MigrationInterface {
|
||||||
|
name = "FixFaydaAddressShape3150000000000";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE OR REPLACE FUNCTION pg_temp.format_fayda_address(addr jsonb)
|
||||||
|
RETURNS text AS $$
|
||||||
|
DECLARE
|
||||||
|
field_order text[] := ARRAY['houseNumber','kebele','woreda','city','subCity','zone','region','postalCode','country'];
|
||||||
|
f text;
|
||||||
|
v text;
|
||||||
|
parts text[] := '{}';
|
||||||
|
used_keys text[] := '{}';
|
||||||
|
kv record;
|
||||||
|
BEGIN
|
||||||
|
IF addr IS NULL OR jsonb_typeof(addr) != 'object' THEN
|
||||||
|
RETURN NULL;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
FOREACH f IN ARRAY field_order LOOP
|
||||||
|
v := addr ->> (f || '#en');
|
||||||
|
IF v IS NOT NULL AND trim(v) != '' THEN
|
||||||
|
parts := array_append(parts, trim(v));
|
||||||
|
used_keys := array_append(used_keys, f || '#en');
|
||||||
|
END IF;
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
FOR kv IN SELECT * FROM jsonb_each_text(addr) LOOP
|
||||||
|
IF kv.key LIKE '%#en' AND NOT (kv.key = ANY(used_keys))
|
||||||
|
AND kv.value IS NOT NULL AND trim(kv.value) != '' THEN
|
||||||
|
parts := array_append(parts, trim(kv.value));
|
||||||
|
END IF;
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
IF array_length(parts, 1) IS NULL THEN
|
||||||
|
RETURN NULL;
|
||||||
|
END IF;
|
||||||
|
RETURN array_to_string(parts, ', ');
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
UPDATE freight.companies
|
||||||
|
SET attributes = jsonb_set(
|
||||||
|
attributes,
|
||||||
|
'{ownerAddress}',
|
||||||
|
to_jsonb(pg_temp.format_fayda_address(attributes -> 'ownerAddress'))
|
||||||
|
)
|
||||||
|
WHERE jsonb_typeof(attributes -> 'ownerAddress') = 'object';
|
||||||
|
|
||||||
|
UPDATE freight.companies
|
||||||
|
SET attributes = jsonb_set(
|
||||||
|
attributes,
|
||||||
|
'{poaAddress}',
|
||||||
|
to_jsonb(pg_temp.format_fayda_address(attributes -> 'poaAddress'))
|
||||||
|
)
|
||||||
|
WHERE jsonb_typeof(attributes -> 'poaAddress') = 'object';
|
||||||
|
|
||||||
|
DROP FUNCTION pg_temp.format_fayda_address(jsonb);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(): Promise<void> {
|
||||||
|
// Data repair — not reversible (the original malformed shape isn't worth restoring).
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `freight.payments.paid_at` was created as `date` (CreatePaymentTable) and never
|
||||||
|
* migrated to `timestamp` alongside its siblings `refunded_at`/`expires_at`
|
||||||
|
* (UpdatePaymentTimestamp). TypeORM's postgres driver hydrates `date` columns as a
|
||||||
|
* plain "YYYY-MM-DD" string, not a `Date` — so `PaymentEntity.paidAt` (typed `Date`)
|
||||||
|
* was actually a string once read back from the DB, and
|
||||||
|
* `intent.paidAt?.toISOString()` in PaymentService.formatIntentStatus threw
|
||||||
|
* `TypeError: intent.paidAt.toISOString is not a function`. This hit every
|
||||||
|
* OTP-confirm response (CAC Bank) because confirmOtp always re-reads the intent
|
||||||
|
* before formatting the response.
|
||||||
|
*/
|
||||||
|
export class FixPaymentPaidAtType3160000000000 implements MigrationInterface {
|
||||||
|
name = "FixPaymentPaidAtType3160000000000";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.payments
|
||||||
|
ALTER COLUMN paid_at TYPE timestamp
|
||||||
|
USING paid_at::timestamp;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.payments
|
||||||
|
ALTER COLUMN paid_at TYPE date
|
||||||
|
USING paid_at::date;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handling a freight type is not the same as handling it in both directions. A
|
||||||
|
* facility can be equipped to load containers onto a train but have no yard
|
||||||
|
* space to receive and stage inbound ones, so the origin and destination sides
|
||||||
|
* are stated independently per freight type.
|
||||||
|
*
|
||||||
|
* Backfilled from `handles_container` / `handles_bulk` so every existing row
|
||||||
|
* keeps its current behaviour: a facility that handles a type today handles it
|
||||||
|
* on both sides until someone narrows it in the backoffice. New rows default
|
||||||
|
* false — an unconfigured facility offers nothing rather than silently
|
||||||
|
* offering everything.
|
||||||
|
*/
|
||||||
|
export class YardFacilityOriginDestination3170000000000 implements MigrationInterface {
|
||||||
|
name = 'YardFacilityOriginDestination3170000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.yard_facilities
|
||||||
|
ADD COLUMN IF NOT EXISTS has_container_facility_origin boolean NOT NULL DEFAULT false,
|
||||||
|
ADD COLUMN IF NOT EXISTS has_bulk_facility_origin boolean NOT NULL DEFAULT false,
|
||||||
|
ADD COLUMN IF NOT EXISTS has_container_facility_destination boolean NOT NULL DEFAULT false,
|
||||||
|
ADD COLUMN IF NOT EXISTS has_bulk_facility_destination boolean NOT NULL DEFAULT false
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.yard_facilities
|
||||||
|
SET has_container_facility_origin = handles_container,
|
||||||
|
has_container_facility_destination = handles_container,
|
||||||
|
has_bulk_facility_origin = handles_bulk,
|
||||||
|
has_bulk_facility_destination = handles_bulk
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.yard_facilities
|
||||||
|
DROP COLUMN IF EXISTS has_container_facility_origin,
|
||||||
|
DROP COLUMN IF EXISTS has_bulk_facility_origin,
|
||||||
|
DROP COLUMN IF EXISTS has_container_facility_destination,
|
||||||
|
DROP COLUMN IF EXISTS has_bulk_facility_destination
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,6 +59,30 @@ export class BackofficeService {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IAM user ids of current employees (any org) holding ANY of the given
|
||||||
|
* permission keys — used by the notification recipients resolver's
|
||||||
|
* `permissionKeys` selector for department/role-scoped targeting.
|
||||||
|
*/
|
||||||
|
async getEmployeeUserIdsByPermission(
|
||||||
|
permissionKeys: string[],
|
||||||
|
): Promise<string[]> {
|
||||||
|
if (!permissionKeys.length) return [];
|
||||||
|
const rows: { userId: string | null }[] = await this.employeeRepository
|
||||||
|
.createQueryBuilder("employee")
|
||||||
|
.innerJoin("employee.employeePositions", "employeePosition")
|
||||||
|
.innerJoin("employeePosition.position", "position")
|
||||||
|
.innerJoin("position.positionPermission", "positionPermission")
|
||||||
|
.innerJoin("positionPermission.permission", "permission")
|
||||||
|
.where("employee.isCurrent = :isCurrent", { isCurrent: true })
|
||||||
|
.andWhere("permission.key IN (:...permissionKeys)", { permissionKeys })
|
||||||
|
.select("DISTINCT employee.user_id", "userId")
|
||||||
|
.getRawMany();
|
||||||
|
return rows
|
||||||
|
.map((r) => r.userId)
|
||||||
|
.filter((id): id is string => Boolean(id));
|
||||||
|
}
|
||||||
|
|
||||||
async createOrganizationUser(
|
async createOrganizationUser(
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
dto: CreateOrganizationUserDto,
|
dto: CreateOrganizationUserDto,
|
||||||
|
|||||||
@@ -9,7 +9,11 @@ import {
|
|||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||||
import type { Response } from "express";
|
import type { Response } from "express";
|
||||||
|
|
||||||
|
import { CurrentUser } from "@edr/api-common";
|
||||||
|
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
|
||||||
|
|
||||||
import { BookingView } from "../../common/booking-guards";
|
import { BookingView } from "../../common/booking-guards";
|
||||||
|
import { UserTradeAccessService } from "../user-trade-access/user-trade-access.service";
|
||||||
import { BillingService } from "./billing.service";
|
import { BillingService } from "./billing.service";
|
||||||
import { FilterInvoiceDto } from "./dto/filter-invoice.dto";
|
import { FilterInvoiceDto } from "./dto/filter-invoice.dto";
|
||||||
|
|
||||||
@@ -18,14 +22,26 @@ import { FilterInvoiceDto } from "./dto/filter-invoice.dto";
|
|||||||
@BookingView()
|
@BookingView()
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
export class BillingController {
|
export class BillingController {
|
||||||
constructor(private readonly billingService: BillingService) {}
|
constructor(
|
||||||
|
private readonly billingService: BillingService,
|
||||||
|
private readonly userTradeAccessService: UserTradeAccessService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Get("invoices")
|
@Get("invoices")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "List invoices (paginated, filterable by company/status/search)",
|
summary: "List invoices (paginated, filterable by company/status/search)",
|
||||||
})
|
})
|
||||||
findAll(@Query() query: FilterInvoiceDto) {
|
async findAll(
|
||||||
return this.billingService.findAllPaginated(query);
|
@Query() query: FilterInvoiceDto,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
|
) {
|
||||||
|
// Per-user trade-direction scope, applied via each invoice's source booking.
|
||||||
|
const allowed =
|
||||||
|
await this.userTradeAccessService.resolveAllowedDirections(user);
|
||||||
|
return this.billingService.findAllPaginated({
|
||||||
|
...query,
|
||||||
|
tradeDirections: allowed ?? undefined,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("invoices/:id")
|
@Get("invoices/:id")
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { TypeOrmModule } from "@nestjs/typeorm";
|
|||||||
import { BillingController } from "./billing.controller";
|
import { BillingController } from "./billing.controller";
|
||||||
import { PortalBillingController } from "./portal-billing.controller";
|
import { PortalBillingController } from "./portal-billing.controller";
|
||||||
import { PaymentController } from "./payment.controller";
|
import { PaymentController } from "./payment.controller";
|
||||||
|
import { UserTradeAccessModule } from "../user-trade-access/user-trade-access.module";
|
||||||
import { BillingService } from "./billing.service";
|
import { BillingService } from "./billing.service";
|
||||||
import { DocumentsModule } from "./documents/documents.module";
|
import { DocumentsModule } from "./documents/documents.module";
|
||||||
import { Invoice } from "./entities/invoice.entity";
|
import { Invoice } from "./entities/invoice.entity";
|
||||||
@@ -19,6 +20,7 @@ import { CompaniesModule } from "../companies/companies.module";
|
|||||||
forwardRef(() => PaymentModule),
|
forwardRef(() => PaymentModule),
|
||||||
CompaniesModule,
|
CompaniesModule,
|
||||||
DocumentsModule,
|
DocumentsModule,
|
||||||
|
UserTradeAccessModule,
|
||||||
],
|
],
|
||||||
controllers: [BillingController, PortalBillingController, PaymentController],
|
controllers: [BillingController, PortalBillingController, PaymentController],
|
||||||
providers: [BillingService, InvoiceRepository, InvoiceLineRepository],
|
providers: [BillingService, InvoiceRepository, InvoiceLineRepository],
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { EventEmitter2 } from "@nestjs/event-emitter";
|
|||||||
import { DataSource, EntityManager, In } from "typeorm";
|
import { DataSource, EntityManager, In } from "typeorm";
|
||||||
|
|
||||||
import { CompaniesService } from "../companies/companies.service";
|
import { CompaniesService } from "../companies/companies.service";
|
||||||
|
import { applyBookingRefDirectionScope } from "../user-trade-access/trade-scope.util";
|
||||||
import { PaymentService } from "../payment/payment.service";
|
import { PaymentService } from "../payment/payment.service";
|
||||||
import { InitiateResponseDto, IntentStatusDto } from "../payment/payments.dto";
|
import { InitiateResponseDto, IntentStatusDto } from "../payment/payments.dto";
|
||||||
import {
|
import {
|
||||||
@@ -167,6 +168,8 @@ export class BillingService {
|
|||||||
search?: string;
|
search?: string;
|
||||||
page?: number;
|
page?: number;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
|
/** Per-user trade-direction scope, applied via the source booking. */
|
||||||
|
tradeDirections?: string[];
|
||||||
} = {},
|
} = {},
|
||||||
): Promise<{ items: Invoice[]; total: number }> {
|
): Promise<{ items: Invoice[]; total: number }> {
|
||||||
const page = filter.page && filter.page > 0 ? filter.page : 1;
|
const page = filter.page && filter.page > 0 ? filter.page : 1;
|
||||||
@@ -196,6 +199,14 @@ export class BillingService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (filter.tradeDirections) {
|
||||||
|
applyBookingRefDirectionScope(
|
||||||
|
qb,
|
||||||
|
"invoice.source_id",
|
||||||
|
filter.tradeDirections,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const [items, total] = await qb.getManyAndCount();
|
const [items, total] = await qb.getManyAndCount();
|
||||||
return { items, total };
|
return { items, total };
|
||||||
}
|
}
|
||||||
@@ -1126,23 +1137,24 @@ export class BillingService {
|
|||||||
.update({ id: invoice.id }, { paymentId: result.intentId });
|
.update({ id: invoice.id }, { paymentId: result.intentId });
|
||||||
|
|
||||||
// Settlement is driven by the payment API (webhook/outbox → payment.succeeded);
|
// Settlement is driven by the payment API (webhook/outbox → payment.succeeded);
|
||||||
// billing must not simulate it. Kept commented for local demos only.
|
// billing must not simulate it. Kept for local demos only.
|
||||||
// An OTP intent (CAC Bank) is NOT paid yet — the payer still has to enter the
|
// An OTP intent (CAC Bank) is NOT paid yet — the payer still has to enter the
|
||||||
// code — so the demo shortcut must never fire for it.
|
// code — so the demo shortcut must never fire for it. Same for CBE_BILL: its
|
||||||
if (
|
// bill must stay open until CBE actually settles it via /cbe/payment.
|
||||||
!result.immediateSuccess &&
|
// if (
|
||||||
result.response.clientAction?.type !== "COLLECT_OTP" &&
|
// !result.immediateSuccess &&
|
||||||
opts.method !== "CBE_BILL"
|
// result.response.clientAction?.type !== "COLLECT_OTP" &&
|
||||||
) {
|
// opts.method !== "CBE_BILL"
|
||||||
await this.payment.handlePaymentEvent({
|
// ) {
|
||||||
eventType: "payment.succeeded",
|
// await this.payment.handlePaymentEvent({
|
||||||
eventId: `demo-${result.intentId}`,
|
// eventType: "payment.succeeded",
|
||||||
referenceId: invoice.sourceId,
|
// eventId: `demo-${result.intentId}`,
|
||||||
intentId: result.intentId,
|
// referenceId: invoice.sourceId,
|
||||||
providerTxnId: result.providerTxnId,
|
// intentId: result.intentId,
|
||||||
paidAt: (result.paidAt ?? new Date()).toISOString(),
|
// providerTxnId: result.providerTxnId,
|
||||||
});
|
// paidAt: (result.paidAt ?? new Date()).toISOString(),
|
||||||
}
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
if (result.immediateSuccess) {
|
if (result.immediateSuccess) {
|
||||||
await this.settleByPaymentId(
|
await this.settleByPaymentId(
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
IYardsRepository,
|
IYardsRepository,
|
||||||
YARDS_REPOSITORY,
|
YARDS_REPOSITORY,
|
||||||
} from "../rule-engine/interfaces/yards.repository.interface";
|
} from "../rule-engine/interfaces/yards.repository.interface";
|
||||||
|
import { YardFacilitiesService } from "../rule-engine/services/yard-facilities.service";
|
||||||
import {
|
import {
|
||||||
BookingReferenceCargoTypeChildDto,
|
BookingReferenceCargoTypeChildDto,
|
||||||
BookingReferenceCargoTypeGroupDto,
|
BookingReferenceCargoTypeGroupDto,
|
||||||
@@ -170,11 +171,18 @@ export class BookingReferenceDataService {
|
|||||||
private readonly shippingLinesRepository: IShippingLinesRepository,
|
private readonly shippingLinesRepository: IShippingLinesRepository,
|
||||||
@Inject(CARGO_TYPES_REPOSITORY)
|
@Inject(CARGO_TYPES_REPOSITORY)
|
||||||
private readonly cargoTypesRepository: ICargoTypesRepository,
|
private readonly cargoTypesRepository: ICargoTypesRepository,
|
||||||
|
private readonly yardFacilitiesService: YardFacilitiesService,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
async getReferenceData(): Promise<BookingReferenceDataDto> {
|
async getReferenceData(): Promise<BookingReferenceDataDto> {
|
||||||
const [yards, containerTypes, serviceTypes, shippingLines, cargoTypes] =
|
const [
|
||||||
await Promise.all([
|
yards,
|
||||||
|
containerTypes,
|
||||||
|
serviceTypes,
|
||||||
|
shippingLines,
|
||||||
|
cargoTypes,
|
||||||
|
facilityYards,
|
||||||
|
] = await Promise.all([
|
||||||
this.yardsRepository.findAll({
|
this.yardsRepository.findAll({
|
||||||
where: { isActive: true },
|
where: { isActive: true },
|
||||||
order: { displayOrder: "ASC", code: "ASC" },
|
order: { displayOrder: "ASC", code: "ASC" },
|
||||||
@@ -195,17 +203,30 @@ export class BookingReferenceDataService {
|
|||||||
where: { isActive: true },
|
where: { isActive: true },
|
||||||
order: { displayOrder: "ASC", code: "ASC" },
|
order: { displayOrder: "ASC", code: "ASC" },
|
||||||
}),
|
}),
|
||||||
|
this.yardFacilitiesService.listFacilityYards(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Every active yard is still listed; a yard with no facility record simply
|
||||||
|
// reports no capability, so the forms drop it from the pickers themselves.
|
||||||
|
const facilityByYardId = new Map(facilityYards.map((f) => [f.yardId, f]));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
yard: yards.map(
|
yard: yards.map((y): BookingReferenceYardDto => {
|
||||||
(y): BookingReferenceYardDto => ({
|
const facility = facilityByYardId.get(y.id);
|
||||||
|
return {
|
||||||
id: y.id,
|
id: y.id,
|
||||||
name: y.label,
|
name: y.label,
|
||||||
code: y.code,
|
code: y.code,
|
||||||
country: y.country,
|
country: y.country,
|
||||||
}),
|
hasContainerFacilityOrigin:
|
||||||
),
|
facility?.hasContainerFacilityOrigin ?? false,
|
||||||
|
hasBulkFacilityOrigin: facility?.hasBulkFacilityOrigin ?? false,
|
||||||
|
hasContainerFacilityDestination:
|
||||||
|
facility?.hasContainerFacilityDestination ?? false,
|
||||||
|
hasBulkFacilityDestination:
|
||||||
|
facility?.hasBulkFacilityDestination ?? false,
|
||||||
|
};
|
||||||
|
}),
|
||||||
containers: groupContainersBySize(containerTypes),
|
containers: groupContainersBySize(containerTypes),
|
||||||
service: serviceTypes.map(
|
service: serviceTypes.map(
|
||||||
(s): BookingReferenceServiceDto => ({
|
(s): BookingReferenceServiceDto => ({
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ import {
|
|||||||
RoAmendmentDto,
|
RoAmendmentDto,
|
||||||
} from '../contracts/dto/phased-clearance.dto';
|
} from '../contracts/dto/phased-clearance.dto';
|
||||||
import { BookingReferenceDataService } from './booking-reference-data.service';
|
import { BookingReferenceDataService } from './booking-reference-data.service';
|
||||||
|
import { scopedDirections } from '../user-trade-access/trade-scope.util';
|
||||||
|
import { UserTradeAccessService } from '../user-trade-access/user-trade-access.service';
|
||||||
import { BookingsService } from './bookings.service';
|
import { BookingsService } from './bookings.service';
|
||||||
import { BookingReferenceDataDto } from './dto/booking-reference-data.dto';
|
import { BookingReferenceDataDto } from './dto/booking-reference-data.dto';
|
||||||
import { CreateBookingDto } from './dto/create-booking.dto';
|
import { CreateBookingDto } from './dto/create-booking.dto';
|
||||||
@@ -150,6 +152,7 @@ export class BookingsController {
|
|||||||
private readonly containerReceiptService: ContainerReceiptService,
|
private readonly containerReceiptService: ContainerReceiptService,
|
||||||
private readonly firstMileService: FirstMileService,
|
private readonly firstMileService: FirstMileService,
|
||||||
private readonly lastMileService: LastMileService,
|
private readonly lastMileService: LastMileService,
|
||||||
|
private readonly userTradeAccessService: UserTradeAccessService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@@ -217,7 +220,16 @@ export class BookingsController {
|
|||||||
// Staff (backoffice) see every booking. Customers (portal) are always
|
// Staff (backoffice) see every booking. Customers (portal) are always
|
||||||
// force-scoped to their own company, regardless of any companyId they pass.
|
// force-scoped to their own company, regardless of any companyId they pass.
|
||||||
if (hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
if (hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||||
return this.bookingsService.findAll(filter);
|
// Per-user trade-direction scope (import/export/intercity checkboxes).
|
||||||
|
const allowed =
|
||||||
|
await this.userTradeAccessService.resolveAllowedDirections(user);
|
||||||
|
const dirs = scopedDirections(allowed, filter.tradeDirection);
|
||||||
|
return this.bookingsService.findAll(
|
||||||
|
filter,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
dirs ?? undefined,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
// Global Logistics has clearance:view but NOT bookings:view — it is scoped
|
// Global Logistics has clearance:view but NOT bookings:view — it is scoped
|
||||||
// to the customs document-clearance queue only and never sees the general
|
// to the customs document-clearance queue only and never sees the general
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Module, forwardRef } from "@nestjs/common";
|
import { Module, forwardRef } from "@nestjs/common";
|
||||||
|
import { UserTradeAccessModule } from "../user-trade-access/user-trade-access.module";
|
||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||||
import { ExchangeModule, ExchangeOptions } from "@edr/api-common";
|
import { ExchangeModule, ExchangeOptions } from "@edr/api-common";
|
||||||
@@ -80,6 +81,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
|||||||
MinioModule,
|
MinioModule,
|
||||||
VehiclesModule,
|
VehiclesModule,
|
||||||
CompaniesModule,
|
CompaniesModule,
|
||||||
|
UserTradeAccessModule,
|
||||||
// CustomersModule,
|
// CustomersModule,
|
||||||
RuleEngineModule,
|
RuleEngineModule,
|
||||||
FileUploadSettingsModule,
|
FileUploadSettingsModule,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
|||||||
import { Contract } from '../contracts/entities/contract.entity';
|
import { Contract } from '../contracts/entities/contract.entity';
|
||||||
import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity';
|
import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity';
|
||||||
import { ContractRoute } from '../contracts/entities/contract-route.entity';
|
import { ContractRoute } from '../contracts/entities/contract-route.entity';
|
||||||
|
import { applyDirectionScope } from '../user-trade-access/trade-scope.util';
|
||||||
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
|
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
|
||||||
import {
|
import {
|
||||||
BookingDocumentReview,
|
BookingDocumentReview,
|
||||||
@@ -64,6 +65,8 @@ export interface BookingListFilterOptions {
|
|||||||
freightType?: string;
|
freightType?: string;
|
||||||
bookingType?: string;
|
bookingType?: string;
|
||||||
tradeDirection?: string;
|
tradeDirection?: string;
|
||||||
|
/** Per-user trade-direction scope — `[]` matches nothing. */
|
||||||
|
tradeDirections?: string[];
|
||||||
paymentCurrency?: string;
|
paymentCurrency?: string;
|
||||||
paymentStatus?: string;
|
paymentStatus?: string;
|
||||||
excludePaymentStatus?: string;
|
excludePaymentStatus?: string;
|
||||||
@@ -1000,6 +1003,9 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
tradeDirection: options.tradeDirection,
|
tradeDirection: options.tradeDirection,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (options.tradeDirections) {
|
||||||
|
applyDirectionScope(qb, 'booking.trade_direction', options.tradeDirections);
|
||||||
|
}
|
||||||
if (options.paymentCurrency) {
|
if (options.paymentCurrency) {
|
||||||
qb.andWhere('booking.payment_currency = :paymentCurrency', {
|
qb.andWhere('booking.payment_currency = :paymentCurrency', {
|
||||||
paymentCurrency: options.paymentCurrency,
|
paymentCurrency: options.paymentCurrency,
|
||||||
@@ -1289,6 +1295,38 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
.getMany();
|
.getMany();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EXPIRED bookings on the day's corridor — the batch board's expired lane.
|
||||||
|
* Expiry nulls train_schedule_id, so neither findAllBySchedule nor the
|
||||||
|
* ready-pool query can ever see them.
|
||||||
|
*/
|
||||||
|
findExpiredByCorridorDay(
|
||||||
|
corridorYardIds: string[],
|
||||||
|
day: string,
|
||||||
|
): Promise<Booking[]> {
|
||||||
|
if (corridorYardIds.length === 0) return Promise.resolve([]);
|
||||||
|
return this.repository
|
||||||
|
.createQueryBuilder('booking')
|
||||||
|
.leftJoinAndSelect('booking.company', 'company')
|
||||||
|
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
||||||
|
.leftJoinAndSelect('bookingContainer.containerType', 'containerType')
|
||||||
|
.leftJoinAndSelect('booking.cargoType', 'cargoType')
|
||||||
|
.leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id')
|
||||||
|
.where('booking.origin_yard_id IN (:...corridorYardIds)', { corridorYardIds })
|
||||||
|
.andWhere('booking.destination_yard_id IN (:...corridorYardIds)', {
|
||||||
|
corridorYardIds,
|
||||||
|
})
|
||||||
|
.andWhere(
|
||||||
|
`DATE(booking.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = :day`,
|
||||||
|
{ day },
|
||||||
|
)
|
||||||
|
.andWhere('sb.id IS NULL')
|
||||||
|
.andWhere(`booking.status = 'EXPIRED'`)
|
||||||
|
.orderBy('booking.priority_score', 'DESC')
|
||||||
|
.addOrderBy('booking.created_at', 'ASC')
|
||||||
|
.getMany();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Commercial bookings on the day's corridor whose operation request was NOT
|
* Commercial bookings on the day's corridor whose operation request was NOT
|
||||||
* accepted by staff (still pending / changes / price-confirm) and are not yet
|
* accepted by staff (still pending / changes / price-confirm) and are not yet
|
||||||
|
|||||||
@@ -1619,6 +1619,7 @@ export class BookingsService {
|
|||||||
filter: FilterBookingDto,
|
filter: FilterBookingDto,
|
||||||
forceCompanyId?: string,
|
forceCompanyId?: string,
|
||||||
forceCompanyProfileId?: string,
|
forceCompanyProfileId?: string,
|
||||||
|
tradeDirections?: string[],
|
||||||
): Promise<PaginatedBookings> {
|
): Promise<PaginatedBookings> {
|
||||||
const page = filter.page ?? 1;
|
const page = filter.page ?? 1;
|
||||||
const pageSize = filter.pageSize ?? 20;
|
const pageSize = filter.pageSize ?? 20;
|
||||||
@@ -1638,6 +1639,7 @@ export class BookingsService {
|
|||||||
// ANDs both, so cross-company access is impossible.
|
// ANDs both, so cross-company access is impossible.
|
||||||
companyId: forceCompanyId ?? filter.companyId,
|
companyId: forceCompanyId ?? filter.companyId,
|
||||||
companyProfileId: forceCompanyProfileId ?? filter.companyProfileId,
|
companyProfileId: forceCompanyProfileId ?? filter.companyProfileId,
|
||||||
|
tradeDirections,
|
||||||
contractType: filter.contractType,
|
contractType: filter.contractType,
|
||||||
serviceTypeId: filter.serviceTypeId,
|
serviceTypeId: filter.serviceTypeId,
|
||||||
cargoTypeId: filter.cargoTypeId,
|
cargoTypeId: filter.cargoTypeId,
|
||||||
|
|||||||
@@ -13,6 +13,18 @@ export class BookingReferenceYardDto {
|
|||||||
|
|
||||||
@ApiProperty({ example: 'Ethiopia' })
|
@ApiProperty({ example: 'Ethiopia' })
|
||||||
country!: string;
|
country!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Can load containers onto a train here.' })
|
||||||
|
hasContainerFacilityOrigin!: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Can load bulk cargo onto a train here.' })
|
||||||
|
hasBulkFacilityOrigin!: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Can receive containers off a train here.' })
|
||||||
|
hasContainerFacilityDestination!: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Can receive bulk cargo off a train here.' })
|
||||||
|
hasBulkFacilityDestination!: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class BookingReferenceContainerTypeDto {
|
export class BookingReferenceContainerTypeDto {
|
||||||
|
|||||||
@@ -928,7 +928,7 @@ export class CompaniesService {
|
|||||||
): Promise<ProfileResponseDto> {
|
): Promise<ProfileResponseDto> {
|
||||||
const { profile, company } = await this.getCompanyInfoByUserId(userId);
|
const { profile, company } = await this.getCompanyInfoByUserId(userId);
|
||||||
|
|
||||||
await this.assertEtradeFieldsAuthentic(company, dto);
|
await this.applyEtradeSourcedFields(company, dto);
|
||||||
|
|
||||||
// Naming (or renaming) a Power of Attorney is one of the writes that can
|
// 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
|
// leave the company with a representative and nothing evidencing them, so
|
||||||
@@ -3216,22 +3216,24 @@ export class CompaniesService {
|
|||||||
/**
|
/**
|
||||||
* An eTrade-sourced field can only ever hold what a fresh eTrade lookup for
|
* 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
|
* this TIN actually returns — the portal never lets the customer type these
|
||||||
* once eTrade has supplied them, so a mismatch here means either stale
|
* once eTrade has supplied them. Rather than trust the client's copy (stale
|
||||||
* client state or a hand-crafted request, and either way the write is
|
* cache, hand-crafted request, or just a formatting mismatch) and reject it,
|
||||||
* refused rather than silently trusting it.
|
* refetch eTrade ourselves and overwrite the touched fields with whatever it
|
||||||
|
* says now — the client's submitted values for these keys only matter as a
|
||||||
|
* "this field is part of the save" flag, never as data we persist.
|
||||||
*/
|
*/
|
||||||
private async assertEtradeFieldsAuthentic(
|
private async applyEtradeSourcedFields(
|
||||||
company: Company,
|
company: Company,
|
||||||
dto: UpdateProfileDto,
|
dto: UpdateProfileDto,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const touched = ETRADE_SOURCED_FIELDS.some(
|
const touched = ETRADE_SOURCED_FIELDS.some(
|
||||||
(key) => dto[key] !== undefined,
|
(key) => key !== "tin" && dto[key] !== undefined,
|
||||||
);
|
);
|
||||||
if (!touched) return;
|
if (!touched) return;
|
||||||
|
|
||||||
const tin = dto.tin ?? company.tin;
|
const tin = dto.tin ?? company.tin;
|
||||||
const registration = await this.resolveEtradeRegistration(tin);
|
const registration = await this.resolveEtradeRegistration(tin);
|
||||||
const expected: Partial<Record<(typeof ETRADE_SOURCED_FIELDS)[number], string>> = {
|
const fresh: Partial<Record<(typeof ETRADE_SOURCED_FIELDS)[number], string>> = {
|
||||||
companyName: registration.companyName,
|
companyName: registration.companyName,
|
||||||
licenceNumber: registration.licenceNumber,
|
licenceNumber: registration.licenceNumber,
|
||||||
statusDescription: registration.statusDescription,
|
statusDescription: registration.statusDescription,
|
||||||
@@ -3251,21 +3253,11 @@ export class CompaniesService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
for (const key of ETRADE_SOURCED_FIELDS) {
|
for (const key of ETRADE_SOURCED_FIELDS) {
|
||||||
const submitted = dto[key];
|
if (key === "tin" || dto[key] === undefined) continue;
|
||||||
if (submitted === undefined) continue;
|
const value = fresh[key];
|
||||||
const source = expected[key];
|
// eTrade left this field blank — fall back to whatever the client sent
|
||||||
// eTrade left this field blank — the onboarding/settings card falls back
|
// (the onboarding/settings card lets the customer type it directly then).
|
||||||
// to letting the customer type it directly, so nothing to check against.
|
if (value) (dto as Record<string, unknown>)[key] = value;
|
||||||
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.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -179,9 +179,12 @@ export class UpdateProfileDto {
|
|||||||
@MaxLength(100)
|
@MaxLength(100)
|
||||||
houseNo?: string;
|
houseNo?: string;
|
||||||
|
|
||||||
|
// Not validated as a phone number: eTrade-sourced, so a fresh eTrade lookup
|
||||||
|
// overwrites whatever the client sends here — see
|
||||||
|
// CompaniesService.applyEtradeSourcedFields. Presence just flags "this save
|
||||||
|
// touches an eTrade-owned field."
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(20)
|
@MaxLength(20)
|
||||||
@IsValidPhone()
|
|
||||||
etradePhone?: string;
|
etradePhone?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,7 +108,9 @@ export class ETradeService {
|
|||||||
dateRegistered: businessInfo.DateRegistered,
|
dateRegistered: businessInfo.DateRegistered,
|
||||||
renewedFrom: businessInfo.RenewedFrom,
|
renewedFrom: businessInfo.RenewedFrom,
|
||||||
renewalDate: businessInfo.RenewalDate,
|
renewalDate: businessInfo.RenewalDate,
|
||||||
renewedTo: businessInfo.RenewedTo,
|
// RenewedTo is ISO ("2018-07-07T00:00:00"); RenewedToDateString matches
|
||||||
|
// RenewedFrom/RenewalDate's "M/D/YYYY" format — use that for consistency.
|
||||||
|
renewedTo: businessInfo.RenewedToDateString,
|
||||||
// eTrade returns uncoded uppercase text and sometimes a zone name in the
|
// eTrade returns uncoded uppercase text and sometimes a zone name in the
|
||||||
// Region slot. Map it onto the canonical list; an unresolved value yields
|
// Region slot. Map it onto the canonical list; an unresolved value yields
|
||||||
// "" so the form asks the user to pick rather than failing validation on
|
// "" so the form asks the user to pick rather than failing validation on
|
||||||
|
|||||||
@@ -290,6 +290,7 @@ export class ContractBookingService {
|
|||||||
isHazardous: this.resolveShipmentHandlingFlag(contract, dto, 'hazardousQuantity'),
|
isHazardous: this.resolveShipmentHandlingFlag(contract, dto, 'hazardousQuantity'),
|
||||||
isReefer: this.resolveShipmentHandlingFlag(contract, dto, 'reeferQuantity'),
|
isReefer: this.resolveShipmentHandlingFlag(contract, dto, 'reeferQuantity'),
|
||||||
cargoTotalWeightVgm: this.resolveBulkTons(dto),
|
cargoTotalWeightVgm: this.resolveBulkTons(dto),
|
||||||
|
bulkTotalWeightTons: this.resolveBulkWeightTons(dto),
|
||||||
firstMilePickupAddress: contract.firstMilePickupAddress ?? null,
|
firstMilePickupAddress: contract.firstMilePickupAddress ?? null,
|
||||||
firstMilePickupLat: contract.firstMilePickupLat ?? null,
|
firstMilePickupLat: contract.firstMilePickupLat ?? null,
|
||||||
firstMilePickupLng: contract.firstMilePickupLng ?? null,
|
firstMilePickupLng: contract.firstMilePickupLng ?? null,
|
||||||
@@ -773,6 +774,7 @@ export class ContractBookingService {
|
|||||||
cargoTypeId: this.resolveCargoTypeId(contract, dto),
|
cargoTypeId: this.resolveCargoTypeId(contract, dto),
|
||||||
cargoFreeText: dto.cargoFreeText?.trim() || null,
|
cargoFreeText: dto.cargoFreeText?.trim() || null,
|
||||||
cargoTotalWeightVgm: this.resolveBulkTons(dto),
|
cargoTotalWeightVgm: this.resolveBulkTons(dto),
|
||||||
|
bulkTotalWeightTons: this.resolveBulkWeightTons(dto),
|
||||||
equipmentReturn: this.resolveShipmentEquipmentReturn(contract, dto),
|
equipmentReturn: this.resolveShipmentEquipmentReturn(contract, dto),
|
||||||
// Completion is where the cargo — and therefore the price — is fixed, so
|
// Completion is where the cargo — and therefore the price — is fixed, so
|
||||||
// it is also where the billing currency is chosen. A bare instance was
|
// it is also where the billing currency is chosen. A bare instance was
|
||||||
@@ -1253,6 +1255,7 @@ export class ContractBookingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
probe.cargoTotalWeightVgm = this.resolveBulkTons(dto);
|
probe.cargoTotalWeightVgm = this.resolveBulkTons(dto);
|
||||||
|
probe.bulkTotalWeightTons = this.resolveBulkWeightTons(dto);
|
||||||
const cargoTypeId = this.resolveCargoTypeId(contract, dto);
|
const cargoTypeId = this.resolveCargoTypeId(contract, dto);
|
||||||
probe.cargoTypeId = cargoTypeId;
|
probe.cargoTypeId = cargoTypeId;
|
||||||
if (cargoTypeId) {
|
if (cargoTypeId) {
|
||||||
@@ -1297,11 +1300,7 @@ export class ContractBookingService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const requested =
|
const requested = this.resolveBulkTons(dto);
|
||||||
(dto.bulkLines ?? []).reduce(
|
|
||||||
(sum, b) => sum + (b.cargoWeightTons ?? b.itemCount ?? 0),
|
|
||||||
0,
|
|
||||||
) || this.resolveBulkTons(dto) || 0;
|
|
||||||
const remaining = outstanding.bulk?.outstanding ?? 0;
|
const remaining = outstanding.bulk?.outstanding ?? 0;
|
||||||
// 0.001 t tolerance absorbs the 3-decimal rounding applied to split weights.
|
// 0.001 t tolerance absorbs the 3-decimal rounding applied to split weights.
|
||||||
if (Math.abs(requested - remaining) > 0.001) {
|
if (Math.abs(requested - remaining) > 0.001) {
|
||||||
@@ -1344,8 +1343,10 @@ export class ContractBookingService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// PER_ITEM contracts are capped in items, so the item count is the
|
||||||
|
// consumption figure — tonnage is only wagon-sizing data.
|
||||||
const requested =
|
const requested =
|
||||||
(lines.bulk?.cargoWeightTons ?? lines.bulk?.itemCount ?? 0) || 0;
|
Number(lines.bulk?.itemCount ?? lines.bulk?.cargoWeightTons ?? 0) || 0;
|
||||||
const cap = capacity.find((c) => c.cap != null);
|
const cap = capacity.find((c) => c.cap != null);
|
||||||
if (cap && cap.remaining != null && requested > cap.remaining) {
|
if (cap && cap.remaining != null && requested > cap.remaining) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
@@ -1373,11 +1374,7 @@ export class ContractBookingService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const requested =
|
const requested = this.resolveBulkTons(dto);
|
||||||
(dto.bulkLines ?? []).reduce(
|
|
||||||
(sum, b) => sum + (b.cargoWeightTons ?? b.itemCount ?? 0),
|
|
||||||
0,
|
|
||||||
) || this.resolveBulkTons(dto) || 0;
|
|
||||||
const cap = capacity.find((c) => c.cap != null);
|
const cap = capacity.find((c) => c.cap != null);
|
||||||
if (cap && cap.remaining != null && requested > cap.remaining) {
|
if (cap && cap.remaining != null && requested > cap.remaining) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
@@ -1641,11 +1638,27 @@ export class ContractBookingService {
|
|||||||
private resolveBulkTons(dto: CreateBookingUnderContractDto): number {
|
private resolveBulkTons(dto: CreateBookingUnderContractDto): number {
|
||||||
if (!dto.bulkLines?.length) return 0;
|
if (!dto.bulkLines?.length) return 0;
|
||||||
return dto.bulkLines.reduce(
|
return dto.bulkLines.reduce(
|
||||||
(sum, l) => sum + Number(l.cargoWeightTons ?? l.itemCount ?? 0),
|
(sum, l) => sum + Number(l.itemCount ?? l.cargoWeightTons ?? 0),
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Real tonnage of a PER_ITEM (break-bulk) booking, kept alongside the item
|
||||||
|
* count `cargoTotalWeightVgm` holds. Both are needed: the item count prices
|
||||||
|
* the booking, the tonnage sizes the wagons (`bulkItemWagonsRequired` derives
|
||||||
|
* per-item weight from tonnage ÷ items). Null for PER_TON bulk, where
|
||||||
|
* `cargoTotalWeightVgm` already IS the tonnage.
|
||||||
|
*/
|
||||||
|
private resolveBulkWeightTons(
|
||||||
|
dto: CreateBookingUnderContractDto,
|
||||||
|
): number | null {
|
||||||
|
const lines = dto.bulkLines ?? [];
|
||||||
|
if (!lines.some((l) => Number(l.itemCount) > 0)) return null;
|
||||||
|
const tons = lines.reduce((sum, l) => sum + Number(l.cargoWeightTons ?? 0), 0);
|
||||||
|
return tons > 0 ? tons : null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-line handling counts. Each physical container carries its own hazardous
|
* Per-line handling counts. Each physical container carries its own hazardous
|
||||||
* / reefer / return switch (entered next to its VGM), so the count is however
|
* / reefer / return switch (entered next to its VGM), so the count is however
|
||||||
@@ -1961,6 +1974,7 @@ export class ContractBookingService {
|
|||||||
originYardId: route?.originYardId ?? null,
|
originYardId: route?.originYardId ?? null,
|
||||||
destinationYardId: route?.destinationYardId ?? null,
|
destinationYardId: route?.destinationYardId ?? null,
|
||||||
cargoTotalWeightVgm: this.resolveBulkTons(dto),
|
cargoTotalWeightVgm: this.resolveBulkTons(dto),
|
||||||
|
bulkTotalWeightTons: this.resolveBulkWeightTons(dto),
|
||||||
firstMilePickupAddress: contract.firstMilePickupAddress ?? null,
|
firstMilePickupAddress: contract.firstMilePickupAddress ?? null,
|
||||||
lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null,
|
lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null,
|
||||||
bookingContainers: resolved.map(({ line, ct, totalVgmTons }) =>
|
bookingContainers: resolved.map(({ line, ct, totalVgmTons }) =>
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ import { GlOperationsService } from './gl-operations.service';
|
|||||||
import { BookingRequestService } from './booking-request.service';
|
import { BookingRequestService } from './booking-request.service';
|
||||||
import { SignaturesService } from '../signatures/signatures.service';
|
import { SignaturesService } from '../signatures/signatures.service';
|
||||||
import { BookingsService } from '../bookings/bookings.service';
|
import { BookingsService } from '../bookings/bookings.service';
|
||||||
|
import { scopedDirections } from '../user-trade-access/trade-scope.util';
|
||||||
|
import { UserTradeAccessService } from '../user-trade-access/user-trade-access.service';
|
||||||
import { CreateContractDto } from './dto/create-contract.dto';
|
import { CreateContractDto } from './dto/create-contract.dto';
|
||||||
import { UpdateContractDto } from './dto/update-contract.dto';
|
import { UpdateContractDto } from './dto/update-contract.dto';
|
||||||
import { FilterContractDto } from './dto/filter-contract.dto';
|
import { FilterContractDto } from './dto/filter-contract.dto';
|
||||||
@@ -108,6 +110,7 @@ export class ContractsController {
|
|||||||
private readonly glOperationsService: GlOperationsService,
|
private readonly glOperationsService: GlOperationsService,
|
||||||
private readonly bookingRequestService: BookingRequestService,
|
private readonly bookingRequestService: BookingRequestService,
|
||||||
private readonly signaturesService: SignaturesService,
|
private readonly signaturesService: SignaturesService,
|
||||||
|
private readonly userTradeAccessService: UserTradeAccessService,
|
||||||
private readonly bookingClearanceService: BookingClearanceService,
|
private readonly bookingClearanceService: BookingClearanceService,
|
||||||
private readonly bookingsService: BookingsService,
|
private readonly bookingsService: BookingsService,
|
||||||
) {}
|
) {}
|
||||||
@@ -210,7 +213,16 @@ export class ContractsController {
|
|||||||
hasFreightPermission(user, FREIGHT_PERMS.bookings.view) ||
|
hasFreightPermission(user, FREIGHT_PERMS.bookings.view) ||
|
||||||
hasFreightPermission(user, FREIGHT_PERMS.contracts.view)
|
hasFreightPermission(user, FREIGHT_PERMS.contracts.view)
|
||||||
) {
|
) {
|
||||||
return this.contractsService.findAll(filter);
|
// Per-user trade-direction scope (import/export/intercity checkboxes).
|
||||||
|
const allowed =
|
||||||
|
await this.userTradeAccessService.resolveAllowedDirections(user);
|
||||||
|
const dirs = scopedDirections(allowed, filter.tradeDirection);
|
||||||
|
return this.contractsService.findAll(
|
||||||
|
filter,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
dirs ?? undefined,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const userId = user?.id;
|
const userId = user?.id;
|
||||||
if (!userId) throw new UnauthorizedException('Authentication required');
|
if (!userId) throw new UnauthorizedException('Authentication required');
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Module, forwardRef } from '@nestjs/common';
|
import { Module, forwardRef } from '@nestjs/common';
|
||||||
|
import { UserTradeAccessModule } from '../user-trade-access/user-trade-access.module';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { ExchangeModule, ExchangeOptions } from '@edr/api-common';
|
import { ExchangeModule, ExchangeOptions } from '@edr/api-common';
|
||||||
@@ -89,6 +90,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
|
|||||||
NotificationsModule,
|
NotificationsModule,
|
||||||
NotificationInboxModule,
|
NotificationInboxModule,
|
||||||
CompaniesModule,
|
CompaniesModule,
|
||||||
|
UserTradeAccessModule,
|
||||||
// Provides the admin-editable contract document templates consumed by
|
// Provides the admin-editable contract document templates consumed by
|
||||||
// ContractDocumentViewModelBuilder when rendering contract PDFs.
|
// ContractDocumentViewModelBuilder when rendering contract PDFs.
|
||||||
ContractTemplatesModule,
|
ContractTemplatesModule,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { DataSource, In, IsNull, Repository, SelectQueryBuilder } from 'typeorm'
|
|||||||
|
|
||||||
import { Booking } from '../bookings/entities/booking.entity';
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
import { FileRecord } from '../files/entities/file.entity';
|
import { FileRecord } from '../files/entities/file.entity';
|
||||||
|
import { applyDirectionScope } from '../user-trade-access/trade-scope.util';
|
||||||
import { Contract } from './entities/contract.entity';
|
import { Contract } from './entities/contract.entity';
|
||||||
import { ContractApprovalStep } from './entities/contract-approval-step.entity';
|
import { ContractApprovalStep } from './entities/contract-approval-step.entity';
|
||||||
import { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity';
|
import { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity';
|
||||||
@@ -38,6 +39,8 @@ export interface ContractListFilterOptions {
|
|||||||
serviceTypeId?: string;
|
serviceTypeId?: string;
|
||||||
freightType?: string;
|
freightType?: string;
|
||||||
tradeDirection?: string;
|
tradeDirection?: string;
|
||||||
|
/** Per-user trade-direction scope — `[]` matches nothing. */
|
||||||
|
tradeDirections?: string[];
|
||||||
paymentCurrency?: string;
|
paymentCurrency?: string;
|
||||||
customsClearingEnabled?: boolean;
|
customsClearingEnabled?: boolean;
|
||||||
/** true → only contracts with at least one uploaded clearance document. */
|
/** true → only contracts with at least one uploaded clearance document. */
|
||||||
@@ -436,6 +439,9 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
|||||||
tradeDirection: options.tradeDirection,
|
tradeDirection: options.tradeDirection,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (options.tradeDirections) {
|
||||||
|
applyDirectionScope(qb, 'contract.trade_direction', options.tradeDirections);
|
||||||
|
}
|
||||||
if (options.paymentCurrency) {
|
if (options.paymentCurrency) {
|
||||||
qb.andWhere('contract.payment_currency = :paymentCurrency', {
|
qb.andWhere('contract.payment_currency = :paymentCurrency', {
|
||||||
paymentCurrency: options.paymentCurrency,
|
paymentCurrency: options.paymentCurrency,
|
||||||
|
|||||||
@@ -748,6 +748,7 @@ export class ContractsService {
|
|||||||
filter: FilterContractDto,
|
filter: FilterContractDto,
|
||||||
forceCompanyId?: string,
|
forceCompanyId?: string,
|
||||||
forceCompanyProfileId?: string,
|
forceCompanyProfileId?: string,
|
||||||
|
tradeDirections?: string[],
|
||||||
): Promise<PaginatedContracts> {
|
): Promise<PaginatedContracts> {
|
||||||
const page = filter.page ?? 1;
|
const page = filter.page ?? 1;
|
||||||
const pageSize = filter.pageSize ?? 20;
|
const pageSize = filter.pageSize ?? 20;
|
||||||
@@ -763,6 +764,7 @@ export class ContractsService {
|
|||||||
serviceTypeId: filter.serviceTypeId,
|
serviceTypeId: filter.serviceTypeId,
|
||||||
freightType: filter.freightType,
|
freightType: filter.freightType,
|
||||||
tradeDirection: filter.tradeDirection,
|
tradeDirection: filter.tradeDirection,
|
||||||
|
tradeDirections,
|
||||||
paymentCurrency: filter.paymentCurrency,
|
paymentCurrency: filter.paymentCurrency,
|
||||||
createdFrom: filter.createdFrom,
|
createdFrom: filter.createdFrom,
|
||||||
createdTo: filter.createdTo,
|
createdTo: filter.createdTo,
|
||||||
|
|||||||
@@ -13,9 +13,8 @@ import { ExternalProfileRepository } from "../companies/external-profile.reposit
|
|||||||
* - `companyId` → all portal users linked to the company (external_profiles).
|
* - `companyId` → all portal users linked to the company (external_profiles).
|
||||||
* - `companyProfileId` → resolved to its company, then to that company's users.
|
* - `companyProfileId` → resolved to its company, then to that company's users.
|
||||||
* - `organizationId` → all current employees of the org (backoffice staff).
|
* - `organizationId` → all current employees of the org (backoffice staff).
|
||||||
*
|
* - `permissionKeys` → current employees (any org) holding any of these
|
||||||
* NOTE: permission-scoped staff targeting is intentionally unsupported — freight
|
* permission keys (e.g. department/role-scoped targeting).
|
||||||
* has no "users-by-permission" lookup. Target explicit userIds or an org instead.
|
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class NotificationRecipientsService {
|
export class NotificationRecipientsService {
|
||||||
@@ -82,6 +81,20 @@ export class NotificationRecipientsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (recipients.permissionKeys?.length) {
|
||||||
|
try {
|
||||||
|
for (const uid of await this.backoffice.getEmployeeUserIdsByPermission(
|
||||||
|
recipients.permissionKeys,
|
||||||
|
)) {
|
||||||
|
ids.add(uid);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Failed to resolve permissionKeys recipients: ${(err as Error).message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return [...ids];
|
return [...ids];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { randomInt } from "node:crypto";
|
|||||||
|
|
||||||
import { OtpRepository } from "./otp.repository";
|
import { OtpRepository } from "./otp.repository";
|
||||||
|
|
||||||
import { SmsClientService } from "../notifications/sms-client.service";
|
import { NotificationsService } from "../notifications/notifications.service";
|
||||||
import { EmailClientService } from "../notifications/email-client.service";
|
import { EmailClientService } from "../notifications/email-client.service";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -95,7 +95,7 @@ export class OtpService {
|
|||||||
logger = new Logger(OtpService.name);
|
logger = new Logger(OtpService.name);
|
||||||
constructor(
|
constructor(
|
||||||
private readonly otpRepository: OtpRepository,
|
private readonly otpRepository: OtpRepository,
|
||||||
private readonly smsClient: SmsClientService,
|
private readonly notifications: NotificationsService,
|
||||||
private readonly emailClient: EmailClientService,
|
private readonly emailClient: EmailClientService,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
@@ -263,17 +263,24 @@ export class OtpService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** SMS half of {@link dispatchEmail}; same swallow-and-report contract. */
|
/**
|
||||||
|
* SMS half of {@link dispatchEmail}; same swallow-and-report contract. Sent
|
||||||
|
* via NotificationsService's direct-HTTP Ozeking strategy — the same
|
||||||
|
* transport the notification system uses — rather than the RabbitMQ
|
||||||
|
* `SMS_SERVICE` queue, so `queued: true` here means the gateway accepted the
|
||||||
|
* request, not just that a broker took ownership of the message.
|
||||||
|
*/
|
||||||
private async dispatchSms(
|
private async dispatchSms(
|
||||||
phone: string,
|
phone: string,
|
||||||
otp: string,
|
otp: string,
|
||||||
): Promise<DispatchOutcome> {
|
): Promise<DispatchOutcome> {
|
||||||
try {
|
try {
|
||||||
const { queued } = await this.smsClient.sendSms({
|
await this.notifications.directSend(
|
||||||
to: phone,
|
"sms",
|
||||||
message: `Your verification code is ${otp}`,
|
phone,
|
||||||
});
|
`Your verification code is ${otp}`,
|
||||||
return { channel: "sms", queued };
|
);
|
||||||
|
return { channel: "sms", queued: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
channel: "sms",
|
channel: "sms",
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import {
|
|||||||
ApiOperation,
|
ApiOperation,
|
||||||
ApiTags,
|
ApiTags,
|
||||||
} from '@nestjs/swagger';
|
} from '@nestjs/swagger';
|
||||||
|
import { CurrentUser } from '@edr/api-common';
|
||||||
|
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||||
|
|
||||||
import { BookingView } from '../../common/booking-guards';
|
import { BookingView } from '../../common/booking-guards';
|
||||||
import { OverviewQueryDto } from './dto/overview-query.dto';
|
import { OverviewQueryDto } from './dto/overview-query.dto';
|
||||||
@@ -18,43 +20,79 @@ import {
|
|||||||
OverviewStaffTabDto,
|
OverviewStaffTabDto,
|
||||||
} from './dto/overview-tab-response.dto';
|
} from './dto/overview-tab-response.dto';
|
||||||
import { OverviewService } from './overview.service';
|
import { OverviewService } from './overview.service';
|
||||||
|
import { UserTradeAccessService } from '../user-trade-access/user-trade-access.service';
|
||||||
|
|
||||||
@ApiTags('Overview')
|
@ApiTags('Overview')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@Controller('overview')
|
@Controller('overview')
|
||||||
export class OverviewController {
|
export class OverviewController {
|
||||||
constructor(private readonly overviewService: OverviewService) {}
|
constructor(
|
||||||
|
private readonly overviewService: OverviewService,
|
||||||
|
private readonly userTradeAccessService: UserTradeAccessService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@BookingView()
|
@BookingView()
|
||||||
@ApiOperation({ summary: 'Aggregated dashboard summary for backoffice overview' })
|
@ApiOperation({ summary: 'Aggregated dashboard summary for backoffice overview' })
|
||||||
@ApiOkResponse({ type: OverviewResponseDto })
|
@ApiOkResponse({ type: OverviewResponseDto })
|
||||||
getDashboard(@Query() query: OverviewQueryDto): Promise<OverviewResponseDto> {
|
async getDashboard(
|
||||||
return this.overviewService.getDashboard(query.range ?? '30d');
|
@Query() query: OverviewQueryDto,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
|
): Promise<OverviewResponseDto> {
|
||||||
|
const allowed =
|
||||||
|
await this.userTradeAccessService.resolveAllowedDirections(user);
|
||||||
|
return this.overviewService.getDashboard(
|
||||||
|
query.range ?? '30d',
|
||||||
|
allowed ?? undefined,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('bookings')
|
@Get('bookings')
|
||||||
@BookingView()
|
@BookingView()
|
||||||
@ApiOperation({ summary: 'Bookings tab metrics and charts' })
|
@ApiOperation({ summary: 'Bookings tab metrics and charts' })
|
||||||
@ApiOkResponse({ type: OverviewBookingsTabDto })
|
@ApiOkResponse({ type: OverviewBookingsTabDto })
|
||||||
getBookingsTab(@Query() query: OverviewQueryDto): Promise<OverviewBookingsTabDto> {
|
async getBookingsTab(
|
||||||
return this.overviewService.getBookingsTab(query.range ?? '30d');
|
@Query() query: OverviewQueryDto,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
|
): Promise<OverviewBookingsTabDto> {
|
||||||
|
const allowed =
|
||||||
|
await this.userTradeAccessService.resolveAllowedDirections(user);
|
||||||
|
return this.overviewService.getBookingsTab(
|
||||||
|
query.range ?? '30d',
|
||||||
|
allowed ?? undefined,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('contracts')
|
@Get('contracts')
|
||||||
@BookingView()
|
@BookingView()
|
||||||
@ApiOperation({ summary: 'Contracts tab metrics and charts' })
|
@ApiOperation({ summary: 'Contracts tab metrics and charts' })
|
||||||
@ApiOkResponse({ type: OverviewContractsTabDto })
|
@ApiOkResponse({ type: OverviewContractsTabDto })
|
||||||
getContractsTab(@Query() query: OverviewQueryDto): Promise<OverviewContractsTabDto> {
|
async getContractsTab(
|
||||||
return this.overviewService.getContractsTab(query.range ?? '30d');
|
@Query() query: OverviewQueryDto,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
|
): Promise<OverviewContractsTabDto> {
|
||||||
|
const allowed =
|
||||||
|
await this.userTradeAccessService.resolveAllowedDirections(user);
|
||||||
|
return this.overviewService.getContractsTab(
|
||||||
|
query.range ?? '30d',
|
||||||
|
allowed ?? undefined,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('billing')
|
@Get('billing')
|
||||||
@BookingView()
|
@BookingView()
|
||||||
@ApiOperation({ summary: 'Billing tab metrics and charts' })
|
@ApiOperation({ summary: 'Billing tab metrics and charts' })
|
||||||
@ApiOkResponse({ type: OverviewBillingTabDto })
|
@ApiOkResponse({ type: OverviewBillingTabDto })
|
||||||
getBillingTab(@Query() query: OverviewQueryDto): Promise<OverviewBillingTabDto> {
|
async getBillingTab(
|
||||||
return this.overviewService.getBillingTab(query.range ?? '30d');
|
@Query() query: OverviewQueryDto,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
|
): Promise<OverviewBillingTabDto> {
|
||||||
|
const allowed =
|
||||||
|
await this.userTradeAccessService.resolveAllowedDirections(user);
|
||||||
|
return this.overviewService.getBillingTab(
|
||||||
|
query.range ?? '30d',
|
||||||
|
allowed ?? undefined,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('operations')
|
@Get('operations')
|
||||||
@@ -69,8 +107,16 @@ export class OverviewController {
|
|||||||
@BookingView()
|
@BookingView()
|
||||||
@ApiOperation({ summary: 'Customers tab metrics and charts' })
|
@ApiOperation({ summary: 'Customers tab metrics and charts' })
|
||||||
@ApiOkResponse({ type: OverviewCustomersTabDto })
|
@ApiOkResponse({ type: OverviewCustomersTabDto })
|
||||||
getCustomersTab(@Query() query: OverviewQueryDto): Promise<OverviewCustomersTabDto> {
|
async getCustomersTab(
|
||||||
return this.overviewService.getCustomersTab(query.range ?? '30d');
|
@Query() query: OverviewQueryDto,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
|
): Promise<OverviewCustomersTabDto> {
|
||||||
|
const allowed =
|
||||||
|
await this.userTradeAccessService.resolveAllowedDirections(user);
|
||||||
|
return this.overviewService.getCustomersTab(
|
||||||
|
query.range ?? '30d',
|
||||||
|
allowed ?? undefined,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('staff')
|
@Get('staff')
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { Contract } from "../contracts/entities/contract.entity";
|
|||||||
import { PaymentEntity } from "../payment/entities/payment.entity";
|
import { PaymentEntity } from "../payment/entities/payment.entity";
|
||||||
import { Train } from "../trains/entities/train.entity";
|
import { Train } from "../trains/entities/train.entity";
|
||||||
import { Wagon } from "../wagons/entities/wagon.entity";
|
import { Wagon } from "../wagons/entities/wagon.entity";
|
||||||
|
import { UserTradeAccessModule } from "../user-trade-access/user-trade-access.module";
|
||||||
import { OverviewController } from "./overview.controller";
|
import { OverviewController } from "./overview.controller";
|
||||||
import { OverviewRepository } from "./overview.repository";
|
import { OverviewRepository } from "./overview.repository";
|
||||||
import { OverviewService } from "./overview.service";
|
import { OverviewService } from "./overview.service";
|
||||||
@@ -29,6 +30,7 @@ import { OverviewService } from "./overview.service";
|
|||||||
Employee,
|
Employee,
|
||||||
User,
|
User,
|
||||||
]),
|
]),
|
||||||
|
UserTradeAccessModule,
|
||||||
],
|
],
|
||||||
controllers: [OverviewController],
|
controllers: [OverviewController],
|
||||||
providers: [OverviewService, OverviewRepository],
|
providers: [OverviewService, OverviewRepository],
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ import {
|
|||||||
OVERVIEW_URGENT_PRIORITY_THRESHOLD,
|
OVERVIEW_URGENT_PRIORITY_THRESHOLD,
|
||||||
} from "./overview.constants";
|
} from "./overview.constants";
|
||||||
import { Company } from "../companies/entities/company.entity";
|
import { Company } from "../companies/entities/company.entity";
|
||||||
|
import {
|
||||||
|
bookingRefScopeSql,
|
||||||
|
directionScopeSql,
|
||||||
|
} from "../user-trade-access/trade-scope.util";
|
||||||
|
|
||||||
/** Bookings carry a contract_kind column; GENERAL = umbrella contract row, not a shipment. */
|
/** Bookings carry a contract_kind column; GENERAL = umbrella contract row, not a shipment. */
|
||||||
const EXCLUDE_GENERAL_CONTRACT_BOOKINGS =
|
const EXCLUDE_GENERAL_CONTRACT_BOOKINGS =
|
||||||
@@ -93,7 +97,8 @@ export class OverviewRepository {
|
|||||||
private readonly userRepository: Repository<User>,
|
private readonly userRepository: Repository<User>,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
async getBookingKpis(): Promise<OverviewBookingKpisRow> {
|
async getBookingKpis(dirs?: string[]): Promise<OverviewBookingKpisRow> {
|
||||||
|
const scope = directionScopeSql("booking.trade_direction", dirs);
|
||||||
const row = await this.bookingRepository
|
const row = await this.bookingRepository
|
||||||
.createQueryBuilder("booking")
|
.createQueryBuilder("booking")
|
||||||
.select(
|
.select(
|
||||||
@@ -118,6 +123,7 @@ export class OverviewRepository {
|
|||||||
)
|
)
|
||||||
.where("booking.deleted_at IS NULL")
|
.where("booking.deleted_at IS NULL")
|
||||||
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
|
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
|
||||||
|
.andWhere(scope.sql, scope.params)
|
||||||
.setParameters({
|
.setParameters({
|
||||||
closedStatuses: [...OVERVIEW_CLOSED_STATUSES],
|
closedStatuses: [...OVERVIEW_CLOSED_STATUSES],
|
||||||
needsActionStatuses: [...OVERVIEW_NEEDS_ACTION_STATUSES],
|
needsActionStatuses: [...OVERVIEW_NEEDS_ACTION_STATUSES],
|
||||||
@@ -202,12 +208,13 @@ export class OverviewRepository {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async getBillingKpis(): Promise<{
|
async getBillingKpis(dirs?: string[]): Promise<{
|
||||||
revenueMtdEtb: number;
|
revenueMtdEtb: number;
|
||||||
revenueMtdUsd: number;
|
revenueMtdUsd: number;
|
||||||
pendingPayments: number;
|
pendingPayments: number;
|
||||||
successfulPaymentsMtd: number;
|
successfulPaymentsMtd: number;
|
||||||
}> {
|
}> {
|
||||||
|
const scope = bookingRefScopeSql("payment.ref_id", dirs);
|
||||||
const revenueRow = await this.paymentRepository
|
const revenueRow = await this.paymentRepository
|
||||||
.createQueryBuilder("payment")
|
.createQueryBuilder("payment")
|
||||||
.select(
|
.select(
|
||||||
@@ -223,6 +230,7 @@ export class OverviewRepository {
|
|||||||
.andWhere(
|
.andWhere(
|
||||||
`COALESCE(payment.paid_at, payment.created_at) >= date_trunc('month', CURRENT_DATE)`,
|
`COALESCE(payment.paid_at, payment.created_at) >= date_trunc('month', CURRENT_DATE)`,
|
||||||
)
|
)
|
||||||
|
.andWhere(scope.sql, scope.params)
|
||||||
.getRawOne<Record<string, string>>();
|
.getRawOne<Record<string, string>>();
|
||||||
|
|
||||||
const pendingPayments = await this.paymentRepository
|
const pendingPayments = await this.paymentRepository
|
||||||
@@ -230,6 +238,7 @@ export class OverviewRepository {
|
|||||||
.where("payment.status IN (:...statuses)", {
|
.where("payment.status IN (:...statuses)", {
|
||||||
statuses: ["action-required", "processing"],
|
statuses: ["action-required", "processing"],
|
||||||
})
|
})
|
||||||
|
.andWhere(scope.sql, scope.params)
|
||||||
.getCount();
|
.getCount();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -261,13 +270,16 @@ export class OverviewRepository {
|
|||||||
|
|
||||||
async getBookingTrend(
|
async getBookingTrend(
|
||||||
days: number,
|
days: number,
|
||||||
|
dirs?: string[],
|
||||||
): Promise<{ date: string; count: number }[]> {
|
): Promise<{ date: string; count: number }[]> {
|
||||||
|
const scope = directionScopeSql("booking.trade_direction", dirs);
|
||||||
const rows = await this.bookingRepository
|
const rows = await this.bookingRepository
|
||||||
.createQueryBuilder("booking")
|
.createQueryBuilder("booking")
|
||||||
.select(`to_char(booking.created_at::date, 'YYYY-MM-DD')`, "date")
|
.select(`to_char(booking.created_at::date, 'YYYY-MM-DD')`, "date")
|
||||||
.addSelect("COUNT(*)::int", "count")
|
.addSelect("COUNT(*)::int", "count")
|
||||||
.where("booking.deleted_at IS NULL")
|
.where("booking.deleted_at IS NULL")
|
||||||
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
|
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
|
||||||
|
.andWhere(scope.sql, scope.params)
|
||||||
.andWhere(`booking.created_at >= CURRENT_DATE - :days::int + 1`, { days })
|
.andWhere(`booking.created_at >= CURRENT_DATE - :days::int + 1`, { days })
|
||||||
.groupBy("booking.created_at::date")
|
.groupBy("booking.created_at::date")
|
||||||
.orderBy("booking.created_at::date", "ASC")
|
.orderBy("booking.created_at::date", "ASC")
|
||||||
@@ -279,13 +291,15 @@ export class OverviewRepository {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
async getStatusCounts(): Promise<Record<string, number>> {
|
async getStatusCounts(dirs?: string[]): Promise<Record<string, number>> {
|
||||||
|
const scope = directionScopeSql("booking.trade_direction", dirs);
|
||||||
const rows = await this.bookingRepository
|
const rows = await this.bookingRepository
|
||||||
.createQueryBuilder("booking")
|
.createQueryBuilder("booking")
|
||||||
.select("booking.status", "status")
|
.select("booking.status", "status")
|
||||||
.addSelect("COUNT(*)::int", "count")
|
.addSelect("COUNT(*)::int", "count")
|
||||||
.where("booking.deleted_at IS NULL")
|
.where("booking.deleted_at IS NULL")
|
||||||
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
|
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
|
||||||
|
.andWhere(scope.sql, scope.params)
|
||||||
.groupBy("booking.status")
|
.groupBy("booking.status")
|
||||||
.getRawMany<{ status: string; count: string }>();
|
.getRawMany<{ status: string; count: string }>();
|
||||||
|
|
||||||
@@ -296,7 +310,9 @@ export class OverviewRepository {
|
|||||||
|
|
||||||
async getPaymentTrend(
|
async getPaymentTrend(
|
||||||
days: number,
|
days: number,
|
||||||
|
dirs?: string[],
|
||||||
): Promise<{ date: string; amountEtb: number; amountUsd: number }[]> {
|
): Promise<{ date: string; amountEtb: number; amountUsd: number }[]> {
|
||||||
|
const scope = bookingRefScopeSql("payment.ref_id", dirs);
|
||||||
const rows = await this.paymentRepository
|
const rows = await this.paymentRepository
|
||||||
.createQueryBuilder("payment")
|
.createQueryBuilder("payment")
|
||||||
.select(
|
.select(
|
||||||
@@ -316,6 +332,7 @@ export class OverviewRepository {
|
|||||||
`COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`,
|
`COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`,
|
||||||
{ days },
|
{ days },
|
||||||
)
|
)
|
||||||
|
.andWhere(scope.sql, scope.params)
|
||||||
.groupBy(`COALESCE(payment.paid_at, payment.created_at)::date`)
|
.groupBy(`COALESCE(payment.paid_at, payment.created_at)::date`)
|
||||||
.orderBy(`COALESCE(payment.paid_at, payment.created_at)::date`, "ASC")
|
.orderBy(`COALESCE(payment.paid_at, payment.created_at)::date`, "ASC")
|
||||||
.getRawMany<{ date: string; amountEtb: string; amountUsd: string }>();
|
.getRawMany<{ date: string; amountEtb: string; amountUsd: string }>();
|
||||||
@@ -327,7 +344,11 @@ export class OverviewRepository {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
async getRecentBookings(limit: number): Promise<OverviewRecentBookingRow[]> {
|
async getRecentBookings(
|
||||||
|
limit: number,
|
||||||
|
dirs?: string[],
|
||||||
|
): Promise<OverviewRecentBookingRow[]> {
|
||||||
|
const scope = directionScopeSql("booking.trade_direction", dirs);
|
||||||
const rows = await this.bookingRepository
|
const rows = await this.bookingRepository
|
||||||
.createQueryBuilder("booking")
|
.createQueryBuilder("booking")
|
||||||
.leftJoin("booking.company", "company")
|
.leftJoin("booking.company", "company")
|
||||||
@@ -341,6 +362,7 @@ export class OverviewRepository {
|
|||||||
.addSelect("booking.created_at", "createdAt")
|
.addSelect("booking.created_at", "createdAt")
|
||||||
.where("booking.deleted_at IS NULL")
|
.where("booking.deleted_at IS NULL")
|
||||||
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
|
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
|
||||||
|
.andWhere(scope.sql, scope.params)
|
||||||
.orderBy("booking.created_at", "DESC")
|
.orderBy("booking.created_at", "DESC")
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.getRawMany<{
|
.getRawMany<{
|
||||||
@@ -366,9 +388,10 @@ export class OverviewRepository {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
async getBookingsByFreightType(): Promise<
|
async getBookingsByFreightType(
|
||||||
{ label: string; count: number }[]
|
dirs?: string[],
|
||||||
> {
|
): Promise<{ label: string; count: number }[]> {
|
||||||
|
const scope = directionScopeSql("booking.trade_direction", dirs);
|
||||||
const rows = await this.bookingRepository
|
const rows = await this.bookingRepository
|
||||||
.createQueryBuilder("booking")
|
.createQueryBuilder("booking")
|
||||||
.select("booking.freight_type", "label")
|
.select("booking.freight_type", "label")
|
||||||
@@ -376,6 +399,7 @@ export class OverviewRepository {
|
|||||||
.where("booking.deleted_at IS NULL")
|
.where("booking.deleted_at IS NULL")
|
||||||
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
|
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
|
||||||
.andWhere("booking.status != 'DRAFT'")
|
.andWhere("booking.status != 'DRAFT'")
|
||||||
|
.andWhere(scope.sql, scope.params)
|
||||||
.groupBy("booking.freight_type")
|
.groupBy("booking.freight_type")
|
||||||
.orderBy("count", "DESC")
|
.orderBy("count", "DESC")
|
||||||
.getRawMany<{ label: string; count: string }>();
|
.getRawMany<{ label: string; count: string }>();
|
||||||
@@ -386,7 +410,10 @@ export class OverviewRepository {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
async getBookingsByCurrency(): Promise<{ label: string; count: number }[]> {
|
async getBookingsByCurrency(
|
||||||
|
dirs?: string[],
|
||||||
|
): Promise<{ label: string; count: number }[]> {
|
||||||
|
const scope = directionScopeSql("booking.trade_direction", dirs);
|
||||||
const rows = await this.bookingRepository
|
const rows = await this.bookingRepository
|
||||||
.createQueryBuilder("booking")
|
.createQueryBuilder("booking")
|
||||||
.select("booking.payment_currency", "label")
|
.select("booking.payment_currency", "label")
|
||||||
@@ -394,6 +421,7 @@ export class OverviewRepository {
|
|||||||
.where("booking.deleted_at IS NULL")
|
.where("booking.deleted_at IS NULL")
|
||||||
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
|
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
|
||||||
.andWhere("booking.status != 'DRAFT'")
|
.andWhere("booking.status != 'DRAFT'")
|
||||||
|
.andWhere(scope.sql, scope.params)
|
||||||
.groupBy("booking.payment_currency")
|
.groupBy("booking.payment_currency")
|
||||||
.orderBy("count", "DESC")
|
.orderBy("count", "DESC")
|
||||||
.getRawMany<{ label: string; count: string }>();
|
.getRawMany<{ label: string; count: string }>();
|
||||||
@@ -404,11 +432,15 @@ export class OverviewRepository {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPaymentsByStatus(): Promise<{ status: string; count: number }[]> {
|
async getPaymentsByStatus(
|
||||||
|
dirs?: string[],
|
||||||
|
): Promise<{ status: string; count: number }[]> {
|
||||||
|
const scope = bookingRefScopeSql("payment.ref_id", dirs);
|
||||||
const rows = await this.paymentRepository
|
const rows = await this.paymentRepository
|
||||||
.createQueryBuilder("payment")
|
.createQueryBuilder("payment")
|
||||||
.select("payment.status", "status")
|
.select("payment.status", "status")
|
||||||
.addSelect("COUNT(*)::int", "count")
|
.addSelect("COUNT(*)::int", "count")
|
||||||
|
.where(scope.sql, scope.params)
|
||||||
.groupBy("payment.status")
|
.groupBy("payment.status")
|
||||||
.orderBy("count", "DESC")
|
.orderBy("count", "DESC")
|
||||||
.getRawMany<{ status: string; count: string }>();
|
.getRawMany<{ status: string; count: string }>();
|
||||||
@@ -419,9 +451,12 @@ export class OverviewRepository {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPaymentsByMethod(): Promise<
|
async getPaymentsByMethod(
|
||||||
|
dirs?: string[],
|
||||||
|
): Promise<
|
||||||
{ method: string; count: number; amountEtb: number; amountUsd: number }[]
|
{ method: string; count: number; amountEtb: number; amountUsd: number }[]
|
||||||
> {
|
> {
|
||||||
|
const scope = bookingRefScopeSql("payment.ref_id", dirs);
|
||||||
const rows = await this.paymentRepository
|
const rows = await this.paymentRepository
|
||||||
.createQueryBuilder("payment")
|
.createQueryBuilder("payment")
|
||||||
.select("payment.method", "method")
|
.select("payment.method", "method")
|
||||||
@@ -434,6 +469,7 @@ export class OverviewRepository {
|
|||||||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD' AND payment.status = 'success'), 0)`,
|
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD' AND payment.status = 'success'), 0)`,
|
||||||
"amountUsd",
|
"amountUsd",
|
||||||
)
|
)
|
||||||
|
.where(scope.sql, scope.params)
|
||||||
.groupBy("payment.method")
|
.groupBy("payment.method")
|
||||||
.orderBy("count", "DESC")
|
.orderBy("count", "DESC")
|
||||||
.getRawMany<{
|
.getRawMany<{
|
||||||
@@ -451,9 +487,10 @@ export class OverviewRepository {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
async getRevenueByCurrency(): Promise<
|
async getRevenueByCurrency(
|
||||||
{ currency: string; amount: number }[]
|
dirs?: string[],
|
||||||
> {
|
): Promise<{ currency: string; amount: number }[]> {
|
||||||
|
const scope = bookingRefScopeSql("payment.ref_id", dirs);
|
||||||
const rows = await this.paymentRepository
|
const rows = await this.paymentRepository
|
||||||
.createQueryBuilder("payment")
|
.createQueryBuilder("payment")
|
||||||
.select("payment.currency", "currency")
|
.select("payment.currency", "currency")
|
||||||
@@ -462,6 +499,7 @@ export class OverviewRepository {
|
|||||||
.andWhere(
|
.andWhere(
|
||||||
`COALESCE(payment.paid_at, payment.created_at) >= date_trunc('month', CURRENT_DATE)`,
|
`COALESCE(payment.paid_at, payment.created_at) >= date_trunc('month', CURRENT_DATE)`,
|
||||||
)
|
)
|
||||||
|
.andWhere(scope.sql, scope.params)
|
||||||
.groupBy("payment.currency")
|
.groupBy("payment.currency")
|
||||||
.getRawMany<{ currency: string; amount: string }>();
|
.getRawMany<{ currency: string; amount: string }>();
|
||||||
|
|
||||||
@@ -556,7 +594,9 @@ export class OverviewRepository {
|
|||||||
|
|
||||||
async getTopCustomersByBookings(
|
async getTopCustomersByBookings(
|
||||||
limit: number,
|
limit: number,
|
||||||
|
dirs?: string[],
|
||||||
): Promise<{ label: string; count: number }[]> {
|
): Promise<{ label: string; count: number }[]> {
|
||||||
|
const scope = directionScopeSql("booking.trade_direction", dirs);
|
||||||
const rows = await this.bookingRepository
|
const rows = await this.bookingRepository
|
||||||
.createQueryBuilder("booking")
|
.createQueryBuilder("booking")
|
||||||
.leftJoin("booking.company", "company")
|
.leftJoin("booking.company", "company")
|
||||||
@@ -564,6 +604,7 @@ export class OverviewRepository {
|
|||||||
.addSelect("COUNT(*)::int", "count")
|
.addSelect("COUNT(*)::int", "count")
|
||||||
.where("booking.deleted_at IS NULL")
|
.where("booking.deleted_at IS NULL")
|
||||||
.andWhere("booking.status != 'DRAFT'")
|
.andWhere("booking.status != 'DRAFT'")
|
||||||
|
.andWhere(scope.sql, scope.params)
|
||||||
.groupBy("company.name")
|
.groupBy("company.name")
|
||||||
.orderBy("count", "DESC")
|
.orderBy("count", "DESC")
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
@@ -632,7 +673,8 @@ export class OverviewRepository {
|
|||||||
|
|
||||||
// ── Contracts (overview Contract tab) ──────────────────────────────────────
|
// ── Contracts (overview Contract tab) ──────────────────────────────────────
|
||||||
|
|
||||||
async getContractKpis(): Promise<OverviewContractKpisRow> {
|
async getContractKpis(dirs?: string[]): Promise<OverviewContractKpisRow> {
|
||||||
|
const scope = directionScopeSql("contract.trade_direction", dirs);
|
||||||
const row = await this.contractRepository
|
const row = await this.contractRepository
|
||||||
.createQueryBuilder("contract")
|
.createQueryBuilder("contract")
|
||||||
.select(
|
.select(
|
||||||
@@ -656,6 +698,7 @@ export class OverviewRepository {
|
|||||||
"createdToday",
|
"createdToday",
|
||||||
)
|
)
|
||||||
.where("contract.deleted_at IS NULL")
|
.where("contract.deleted_at IS NULL")
|
||||||
|
.andWhere(scope.sql, scope.params)
|
||||||
.setParameters({
|
.setParameters({
|
||||||
closedStatuses: [...OVERVIEW_CONTRACT_CLOSED_STATUSES],
|
closedStatuses: [...OVERVIEW_CONTRACT_CLOSED_STATUSES],
|
||||||
needsActionStatuses: [...OVERVIEW_CONTRACT_NEEDS_ACTION_STATUSES],
|
needsActionStatuses: [...OVERVIEW_CONTRACT_NEEDS_ACTION_STATUSES],
|
||||||
@@ -673,24 +716,33 @@ export class OverviewRepository {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async getContractStatusCounts(): Promise<Record<string, number>> {
|
async getContractStatusCounts(
|
||||||
|
dirs?: string[],
|
||||||
|
): Promise<Record<string, number>> {
|
||||||
|
const scope = directionScopeSql("contract.trade_direction", dirs);
|
||||||
const rows = await this.contractRepository
|
const rows = await this.contractRepository
|
||||||
.createQueryBuilder("contract")
|
.createQueryBuilder("contract")
|
||||||
.select("contract.status", "status")
|
.select("contract.status", "status")
|
||||||
.addSelect("COUNT(*)::int", "count")
|
.addSelect("COUNT(*)::int", "count")
|
||||||
.where("contract.deleted_at IS NULL")
|
.where("contract.deleted_at IS NULL")
|
||||||
|
.andWhere(scope.sql, scope.params)
|
||||||
.groupBy("contract.status")
|
.groupBy("contract.status")
|
||||||
.getRawMany<{ status: string; count: string }>();
|
.getRawMany<{ status: string; count: string }>();
|
||||||
|
|
||||||
return Object.fromEntries(rows.map((row) => [row.status, Number(row.count)]));
|
return Object.fromEntries(rows.map((row) => [row.status, Number(row.count)]));
|
||||||
}
|
}
|
||||||
|
|
||||||
async getContractTrend(days: number): Promise<{ date: string; count: number }[]> {
|
async getContractTrend(
|
||||||
|
days: number,
|
||||||
|
dirs?: string[],
|
||||||
|
): Promise<{ date: string; count: number }[]> {
|
||||||
|
const scope = directionScopeSql("contract.trade_direction", dirs);
|
||||||
const rows = await this.contractRepository
|
const rows = await this.contractRepository
|
||||||
.createQueryBuilder("contract")
|
.createQueryBuilder("contract")
|
||||||
.select(`to_char(contract.created_at::date, 'YYYY-MM-DD')`, "date")
|
.select(`to_char(contract.created_at::date, 'YYYY-MM-DD')`, "date")
|
||||||
.addSelect("COUNT(*)::int", "count")
|
.addSelect("COUNT(*)::int", "count")
|
||||||
.where("contract.deleted_at IS NULL")
|
.where("contract.deleted_at IS NULL")
|
||||||
|
.andWhere(scope.sql, scope.params)
|
||||||
.andWhere(`contract.created_at >= CURRENT_DATE - :days::int + 1`, { days })
|
.andWhere(`contract.created_at >= CURRENT_DATE - :days::int + 1`, { days })
|
||||||
.groupBy("contract.created_at::date")
|
.groupBy("contract.created_at::date")
|
||||||
.orderBy("contract.created_at::date", "ASC")
|
.orderBy("contract.created_at::date", "ASC")
|
||||||
@@ -699,13 +751,17 @@ export class OverviewRepository {
|
|||||||
return rows.map((row) => ({ date: row.date, count: Number(row.count) }));
|
return rows.map((row) => ({ date: row.date, count: Number(row.count) }));
|
||||||
}
|
}
|
||||||
|
|
||||||
async getContractsByKind(): Promise<{ label: string; count: number }[]> {
|
async getContractsByKind(
|
||||||
|
dirs?: string[],
|
||||||
|
): Promise<{ label: string; count: number }[]> {
|
||||||
|
const scope = directionScopeSql("contract.trade_direction", dirs);
|
||||||
const rows = await this.contractRepository
|
const rows = await this.contractRepository
|
||||||
.createQueryBuilder("contract")
|
.createQueryBuilder("contract")
|
||||||
.select("contract.contract_kind", "label")
|
.select("contract.contract_kind", "label")
|
||||||
.addSelect("COUNT(*)::int", "count")
|
.addSelect("COUNT(*)::int", "count")
|
||||||
.where("contract.deleted_at IS NULL")
|
.where("contract.deleted_at IS NULL")
|
||||||
.andWhere("contract.status != 'DRAFT'")
|
.andWhere("contract.status != 'DRAFT'")
|
||||||
|
.andWhere(scope.sql, scope.params)
|
||||||
.groupBy("contract.contract_kind")
|
.groupBy("contract.contract_kind")
|
||||||
.orderBy("count", "DESC")
|
.orderBy("count", "DESC")
|
||||||
.getRawMany<{ label: string; count: string }>();
|
.getRawMany<{ label: string; count: string }>();
|
||||||
@@ -713,13 +769,17 @@ export class OverviewRepository {
|
|||||||
return rows.map((row) => ({ label: row.label, count: Number(row.count) }));
|
return rows.map((row) => ({ label: row.label, count: Number(row.count) }));
|
||||||
}
|
}
|
||||||
|
|
||||||
async getContractsByFreightType(): Promise<{ label: string; count: number }[]> {
|
async getContractsByFreightType(
|
||||||
|
dirs?: string[],
|
||||||
|
): Promise<{ label: string; count: number }[]> {
|
||||||
|
const scope = directionScopeSql("contract.trade_direction", dirs);
|
||||||
const rows = await this.contractRepository
|
const rows = await this.contractRepository
|
||||||
.createQueryBuilder("contract")
|
.createQueryBuilder("contract")
|
||||||
.select("contract.freight_type", "label")
|
.select("contract.freight_type", "label")
|
||||||
.addSelect("COUNT(*)::int", "count")
|
.addSelect("COUNT(*)::int", "count")
|
||||||
.where("contract.deleted_at IS NULL")
|
.where("contract.deleted_at IS NULL")
|
||||||
.andWhere("contract.status != 'DRAFT'")
|
.andWhere("contract.status != 'DRAFT'")
|
||||||
|
.andWhere(scope.sql, scope.params)
|
||||||
.groupBy("contract.freight_type")
|
.groupBy("contract.freight_type")
|
||||||
.orderBy("count", "DESC")
|
.orderBy("count", "DESC")
|
||||||
.getRawMany<{ label: string; count: string }>();
|
.getRawMany<{ label: string; count: string }>();
|
||||||
@@ -727,7 +787,11 @@ export class OverviewRepository {
|
|||||||
return rows.map((row) => ({ label: row.label, count: Number(row.count) }));
|
return rows.map((row) => ({ label: row.label, count: Number(row.count) }));
|
||||||
}
|
}
|
||||||
|
|
||||||
async getRecentContracts(limit: number): Promise<OverviewRecentContractRow[]> {
|
async getRecentContracts(
|
||||||
|
limit: number,
|
||||||
|
dirs?: string[],
|
||||||
|
): Promise<OverviewRecentContractRow[]> {
|
||||||
|
const scope = directionScopeSql("contract.trade_direction", dirs);
|
||||||
const rows = await this.contractRepository
|
const rows = await this.contractRepository
|
||||||
.createQueryBuilder("contract")
|
.createQueryBuilder("contract")
|
||||||
.leftJoin("contract.company", "company")
|
.leftJoin("contract.company", "company")
|
||||||
@@ -741,6 +805,7 @@ export class OverviewRepository {
|
|||||||
.addSelect("contract.contract_valid_until", "validUntil")
|
.addSelect("contract.contract_valid_until", "validUntil")
|
||||||
.addSelect("contract.created_at", "createdAt")
|
.addSelect("contract.created_at", "createdAt")
|
||||||
.where("contract.deleted_at IS NULL")
|
.where("contract.deleted_at IS NULL")
|
||||||
|
.andWhere(scope.sql, scope.params)
|
||||||
.orderBy("contract.created_at", "DESC")
|
.orderBy("contract.created_at", "DESC")
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.getRawMany<{
|
.getRawMany<{
|
||||||
|
|||||||
@@ -40,7 +40,10 @@ export class OverviewService {
|
|||||||
return { bookingsByPipeline, bookingsByStatus };
|
return { bookingsByPipeline, bookingsByStatus };
|
||||||
}
|
}
|
||||||
|
|
||||||
async getDashboard(range: OverviewRangeQuery = '30d'): Promise<OverviewResponseDto> {
|
async getDashboard(
|
||||||
|
range: OverviewRangeQuery = '30d',
|
||||||
|
dirs?: string[],
|
||||||
|
): Promise<OverviewResponseDto> {
|
||||||
const days = OVERVIEW_RANGE_DAYS[range];
|
const days = OVERVIEW_RANGE_DAYS[range];
|
||||||
|
|
||||||
const [
|
const [
|
||||||
@@ -55,16 +58,16 @@ export class OverviewService {
|
|||||||
paymentTrend,
|
paymentTrend,
|
||||||
recentBookings,
|
recentBookings,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
this.overviewRepository.getBookingKpis(),
|
this.overviewRepository.getBookingKpis(dirs),
|
||||||
this.overviewRepository.getContractKpis(),
|
this.overviewRepository.getContractKpis(dirs),
|
||||||
this.overviewRepository.getOperationsKpis(),
|
this.overviewRepository.getOperationsKpis(),
|
||||||
this.overviewRepository.getCustomerKpis(),
|
this.overviewRepository.getCustomerKpis(),
|
||||||
this.overviewRepository.getBillingKpis(),
|
this.overviewRepository.getBillingKpis(dirs),
|
||||||
this.overviewRepository.getStaffKpis(),
|
this.overviewRepository.getStaffKpis(),
|
||||||
this.overviewRepository.getBookingTrend(days),
|
this.overviewRepository.getBookingTrend(days, dirs),
|
||||||
this.overviewRepository.getStatusCounts(),
|
this.overviewRepository.getStatusCounts(dirs),
|
||||||
this.overviewRepository.getPaymentTrend(days),
|
this.overviewRepository.getPaymentTrend(days, dirs),
|
||||||
this.overviewRepository.getRecentBookings(8),
|
this.overviewRepository.getRecentBookings(8, dirs),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const { bookingsByPipeline, bookingsByStatus } =
|
const { bookingsByPipeline, bookingsByStatus } =
|
||||||
@@ -91,7 +94,10 @@ export class OverviewService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async getBookingsTab(range: OverviewRangeQuery = '30d'): Promise<OverviewBookingsTabDto> {
|
async getBookingsTab(
|
||||||
|
range: OverviewRangeQuery = '30d',
|
||||||
|
dirs?: string[],
|
||||||
|
): Promise<OverviewBookingsTabDto> {
|
||||||
const days = OVERVIEW_RANGE_DAYS[range];
|
const days = OVERVIEW_RANGE_DAYS[range];
|
||||||
|
|
||||||
const [
|
const [
|
||||||
@@ -102,12 +108,12 @@ export class OverviewService {
|
|||||||
bookingsByCurrency,
|
bookingsByCurrency,
|
||||||
recentBookings,
|
recentBookings,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
this.overviewRepository.getBookingKpis(),
|
this.overviewRepository.getBookingKpis(dirs),
|
||||||
this.overviewRepository.getBookingTrend(days),
|
this.overviewRepository.getBookingTrend(days, dirs),
|
||||||
this.overviewRepository.getStatusCounts(),
|
this.overviewRepository.getStatusCounts(dirs),
|
||||||
this.overviewRepository.getBookingsByFreightType(),
|
this.overviewRepository.getBookingsByFreightType(dirs),
|
||||||
this.overviewRepository.getBookingsByCurrency(),
|
this.overviewRepository.getBookingsByCurrency(dirs),
|
||||||
this.overviewRepository.getRecentBookings(8),
|
this.overviewRepository.getRecentBookings(8, dirs),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const { bookingsByPipeline, bookingsByStatus } =
|
const { bookingsByPipeline, bookingsByStatus } =
|
||||||
@@ -130,6 +136,7 @@ export class OverviewService {
|
|||||||
|
|
||||||
async getContractsTab(
|
async getContractsTab(
|
||||||
range: OverviewRangeQuery = '30d',
|
range: OverviewRangeQuery = '30d',
|
||||||
|
dirs?: string[],
|
||||||
): Promise<OverviewContractsTabDto> {
|
): Promise<OverviewContractsTabDto> {
|
||||||
const days = OVERVIEW_RANGE_DAYS[range];
|
const days = OVERVIEW_RANGE_DAYS[range];
|
||||||
|
|
||||||
@@ -141,12 +148,12 @@ export class OverviewService {
|
|||||||
contractsByFreightType,
|
contractsByFreightType,
|
||||||
recentContracts,
|
recentContracts,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
this.overviewRepository.getContractKpis(),
|
this.overviewRepository.getContractKpis(dirs),
|
||||||
this.overviewRepository.getContractTrend(days),
|
this.overviewRepository.getContractTrend(days, dirs),
|
||||||
this.overviewRepository.getContractStatusCounts(),
|
this.overviewRepository.getContractStatusCounts(dirs),
|
||||||
this.overviewRepository.getContractsByKind(),
|
this.overviewRepository.getContractsByKind(dirs),
|
||||||
this.overviewRepository.getContractsByFreightType(),
|
this.overviewRepository.getContractsByFreightType(dirs),
|
||||||
this.overviewRepository.getRecentContracts(8),
|
this.overviewRepository.getRecentContracts(8, dirs),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const contractsByStatus = Object.entries(statusCounts)
|
const contractsByStatus = Object.entries(statusCounts)
|
||||||
@@ -170,16 +177,19 @@ export class OverviewService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async getBillingTab(range: OverviewRangeQuery = '30d'): Promise<OverviewBillingTabDto> {
|
async getBillingTab(
|
||||||
|
range: OverviewRangeQuery = '30d',
|
||||||
|
dirs?: string[],
|
||||||
|
): Promise<OverviewBillingTabDto> {
|
||||||
const days = OVERVIEW_RANGE_DAYS[range];
|
const days = OVERVIEW_RANGE_DAYS[range];
|
||||||
|
|
||||||
const [kpis, paymentTrend, paymentsByStatus, paymentsByMethod, revenueByCurrency] =
|
const [kpis, paymentTrend, paymentsByStatus, paymentsByMethod, revenueByCurrency] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
this.overviewRepository.getBillingKpis(),
|
this.overviewRepository.getBillingKpis(dirs),
|
||||||
this.overviewRepository.getPaymentTrend(days),
|
this.overviewRepository.getPaymentTrend(days, dirs),
|
||||||
this.overviewRepository.getPaymentsByStatus(),
|
this.overviewRepository.getPaymentsByStatus(dirs),
|
||||||
this.overviewRepository.getPaymentsByMethod(),
|
this.overviewRepository.getPaymentsByMethod(dirs),
|
||||||
this.overviewRepository.getRevenueByCurrency(),
|
this.overviewRepository.getRevenueByCurrency(dirs),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -217,7 +227,10 @@ export class OverviewService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async getCustomersTab(range: OverviewRangeQuery = '30d'): Promise<OverviewCustomersTabDto> {
|
async getCustomersTab(
|
||||||
|
range: OverviewRangeQuery = '30d',
|
||||||
|
dirs?: string[],
|
||||||
|
): Promise<OverviewCustomersTabDto> {
|
||||||
const days = OVERVIEW_RANGE_DAYS[range];
|
const days = OVERVIEW_RANGE_DAYS[range];
|
||||||
|
|
||||||
const [kpis, customerGrowthTrend, customersByType, topCustomersByBookings] =
|
const [kpis, customerGrowthTrend, customersByType, topCustomersByBookings] =
|
||||||
@@ -225,7 +238,7 @@ export class OverviewService {
|
|||||||
this.overviewRepository.getCustomerKpis(),
|
this.overviewRepository.getCustomerKpis(),
|
||||||
this.overviewRepository.getCustomerGrowthTrend(days),
|
this.overviewRepository.getCustomerGrowthTrend(days),
|
||||||
this.overviewRepository.getCustomersByType(),
|
this.overviewRepository.getCustomersByType(),
|
||||||
this.overviewRepository.getTopCustomersByBookings(8),
|
this.overviewRepository.getTopCustomersByBookings(8, dirs),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export class PaymentEntity extends BaseEntity {
|
|||||||
@Column({ type: "enum", enum: ["action-required", "processing", "success", "failed", "canceled", "refunded"], default: "action-required" })
|
@Column({ type: "enum", enum: ["action-required", "processing", "success", "failed", "canceled", "refunded"], default: "action-required" })
|
||||||
status!: PaymentStatus
|
status!: PaymentStatus
|
||||||
|
|
||||||
@Column({ type: "date", nullable: true, name: "paid_at" })
|
@Column({ type: "timestamp", nullable: true, name: "paid_at" })
|
||||||
paidAt?: Date
|
paidAt?: Date
|
||||||
|
|
||||||
@Column({ type: "timestamp", nullable: true, name: "refunded_at" })
|
@Column({ type: "timestamp", nullable: true, name: "refunded_at" })
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
BillQueryRequestDto,
|
BillQueryRequestDto,
|
||||||
BillQueryResponseDto,
|
BillQueryResponseDto,
|
||||||
} from "./internal-payment.dto";
|
} from "./internal-payment.dto";
|
||||||
|
import { Public } from "@edr/api-common";
|
||||||
import { PaymentService } from "./payment.service";
|
import { PaymentService } from "./payment.service";
|
||||||
import { BillingService } from "../billing/billing.service";
|
import { BillingService } from "../billing/billing.service";
|
||||||
import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
|
import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
|
||||||
@@ -28,6 +29,9 @@ import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
|
|||||||
* this HTTP endpoint remains as a transport-agnostic fallback.
|
* this HTTP endpoint remains as a transport-agnostic fallback.
|
||||||
*/
|
*/
|
||||||
@ApiTags("Internal Payments")
|
@ApiTags("Internal Payments")
|
||||||
|
// Skips the global JwtGuard (no end-user JWT on a service-to-service call);
|
||||||
|
// ServiceAuthGuard below still enforces the shared SERVICE_AUTH_TOKEN.
|
||||||
|
@Public()
|
||||||
@UseGuards(ServiceAuthGuard)
|
@UseGuards(ServiceAuthGuard)
|
||||||
@Controller("internal/payments")
|
@Controller("internal/payments")
|
||||||
export class InternalPaymentController {
|
export class InternalPaymentController {
|
||||||
|
|||||||
@@ -15,8 +15,10 @@ import {
|
|||||||
ApiProduces,
|
ApiProduces,
|
||||||
} from "@nestjs/swagger";
|
} from "@nestjs/swagger";
|
||||||
import { Response } from "express";
|
import { Response } from "express";
|
||||||
import { Public } from "@edr/api-common";
|
import { CurrentUser, Public } from "@edr/api-common";
|
||||||
|
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
|
||||||
import { BookingStaff, BookingView } from "../../common/booking-guards";
|
import { BookingStaff, BookingView } from "../../common/booking-guards";
|
||||||
|
import { UserTradeAccessService } from "../user-trade-access/user-trade-access.service";
|
||||||
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||||
import { PaymentService } from "./payment.service";
|
import { PaymentService } from "./payment.service";
|
||||||
import { IntentStatusDto } from "./payments.dto";
|
import { IntentStatusDto } from "./payments.dto";
|
||||||
@@ -24,7 +26,10 @@ import { IntentStatusDto } from "./payments.dto";
|
|||||||
@ApiTags("Payment")
|
@ApiTags("Payment")
|
||||||
@Controller("payments")
|
@Controller("payments")
|
||||||
export class PaymentController {
|
export class PaymentController {
|
||||||
constructor(private readonly paymentService: PaymentService) { }
|
constructor(
|
||||||
|
private readonly paymentService: PaymentService,
|
||||||
|
private readonly userTradeAccessService: UserTradeAccessService,
|
||||||
|
) { }
|
||||||
|
|
||||||
// Customer-detail payments tab — same one-of rule as the bookings tab.
|
// Customer-detail payments tab — same one-of rule as the bookings tab.
|
||||||
@Get("by-company/:companyId/customer-view")
|
@Get("by-company/:companyId/customer-view")
|
||||||
@@ -52,18 +57,23 @@ export class PaymentController {
|
|||||||
@ApiQuery({ name: "page", required: false })
|
@ApiQuery({ name: "page", required: false })
|
||||||
@ApiQuery({ name: "pageSize", required: false })
|
@ApiQuery({ name: "pageSize", required: false })
|
||||||
async getAll(
|
async getAll(
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
@Query("search") search?: string,
|
@Query("search") search?: string,
|
||||||
@Query("status") status?: string,
|
@Query("status") status?: string,
|
||||||
@Query("method") method?: string,
|
@Query("method") method?: string,
|
||||||
@Query("page") page?: string,
|
@Query("page") page?: string,
|
||||||
@Query("pageSize") pageSize?: string,
|
@Query("pageSize") pageSize?: string,
|
||||||
) {
|
) {
|
||||||
|
// Per-user trade-direction scope, applied via the booking in ref_id.
|
||||||
|
const allowed =
|
||||||
|
await this.userTradeAccessService.resolveAllowedDirections(user);
|
||||||
return this.paymentService.getAll({
|
return this.paymentService.getAll({
|
||||||
search,
|
search,
|
||||||
status,
|
status,
|
||||||
method,
|
method,
|
||||||
page: page ? parseInt(page) : 1,
|
page: page ? parseInt(page) : 1,
|
||||||
pageSize: pageSize ? parseInt(pageSize) : 10,
|
pageSize: pageSize ? parseInt(pageSize) : 10,
|
||||||
|
tradeDirections: allowed ?? undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
|
|
||||||
import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
|
import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
|
||||||
import { BillingModule } from "../billing/billing.module";
|
import { BillingModule } from "../billing/billing.module";
|
||||||
|
import { UserTradeAccessModule } from "../user-trade-access/user-trade-access.module";
|
||||||
// import { FirstMileModule } from "../first-mile/first-mile.module";
|
// import { FirstMileModule } from "../first-mile/first-mile.module";
|
||||||
// import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.module";
|
// import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.module";
|
||||||
import { PaymentRefundEntity } from "./entities/payment-refund.entity";
|
import { PaymentRefundEntity } from "./entities/payment-refund.entity";
|
||||||
@@ -64,6 +65,7 @@ function rabbitMQImport(): DynamicModule[] {
|
|||||||
timeout: Number(process.env.PAYMENT_API_HTTP_TIMEOUT_MS) || 60_000,
|
timeout: Number(process.env.PAYMENT_API_HTTP_TIMEOUT_MS) || 60_000,
|
||||||
}),
|
}),
|
||||||
ConfigModule,
|
ConfigModule,
|
||||||
|
UserTradeAccessModule,
|
||||||
forwardRef(() => BillingModule),
|
forwardRef(() => BillingModule),
|
||||||
// forwardRef(() => TrainSchedulingModule),
|
// forwardRef(() => TrainSchedulingModule),
|
||||||
// FirstMileModule,
|
// FirstMileModule,
|
||||||
|
|||||||
@@ -188,3 +188,45 @@ describe("PaymentClientService.confirmOtp", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("PaymentService.markIntentSucceeded", () => {
|
||||||
|
const build = (rows: Record<string, unknown>[]) => {
|
||||||
|
const repo = makeRepo(rows);
|
||||||
|
const billing = { settleByPaymentId: jest.fn().mockResolvedValue(null) };
|
||||||
|
const service = new PaymentService(
|
||||||
|
repo as never,
|
||||||
|
{} as never,
|
||||||
|
billing as never,
|
||||||
|
);
|
||||||
|
return { service, repo, billing };
|
||||||
|
};
|
||||||
|
|
||||||
|
it("re-notifies billing on an already-success intent so a settle that died mid-way converges on redelivery", async () => {
|
||||||
|
const paidAt = new Date("2026-08-01T09:00:00.000Z");
|
||||||
|
const { service, repo, billing } = build([
|
||||||
|
localIntent({ status: "success", transactionId: "txn-1", paidAt }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await service.markIntentSucceeded("intent-1", {
|
||||||
|
notify: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.alreadyFinalized).toBe(true);
|
||||||
|
// No re-write of the intent row…
|
||||||
|
expect(repo.update).not.toHaveBeenCalled();
|
||||||
|
// …but billing still gets the (idempotent) settle call.
|
||||||
|
expect(billing.settleByPaymentId).toHaveBeenCalledWith(
|
||||||
|
"intent-1",
|
||||||
|
"txn-1",
|
||||||
|
paidAt,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not notify billing when notify is false, even when already success", async () => {
|
||||||
|
const { service, billing } = build([localIntent({ status: "success" })]);
|
||||||
|
|
||||||
|
await service.markIntentSucceeded("intent-1", { notify: false });
|
||||||
|
|
||||||
|
expect(billing.settleByPaymentId).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
Logger,
|
Logger,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
|
import { applyBookingRefDirectionScope } from "../user-trade-access/trade-scope.util";
|
||||||
import { PaymentEntity } from "./entities/payment.entity";
|
import { PaymentEntity } from "./entities/payment.entity";
|
||||||
import { PaymentRepository } from "./payment.repository";
|
import { PaymentRepository } from "./payment.repository";
|
||||||
import { PaymentClientService } from "./payment-client.service";
|
import { PaymentClientService } from "./payment-client.service";
|
||||||
@@ -110,6 +111,8 @@ export class PaymentService {
|
|||||||
method?: string;
|
method?: string;
|
||||||
page?: number;
|
page?: number;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
|
/** Per-user trade-direction scope, applied via the booking in ref_id. */
|
||||||
|
tradeDirections?: string[];
|
||||||
}) {
|
}) {
|
||||||
const { search, status, method, page = 1, pageSize = 10 } = filters;
|
const { search, status, method, page = 1, pageSize = 10 } = filters;
|
||||||
const skip = (page - 1) * pageSize;
|
const skip = (page - 1) * pageSize;
|
||||||
@@ -128,6 +131,9 @@ export class PaymentService {
|
|||||||
if (method) {
|
if (method) {
|
||||||
qb.andWhere("payment.method = :method", { method });
|
qb.andWhere("payment.method = :method", { method });
|
||||||
}
|
}
|
||||||
|
if (filters.tradeDirections) {
|
||||||
|
applyBookingRefDirectionScope(qb, "payment.ref_id", filters.tradeDirections);
|
||||||
|
}
|
||||||
|
|
||||||
const [items, total] = await qb
|
const [items, total] = await qb
|
||||||
.orderBy("payment.createdAt", "DESC")
|
.orderBy("payment.createdAt", "DESC")
|
||||||
@@ -232,10 +238,15 @@ export class PaymentService {
|
|||||||
referenceType: PaymentReferenceType.SHIPMENT,
|
referenceType: PaymentReferenceType.SHIPMENT,
|
||||||
referenceId: input.referenceId,
|
referenceId: input.referenceId,
|
||||||
orderRef: input.orderRef,
|
orderRef: input.orderRef,
|
||||||
// amountMinor: input.amountMinor,
|
|
||||||
// CBE_BILL must carry the REAL amount: /cbe/payment verifies what the customer was
|
// CBE_BILL must carry the REAL amount: /cbe/payment verifies what the customer was
|
||||||
// debited against the intent amount, so the 1-birr dev shortcut would break it.
|
// debited against the intent amount, so the dev shortcut would break it.
|
||||||
amountMinor: isCbeBill ? input.amountMinor : 1,
|
// CAC bank rejects amounts below 10 (DJF bounds 10–100,000), so its dev
|
||||||
|
// shortcut floor is 10, not 1.
|
||||||
|
amountMinor: isCbeBill
|
||||||
|
? input.amountMinor
|
||||||
|
: input.method === ProviderMethod.CAC_BANK
|
||||||
|
? 10
|
||||||
|
: 1,
|
||||||
currency: input.currency,
|
currency: input.currency,
|
||||||
provider: input.method as ProviderMethod,
|
provider: input.method as ProviderMethod,
|
||||||
platform: input.platform,
|
platform: input.platform,
|
||||||
@@ -446,7 +457,19 @@ export class PaymentService {
|
|||||||
): Promise<{ alreadyFinalized: boolean }> {
|
): Promise<{ alreadyFinalized: boolean }> {
|
||||||
const intent = await this.paymentRepo.findOneBy({ id: intentId });
|
const intent = await this.paymentRepo.findOneBy({ id: intentId });
|
||||||
if (!intent) throw new NotFoundException("PaymentIntent not found");
|
if (!intent) throw new NotFoundException("PaymentIntent not found");
|
||||||
if (intent.status === "success") return { alreadyFinalized: true };
|
if (intent.status === "success") {
|
||||||
|
// Still notify billing: a prior delivery may have flipped the intent to
|
||||||
|
// success and then died before the invoice settled (the two steps are not
|
||||||
|
// atomic). settleByPaymentId is idempotent — no open invoice, no-op.
|
||||||
|
if (opts.notify !== false) {
|
||||||
|
await this.billing.settleByPaymentId(
|
||||||
|
intent.id,
|
||||||
|
opts.providerTxnId ?? intent.transactionId ?? undefined,
|
||||||
|
opts.paidAt ?? intent.paidAt ?? undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { alreadyFinalized: true };
|
||||||
|
}
|
||||||
|
|
||||||
const paidAt = opts.paidAt ?? new Date();
|
const paidAt = opts.paidAt ?? new Date();
|
||||||
await this.paymentRepo.update(
|
await this.paymentRepo.update(
|
||||||
|
|||||||
@@ -37,6 +37,27 @@ export class YardFacility extends BaseEntity {
|
|||||||
@Column({ name: 'handles_bulk', type: 'boolean', default: true })
|
@Column({ name: 'handles_bulk', type: 'boolean', default: true })
|
||||||
handlesBulk!: boolean;
|
handlesBulk!: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-side capability. Loading a type onto a train and receiving it off one
|
||||||
|
* need different ground: a facility can be equipped to send containers but
|
||||||
|
* have no space to stage arriving ones. `handles_container` / `handles_bulk`
|
||||||
|
* stay the coarse "is this type handled here at all" switch; these four say
|
||||||
|
* on which side. A yard is offered as a contract origin for a freight type
|
||||||
|
* when it handles the type AND the matching `_origin` flag is set, and as a
|
||||||
|
* destination on the same rule with `_destination`.
|
||||||
|
*/
|
||||||
|
@Column({ name: 'has_container_facility_origin', type: 'boolean', default: false })
|
||||||
|
hasContainerFacilityOrigin!: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'has_bulk_facility_origin', type: 'boolean', default: false })
|
||||||
|
hasBulkFacilityOrigin!: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'has_container_facility_destination', type: 'boolean', default: false })
|
||||||
|
hasContainerFacilityDestination!: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'has_bulk_facility_destination', type: 'boolean', default: false })
|
||||||
|
hasBulkFacilityDestination!: boolean;
|
||||||
|
|
||||||
@Column({ name: 'equipment_notes', type: 'text', nullable: true })
|
@Column({ name: 'equipment_notes', type: 'text', nullable: true })
|
||||||
equipmentNotes?: string | null;
|
equipmentNotes?: string | null;
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,20 @@ export interface YardFacilityInfo {
|
|||||||
/** Containers need a reach stacker/gantry — not every facility has one. */
|
/** Containers need a reach stacker/gantry — not every facility has one. */
|
||||||
handlesContainer: boolean;
|
handlesContainer: boolean;
|
||||||
handlesBulk: boolean;
|
handlesBulk: boolean;
|
||||||
|
/**
|
||||||
|
* The same capability split by side of the trip — loading onto a train and
|
||||||
|
* receiving off one need different ground. Always false where the coarse
|
||||||
|
* `handles*` flag for that type is false.
|
||||||
|
*/
|
||||||
|
hasContainerFacilityOrigin: boolean;
|
||||||
|
hasBulkFacilityOrigin: boolean;
|
||||||
|
hasContainerFacilityDestination: boolean;
|
||||||
|
hasBulkFacilityDestination: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Which side of the trip a yard is being considered for. */
|
||||||
|
export type YardSide = 'ORIGIN' | 'DESTINATION';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Which yards can handle cargo, and what kind.
|
* Which yards can handle cargo, and what kind.
|
||||||
*
|
*
|
||||||
@@ -38,7 +50,11 @@ export class YardFacilitiesService {
|
|||||||
y.has_facility AS "hasFacility",
|
y.has_facility AS "hasFacility",
|
||||||
f.has_warehouse AS "hasWarehouse",
|
f.has_warehouse AS "hasWarehouse",
|
||||||
f.handles_container AS "handlesContainer",
|
f.handles_container AS "handlesContainer",
|
||||||
f.handles_bulk AS "handlesBulk"
|
f.handles_bulk AS "handlesBulk",
|
||||||
|
f.has_container_facility_origin AS "hasContainerFacilityOrigin",
|
||||||
|
f.has_bulk_facility_origin AS "hasBulkFacilityOrigin",
|
||||||
|
f.has_container_facility_destination AS "hasContainerFacilityDestination",
|
||||||
|
f.has_bulk_facility_destination AS "hasBulkFacilityDestination"
|
||||||
FROM freight.yards y
|
FROM freight.yards y
|
||||||
LEFT JOIN freight.yard_facilities f
|
LEFT JOIN freight.yard_facilities f
|
||||||
ON f.yard_id = y.id AND f.deleted_at IS NULL AND f.is_active = true`;
|
ON f.yard_id = y.id AND f.deleted_at IS NULL AND f.is_active = true`;
|
||||||
@@ -51,17 +67,33 @@ export class YardFacilitiesService {
|
|||||||
hasWarehouse: boolean | null;
|
hasWarehouse: boolean | null;
|
||||||
handlesContainer: boolean | null;
|
handlesContainer: boolean | null;
|
||||||
handlesBulk: boolean | null;
|
handlesBulk: boolean | null;
|
||||||
|
hasContainerFacilityOrigin: boolean | null;
|
||||||
|
hasBulkFacilityOrigin: boolean | null;
|
||||||
|
hasContainerFacilityDestination: boolean | null;
|
||||||
|
hasBulkFacilityDestination: boolean | null;
|
||||||
}): YardFacilityInfo {
|
}): YardFacilityInfo {
|
||||||
// No facility record means no capability, whatever the flag says.
|
// No facility record means no capability, whatever the flag says.
|
||||||
const hasFacility = Boolean(row.hasFacility);
|
const hasFacility = Boolean(row.hasFacility);
|
||||||
|
const handlesContainer = hasFacility && Boolean(row.handlesContainer);
|
||||||
|
const handlesBulk = hasFacility && Boolean(row.handlesBulk);
|
||||||
return {
|
return {
|
||||||
yardId: row.yardId,
|
yardId: row.yardId,
|
||||||
yardCode: row.yardCode,
|
yardCode: row.yardCode,
|
||||||
yardLabel: row.yardLabel,
|
yardLabel: row.yardLabel,
|
||||||
hasFacility,
|
hasFacility,
|
||||||
hasWarehouse: hasFacility && Boolean(row.hasWarehouse),
|
hasWarehouse: hasFacility && Boolean(row.hasWarehouse),
|
||||||
handlesContainer: hasFacility && Boolean(row.handlesContainer),
|
handlesContainer,
|
||||||
handlesBulk: hasFacility && Boolean(row.handlesBulk),
|
handlesBulk,
|
||||||
|
// Gated on the coarse flag so the two can't contradict each other: a
|
||||||
|
// per-side flag left set on a type the facility no longer handles at all
|
||||||
|
// never resurrects that type.
|
||||||
|
hasContainerFacilityOrigin:
|
||||||
|
handlesContainer && Boolean(row.hasContainerFacilityOrigin),
|
||||||
|
hasBulkFacilityOrigin: handlesBulk && Boolean(row.hasBulkFacilityOrigin),
|
||||||
|
hasContainerFacilityDestination:
|
||||||
|
handlesContainer && Boolean(row.hasContainerFacilityDestination),
|
||||||
|
hasBulkFacilityDestination:
|
||||||
|
handlesBulk && Boolean(row.hasBulkFacilityDestination),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,4 +129,26 @@ export class YardFacilitiesService {
|
|||||||
? facility.handlesContainer
|
? facility.handlesContainer
|
||||||
: facility.handlesBulk;
|
: facility.handlesBulk;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Can this facility take this cargo on this side of the trip? The rule behind
|
||||||
|
* the contract's origin/destination yard pickers — keep it here so the API
|
||||||
|
* and the forms can't drift apart on what is offerable.
|
||||||
|
*/
|
||||||
|
canHandleFreightOnSide(
|
||||||
|
facility: YardFacilityInfo | null,
|
||||||
|
freightType: string | null | undefined,
|
||||||
|
side: YardSide,
|
||||||
|
): boolean {
|
||||||
|
if (!facility?.hasFacility) return false;
|
||||||
|
const isContainer = String(freightType).toUpperCase() === 'CONTAINER';
|
||||||
|
if (side === 'ORIGIN') {
|
||||||
|
return isContainer
|
||||||
|
? facility.hasContainerFacilityOrigin
|
||||||
|
: facility.hasBulkFacilityOrigin;
|
||||||
|
}
|
||||||
|
return isContainer
|
||||||
|
? facility.hasContainerFacilityDestination
|
||||||
|
: facility.hasBulkFacilityDestination;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,12 +7,14 @@ export class SaveSignatureDto {
|
|||||||
@MinLength(1)
|
@MinLength(1)
|
||||||
signerDisplayName!: string;
|
signerDisplayName!: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiPropertyOptional({
|
||||||
description: 'PNG signature image as base64 (with or without data URL prefix)',
|
description:
|
||||||
|
'PNG signature image as base64 (with or without data URL prefix). Omit to keep the existing saved signature (stamp-only update).',
|
||||||
})
|
})
|
||||||
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(20)
|
@MinLength(20)
|
||||||
signatureImageBase64!: string;
|
signatureImageBase64?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ import { SavedSignatureDto } from './dto/save-signature.dto';
|
|||||||
export interface UpsertSignatureInput {
|
export interface UpsertSignatureInput {
|
||||||
userId: string;
|
userId: string;
|
||||||
signerDisplayName: string;
|
signerDisplayName: string;
|
||||||
signatureImageBase64: string;
|
/** Optional; omitted = keep the existing saved signature (stamp-only update). */
|
||||||
|
signatureImageBase64?: string;
|
||||||
/** Optional company stamp/seal; omitted = keep the existing saved stamp. */
|
/** Optional company stamp/seal; omitted = keep the existing saved stamp. */
|
||||||
stampImageBase64?: string;
|
stampImageBase64?: string;
|
||||||
}
|
}
|
||||||
@@ -46,12 +47,14 @@ export class SignaturesService {
|
|||||||
const previousFileId = existing?.signatureFileId ?? null;
|
const previousFileId = existing?.signatureFileId ?? null;
|
||||||
const previousStampFileId = existing?.stampFileId ?? null;
|
const previousStampFileId = existing?.stampFileId ?? null;
|
||||||
|
|
||||||
const fileRecord = await this.filesService.upload({
|
const fileRecord = input.signatureImageBase64
|
||||||
resourceId: input.userId,
|
? await this.filesService.upload({
|
||||||
resource: 'saved_signatures',
|
resourceId: input.userId,
|
||||||
code: 'signature',
|
resource: 'saved_signatures',
|
||||||
file: this.toUploadFile('signature', input.userId, input.signatureImageBase64),
|
code: 'signature',
|
||||||
});
|
file: this.toUploadFile('signature', input.userId, input.signatureImageBase64),
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
const stampRecord = input.stampImageBase64
|
const stampRecord = input.stampImageBase64
|
||||||
? await this.filesService.upload({
|
? await this.filesService.upload({
|
||||||
@@ -65,13 +68,13 @@ export class SignaturesService {
|
|||||||
const saved = await this.signaturesRepository.upsert({
|
const saved = await this.signaturesRepository.upsert({
|
||||||
userId: input.userId,
|
userId: input.userId,
|
||||||
signerDisplayName: input.signerDisplayName,
|
signerDisplayName: input.signerDisplayName,
|
||||||
signatureFileId: fileRecord.id,
|
// Omitted image keeps whatever was saved before.
|
||||||
// Omitted stamp keeps whatever was saved before.
|
...(fileRecord ? { signatureFileId: fileRecord.id } : {}),
|
||||||
...(stampRecord ? { stampFileId: stampRecord.id } : {}),
|
...(stampRecord ? { stampFileId: stampRecord.id } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const staleIds = [
|
const staleIds = [
|
||||||
previousFileId !== fileRecord.id ? previousFileId : null,
|
fileRecord && previousFileId !== fileRecord.id ? previousFileId : null,
|
||||||
stampRecord && previousStampFileId !== stampRecord.id
|
stampRecord && previousStampFileId !== stampRecord.id
|
||||||
? previousStampFileId
|
? previousStampFileId
|
||||||
: null,
|
: null,
|
||||||
|
|||||||
@@ -1437,6 +1437,7 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
*/
|
*/
|
||||||
async getBatchBoard(
|
async getBatchBoard(
|
||||||
query: BatchBoardQueryDto = {},
|
query: BatchBoardQueryDto = {},
|
||||||
|
allowedDirections?: string[],
|
||||||
): Promise<BatchBoardListResponse> {
|
): Promise<BatchBoardListResponse> {
|
||||||
// Board cards are heavy (per-schedule booking summaries), so the default
|
// Board cards are heavy (per-schedule booking summaries), so the default
|
||||||
// page is smaller than the toolkit-wide 20.
|
// page is smaller than the toolkit-wide 20.
|
||||||
@@ -1444,6 +1445,11 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
defaultPageSize: 12,
|
defaultPageSize: 12,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The board is IMPORT-only — a user scoped away from IMPORT sees nothing.
|
||||||
|
if (allowedDirections && !allowedDirections.includes("IMPORT")) {
|
||||||
|
return { items: [], meta: buildPaginationMeta(0, page, pageSize) };
|
||||||
|
}
|
||||||
|
|
||||||
// Status filter: any subset of the lifecycle. Omitted = all statuses, so
|
// Status filter: any subset of the lifecycle. Omitted = all statuses, so
|
||||||
// arrived / cancelled / dispatched schedules stay visible as history.
|
// arrived / cancelled / dispatched schedules stay visible as history.
|
||||||
const allowedStatuses = new Set<string>(BATCH_BOARD_STATUSES);
|
const allowedStatuses = new Set<string>(BATCH_BOARD_STATUSES);
|
||||||
@@ -1632,6 +1638,18 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
for (const b of candidates) {
|
for (const b of candidates) {
|
||||||
if (!pinnedIds.has(b.id)) bookings.push(b);
|
if (!pinnedIds.has(b.id)) bookings.push(b);
|
||||||
}
|
}
|
||||||
|
// Expiry frees the schedule pin (expire() nulls train_schedule_id), so
|
||||||
|
// expired bookings match neither query above — merge them back so the
|
||||||
|
// board keeps its expired lane. Display-only: boardState maps them to
|
||||||
|
// EXPIRED, which every capacity meter already excludes.
|
||||||
|
const expiredPool =
|
||||||
|
await this.bookingsRepository.findExpiredByCorridorDay(
|
||||||
|
stops,
|
||||||
|
eatDay(s.scheduledDepartureDate),
|
||||||
|
);
|
||||||
|
for (const b of expiredPool) {
|
||||||
|
if (!pinnedIds.has(b.id)) bookings.push(b);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// The board must still render the pinned bookings.
|
// The board must still render the pinned bookings.
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
@@ -3131,6 +3149,14 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
async intercityCapacity(scheduleId: string): Promise<{
|
async intercityCapacity(scheduleId: string): Promise<{
|
||||||
budget: CorridorBudget;
|
budget: CorridorBudget;
|
||||||
needFor: (booking: Booking) => Capacity;
|
needFor: (booking: Booking) => Capacity;
|
||||||
|
/**
|
||||||
|
* Per-wagon-type split of `needFor(booking).wagons`, against THIS
|
||||||
|
* schedule's own wagon stock — so the same booking reads differently on a
|
||||||
|
* different train. Empty when the stock can't be resolved.
|
||||||
|
*/
|
||||||
|
breakdownFor: (
|
||||||
|
booking: Booking,
|
||||||
|
) => Array<{ wagonTypeId: string; code: string; wagons: number }>;
|
||||||
} | null> {
|
} | null> {
|
||||||
const schedule =
|
const schedule =
|
||||||
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||||
@@ -3145,7 +3171,24 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
// still accepts ride-alongs on its empty legs — that is the whole point
|
// still accepts ride-alongs on its empty legs — that is the whole point
|
||||||
// of the ride-along flow.
|
// of the ride-along flow.
|
||||||
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
||||||
return { budget, needFor: (booking) => this.needFor(booking, wagonDims) };
|
// Physical stock of THIS schedule's train (built consist, or the yard fleet
|
||||||
|
// it will draw from) — what makes the breakdown train-specific.
|
||||||
|
const stock = await this.trainSchedulingService.wagonStockForSchedule(
|
||||||
|
schedule.id,
|
||||||
|
schedule.originStationId,
|
||||||
|
budget.stops,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
budget,
|
||||||
|
needFor: (booking) => this.needFor(booking, wagonDims),
|
||||||
|
breakdownFor: (booking) =>
|
||||||
|
this.wagonBreakdownFor(
|
||||||
|
booking,
|
||||||
|
wagonDims,
|
||||||
|
stock.remainingByTypeId,
|
||||||
|
stock.codesByTypeId,
|
||||||
|
),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -4094,6 +4137,78 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The wagon count of {@link wagonsFor}, split across the wagon TYPES this
|
||||||
|
* particular train stocks — "3 × N35 + 1 × PW2" rather than a bare 4.
|
||||||
|
*
|
||||||
|
* `wagonsFor` sizes the booking on ONE representative type (the first the
|
||||||
|
* cargo type allows), which is all the abstract budget needs. Staff placing a
|
||||||
|
* ride-along need the physical picture: how many of each type this schedule
|
||||||
|
* must actually give up. So each allowed type is sized on its OWN capacity and
|
||||||
|
* items-fit, then filled greedily from the type with the largest per-wagon
|
||||||
|
* take, bounded by what the schedule has left of it.
|
||||||
|
*
|
||||||
|
* Because the stock is per-schedule, the same booking breaks down differently
|
||||||
|
* on a train stocking 60T N35s than on one stocking 40T PW2s. Returns [] when
|
||||||
|
* the booking's types are unconfigured or the train stocks none of them — the
|
||||||
|
* caller then shows the plain total.
|
||||||
|
*/
|
||||||
|
private wagonBreakdownFor(
|
||||||
|
booking: Booking,
|
||||||
|
wagonDims: WagonDims,
|
||||||
|
stockByTypeId: Map<string, number>,
|
||||||
|
codesByTypeId: Map<string, string>,
|
||||||
|
): Array<{ wagonTypeId: string; code: string; wagons: number }> {
|
||||||
|
const total = this.wagonsFor(booking, wagonDims);
|
||||||
|
if (total <= 0) return [];
|
||||||
|
|
||||||
|
// Per-wagon take of each allowed type ON THIS TRAIN, largest first: a type
|
||||||
|
// that swallows more of the booking per wagon needs fewer wagons.
|
||||||
|
const options = this.allowedDimsWithTypes(booking, wagonDims)
|
||||||
|
.filter((o) => o.wagonTypeId && (stockByTypeId.get(o.wagonTypeId) ?? 0) > 0)
|
||||||
|
.map((o) => {
|
||||||
|
const wagonTypeId = o.wagonTypeId as string;
|
||||||
|
const wagonsIfAlone = Math.max(
|
||||||
|
1,
|
||||||
|
bulkItemWagonsRequired(
|
||||||
|
booking,
|
||||||
|
o.dims.capacityTons,
|
||||||
|
bulkItemsFitFor(booking.cargoType, wagonTypeId),
|
||||||
|
) ||
|
||||||
|
(o.dims.capacityTons > 0
|
||||||
|
? Math.ceil(bookingCargoTons(booking) / o.dims.capacityTons)
|
||||||
|
: total),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
wagonTypeId,
|
||||||
|
code: codesByTypeId.get(wagonTypeId) ?? '—',
|
||||||
|
available: stockByTypeId.get(wagonTypeId) ?? 0,
|
||||||
|
// Share of the whole booking one wagon of this type carries.
|
||||||
|
takePerWagon: 1 / wagonsIfAlone,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.takePerWagon - a.takePerWagon);
|
||||||
|
if (!options.length) return [];
|
||||||
|
|
||||||
|
// Fill greedily by take, capped by stock; `remaining` is the fraction of the
|
||||||
|
// booking still unplaced, so a wagon of any type covers `takePerWagon` of it.
|
||||||
|
const out: Array<{ wagonTypeId: string; code: string; wagons: number }> = [];
|
||||||
|
let remaining = 1;
|
||||||
|
for (const option of options) {
|
||||||
|
if (remaining <= 1e-9) break;
|
||||||
|
const wagons = Math.min(
|
||||||
|
option.available,
|
||||||
|
Math.ceil(remaining / option.takePerWagon),
|
||||||
|
);
|
||||||
|
if (wagons <= 0) continue;
|
||||||
|
out.push({ wagonTypeId: option.wagonTypeId, code: option.code, wagons });
|
||||||
|
remaining -= wagons * option.takePerWagon;
|
||||||
|
}
|
||||||
|
// The train cannot hold the whole booking in the types it stocks — the
|
||||||
|
// `fits` check already fails it; report only what it CAN take.
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
private fits(need: Capacity, budget: Capacity): boolean {
|
private fits(need: Capacity, budget: Capacity): boolean {
|
||||||
return (
|
return (
|
||||||
need.wagons <= budget.wagons &&
|
need.wagons <= budget.wagons &&
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { BookingBatchService } from './booking-batch.service';
|
||||||
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The intercity ride-along board shows WHICH wagon types a booking takes from
|
||||||
|
* the train it is being placed on ("3 × N35 + 1 × PW2"), not just how many
|
||||||
|
* wagons. Because the split is drawn against that schedule's own stock, the
|
||||||
|
* same booking must read differently on a different train.
|
||||||
|
*/
|
||||||
|
describe('BookingBatchService — intercity wagon breakdown', () => {
|
||||||
|
const N35 = 'wagon-type-n35';
|
||||||
|
const PW2 = 'wagon-type-pw2';
|
||||||
|
|
||||||
|
const dims = (capacityTons: number) => ({
|
||||||
|
capacityTons,
|
||||||
|
lengthMeters: 14,
|
||||||
|
tareWeightTons: 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
const wagonDims = {
|
||||||
|
bulk: dims(60),
|
||||||
|
container: dims(60),
|
||||||
|
byWagonTypeId: new Map([
|
||||||
|
[N35, dims(60)],
|
||||||
|
[PW2, dims(20)],
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
|
||||||
|
const codes = new Map([
|
||||||
|
[N35, 'N35'],
|
||||||
|
[PW2, 'PW2'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 400 break-bulk items weighing 800t — 2t per item. On a 60t N35 that is 30
|
||||||
|
* items per wagon (14 wagons); on a 20t PW2, 10 items (40 wagons).
|
||||||
|
*/
|
||||||
|
const perItemBooking = {
|
||||||
|
id: 'booking-1',
|
||||||
|
freightType: 'BULK',
|
||||||
|
cargoTotalWeightVgm: 400,
|
||||||
|
bulkTotalWeightTons: 800,
|
||||||
|
bookingContainers: [],
|
||||||
|
cargoType: {
|
||||||
|
wagonTypes: [{ id: N35 }, { id: PW2 }],
|
||||||
|
itemsPerWagonMap: {},
|
||||||
|
},
|
||||||
|
} as unknown as Booking;
|
||||||
|
|
||||||
|
const service = Object.create(
|
||||||
|
BookingBatchService.prototype,
|
||||||
|
) as BookingBatchService;
|
||||||
|
|
||||||
|
const breakdown = (
|
||||||
|
booking: Booking,
|
||||||
|
stock: Map<string, number>,
|
||||||
|
): Array<{ code: string; wagons: number }> =>
|
||||||
|
(
|
||||||
|
service as unknown as {
|
||||||
|
wagonBreakdownFor: (
|
||||||
|
b: Booking,
|
||||||
|
d: typeof wagonDims,
|
||||||
|
s: Map<string, number>,
|
||||||
|
c: Map<string, string>,
|
||||||
|
) => Array<{ code: string; wagons: number }>;
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.wagonBreakdownFor(booking, wagonDims, stock, codes)
|
||||||
|
.map(({ code, wagons }) => ({ code, wagons }));
|
||||||
|
|
||||||
|
it('takes the highest-capacity type first when the train stocks plenty', () => {
|
||||||
|
const rows = breakdown(perItemBooking, new Map([[N35, 50], [PW2, 50]]));
|
||||||
|
expect(rows).toEqual([{ code: 'N35', wagons: 14 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the smaller type for the remainder when the big one runs short', () => {
|
||||||
|
// Only 10 of the 14 N35s the booking wants — the rest rides PW2s. Ten N35s
|
||||||
|
// carry 10/14 of the booking, leaving 4/14, which needs ceil(40 × 4/14) PW2s.
|
||||||
|
const rows = breakdown(perItemBooking, new Map([[N35, 10], [PW2, 50]]));
|
||||||
|
expect(rows[0]).toEqual({ code: 'N35', wagons: 10 });
|
||||||
|
expect(rows[1].code).toBe('PW2');
|
||||||
|
expect(rows[1].wagons).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads differently on a train that stocks only the small type', () => {
|
||||||
|
const rows = breakdown(perItemBooking, new Map([[PW2, 60]]));
|
||||||
|
expect(rows).toEqual([{ code: 'PW2', wagons: 40 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honours the configured items-per-wagon fit over raw tonnage', () => {
|
||||||
|
// Floor space binds before weight: an N35 physically holds 20 of these
|
||||||
|
// items even though 30 would fit by weight → 20 wagons, not 14.
|
||||||
|
const floorBound = {
|
||||||
|
...perItemBooking,
|
||||||
|
cargoType: {
|
||||||
|
wagonTypes: [{ id: N35 }],
|
||||||
|
itemsPerWagonMap: { [N35]: 20 },
|
||||||
|
},
|
||||||
|
} as unknown as Booking;
|
||||||
|
expect(breakdown(floorBound, new Map([[N35, 50]]))).toEqual([
|
||||||
|
{ code: 'N35', wagons: 20 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns nothing when the train stocks none of the allowed types', () => {
|
||||||
|
expect(breakdown(perItemBooking, new Map([['other-type', 30]]))).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -155,12 +155,17 @@ export class IntercityService {
|
|||||||
return {
|
return {
|
||||||
...this.mapBooking(booking, need),
|
...this.mapBooking(booking, need),
|
||||||
need,
|
need,
|
||||||
|
wagonBreakdown: capacity?.breakdownFor(booking) ?? [],
|
||||||
fits: Boolean(need && capacity && leg && capacity.budget.fits(need, leg)),
|
fits: Boolean(need && capacity && leg && capacity.budget.fits(need, leg)),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
accepted: accepted.map((booking) => {
|
accepted: accepted.map((booking) => {
|
||||||
const need = capacity?.needFor(booking) ?? null;
|
const need = capacity?.needFor(booking) ?? null;
|
||||||
return { ...this.mapBooking(booking, need), need };
|
return {
|
||||||
|
...this.mapBooking(booking, need),
|
||||||
|
need,
|
||||||
|
wagonBreakdown: capacity?.breakdownFor(booking) ?? [],
|
||||||
|
};
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -200,7 +205,9 @@ export class IntercityService {
|
|||||||
where: { id: bookingId },
|
where: { id: bookingId },
|
||||||
relations: {
|
relations: {
|
||||||
bookingContainers: { containerType: true },
|
bookingContainers: { containerType: true },
|
||||||
cargoType: true,
|
// wagonTypes drives the break-bulk items-per-wagon fit — the accept
|
||||||
|
// check must size the booking exactly as the candidate list did.
|
||||||
|
cargoType: { wagonTypes: true },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!booking) {
|
if (!booking) {
|
||||||
@@ -326,6 +333,10 @@ export class IntercityService {
|
|||||||
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
||||||
.leftJoinAndSelect('bookingContainer.containerType', 'containerType')
|
.leftJoinAndSelect('bookingContainer.containerType', 'containerType')
|
||||||
.leftJoinAndSelect('booking.cargoType', 'cargoType')
|
.leftJoinAndSelect('booking.cargoType', 'cargoType')
|
||||||
|
// The allowed wagon-type list is what sizes a break-bulk (PER_ITEM)
|
||||||
|
// booking: without it `bulkItemsFitFor` reads no items-per-wagon fit and
|
||||||
|
// the wagon count silently degrades to tonnage-only.
|
||||||
|
.leftJoinAndSelect('cargoType.wagonTypes', 'cargoWagonType')
|
||||||
.leftJoinAndSelect('booking.originYard', 'originYard')
|
.leftJoinAndSelect('booking.originYard', 'originYard')
|
||||||
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
|
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
|
||||||
.where(`booking.trade_direction = 'DOMESTIC'`)
|
.where(`booking.trade_direction = 'DOMESTIC'`)
|
||||||
@@ -355,6 +366,10 @@ export class IntercityService {
|
|||||||
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
||||||
.leftJoinAndSelect('bookingContainer.containerType', 'containerType')
|
.leftJoinAndSelect('bookingContainer.containerType', 'containerType')
|
||||||
.leftJoinAndSelect('booking.cargoType', 'cargoType')
|
.leftJoinAndSelect('booking.cargoType', 'cargoType')
|
||||||
|
// The allowed wagon-type list is what sizes a break-bulk (PER_ITEM)
|
||||||
|
// booking: without it `bulkItemsFitFor` reads no items-per-wagon fit and
|
||||||
|
// the wagon count silently degrades to tonnage-only.
|
||||||
|
.leftJoinAndSelect('cargoType.wagonTypes', 'cargoWagonType')
|
||||||
.leftJoinAndSelect('booking.originYard', 'originYard')
|
.leftJoinAndSelect('booking.originYard', 'originYard')
|
||||||
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
|
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
|
||||||
.where(`booking.trade_direction = 'DOMESTIC'`)
|
.where(`booking.trade_direction = 'DOMESTIC'`)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||||
import type { Response } from "express";
|
import type { Response } from "express";
|
||||||
import type { AuthUserPayload } from "../../common/resolve-auth-user-id";
|
import type { AuthUserPayload } from "../../common/resolve-auth-user-id";
|
||||||
|
import { UserTradeAccessService } from "../user-trade-access/user-trade-access.service";
|
||||||
import { resolveAuthUserId } from "../../common/resolve-auth-user-id";
|
import { resolveAuthUserId } from "../../common/resolve-auth-user-id";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -66,6 +67,7 @@ export class TrainSchedulingController {
|
|||||||
private readonly intercityService: IntercityService,
|
private readonly intercityService: IntercityService,
|
||||||
private readonly bookingJourneyService: BookingJourneyService,
|
private readonly bookingJourneyService: BookingJourneyService,
|
||||||
private readonly billingService: BillingService,
|
private readonly billingService: BillingService,
|
||||||
|
private readonly userTradeAccessService: UserTradeAccessService,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
@Get("my-booking-windows")
|
@Get("my-booking-windows")
|
||||||
@@ -130,8 +132,14 @@ export class TrainSchedulingController {
|
|||||||
summary:
|
summary:
|
||||||
"Batch monitoring board: paginated import schedules (all statuses) with bookings grouped by state",
|
"Batch monitoring board: paginated import schedules (all statuses) with bookings grouped by state",
|
||||||
})
|
})
|
||||||
getBatchBoard(@Query() query: BatchBoardQueryDto) {
|
async getBatchBoard(
|
||||||
return this.bookingBatchService.getBatchBoard(query);
|
@Query() query: BatchBoardQueryDto,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
) {
|
||||||
|
// Batch board is IMPORT-only — a user without IMPORT access sees nothing.
|
||||||
|
const allowed =
|
||||||
|
await this.userTradeAccessService.resolveAllowedDirections(user);
|
||||||
|
return this.bookingBatchService.getBatchBoard(query, allowed ?? undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("batch-board/:scheduleId")
|
@Get("batch-board/:scheduleId")
|
||||||
@@ -868,15 +876,31 @@ export class TrainSchedulingController {
|
|||||||
@Get("container/schedules")
|
@Get("container/schedules")
|
||||||
@TrainSchedulingView()
|
@TrainSchedulingView()
|
||||||
@ApiOperation({ summary: "List container train schedules (paginated)" })
|
@ApiOperation({ summary: "List container train schedules (paginated)" })
|
||||||
getContainerTrainSchedules(@Query() query: ListTrainSchedulesQueryDto) {
|
async getContainerTrainSchedules(
|
||||||
return this.trainSchedulingService.getContainerTrainSchedules(query);
|
@Query() query: ListTrainSchedulesQueryDto,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
) {
|
||||||
|
const allowed =
|
||||||
|
await this.userTradeAccessService.resolveAllowedDirections(user);
|
||||||
|
return this.trainSchedulingService.getContainerTrainSchedules(
|
||||||
|
query,
|
||||||
|
allowed ?? undefined,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("bulk/schedules")
|
@Get("bulk/schedules")
|
||||||
@TrainSchedulingView()
|
@TrainSchedulingView()
|
||||||
@ApiOperation({ summary: "List bulk train schedules (paginated)" })
|
@ApiOperation({ summary: "List bulk train schedules (paginated)" })
|
||||||
getBulkTrainSchedules(@Query() query: ListTrainSchedulesQueryDto) {
|
async getBulkTrainSchedules(
|
||||||
return this.trainSchedulingService.getContainerTrainSchedules(query);
|
@Query() query: ListTrainSchedulesQueryDto,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
) {
|
||||||
|
const allowed =
|
||||||
|
await this.userTradeAccessService.resolveAllowedDirections(user);
|
||||||
|
return this.trainSchedulingService.getContainerTrainSchedules(
|
||||||
|
query,
|
||||||
|
allowed ?? undefined,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("container/schedules/:id")
|
@Get("container/schedules/:id")
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
|||||||
import { Session } from '@tria-plc/iamapi-common/entities/iam/user/session.entity';
|
import { Session } from '@tria-plc/iamapi-common/entities/iam/user/session.entity';
|
||||||
|
|
||||||
import { BillingModule } from '../billing/billing.module';
|
import { BillingModule } from '../billing/billing.module';
|
||||||
|
import { UserTradeAccessModule } from '../user-trade-access/user-trade-access.module';
|
||||||
import { BookingsModule } from '../bookings/bookings.module';
|
import { BookingsModule } from '../bookings/bookings.module';
|
||||||
import { Container } from '../container-management/entities/container.entity';
|
import { Container } from '../container-management/entities/container.entity';
|
||||||
import { LocomotivesModule } from '../locomotives/locomotives.module';
|
import { LocomotivesModule } from '../locomotives/locomotives.module';
|
||||||
@@ -63,6 +64,7 @@ import { ContractsModule } from '../contracts/contracts.module';
|
|||||||
]),
|
]),
|
||||||
forwardRef(() => BookingsModule),
|
forwardRef(() => BookingsModule),
|
||||||
BillingModule,
|
BillingModule,
|
||||||
|
UserTradeAccessModule,
|
||||||
NotificationsModule,
|
NotificationsModule,
|
||||||
NotificationInboxModule,
|
NotificationInboxModule,
|
||||||
LocomotivesModule,
|
LocomotivesModule,
|
||||||
|
|||||||
@@ -1435,4 +1435,44 @@ describe('TrainSchedulingService', () => {
|
|||||||
).rejects.toThrow(/free only 5/);
|
).rejects.toThrow(/free only 5/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('effectiveWagonsRequired', () => {
|
||||||
|
const effective = (booking: unknown): number =>
|
||||||
|
(service as never as { effectiveWagonsRequired(b: unknown): number })
|
||||||
|
.effectiveWagonsRequired(booking);
|
||||||
|
|
||||||
|
// 20-item / 100T break-bulk on 70T wagons with a 4-items-per-wagon fit:
|
||||||
|
// ceil(20/4) = 5 wagons.
|
||||||
|
const perItemBooking = (wagonsRequired: number | null) => ({
|
||||||
|
freightType: 'BULK',
|
||||||
|
cargoTotalWeightVgm: 20,
|
||||||
|
bulkTotalWeightTons: 100,
|
||||||
|
wagonsRequired,
|
||||||
|
cargoType: {
|
||||||
|
wagonTypes: [{ id: 'wt-nw5', capacityTons: 70 }],
|
||||||
|
itemsPerWagonMap: { 'wt-nw5': 4 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
it('overrides a stale too-small stamp with the item-aware recompute', () => {
|
||||||
|
// Stamped 1 by old code that read the PER_ITEM count (20) as tons.
|
||||||
|
expect(effective(perItemBooking(1))).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a stored stamp that is at least the recompute', () => {
|
||||||
|
expect(effective(perItemBooking(7))).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('trusts the stamp when BULK cargo relations are not loaded', () => {
|
||||||
|
expect(
|
||||||
|
effective({
|
||||||
|
freightType: 'BULK',
|
||||||
|
cargoTotalWeightVgm: 20,
|
||||||
|
bulkTotalWeightTons: 100,
|
||||||
|
wagonsRequired: 5,
|
||||||
|
cargoType: null,
|
||||||
|
}),
|
||||||
|
).toBe(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -136,6 +136,8 @@ import { deriveScheduleDirection } from './derive-schedule-direction.util';
|
|||||||
import { pickLowestFreeNumber, pickTrainNumberPool } from './train-number.util';
|
import { pickLowestFreeNumber, pickTrainNumberPool } from './train-number.util';
|
||||||
import {
|
import {
|
||||||
bookingCargoTons,
|
bookingCargoTons,
|
||||||
|
bulkItemsFitFor,
|
||||||
|
bulkItemWagonsRequired,
|
||||||
deriveTrainCapacityFromLocomotive,
|
deriveTrainCapacityFromLocomotive,
|
||||||
combinedLocomotiveLimits,
|
combinedLocomotiveLimits,
|
||||||
trainSetLocomotiveLimits,
|
trainSetLocomotiveLimits,
|
||||||
@@ -3897,13 +3899,25 @@ export class TrainSchedulingService {
|
|||||||
return Object.assign(detail, { warehouseAutomation });
|
return Object.assign(detail, { warehouseAutomation });
|
||||||
}
|
}
|
||||||
|
|
||||||
async getContainerTrainSchedules(query: ListTrainSchedulesQueryDto = {}) {
|
async getContainerTrainSchedules(
|
||||||
|
query: ListTrainSchedulesQueryDto = {},
|
||||||
|
allowedDirections?: string[],
|
||||||
|
) {
|
||||||
const { page, pageSize, skip, take } = normalizePagination(query);
|
const { page, pageSize, skip, take } = normalizePagination(query);
|
||||||
|
|
||||||
|
// Per-user trade-direction scope: schedules carry a `direction` column.
|
||||||
|
if (allowedDirections && allowedDirections.length === 0) {
|
||||||
|
return {
|
||||||
|
items: [],
|
||||||
|
meta: buildPaginationMeta(0, page, pageSize),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Exact-match filters (enum/id semantics). Freight type is derived from
|
// Exact-match filters (enum/id semantics). Freight type is derived from
|
||||||
// the bookings aboard — no column to match — so it rides on `id` as an
|
// the bookings aboard — no column to match — so it rides on `id` as an
|
||||||
// EXISTS fragment instead.
|
// EXISTS fragment instead.
|
||||||
const base: FindOptionsWhere<TrainSchedule> = {};
|
const base: FindOptionsWhere<TrainSchedule> = {};
|
||||||
|
if (allowedDirections) base.direction = In(allowedDirections) as never;
|
||||||
if (query.status) base.status = query.status;
|
if (query.status) base.status = query.status;
|
||||||
if (query.originStationId) base.originStationId = query.originStationId;
|
if (query.originStationId) base.originStationId = query.originStationId;
|
||||||
if (query.destinationStationId) base.destinationStationId = query.destinationStationId;
|
if (query.destinationStationId) base.destinationStationId = query.destinationStationId;
|
||||||
@@ -7335,7 +7349,14 @@ export class TrainSchedulingService {
|
|||||||
const byLength = containerWagonsForLines(booking.bookingContainers ?? []);
|
const byLength = containerWagonsForLines(booking.bookingContainers ?? []);
|
||||||
const byWeight =
|
const byWeight =
|
||||||
cargo > 0 && dims.capacityTons > 0 ? Math.ceil(cargo / dims.capacityTons) : 0;
|
cargo > 0 && dims.capacityTons > 0 ? Math.ceil(cargo / dims.capacityTons) : 0;
|
||||||
const wagons = Math.max(1, stored, byLength, byWeight);
|
// Break-bulk (PER_ITEM): indivisible items occupy more wagons than raw
|
||||||
|
// tonnage suggests — their tare must be pulled too (batch dimsFor parity).
|
||||||
|
const byItems = bulkItemWagonsRequired(
|
||||||
|
booking,
|
||||||
|
dims.capacityTons,
|
||||||
|
bulkItemsFitFor(booking.cargoType, wagonTypeId),
|
||||||
|
);
|
||||||
|
const wagons = Math.max(1, stored, byLength, byWeight, byItems);
|
||||||
return roundTons(cargo + wagons * dims.tareWeightTons);
|
return roundTons(cargo + wagons * dims.tareWeightTons);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7819,7 +7840,7 @@ export class TrainSchedulingService {
|
|||||||
*/
|
*/
|
||||||
private effectiveWagonsRequired(booking: Booking): number {
|
private effectiveWagonsRequired(booking: Booking): number {
|
||||||
const stored = Number(booking.wagonsRequired);
|
const stored = Number(booking.wagonsRequired);
|
||||||
if (stored > 0) return Math.ceil(stored);
|
const storedCeil = stored > 0 ? Math.ceil(stored) : 0;
|
||||||
const bulkCapacities = (booking.cargoType?.wagonTypes ?? [])
|
const bulkCapacities = (booking.cargoType?.wagonTypes ?? [])
|
||||||
.map((wt) => Number(wt.capacityTons))
|
.map((wt) => Number(wt.capacityTons))
|
||||||
.filter((c) => c > 0);
|
.filter((c) => c > 0);
|
||||||
@@ -7827,7 +7848,15 @@ export class TrainSchedulingService {
|
|||||||
booking.freightType === 'BULK' && bulkCapacities.length
|
booking.freightType === 'BULK' && bulkCapacities.length
|
||||||
? Math.max(...bulkCapacities)
|
? Math.max(...bulkCapacities)
|
||||||
: undefined;
|
: undefined;
|
||||||
return wagonsRequiredForBooking(booking, bulkCapacity);
|
// BULK with no cargo relations loaded: recomputing would size against a
|
||||||
|
// 1T capacity and read a PER_ITEM item count as tons — trust the stamp.
|
||||||
|
if (booking.freightType === 'BULK' && bulkCapacity === undefined && storedCeil > 0) {
|
||||||
|
return storedCeil;
|
||||||
|
}
|
||||||
|
// Stored is a candidate, never an early return (batch parity): rows
|
||||||
|
// stamped while BULK sizing read the PER_ITEM item count as tons carry a
|
||||||
|
// too-small footprint — a 20-item/100T booking was stamped 1 wagon.
|
||||||
|
return Math.max(storedCeil, wagonsRequiredForBooking(booking, bulkCapacity));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -315,3 +315,131 @@ describe('planWagonsWithStock — leg-aware stock (intercity ride-along)', () =>
|
|||||||
expect(result.plan).toHaveLength(1);
|
expect(result.plan).toHaveLength(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('planWagonsWithStock — break-bulk (PER_ITEM) item-aware packing', () => {
|
||||||
|
const pw2: WagonType = {
|
||||||
|
id: 'wt-pw2',
|
||||||
|
code: 'PW2',
|
||||||
|
capacityTons: 70,
|
||||||
|
lengthMeters: 17,
|
||||||
|
supportedLoadTypes: ['BULK'],
|
||||||
|
isActive: true,
|
||||||
|
supportsContainer: false,
|
||||||
|
} as WagonType;
|
||||||
|
const nw5: WagonType = {
|
||||||
|
id: 'wt-nw5',
|
||||||
|
code: 'NW5',
|
||||||
|
capacityTons: 70,
|
||||||
|
lengthMeters: 14,
|
||||||
|
supportedLoadTypes: ['BULK'],
|
||||||
|
isActive: true,
|
||||||
|
supportsContainer: false,
|
||||||
|
} as WagonType;
|
||||||
|
|
||||||
|
// 20 machinery items, 100T total (5T each). NW5 fits 4/wagon, PW2 fits 3.
|
||||||
|
const machineryBooking = (): Booking =>
|
||||||
|
({
|
||||||
|
id: 'BULK-ITEMS',
|
||||||
|
reference: 'BULK-ITEMS',
|
||||||
|
freightType: 'BULK',
|
||||||
|
cargoTypeId: 'ct-machinery',
|
||||||
|
cargoTotalWeightVgm: 20,
|
||||||
|
bulkTotalWeightTons: 100,
|
||||||
|
cargoType: {
|
||||||
|
id: 'ct-machinery',
|
||||||
|
cargoTypeName: 'Machinery',
|
||||||
|
itemsPerWagonMap: { 'wt-nw5': 4, 'wt-pw2': 3 },
|
||||||
|
wagonTypes: [pw2, nw5],
|
||||||
|
},
|
||||||
|
}) as unknown as Booking;
|
||||||
|
|
||||||
|
const allowed = {
|
||||||
|
byContainerTypeId: new Map<string, WagonType[]>(),
|
||||||
|
byCargoTypeId: new Map([['ct-machinery', [pw2, nw5]]]),
|
||||||
|
};
|
||||||
|
|
||||||
|
it('packs whole items per wagon by the items-fit map, not raw tonnage', () => {
|
||||||
|
const result = planWagonsWithStock({
|
||||||
|
bookings: [machineryBooking()],
|
||||||
|
allowed,
|
||||||
|
stock: {
|
||||||
|
mode: 'YARD',
|
||||||
|
remainingByTypeId: new Map([
|
||||||
|
[pw2.id, 50],
|
||||||
|
[nw5.id, 50],
|
||||||
|
]),
|
||||||
|
codesByTypeId: new Map([
|
||||||
|
[pw2.id, pw2.code],
|
||||||
|
[nw5.id, nw5.code],
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.deferred).toHaveLength(0);
|
||||||
|
// Best fit: NW5 at 4 items/wagon → ceil(20/4) = 5 wagons, 20T each.
|
||||||
|
expect(result.plan).toHaveLength(5);
|
||||||
|
expect(result.plan.every((s) => s.wagonTypeCode === 'NW5')).toBe(true);
|
||||||
|
expect(result.plan.map((s) => s.assignedWeightTons)).toEqual([20, 20, 20, 20, 20]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('weight cap binds before items-fit when items are heavy', () => {
|
||||||
|
// 14 items of 10T on 70T wagons with a 100-item floor fit → 7 items/wagon.
|
||||||
|
const heavy = {
|
||||||
|
...machineryBooking(),
|
||||||
|
cargoTotalWeightVgm: 14,
|
||||||
|
bulkTotalWeightTons: 140,
|
||||||
|
cargoType: {
|
||||||
|
id: 'ct-machinery',
|
||||||
|
cargoTypeName: 'Machinery',
|
||||||
|
itemsPerWagonMap: { 'wt-nw5': 100, 'wt-pw2': 100 },
|
||||||
|
wagonTypes: [pw2, nw5],
|
||||||
|
},
|
||||||
|
} as unknown as Booking;
|
||||||
|
const result = planWagonsWithStock({
|
||||||
|
bookings: [heavy],
|
||||||
|
allowed,
|
||||||
|
stock: {
|
||||||
|
mode: 'YARD',
|
||||||
|
remainingByTypeId: new Map([
|
||||||
|
[pw2.id, 50],
|
||||||
|
[nw5.id, 50],
|
||||||
|
]),
|
||||||
|
codesByTypeId: new Map([
|
||||||
|
[pw2.id, pw2.code],
|
||||||
|
[nw5.id, nw5.code],
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.deferred).toHaveLength(0);
|
||||||
|
expect(result.plan).toHaveLength(2);
|
||||||
|
expect(result.plan.map((s) => s.assignedWeightTons)).toEqual([70, 70]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PER_TON bulk (no bulkTotalWeightTons) still packs by weight', () => {
|
||||||
|
const loose = {
|
||||||
|
...machineryBooking(),
|
||||||
|
cargoTotalWeightVgm: 100,
|
||||||
|
bulkTotalWeightTons: null,
|
||||||
|
} as unknown as Booking;
|
||||||
|
const result = planWagonsWithStock({
|
||||||
|
bookings: [loose],
|
||||||
|
allowed,
|
||||||
|
stock: {
|
||||||
|
mode: 'YARD',
|
||||||
|
remainingByTypeId: new Map([
|
||||||
|
[pw2.id, 50],
|
||||||
|
[nw5.id, 50],
|
||||||
|
]),
|
||||||
|
codesByTypeId: new Map([
|
||||||
|
[pw2.id, pw2.code],
|
||||||
|
[nw5.id, nw5.code],
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.deferred).toHaveLength(0);
|
||||||
|
expect(result.plan).toHaveLength(2);
|
||||||
|
expect(result.plan.map((s) => s.assignedWeightTons)).toEqual([70, 30]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ import { AllocationLoadType } from '@edr/types';
|
|||||||
|
|
||||||
import { Booking } from '../bookings/entities/booking.entity';
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||||
|
import {
|
||||||
|
bookingCargoTons,
|
||||||
|
bulkItemsFitFor,
|
||||||
|
bulkItemWagonsForAllowedTypes,
|
||||||
|
} from './train-capacity.util';
|
||||||
import {
|
import {
|
||||||
sortBookingsForScheduling,
|
sortBookingsForScheduling,
|
||||||
type BookingWagonShortage,
|
type BookingWagonShortage,
|
||||||
@@ -64,6 +69,12 @@ type OpenSlot = {
|
|||||||
/** Kind purity: a bulk wagon carries ONE cargo type at a time. */
|
/** Kind purity: a bulk wagon carries ONE cargo type at a time. */
|
||||||
cargoTypeId: string | null;
|
cargoTypeId: string | null;
|
||||||
freeCapacityTons: number;
|
freeCapacityTons: number;
|
||||||
|
/**
|
||||||
|
* Whole-item slots left on this wagon (break-bulk PER_ITEM cargo only —
|
||||||
|
* bounded by the cargo type's items-per-wagon fit and by tonnage). Undefined
|
||||||
|
* for weight-only (PER_TON) bulk and container wagons.
|
||||||
|
*/
|
||||||
|
freeItems?: number;
|
||||||
/**
|
/**
|
||||||
* Leg of the FIRST booking placed (`"from-to"` stop indexes). Containers
|
* Leg of the FIRST booking placed (`"from-to"` stop indexes). Containers
|
||||||
* prefer a same-leg slot but may extend onto a different-leg one (span
|
* prefer a same-leg slot but may extend onto a different-leg one (span
|
||||||
@@ -110,10 +121,19 @@ const shortageFor = (
|
|||||||
booking.freightType === 'BULK'
|
booking.freightType === 'BULK'
|
||||||
? Math.max(
|
? Math.max(
|
||||||
1,
|
1,
|
||||||
Math.ceil(
|
// Break-bulk (PER_ITEM) sizes by indivisible items (items-fit map
|
||||||
Number(booking.cargoTotalWeightVgm ?? 0) /
|
// respected); PER_TON falls through to tonnage over the largest
|
||||||
Math.max(1, ...candidates.map((wt) => Number(wt.capacityTons))),
|
// candidate. bookingCargoTons, not raw VGM — for PER_ITEM that
|
||||||
),
|
// column is the item count, not tons.
|
||||||
|
bulkItemWagonsForAllowedTypes(
|
||||||
|
booking,
|
||||||
|
booking.cargoType,
|
||||||
|
Math.max(1, ...candidates.map((wt) => Number(wt.capacityTons))),
|
||||||
|
) ||
|
||||||
|
Math.ceil(
|
||||||
|
bookingCargoTons(booking) /
|
||||||
|
Math.max(1, ...candidates.map((wt) => Number(wt.capacityTons))),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
: Math.max(1, containerWagonsForLines(booking.bookingContainers ?? []));
|
: Math.max(1, containerWagonsForLines(booking.bookingContainers ?? []));
|
||||||
const wagonsAvailable = candidates.reduce(
|
const wagonsAvailable = candidates.reduce(
|
||||||
@@ -347,18 +367,64 @@ export function planWagonsWithStock(params: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
const allowedIds = new Set(candidates.map((wt) => wt.id));
|
const allowedIds = new Set(candidates.map((wt) => wt.id));
|
||||||
let remainingWeight = roundTons(Number(booking.cargoTotalWeightVgm ?? 0));
|
// Break-bulk (PER_ITEM): `cargoTotalWeightVgm` is the ITEM COUNT and the
|
||||||
|
// real tonnage lives in `bulkTotalWeightTons` — bookingCargoTons resolves
|
||||||
|
// it either way. Items are indivisible, so a wagon takes whole items only,
|
||||||
|
// bounded by tonnage AND by the cargo type's items-per-wagon fit.
|
||||||
|
const quantity = Number(booking.cargoTotalWeightVgm ?? 0);
|
||||||
|
const perItem =
|
||||||
|
Number(booking.bulkTotalWeightTons ?? 0) > 0 && quantity > 0;
|
||||||
|
let remainingWeight = roundTons(bookingCargoTons(booking));
|
||||||
|
const perItemTons = perItem ? remainingWeight / quantity : 0;
|
||||||
|
let remainingItems = perItem ? quantity : 0;
|
||||||
|
|
||||||
|
/** Whole items one wagon of this slot's type can still take. */
|
||||||
|
const itemRoomOf = (open: OpenSlot): number =>
|
||||||
|
Math.min(
|
||||||
|
open.freeItems ?? Number.MAX_SAFE_INTEGER,
|
||||||
|
perItemTons > 0 ? Math.floor(open.freeCapacityTons / perItemTons) : 0,
|
||||||
|
);
|
||||||
|
/** Fresh wagon's whole-item budget: items-fit map floor'd by tonnage. */
|
||||||
|
const itemBudgetOf = (open: OpenSlot): number => {
|
||||||
|
const fit = bulkItemsFitFor(booking.cargoType, open.slot.wagonTypeId);
|
||||||
|
const byTonnage =
|
||||||
|
perItemTons > 0
|
||||||
|
? Math.max(1, Math.floor(Number(open.slot.capacityTons) / perItemTons))
|
||||||
|
: 1;
|
||||||
|
return Math.min(fit ?? Number.MAX_SAFE_INTEGER, byTonnage);
|
||||||
|
};
|
||||||
let placedAnywhere = false;
|
let placedAnywhere = false;
|
||||||
|
|
||||||
|
// Per-item: prefer the type carrying the most whole items per wagon.
|
||||||
|
// openSlot's own capacity sort is stable, so this order breaks its ties.
|
||||||
|
const itemBudgetOfType = (wt: WagonType): number =>
|
||||||
|
Math.min(
|
||||||
|
bulkItemsFitFor(booking.cargoType, wt.id) ?? Number.MAX_SAFE_INTEGER,
|
||||||
|
perItemTons > 0
|
||||||
|
? Math.max(1, Math.floor(Number(wt.capacityTons) / perItemTons))
|
||||||
|
: 1,
|
||||||
|
);
|
||||||
|
const orderedCandidates = perItem
|
||||||
|
? [...candidates].sort((a, b) => itemBudgetOfType(b) - itemBudgetOfType(a))
|
||||||
|
: candidates;
|
||||||
|
|
||||||
// Top off wagons already carrying THIS cargo type before opening new ones.
|
// Top off wagons already carrying THIS cargo type before opening new ones.
|
||||||
|
// ponytail: per-item cargo only shares wagons that were opened per-item
|
||||||
|
// (freeItems tracked); mixing itemized and loose loads of one cargo type
|
||||||
|
// on one wagon is not modeled — open a new wagon instead.
|
||||||
for (const open of openSlots) {
|
for (const open of openSlots) {
|
||||||
if (remainingWeight <= 0) break;
|
if (perItem ? remainingItems <= 0 : remainingWeight <= 0) break;
|
||||||
if (open.kind !== 'BULK') continue;
|
if (open.kind !== 'BULK') continue;
|
||||||
if (open.legKey !== legKey) continue;
|
if (open.legKey !== legKey) continue;
|
||||||
if (open.cargoTypeId !== cargoTypeId) continue;
|
if (open.cargoTypeId !== cargoTypeId) continue;
|
||||||
if (!allowedIds.has(open.slot.wagonTypeId)) continue;
|
if (!allowedIds.has(open.slot.wagonTypeId)) continue;
|
||||||
if (open.freeCapacityTons <= 0) continue;
|
if (open.freeCapacityTons <= 0) continue;
|
||||||
const take = roundTons(Math.min(open.freeCapacityTons, remainingWeight));
|
if (perItem !== (open.freeItems !== undefined)) continue;
|
||||||
|
const takeItems = perItem ? Math.min(itemRoomOf(open), remainingItems) : 0;
|
||||||
|
if (perItem && takeItems <= 0) continue;
|
||||||
|
const take = perItem
|
||||||
|
? roundTons(takeItems * perItemTons)
|
||||||
|
: roundTons(Math.min(open.freeCapacityTons, remainingWeight));
|
||||||
addAllocation(
|
addAllocation(
|
||||||
open.slot,
|
open.slot,
|
||||||
booking.id,
|
booking.id,
|
||||||
@@ -367,14 +433,39 @@ export function planWagonsWithStock(params: {
|
|||||||
AllocationLoadType.Bulk,
|
AllocationLoadType.Bulk,
|
||||||
);
|
);
|
||||||
open.freeCapacityTons = roundTons(open.freeCapacityTons - take);
|
open.freeCapacityTons = roundTons(open.freeCapacityTons - take);
|
||||||
|
if (perItem) {
|
||||||
|
open.freeItems = (open.freeItems ?? 0) - takeItems;
|
||||||
|
remainingItems -= takeItems;
|
||||||
|
}
|
||||||
remainingWeight = roundTons(remainingWeight - take);
|
remainingWeight = roundTons(remainingWeight - take);
|
||||||
placedAnywhere = true;
|
placedAnywhere = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
while (remainingWeight > 0 || !placedAnywhere) {
|
while ((perItem ? remainingItems > 0 : remainingWeight > 0) || !placedAnywhere) {
|
||||||
const openedSlot = openSlot(candidates, 'BULK', cargoTypeId, leg);
|
// Per-item: openSlot's stock-depth tie-break would override the fit
|
||||||
|
// preference, so hand it exactly the best in-stock type (full candidate
|
||||||
|
// list only when none has stock, for the proper shortfall message).
|
||||||
|
const inStockBest = perItem
|
||||||
|
? orderedCandidates.find((wt) => availableFor(wt.id, leg) > 0)
|
||||||
|
: undefined;
|
||||||
|
const openedSlot = openSlot(
|
||||||
|
inStockBest ? [inStockBest] : orderedCandidates,
|
||||||
|
'BULK',
|
||||||
|
cargoTypeId,
|
||||||
|
leg,
|
||||||
|
);
|
||||||
if ('message' in openedSlot) return openedSlot;
|
if ('message' in openedSlot) return openedSlot;
|
||||||
const take = roundTons(Math.min(openedSlot.freeCapacityTons, remainingWeight));
|
let take: number;
|
||||||
|
if (perItem) {
|
||||||
|
// An item heavier than a whole wagon still charges 1 wagon per item
|
||||||
|
// (creation-time validation owns rejecting that case).
|
||||||
|
const takeItems = Math.max(1, Math.min(itemBudgetOf(openedSlot), remainingItems));
|
||||||
|
take = roundTons(Math.min(takeItems * perItemTons, remainingWeight));
|
||||||
|
openedSlot.freeItems = itemBudgetOf(openedSlot) - takeItems;
|
||||||
|
remainingItems -= takeItems;
|
||||||
|
} else {
|
||||||
|
take = roundTons(Math.min(openedSlot.freeCapacityTons, remainingWeight));
|
||||||
|
}
|
||||||
addAllocation(
|
addAllocation(
|
||||||
openedSlot.slot,
|
openedSlot.slot,
|
||||||
booking.id,
|
booking.id,
|
||||||
@@ -399,6 +490,7 @@ export function planWagonsWithStock(params: {
|
|||||||
teuPerEdge: [...open.teuPerEdge],
|
teuPerEdge: [...open.teuPerEdge],
|
||||||
covered: { ...open.covered },
|
covered: { ...open.covered },
|
||||||
freeCapacityTons: open.freeCapacityTons,
|
freeCapacityTons: open.freeCapacityTons,
|
||||||
|
freeItems: open.freeItems,
|
||||||
assignedWeightTons: open.slot.assignedWeightTons,
|
assignedWeightTons: open.slot.assignedWeightTons,
|
||||||
allocationCount: open.slot.allocations.length,
|
allocationCount: open.slot.allocations.length,
|
||||||
allocationWeights: open.slot.allocations.map((a) => a.allocatedWeightTons),
|
allocationWeights: open.slot.allocations.map((a) => a.allocatedWeightTons),
|
||||||
@@ -420,6 +512,7 @@ export function planWagonsWithStock(params: {
|
|||||||
open.teuPerEdge = [...snap.teuPerEdge];
|
open.teuPerEdge = [...snap.teuPerEdge];
|
||||||
open.covered = { ...snap.covered };
|
open.covered = { ...snap.covered };
|
||||||
open.freeCapacityTons = snap.freeCapacityTons;
|
open.freeCapacityTons = snap.freeCapacityTons;
|
||||||
|
open.freeItems = snap.freeItems;
|
||||||
open.slot.assignedWeightTons = snap.assignedWeightTons;
|
open.slot.assignedWeightTons = snap.assignedWeightTons;
|
||||||
open.slot.allocations.length = snap.allocationCount;
|
open.slot.allocations.length = snap.allocationCount;
|
||||||
snap.allocationWeights.forEach((weight, allocationIndex) => {
|
snap.allocationWeights.forEach((weight, allocationIndex) => {
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { ArrayUnique, IsIn } from 'class-validator';
|
||||||
|
import { Freight } from '@edr/types';
|
||||||
|
|
||||||
|
export class UpsertUserTradeAccessDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description:
|
||||||
|
'Trade directions the user may see. All three (or no config row) = unrestricted; empty array = sees nothing.',
|
||||||
|
isArray: true,
|
||||||
|
enum: ['IMPORT', 'EXPORT', 'DOMESTIC'],
|
||||||
|
example: ['IMPORT', 'DOMESTIC'],
|
||||||
|
})
|
||||||
|
@ArrayUnique()
|
||||||
|
@IsIn(['IMPORT', 'EXPORT', 'DOMESTIC'], { each: true })
|
||||||
|
directions!: Freight.ScheduleTradeDirection[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { BaseEntity } from '@edr/api-common';
|
||||||
|
import { Freight } from '@edr/types';
|
||||||
|
import { Column, Entity, Index } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which trade directions (IMPORT / EXPORT / DOMESTIC=Intercity) a backoffice
|
||||||
|
* user may see. No row, or all three directions, means unrestricted.
|
||||||
|
*/
|
||||||
|
@Entity({ schema: 'freight', name: 'user_trade_access' })
|
||||||
|
export class UserTradeAccess extends BaseEntity {
|
||||||
|
/** IAM user id (iam.users) — no FK, iam schema is externally owned. */
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'user_id', type: 'uuid', unique: true })
|
||||||
|
userId!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'directions', type: 'text', default: '' })
|
||||||
|
directionsRaw!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'updated_by_id', type: 'uuid', nullable: true })
|
||||||
|
updatedById!: string | null;
|
||||||
|
|
||||||
|
get directions(): Freight.ScheduleTradeDirection[] {
|
||||||
|
return this.directionsRaw
|
||||||
|
? (this.directionsRaw.split(',') as Freight.ScheduleTradeDirection[])
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { Freight } from '@edr/types';
|
||||||
|
import { Brackets, SelectQueryBuilder, WhereExpressionBuilder } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the effective direction list for a query.
|
||||||
|
*
|
||||||
|
* @param allowed the user's scope — null = unrestricted
|
||||||
|
* @param requested an explicit ?tradeDirection=… filter, if any
|
||||||
|
* @returns directions to filter by, `null` = no filter, `[]` = show nothing
|
||||||
|
*/
|
||||||
|
export function scopedDirections(
|
||||||
|
allowed: Freight.ScheduleTradeDirection[] | null,
|
||||||
|
requested?: string | null,
|
||||||
|
): string[] | null {
|
||||||
|
if (!allowed) return requested ? [requested] : null;
|
||||||
|
if (!requested) return [...allowed];
|
||||||
|
return allowed.includes(requested as Freight.ScheduleTradeDirection)
|
||||||
|
? [requested]
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a direction scope to a query builder column.
|
||||||
|
* `dirs = null` → untouched; `dirs = []` → matches nothing.
|
||||||
|
*/
|
||||||
|
export function applyDirectionScope<T extends WhereExpressionBuilder>(
|
||||||
|
qb: T,
|
||||||
|
column: string,
|
||||||
|
dirs: string[] | null,
|
||||||
|
): T {
|
||||||
|
if (dirs === null) return qb;
|
||||||
|
if (dirs.length === 0) {
|
||||||
|
qb.andWhere('1 = 0');
|
||||||
|
return qb;
|
||||||
|
}
|
||||||
|
// Unique param name so multiple scopes can coexist on one query.
|
||||||
|
const param = `scopeDirs_${column.replace(/\W/g, '_')}`;
|
||||||
|
qb.andWhere(`${column} IN (:...${param})`, { [param]: dirs });
|
||||||
|
return qb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SQL-fragment form of {@link applyDirectionScope} for fluent query chains:
|
||||||
|
* `.andWhere(f.sql, f.params)`. `dirs = null/undefined` → TRUE (no-op).
|
||||||
|
*/
|
||||||
|
export function directionScopeSql(
|
||||||
|
column: string,
|
||||||
|
dirs: string[] | null | undefined,
|
||||||
|
): { sql: string; params: Record<string, unknown> } {
|
||||||
|
if (!dirs) return { sql: 'TRUE', params: {} };
|
||||||
|
if (dirs.length === 0) return { sql: 'FALSE', params: {} };
|
||||||
|
const param = `scopeDirs_${column.replace(/\W/g, '_')}`;
|
||||||
|
return { sql: `${column} IN (:...${param})`, params: { [param]: dirs } };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SQL-fragment form of {@link applyBookingRefDirectionScope}: hides rows whose
|
||||||
|
* varchar ref column points at a booking outside the scope; rows that do not
|
||||||
|
* point at a booking stay visible (they carry no direction to scope by).
|
||||||
|
*/
|
||||||
|
export function bookingRefScopeSql(
|
||||||
|
refColumn: string,
|
||||||
|
dirs: string[] | null | undefined,
|
||||||
|
): { sql: string; params: Record<string, unknown> } {
|
||||||
|
if (!dirs) return { sql: 'TRUE', params: {} };
|
||||||
|
const param = `scopeRefDirs_${refColumn.replace(/\W/g, '_')}`;
|
||||||
|
const disallowed = dirs.length
|
||||||
|
? `b.trade_direction NOT IN (:...${param})`
|
||||||
|
: 'TRUE';
|
||||||
|
return {
|
||||||
|
sql: `NOT EXISTS (SELECT 1 FROM freight.bookings b WHERE b.id::text = ${refColumn} AND ${disallowed})`,
|
||||||
|
params: dirs.length ? { [param]: dirs } : {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope rows whose direction lives on a related booking referenced by a
|
||||||
|
* varchar id column (invoices.source_id, payments.ref_id). Rows that do not
|
||||||
|
* point at a booking stay visible — they carry no direction to scope by.
|
||||||
|
*/
|
||||||
|
export function applyBookingRefDirectionScope<T>(
|
||||||
|
qb: SelectQueryBuilder<T & object>,
|
||||||
|
refColumn: string,
|
||||||
|
dirs: string[] | null,
|
||||||
|
): SelectQueryBuilder<T & object> {
|
||||||
|
if (dirs === null) return qb;
|
||||||
|
const param = `scopeRefDirs_${refColumn.replace(/\W/g, '_')}`;
|
||||||
|
const disallowed = dirs.length
|
||||||
|
? `b.trade_direction NOT IN (:...${param})`
|
||||||
|
: 'TRUE';
|
||||||
|
qb.andWhere(
|
||||||
|
new Brackets((w) => {
|
||||||
|
w.where(
|
||||||
|
`NOT EXISTS (SELECT 1 FROM freight.bookings b WHERE b.id::text = ${refColumn} AND ${disallowed})`,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (dirs.length) qb.setParameter(param, dirs);
|
||||||
|
return qb;
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
ForbiddenException,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Put,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { CurrentUser } from '@edr/api-common';
|
||||||
|
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||||
|
|
||||||
|
import { StaffReference } from '../../common/booking-guards';
|
||||||
|
import { isFreightApprovalAdmin } from '../../common/freight-permission.util';
|
||||||
|
import { UpsertUserTradeAccessDto } from './dto/upsert-user-trade-access.dto';
|
||||||
|
import { UserTradeAccessService } from './user-trade-access.service';
|
||||||
|
|
||||||
|
@ApiTags('user-trade-access')
|
||||||
|
@Controller('user-trade-access')
|
||||||
|
@StaffReference()
|
||||||
|
@ApiBearerAuth()
|
||||||
|
export class UserTradeAccessController {
|
||||||
|
constructor(private readonly service: UserTradeAccessService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: 'List every configured user trade-direction scope' })
|
||||||
|
list(@CurrentUser() user: TCurrentUser) {
|
||||||
|
this.assertAdmin(user);
|
||||||
|
return this.service.listConfigs();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('me')
|
||||||
|
@ApiOperation({ summary: "Current user's effective trade-direction scope" })
|
||||||
|
async me(@CurrentUser() user: TCurrentUser) {
|
||||||
|
const allowed = await this.service.resolveAllowedDirections(user);
|
||||||
|
return {
|
||||||
|
restricted: allowed !== null,
|
||||||
|
directions: allowed ?? ['IMPORT', 'EXPORT', 'DOMESTIC'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':userId')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Set the trade directions a backoffice user may see',
|
||||||
|
})
|
||||||
|
upsert(
|
||||||
|
@Param('userId', ParseUUIDPipe) userId: string,
|
||||||
|
@Body() dto: UpsertUserTradeAccessDto,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
|
) {
|
||||||
|
this.assertAdmin(user);
|
||||||
|
return this.service.upsert(
|
||||||
|
userId,
|
||||||
|
dto.directions,
|
||||||
|
(user as { id?: string } | null)?.id ?? null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertAdmin(user: TCurrentUser) {
|
||||||
|
if (!isFreightApprovalAdmin(user)) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'Only super or organization admins can manage trade-direction access',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
|
||||||
|
import { UserTradeAccess } from './entities/user-trade-access.entity';
|
||||||
|
import { UserTradeAccessController } from './user-trade-access.controller';
|
||||||
|
import { UserTradeAccessRepository } from './user-trade-access.repository';
|
||||||
|
import { UserTradeAccessService } from './user-trade-access.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([UserTradeAccess])],
|
||||||
|
controllers: [UserTradeAccessController],
|
||||||
|
providers: [UserTradeAccessService, UserTradeAccessRepository],
|
||||||
|
exports: [UserTradeAccessService],
|
||||||
|
})
|
||||||
|
export class UserTradeAccessModule {}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { BaseRepository } from '@edr/api-common';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
|
||||||
|
import { UserTradeAccess } from './entities/user-trade-access.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class UserTradeAccessRepository extends BaseRepository<UserTradeAccess> {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(UserTradeAccess) repository: Repository<UserTradeAccess>,
|
||||||
|
) {
|
||||||
|
super(repository);
|
||||||
|
}
|
||||||
|
|
||||||
|
findByUserId(userId: string): Promise<UserTradeAccess | null> {
|
||||||
|
return this.repository.findOne({ where: { userId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
findAllConfigs(): Promise<UserTradeAccess[]> {
|
||||||
|
return this.repository.find({ order: { updatedAt: 'DESC' } });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { Freight } from '@edr/types';
|
||||||
|
import { isFreightApprovalAdmin } from '../../common/freight-permission.util';
|
||||||
|
import { UserTradeAccess } from './entities/user-trade-access.entity';
|
||||||
|
import { UserTradeAccessRepository } from './user-trade-access.repository';
|
||||||
|
|
||||||
|
const ALL: Freight.ScheduleTradeDirection[] = ['IMPORT', 'EXPORT', 'DOMESTIC'];
|
||||||
|
|
||||||
|
/** Loose current-user shape: JWT payloads and TCurrentUser both fit. */
|
||||||
|
export type ScopeUser =
|
||||||
|
| ({ id?: string; sub?: string; roles?: { key?: string }[] } & object)
|
||||||
|
| null
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
export type UserTradeAccessView = {
|
||||||
|
userId: string;
|
||||||
|
directions: Freight.ScheduleTradeDirection[];
|
||||||
|
updatedAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class UserTradeAccessService {
|
||||||
|
constructor(private readonly repository: UserTradeAccessRepository) {}
|
||||||
|
|
||||||
|
async listConfigs(): Promise<UserTradeAccessView[]> {
|
||||||
|
const rows = await this.repository.findAllConfigs();
|
||||||
|
return rows.map((r) => this.toView(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
async upsert(
|
||||||
|
userId: string,
|
||||||
|
directions: Freight.ScheduleTradeDirection[],
|
||||||
|
actorId?: string | null,
|
||||||
|
): Promise<UserTradeAccessView> {
|
||||||
|
// Normalize to canonical order so "all three" compares reliably.
|
||||||
|
const normalized = ALL.filter((d) => directions.includes(d));
|
||||||
|
const existing = await this.repository.findByUserId(userId);
|
||||||
|
const saved = existing
|
||||||
|
? await this.repository.update(existing.id, {
|
||||||
|
directionsRaw: normalized.join(','),
|
||||||
|
updatedById: actorId ?? null,
|
||||||
|
})
|
||||||
|
: await this.repository.create({
|
||||||
|
userId,
|
||||||
|
directionsRaw: normalized.join(','),
|
||||||
|
updatedById: actorId ?? null,
|
||||||
|
});
|
||||||
|
return this.toView(saved as UserTradeAccess);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Effective scope for the current user.
|
||||||
|
* `null` = unrestricted (no config, all three directions, admin, or no user
|
||||||
|
* on the request — routes without auth cannot be scoped).
|
||||||
|
*/
|
||||||
|
async resolveAllowedDirections(
|
||||||
|
user: ScopeUser,
|
||||||
|
): Promise<Freight.ScheduleTradeDirection[] | null> {
|
||||||
|
const userId = user?.id ?? user?.sub;
|
||||||
|
if (!userId) return null;
|
||||||
|
if (isFreightApprovalAdmin(user)) return null;
|
||||||
|
const row = await this.repository.findByUserId(userId);
|
||||||
|
if (!row) return null;
|
||||||
|
const dirs = row.directions;
|
||||||
|
if (dirs.length >= ALL.length) return null;
|
||||||
|
return dirs;
|
||||||
|
}
|
||||||
|
|
||||||
|
private toView(row: UserTradeAccess): UserTradeAccessView {
|
||||||
|
return {
|
||||||
|
userId: row.userId,
|
||||||
|
directions: row.directions,
|
||||||
|
updatedAt: row.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,12 +7,15 @@ export interface GenerateClientAssertionInput {
|
|||||||
expiresIn?: string;
|
expiresIn?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mirrors the National ID Program's own reference implementation
|
||||||
|
// (fayda-auth-python): plain {alg: RS256} header, no kid, no jti — eSignet
|
||||||
|
// resolves the verification key from client_id alone.
|
||||||
export async function generateClientAssertion(
|
export async function generateClientAssertion(
|
||||||
input: GenerateClientAssertionInput,
|
input: GenerateClientAssertionInput,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const privateKey = await importJWK(input.privateJwk, 'RS256');
|
const privateKey = await importJWK(input.privateJwk, 'RS256');
|
||||||
return new SignJWT({})
|
return new SignJWT({})
|
||||||
.setProtectedHeader({ alg: 'RS256', typ: 'JWT' })
|
.setProtectedHeader({ alg: 'RS256' })
|
||||||
.setIssuer(input.clientId)
|
.setIssuer(input.clientId)
|
||||||
.setSubject(input.clientId)
|
.setSubject(input.clientId)
|
||||||
.setAudience(input.audience)
|
.setAudience(input.audience)
|
||||||
|
|||||||
@@ -396,13 +396,63 @@ export class VerifaydaService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Like name/gender, address is flattened by eSignet into top-level
|
||||||
|
// `address#en` / `address#am` keys — but since it's a structured claim,
|
||||||
|
// each of those is itself an object whose *leaf* fields carry the same
|
||||||
|
// locale suffix again, e.g.
|
||||||
|
// `address#en: { "zone#en": "...", "region#en": "...", "woreda#en": "..." }`.
|
||||||
|
private static readonly ADDRESS_FIELD_ORDER = [
|
||||||
|
'houseNumber',
|
||||||
|
'kebele',
|
||||||
|
'woreda',
|
||||||
|
'city',
|
||||||
|
'subCity',
|
||||||
|
'zone',
|
||||||
|
'region',
|
||||||
|
'postalCode',
|
||||||
|
'country',
|
||||||
|
];
|
||||||
|
|
||||||
|
private formatFaydaAddress(
|
||||||
|
address: Record<string, unknown> | undefined,
|
||||||
|
locale: 'en' | 'am',
|
||||||
|
): string | undefined {
|
||||||
|
if (!address) return undefined;
|
||||||
|
|
||||||
|
const formatted = address[`formatted#${locale}`];
|
||||||
|
if (typeof formatted === 'string' && formatted.trim()) return formatted;
|
||||||
|
|
||||||
|
const suffix = `#${locale}`;
|
||||||
|
const byField = new Map<string, string>();
|
||||||
|
for (const [key, value] of Object.entries(address)) {
|
||||||
|
if (!key.endsWith(suffix) || typeof value !== 'string' || !value.trim()) continue;
|
||||||
|
byField.set(key.slice(0, -suffix.length), value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ordered = VerifaydaService.ADDRESS_FIELD_ORDER.filter((f) =>
|
||||||
|
byField.has(f),
|
||||||
|
).map((f) => byField.get(f)!);
|
||||||
|
const rest = [...byField.entries()]
|
||||||
|
.filter(([f]) => !VerifaydaService.ADDRESS_FIELD_ORDER.includes(f))
|
||||||
|
.map(([, v]) => v);
|
||||||
|
|
||||||
|
const parts = [...ordered, ...rest];
|
||||||
|
return parts.length > 0 ? parts.join(', ') : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
private normalizeUserInfo(raw: FaydaUserInfo): NormalizedFaydaUserInfo {
|
private normalizeUserInfo(raw: FaydaUserInfo): NormalizedFaydaUserInfo {
|
||||||
const nameEn = raw['name#en'] as string | undefined;
|
const nameEn = raw['name#en'] as string | undefined;
|
||||||
const nameAm = raw['name#am'] as string | undefined;
|
const nameAm = raw['name#am'] as string | undefined;
|
||||||
const genderEn = raw['gender#en'] as string | undefined;
|
const genderEn = raw['gender#en'] as string | undefined;
|
||||||
const genderAm = raw['gender#am'] as string | undefined;
|
const genderAm = raw['gender#am'] as string | undefined;
|
||||||
const addressEn = raw['address#en'] as string | undefined;
|
const addressEn = this.formatFaydaAddress(
|
||||||
const addressAm = raw['address#am'] as string | undefined;
|
raw['address#en'] as Record<string, unknown> | undefined,
|
||||||
|
'en',
|
||||||
|
);
|
||||||
|
const addressAm = this.formatFaydaAddress(
|
||||||
|
raw['address#am'] as Record<string, unknown> | undefined,
|
||||||
|
'am',
|
||||||
|
);
|
||||||
const rawPhone = (raw.phone_number ?? raw['phone_number#en'] ?? raw['phone_number#am'] ?? raw.phone) as string | undefined;
|
const rawPhone = (raw.phone_number ?? raw['phone_number#en'] ?? raw['phone_number#am'] ?? raw.phone) as string | undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ export interface FaydaUserInfo {
|
|||||||
gender?: string;
|
gender?: string;
|
||||||
birthdate?: string;
|
birthdate?: string;
|
||||||
picture?: string;
|
picture?: string;
|
||||||
address?: Record<string, unknown>;
|
'address#en'?: Record<string, unknown>;
|
||||||
|
'address#am'?: Record<string, unknown>;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,14 +59,23 @@ export class YardFacilitiesSeeder {
|
|||||||
[yard.id],
|
[yard.id],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Every facility works both sides of the trip today, so the per-side
|
||||||
|
// flags mirror the freight-type flags. Narrow an individual yard here
|
||||||
|
// when a real one turns out to load a type but not receive it.
|
||||||
await this.dataSource.query(
|
await this.dataSource.query(
|
||||||
`INSERT INTO freight.yard_facilities
|
`INSERT INTO freight.yard_facilities
|
||||||
(yard_id, has_warehouse, handles_container, handles_bulk, equipment_notes)
|
(yard_id, has_warehouse, handles_container, handles_bulk, equipment_notes,
|
||||||
VALUES ($1, $2, $3, true, $4)
|
has_container_facility_origin, has_container_facility_destination,
|
||||||
|
has_bulk_facility_origin, has_bulk_facility_destination)
|
||||||
|
VALUES ($1, $2, $3, true, $4, $3, $3, true, true)
|
||||||
ON CONFLICT (yard_id) WHERE deleted_at IS NULL
|
ON CONFLICT (yard_id) WHERE deleted_at IS NULL
|
||||||
DO UPDATE SET has_warehouse = EXCLUDED.has_warehouse,
|
DO UPDATE SET has_warehouse = EXCLUDED.has_warehouse,
|
||||||
handles_container = EXCLUDED.handles_container,
|
handles_container = EXCLUDED.handles_container,
|
||||||
handles_bulk = EXCLUDED.handles_bulk,
|
handles_bulk = EXCLUDED.handles_bulk,
|
||||||
|
has_container_facility_origin = EXCLUDED.has_container_facility_origin,
|
||||||
|
has_container_facility_destination = EXCLUDED.has_container_facility_destination,
|
||||||
|
has_bulk_facility_origin = EXCLUDED.has_bulk_facility_origin,
|
||||||
|
has_bulk_facility_destination = EXCLUDED.has_bulk_facility_destination,
|
||||||
updated_at = NOW()`,
|
updated_at = NOW()`,
|
||||||
[yard.id, hasWarehouse, handlesContainer, `${facility} load/unload facility`],
|
[yard.id, hasWarehouse, handlesContainer, `${facility} load/unload facility`],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -55,6 +55,7 @@
|
|||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@tinymce/tinymce-react": "^6.3.0",
|
"@tinymce/tinymce-react": "^6.3.0",
|
||||||
"@tria-plc/iamui": "file:../../../local-packages/tria-plc-iamui-0.1.1.tgz",
|
"@tria-plc/iamui": "file:../../../local-packages/tria-plc-iamui-0.1.1.tgz",
|
||||||
|
"@types/three": "^0.185.3",
|
||||||
"@vis.gl/react-google-maps": "^1.8.3",
|
"@vis.gl/react-google-maps": "^1.8.3",
|
||||||
"axios": "^1.7.7",
|
"axios": "^1.7.7",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
@@ -103,6 +104,7 @@
|
|||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"stream-browserify": "^3.0.0",
|
"stream-browserify": "^3.0.0",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
|
"three": "^0.185.1",
|
||||||
"tinymce": "^8.6.0",
|
"tinymce": "^8.6.0",
|
||||||
"xlsx": "^0.18.5",
|
"xlsx": "^0.18.5",
|
||||||
"zod": "^3.25.76",
|
"zod": "^3.25.76",
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ import BatchScheduleDetailPage from "./pages/trainScheduling/BatchScheduleDetail
|
|||||||
import TrainScheduleV2DetailPage from "./pages/trainScheduling/TrainScheduleV2DetailPage";
|
import TrainScheduleV2DetailPage from "./pages/trainScheduling/TrainScheduleV2DetailPage";
|
||||||
import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPage";
|
import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPage";
|
||||||
import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage";
|
import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage";
|
||||||
|
import TradeAccessPage from "./pages/configuration/TradeAccessPage";
|
||||||
import ContractValidityPeriodsPage from "./pages/configuration/ContractValidityPeriodsPage";
|
import ContractValidityPeriodsPage from "./pages/configuration/ContractValidityPeriodsPage";
|
||||||
import FirstMilePage from "./pages/operations/FirstMilePage";
|
import FirstMilePage from "./pages/operations/FirstMilePage";
|
||||||
import LastMilePage from "./pages/operations/LastMilePage";
|
import LastMilePage from "./pages/operations/LastMilePage";
|
||||||
@@ -585,6 +586,11 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
href: "/dashboard/configuration/train-scheduling-rules",
|
href: "/dashboard/configuration/train-scheduling-rules",
|
||||||
permission: FREIGHT_PERMS.trainScheduling.rulesManage,
|
permission: FREIGHT_PERMS.trainScheduling.rulesManage,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "Trade access",
|
||||||
|
href: "/dashboard/configuration/trade-access",
|
||||||
|
permission: FREIGHT_PERMS.admin,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1549,6 +1555,14 @@ const App = () => {
|
|||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="configuration/trade-access"
|
||||||
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.admin}>
|
||||||
|
<TradeAccessPage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
{/* <Route
|
{/* <Route
|
||||||
path="configuration/contract-validity-periods"
|
path="configuration/contract-validity-periods"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Package } from "lucide-react";
|
|||||||
import { SimpleGrid, Divider, Box, Table, Text } from "@mantine/core";
|
import { SimpleGrid, Divider, Box, Table, Text } from "@mantine/core";
|
||||||
|
|
||||||
import type { BookingDetail } from "@/types/booking";
|
import type { BookingDetail } from "@/types/booking";
|
||||||
|
import { cargoTonsAndItems } from "@/utils/cargoWeight";
|
||||||
|
|
||||||
import { SectionCard } from "./SectionCard";
|
import { SectionCard } from "./SectionCard";
|
||||||
import { MetricTile } from "./MetricTile";
|
import { MetricTile } from "./MetricTile";
|
||||||
@@ -13,6 +14,7 @@ export interface BookingCargoCardProps {
|
|||||||
/** Cargo specs + container manifest table. */
|
/** Cargo specs + container manifest table. */
|
||||||
export function BookingCargoCard({ booking }: BookingCargoCardProps) {
|
export function BookingCargoCard({ booking }: BookingCargoCardProps) {
|
||||||
const containers = booking.bookingContainers ?? [];
|
const containers = booking.bookingContainers ?? [];
|
||||||
|
const { tons, items } = cargoTonsAndItems(booking);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionCard icon={Package} title="Cargo specifications" accent="orange">
|
<SectionCard icon={Package} title="Cargo specifications" accent="orange">
|
||||||
@@ -21,7 +23,8 @@ export function BookingCargoCard({ booking }: BookingCargoCardProps) {
|
|||||||
label="Cargo type"
|
label="Cargo type"
|
||||||
value={booking.cargoType?.label ?? booking.freightType}
|
value={booking.cargoType?.label ?? booking.freightType}
|
||||||
/>
|
/>
|
||||||
<MetricTile label="Total VGM" value={`${booking.cargoTotalWeightVgm} tons`} />
|
<MetricTile label="Total VGM" value={`${tons} tons`} />
|
||||||
|
{items != null && <MetricTile label="Items" value={`${items}`} />}
|
||||||
<MetricTile
|
<MetricTile
|
||||||
label="Hazardous"
|
label="Hazardous"
|
||||||
value={booking.isHazardous ? "Yes" : "No"}
|
value={booking.isHazardous ? "Yes" : "No"}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import type { ReactNode } from "react";
|
|||||||
import { Hash, Package, Ship, Weight, Clock } from "lucide-react";
|
import { Hash, Package, Ship, Weight, Clock } from "lucide-react";
|
||||||
import { Group, Stack, Text, Divider } from "@mantine/core";
|
import { Group, Stack, Text, Divider } from "@mantine/core";
|
||||||
|
|
||||||
|
import { cargoTonsAndItems } from "@/utils/cargoWeight";
|
||||||
|
|
||||||
import { SectionCard } from "./SectionCard";
|
import { SectionCard } from "./SectionCard";
|
||||||
import { formatDate, type BookingDetailView } from "./booking-detail.styles";
|
import { formatDate, type BookingDetailView } from "./booking-detail.styles";
|
||||||
|
|
||||||
@@ -38,7 +40,14 @@ export function BookingFactsCard({ booking }: BookingFactsCardProps) {
|
|||||||
{ icon: Hash, label: "PNR Code", value: booking.pnrCode || "—" },
|
{ icon: Hash, label: "PNR Code", value: booking.pnrCode || "—" },
|
||||||
{ icon: Package, label: "Cargo Type", value: booking.cargoType?.label ?? "—" },
|
{ icon: Package, label: "Cargo Type", value: booking.cargoType?.label ?? "—" },
|
||||||
{ icon: Ship, label: "Shipping Line", value: booking.shippingLine?.label ?? "—" },
|
{ icon: Ship, label: "Shipping Line", value: booking.shippingLine?.label ?? "—" },
|
||||||
{ icon: Weight, label: "VGM Weight", value: `${booking.cargoTotalWeightVgm} tons` },
|
{
|
||||||
|
icon: Weight,
|
||||||
|
label: "VGM Weight",
|
||||||
|
value: (() => {
|
||||||
|
const { tons, items } = cargoTonsAndItems(booking);
|
||||||
|
return items != null ? `${tons} tons (${items} items)` : `${tons} tons`;
|
||||||
|
})(),
|
||||||
|
},
|
||||||
{ icon: Clock, label: "Last Updated", value: formatDate(booking.updatedAt) },
|
{ icon: Clock, label: "Last Updated", value: formatDate(booking.updatedAt) },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
|
||||||
import type { BookingDetail } from "@/types/booking";
|
import type { BookingDetail } from "@/types/booking";
|
||||||
|
import { cargoTonsAndItems } from "@/utils/cargoWeight";
|
||||||
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
||||||
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
|
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
|
||||||
import { ContractReferenceLink } from "@/components/bookings/ContractReferenceLink";
|
import { ContractReferenceLink } from "@/components/bookings/ContractReferenceLink";
|
||||||
@@ -52,7 +53,7 @@ export function BookingRequestHero({
|
|||||||
(sum, c) => sum + Number(c.quantity ?? 0),
|
(sum, c) => sum + Number(c.quantity ?? 0),
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
const weight = Number(booking.cargoTotalWeightVgm ?? 0);
|
const { tons: weight, items: itemCount } = cargoTonsAndItems(booking);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper
|
<Paper
|
||||||
@@ -162,7 +163,7 @@ export function BookingRequestHero({
|
|||||||
icon={Weight}
|
icon={Weight}
|
||||||
label="Cargo weight"
|
label="Cargo weight"
|
||||||
value={`${weight} T`}
|
value={`${weight} T`}
|
||||||
hint="VGM total"
|
hint={itemCount != null ? `${itemCount} items` : "VGM total"}
|
||||||
accent="blue"
|
accent="blue"
|
||||||
/>
|
/>
|
||||||
<HeroTile
|
<HeroTile
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
|
ActionIcon,
|
||||||
Alert,
|
Alert,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
@@ -171,11 +172,11 @@ function emptyUnit(): UnitDraft {
|
|||||||
function emptyLine(size: string): ContainerLineDraft {
|
function emptyLine(size: string): ContainerLineDraft {
|
||||||
return {
|
return {
|
||||||
containerSize: size,
|
containerSize: size,
|
||||||
quantity: "1",
|
quantity: "0",
|
||||||
hazardousQuantity: "0",
|
hazardousQuantity: "0",
|
||||||
reeferQuantity: "0",
|
reeferQuantity: "0",
|
||||||
returnQuantity: "0",
|
returnQuantity: "0",
|
||||||
units: [emptyUnit()],
|
units: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -665,6 +666,17 @@ export default function GlCreateBookingForm() {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Drop one container row and shrink quantity to match — the inverse of
|
||||||
|
// syncUnits growing the array when quantity goes up.
|
||||||
|
const removeUnit = (lineIdx: number, unitIdx: number) =>
|
||||||
|
setContainerLines((prev) =>
|
||||||
|
prev.map((l, i) => {
|
||||||
|
if (i !== lineIdx) return l;
|
||||||
|
const units = l.units.filter((_, j) => j !== unitIdx);
|
||||||
|
return withDerivedCounts({ ...l, quantity: String(units.length), units });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
// Same client-side validation as the customer portal shipment form
|
// Same client-side validation as the customer portal shipment form
|
||||||
// (new-shipment-form/schema.ts): ISO container numbers unique within the
|
// (new-shipment-form/schema.ts): ISO container numbers unique within the
|
||||||
// shipment, positive VGM per unit, hazardous/reefer counts bounded by the
|
// shipment, positive VGM per unit, hazardous/reefer counts bounded by the
|
||||||
@@ -1362,6 +1374,8 @@ export default function GlCreateBookingForm() {
|
|||||||
}
|
}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
patchLine(lineIdx, { quantity: e.currentTarget.value });
|
patchLine(lineIdx, { quantity: e.currentTarget.value });
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
syncUnits(lineIdx, Number(e.currentTarget.value || 0));
|
syncUnits(lineIdx, Number(e.currentTarget.value || 0));
|
||||||
}}
|
}}
|
||||||
radius={10}
|
radius={10}
|
||||||
@@ -1495,6 +1509,14 @@ export default function GlCreateBookingForm() {
|
|||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="red"
|
||||||
|
aria-label={`Remove container ${unitIdx + 1}`}
|
||||||
|
onClick={() => removeUnit(lineIdx, unitIdx)}
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</ActionIcon>
|
||||||
</Group>
|
</Group>
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -182,18 +182,18 @@ const FreightDashboardHeader = ({
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
<Divider />
|
<Divider />
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<FileSignature size={15} />}
|
||||||
|
onClick={() => navigate("/dashboard/profile#signature")}
|
||||||
|
>
|
||||||
|
Signature & Stamp
|
||||||
|
</Menu.Item>
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<User size={15} />}
|
leftSection={<User size={15} />}
|
||||||
onClick={() => navigate("/dashboard/profile")}
|
onClick={() => navigate("/dashboard/profile")}
|
||||||
>
|
>
|
||||||
Profile
|
Profile
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Item
|
|
||||||
leftSection={<FileSignature size={15} />}
|
|
||||||
onClick={() => navigate("/dashboard/profile#signature")}
|
|
||||||
>
|
|
||||||
My signature
|
|
||||||
</Menu.Item>
|
|
||||||
<Divider />
|
<Divider />
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<LogOut size={15} />}
|
leftSection={<LogOut size={15} />}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { FileSignature, Loader2 } from "lucide-react";
|
import { FileSignature, Loader2, Stamp } from "lucide-react";
|
||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
@@ -27,9 +27,9 @@ import {
|
|||||||
} from "@edr/ui-common";
|
} from "@edr/ui-common";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lets the signed-in user view and update the reusable signature stored on
|
* Lets the signed-in user view and update the reusable signature and company
|
||||||
* their profile. The same signature is offered for approval when signing a
|
* stamp stored on their profile — managed independently of each other. Both
|
||||||
* booking contract.
|
* are offered when signing a booking contract.
|
||||||
*/
|
*/
|
||||||
export function MySignatureCard() {
|
export function MySignatureCard() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
@@ -38,94 +38,132 @@ export function MySignatureCard() {
|
|||||||
);
|
);
|
||||||
const saveMutation = useMutation(api.signatures.save.mutationOptions());
|
const saveMutation = useMutation(api.signatures.save.mutationOptions());
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
const [signatureOpen, setSignatureOpen] = useState(false);
|
||||||
|
const [stampOpen, setStampOpen] = useState(false);
|
||||||
const [signerName, setSignerName] = useState("");
|
const [signerName, setSignerName] = useState("");
|
||||||
const [signatureData, setSignatureData] = useState<string | null>(null);
|
const [signatureData, setSignatureData] = useState<string | null>(null);
|
||||||
const [stampData, setStampData] = useState<string | null>(null);
|
const [stampData, setStampData] = useState<string | null>(null);
|
||||||
|
|
||||||
const defaultName =
|
const defaultName =
|
||||||
user?.name?.en || user?.username || user?.email || "";
|
user?.name?.en || user?.username || user?.email || "";
|
||||||
|
const savedName = saved?.signerDisplayName ?? defaultName;
|
||||||
|
|
||||||
const openDialog = () => {
|
const openSignatureDialog = () => {
|
||||||
setSignerName(saved?.signerDisplayName ?? defaultName);
|
setSignerName(savedName);
|
||||||
setSignatureData(null);
|
setSignatureData(null);
|
||||||
setStampData(saved?.stampImageUrl ?? null);
|
setSignatureOpen(true);
|
||||||
setOpen(true);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const save = () => {
|
const saveSignature = () => {
|
||||||
if (!signatureData || !signerName.trim()) return;
|
if (!signatureData || !signerName.trim()) return;
|
||||||
saveMutation.mutate(
|
saveMutation.mutate(
|
||||||
{
|
{
|
||||||
signerDisplayName: signerName.trim(),
|
signerDisplayName: signerName.trim(),
|
||||||
signatureImageBase64: signatureData,
|
signatureImageBase64: signatureData,
|
||||||
// Only send the stamp when it changed — omitted keeps the saved one.
|
// Stamp untouched — it is managed by its own dialog.
|
||||||
...(stampData && stampData !== saved?.stampImageUrl
|
|
||||||
? { stampImageBase64: stampData }
|
|
||||||
: {}),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success("Signature saved");
|
toast.success("Signature saved");
|
||||||
setOpen(false);
|
setSignatureOpen(false);
|
||||||
},
|
},
|
||||||
onError: () => toast.error("Failed to save signature"),
|
onError: () => toast.error("Failed to save signature"),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openStampDialog = () => {
|
||||||
|
setStampData(saved?.stampImageUrl ?? null);
|
||||||
|
setStampOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveStamp = () => {
|
||||||
|
if (!stampData) return;
|
||||||
|
saveMutation.mutate(
|
||||||
|
{
|
||||||
|
signerDisplayName: savedName || defaultName,
|
||||||
|
// Signature untouched — stamp-only update.
|
||||||
|
stampImageBase64: stampData,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Stamp saved");
|
||||||
|
setStampOpen(false);
|
||||||
|
},
|
||||||
|
onError: () => toast.error("Failed to save stamp"),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<FileSignature className="size-4" />
|
<FileSignature className="size-4" />
|
||||||
My signature
|
Signature & Stamp
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
This signature can be reused to sign booking contracts.
|
This signature can be reused to sign booking contracts.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-6">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex h-36 items-center justify-center">
|
<div className="flex h-36 items-center justify-center">
|
||||||
<Loader2 className="size-6 animate-spin text-primary" />
|
<Loader2 className="size-6 animate-spin text-primary" />
|
||||||
</div>
|
</div>
|
||||||
) : saved?.signatureImageUrl ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="overflow-hidden rounded-lg border-2 border-dashed border-border bg-white">
|
|
||||||
<img
|
|
||||||
src={saved.signatureImageUrl}
|
|
||||||
alt="My saved signature"
|
|
||||||
className="mx-auto h-36 w-full object-contain"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Saved as {saved.signerDisplayName}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-muted-foreground">
|
<>
|
||||||
You have not saved a signature yet.
|
<div className="space-y-2">
|
||||||
</p>
|
{saved?.signatureImageUrl ? (
|
||||||
)}
|
<>
|
||||||
{saved?.stampImageUrl && (
|
<div className="overflow-hidden rounded-lg border-2 border-dashed border-border bg-white">
|
||||||
<div className="space-y-2">
|
<img
|
||||||
<div className="overflow-hidden rounded-lg border-2 border-dashed border-border bg-white">
|
src={saved.signatureImageUrl}
|
||||||
<img
|
alt="My saved signature"
|
||||||
src={saved.stampImageUrl}
|
className="mx-auto h-36 w-full object-contain"
|
||||||
alt="My saved company stamp"
|
/>
|
||||||
className="mx-auto h-24 w-full object-contain"
|
</div>
|
||||||
/>
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Saved as {saved.signerDisplayName}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
You have not saved a signature yet.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Button variant="outline" size="sm" onClick={openSignatureDialog}>
|
||||||
|
{saved?.signatureImageUrl ? "Update signature" : "Add signature"}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">Company stamp</p>
|
|
||||||
</div>
|
<div className="space-y-2">
|
||||||
|
{saved?.stampImageUrl ? (
|
||||||
|
<>
|
||||||
|
<div className="overflow-hidden rounded-lg border-2 border-dashed border-border bg-white">
|
||||||
|
<img
|
||||||
|
src={saved.stampImageUrl}
|
||||||
|
alt="My saved company stamp"
|
||||||
|
className="mx-auto h-24 w-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">Company stamp</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
You have not uploaded a company stamp yet.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Button variant="outline" size="sm" onClick={openStampDialog}>
|
||||||
|
<Stamp className="size-4" />
|
||||||
|
{saved?.stampImageUrl ? "Update stamp" : "Upload stamp"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<Button variant="outline" size="sm" onClick={openDialog}>
|
|
||||||
{saved?.signatureImageUrl ? "Update signature" : "Add signature"}
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={signatureOpen} onOpenChange={setSignatureOpen}>
|
||||||
<DialogContent className="sm:max-w-md">
|
<DialogContent className="sm:max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Save your signature</DialogTitle>
|
<DialogTitle>Save your signature</DialogTitle>
|
||||||
@@ -145,21 +183,16 @@ export function MySignatureCard() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<ContractSignaturePad onChange={setSignatureData} />
|
<ContractSignaturePad onChange={setSignatureData} />
|
||||||
<StampUpload
|
|
||||||
value={stampData}
|
|
||||||
onChange={setStampData}
|
|
||||||
description="Stored on your profile and prefilled when you sign contracts."
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
<Button variant="outline" onClick={() => setSignatureOpen(false)}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
disabled={
|
disabled={
|
||||||
saveMutation.isPending || !signatureData || !signerName.trim()
|
saveMutation.isPending || !signatureData || !signerName.trim()
|
||||||
}
|
}
|
||||||
onClick={save}
|
onClick={saveSignature}
|
||||||
>
|
>
|
||||||
{saveMutation.isPending ? (
|
{saveMutation.isPending ? (
|
||||||
<Loader2 className="size-4 animate-spin" />
|
<Loader2 className="size-4 animate-spin" />
|
||||||
@@ -170,6 +203,39 @@ export function MySignatureCard() {
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog open={stampOpen} onOpenChange={setStampOpen}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Company stamp</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Upload your official company stamp or seal as an image. It is
|
||||||
|
stored on your profile and applied next to your signature on
|
||||||
|
contracts.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<StampUpload
|
||||||
|
value={stampData}
|
||||||
|
onChange={setStampData}
|
||||||
|
description="Stored on your profile and prefilled when you sign contracts."
|
||||||
|
/>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setStampOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={saveMutation.isPending || !stampData}
|
||||||
|
onClick={saveStamp}
|
||||||
|
>
|
||||||
|
{saveMutation.isPending ? (
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
"Save stamp"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import { formatRouteLabel } from "@/services/routes.service";
|
|||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { trainSchedulingService } from "@/services/trainScheduling.service";
|
import { trainSchedulingService } from "@/services/trainScheduling.service";
|
||||||
import type { BookingDetail } from "@/types/booking";
|
import type { BookingDetail } from "@/types/booking";
|
||||||
|
import { cargoTonsAndItems } from "@/utils/cargoWeight";
|
||||||
import type {
|
import type {
|
||||||
ContainerPlacement,
|
ContainerPlacement,
|
||||||
FreightType,
|
FreightType,
|
||||||
@@ -416,7 +417,7 @@ export function AllocateBookingWizard({
|
|||||||
const amount = Number(booking.totalAmount);
|
const amount = Number(booking.totalAmount);
|
||||||
const containers = booking.bookingContainers ?? [];
|
const containers = booking.bookingContainers ?? [];
|
||||||
const containerCount = containers.reduce((sum, c) => sum + Number(c.quantity ?? 0), 0);
|
const containerCount = containers.reduce((sum, c) => sum + Number(c.quantity ?? 0), 0);
|
||||||
const weight = Number(booking.cargoTotalWeightVgm ?? 0);
|
const { tons: weight, items: itemCount } = cargoTonsAndItems(booking);
|
||||||
const holdCountdown = formatCountdown(booking.holdExpiresAt);
|
const holdCountdown = formatCountdown(booking.holdExpiresAt);
|
||||||
|
|
||||||
const containerComplete =
|
const containerComplete =
|
||||||
@@ -945,7 +946,13 @@ export function AllocateBookingWizard({
|
|||||||
})}`}
|
})}`}
|
||||||
hint={booking.paymentStatus}
|
hint={booking.paymentStatus}
|
||||||
/>
|
/>
|
||||||
<StatTile onDark icon={Weight} label="Cargo weight" value={`${weight} T`} hint="VGM total" />
|
<StatTile
|
||||||
|
onDark
|
||||||
|
icon={Weight}
|
||||||
|
label="Cargo weight"
|
||||||
|
value={`${weight} T`}
|
||||||
|
hint={itemCount != null ? `${itemCount} items` : "VGM total"}
|
||||||
|
/>
|
||||||
<StatTile
|
<StatTile
|
||||||
onDark
|
onDark
|
||||||
icon={ContainerIcon}
|
icon={ContainerIcon}
|
||||||
|
|||||||
@@ -68,11 +68,27 @@ function CapacityBadges({ capacity }: { capacity: IntercityCapacity | null }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function NeedCells({ need }: { need: IntercityCapacity | null }) {
|
function NeedCells({ row }: { row: IntercityBookingRow }) {
|
||||||
|
const { need, wagonBreakdown } = row;
|
||||||
if (!need) return <Table.Td colSpan={3}>—</Table.Td>;
|
if (!need) return <Table.Td colSpan={3}>—</Table.Td>;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Table.Td>{fmt(need.wagons)}</Table.Td>
|
<Table.Td>
|
||||||
|
{wagonBreakdown?.length ? (
|
||||||
|
// Which wagon TYPES this train gives up, not just how many wagons —
|
||||||
|
// a break-bulk booking's count depends on each type's capacity and
|
||||||
|
// its configured items-per-wagon fit, so it differs per train.
|
||||||
|
<Stack gap={2}>
|
||||||
|
{wagonBreakdown.map((entry) => (
|
||||||
|
<Text key={entry.wagonTypeId} size="sm">
|
||||||
|
{entry.wagons} × {entry.code}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
fmt(need.wagons)
|
||||||
|
)}
|
||||||
|
</Table.Td>
|
||||||
<Table.Td>{fmt(need.weightTons)} t</Table.Td>
|
<Table.Td>{fmt(need.weightTons)} t</Table.Td>
|
||||||
<Table.Td>{fmt(need.lengthMeters)} m</Table.Td>
|
<Table.Td>{fmt(need.lengthMeters)} m</Table.Td>
|
||||||
</>
|
</>
|
||||||
@@ -285,7 +301,7 @@ export function IntercityRideAlongPanel({
|
|||||||
<Table.Td>
|
<Table.Td>
|
||||||
<CorridorCell row={row} />
|
<CorridorCell row={row} />
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<NeedCells need={row.need} />
|
<NeedCells row={row} />
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
{row.fits ? (
|
{row.fits ? (
|
||||||
<Badge size="sm" variant="light" color="teal">
|
<Badge size="sm" variant="light" color="teal">
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import {
|
||||||
|
ALL_TRADE_DIRECTIONS,
|
||||||
|
userTradeAccessService,
|
||||||
|
type TradeDirection,
|
||||||
|
} from "@/services/userTradeAccess.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current user's trade-direction scope. While loading (or on error) it
|
||||||
|
* reports full access — the API enforces the real scope regardless; this
|
||||||
|
* hook only trims filter dropdowns to the directions the user can see.
|
||||||
|
*/
|
||||||
|
export function useMyTradeAccess() {
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ["user-trade-access", "me"],
|
||||||
|
queryFn: userTradeAccessService.me,
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const directions: TradeDirection[] = data?.directions ?? [
|
||||||
|
...ALL_TRADE_DIRECTIONS,
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
restricted: data?.restricted ?? false,
|
||||||
|
directions,
|
||||||
|
/** Trim `{ value }`-shaped dropdown options to the allowed directions. */
|
||||||
|
filterOptions: <T extends { value: string }>(options: T[]): T[] =>
|
||||||
|
options.filter((o) => directions.includes(o.value as TradeDirection)),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import toast from "react-hot-toast";
|
|||||||
|
|
||||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||||
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
|
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
|
||||||
|
import { StampUpload } from "@/components/contracts/StampUpload";
|
||||||
import { bookingSurface } from "@/components/bookings/booking-ui.styles";
|
import { bookingSurface } from "@/components/bookings/booking-ui.styles";
|
||||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||||
import { invalidateBookingDetail } from "@/utils/queryInvalidation";
|
import { invalidateBookingDetail } from "@/utils/queryInvalidation";
|
||||||
@@ -40,6 +41,9 @@ export default function BookingContractPage() {
|
|||||||
const [signOpen, setSignOpen] = useState(false);
|
const [signOpen, setSignOpen] = useState(false);
|
||||||
const [signerName, setSignerName] = useState("");
|
const [signerName, setSignerName] = useState("");
|
||||||
const [signatureData, setSignatureData] = useState<string | null>(null);
|
const [signatureData, setSignatureData] = useState<string | null>(null);
|
||||||
|
// Company stamp: prefilled from the profile, or uploaded here when none is
|
||||||
|
// saved yet.
|
||||||
|
const [stampData, setStampData] = useState<string | null>(null);
|
||||||
// When the user has a saved signature we offer it for approval first; they
|
// When the user has a saved signature we offer it for approval first; they
|
||||||
// can switch to drawing a fresh one.
|
// can switch to drawing a fresh one.
|
||||||
const [drawNew, setDrawNew] = useState(false);
|
const [drawNew, setDrawNew] = useState(false);
|
||||||
@@ -55,6 +59,7 @@ export default function BookingContractPage() {
|
|||||||
|
|
||||||
const savedSignature = data?.savedSignature ?? null;
|
const savedSignature = data?.savedSignature ?? null;
|
||||||
const savedSignatureImage = savedSignature?.signatureImageUrl ?? null;
|
const savedSignatureImage = savedSignature?.signatureImageUrl ?? null;
|
||||||
|
const savedStampImage = savedSignature?.stampImageUrl ?? null;
|
||||||
// Show the approval view only while a saved signature exists and the user
|
// Show the approval view only while a saved signature exists and the user
|
||||||
// hasn't opted to draw a new one.
|
// hasn't opted to draw a new one.
|
||||||
const usingSaved = Boolean(savedSignatureImage) && !drawNew;
|
const usingSaved = Boolean(savedSignatureImage) && !drawNew;
|
||||||
@@ -98,6 +103,8 @@ export default function BookingContractPage() {
|
|||||||
// approve it; otherwise start with an empty pad.
|
// approve it; otherwise start with an empty pad.
|
||||||
setSignerName(savedSignature?.signerDisplayName ?? "");
|
setSignerName(savedSignature?.signerDisplayName ?? "");
|
||||||
setSignatureData(null);
|
setSignatureData(null);
|
||||||
|
// Prefill with the reusable stamp saved on the profile; still replaceable.
|
||||||
|
setStampData(savedStampImage);
|
||||||
setDrawNew(false);
|
setDrawNew(false);
|
||||||
setSignOpen(true);
|
setSignOpen(true);
|
||||||
};
|
};
|
||||||
@@ -106,10 +113,12 @@ export default function BookingContractPage() {
|
|||||||
if (!canSign || !signerName.trim()) return;
|
if (!canSign || !signerName.trim()) return;
|
||||||
// Approve the saved signature, or submit the freshly drawn one.
|
// Approve the saved signature, or submit the freshly drawn one.
|
||||||
const image = usingSaved ? savedSignatureImage : signatureData;
|
const image = usingSaved ? savedSignatureImage : signatureData;
|
||||||
if (!image) return;
|
// The API rejects a STAFF signature without a stamp.
|
||||||
|
if (!image || !stampData) return;
|
||||||
signMutation.mutate({
|
signMutation.mutate({
|
||||||
role: "STAFF",
|
role: "STAFF",
|
||||||
signatureImageBase64: image,
|
signatureImageBase64: image,
|
||||||
|
stampImageBase64: stampData,
|
||||||
signerDisplayName: signerName.trim(),
|
signerDisplayName: signerName.trim(),
|
||||||
consentText: "I agree to the terms of this contract.",
|
consentText: "I agree to the terms of this contract.",
|
||||||
});
|
});
|
||||||
@@ -234,6 +243,15 @@ export default function BookingContractPage() {
|
|||||||
) : (
|
) : (
|
||||||
<ContractSignaturePad onChange={setSignatureData} />
|
<ContractSignaturePad onChange={setSignatureData} />
|
||||||
)}
|
)}
|
||||||
|
<StampUpload
|
||||||
|
value={stampData}
|
||||||
|
onChange={setStampData}
|
||||||
|
description={
|
||||||
|
savedStampImage
|
||||||
|
? "Your saved company stamp — replace it for this contract if needed."
|
||||||
|
: "Required. Attach your official company stamp or seal."
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setSignOpen(false)}>
|
<Button variant="outline" onClick={() => setSignOpen(false)}>
|
||||||
@@ -243,6 +261,7 @@ export default function BookingContractPage() {
|
|||||||
disabled={
|
disabled={
|
||||||
signMutation.isPending ||
|
signMutation.isPending ||
|
||||||
(!usingSaved && !signatureData) ||
|
(!usingSaved && !signatureData) ||
|
||||||
|
!stampData ||
|
||||||
!signerName.trim()
|
!signerName.trim()
|
||||||
}
|
}
|
||||||
onClick={confirmSign}
|
onClick={confirmSign}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useMyTradeAccess } from "@/hooks/useMyTradeAccess";
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
Box,
|
Box,
|
||||||
@@ -139,6 +140,7 @@ export default function BookingRequestsPage() {
|
|||||||
const [statusFilter, setStatusFilter] = useState<string[]>(() =>
|
const [statusFilter, setStatusFilter] = useState<string[]>(() =>
|
||||||
paramStatuses.split(",").filter(Boolean),
|
paramStatuses.split(",").filter(Boolean),
|
||||||
);
|
);
|
||||||
|
const { filterOptions } = useMyTradeAccess();
|
||||||
const [directionFilter, setDirectionFilter] = useState<string | null>(
|
const [directionFilter, setDirectionFilter] = useState<string | null>(
|
||||||
paramDirection,
|
paramDirection,
|
||||||
);
|
);
|
||||||
@@ -602,7 +604,7 @@ export default function BookingRequestsPage() {
|
|||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
placeholder="All directions"
|
placeholder="All directions"
|
||||||
data={TRADE_DIRECTION_OPTIONS}
|
data={filterOptions(TRADE_DIRECTION_OPTIONS)}
|
||||||
value={directionFilter}
|
value={directionFilter}
|
||||||
onChange={(v) => {
|
onChange={(v) => {
|
||||||
setDirectionFilter(v);
|
setDirectionFilter(v);
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
|
import { useAuth } from "@/auth/useAuth";
|
||||||
|
import { useEmployees } from "@/user-management/hooks/useEmployees";
|
||||||
|
import {
|
||||||
|
ALL_TRADE_DIRECTIONS,
|
||||||
|
TRADE_DIRECTION_LABELS,
|
||||||
|
userTradeAccessService,
|
||||||
|
type TradeDirection,
|
||||||
|
} from "@/services/userTradeAccess.service";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
|
||||||
|
const QUERY_KEY = ["user-trade-access", "list"] as const;
|
||||||
|
|
||||||
|
type EmployeeRow = {
|
||||||
|
userId: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-user trade-direction access (Import / Export / Intercity checkboxes).
|
||||||
|
* All three checked (or never configured) = unrestricted; unchecking limits
|
||||||
|
* the user's contracts, bookings, schedules, batch board, payments, invoices
|
||||||
|
* and overview to the checked directions. Admins always bypass the scope.
|
||||||
|
*/
|
||||||
|
export default function TradeAccessPage() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
|
const organizationId =
|
||||||
|
user?.employee && user.employee.length > 0
|
||||||
|
? user.employee[0].organizationId
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const { employeesResponseByOrg, isLoadingEmployeesByOrg } = useEmployees({
|
||||||
|
organizationId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: configs, isLoading: configsLoading } = useQuery({
|
||||||
|
queryKey: QUERY_KEY,
|
||||||
|
queryFn: userTradeAccessService.list,
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
userId,
|
||||||
|
directions,
|
||||||
|
}: {
|
||||||
|
userId: string;
|
||||||
|
directions: TradeDirection[];
|
||||||
|
}) => userTradeAccessService.set(userId, directions),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: QUERY_KEY });
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: ["user-trade-access", "me"],
|
||||||
|
});
|
||||||
|
toast.success("Trade access updated");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const configByUser = useMemo(() => {
|
||||||
|
const map = new Map<string, TradeDirection[]>();
|
||||||
|
for (const row of configs ?? []) map.set(row.userId, row.directions);
|
||||||
|
return map;
|
||||||
|
}, [configs]);
|
||||||
|
|
||||||
|
const rows: EmployeeRow[] = useMemo(() => {
|
||||||
|
const items = employeesResponseByOrg?.items ?? [];
|
||||||
|
const mapped = items
|
||||||
|
.map((item: { user?: { id?: string; name?: { en?: string }; email?: string; username?: string } }) => ({
|
||||||
|
userId: item.user?.id ?? "",
|
||||||
|
name: item.user?.name?.en ?? item.user?.username ?? "—",
|
||||||
|
email: item.user?.email ?? "",
|
||||||
|
}))
|
||||||
|
.filter((r: EmployeeRow) => r.userId);
|
||||||
|
const term = search.trim().toLowerCase();
|
||||||
|
if (!term) return mapped;
|
||||||
|
return mapped.filter(
|
||||||
|
(r: EmployeeRow) =>
|
||||||
|
r.name.toLowerCase().includes(term) ||
|
||||||
|
r.email.toLowerCase().includes(term),
|
||||||
|
);
|
||||||
|
}, [employeesResponseByOrg, search]);
|
||||||
|
|
||||||
|
// No row yet = unrestricted, so render as all three checked.
|
||||||
|
const directionsFor = (userId: string): TradeDirection[] =>
|
||||||
|
configByUser.get(userId) ?? [...ALL_TRADE_DIRECTIONS];
|
||||||
|
|
||||||
|
const toggle = (userId: string, direction: TradeDirection) => {
|
||||||
|
const current = directionsFor(userId);
|
||||||
|
const next = current.includes(direction)
|
||||||
|
? current.filter((d) => d !== direction)
|
||||||
|
: [...current, direction];
|
||||||
|
saveMutation.mutate({ userId, directions: next });
|
||||||
|
};
|
||||||
|
|
||||||
|
const loading = isLoadingEmployeesByOrg || configsLoading;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 p-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold">Trade direction access</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Choose which trade directions each backoffice user can see. This
|
||||||
|
filters their contracts, bookings, schedules, batch board, payments,
|
||||||
|
invoices and overview. All three checked means full access; super and
|
||||||
|
organization admins are never restricted.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="Search by name or email…"
|
||||||
|
className="w-full max-w-sm rounded-md border px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Loading users…</p>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>User</TableHead>
|
||||||
|
<TableHead>Email</TableHead>
|
||||||
|
{ALL_TRADE_DIRECTIONS.map((d) => (
|
||||||
|
<TableHead key={d} className="text-center">
|
||||||
|
{TRADE_DIRECTION_LABELS[d]}
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
<TableHead>Access</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{rows.map((row) => {
|
||||||
|
const dirs = directionsFor(row.userId);
|
||||||
|
const unrestricted = dirs.length === ALL_TRADE_DIRECTIONS.length;
|
||||||
|
return (
|
||||||
|
<TableRow key={row.userId}>
|
||||||
|
<TableCell className="font-medium">{row.name}</TableCell>
|
||||||
|
<TableCell>{row.email}</TableCell>
|
||||||
|
{ALL_TRADE_DIRECTIONS.map((d) => (
|
||||||
|
<TableCell key={d} className="text-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="h-4 w-4 accent-primary"
|
||||||
|
checked={dirs.includes(d)}
|
||||||
|
disabled={saveMutation.isPending}
|
||||||
|
onChange={() => toggle(row.userId, d)}
|
||||||
|
aria-label={`${row.name} — ${TRADE_DIRECTION_LABELS[d]}`}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
<TableCell>
|
||||||
|
{unrestricted ? (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Full access
|
||||||
|
</span>
|
||||||
|
) : dirs.length === 0 ? (
|
||||||
|
<span className="text-xs font-medium text-red-600">
|
||||||
|
No data
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs font-medium text-amber-600">
|
||||||
|
{dirs.map((d) => TRADE_DIRECTION_LABELS[d]).join(" + ")}{" "}
|
||||||
|
only
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{rows.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={3 + ALL_TRADE_DIRECTIONS.length}
|
||||||
|
className="text-center text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
No users found.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useMyTradeAccess } from "@/hooks/useMyTradeAccess";
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
Box,
|
Box,
|
||||||
@@ -92,6 +93,7 @@ export default function ClearanceDocumentsPage() {
|
|||||||
const [bookingStatuses, setBookingStatuses] = useState(
|
const [bookingStatuses, setBookingStatuses] = useState(
|
||||||
BOOKING_STATUS_OPTIONS[0].value,
|
BOOKING_STATUS_OPTIONS[0].value,
|
||||||
);
|
);
|
||||||
|
const { filterOptions } = useMyTradeAccess();
|
||||||
const [directionFilter, setDirectionFilter] = useState<string | null>(null);
|
const [directionFilter, setDirectionFilter] = useState<string | null>(null);
|
||||||
const [freightTypeFilter, setFreightTypeFilter] = useState<string | null>(null);
|
const [freightTypeFilter, setFreightTypeFilter] = useState<string | null>(null);
|
||||||
const [ownershipFilter, setOwnershipFilter] = useState<string | null>(null);
|
const [ownershipFilter, setOwnershipFilter] = useState<string | null>(null);
|
||||||
@@ -309,7 +311,7 @@ export default function ClearanceDocumentsPage() {
|
|||||||
<Group gap="sm" mt="sm" wrap="wrap">
|
<Group gap="sm" mt="sm" wrap="wrap">
|
||||||
<Select
|
<Select
|
||||||
placeholder="Direction"
|
placeholder="Direction"
|
||||||
data={TRADE_DIRECTION_OPTIONS}
|
data={filterOptions(TRADE_DIRECTION_OPTIONS)}
|
||||||
value={directionFilter}
|
value={directionFilter}
|
||||||
onChange={(v) => {
|
onChange={(v) => {
|
||||||
setDirectionFilter(v);
|
setDirectionFilter(v);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { directionLabel } from "@/lib/utils";
|
import { directionLabel } from "@/lib/utils";
|
||||||
|
import { useMyTradeAccess } from "@/hooks/useMyTradeAccess";
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
Box,
|
Box,
|
||||||
@@ -156,6 +157,7 @@ export default function ContractRequestsPage() {
|
|||||||
const [activeTab, setActiveTab] = useState<ContractStatusTabKey>("all");
|
const [activeTab, setActiveTab] = useState<ContractStatusTabKey>("all");
|
||||||
// Filter controls (empty/null = "all").
|
// Filter controls (empty/null = "all").
|
||||||
const [statusFilter, setStatusFilter] = useState<string[]>([]);
|
const [statusFilter, setStatusFilter] = useState<string[]>([]);
|
||||||
|
const { filterOptions } = useMyTradeAccess();
|
||||||
const [directionFilter, setDirectionFilter] = useState<string | null>(null);
|
const [directionFilter, setDirectionFilter] = useState<string | null>(null);
|
||||||
const [freightTypeFilter, setFreightTypeFilter] = useState<string | null>(
|
const [freightTypeFilter, setFreightTypeFilter] = useState<string | null>(
|
||||||
null,
|
null,
|
||||||
@@ -561,7 +563,7 @@ export default function ContractRequestsPage() {
|
|||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
placeholder="All directions"
|
placeholder="All directions"
|
||||||
data={TRADE_DIRECTION_OPTIONS}
|
data={filterOptions(TRADE_DIRECTION_OPTIONS)}
|
||||||
value={directionFilter}
|
value={directionFilter}
|
||||||
onChange={(v) => {
|
onChange={(v) => {
|
||||||
setDirectionFilter(v);
|
setDirectionFilter(v);
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ import {
|
|||||||
PreviewSummary,
|
PreviewSummary,
|
||||||
ScheduleWarningsAlert,
|
ScheduleWarningsAlert,
|
||||||
} from "@/components/trainScheduling/ScheduleWarningsAlert";
|
} from "@/components/trainScheduling/ScheduleWarningsAlert";
|
||||||
|
import { Train3DVisualization } from "@/components/trainScheduling/Train3DVisualization";
|
||||||
import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram";
|
import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram";
|
||||||
import { TrainConsistView } from "@/components/trainScheduling/compositionEditor";
|
import { TrainConsistView } from "@/components/trainScheduling/compositionEditor";
|
||||||
import { WagonPlanGrid } from "@/components/trainScheduling/WagonPlanGrid";
|
import { WagonPlanGrid } from "@/components/trainScheduling/WagonPlanGrid";
|
||||||
@@ -116,6 +117,7 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
const [gatepassNotes, setGatepassNotes] = useState("");
|
const [gatepassNotes, setGatepassNotes] = useState("");
|
||||||
const [dispatchConfirmOpen, setDispatchConfirmOpen] = useState(false);
|
const [dispatchConfirmOpen, setDispatchConfirmOpen] = useState(false);
|
||||||
const [switchTarget, setSwitchTarget] = useState<EligibleContainerBooking | null>(null);
|
const [switchTarget, setSwitchTarget] = useState<EligibleContainerBooking | null>(null);
|
||||||
|
const [visualization3DOpen, setVisualization3DOpen] = useState(false);
|
||||||
const autoPreviewedRef = useRef(false);
|
const autoPreviewedRef = useRef(false);
|
||||||
|
|
||||||
const detailQuery = useQuery(
|
const detailQuery = useQuery(
|
||||||
@@ -742,16 +744,7 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
<WagonPlanGrid wagonPlan={displayWagonPlanOriented} freightType={freightType} />
|
<WagonPlanGrid wagonPlan={displayWagonPlanOriented} freightType={freightType} />
|
||||||
{canEditBookings && (previewResult || displayWagonPlan.length) ? (
|
{canEditBookings && (previewResult || displayWagonPlan.length) ? (
|
||||||
<Group>
|
<Group>
|
||||||
{!hasContainerStep ? (
|
{hasContainerStep ? (
|
||||||
<Button
|
|
||||||
color="edr-green"
|
|
||||||
radius="md"
|
|
||||||
loading={assign.isPending}
|
|
||||||
onClick={handleAssign}
|
|
||||||
>
|
|
||||||
{assignedIds.length ? "Save assignments" : "Assign bookings"}
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button
|
<Button
|
||||||
color="edr-green"
|
color="edr-green"
|
||||||
radius="md"
|
radius="md"
|
||||||
@@ -760,7 +753,7 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
>
|
>
|
||||||
Continue to containers
|
Continue to containers
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
) : null}
|
||||||
<Button variant="default" radius="md" onClick={() => void runPreview()}>
|
<Button variant="default" radius="md" onClick={() => void runPreview()}>
|
||||||
Refresh preview
|
Refresh preview
|
||||||
</Button>
|
</Button>
|
||||||
@@ -955,6 +948,18 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
</Stack>
|
</Stack>
|
||||||
</Group>
|
</Group>
|
||||||
<Group gap="sm">
|
<Group gap="sm">
|
||||||
|
{(schedule.trainSet?.wagons?.length ?? 0) > 0 ? (
|
||||||
|
<Button
|
||||||
|
variant="gradient"
|
||||||
|
gradient={{ from: "#0f172a", to: "#334155" }}
|
||||||
|
radius="lg"
|
||||||
|
size="sm"
|
||||||
|
leftSection={<Eye size={16} />}
|
||||||
|
onClick={() => setVisualization3DOpen(true)}
|
||||||
|
>
|
||||||
|
3D Visualization
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
{canPrintMarshalling ? (
|
{canPrintMarshalling ? (
|
||||||
<Button
|
<Button
|
||||||
variant="light"
|
variant="light"
|
||||||
@@ -1406,6 +1411,9 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
{visualization3DOpen ? (
|
||||||
|
<Train3DVisualization schedule={schedule} onClose={() => setVisualization3DOpen(false)} />
|
||||||
|
) : null}
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ export interface ContractView {
|
|||||||
savedSignature?: {
|
savedSignature?: {
|
||||||
signerDisplayName: string;
|
signerDisplayName: string;
|
||||||
signatureImageUrl?: string | null;
|
signatureImageUrl?: string | null;
|
||||||
|
stampImageUrl?: string | null;
|
||||||
} | null;
|
} | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,6 +114,8 @@ export interface ConsolidationDetails {
|
|||||||
export interface SignContractPayload {
|
export interface SignContractPayload {
|
||||||
role: "CUSTOMER" | "STAFF";
|
role: "CUSTOMER" | "STAFF";
|
||||||
signatureImageBase64: string;
|
signatureImageBase64: string;
|
||||||
|
/** Company stamp/seal image; the API requires one for CUSTOMER and STAFF. */
|
||||||
|
stampImageBase64?: string;
|
||||||
signerDisplayName: string;
|
signerDisplayName: string;
|
||||||
consentText?: string;
|
consentText?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ export interface SavedSignature {
|
|||||||
|
|
||||||
export interface SaveSignaturePayload {
|
export interface SaveSignaturePayload {
|
||||||
signerDisplayName: string;
|
signerDisplayName: string;
|
||||||
signatureImageBase64: string;
|
/** Omit to keep the existing saved signature (stamp-only update). */
|
||||||
|
signatureImageBase64?: string;
|
||||||
/** Omit to keep the existing saved stamp. */
|
/** Omit to keep the existing saved stamp. */
|
||||||
stampImageBase64?: string;
|
stampImageBase64?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { api as client } from "../auth/http";
|
||||||
|
|
||||||
|
export type TradeDirection = "IMPORT" | "EXPORT" | "DOMESTIC";
|
||||||
|
|
||||||
|
export const ALL_TRADE_DIRECTIONS: TradeDirection[] = [
|
||||||
|
"IMPORT",
|
||||||
|
"EXPORT",
|
||||||
|
"DOMESTIC",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const TRADE_DIRECTION_LABELS: Record<TradeDirection, string> = {
|
||||||
|
IMPORT: "Import",
|
||||||
|
EXPORT: "Export",
|
||||||
|
DOMESTIC: "Intercity",
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface UserTradeAccessRow {
|
||||||
|
userId: string;
|
||||||
|
directions: TradeDirection[];
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MyTradeAccess {
|
||||||
|
restricted: boolean;
|
||||||
|
directions: TradeDirection[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const userTradeAccessService = {
|
||||||
|
/** All configured per-user scopes (admin only). */
|
||||||
|
list: async (): Promise<UserTradeAccessRow[]> =>
|
||||||
|
(await client.get("/user-trade-access")).data,
|
||||||
|
|
||||||
|
/** Current user's effective scope. */
|
||||||
|
me: async (): Promise<MyTradeAccess> =>
|
||||||
|
(await client.get("/user-trade-access/me")).data,
|
||||||
|
|
||||||
|
/** Set the directions a user may see (admin only). */
|
||||||
|
set: async (
|
||||||
|
userId: string,
|
||||||
|
directions: TradeDirection[],
|
||||||
|
): Promise<UserTradeAccessRow> =>
|
||||||
|
(await client.put(`/user-trade-access/${userId}`, { directions })).data,
|
||||||
|
};
|
||||||
@@ -171,6 +171,8 @@ export interface BookingDetail {
|
|||||||
/** What the containers carry / bulk commodity label — entered at booking time. */
|
/** What the containers carry / bulk commodity label — entered at booking time. */
|
||||||
cargoFreeText?: string | null;
|
cargoFreeText?: string | null;
|
||||||
cargoTotalWeightVgm: number;
|
cargoTotalWeightVgm: number;
|
||||||
|
/** Break-bulk (PER_ITEM) only: real total tons — cargoTotalWeightVgm then holds the item count. */
|
||||||
|
bulkTotalWeightTons?: number | null;
|
||||||
isHazardous: boolean;
|
isHazardous: boolean;
|
||||||
consolidationPartnerId?: string | null;
|
consolidationPartnerId?: string | null;
|
||||||
consolidationPartner?: BookingNamedRef & { reference?: string } | null;
|
consolidationPartner?: BookingNamedRef & { reference?: string } | null;
|
||||||
|
|||||||
@@ -959,6 +959,14 @@ export interface IntercityCapacity {
|
|||||||
lengthMeters: number | null;
|
lengthMeters: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One wagon type this train must give up, and how many of it. */
|
||||||
|
export interface IntercityWagonBreakdownEntry {
|
||||||
|
wagonTypeId: string;
|
||||||
|
/** Wagon-type code as marshalled, e.g. "N35" / "PW2". */
|
||||||
|
code: string;
|
||||||
|
wagons: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface IntercityBookingRow {
|
export interface IntercityBookingRow {
|
||||||
id: string;
|
id: string;
|
||||||
reference: string | null;
|
reference: string | null;
|
||||||
@@ -973,6 +981,12 @@ export interface IntercityBookingRow {
|
|||||||
weightTons: number;
|
weightTons: number;
|
||||||
paymentDeadline: string | null;
|
paymentDeadline: string | null;
|
||||||
need: IntercityCapacity | null;
|
need: IntercityCapacity | null;
|
||||||
|
/**
|
||||||
|
* `need.wagons` split across the wagon types THIS schedule stocks — the same
|
||||||
|
* booking reads differently on a train of 60T wagons than on one of 40T.
|
||||||
|
* Empty when the stock or the cargo type's wagon list is unresolved.
|
||||||
|
*/
|
||||||
|
wagonBreakdown?: IntercityWagonBreakdownEntry[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IntercityCandidateRow extends IntercityBookingRow {
|
export interface IntercityCandidateRow extends IntercityBookingRow {
|
||||||
|
|||||||
18
apps/edr-freight-web/backoffice/src/utils/cargoWeight.ts
Normal file
18
apps/edr-freight-web/backoffice/src/utils/cargoWeight.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* Break-bulk (PER_ITEM) bulk bookings overload `cargoTotalWeightVgm` with the
|
||||||
|
* ITEM COUNT; their real tonnage lives in `bulkTotalWeightTons`. Every other
|
||||||
|
* booking stores tons in `cargoTotalWeightVgm` directly. Rendering the raw
|
||||||
|
* VGM column showed a 20-item / 100T booking as "20 tons".
|
||||||
|
*/
|
||||||
|
export function cargoTonsAndItems(booking: {
|
||||||
|
freightType?: string | null;
|
||||||
|
cargoTotalWeightVgm?: number | string | null;
|
||||||
|
bulkTotalWeightTons?: number | string | null;
|
||||||
|
}): { tons: number; items: number | null } {
|
||||||
|
const bulkTons = Number(booking.bulkTotalWeightTons ?? 0);
|
||||||
|
const vgm = Number(booking.cargoTotalWeightVgm ?? 0);
|
||||||
|
if (booking.freightType === "BULK" && bulkTons > 0) {
|
||||||
|
return { tons: bulkTons, items: vgm > 0 ? vgm : null };
|
||||||
|
}
|
||||||
|
return { tons: vgm, items: null };
|
||||||
|
}
|
||||||
@@ -548,18 +548,18 @@ export function AppLayout({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<Divider />
|
<Divider />
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<FileSignature size={15} />}
|
||||||
|
onClick={() => navigate("/signature")}
|
||||||
|
>
|
||||||
|
Signature & Stamp
|
||||||
|
</Menu.Item>
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<User size={15} />}
|
leftSection={<User size={15} />}
|
||||||
onClick={() => navigate("/profile")}
|
onClick={() => navigate("/profile")}
|
||||||
>
|
>
|
||||||
Profile
|
Profile
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Item
|
|
||||||
leftSection={<FileSignature size={15} />}
|
|
||||||
onClick={() => navigate("/signature")}
|
|
||||||
>
|
|
||||||
My signature
|
|
||||||
</Menu.Item>
|
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<Settings size={15} />}
|
leftSection={<Settings size={15} />}
|
||||||
onClick={() => navigate("/settings")}
|
onClick={() => navigate("/settings")}
|
||||||
|
|||||||
@@ -1,15 +1,23 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
|
Avatar,
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Group,
|
Group,
|
||||||
SimpleGrid,
|
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { BadgeCheck, Clock, ShieldCheck, XCircle } from "lucide-react";
|
import {
|
||||||
|
BadgeCheck,
|
||||||
|
Clock,
|
||||||
|
Mail,
|
||||||
|
MapPin,
|
||||||
|
Phone,
|
||||||
|
ShieldCheck,
|
||||||
|
XCircle,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
verifaydaService,
|
verifaydaService,
|
||||||
@@ -42,10 +50,12 @@ interface FaydaVerifyPanelProps {
|
|||||||
pendingReview?: boolean;
|
pendingReview?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(iso: string | null): string {
|
function getInitials(name: string | null): string {
|
||||||
if (!iso) return "";
|
if (!name) return "?";
|
||||||
const d = new Date(iso);
|
const parts = name.trim().split(/\s+/);
|
||||||
return Number.isNaN(d.getTime()) ? "" : d.toLocaleDateString();
|
const first = parts[0]?.[0] ?? "";
|
||||||
|
const last = parts.length > 1 ? (parts[parts.length - 1]?.[0] ?? "") : "";
|
||||||
|
return (first + last).toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -72,6 +82,20 @@ export default function FaydaVerifyPanel({
|
|||||||
// panel between steps can't complete a verification against the wrong person.
|
// panel between steps can't complete a verification against the wrong person.
|
||||||
const subjectRef = useRef(subject);
|
const subjectRef = useRef(subject);
|
||||||
subjectRef.current = subject;
|
subjectRef.current = subject;
|
||||||
|
// FaydaCallbackPage posts its message from a StrictMode-double-invoked
|
||||||
|
// effect in dev, so the same one-time-use code+state can arrive twice.
|
||||||
|
// Track the last state we've started completing so the resend is a no-op.
|
||||||
|
const handledStateRef = useRef<string | null>(null);
|
||||||
|
// Polls the popup so a manually-closed window (no postMessage ever sent)
|
||||||
|
// still clears `loading` instead of leaving the button spinning forever.
|
||||||
|
const pollRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const stopPolling = () => {
|
||||||
|
if (pollRef.current !== null) {
|
||||||
|
window.clearInterval(pollRef.current);
|
||||||
|
pollRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onMessage = async (event: MessageEvent<FaydaCallbackMessage>) => {
|
const onMessage = async (event: MessageEvent<FaydaCallbackMessage>) => {
|
||||||
@@ -79,11 +103,15 @@ export default function FaydaVerifyPanel({
|
|||||||
if (event.data?.type !== "fayda-callback") return;
|
if (event.data?.type !== "fayda-callback") return;
|
||||||
|
|
||||||
if (event.data.error) {
|
if (event.data.error) {
|
||||||
|
stopPolling();
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setError(event.data.errorDescription ?? event.data.error);
|
setError(event.data.errorDescription ?? event.data.error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!event.data.code || !event.data.state) return;
|
if (!event.data.code || !event.data.state) return;
|
||||||
|
if (handledStateRef.current === event.data.state) return;
|
||||||
|
handledStateRef.current = event.data.state;
|
||||||
|
stopPolling();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const next = await verifaydaService.completeIdentity(
|
const next = await verifaydaService.completeIdentity(
|
||||||
@@ -104,13 +132,17 @@ export default function FaydaVerifyPanel({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener("message", onMessage);
|
window.addEventListener("message", onMessage);
|
||||||
return () => window.removeEventListener("message", onMessage);
|
return () => {
|
||||||
|
window.removeEventListener("message", onMessage);
|
||||||
|
stopPolling();
|
||||||
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const startVerification = async () => {
|
const startVerification = async () => {
|
||||||
setError(null);
|
setError(null);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
handledStateRef.current = null;
|
||||||
try {
|
try {
|
||||||
const authorizationUrl = await verifaydaService.start();
|
const authorizationUrl = await verifaydaService.start();
|
||||||
const popup = window.open(
|
const popup = window.open(
|
||||||
@@ -121,8 +153,17 @@ export default function FaydaVerifyPanel({
|
|||||||
if (!popup) {
|
if (!popup) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setError("Pop-up blocked — allow pop-ups for this site and try again.");
|
setError("Pop-up blocked — allow pop-ups for this site and try again.");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
// Loading stays on until the popup posts back.
|
// Loading stays on until the popup posts back — unless the user closes
|
||||||
|
// it by hand, which never sends a message; poll for that and clear
|
||||||
|
// loading ourselves so the button doesn't spin forever.
|
||||||
|
stopPolling();
|
||||||
|
pollRef.current = window.setInterval(() => {
|
||||||
|
if (!popup.closed) return;
|
||||||
|
stopPolling();
|
||||||
|
if (handledStateRef.current === null) setLoading(false);
|
||||||
|
}, 500);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setError(
|
setError(
|
||||||
@@ -141,7 +182,7 @@ export default function FaydaVerifyPanel({
|
|||||||
<Group gap="sm">
|
<Group gap="sm">
|
||||||
<ShieldCheck size={18} />
|
<ShieldCheck size={18} />
|
||||||
<Text fw={600} c="edr-text">
|
<Text fw={600} c="edr-text">
|
||||||
{title} identity
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
{verified ? (
|
{verified ? (
|
||||||
<Badge
|
<Badge
|
||||||
@@ -191,13 +232,21 @@ export default function FaydaVerifyPanel({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{verified && state && (
|
{verified && state && (
|
||||||
<SimpleGrid cols={2} spacing="xs">
|
<Group align="flex-start" gap="sm" wrap="nowrap">
|
||||||
<VerifiedField label="Name" value={state.name} />
|
<Avatar radius="xl" size={44} color="edr-green" variant="light">
|
||||||
<VerifiedField label="Phone" value={state.phone} />
|
{getInitials(state.name)}
|
||||||
<VerifiedField label="Email" value={state.email} />
|
</Avatar>
|
||||||
<VerifiedField label="Address" value={state.address} />
|
<Stack gap={4} style={{ minWidth: 0, flex: 1 }}>
|
||||||
<VerifiedField label="Verified" value={formatDate(state.verifiedAt)} />
|
<Text fw={600} size="sm" c="edr-text" truncate>
|
||||||
</SimpleGrid>
|
{state.name}
|
||||||
|
</Text>
|
||||||
|
<Group gap="md" wrap="wrap">
|
||||||
|
<DataRow icon={<Phone size={13} />} value={state.phone} />
|
||||||
|
<DataRow icon={<Mail size={13} />} value={state.email} />
|
||||||
|
</Group>
|
||||||
|
<DataRow icon={<MapPin size={13} />} value={state.address} />
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
@@ -209,22 +258,16 @@ export default function FaydaVerifyPanel({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function VerifiedField({
|
function DataRow({ icon, value }: { icon: ReactNode; value: string | null }) {
|
||||||
label,
|
|
||||||
value,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
value: string | null;
|
|
||||||
}) {
|
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
return (
|
return (
|
||||||
<Stack gap={0}>
|
<Group gap={6} wrap="nowrap">
|
||||||
<Text size="xs" c="edr-muted">
|
<span style={{ color: "var(--mantine-color-edr-muted-6)", display: "flex", flexShrink: 0 }}>
|
||||||
{label}
|
{icon}
|
||||||
</Text>
|
</span>
|
||||||
<Text size="sm" fw={500} c="edr-text">
|
<Text size="xs" c="edr-muted" truncate>
|
||||||
{value}
|
{value}
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Alert, Button, Group, Loader, Stack, TextInput } from "@mantine/core";
|
import { Alert, Button, Loader, Stack, TextInput } from "@mantine/core";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import type { UseFormRegisterReturn } from "react-hook-form";
|
import type { UseFormRegisterReturn } from "react-hook-form";
|
||||||
import { AlertCircle, Download } from "lucide-react";
|
import { AlertCircle, Download } from "lucide-react";
|
||||||
@@ -24,49 +24,34 @@ interface ETradeInfoProps {
|
|||||||
onDataLoaded: (data: CompanyRegistrationData) => void;
|
onDataLoaded: (data: CompanyRegistrationData) => void;
|
||||||
/** Reports the live lookup status so the parent step can gate on it. */
|
/** Reports the live lookup status so the parent step can gate on it. */
|
||||||
onStatusChange?: (status: ETradeStatus) => void;
|
onStatusChange?: (status: ETradeStatus) => void;
|
||||||
|
/** Called when the TIN changes away from the last fetched value — clear whatever it filled in. */
|
||||||
|
onReset?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isValidTin = (tin: string) => tin.length === 10;
|
const isValidTin = (tin: string) => tin.length === 10;
|
||||||
|
|
||||||
/** Plain-text summary of the fetched eTrade record, downloaded client-side (eTrade returns data, not a document). */
|
|
||||||
function downloadTinRecord(tin: string, data: CompanyRegistrationData) {
|
|
||||||
const lines = [
|
|
||||||
`TIN: ${tin}`,
|
|
||||||
`Company name: ${data.companyName}`,
|
|
||||||
`Licence number: ${data.licenceNumber}`,
|
|
||||||
`Status: ${data.statusDescription}`,
|
|
||||||
`Date registered: ${data.dateRegistered}`,
|
|
||||||
`Renewed from: ${data.renewedFrom}`,
|
|
||||||
`Renewal date: ${data.renewalDate}`,
|
|
||||||
`Renewed to: ${data.renewedTo}`,
|
|
||||||
`Address: ${[data.region, data.zone, data.woreda, data.kebele, data.houseNo].filter(Boolean).join(", ")}`,
|
|
||||||
`Manager: ${data.managerName}`,
|
|
||||||
];
|
|
||||||
const blob = new Blob([lines.join("\n")], { type: "text/plain;charset=utf-8" });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement("a");
|
|
||||||
a.href = url;
|
|
||||||
a.download = `tin-${tin}.txt`;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
a.remove();
|
|
||||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ETradeInfo({
|
export default function ETradeInfo({
|
||||||
tin,
|
tin,
|
||||||
register,
|
register,
|
||||||
error,
|
error,
|
||||||
onDataLoaded,
|
onDataLoaded,
|
||||||
onStatusChange,
|
onStatusChange,
|
||||||
|
onReset,
|
||||||
}: ETradeInfoProps) {
|
}: ETradeInfoProps) {
|
||||||
const mutation = useETradeData();
|
const mutation = useETradeData();
|
||||||
const isLoading = mutation.isPending;
|
const isLoading = mutation.isPending;
|
||||||
const tinTaken = mutation.data?.tinTaken;
|
const tinTaken = mutation.data?.tinTaken;
|
||||||
|
|
||||||
|
// Bumped on every TIN change so a fetch already in flight for an older TIN
|
||||||
|
// is ignored when it lands — otherwise a slow lookup can resolve after the
|
||||||
|
// user has typed a different TIN and overwrite its fields with stale data.
|
||||||
|
const requestIdRef = useRef(0);
|
||||||
|
|
||||||
const handleFetch = async () => {
|
const handleFetch = async () => {
|
||||||
if (!isValidTin(tin)) return;
|
if (!isValidTin(tin)) return;
|
||||||
|
const requestId = ++requestIdRef.current;
|
||||||
const result = await mutation.mutateAsync(tin);
|
const result = await mutation.mutateAsync(tin);
|
||||||
|
if (requestIdRef.current !== requestId) return;
|
||||||
if (result && !result.tinTaken) {
|
if (result && !result.tinTaken) {
|
||||||
onDataLoaded(result);
|
onDataLoaded(result);
|
||||||
}
|
}
|
||||||
@@ -78,6 +63,16 @@ export default function ETradeInfo({
|
|||||||
// doesn't refire the lookup the moment this mounts.
|
// doesn't refire the lookup the moment this mounts.
|
||||||
const lastFetchedTin = useRef<string | null>(tin || null);
|
const lastFetchedTin = useRef<string | null>(tin || null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (tin !== lastFetchedTin.current) {
|
||||||
|
// TIN moved away from whatever we last fetched — that result (verified
|
||||||
|
// data, "taken", or an error) no longer describes this TIN. Drop it so
|
||||||
|
// the UI doesn't keep showing the previous TIN's outcome.
|
||||||
|
requestIdRef.current++;
|
||||||
|
if (mutation.data || mutation.error) {
|
||||||
|
mutation.reset();
|
||||||
|
onReset?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
if (isValidTin(tin) && lastFetchedTin.current !== tin) {
|
if (isValidTin(tin) && lastFetchedTin.current !== tin) {
|
||||||
lastFetchedTin.current = tin;
|
lastFetchedTin.current = tin;
|
||||||
handleFetch();
|
handleFetch();
|
||||||
@@ -86,15 +81,13 @@ export default function ETradeInfo({
|
|||||||
}, [tin]);
|
}, [tin]);
|
||||||
|
|
||||||
const apiError =
|
const apiError =
|
||||||
mutation.isError && mutation.error
|
mutation.isError && mutation.error ? extractApiError(mutation.error) : null;
|
||||||
? extractApiError(mutation.error)
|
|
||||||
: null;
|
|
||||||
// A 400 here means eTrade simply has no record for this TIN.
|
// A 400 here means eTrade simply has no record for this TIN.
|
||||||
const notFound = apiError?.statusCode === 400;
|
const notFound = apiError?.statusCode === 400;
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
apiError && !notFound
|
apiError && !notFound
|
||||||
? apiError.message ||
|
? apiError.message ||
|
||||||
"We couldn't reach eTrade to fetch your company information. Please try again."
|
"We couldn't reach eTrade to fetch your company information. Please try again."
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const status: ETradeStatus = isLoading
|
const status: ETradeStatus = isLoading
|
||||||
@@ -117,49 +110,48 @@ export default function ETradeInfo({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [status]);
|
}, [status]);
|
||||||
|
|
||||||
const showRetry = isValidTin(tin) && status !== "verified" && status !== "loading";
|
// True for the one render between the TIN reaching 10 digits and the
|
||||||
|
// effect above actually starting the fetch — without this, "Get Data"
|
||||||
|
// flashes on screen for that frame before `isLoading` ever turns true.
|
||||||
|
const willAutoFetch = isValidTin(tin) && lastFetchedTin.current !== tin;
|
||||||
|
const showLoading = isLoading || willAutoFetch;
|
||||||
|
|
||||||
|
const showRetry = isValidTin(tin) && status !== "verified" && !showLoading;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<Group align="flex-start" grow>
|
<div className="max-sm:flex-col! max-sm: grow flex items-start gap-4">
|
||||||
<TextInput
|
<TextInput
|
||||||
label={
|
aria-label="TIN Number (10 digits), required"
|
||||||
<>
|
|
||||||
TIN Number (10 digits){" "}
|
|
||||||
<span style={{ color: "var(--mantine-color-red-6)" }}>*</span>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
placeholder="0012345678"
|
placeholder="0012345678"
|
||||||
maxLength={10}
|
maxLength={10}
|
||||||
error={error}
|
error={error}
|
||||||
{...register}
|
{...register}
|
||||||
/>
|
/>
|
||||||
|
{showLoading && (
|
||||||
|
<Button
|
||||||
|
className="max-w-none"
|
||||||
|
variant="filled"
|
||||||
|
color="edr-green"
|
||||||
|
disabled
|
||||||
|
leftSection={<Loader size={16} />}
|
||||||
|
>
|
||||||
|
Getting...
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{showRetry && (
|
{showRetry && (
|
||||||
<Button
|
<Button
|
||||||
|
className="max-w-none"
|
||||||
variant="filled"
|
variant="filled"
|
||||||
color="edr-green"
|
color="edr-green"
|
||||||
onClick={handleFetch}
|
onClick={handleFetch}
|
||||||
disabled={!isValidTin(tin) || isLoading}
|
disabled={!isValidTin(tin)}
|
||||||
leftSection={
|
|
||||||
isLoading ? <Loader size={16} /> : <Download size={16} />
|
|
||||||
}
|
|
||||||
mt="24px"
|
|
||||||
>
|
|
||||||
{isLoading ? "Getting..." : "Get Data"}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{status === "verified" && mutation.data && !mutation.data.tinTaken && (
|
|
||||||
<Button
|
|
||||||
variant="light"
|
|
||||||
color="edr-green"
|
|
||||||
onClick={() => downloadTinRecord(tin, mutation.data!)}
|
|
||||||
leftSection={<Download size={16} />}
|
leftSection={<Download size={16} />}
|
||||||
mt="24px"
|
|
||||||
>
|
>
|
||||||
Download
|
Get Data
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</div>
|
||||||
|
|
||||||
{notFound && (
|
{notFound && (
|
||||||
<Alert
|
<Alert
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { AlertTriangle, ArrowRight, CheckCircle2, Clock } from "lucide-react";
|
import { AlertTriangle, ArrowRight, Clock } from "lucide-react";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import useAuth from "@/hooks/useAuth";
|
import useAuth from "@/hooks/useAuth";
|
||||||
import type { OnboardingRequirements } from "@/services/companies.service";
|
import type { OnboardingRequirements } from "@/services/companies.service";
|
||||||
@@ -28,7 +28,8 @@ function getCopy(
|
|||||||
if (!requirements || requirements.progress.completed === 0) {
|
if (!requirements || requirements.progress.completed === 0) {
|
||||||
return {
|
return {
|
||||||
title: "Set up your company profile",
|
title: "Set up your company profile",
|
||||||
subtitle: "Unlock bookings, tracking and billing — it only takes a minute.",
|
subtitle:
|
||||||
|
"Unlock bookings, tracking and billing — it only takes a minute.",
|
||||||
cta: "Start onboarding",
|
cta: "Start onboarding",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -46,9 +47,8 @@ function getCopy(
|
|||||||
if (remaining <= 2) {
|
if (remaining <= 2) {
|
||||||
return {
|
return {
|
||||||
title: `Almost done — you're ${pct}% set up`,
|
title: `Almost done — you're ${pct}% set up`,
|
||||||
subtitle: `Just ${remaining} more ${
|
subtitle: `Just ${remaining} more ${remaining === 1 ? "item" : "items"
|
||||||
remaining === 1 ? "item" : "items"
|
} to finish: ${requirements.outstanding.join(", ")}.`,
|
||||||
} to finish: ${requirements.outstanding.join(", ")}.`,
|
|
||||||
cta: "Finish onboarding",
|
cta: "Finish onboarding",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -301,26 +301,24 @@ export function AccountReviewBanner() {
|
|||||||
// 5. Per-operational-profile approval (existing behaviour).
|
// 5. Per-operational-profile approval (existing behaviour).
|
||||||
if (pending.length === 0) {
|
if (pending.length === 0) {
|
||||||
// Nothing outstanding — a quiet confirmation that the account is live.
|
// Nothing outstanding — a quiet confirmation that the account is live.
|
||||||
if (companyStatus === "active") {
|
// if (companyStatus === "active") {
|
||||||
return (
|
// return (
|
||||||
<div className="border-b border-emerald-200 bg-emerald-50 px-6 py-3">
|
// <div className="border-b border-emerald-200 bg-emerald-50 px-6 py-3">
|
||||||
<div className="mx-auto flex max-w-6xl items-center gap-3">
|
// <div className="mx-auto flex max-w-6xl items-center gap-3">
|
||||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-emerald-100 text-emerald-700">
|
// <span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-emerald-100 text-emerald-700">
|
||||||
<CheckCircle2 size={18} />
|
// <CheckCircle2 size={18} />
|
||||||
</span>
|
// </span>
|
||||||
<span className="text-sm font-semibold text-emerald-900">
|
// <span className="text-sm font-semibold text-emerald-900">
|
||||||
Your account is approved
|
// Your account is approved
|
||||||
</span>
|
// </span>
|
||||||
</div>
|
// </div>
|
||||||
</div>
|
// </div>
|
||||||
);
|
// );
|
||||||
}
|
// }
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const pendingLabel = pending
|
const pendingLabel = pending.map((p) => p.type.replace(/_/g, " ")).join(", ");
|
||||||
.map((p) => p.type.replace(/_/g, " "))
|
|
||||||
.join(", ");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-b border-amber-200 bg-amber-50 px-6 py-3">
|
<div className="border-b border-amber-200 bg-amber-50 px-6 py-3">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { FileSignature, Loader2 } from "lucide-react";
|
import { FileSignature, Loader2, Stamp } from "lucide-react";
|
||||||
|
|
||||||
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
|
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
|
||||||
import { StampUpload } from "@/components/contracts/StampUpload";
|
import { StampUpload } from "@/components/contracts/StampUpload";
|
||||||
@@ -26,41 +26,56 @@ import {
|
|||||||
} from "@edr/ui-common";
|
} from "@edr/ui-common";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lets the signed-in customer view and update the reusable signature stored on
|
* Lets the signed-in customer view and update the reusable signature and
|
||||||
* their profile. The same signature is offered for approval when signing a
|
* company stamp stored on their profile — managed independently of each
|
||||||
* booking contract.
|
* other. Both are offered when signing a booking contract.
|
||||||
*/
|
*/
|
||||||
export function MySignatureCard() {
|
export function MySignatureCard() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { data: saved, isPending } = useMySignature();
|
const { data: saved, isPending } = useMySignature();
|
||||||
const saveMutation = useSaveSignature();
|
const saveMutation = useSaveSignature();
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
const [signatureOpen, setSignatureOpen] = useState(false);
|
||||||
|
const [stampOpen, setStampOpen] = useState(false);
|
||||||
const [signerName, setSignerName] = useState("");
|
const [signerName, setSignerName] = useState("");
|
||||||
const [signatureData, setSignatureData] = useState<string | null>(null);
|
const [signatureData, setSignatureData] = useState<string | null>(null);
|
||||||
const [stampData, setStampData] = useState<string | null>(null);
|
const [stampData, setStampData] = useState<string | null>(null);
|
||||||
|
|
||||||
const defaultName = user?.name?.en || user?.username || user?.email || "";
|
const defaultName = user?.name?.en || user?.username || user?.email || "";
|
||||||
|
const savedName = saved?.signerDisplayName ?? defaultName;
|
||||||
|
|
||||||
const openDialog = () => {
|
const openSignatureDialog = () => {
|
||||||
setSignerName(saved?.signerDisplayName ?? defaultName);
|
setSignerName(savedName);
|
||||||
setSignatureData(null);
|
setSignatureData(null);
|
||||||
setStampData(saved?.stampImageUrl ?? null);
|
setSignatureOpen(true);
|
||||||
setOpen(true);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const save = () => {
|
const saveSignature = () => {
|
||||||
if (!signatureData || !signerName.trim()) return;
|
if (!signatureData || !signerName.trim()) return;
|
||||||
saveMutation.mutate(
|
saveMutation.mutate(
|
||||||
{
|
{
|
||||||
signerDisplayName: signerName.trim(),
|
signerDisplayName: signerName.trim(),
|
||||||
signatureImageBase64: signatureData,
|
signatureImageBase64: signatureData,
|
||||||
// Only send the stamp when it changed — omitted keeps the saved one.
|
// Stamp untouched — it is managed by its own dialog.
|
||||||
...(stampData && stampData !== saved?.stampImageUrl
|
|
||||||
? { stampImageBase64: stampData }
|
|
||||||
: {}),
|
|
||||||
},
|
},
|
||||||
{ onSuccess: () => setOpen(false) },
|
{ onSuccess: () => setSignatureOpen(false) },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openStampDialog = () => {
|
||||||
|
setStampData(saved?.stampImageUrl ?? null);
|
||||||
|
setStampOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveStamp = () => {
|
||||||
|
if (!stampData) return;
|
||||||
|
saveMutation.mutate(
|
||||||
|
{
|
||||||
|
signerDisplayName: savedName || defaultName,
|
||||||
|
// Signature untouched — stamp-only update.
|
||||||
|
stampImageBase64: stampData,
|
||||||
|
},
|
||||||
|
{ onSuccess: () => setStampOpen(false) },
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -69,53 +84,70 @@ export function MySignatureCard() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<FileSignature className="size-5 text-primary" />
|
<FileSignature className="size-5 text-primary" />
|
||||||
My signature
|
Signature & Stamp
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Reused to approve and sign booking contracts.
|
Reused to approve and sign booking contracts.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-col gap-4">
|
<CardContent className="flex flex-col gap-6">
|
||||||
{isPending ? (
|
{isPending ? (
|
||||||
<div className="flex h-36 items-center justify-center">
|
<div className="flex h-36 items-center justify-center">
|
||||||
<Loader2 className="size-6 animate-spin text-primary" />
|
<Loader2 className="size-6 animate-spin text-primary" />
|
||||||
</div>
|
</div>
|
||||||
) : saved?.signatureImageUrl ? (
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<div className="overflow-hidden rounded-lg border-2 border-dashed border-border bg-white">
|
|
||||||
<img
|
|
||||||
src={saved.signatureImageUrl}
|
|
||||||
alt="My saved signature"
|
|
||||||
className="mx-auto h-36 w-full object-contain"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Saved as {saved.signerDisplayName}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-muted-foreground">
|
<>
|
||||||
You have not saved a signature yet.
|
<div className="flex flex-col gap-2">
|
||||||
</p>
|
{saved?.signatureImageUrl ? (
|
||||||
)}
|
<>
|
||||||
{saved?.stampImageUrl && (
|
<div className="overflow-hidden rounded-lg border-2 border-dashed border-border bg-white">
|
||||||
<div className="flex flex-col gap-2">
|
<img
|
||||||
<div className="overflow-hidden rounded-lg border-2 border-dashed border-border bg-white">
|
src={saved.signatureImageUrl}
|
||||||
<img
|
alt="My saved signature"
|
||||||
src={saved.stampImageUrl}
|
className="mx-auto h-36 w-full object-contain"
|
||||||
alt="My saved company stamp"
|
/>
|
||||||
className="mx-auto h-24 w-full object-contain"
|
</div>
|
||||||
/>
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Saved as {saved.signerDisplayName}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
You have not saved a signature yet.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Button variant="outline" size="sm" onClick={openSignatureDialog}>
|
||||||
|
{saved?.signatureImageUrl ? "Update signature" : "Add signature"}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">Company stamp</p>
|
|
||||||
</div>
|
<div className="flex flex-col gap-2">
|
||||||
|
{saved?.stampImageUrl ? (
|
||||||
|
<>
|
||||||
|
<div className="overflow-hidden rounded-lg border-2 border-dashed border-border bg-white">
|
||||||
|
<img
|
||||||
|
src={saved.stampImageUrl}
|
||||||
|
alt="My saved company stamp"
|
||||||
|
className="mx-auto h-24 w-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">Company stamp</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
You have not uploaded a company stamp yet.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Button variant="outline" size="sm" onClick={openStampDialog}>
|
||||||
|
<Stamp className="size-4" />
|
||||||
|
{saved?.stampImageUrl ? "Update stamp" : "Upload stamp"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<Button variant="outline" size="sm" onClick={openDialog}>
|
|
||||||
{saved?.signatureImageUrl ? "Update signature" : "Add signature"}
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={signatureOpen} onOpenChange={setSignatureOpen}>
|
||||||
<DialogContent className="sm:max-w-md">
|
<DialogContent className="sm:max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Save your signature</DialogTitle>
|
<DialogTitle>Save your signature</DialogTitle>
|
||||||
@@ -135,21 +167,16 @@ export function MySignatureCard() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<ContractSignaturePad onChange={setSignatureData} />
|
<ContractSignaturePad onChange={setSignatureData} />
|
||||||
<StampUpload
|
|
||||||
value={stampData}
|
|
||||||
onChange={setStampData}
|
|
||||||
description="Stored on your profile and prefilled when you sign contracts."
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
<Button variant="outline" onClick={() => setSignatureOpen(false)}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
disabled={
|
disabled={
|
||||||
saveMutation.isPending || !signatureData || !signerName.trim()
|
saveMutation.isPending || !signatureData || !signerName.trim()
|
||||||
}
|
}
|
||||||
onClick={save}
|
onClick={saveSignature}
|
||||||
>
|
>
|
||||||
{saveMutation.isPending ? (
|
{saveMutation.isPending ? (
|
||||||
<Loader2 className="size-4 animate-spin" />
|
<Loader2 className="size-4 animate-spin" />
|
||||||
@@ -160,6 +187,39 @@ export function MySignatureCard() {
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog open={stampOpen} onOpenChange={setStampOpen}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Company stamp</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Upload your official company stamp or seal as an image. It is
|
||||||
|
stored on your profile and applied next to your signature on
|
||||||
|
contracts.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<StampUpload
|
||||||
|
value={stampData}
|
||||||
|
onChange={setStampData}
|
||||||
|
description="Stored on your profile and prefilled when you sign contracts."
|
||||||
|
/>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setStampOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={saveMutation.isPending || !stampData}
|
||||||
|
onClick={saveStamp}
|
||||||
|
>
|
||||||
|
{saveMutation.isPending ? (
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
"Save stamp"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import type { AxiosError } from "axios";
|
import type { AxiosError } from "axios";
|
||||||
|
import { Freight } from "@edr/types";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import { invoicesService } from "@/services/invoices.service";
|
import { invoicesService } from "@/services/invoices.service";
|
||||||
@@ -34,9 +35,18 @@ function apiMessage(err: unknown, fallback: string): string {
|
|||||||
* charge through a different endpoint (warehouse fee invoices); OTP
|
* charge through a different endpoint (warehouse fee invoices); OTP
|
||||||
* confirmation always goes through billing, which owns the intent either way.
|
* confirmation always goes through billing, which owns the intent either way.
|
||||||
*/
|
*/
|
||||||
|
/** CBE bill payment: no redirect — the payer takes this reference to any CBE channel. */
|
||||||
|
interface BillAction {
|
||||||
|
invoiceId: string;
|
||||||
|
billReference: string;
|
||||||
|
instructions?: string;
|
||||||
|
expiresAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export function useInvoicePayment(initiate: InitiateFn = payViaBilling) {
|
export function useInvoicePayment(initiate: InitiateFn = payViaBilling) {
|
||||||
const [otpInvoiceId, setOtpInvoiceId] = useState<string | null>(null);
|
const [otpInvoiceId, setOtpInvoiceId] = useState<string | null>(null);
|
||||||
const [otpMessage, setOtpMessage] = useState<string | undefined>();
|
const [otpMessage, setOtpMessage] = useState<string | undefined>();
|
||||||
|
const [billAction, setBillAction] = useState<BillAction | null>(null);
|
||||||
|
|
||||||
const payMutation = useMutation({
|
const payMutation = useMutation({
|
||||||
mutationFn: (vars: {
|
mutationFn: (vars: {
|
||||||
@@ -52,6 +62,17 @@ export function useInvoicePayment(initiate: InitiateFn = payViaBilling) {
|
|||||||
setOtpInvoiceId(vars.invoiceId);
|
setOtpInvoiceId(vars.invoiceId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// CBE_BILL settles asynchronously via CBE, not the browser — show the
|
||||||
|
// bill reference instead of redirecting to a (nonexistent) checkout page.
|
||||||
|
if (data?.clientAction?.type === "SHOW_BILL_REFERENCE") {
|
||||||
|
setBillAction({
|
||||||
|
invoiceId: vars.invoiceId,
|
||||||
|
billReference: data.clientAction.billReference ?? "",
|
||||||
|
instructions: data.clientAction.instructions,
|
||||||
|
expiresAt: data.clientAction.expiresAt,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
window.location.href =
|
window.location.href =
|
||||||
data?.clientAction?.type === "REDIRECT" && data.clientAction.url
|
data?.clientAction?.type === "REDIRECT" && data.clientAction.url
|
||||||
? data.clientAction.url
|
? data.clientAction.url
|
||||||
@@ -72,10 +93,27 @@ export function useInvoicePayment(initiate: InitiateFn = payViaBilling) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Poll the invoice while the CBE bill dialog is open — CBE settles out of
|
||||||
|
// band (branch/app/USSD), so this is the only way the browser learns it paid.
|
||||||
|
useQuery({
|
||||||
|
queryKey: ["invoice-bill-poll", billAction?.invoiceId],
|
||||||
|
queryFn: async () => {
|
||||||
|
const invoice = await invoicesService.get(billAction!.invoiceId);
|
||||||
|
if (invoice.status === Freight.InvoiceStatus.Paid) {
|
||||||
|
setBillAction(null);
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
return invoice;
|
||||||
|
},
|
||||||
|
enabled: billAction !== null,
|
||||||
|
refetchInterval: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
const reset = () => {
|
const reset = () => {
|
||||||
payMutation.reset();
|
payMutation.reset();
|
||||||
otpMutation.reset();
|
otpMutation.reset();
|
||||||
setOtpInvoiceId(null);
|
setOtpInvoiceId(null);
|
||||||
|
setBillAction(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -109,6 +147,14 @@ export function useInvoicePayment(initiate: InitiateFn = payViaBilling) {
|
|||||||
setOtpInvoiceId(null);
|
setOtpInvoiceId(null);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
/** Drives the modal's "pay at CBE" step; `open` only for CBE_BILL. */
|
||||||
|
bill: {
|
||||||
|
open: billAction !== null,
|
||||||
|
billReference: billAction?.billReference,
|
||||||
|
instructions: billAction?.instructions,
|
||||||
|
expiresAt: billAction?.expiresAt,
|
||||||
|
close: () => setBillAction(null),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -55,9 +55,8 @@ export function ActionNeededSection({ items: base }: ActionNeededSectionProps) {
|
|||||||
invoicesService.listForSource("booking", payItem!.targetId),
|
invoicesService.listForSource("booking", payItem!.targetId),
|
||||||
enabled: payItem !== null,
|
enabled: payItem !== null,
|
||||||
});
|
});
|
||||||
const payableInvoiceId = payItemInvoices.find((inv) =>
|
const payableInvoice = payItemInvoices.find((inv) => isPayable(inv.status));
|
||||||
isPayable(inv.status),
|
const payableInvoiceId = payableInvoice?.id;
|
||||||
)?.id;
|
|
||||||
|
|
||||||
const pay = useInvoicePayment();
|
const pay = useInvoicePayment();
|
||||||
|
|
||||||
@@ -171,7 +170,7 @@ export function ActionNeededSection({ items: base }: ActionNeededSectionProps) {
|
|||||||
pay.reset();
|
pay.reset();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
currency={undefined}
|
currency={payableInvoice?.currency}
|
||||||
processing={pay.processing}
|
processing={pay.processing}
|
||||||
error={
|
error={
|
||||||
pay.error ??
|
pay.error ??
|
||||||
@@ -180,6 +179,7 @@ export function ActionNeededSection({ items: base }: ActionNeededSectionProps) {
|
|||||||
: null)
|
: null)
|
||||||
}
|
}
|
||||||
otp={pay.otp}
|
otp={pay.otp}
|
||||||
|
bill={pay.bill}
|
||||||
onConfirm={(method, payerAccount) =>
|
onConfirm={(method, payerAccount) =>
|
||||||
payableInvoiceId &&
|
payableInvoiceId &&
|
||||||
pay.pay(payableInvoiceId, method, payerAccount)
|
pay.pay(payableInvoiceId, method, payerAccount)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export default function MySignaturePage() {
|
|||||||
<div className="mx-auto flex max-w-md flex-col gap-6">
|
<div className="mx-auto flex max-w-md flex-col gap-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-black tracking-tight text-foreground">
|
<h1 className="text-2xl font-black tracking-tight text-foreground">
|
||||||
My signature
|
Signature & Stamp
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Saved and reused to approve and sign booking contracts.
|
Saved and reused to approve and sign booking contracts.
|
||||||
|
|||||||
@@ -197,6 +197,7 @@ export default function CompanyProfileForm({
|
|||||||
companyEmail: "",
|
companyEmail: "",
|
||||||
companyPhone: "",
|
companyPhone: "",
|
||||||
companyAddress: "",
|
companyAddress: "",
|
||||||
|
etradePhone: "",
|
||||||
tinNumber: "",
|
tinNumber: "",
|
||||||
vatNumber: "",
|
vatNumber: "",
|
||||||
ownerPassportNumber: "",
|
ownerPassportNumber: "",
|
||||||
@@ -279,6 +280,14 @@ export default function CompanyProfileForm({
|
|||||||
// setting region/zone/woreda/kebele/houseNo above is enough — no need to
|
// setting region/zone/woreda/kebele/houseNo above is enough — no need to
|
||||||
// compose it here. companyPhone is derived below (identity → eTrade →
|
// compose it here. companyPhone is derived below (identity → eTrade →
|
||||||
// account), not set directly here.
|
// account), not set directly here.
|
||||||
|
// etradePhone is the raw number eTrade returned for this TIN — kept as its
|
||||||
|
// own field (distinct from companyPhone, which prefers the Fayda-verified
|
||||||
|
// owner's phone) so the backend's "matches eTrade's current record" check
|
||||||
|
// always compares against what eTrade actually said, not the owner's phone.
|
||||||
|
setValue(
|
||||||
|
"etradePhone",
|
||||||
|
data.managerPhone || data.regularPhone || data.mobilePhone,
|
||||||
|
);
|
||||||
|
|
||||||
setEtradeOwner({
|
setEtradeOwner({
|
||||||
name: data.managerName,
|
name: data.managerName,
|
||||||
@@ -288,6 +297,25 @@ export default function CompanyProfileForm({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// TIN changed since the last successful lookup — the registration/address
|
||||||
|
// fields it filled in describe the OLD TIN, not this one, so clear them
|
||||||
|
// rather than leaving them stale on screen.
|
||||||
|
const handleETradeReset = () => {
|
||||||
|
setValue("licenceNumber", "");
|
||||||
|
setValue("statusDescription", "");
|
||||||
|
setValue("dateRegistered", "");
|
||||||
|
setValue("renewedFrom", "");
|
||||||
|
setValue("renewalDate", "");
|
||||||
|
setValue("renewedTo", "");
|
||||||
|
setValue("region", "");
|
||||||
|
setValue("zone", "");
|
||||||
|
setValue("woreda", "");
|
||||||
|
setValue("kebele", "");
|
||||||
|
setValue("houseNo", "");
|
||||||
|
setValue("etradePhone", "");
|
||||||
|
setEtradeOwner(null);
|
||||||
|
};
|
||||||
|
|
||||||
// companyEmail/companyPhone are no longer typed — the Fayda-verified owner
|
// companyEmail/companyPhone are no longer typed — the Fayda-verified owner
|
||||||
// is the highest-trust source (that's the whole point of verifying), eTrade's
|
// 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
|
// registered number and the account email/phone are the fallbacks used
|
||||||
@@ -297,7 +325,7 @@ export default function CompanyProfileForm({
|
|||||||
shouldValidate: true,
|
shouldValidate: true,
|
||||||
});
|
});
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [identity?.owner.email, user.email]);
|
}, [identity?.owner.email, user.email, rehydrate]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setValue(
|
setValue(
|
||||||
@@ -309,7 +337,7 @@ export default function CompanyProfileForm({
|
|||||||
{ shouldValidate: true },
|
{ shouldValidate: true },
|
||||||
);
|
);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [identity?.owner.phone, etradeOwner?.phone, user.phoneNumber]);
|
}, [identity?.owner.phone, etradeOwner?.phone, user.phoneNumber, rehydrate]);
|
||||||
|
|
||||||
// "Same as …" links. A checked card prefills the target step's fields from the
|
// "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
|
// source step and disables them (kept mirrored while linked); unchecking clears
|
||||||
@@ -649,7 +677,7 @@ export default function CompanyProfileForm({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<TextInput
|
<TextInput
|
||||||
label="VAT Number"
|
aria-label="VAT Number"
|
||||||
placeholder="0012345678"
|
placeholder="0012345678"
|
||||||
maxLength={10}
|
maxLength={10}
|
||||||
error={errors.vatNumber?.message}
|
error={errors.vatNumber?.message}
|
||||||
@@ -661,9 +689,9 @@ export default function CompanyProfileForm({
|
|||||||
index={2}
|
index={2}
|
||||||
title="Owner identity"
|
title="Owner identity"
|
||||||
subtitle={
|
subtitle={
|
||||||
verifiedIdentity
|
!identity?.owner.verified && !verifiedIdentity
|
||||||
? "Verify the company owner with Fayda — their name, phone, email and address come from the verification."
|
? "Provide the company owner's passport number."
|
||||||
: "Provide the company owner's passport number."
|
: undefined
|
||||||
}
|
}
|
||||||
status={
|
status={
|
||||||
verifiedIdentity
|
verifiedIdentity
|
||||||
@@ -683,7 +711,7 @@ export default function CompanyProfileForm({
|
|||||||
<>
|
<>
|
||||||
<FaydaVerifyPanel
|
<FaydaVerifyPanel
|
||||||
subject="owner"
|
subject="owner"
|
||||||
title="Company owner"
|
title="Owner"
|
||||||
state={identity.owner}
|
state={identity.owner}
|
||||||
required={identity.faydaRequired}
|
required={identity.faydaRequired}
|
||||||
onVerified={() => onIdentityChange?.()}
|
onVerified={() => onIdentityChange?.()}
|
||||||
@@ -718,6 +746,7 @@ export default function CompanyProfileForm({
|
|||||||
error={errors.tinNumber?.message}
|
error={errors.tinNumber?.message}
|
||||||
onDataLoaded={handleETradeDataLoaded}
|
onDataLoaded={handleETradeDataLoaded}
|
||||||
onStatusChange={setTinStatus}
|
onStatusChange={setTinStatus}
|
||||||
|
onReset={handleETradeReset}
|
||||||
/>
|
/>
|
||||||
{tinVerified && (
|
{tinVerified && (
|
||||||
<ETradeCompanyCard
|
<ETradeCompanyCard
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ import AuthShell from "@/components/auth/AuthShell";
|
|||||||
import OtpChannelStep, { OTP_LENGTH } from "@/components/auth/OtpChannelStep";
|
import OtpChannelStep, { OTP_LENGTH } from "@/components/auth/OtpChannelStep";
|
||||||
import PasswordChecklist from "@/components/auth/PasswordChecklist";
|
import PasswordChecklist from "@/components/auth/PasswordChecklist";
|
||||||
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
|
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
|
||||||
|
import { StampUpload } from "@/components/contracts/StampUpload";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
|
import { signaturesService } from "@/services/signatures.service";
|
||||||
import {
|
import {
|
||||||
confirmPasswordField,
|
confirmPasswordField,
|
||||||
passwordField,
|
passwordField,
|
||||||
@@ -59,6 +61,10 @@ export default function SignupPage() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { signup } = useAuth();
|
const { signup } = useAuth();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
// Company stamp image, captured at signup and stored on the new profile so
|
||||||
|
// it is ready when the customer signs their first contract. Optional —
|
||||||
|
// individuals without a stamp can add one later from Settings.
|
||||||
|
const [stampData, setStampData] = useState<string | null>(null);
|
||||||
|
|
||||||
// Two-stage signup: fill the form, then a mandatory OTP challenge before the
|
// Two-stage signup: fill the form, then a mandatory OTP challenge before the
|
||||||
// account is actually created. The code goes to BOTH the email and phone just
|
// account is actually created. The code goes to BOTH the email and phone just
|
||||||
@@ -179,6 +185,18 @@ export default function SignupPage() {
|
|||||||
};
|
};
|
||||||
const result = await signup(payload);
|
const result = await signup(payload);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
// Signup logs the user in, so the stamp can land on their profile
|
||||||
|
// right away. Non-fatal — it can also be added later from Settings.
|
||||||
|
if (stampData) {
|
||||||
|
try {
|
||||||
|
await signaturesService.saveMySignature({
|
||||||
|
signerDisplayName: payload.name.en,
|
||||||
|
stampImageBase64: stampData,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Ignore — account exists; the stamp can be re-uploaded later.
|
||||||
|
}
|
||||||
|
}
|
||||||
navigate("/portal");
|
navigate("/portal");
|
||||||
} else {
|
} else {
|
||||||
setOtpError(result.error.message);
|
setOtpError(result.error.message);
|
||||||
@@ -269,6 +287,13 @@ export default function SignupPage() {
|
|||||||
{...register("confirmPassword")}
|
{...register("confirmPassword")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<StampUpload
|
||||||
|
value={stampData}
|
||||||
|
onChange={setStampData}
|
||||||
|
label="Company stamp (optional)"
|
||||||
|
description="Stored on your profile and applied next to your signature when you sign contracts."
|
||||||
|
/>
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
<Alert
|
<Alert
|
||||||
color="red"
|
color="red"
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export function ReadOnlyField({
|
|||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
{label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="sm" c="edr-text" fw={500}>
|
<Text className="wrap-break-word" size="sm" c="edr-text" fw={500}>
|
||||||
{value && value.trim() ? value : "—"}
|
{value && value.trim() ? value : "—"}
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Badge, Group, Stack, Text } from "@mantine/core";
|
import { Badge, Group, Stack, Text } from "@mantine/core";
|
||||||
|
import { useMediaQuery } from "@mantine/hooks";
|
||||||
import { Check, X } from "lucide-react";
|
import { Check, X } from "lucide-react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
@@ -32,6 +33,7 @@ export default function StepSection({
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const badge = STATUS_BADGE[status];
|
const badge = STATUS_BADGE[status];
|
||||||
|
const isMobile = useMediaQuery("(max-width: 48em)");
|
||||||
return (
|
return (
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<Group justify="space-between" align="center">
|
<Group justify="space-between" align="center">
|
||||||
@@ -75,7 +77,7 @@ export default function StepSection({
|
|||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
<div style={{ paddingLeft: 34 }}>
|
<div style={{ paddingLeft: isMobile ? 0 : 34 }}>
|
||||||
<Stack gap="sm">{children}</Stack>
|
<Stack gap="sm">{children}</Stack>
|
||||||
</div>
|
</div>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export function stepPayload(
|
|||||||
woreda: d.woreda,
|
woreda: d.woreda,
|
||||||
kebele: d.kebele,
|
kebele: d.kebele,
|
||||||
houseNo: d.houseNo,
|
houseNo: d.houseNo,
|
||||||
etradePhone: d.companyPhone,
|
etradePhone: d.etradePhone,
|
||||||
};
|
};
|
||||||
case "personnel":
|
case "personnel":
|
||||||
return {
|
return {
|
||||||
@@ -101,6 +101,7 @@ export function toFormValues(p: ProfileResponse): FormData {
|
|||||||
companyEmail: p.companyEmail ?? "",
|
companyEmail: p.companyEmail ?? "",
|
||||||
companyPhone: p.companyPhone ?? "",
|
companyPhone: p.companyPhone ?? "",
|
||||||
companyAddress: p.companyAddress ?? "",
|
companyAddress: p.companyAddress ?? "",
|
||||||
|
etradePhone: p.etradePhone ?? "",
|
||||||
tinNumber: tin,
|
tinNumber: tin,
|
||||||
vatNumber: p.vatNumber ?? "",
|
vatNumber: p.vatNumber ?? "",
|
||||||
ownerPassportNumber: p.identity?.owner.passportNumber ?? "",
|
ownerPassportNumber: p.identity?.owner.passportNumber ?? "",
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ export const onboardingSchema = z.object({
|
|||||||
// Derived from the eTrade address parts (kebele/woreda/zone/region); no
|
// Derived from the eTrade address parts (kebele/woreda/zone/region); no
|
||||||
// standalone input — the granular fields live in the registration section.
|
// standalone input — the granular fields live in the registration section.
|
||||||
companyAddress: z.string().optional(),
|
companyAddress: z.string().optional(),
|
||||||
|
// Raw phone from the eTrade lookup itself — kept separate from companyPhone
|
||||||
|
// (which shows the Fayda-verified owner's phone once verified) so the two
|
||||||
|
// can diverge without the backend's eTrade-authenticity check misfiring.
|
||||||
|
etradePhone: z.string().optional(),
|
||||||
tinNumber: z.string().regex(/^\d{10}$/, "TIN must be exactly 10 digits"),
|
tinNumber: z.string().regex(/^\d{10}$/, "TIN must be exactly 10 digits"),
|
||||||
vatNumber: z
|
vatNumber: z
|
||||||
.string()
|
.string()
|
||||||
@@ -124,6 +128,7 @@ export const stepFields: Record<CompanyStep, (keyof FormData)[]> = {
|
|||||||
"companyEmail",
|
"companyEmail",
|
||||||
"companyPhone",
|
"companyPhone",
|
||||||
"companyAddress",
|
"companyAddress",
|
||||||
|
"etradePhone",
|
||||||
"tinNumber",
|
"tinNumber",
|
||||||
"vatNumber",
|
"vatNumber",
|
||||||
"ownerPassportNumber",
|
"ownerPassportNumber",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user