diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index f3ba897a0..e10e5dcb7 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -69,6 +69,7 @@ "cross-env": "^10.1.0", "dotenv": "^17.4.2", "dotenv-cli": "^11.0.0", + "exceljs": "^4.4.0", "handlebars": "^4.7.9", "jose": "^5.10.0", "libphonenumber-js": "^1.13.6", diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 1efe750fc..d38c6ea92 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -40,6 +40,8 @@ import { TrainSchedulesModule } from "./modules/train-schedules/train-schedules. import { TrainSchedulingModule } from "./modules/train-scheduling/train-scheduling.module"; import { SchedulingRescheduleModule } from "./modules/scheduling-reschedule/scheduling-reschedule.module"; import { CompaniesModule } from "./modules/companies/companies.module"; +import { ShippingLineBookingCompletionModule } from "./modules/shipping-lines/shipping-line-booking-completion.module"; +import { ShippingLineCompaniesModule } from "./modules/shipping-lines/shipping-line-companies.module"; import { TrackingModule } from "./modules/tracking/tracking.module"; import { BillingModule } from "./modules/billing/billing.module"; import { NotificationsModule } from "./modules/notifications/notifications.module"; @@ -49,6 +51,7 @@ import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-up import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module"; import { ExchangeSettingsModule } from "./modules/exchange-settings/exchange-settings.module"; import { StampSettingsModule } from "./modules/stamp-settings/stamp-settings.module"; +import { LogoSettingsModule } from "./modules/logo-settings/logo-settings.module"; import { ContractTemplatesModule } from "./modules/contract-templates/contract-templates.module"; import { SupportContentModule } from "./modules/support-content/support-content.module"; import { OtpModule } from "./modules/otp/otp.module"; @@ -200,6 +203,8 @@ if (!process.env.APPLICATION_NAME) { TrainSchedulingModule, SchedulingRescheduleModule, CompaniesModule, + ShippingLineCompaniesModule, + ShippingLineBookingCompletionModule, TrackingModule, BillingModule, NotificationsModule, @@ -209,6 +214,7 @@ if (!process.env.APPLICATION_NAME) { DropdownSettingsModule, ExchangeSettingsModule, StampSettingsModule, + LogoSettingsModule, ContractTemplatesModule, SupportContentModule, OtpModule, diff --git a/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts b/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts index 94a6809e6..517934bb9 100644 --- a/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts +++ b/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts @@ -10,6 +10,7 @@ import { ContractPricingScheduleBuilder, PricingSchedule } from './contract-pric import { ContractRateScheduleBuilder, RateSchedule } from './contract-rate-schedule.builder'; import { ContractTemplateResolver } from './contract-template.resolver'; import { StampSettingsService } from '../modules/stamp-settings/stamp-settings.service'; +import { LogoSettingsService } from '../modules/logo-settings/logo-settings.service'; import { ContractTemplateMeta, getTemplateMeta } from './contract-template.registry'; export interface ContractSignatureView { @@ -111,6 +112,8 @@ export interface ContractViewModel { hasCustomerSignature: boolean; hasStaffSignature: boolean; dynamicTemplate?: ContractDynamicTemplateView; + /** Company logo for the cover-page header (LogoSettingsService); null renders the "EDR" mark. */ + logoImageUrl?: string | null; } @Injectable() @@ -121,6 +124,7 @@ export class ContractViewModelBuilder { private readonly pricingBuilder: ContractPricingScheduleBuilder, private readonly rateScheduleBuilder: ContractRateScheduleBuilder, private readonly stampSettings: StampSettingsService, + private readonly logoSettings: LogoSettingsService, ) {} async build(bookingId: string): Promise<{ booking: Booking; view: ContractViewModel }> { @@ -138,6 +142,7 @@ export class ContractViewModelBuilder { template.freight, ); const signatures = await this.loadSignatures(bookingId); + const logoImageUrl = await this.logoSettings.getLogoImageUrl(); const hasCustomer = signatures.some((s) => s.role === 'CUSTOMER'); const hasStaff = signatures.some((s) => s.role === 'STAFF'); @@ -194,6 +199,7 @@ export class ContractViewModelBuilder { hasContractDocument: hasContractFile, hasCustomerSignature: hasCustomer, hasStaffSignature: hasStaff, + logoImageUrl, }; return { booking, view }; diff --git a/apps/edr-freight-api/src/contracts/templates/_partials/styles.hbs b/apps/edr-freight-api/src/contracts/templates/_partials/styles.hbs index d9bc9927f..09bf3031e 100644 --- a/apps/edr-freight-api/src/contracts/templates/_partials/styles.hbs +++ b/apps/edr-freight-api/src/contracts/templates/_partials/styles.hbs @@ -77,6 +77,12 @@ letter-spacing: 0.08em; width: 72px; } + .logo-mark img { + display: block; + max-height: 100%; + max-width: 100%; + object-fit: contain; + } .kicker { color: #0e5b45; font-family: Arial, sans-serif; diff --git a/apps/edr-freight-api/src/contracts/templates/edr-dynamic.hbs b/apps/edr-freight-api/src/contracts/templates/edr-dynamic.hbs index 6ba7c1610..81631e5d2 100644 --- a/apps/edr-freight-api/src/contracts/templates/edr-dynamic.hbs +++ b/apps/edr-freight-api/src/contracts/templates/edr-dynamic.hbs @@ -11,7 +11,7 @@ {{!-- ─────────────────────────── Cover page ─────────────────────────── --}}
-
EDR
+
{{#if logoImageUrl}}Company logo{{else}}EDR{{/if}}

Ethio-Djibouti Standard Gauge Railway Share Company

Freight Transport Services

diff --git a/apps/edr-freight-api/src/contracts/templates/generic.hbs b/apps/edr-freight-api/src/contracts/templates/generic.hbs index 75f795bce..f576c99ce 100644 --- a/apps/edr-freight-api/src/contracts/templates/generic.hbs +++ b/apps/edr-freight-api/src/contracts/templates/generic.hbs @@ -9,7 +9,7 @@
-
EDR
+
{{#if logoImageUrl}}Company logo{{else}}EDR{{/if}}

Ethio-Djibouti Standard Gauge Railway Share Company

Freight Transport Contract

diff --git a/apps/edr-freight-api/src/contracts/templates/last-mile.hbs b/apps/edr-freight-api/src/contracts/templates/last-mile.hbs index 9437bb18b..0cfef51d5 100644 --- a/apps/edr-freight-api/src/contracts/templates/last-mile.hbs +++ b/apps/edr-freight-api/src/contracts/templates/last-mile.hbs @@ -9,6 +9,7 @@ main { padding: 32px 40px; } .brand-row { display: flex; align-items: center; gap: 14px; border-bottom: 3px solid #1a5632; padding-bottom: 14px; } .logo-mark { background: #1a5632; color: #fff; font-weight: 700; font-size: 18px; padding: 10px 14px; border-radius: 6px; } + .logo-mark img { display: block; max-height: 32px; max-width: 100px; object-fit: contain; } .kicker { margin: 0; font-weight: 700; } .muted { margin: 0; color: #666; } h1 { font-size: 20px; margin: 24px 0 4px; } @@ -32,7 +33,7 @@
-
EDR
+
{{#if logoImageUrl}}Company logo{{else}}EDR{{/if}}

Ethio-Djibouti Standard Gauge Railway Share Company

Last-Mile Delivery Contract

diff --git a/apps/edr-freight-api/src/migrations/3440000000000-ShippingLineCompany.ts b/apps/edr-freight-api/src/migrations/3440000000000-ShippingLineCompany.ts new file mode 100644 index 000000000..91e78ab14 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3440000000000-ShippingLineCompany.ts @@ -0,0 +1,78 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Shipping lines — carriers registered by backoffice staff who sign in to the + * portal directly. + * + * Separate from `freight.companies` on purpose: a shipping line has no TIN, + * business licence, eTrade record, operational profile or onboarding state, so + * it shares none of the customer columns. `user_id` sits on the company row + * itself because the company IS the account — there is no contact-person row. + * + * No FK on `user_id`: `iam.users` belongs to the IAM service's schema, which + * this API reads but never owns. + */ +export class ShippingLineCompany3440000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DO $$ BEGIN + CREATE TYPE freight.shipping_line_companies_status_enum + AS ENUM ('active', 'suspended'); + EXCEPTION WHEN duplicate_object THEN NULL; + END $$ + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.shipping_line_companies ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL, + name varchar(200) NOT NULL, + scac_code varchar(4), + imo_number varchar(20), + bic_code varchar(20), + email varchar(150) NOT NULL, + phone_number varchar(30), + status freight.shipping_line_companies_status_enum + NOT NULL DEFAULT 'active', + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ) + `); + + // One login per shipping line. Partial so a soft-deleted row frees its + // account for re-registration rather than blocking it forever. + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_shipping_line_companies_user" + ON freight.shipping_line_companies (user_id) + WHERE deleted_at IS NULL + `); + + // SCAC identifies the carrier globally — two live lines cannot share one. + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_shipping_line_companies_scac" + ON freight.shipping_line_companies (scac_code) + WHERE scac_code IS NOT NULL AND deleted_at IS NULL + `); + + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_shipping_line_companies_email" + ON freight.shipping_line_companies (lower(email)) + WHERE deleted_at IS NULL + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_shipping_line_companies_status" + ON freight.shipping_line_companies (status) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP TABLE IF EXISTS freight.shipping_line_companies`, + ); + await queryRunner.query( + `DROP TYPE IF EXISTS freight.shipping_line_companies_status_enum`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/3450000000000-BookingShippingLine.ts b/apps/edr-freight-api/src/migrations/3450000000000-BookingShippingLine.ts new file mode 100644 index 000000000..d53914ef3 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3450000000000-BookingShippingLine.ts @@ -0,0 +1,117 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Shipping lines book rail capacity directly, without a contract. + * + * A booking has always been owned by `company_id` (a customer `companies` row), + * but a shipping line is a `shipping_line_companies` row and deliberately NOT a + * company — it carries no TIN, licence or operational profiles. So it gets its + * own nullable owner column rather than a synthetic company row. + * + * Exactly one of the two is set: `company_id` for a customer booking, + * `shipping_line_company_id` for a shipping-line one. Existing rows keep + * `company_id` and a NULL `shipping_line_company_id`, so nothing needs + * backfilling and every customer query filtering on `company_id` behaves + * exactly as before. Government bookings already bill to a seeded government + * company, so they satisfy the CHECK unchanged. + * + * NOTE: not to be confused with the existing `bookings.shipping_line_id`, which + * is cargo metadata naming the carrier line that moves the goods + * (`freight.shipping_lines`, reference data). This column points at + * `freight.shipping_line_companies` — the portal account — and is unrelated. + */ +export class BookingShippingLine3450000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings + ADD COLUMN IF NOT EXISTS shipping_line_company_id uuid + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_bookings_shipping_line_company_id + ON freight.bookings (shipping_line_company_id) + `); + + // `company_id` / `company_profile_id` are NOT NULL and point at the customer + // tables, so a shipping-line booking could not be inserted at all. Relax + // them to nullable; their foreign keys are left in place and keep validating + // every non-NULL value, so a customer booking is constrained exactly as + // before. The CHECK below is what now guarantees an owner is present. + await queryRunner.query(` + ALTER TABLE freight.bookings ALTER COLUMN company_id DROP NOT NULL + `); + await queryRunner.query(` + ALTER TABLE freight.bookings ALTER COLUMN company_profile_id DROP NOT NULL + `); + + // Route and service are inherited from the contract on a customer booking. + // A shipping line initiates before any of that is known — the bare booking + // exists only to hang documents off — so these are relaxed too and filled + // in when the booking is completed. Existing rows all have values, and the + // customer paths still always set them. + await queryRunner.query(` + ALTER TABLE freight.bookings ALTER COLUMN origin_yard_id DROP NOT NULL + `); + await queryRunner.query(` + ALTER TABLE freight.bookings ALTER COLUMN destination_yard_id DROP NOT NULL + `); + await queryRunner.query(` + ALTER TABLE freight.bookings ALTER COLUMN service_type_id DROP NOT NULL + `); + await queryRunner.query(` + ALTER TABLE freight.bookings ALTER COLUMN freight_type DROP NOT NULL + `); + + // No FK: kept consistent with how the column is populated at the service + // layer, and avoids a lock on shipping_line_companies during deploy. + await queryRunner.query(` + ALTER TABLE freight.bookings + DROP CONSTRAINT IF EXISTS chk_bookings_single_owner + `); + await queryRunner.query(` + ALTER TABLE freight.bookings + ADD CONSTRAINT chk_bookings_single_owner + CHECK ( + (company_id IS NOT NULL AND shipping_line_company_id IS NULL) + OR (company_id IS NULL AND shipping_line_company_id IS NOT NULL) + ) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings + DROP CONSTRAINT IF EXISTS chk_bookings_single_owner + `); + + // Only reinstate NOT NULL if no shipping-line booking exists; those rows + // have a NULL company_id by design and would make the ALTER fail. Leaving + // the columns nullable is the safe outcome — the constraint is additive. + const [{ count }] = (await queryRunner.query(` + SELECT COUNT(*)::int AS count FROM freight.bookings + WHERE shipping_line_company_id IS NOT NULL + `)) as Array<{ count: number }>; + + if (count === 0) { + for (const column of [ + "company_id", + "company_profile_id", + "origin_yard_id", + "destination_yard_id", + "service_type_id", + "freight_type", + ]) { + await queryRunner.query(` + ALTER TABLE freight.bookings ALTER COLUMN ${column} SET NOT NULL + `); + } + } + + await queryRunner.query(` + DROP INDEX IF EXISTS freight.idx_bookings_shipping_line_company_id + `); + await queryRunner.query(` + ALTER TABLE freight.bookings DROP COLUMN IF EXISTS shipping_line_company_id + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3460000000000-ShippingLineCredits.ts b/apps/edr-freight-api/src/migrations/3460000000000-ShippingLineCredits.ts new file mode 100644 index 000000000..5a59d9d80 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3460000000000-ShippingLineCredits.ts @@ -0,0 +1,201 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Shipping lines consume services before paying for them. + * + * A shipping line books rail capacity and the booking proceeds with no payment + * gate at all — unlike a customer booking, which cannot advance until its + * PREPAID invoice settles. What the line owes is instead recorded here as a + * credit: one row per booking, priced once and never recalculated. Finance + * later selects a batch of unbilled credits, generates a single invoice for + * them, and the line pays that invoice through the normal CBE flow. When the + * invoice settles, its credits are marked paid and stop counting as debt. + * + * This is deliberately NOT a wallet or a stored balance. There is no money in + * the system to draw down: a credit is a debt the line already incurred, so + * the outstanding figure is always derived (`SUM(amount) WHERE status <> + * 'PAID'`) rather than kept in a column that UPDATEs can drift out of sync. + * + * `invoices.company_id` / `company_profile_id` are relaxed to nullable for the + * same reason `bookings` was in {@link BookingShippingLine3450000000000}: a + * shipping line is not a `companies` row and never will be, so an invoice + * billed to one has no customer to point at. Both FKs stay in place and keep + * validating every non-NULL value, so a customer invoice is constrained + * exactly as before; the CHECK below is what now guarantees a payer exists. + */ +export class ShippingLineCredits3460000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // ── Invoices: allow a shipping-line payer ──────────────────────────────── + await queryRunner.query(` + ALTER TABLE freight.invoices + ADD COLUMN IF NOT EXISTS shipping_line_company_id uuid + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_invoices_shipping_line_company_id + ON freight.invoices (shipping_line_company_id) + `); + + await queryRunner.query(` + ALTER TABLE freight.invoices ALTER COLUMN company_id DROP NOT NULL + `); + await queryRunner.query(` + ALTER TABLE freight.invoices ALTER COLUMN company_profile_id DROP NOT NULL + `); + + // Exactly one payer. Mirrors chk_bookings_single_owner so the two tables + // answer "who owes this?" the same way. Existing rows all have company_id + // and a NULL shipping_line_company_id, so nothing needs backfilling. + await queryRunner.query(` + ALTER TABLE freight.invoices + DROP CONSTRAINT IF EXISTS chk_invoices_single_payer + `); + await queryRunner.query(` + ALTER TABLE freight.invoices + ADD CONSTRAINT chk_invoices_single_payer + CHECK ( + (company_id IS NOT NULL AND shipping_line_company_id IS NULL) + OR (company_id IS NULL AND shipping_line_company_id IS NOT NULL) + ) + `); + + // ── The credit ledger ──────────────────────────────────────────────────── + await queryRunner.query(` + DO $$ BEGIN + CREATE TYPE freight.shipping_line_credits_status_enum AS ENUM ( + 'UNBILLED', 'BILLED', 'PAID', 'CANCELLED' + ); + EXCEPTION WHEN duplicate_object THEN NULL; END $$ + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.shipping_line_credits ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + shipping_line_company_id uuid NOT NULL, + booking_id uuid NOT NULL, + amount numeric(14,2) NOT NULL, + currency character varying(8) DEFAULT 'ETB'::character varying NOT NULL, + status freight.shipping_line_credits_status_enum + DEFAULT 'UNBILLED'::freight.shipping_line_credits_status_enum NOT NULL, + description character varying(255), + invoice_id uuid, + billed_at timestamp with time zone, + paid_at timestamp with time zone, + cancelled_at timestamp with time zone, + cancellation_reason character varying(255), + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + deleted_at timestamp with time zone, + CONSTRAINT pk_shipping_line_credits PRIMARY KEY (id), + CONSTRAINT chk_shipping_line_credits_amount CHECK (amount >= 0), + -- The state machine, enforced in the DB rather than trusted to the + -- service: an UNBILLED credit has no invoice, and anything past + -- UNBILLED must name the invoice it was billed on. Without this a + -- half-applied batch could leave BILLED rows with a NULL invoice_id + -- and silently vanish from both the unbilled list and the invoice. + CONSTRAINT chk_shipping_line_credits_invoice_link CHECK ( + (status = 'UNBILLED' AND invoice_id IS NULL) + OR (status IN ('BILLED', 'PAID') AND invoice_id IS NOT NULL) + OR status = 'CANCELLED' + ) + ) + `); + + await queryRunner.query(` + ALTER TABLE freight.shipping_line_credits + DROP CONSTRAINT IF EXISTS fk_shipping_line_credits_shipping_line + `); + await queryRunner.query(` + ALTER TABLE freight.shipping_line_credits + ADD CONSTRAINT fk_shipping_line_credits_shipping_line + FOREIGN KEY (shipping_line_company_id) + REFERENCES freight.shipping_line_companies(id) ON DELETE RESTRICT + `); + + await queryRunner.query(` + ALTER TABLE freight.shipping_line_credits + DROP CONSTRAINT IF EXISTS fk_shipping_line_credits_booking + `); + await queryRunner.query(` + ALTER TABLE freight.shipping_line_credits + ADD CONSTRAINT fk_shipping_line_credits_booking + FOREIGN KEY (booking_id) + REFERENCES freight.bookings(id) ON DELETE RESTRICT + `); + + // SET NULL rather than CASCADE: deleting an invoice must never delete the + // record of what was owed. The row would then violate the link CHECK, so a + // credit whose invoice is removed has to be walked back to UNBILLED + // explicitly — which is the correct, visible outcome. + await queryRunner.query(` + ALTER TABLE freight.shipping_line_credits + DROP CONSTRAINT IF EXISTS fk_shipping_line_credits_invoice + `); + await queryRunner.query(` + ALTER TABLE freight.shipping_line_credits + ADD CONSTRAINT fk_shipping_line_credits_invoice + FOREIGN KEY (invoice_id) + REFERENCES freight.invoices(id) ON DELETE SET NULL + `); + + // One live credit per booking. Partial so a soft-deleted or cancelled row + // does not block re-pricing a booking that was voided and rebooked. + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS uq_shipping_line_credits_booking + ON freight.shipping_line_credits (booking_id) + WHERE deleted_at IS NULL AND status <> 'CANCELLED' + `); + + // Drives the two hot reads: finance's unbilled worklist per line, and the + // outstanding total on the shipping-line detail page. + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_shipping_line_credits_line_status + ON freight.shipping_line_credits (shipping_line_company_id, status) + WHERE deleted_at IS NULL + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_shipping_line_credits_invoice_id + ON freight.shipping_line_credits (invoice_id) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DROP TABLE IF EXISTS freight.shipping_line_credits + `); + await queryRunner.query(` + DROP TYPE IF EXISTS freight.shipping_line_credits_status_enum + `); + + await queryRunner.query(` + ALTER TABLE freight.invoices + DROP CONSTRAINT IF EXISTS chk_invoices_single_payer + `); + + // Only reinstate NOT NULL if no shipping-line invoice exists; those rows + // have a NULL company_id by design and would make the ALTER fail. Leaving + // the columns nullable is the safe outcome — the constraint is additive. + const [{ count }] = (await queryRunner.query(` + SELECT COUNT(*)::int AS count FROM freight.invoices + WHERE shipping_line_company_id IS NOT NULL + `)) as Array<{ count: number }>; + + if (count === 0) { + await queryRunner.query(` + ALTER TABLE freight.invoices ALTER COLUMN company_id SET NOT NULL + `); + await queryRunner.query(` + ALTER TABLE freight.invoices ALTER COLUMN company_profile_id SET NOT NULL + `); + } + + await queryRunner.query(` + DROP INDEX IF EXISTS freight.idx_invoices_shipping_line_company_id + `); + await queryRunner.query(` + ALTER TABLE freight.invoices + DROP COLUMN IF EXISTS shipping_line_company_id + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3470000000000-ShippingLineRates.ts b/apps/edr-freight-api/src/migrations/3470000000000-ShippingLineRates.ts new file mode 100644 index 000000000..05e0bb799 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3470000000000-ShippingLineRates.ts @@ -0,0 +1,127 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Per-shipping-line rates. + * + * A shipping line books rail capacity directly (see BookingShippingLine3450000000000) + * and negotiates its own prices, so the rate table gains an owner column: + * `shipping_line_company_id` NULL = the standard rate every customer pays, + * NOT NULL = a rate that only that line's bookings resolve. + * + * Points at `freight.shipping_line_companies` (the portal account that owns the + * booking), NOT `freight.shipping_lines` — the latter is carrier reference data + * naming who physically moves the goods, and the existing SHIPPING_LINE trigger + * already keys off it. Both stay independent. + * + * Line rates OVERRIDE rather than stack: a booking owned by a line prices off + * that line's rate for the lane, and is hard-blocked when none exists (the + * standard rate is deliberately not a fallback — see RuleEngineService). + * + * Every existing row keeps a NULL owner, so nothing needs backfilling and the + * standard-rate lookups behave exactly as before. + */ +export class ShippingLineRates3470000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.rates + ADD COLUMN IF NOT EXISTS shipping_line_company_id uuid + `); + + await queryRunner.query(` + ALTER TABLE freight.rates + DROP CONSTRAINT IF EXISTS "FK_rates_shipping_line_company" + `); + await queryRunner.query(` + ALTER TABLE freight.rates + ADD CONSTRAINT "FK_rates_shipping_line_company" + FOREIGN KEY (shipping_line_company_id) + REFERENCES freight.shipping_line_companies (id) + ON DELETE RESTRICT + `); + + // Rate resolution always filters by owner, so the lookups this column + // participates in are (owner, lane) — indexed together with rate_type, + // which every lookup also pins. + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_rates_shipping_line_company_id + ON freight.rates (shipping_line_company_id) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_rates_shipping_line_lane + ON freight.rates (shipping_line_company_id, rate_type, origin_yard_id, destination_yard_id) + WHERE shipping_line_company_id IS NOT NULL + `); + + // A shipping line sells import freight only — the export leg is contracted + // through the customer, not the carrier. Enforced here so a line rate can + // never be filed against an export lane regardless of which API path wrote + // it. Surcharges carry no direction and are unaffected. + await queryRunner.query(` + ALTER TABLE freight.rates + DROP CONSTRAINT IF EXISTS "CK_rates_shipping_line_import_only" + `); + await queryRunner.query(` + ALTER TABLE freight.rates + ADD CONSTRAINT "CK_rates_shipping_line_import_only" CHECK ( + deleted_at IS NOT NULL OR status = 'SUPERSEDED' OR + shipping_line_company_id IS NULL OR + trade_direction IS NULL OR trade_direction = 'IMPORT' + ) + `); + + // The owner joins the rate's identity. Without it MSC's 20ft Djibouti→Modjo + // rate collides with the standard rate for the same lane — same rate_type, + // same scope, same unit — and the insert fails on UQ_rates_pattern. NULL + // (the standard rate) collapses to the zero uuid like every other nullable + // scope column, so existing rows keep their current uniqueness exactly. + await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_rates_pattern"`); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_rates_pattern" ON freight.rates USING btree ( + rate_type, + COALESCE(shipping_line_company_id, '00000000-0000-0000-0000-000000000000'::uuid), + COALESCE(container_type_id, '00000000-0000-0000-0000-000000000000'::uuid), + COALESCE(cargo_type_id, '00000000-0000-0000-0000-000000000000'::uuid), + COALESCE(trade_direction, ''::character varying), + COALESCE(origin_yard_id, '00000000-0000-0000-0000-000000000000'::uuid), + COALESCE(destination_yard_id, '00000000-0000-0000-0000-000000000000'::uuid), + rate_unit, + COALESCE(min_km, '-1'::numeric) + ) WHERE ((deleted_at IS NULL) AND ((status)::text <> 'SUPERSEDED'::text)) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + // Restore the pre-owner pattern index (as left by LastMileRateBands). + await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_rates_pattern"`); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_rates_pattern" ON freight.rates USING btree ( + rate_type, + COALESCE(container_type_id, '00000000-0000-0000-0000-000000000000'::uuid), + COALESCE(cargo_type_id, '00000000-0000-0000-0000-000000000000'::uuid), + COALESCE(trade_direction, ''::character varying), + COALESCE(origin_yard_id, '00000000-0000-0000-0000-000000000000'::uuid), + COALESCE(destination_yard_id, '00000000-0000-0000-0000-000000000000'::uuid), + rate_unit, + COALESCE(min_km, '-1'::numeric) + ) WHERE ((deleted_at IS NULL) AND ((status)::text <> 'SUPERSEDED'::text)) + `); + + await queryRunner.query(` + ALTER TABLE freight.rates + DROP CONSTRAINT IF EXISTS "CK_rates_shipping_line_import_only" + `); + await queryRunner.query( + `DROP INDEX IF EXISTS freight.idx_rates_shipping_line_lane`, + ); + await queryRunner.query( + `DROP INDEX IF EXISTS freight.idx_rates_shipping_line_company_id`, + ); + await queryRunner.query(` + ALTER TABLE freight.rates + DROP CONSTRAINT IF EXISTS "FK_rates_shipping_line_company" + `); + await queryRunner.query(` + ALTER TABLE freight.rates DROP COLUMN IF EXISTS shipping_line_company_id + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3500000000000-LogoSettings.ts b/apps/edr-freight-api/src/migrations/3500000000000-LogoSettings.ts new file mode 100644 index 000000000..36d70906f --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3500000000000-LogoSettings.ts @@ -0,0 +1,27 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Single-row table holding the one company logo image stamped onto every + * generated document (see LogoSettingsService). Same single-row shape as + * stamp_settings; the app never inserts more than one row. + */ +export class LogoSettings3500000000000 implements MigrationInterface { + name = "LogoSettings3500000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.logo_settings ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + logo_file_id uuid REFERENCES freight.files(id), + updated_by_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.logo_settings;`); + } +} diff --git a/apps/edr-freight-api/src/migrations/3510000000000-TrainScheduleShippingLine.ts b/apps/edr-freight-api/src/migrations/3510000000000-TrainScheduleShippingLine.ts new file mode 100644 index 000000000..e1713db94 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3510000000000-TrainScheduleShippingLine.ts @@ -0,0 +1,34 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * A train schedule can be dedicated to one shipping line. + * + * NULL = a normal train, visible and bookable to customers as before. Set = + * the departure exists for that shipping line alone: it is excluded from every + * customer-facing read (booking windows, day pools, portal home cards) and + * surfaces only in the assigned line's portal (home page + booking detail). + */ +export class TrainScheduleShippingLine3510000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_schedules + ADD COLUMN IF NOT EXISTS shipping_line_company_id uuid + REFERENCES freight.shipping_line_companies (id) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_train_schedules_shipping_line_company_id + ON freight.train_schedules (shipping_line_company_id) + WHERE shipping_line_company_id IS NOT NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DROP INDEX IF EXISTS freight.idx_train_schedules_shipping_line_company_id + `); + await queryRunner.query(` + ALTER TABLE freight.train_schedules + DROP COLUMN IF EXISTS shipping_line_company_id + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3520000000000-DefaultDeskHours24h.ts b/apps/edr-freight-api/src/migrations/3520000000000-DefaultDeskHours24h.ts new file mode 100644 index 000000000..8cd71eff9 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3520000000000-DefaultDeskHours24h.ts @@ -0,0 +1,31 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Default the daily booking desk to 24 hours: window_close_hour equal to + * window_open_hour means the desk never pauses overnight. Aligns the column + * default and the existing global-rules row; per-schedule overrides keep + * whatever staff set on them. + */ +export class DefaultDeskHours24h3520000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_scheduling_global_rules + ALTER COLUMN window_close_hour SET DEFAULT 8 + `); + await queryRunner.query(` + UPDATE freight.train_scheduling_global_rules + SET window_close_hour = window_open_hour + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_scheduling_global_rules + ALTER COLUMN window_close_hour SET DEFAULT 17 + `); + await queryRunner.query(` + UPDATE freight.train_scheduling_global_rules + SET window_close_hour = 17 + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3530000000000-ShippingLineInvoiceApprovals.ts b/apps/edr-freight-api/src/migrations/3530000000000-ShippingLineInvoiceApprovals.ts new file mode 100644 index 000000000..4183be8ff --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3530000000000-ShippingLineInvoiceApprovals.ts @@ -0,0 +1,78 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Maker–checker for manual actions on shipping-line credit invoices. + * + * A shipping-line credit invoice is normally settled by the CBE webhook. Two + * manual paths exist for finance: recording an offline payment (MARK_PAID) + * and voiding an invoice raised in error (CANCEL, which releases its credits + * back to the unbilled pool). Both erase or move real debt, so neither is a + * single-person action: one permission raises the request, a different + * permission — held by a chief, and never the requester themselves — approves + * or rejects it. Rows are never deleted; decided requests are the audit trail. + * + * One PENDING row per invoice at a time (partial unique index): a second + * request while one is undecided is a coordination failure, not a workflow. + */ +export class ShippingLineInvoiceApprovals3530000000000 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DO $$ BEGIN + CREATE TYPE freight.shipping_line_invoice_approvals_action_enum + AS ENUM ('MARK_PAID', 'CANCEL'); + EXCEPTION WHEN duplicate_object THEN NULL; END $$ + `); + + await queryRunner.query(` + DO $$ BEGIN + CREATE TYPE freight.shipping_line_invoice_approvals_status_enum + AS ENUM ('PENDING', 'APPROVED', 'REJECTED'); + EXCEPTION WHEN duplicate_object THEN NULL; END $$ + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.shipping_line_invoice_approvals ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + invoice_id uuid NOT NULL REFERENCES freight.invoices (id), + action freight.shipping_line_invoice_approvals_action_enum NOT NULL, + status freight.shipping_line_invoice_approvals_status_enum NOT NULL DEFAULT 'PENDING', + requested_by uuid NOT NULL, + reason varchar(500) NOT NULL, + payment_reference varchar(255), + decided_by uuid, + decided_at timestamptz, + decision_note varchar(500), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ) + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_sl_invoice_approvals_invoice_status + ON freight.shipping_line_invoice_approvals (invoice_id, status) + `); + + // The workflow invariant, enforced where it cannot race: at most one + // undecided request per invoice. + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS uq_sl_invoice_approvals_one_pending + ON freight.shipping_line_invoice_approvals (invoice_id) + WHERE status = 'PENDING' AND deleted_at IS NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP TABLE IF EXISTS freight.shipping_line_invoice_approvals`, + ); + await queryRunner.query( + `DROP TYPE IF EXISTS freight.shipping_line_invoice_approvals_status_enum`, + ); + await queryRunner.query( + `DROP TYPE IF EXISTS freight.shipping_line_invoice_approvals_action_enum`, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/audit/audit-endpoints.ts b/apps/edr-freight-api/src/modules/audit/audit-endpoints.ts index 0526636ff..d46f63a7d 100644 --- a/apps/edr-freight-api/src/modules/audit/audit-endpoints.ts +++ b/apps/edr-freight-api/src/modules/audit/audit-endpoints.ts @@ -432,11 +432,16 @@ export const AUDIT_ENDPOINTS: Readonly> = { "POST /api/service-types/:id/move-order": ["Move a service type up or down in display order", "POST", "Service Type"], "POST /api/service-types/reorder": ["Bulk reorder service types by ID list", "POST", "Service Type"], - // Shipping Line + // Shipping Line (rule-engine lookup list — a code/label bookings reference, + // not an account) "POST /api/shipping-lines": ["Create a shipping line", "POST", "Shipping Line"], "PATCH /api/shipping-lines/:id": ["Update a shipping line", "PATCH", "Shipping Line"], "DELETE /api/shipping-lines/:id": ["Soft-delete a shipping line", "DELETE", "Shipping Line"], + // Shipping Line Company (carrier with a portal login, registered by staff) + "POST /api/shipping-line-companies": ["Register a shipping line company and send its activation link", "POST", "Shipping Line Company"], + "POST /api/shipping-line-companies/:id/resend-activation": ["Resend a shipping line company's activation link", "POST", "Shipping Line Company"], + // Signature "PUT /api/me/signature": ["Create or update the reusable saved signature", "PUT", "Signature"], diff --git a/apps/edr-freight-api/src/modules/auth/customer-reset.service.ts b/apps/edr-freight-api/src/modules/auth/customer-reset.service.ts index 4eb6ecc31..b00fd588e 100644 --- a/apps/edr-freight-api/src/modules/auth/customer-reset.service.ts +++ b/apps/edr-freight-api/src/modules/auth/customer-reset.service.ts @@ -1,6 +1,7 @@ import { Injectable, Logger } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { InjectRepository } from "@nestjs/typeorm"; +import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity"; import { Repository } from "typeorm"; import { ExternalProfile } from "../companies/entities/external-profile.entity"; @@ -86,7 +87,66 @@ export class CustomerResetService { const resolved = await this.resolvePrimaryContactUser(companyId); if (!resolved) return null; - const { user, userId } = resolved; + return this.sendResetLinkToUser(resolved.userId, channel, { + scope: `company ${companyId}`, + }); + } + + /** + * Mint and deliver a reset link to a specific IAM account. + * + * The delivery half of {@link sendResetLinkToCustomer}, split out so callers + * that resolve their target differently can reuse it: a customer is found via + * the company's primary contact, while a shipping line has no contact row at + * all and resolves straight off its own record. Everything below the lookup — + * active-account gating, the domestic-SMS rule, mint-before-send, the + * undelivered-link diagnostic — is identical for both and must stay that way. + * + * `scope` only labels the log line with whatever the caller resolved from. + * + * `allowWithoutCredential` relaxes the lookup for first-time activation: + * the default gate requires an existing active credential (so a reset cannot + * revive a suspended account), but an account that has never set a password + * has no credential row yet and would be excluded from its own activation + * link. Callers pass it only when the account is expected to be + * password-less — see ShippingLineCompaniesService. + */ + async sendResetLinkToUser( + userId: string, + channel: ResetChannel, + options?: { scope?: string; allowWithoutCredential?: boolean }, + ): Promise { + const user = options?.allowWithoutCredential + ? await this.forgotPasswordService.resolveActivatableUserById(userId) + : await this.forgotPasswordService.resolveActiveUserById(userId); + + if (!user?.id) { + this.logger.warn( + `User ${userId} is not an active account${ + options?.allowWithoutCredential + ? "" + : " (or has no active credential — pass allowWithoutCredential for first-time activation)" + }`, + ); + return null; + } + + return this.deliverResetLink(user, user.id, channel, options?.scope); + } + + /** + * Shared tail: target selection → SMS reachability → mint → send → report. + * Callers have already resolved `user` to an active account. + */ + private async deliverResetLink( + user: User, + userId: string, + channel: ResetChannel, + scope?: string, + ): Promise { + this.logger.log( + `Staff-triggered shipping line ${"link"}`, + ); const target = this.forgotPasswordService.targetFor(user, channel); if (!target) return null; @@ -110,6 +170,9 @@ export class CustomerResetService { ); const link = this.buildResetLink(ticket.userId, ticket.verificationCode); const expiresAt = new Date(Date.now() + RESET_LINK_TTL_MS); +this.logger.log( + `Staff-triggered shipping line ${link}`, + ); const { queued } = target.email ? await this.emailClient.sendEmail({ @@ -127,7 +190,22 @@ export class CustomerResetService { }); this.logger.log( - `Staff-triggered ${channel} reset link sent to user ${userId} (company ${companyId}) queued=${queued}`, + `Staff-triggered shipping line ${channel} reset link sent to user ${userId}${ + scope ? ` (${scope})` : "" + } queued=${queued}`, + ); + + // SECURITY: logs a live password-reset credential in cleartext. Anyone with + // read access to the log stream can set the password for the account named + // on the same line — including on sends that succeeded, not just failures. + // Kept deliberately: log aggregation is the debugging path for flaky + // email/SMS here, the same tradeoff otp.service.ts makes for OTP codes. If + // that is ever revisited, gate this on an env flag rather than deleting it, + // so dev keeps its workflow. + this.logger.warn( + `reset-link.cleartext channel=${channel} user=${userId}${ + scope ? ` (${scope})` : "" + } link=${link}`, ); if (!queued) { diff --git a/apps/edr-freight-api/src/modules/auth/forgot-password.service.ts b/apps/edr-freight-api/src/modules/auth/forgot-password.service.ts index a3dbf1061..a2afdbbbc 100644 --- a/apps/edr-freight-api/src/modules/auth/forgot-password.service.ts +++ b/apps/edr-freight-api/src/modules/auth/forgot-password.service.ts @@ -89,6 +89,28 @@ export class ForgotPasswordService { .getOne(); } + /** + * Active account by id, WITHOUT requiring an existing credential. + * + * {@link activeUserQuery} inner-joins an active `user_credentials` row, which + * is right for a *reset*: it stops a staff-triggered link from reactivating a + * suspended account. But an account that has never set a password has no + * credential row yet, so that join excludes exactly the accounts a first-time + * *activation* link is for — shipping lines are created deliberately without + * one (see ShippingLineCompaniesService.register). + * + * The `isActive` gate is kept; only the credential requirement is dropped. + */ + async resolveActivatableUserById(userId: string): Promise { + if (!userId) return null; + return await this.userRepository + .createQueryBuilder("u") + .where("u.isActive = true") + .andWhere("u.id = :userId", { userId }) + .orderBy("u.createdAt", "DESC") + .getOne(); + } + /** * Base query for accounts eligible to reset. `.where()` is claimed here so * callers must use `.andWhere()` — TypeORM's `.where()` resets the clause, @@ -233,9 +255,22 @@ export class ForgotPasswordService { "This password-reset link is invalid or has expired. Request a new one.", ); - const user = await this.resolveActiveUserById(userId); + // Credential-less on purpose: this resolves links for *setting* a password, + // which includes first-time activation of an account that has never had one + // (shipping lines are created without a credential row). Requiring one here + // rejected a perfectly valid activation link before its token was ever + // checked. The ticket checks below are what actually authorise the reset. + const user = await this.resolveActivatableUserById(userId); const identifier = user && this.identifierFor(user); - if (!user || !identifier) throw invalid; + if (!user || !identifier) { + // Logged because the early return above bypasses the rejection warning + // below — without this, an account that fails the lookup produces no + // diagnostic at all and looks identical to a bad token. + this.logger.warn( + `Reset link rejected for user ${userId} — no active account or no usable identifier`, + ); + throw invalid; + } const verification = await this.dataSource .getRepository(UserVerification) diff --git a/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts b/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts index ff8f803b9..557e50fb3 100644 --- a/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts +++ b/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts @@ -51,5 +51,8 @@ import { ListUsersService } from './list-users.service'; ForgotPasswordService, CustomerResetService, ], + // Shipping-line registration mints activation links through the same + // staff-triggered reset path customers use. + exports: [CustomerResetService], }) export class FreightAuthModule {} diff --git a/apps/edr-freight-api/src/modules/billing/billing.module.ts b/apps/edr-freight-api/src/modules/billing/billing.module.ts index 7ec5c333b..06849d560 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.module.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.module.ts @@ -19,7 +19,8 @@ import { FilesModule } from "../files/files.module"; imports: [ TypeOrmModule.forFeature([Invoice, InvoiceLine]), forwardRef(() => PaymentModule), - CompaniesModule, + // Cycles back via ShippingLineCompaniesModule, which imports this module. + forwardRef(() => CompaniesModule), DocumentsModule, UserTradeAccessModule, FilesModule, @@ -29,3 +30,4 @@ import { FilesModule } from "../files/files.module"; exports: [BillingService], }) export class BillingModule {} + \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index caa25fbaa..bc79f8899 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -13,6 +13,9 @@ import { logCtx } from "@edr/api-common"; import { DataSource, EntityManager, In } from "typeorm"; import { Booking } from "../bookings/entities/booking.entity"; +// Entity-only import (no module edge): portal reads resolve shipping-line +// payers straight off the table. +import { ShippingLineCompany } from "../shipping-lines/entities/shipping-line-company.entity"; import { EimsConfig } from "../../config/eims.config"; import { CompaniesService } from "../companies/companies.service"; import { FilesService } from "../files/files.service"; @@ -114,8 +117,16 @@ export interface GenerateInvoiceInput { sourceId: string; /** What the invoice is for (e.g. "prepaid", "credit"). */ type: string; - companyId: string; - companyProfileId: string; + /** The customer billed. Omit only when billing a shipping line instead. */ + companyId?: string | null; + companyProfileId?: string | null; + /** + * The shipping line billed, for an invoice covering batched shipping-line + * credits. Mutually exclusive with `companyId` — the DB enforces this via + * `chk_invoices_single_payer`, and {@link createInvoice} rejects a payload + * setting both or neither before it ever reaches the constraint. + */ + shippingLineCompanyId?: string | null; lines: InvoiceLineInput[]; currency?: string; /** Explicit pre-tax subtotal; defaults to the sum of line amounts. */ @@ -141,8 +152,11 @@ export interface InvoiceEventPayload { source: Freight.InvoiceSource; sourceId: string; type: string; - companyId: string; - companyProfileId: string; + /** Null when the payer is a shipping line rather than a customer company. */ + companyId: string | null; + companyProfileId: string | null; + /** Set only on shipping-line invoices; mutually exclusive with `companyId`. */ + shippingLineCompanyId?: string | null; totalAmount: number; currency: string; status: Freight.InvoiceStatus; @@ -413,6 +427,32 @@ export class BillingService { return `data:image/png;base64,${signedQr}`; } + /** Route + wagon count summary rows for a booking-sourced invoice; empty for every other source. */ + private async bookingSummaryRows( + invoice: Invoice, + ): Promise { + if (invoice.source !== Freight.InvoiceSource.Booking) return []; + const booking = await this.dataSource.getRepository(Booking).findOne({ + where: { id: invoice.sourceId }, + relations: { originYard: true, destinationYard: true }, + }); + if (!booking) return []; + return [ + { + label: "Route", + value: + booking.originYard && booking.destinationYard + ? `${booking.originYard.label} → ${booking.destinationYard.label}` + : null, + }, + { + label: "Wagons", + value: + booking.wagonsRequired != null ? String(booking.wagonsRequired) : null, + }, + ]; + } + /** Map a global invoice (+ lines) onto the source-agnostic document model. */ private async toDocumentModel( invoice: Invoice & { lines: InvoiceLine[] }, @@ -446,6 +486,7 @@ export class BillingService { { label: "Status", value: invoice.status }, { label: "Type", value: invoice.type }, { label: "Reference", value: invoice.sourceId }, + ...(await this.bookingSummaryRows(invoice)), { label: "Currency", value: invoice.currency }, { label: "Issued", @@ -536,23 +577,61 @@ export class BillingService { }); } - /** Invoices for the signed-in customer; empty when they have no company. */ + /** + * Resolve a shipping-line company from the signed-in user (null for ordinary + * customers). Queried straight off the entity rather than through + * ShippingLineCompaniesService — that module already imports billing, so a + * service edge back would deepen the forwardRef cycle for one lookup. + */ + private async resolveShippingLineCompanyId( + userId: string, + ): Promise { + const line = await this.dataSource + .getRepository(ShippingLineCompany) + .findOne({ where: { userId } }); + return line?.id ?? null; + } + + /** + * Invoices for the signed-in portal user; empty when they have no company. + * A payer is either a customer company or a shipping line (enforced by the + * DB's single-payer check), so the two lookups cannot both match. + */ async findForUser( userId: string, filter: { source?: string; sourceId?: string } = {}, ): Promise { const companyId = await this.resolveCompanyId(userId); - return companyId ? this.findByCompany(companyId, filter) : []; + if (companyId) return this.findByCompany(companyId, filter); + + const shippingLineCompanyId = + await this.resolveShippingLineCompanyId(userId); + if (!shippingLineCompanyId) return []; + return this.invoices.findAll({ + where: { + shippingLineCompanyId, + ...(filter.source ? { source: filter.source } : {}), + ...(filter.sourceId ? { sourceId: filter.sourceId } : {}), + }, + order: { createdAt: "DESC" }, + }); } - /** Company-scoped invoice detail (+ lines); 404 when not owned by the user. */ + /** Payer-scoped invoice detail (+ lines); 404 when not owned by the user. */ async findByIdForUser( id: string, userId: string, ): Promise { - const companyId = await this.resolveCompanyId(userId); const invoice = await this.findById(id); - if (!companyId || invoice.companyId !== companyId) { + const ownedByCompany = + invoice.companyId != null && + invoice.companyId === (await this.resolveCompanyId(userId)); + const ownedByShippingLine = + !ownedByCompany && + invoice.shippingLineCompanyId != null && + invoice.shippingLineCompanyId === + (await this.resolveShippingLineCompanyId(userId)); + if (!ownedByCompany && !ownedByShippingLine) { throw new NotFoundException(`Invoice ${id} not found`); } return invoice; @@ -642,7 +721,6 @@ export class BillingService { input: GenerateInvoiceInput, manager?: EntityManager, ): Promise { - console.log("oooooooooo", input); const run = (mg: EntityManager) => this.createInvoice(input, mg); return manager ? run(manager) : this.dataSource.transaction(run); } @@ -655,6 +733,21 @@ export class BillingService { const status = input.status ?? Freight.InvoiceStatus.Pending; const issued = status !== Freight.InvoiceStatus.Draft; + // Exactly one payer, checked here so a bad payload fails with a clear + // message instead of a raw `chk_invoices_single_payer` violation. + const billsCompany = Boolean(input.companyId); + const billsShippingLine = Boolean(input.shippingLineCompanyId); + if (billsCompany === billsShippingLine) { + throw new BadRequestException( + "An invoice must be billed to exactly one payer: either companyId or shippingLineCompanyId.", + ); + } + if (billsCompany && !input.companyProfileId) { + throw new BadRequestException( + "companyProfileId is required when billing a company.", + ); + } + const lines = input.lines.map((l) => { const quantity = l.quantity ?? 1; const unitRate = l.unitRate ?? 0; @@ -690,8 +783,9 @@ export class BillingService { source: input.source, sourceId: input.sourceId, type: input.type, - companyId: input.companyId, - companyProfileId: input.companyProfileId, + companyId: input.companyId ?? null, + companyProfileId: input.companyProfileId ?? null, + shippingLineCompanyId: input.shippingLineCompanyId ?? null, subtotalAmount: round2(subtotalAmount), taxAmount: round2(taxAmount), totalAmount: round2(totalAmount), @@ -1021,6 +1115,7 @@ export class BillingService { type: invoice.type, companyId: invoice.companyId, companyProfileId: invoice.companyProfileId, + shippingLineCompanyId: invoice.shippingLineCompanyId ?? null, totalAmount: invoice.totalAmount, currency: invoice.currency, status: invoice.status, diff --git a/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.spec.ts b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.spec.ts index 00e590e17..bc5250888 100644 --- a/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.spec.ts +++ b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.spec.ts @@ -14,7 +14,7 @@ const model = (over: Partial = {}): InvoiceDocumentModel = }); describe("InvoiceDocumentService.buildHtml — EIMS QR", () => { - const service = new InvoiceDocumentService({} as never, {} as never); + const service = new InvoiceDocumentService({} as never, {} as never, {} as never); it("renders no QR block when qrImageUrl is unset", () => { const html = service.buildHtml(model()); diff --git a/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts index d83b7e2f0..f6b264636 100644 --- a/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts +++ b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts @@ -1,8 +1,10 @@ import { Injectable } from "@nestjs/common"; import { StampSettingsService } from "../../stamp-settings/stamp-settings.service"; +import { LogoSettingsService } from "../../logo-settings/logo-settings.service"; import { PdfRenderService } from "./pdf-render.service"; import { sealClass, sealImageCss, sealMarkup } from "./seal-markup.util"; +import { logoImageCss, logoMarkup } from "./logo-markup.util"; import { PdfColor, assembleSinglePagePdf, @@ -62,6 +64,7 @@ export interface InvoiceDocumentModel { * explicitly only to override that default for one document. */ stampImageUrl?: string | null; + logoImageUrl?: string | null; /** * MoR EIMS verification QR (data URL, pre-rendered by the caller from `Invoice.eimsSignedQr` — * see that column's comment). Set only once an invoice is actually registered; the IRN text @@ -81,6 +84,7 @@ export class InvoiceDocumentService { constructor( private readonly pdf: PdfRenderService, private readonly stampSettings: StampSettingsService, + private readonly logoSettings: LogoSettingsService, ) {} async render( @@ -90,7 +94,11 @@ export class InvoiceDocumentService { model.stampImageUrl !== undefined ? model.stampImageUrl : await this.stampSettings.getStampImageUrl(); - const resolvedModel: InvoiceDocumentModel = { ...model, stampImageUrl }; + const logoImageUrl = + model.logoImageUrl !== undefined + ? model.logoImageUrl + : await this.logoSettings.getLogoImageUrl(); + const resolvedModel: InvoiceDocumentModel = { ...model, stampImageUrl, logoImageUrl }; const html = this.buildHtml(resolvedModel); const kindLabel = model.kind === "RECEIPT" ? "receipt" : "invoice"; @@ -250,6 +258,7 @@ export class InvoiceDocumentService { model.sealText ?? (model.kind === "RECEIPT" || model.status === "PAID" ? "EDR PAID" : "EDR"); const sealInner = sealMarkup(model.stampImageUrl, sealText); const sealCssClass = sealClass(model.stampImageUrl); + const logoInner = logoMarkup(model.logoImageUrl); const qrMarkup = model.qrImageUrl ? `
EIMS verification QRScan to verify (MoR EIMS)
` @@ -293,6 +302,7 @@ export class InvoiceDocumentService { .meta strong { display: block; color: #0f172a; font-size: 17px; margin-top: 5px; } .seal { position: absolute; right: 28px; top: 118px; width: 116px; height: 116px; border: 4px double #0f766e; border-radius: 999px; color: #0f766e; display: flex; align-items: center; justify-content: center; text-align: center; font-weight: 800; font-size: 18px; transform: rotate(-14deg); opacity: .82; } ${sealImageCss()} + ${logoImageCss()} .qr { position: absolute; right: 160px; top: 118px; width: 90px; text-align: center; } .qr img { width: 90px; height: 90px; } .qr span { display: block; font-size: 7px; color: #64748b; margin-top: 3px; } @@ -322,6 +332,7 @@ export class InvoiceDocumentService {
+ ${logoInner}
Ethio-Djibouti Railway S.C.

${esc(model.title)} ${model.kind === "RECEIPT" ? "Receipt" : "Invoice"}

diff --git a/apps/edr-freight-api/src/modules/billing/documents/logo-markup.util.ts b/apps/edr-freight-api/src/modules/billing/documents/logo-markup.util.ts new file mode 100644 index 000000000..f78109855 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/documents/logo-markup.util.ts @@ -0,0 +1,35 @@ +/** + * The single decision every EDR document makes about its header logo: draw + * the one uploaded company logo when configured (LogoSettingsService), or + * render nothing — the existing "Ethio-Djibouti Railway S.C." text brand next + * to it already covers the no-logo case, so there is no text fallback here + * (contrast seal-markup.util.ts, whose seal has no text of its own). + */ + +function escapeHtml(value: unknown): string { + return String(value ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +/** + * `` markup for the header logo, or "" when unset. `logoImageUrl` is + * expected to be a data URL from LogoSettingsService.getLogoImageUrl(). + * `className` defaults to "doc-logo" — each document supplies that class's + * sizing in its own +

${esc(def.title)}

+

${esc(def.description)}

+ ${kpiHtml} + ${head}${body}
+ `; + } +} diff --git a/apps/edr-freight-api/src/modules/reports/report-queries.ts b/apps/edr-freight-api/src/modules/reports/report-queries.ts deleted file mode 100644 index 9e4a6f617..000000000 --- a/apps/edr-freight-api/src/modules/reports/report-queries.ts +++ /dev/null @@ -1,669 +0,0 @@ -import { DataSource } from 'typeorm'; - -export interface ReportFilters { - /** ISO timestamp, inclusive lower bound. null = no lower bound (all time). */ - dateFrom: string | null; - /** ISO timestamp, exclusive upper bound. null = no upper bound. */ - dateTo: string | null; - granularity: 'day' | 'week' | 'month'; - companyIds: string[] | null; - routeIds: string[] | null; - yardIds: string[] | null; - cargoTypeIds: string[] | null; - statuses: string[] | null; - /** Trade-scope-resolved directions. null = unrestricted, [] = show nothing. */ - directions: string[] | null; - freightType: string | null; -} - -export interface ReportKpi { - label: string; - value: number; - unit?: string; -} - -export interface ReportResult { - kpis: ReportKpi[]; - rows: Record[]; -} - -type ReportQuery = (ds: DataSource, f: ReportFilters) => Promise; - -// For PER_ITEM bulk bookings cargo_total_weight_vgm holds an item COUNT, and -// the real tonnage lives in bulk_total_weight_tons — hence the COALESCE order. -const TONS = 'COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)'; -// adjusted_total_amount silently overrides total_amount when set. -const REVENUE = 'COALESCE(b.adjusted_total_amount, b.total_amount)'; -// GENERAL contract_kind rows are umbrella contracts, not shipments; counting -// them double-counts every child booking (same guard as overview.repository). -const NOT_UMBRELLA = "(b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')"; -const DEAD_STATUSES = "'DRAFT','CANCELLED','REJECTED','EXPIRED'"; - -const num = (v: unknown): number => (v === null || v === undefined ? 0 : Number(v)); -const sum = (rows: Record[], col: string): number => - rows.reduce((acc, r) => acc + num(r[col]), 0); - -/** - * Shared WHERE for booking-based reports (alias `b`). - * Params occupy $1..$8 in this fixed order; report SQL continues at $9. - */ -function bookingWhere(f: ReportFilters): { where: string; params: unknown[] } { - return { - where: ` - b.deleted_at IS NULL - AND ${NOT_UMBRELLA} - AND ($1::timestamptz IS NULL OR b.created_at >= $1) - AND ($2::timestamptz IS NULL OR b.created_at < $2) - AND ($3::uuid[] IS NULL OR b.company_id = ANY($3)) - AND ($4::uuid[] IS NULL OR b.cargo_type_id = ANY($4)) - AND ($5::text[] IS NULL OR b.trade_direction = ANY($5)) - AND ($6::text IS NULL OR b.freight_type = $6) - AND (CASE WHEN $7::text[] IS NULL - THEN b.status NOT IN (${DEAD_STATUSES}) - ELSE b.status = ANY($7) END) - AND ($8::uuid[] IS NULL OR b.origin_yard_id = ANY($8) OR b.destination_yard_id = ANY($8))`, - params: [ - f.dateFrom, - f.dateTo, - f.companyIds, - f.cargoTypeIds, - f.directions, - f.freightType, - f.statuses, - f.yardIds, - ], - }; -} - -/** - * Direction scope for rows that reference a booking through a varchar id - * column (invoices.source_id, payments.ref_id). Rows not pointing at a - * booking stay visible — they carry no direction to scope by. - * (Positional-param port of trade-scope.util's bookingRefScopeSql.) - */ -const refDirScope = (refColumn: string, param: string): string => ` - (${param}::text[] IS NULL OR NOT EXISTS ( - SELECT 1 FROM freight.bookings sb - WHERE sb.id::text = ${refColumn} AND NOT (sb.trade_direction = ANY(${param}))))`; - -const bookingsTrend: ReportQuery = async (ds, f) => { - const { where, params } = bookingWhere(f); - const rows = await ds.query( - `SELECT to_char(date_trunc($9, b.created_at), 'YYYY-MM-DD') AS period, - COUNT(*)::int AS bookings, - ROUND(COALESCE(SUM(${TONS}), 0))::float8 AS tons, - ROUND(COALESCE(SUM(${REVENUE}), 0))::float8 AS revenue - FROM freight.bookings b - WHERE ${where} - GROUP BY 1 ORDER BY 1`, - [...params, f.granularity], - ); - return { - kpis: [ - { label: 'Bookings', value: sum(rows, 'bookings') }, - { label: 'Tonnage', value: sum(rows, 'tons'), unit: 't' }, - { label: 'Revenue', value: sum(rows, 'revenue'), unit: 'ETB' }, - ], - rows, - }; -}; - -const revenueByCustomer: ReportQuery = async (ds, f) => { - const { where, params } = bookingWhere(f); - const rows = await ds.query( - `SELECT c.name AS customer, - COUNT(*)::int AS bookings, - ROUND(COALESCE(SUM(${TONS}), 0))::float8 AS tons, - ROUND(COALESCE(SUM(${REVENUE}), 0))::float8 AS revenue - FROM freight.bookings b - JOIN freight.companies c ON c.id = b.company_id - WHERE ${where} - GROUP BY c.name ORDER BY revenue DESC LIMIT 100`, - params, - ); - const total = sum(rows, 'revenue'); - return { - kpis: [ - { label: 'Customers', value: rows.length }, - { label: 'Revenue', value: total, unit: 'ETB' }, - { - label: 'Top customer share', - value: total > 0 ? Math.round((num(rows[0]?.revenue) / total) * 100) : 0, - unit: '%', - }, - ], - rows, - }; -}; - -const revenueByLane: ReportQuery = async (ds, f) => { - const { where, params } = bookingWhere(f); - const rows = await ds.query( - `SELECT o.label AS origin, d.label AS destination, - COUNT(*)::int AS bookings, - ROUND(COALESCE(SUM(${TONS}), 0))::float8 AS tons, - ROUND(COALESCE(SUM(${REVENUE}), 0))::float8 AS revenue - FROM freight.bookings b - JOIN freight.yards o ON o.id = b.origin_yard_id - JOIN freight.yards d ON d.id = b.destination_yard_id - WHERE ${where} - GROUP BY 1, 2 ORDER BY revenue DESC LIMIT 100`, - params, - ); - return { - kpis: [ - { label: 'Lanes', value: rows.length }, - { label: 'Tonnage', value: sum(rows, 'tons'), unit: 't' }, - { label: 'Revenue', value: sum(rows, 'revenue'), unit: 'ETB' }, - ], - rows, - }; -}; - -const contractUtilization: ReportQuery = async (ds, f) => { - const rows = await ds.query( - `SELECT ct.reference, c.name AS customer, ct.status, ct.contract_kind AS kind, - to_char(ct.contract_valid_from, 'YYYY-MM-DD') AS valid_from, - to_char(ct.contract_valid_until, 'YYYY-MM-DD') AS valid_until, - cap.committed::float8 AS committed, - booked.tons::float8 AS booked_tons, - booked.cnt AS bookings, - CASE WHEN cap.committed > 0 - THEN ROUND(booked.tons / cap.committed * 100)::float8 END AS utilization_pct - FROM freight.contracts ct - LEFT JOIN freight.companies c ON c.id = ct.company_id - LEFT JOIN LATERAL ( - SELECT COALESCE(SUM(s.quantity_cap), 0) AS committed - FROM freight.contract_cargo_scope s - WHERE s.contract_id = ct.id AND s.deleted_at IS NULL) cap ON true - LEFT JOIN LATERAL ( - SELECT COALESCE(SUM(${TONS}), 0) AS tons, COUNT(*)::int AS cnt - FROM freight.bookings b - WHERE b.contract_id = ct.id AND b.deleted_at IS NULL - AND b.status NOT IN (${DEAD_STATUSES})) booked ON true - WHERE ct.deleted_at IS NULL - AND ct.status NOT IN ('DRAFT') - AND ct.contract_valid_from < COALESCE($2::timestamptz, 'infinity') - AND (ct.contract_valid_until IS NULL - OR ct.contract_valid_until >= COALESCE($1::timestamptz, '-infinity')) - AND ($3::uuid[] IS NULL OR ct.company_id = ANY($3)) - AND ($4::text[] IS NULL OR ct.trade_direction = ANY($4)) - AND ($5::text[] IS NULL OR ct.status = ANY($5)) - ORDER BY utilization_pct DESC NULLS LAST LIMIT 200`, - [f.dateFrom, f.dateTo, f.companyIds, f.directions, f.statuses], - ); - const capped = rows.filter((r: Record) => num(r.committed) > 0); - return { - kpis: [ - { label: 'Contracts', value: rows.length }, - { - label: 'Avg utilization', - value: capped.length - ? Math.round(sum(capped, 'utilization_pct') / capped.length) - : 0, - unit: '%', - }, - { label: 'Booked tonnage', value: sum(rows, 'booked_tons'), unit: 't' }, - ], - rows, - }; -}; - -// ponytail: 60-min departure grace is a constant; make it a query param if ops -// ever wants a configurable threshold. -const trainOnTime: ReportQuery = async (ds, f) => { - const rows = await ds.query( - `SELECT o.label AS origin, d.label AS destination, - COUNT(*)::int AS trips, - COUNT(*) FILTER (WHERE ts.actual_departure_at IS NOT NULL)::int AS departed, - ROUND(AVG(EXTRACT(EPOCH FROM (ts.actual_departure_at - ts.scheduled_departure_date)) / 60) - FILTER (WHERE ts.actual_departure_at IS NOT NULL))::float8 AS avg_dep_delay_min, - ROUND(AVG(EXTRACT(EPOCH FROM (ts.actual_arrival_at - ts.scheduled_arrival_date)) / 60) - FILTER (WHERE ts.actual_arrival_at IS NOT NULL - AND ts.scheduled_arrival_date IS NOT NULL))::float8 AS avg_arr_delay_min, - ROUND(100.0 * COUNT(*) FILTER (WHERE ts.actual_departure_at - <= ts.scheduled_departure_date + interval '60 minutes') - / NULLIF(COUNT(*) FILTER (WHERE ts.actual_departure_at IS NOT NULL), 0))::float8 AS on_time_pct - FROM freight.train_schedules ts - JOIN freight.yards o ON o.id = ts.origin_station_id - JOIN freight.yards d ON d.id = ts.destination_station_id - WHERE ts.deleted_at IS NULL - AND ts.status IN ('DISPATCHED', 'ARRIVED') - AND ($1::timestamptz IS NULL OR ts.scheduled_departure_date >= $1) - AND ($2::timestamptz IS NULL OR ts.scheduled_departure_date < $2) - AND ($3::uuid[] IS NULL OR ts.route_id = ANY($3)) - AND ($4::text[] IS NULL OR ts.direction = ANY($4)) - AND ($5::uuid[] IS NULL OR ts.origin_station_id = ANY($5) OR ts.destination_station_id = ANY($5)) - GROUP BY 1, 2 ORDER BY trips DESC`, - [f.dateFrom, f.dateTo, f.routeIds, f.directions, f.yardIds], - ); - const departed = sum(rows, 'departed'); - const weighted = rows.reduce( - (acc: number, r: Record) => - acc + (num(r.on_time_pct) * num(r.departed)) / 100, - 0, - ); - return { - kpis: [ - { label: 'Trips', value: sum(rows, 'trips') }, - { - label: 'On-time departures', - value: departed > 0 ? Math.round((weighted / departed) * 100) : 0, - unit: '%', - }, - { - label: 'Avg departure delay', - value: rows.length ? Math.round(sum(rows, 'avg_dep_delay_min') / rows.length) : 0, - unit: 'min', - }, - ], - rows, - }; -}; - -const scheduleFillRate: ReportQuery = async (ds, f) => { - const rows = await ds.query( - `SELECT ts.train_number, ts.reference, - to_char(ts.scheduled_departure_date, 'YYYY-MM-DD') AS departure, - o.label AS origin, d.label AS destination, ts.direction, ts.status, - ts.max_wagons, tset.wagon_count, - ROUND(w.cap_tons)::float8 AS capacity_tons, - ROUND(w.booked_tons)::float8 AS booked_tons, - CASE WHEN w.cap_tons > 0 - THEN ROUND(w.booked_tons / w.cap_tons * 100)::float8 END AS fill_pct - FROM freight.train_schedules ts - JOIN freight.yards o ON o.id = ts.origin_station_id - JOIN freight.yards d ON d.id = ts.destination_station_id - LEFT JOIN freight.train_sets tset ON tset.id = ts.train_set_id - LEFT JOIN LATERAL ( - SELECT COALESCE(SUM(tw.capacity_tons), 0) AS cap_tons, - COALESCE(SUM(tw.assigned_weight_tons), 0) AS booked_tons - FROM freight.train_set_wagons tw - WHERE tw.train_set_id = ts.train_set_id AND tw.deleted_at IS NULL) w ON true - WHERE ts.deleted_at IS NULL - AND ts.status <> 'CANCELLED' - AND ($1::timestamptz IS NULL OR ts.scheduled_departure_date >= $1) - AND ($2::timestamptz IS NULL OR ts.scheduled_departure_date < $2) - AND ($3::uuid[] IS NULL OR ts.route_id = ANY($3)) - AND ($4::text[] IS NULL OR ts.direction = ANY($4)) - AND ($5::uuid[] IS NULL OR ts.origin_station_id = ANY($5) OR ts.destination_station_id = ANY($5)) - ORDER BY ts.scheduled_departure_date DESC LIMIT 200`, - [f.dateFrom, f.dateTo, f.routeIds, f.directions, f.yardIds], - ); - const withCap = rows.filter((r: Record) => num(r.capacity_tons) > 0); - const capTons = sum(withCap, 'capacity_tons'); - return { - kpis: [ - { label: 'Schedules', value: rows.length }, - { - label: 'Avg fill rate', - value: capTons > 0 ? Math.round((sum(withCap, 'booked_tons') / capTons) * 100) : 0, - unit: '%', - }, - { label: 'Booked tonnage', value: sum(rows, 'booked_tons'), unit: 't' }, - ], - rows, - }; -}; - -const tripsPerRoute: ReportQuery = async (ds, f) => { - const rows = await ds.query( - `SELECT o.label AS origin, d.label AS destination, ts.direction, - COUNT(*)::int AS trips, - ROUND(COALESCE(SUM(w.booked_tons), 0))::float8 AS tons_hauled, - ROUND(COALESCE(AVG(w.booked_tons), 0))::float8 AS avg_tons_per_trip - FROM freight.train_schedules ts - JOIN freight.yards o ON o.id = ts.origin_station_id - JOIN freight.yards d ON d.id = ts.destination_station_id - LEFT JOIN LATERAL ( - SELECT COALESCE(SUM(tw.assigned_weight_tons), 0) AS booked_tons - FROM freight.train_set_wagons tw - WHERE tw.train_set_id = ts.train_set_id AND tw.deleted_at IS NULL) w ON true - WHERE ts.deleted_at IS NULL - AND ts.status IN ('DISPATCHED', 'ARRIVED') - AND ($1::timestamptz IS NULL OR ts.scheduled_departure_date >= $1) - AND ($2::timestamptz IS NULL OR ts.scheduled_departure_date < $2) - AND ($3::uuid[] IS NULL OR ts.route_id = ANY($3)) - AND ($4::text[] IS NULL OR ts.direction = ANY($4)) - AND ($5::uuid[] IS NULL OR ts.origin_station_id = ANY($5) OR ts.destination_station_id = ANY($5)) - GROUP BY 1, 2, 3 ORDER BY trips DESC`, - [f.dateFrom, f.dateTo, f.routeIds, f.directions, f.yardIds], - ); - return { - kpis: [ - { label: 'Trips', value: sum(rows, 'trips') }, - { label: 'Routes served', value: rows.length }, - { label: 'Tonnage hauled', value: sum(rows, 'tons_hauled'), unit: 't' }, - ], - rows, - }; -}; - -const invoicedVsCollected: ReportQuery = async (ds, f) => { - const rows = await ds.query( - `SELECT to_char(date_trunc($5, COALESCE(i.issued_at, i.created_at)), 'YYYY-MM-DD') AS period, - COUNT(*)::int AS invoices, - ROUND(SUM(i.total_amount))::float8 AS invoiced, - ROUND(SUM(i.paid_amount))::float8 AS collected, - ROUND(SUM(i.balance_amount))::float8 AS outstanding - FROM freight.invoices i - WHERE i.deleted_at IS NULL - AND i.status NOT IN ('DRAFT', 'CANCELLED') - AND ($1::timestamptz IS NULL OR COALESCE(i.issued_at, i.created_at) >= $1) - AND ($2::timestamptz IS NULL OR COALESCE(i.issued_at, i.created_at) < $2) - AND ($3::uuid[] IS NULL OR i.company_id = ANY($3)) - AND ${refDirScope('i.source_id', '$4')} - GROUP BY 1 ORDER BY 1`, - [f.dateFrom, f.dateTo, f.companyIds, f.directions, f.granularity], - ); - const invoiced = sum(rows, 'invoiced'); - const collected = sum(rows, 'collected'); - return { - kpis: [ - { label: 'Invoiced', value: invoiced, unit: 'ETB' }, - { label: 'Collected', value: collected, unit: 'ETB' }, - { - label: 'Collection rate', - value: invoiced > 0 ? Math.round((collected / invoiced) * 100) : 0, - unit: '%', - }, - { label: 'Outstanding', value: sum(rows, 'outstanding'), unit: 'ETB' }, - ], - rows, - }; -}; - -// Aging is an as-of snapshot: dateTo is the as-of moment (default now), -// dateFrom is ignored. -const agingReceivables: ReportQuery = async (ds, f) => { - const rows = await ds.query( - `SELECT c.name AS customer, - COUNT(*)::int AS invoices, - ROUND(SUM(i.balance_amount))::float8 AS outstanding, - ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at >= COALESCE($1::timestamptz, now())), 0))::float8 AS current, - ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < COALESCE($1::timestamptz, now()) - AND i.due_at >= COALESCE($1::timestamptz, now()) - interval '30 days'), 0))::float8 AS overdue_0_30, - ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < COALESCE($1::timestamptz, now()) - interval '30 days' - AND i.due_at >= COALESCE($1::timestamptz, now()) - interval '60 days'), 0))::float8 AS overdue_31_60, - ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < COALESCE($1::timestamptz, now()) - interval '60 days' - AND i.due_at >= COALESCE($1::timestamptz, now()) - interval '90 days'), 0))::float8 AS overdue_61_90, - ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < COALESCE($1::timestamptz, now()) - interval '90 days'), 0))::float8 AS overdue_90_plus - FROM freight.invoices i - JOIN freight.companies c ON c.id = i.company_id - WHERE i.deleted_at IS NULL - AND i.status IN ('ISSUED', 'PENDING', 'PARTIALLY_PAID', 'OVERDUE') - AND i.balance_amount > 0 - AND ($1::timestamptz IS NULL OR i.created_at < $1) - AND ($2::uuid[] IS NULL OR i.company_id = ANY($2)) - AND ${refDirScope('i.source_id', '$3')} - GROUP BY 1 ORDER BY outstanding DESC LIMIT 200`, - [f.dateTo, f.companyIds, f.directions], - ); - const outstanding = sum(rows, 'outstanding'); - return { - kpis: [ - { label: 'Outstanding', value: outstanding, unit: 'ETB' }, - { label: 'Overdue', value: outstanding - sum(rows, 'current'), unit: 'ETB' }, - { label: 'Customers with balance', value: rows.length }, - ], - rows, - }; -}; - -const revenueByPaymentMethod: ReportQuery = async (ds, f) => { - // payments.status values are lowercase-hyphenated ('success'), unlike every - // other status enum in the schema. No deleted_at on this table. - const rows = await ds.query( - `SELECT p.method::text AS method, - COUNT(*)::int AS payments, - ROUND(SUM(p.amount))::float8 AS amount - FROM freight.payments p - WHERE p.status = 'success' - AND ($1::timestamptz IS NULL OR p.created_at >= $1) - AND ($2::timestamptz IS NULL OR p.created_at < $2) - AND ${refDirScope('p.ref_id', '$3')} - GROUP BY 1 ORDER BY amount DESC`, - [f.dateFrom, f.dateTo, f.directions], - ); - const total = sum(rows, 'amount'); - return { - kpis: [ - { label: 'Collected', value: total, unit: 'ETB' }, - { label: 'Payments', value: sum(rows, 'payments') }, - { - label: 'Top method share', - value: total > 0 ? Math.round((num(rows[0]?.amount) / total) * 100) : 0, - unit: '%', - }, - ], - rows, - }; -}; - -// --------------------------------------------------------------------------- -// Record-level list exports. Same engine, raw rows instead of aggregates. -// ponytail: flat LIMIT 5000 per list — stream/paginate the export if a table -// ever outgrows that. -const LIST_LIMIT = 5000; - -const bookingsList: ReportQuery = async (ds, f) => { - const { where, params } = bookingWhere(f); - const rows = await ds.query( - `SELECT b.reference, - to_char(b.created_at, 'YYYY-MM-DD') AS created, - c.name AS customer, b.status, b.freight_type, - b.trade_direction AS direction, - o.label AS origin, d.label AS destination, - COALESCE(cty.cargo_type_name, b.cargo_free_text) AS cargo, - ROUND(${TONS})::float8 AS tons, - ROUND(${REVENUE})::float8 AS amount, - b.payment_status, b.scheduling_status - FROM freight.bookings b - JOIN freight.companies c ON c.id = b.company_id - JOIN freight.yards o ON o.id = b.origin_yard_id - JOIN freight.yards d ON d.id = b.destination_yard_id - LEFT JOIN freight.cargo_types cty ON cty.id = b.cargo_type_id - WHERE ${where} - ORDER BY b.created_at DESC LIMIT ${LIST_LIMIT}`, - params, - ); - return { - kpis: [ - { label: 'Bookings', value: rows.length }, - { label: 'Tonnage', value: sum(rows, 'tons'), unit: 't' }, - { label: 'Amount', value: sum(rows, 'amount'), unit: 'ETB' }, - ], - rows, - }; -}; - -const contractsList: ReportQuery = async (ds, f) => { - const rows = await ds.query( - `SELECT ct.reference, c.name AS customer, ct.contract_kind AS kind, - ct.status, ct.trade_direction AS direction, ct.freight_type, - to_char(ct.contract_valid_from, 'YYYY-MM-DD') AS valid_from, - to_char(ct.contract_valid_until, 'YYYY-MM-DD') AS valid_until, - to_char(ct.created_at, 'YYYY-MM-DD') AS created - FROM freight.contracts ct - LEFT JOIN freight.companies c ON c.id = ct.company_id - WHERE ct.deleted_at IS NULL - AND ($1::timestamptz IS NULL OR ct.created_at >= $1) - AND ($2::timestamptz IS NULL OR ct.created_at < $2) - AND ($3::uuid[] IS NULL OR ct.company_id = ANY($3)) - AND ($4::text[] IS NULL OR ct.trade_direction = ANY($4)) - AND ($5::text[] IS NULL OR ct.status = ANY($5)) - ORDER BY ct.created_at DESC LIMIT ${LIST_LIMIT}`, - [f.dateFrom, f.dateTo, f.companyIds, f.directions, f.statuses], - ); - const active = rows.filter((r: Record) => - ['CONTRACT_ACTIVE', 'ACTIVE_SHIPMENT_IN_PROGRESS'].includes(String(r.status)), - ).length; - return { - kpis: [ - { label: 'Contracts', value: rows.length }, - { label: 'Active', value: active }, - ], - rows, - }; -}; - -const schedulesList: ReportQuery = async (ds, f) => { - const rows = await ds.query( - `SELECT ts.train_number, ts.reference, ts.direction, ts.status, - o.label AS origin, d.label AS destination, - to_char(ts.scheduled_departure_date, 'YYYY-MM-DD HH24:MI') AS scheduled_departure, - to_char(ts.actual_departure_at, 'YYYY-MM-DD HH24:MI') AS actual_departure, - to_char(ts.scheduled_arrival_date, 'YYYY-MM-DD HH24:MI') AS scheduled_arrival, - to_char(ts.actual_arrival_at, 'YYYY-MM-DD HH24:MI') AS actual_arrival, - ts.max_wagons, tset.wagon_count - FROM freight.train_schedules ts - JOIN freight.yards o ON o.id = ts.origin_station_id - JOIN freight.yards d ON d.id = ts.destination_station_id - LEFT JOIN freight.train_sets tset ON tset.id = ts.train_set_id - WHERE ts.deleted_at IS NULL - AND ($1::timestamptz IS NULL OR ts.scheduled_departure_date >= $1) - AND ($2::timestamptz IS NULL OR ts.scheduled_departure_date < $2) - AND ($3::text[] IS NULL OR ts.direction = ANY($3)) - AND ($4::text[] IS NULL OR ts.status = ANY($4)) - AND ($5::uuid[] IS NULL OR ts.origin_station_id = ANY($5) OR ts.destination_station_id = ANY($5)) - ORDER BY ts.scheduled_departure_date DESC LIMIT ${LIST_LIMIT}`, - [f.dateFrom, f.dateTo, f.directions, f.statuses, f.yardIds], - ); - const count = (s: string) => - rows.filter((r: Record) => r.status === s).length; - return { - kpis: [ - { label: 'Schedules', value: rows.length }, - { label: 'Dispatched', value: count('DISPATCHED') }, - { label: 'Arrived', value: count('ARRIVED') }, - ], - rows, - }; -}; - -const fleetWagons: ReportQuery = async (ds, f) => { - const rows = await ds.query( - `SELECT w.wagon_number, wt.name AS type, - wt.capacity_tons::float8 AS capacity_tons, - w.status, y.label AS current_yard - FROM freight.wagons w - JOIN freight.wagon_types wt ON wt.id = w.wagon_type_id - LEFT JOIN freight.yards y ON y.id = w.current_yard_id - WHERE w.deleted_at IS NULL - AND ($1::text[] IS NULL OR w.status = ANY($1)) - AND ($2::uuid[] IS NULL OR w.current_yard_id = ANY($2)) - ORDER BY w.wagon_number LIMIT ${LIST_LIMIT}`, - [f.statuses, f.yardIds], - ); - const count = (s: string) => - rows.filter((r: Record) => r.status === s).length; - return { - kpis: [ - { label: 'Wagons', value: rows.length }, - { label: 'Available', value: count('AVAILABLE') }, - { label: 'Assigned', value: count('ASSIGNED') }, - { label: 'Maintenance', value: count('MAINTENANCE') }, - ], - rows, - }; -}; - -const fleetLocomotives: ReportQuery = async (ds, f) => { - const rows = await ds.query( - `SELECT l.code, l.name, l.locomotive_type, - l.max_pull_weight_tons::float8 AS max_pull_tons, - l.status, y.label AS current_yard - FROM freight.locomotives l - LEFT JOIN freight.yards y ON y.id = l.current_yard_id - WHERE l.deleted_at IS NULL - AND ($1::text[] IS NULL OR l.status = ANY($1)) - AND ($2::uuid[] IS NULL OR l.current_yard_id = ANY($2)) - ORDER BY l.code LIMIT ${LIST_LIMIT}`, - [f.statuses, f.yardIds], - ); - const available = rows.filter( - (r: Record) => r.status === 'AVAILABLE', - ).length; - return { - kpis: [ - { label: 'Locomotives', value: rows.length }, - { label: 'Available', value: available }, - ], - rows, - }; -}; - -const customersList: ReportQuery = async (ds, f) => { - const rows = await ds.query( - `SELECT c.name, c.type, c.kind, c.status, c.tin, - to_char(c.approved_at, 'YYYY-MM-DD') AS approved, - to_char(c.created_at, 'YYYY-MM-DD') AS created - FROM freight.companies c - WHERE c.deleted_at IS NULL - AND ($1::timestamptz IS NULL OR c.created_at >= $1) - AND ($2::timestamptz IS NULL OR c.created_at < $2) - AND ($3::text[] IS NULL OR c.status = ANY($3)) - ORDER BY c.created_at DESC LIMIT ${LIST_LIMIT}`, - [f.dateFrom, f.dateTo, f.statuses], - ); - const active = rows.filter( - (r: Record) => r.status === 'active', - ).length; - return { - kpis: [ - { label: 'Customers', value: rows.length }, - { label: 'Active', value: active }, - ], - rows, - }; -}; - -const paymentsList: ReportQuery = async (ds, f) => { - // No deleted_at on freight.payments; statuses are lowercase-hyphenated. - const rows = await ds.query( - `SELECT to_char(p.created_at, 'YYYY-MM-DD HH24:MI') AS created, - p.method::text AS method, p.status::text AS status, - p.currency::text AS currency, - ROUND(p.amount)::float8 AS amount, - p.transaction_id, p.merchant_order_id, - to_char(p.paid_at, 'YYYY-MM-DD') AS paid - FROM freight.payments p - WHERE ($1::timestamptz IS NULL OR p.created_at >= $1) - AND ($2::timestamptz IS NULL OR p.created_at < $2) - AND ($3::text[] IS NULL OR p.status::text = ANY($3)) - AND ${refDirScope('p.ref_id', '$4')} - ORDER BY p.created_at DESC LIMIT ${LIST_LIMIT}`, - [f.dateFrom, f.dateTo, f.statuses, f.directions], - ); - const success = rows.filter( - (r: Record) => r.status === 'success', - ); - return { - kpis: [ - { label: 'Payments', value: rows.length }, - { label: 'Successful', value: success.length }, - { label: 'Collected', value: sum(success, 'amount'), unit: 'ETB' }, - ], - rows, - }; -}; - -export const REPORT_QUERIES: Record = { - 'bookings-list': bookingsList, - 'contracts-list': contractsList, - 'schedules-list': schedulesList, - 'fleet-wagons': fleetWagons, - 'fleet-locomotives': fleetLocomotives, - 'customers-list': customersList, - 'payments-list': paymentsList, - 'bookings-trend': bookingsTrend, - 'revenue-by-customer': revenueByCustomer, - 'revenue-by-lane': revenueByLane, - 'contract-utilization': contractUtilization, - 'train-on-time': trainOnTime, - 'schedule-fill-rate': scheduleFillRate, - 'trips-per-route': tripsPerRoute, - 'invoiced-vs-collected': invoicedVsCollected, - 'aging-receivables': agingReceivables, - 'revenue-by-payment-method': revenueByPaymentMethod, -}; diff --git a/apps/edr-freight-api/src/modules/reports/report-runner.service.ts b/apps/edr-freight-api/src/modules/reports/report-runner.service.ts new file mode 100644 index 000000000..a9b662077 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/report-runner.service.ts @@ -0,0 +1,152 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; + +import { + buildPaginationMeta, + normalizePagination, +} from '../../common/utils/pagination.util'; +import { applyBookingRefDirectionScope } from '../user-trade-access/trade-scope.util'; +import { ReportDefinition, ReportRunResult } from './report.types'; + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** Raw query params, minus the pagination/sort keys the runner owns. */ +export type RawReportQuery = Record; + +/** + * Coerce raw query strings into typed filter params per the report's own + * filter declarations. Unknown filter keys are ignored — `forbidNonWhitelisted` + * can't police a per-report bag, so extras are just dropped, not rejected. + */ +function coerceParams( + def: ReportDefinition, + raw: RawReportQuery, +): Record { + const params: Record = {}; + for (const filter of def.filters) { + if (filter.type === 'daterange') { + const from = raw[`${filter.key}From`]; + const to = raw[`${filter.key}To`]; + params[`${filter.key}From`] = from ? new Date(from).toISOString() : null; + // Inclusive end date, exclusive bound in SQL. + params[`${filter.key}To`] = to + ? new Date(new Date(to).getTime() + DAY_MS).toISOString() + : null; + } else if (filter.type === 'multiselect') { + const csv = raw[filter.key]; + const items = csv?.split(',').map((s) => s.trim()).filter(Boolean) ?? []; + params[filter.key] = items.length ? items : null; + } else { + params[filter.key] = raw[filter.key]?.trim() || null; + } + } + // idKey, when the report declares one, is a plain string param. + if (def.idKey) { + params[def.idKey.key] = raw[def.idKey.key]?.trim() || null; + } + return params; +} + +/** + * Sort expression for a column with no explicit `sortExpr`: the SELECT alias + * TypeORM emitted for it, quoted. TypeORM always double-quotes `addSelect` + * aliases in the generated SQL (preserving case) — ordering by the bare, + * unquoted key instead lets Postgres fold it to lowercase and 42703 on any + * camelCase alias (e.g. "utilizationPct" -> unquoted "utilizationpct"). + */ +const aliasSortExpr = (key: string): string => `"${key.replace(/"/g, '""')}"`; + +/** Resolve a client-requested sort column against the report's own whitelist. */ +function resolveSort( + def: ReportDefinition, + sortBy?: string, + sortOrder?: string, +): { key: string; expr: string; dir: 'ASC' | 'DESC' } | null { + const dir = sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC'; + const requested = sortBy && def.columns.find((c) => c.key === sortBy && c.sortable); + if (requested) { + return { key: requested.key, expr: requested.sortExpr ?? aliasSortExpr(requested.key), dir }; + } + if (!def.defaultSort) return null; + const fallback = def.columns.find((c) => c.key === def.defaultSort!.key); + if (!fallback) return null; + return { + key: fallback.key, + expr: fallback.sortExpr ?? aliasSortExpr(fallback.key), + dir: def.defaultSort.dir, + }; +} + +@Injectable() +export class ReportRunnerService { + constructor(@InjectDataSource() private readonly ds: DataSource) {} + + async run( + def: ReportDefinition, + raw: RawReportQuery, + directions: string[] | null, + ): Promise { + const params = coerceParams(def, raw); + const ctx = { ds: this.ds, params, directions }; + + const qb = def.query(ctx); + const sort = resolveSort(def, raw.sortBy, raw.sortOrder); + if (sort) qb.orderBy(sort.expr, sort.dir); + + const { page: pageNum, pageSize, skip, take } = normalizePagination({ + page: raw.page ? Number(raw.page) : undefined, + pageSize: raw.pageSize ? Number(raw.pageSize) : undefined, + }); + + const [sql, sqlParams] = qb.getQueryAndParameters(); + // getCount() re-derives its own (wrong) select list for GROUP BY queries — + // wrapping the real query as a subquery counts exactly what will be paged. + const countRow = await this.ds.query( + `SELECT COUNT(*)::int AS c FROM (${sql}) report_count`, + sqlParams, + ); + const total = Number(countRow[0]?.c ?? 0); + + // .offset()/.limit(), not .skip()/.take() — skip/take route raw & grouped + // selects through TypeORM's DISTINCT-id subquery path, which is wrong here. + const items = await qb.offset(skip).limit(take).getRawMany(); + + const kpis = def.summary ? await def.summary(ctx) : []; + + return { + columns: def.columns, + items, + meta: buildPaginationMeta(total, pageNum, pageSize), + kpis, + }; + } + + /** Same query, no paging — used by the export path. */ + async runAll( + def: ReportDefinition, + raw: RawReportQuery, + directions: string[] | null, + limit: number, + ): Promise<{ columns: typeof def.columns; items: Record[]; kpis: ReportRunResult['kpis'] }> { + const params = coerceParams(def, raw); + const ctx = { ds: this.ds, params, directions }; + const qb = def.query(ctx); + // Same sort the on-screen table is using, not always the default — an + // export is supposed to match what the user is looking at. + const sort = resolveSort(def, raw.sortBy, raw.sortOrder); + if (sort) qb.orderBy(sort.expr, sort.dir); + const items = await qb.limit(limit).getRawMany(); + if (items.length >= limit) { + throw new BadRequestException( + `Export exceeds the ${limit}-row cap for this format. Narrow the filters.`, + ); + } + const kpis = def.summary ? await def.summary(ctx) : []; + return { columns: def.columns, items, kpis }; + } +} + +// Re-exported so definitions can scope ACL columns without importing the +// trade-scope module directly. +export { applyBookingRefDirectionScope }; diff --git a/apps/edr-freight-api/src/modules/reports/report.registry.spec.ts b/apps/edr-freight-api/src/modules/reports/report.registry.spec.ts new file mode 100644 index 000000000..ccec53c3d --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/report.registry.spec.ts @@ -0,0 +1,52 @@ +import { REPORT_KEYS } from '../../seed/freight-permissions.registry'; +import { REPORTS, getReport } from './report.registry'; + +describe('REPORTS', () => { + it('has exactly one definition per seeded REPORT_KEYS entry', () => { + const defKeys = REPORTS.map((r) => r.key).sort(); + expect(defKeys).toEqual([...REPORT_KEYS].sort()); + }); + + it('has no duplicate keys', () => { + const keys = REPORTS.map((r) => r.key); + expect(new Set(keys).size).toBe(keys.length); + }); + + it('resolves every key via getReport', () => { + for (const key of REPORT_KEYS) { + expect(getReport(key)?.key).toBe(key); + } + }); + + it('every sortable column and defaultSort point at a real column key', () => { + for (const def of REPORTS) { + const columnKeys = new Set(def.columns.map((c) => c.key)); + if (def.defaultSort) { + expect(columnKeys.has(def.defaultSort.key)).toBe(true); + } + // Every column marked sortable must have a resolvable key (itself, since + // the runner falls back to `key` when `sortExpr` is absent). + for (const col of def.columns.filter((c) => c.sortable)) { + expect(col.key.length).toBeGreaterThan(0); + } + } + }); + + it('idKey, when declared, is not also listed as a user-facing filter', () => { + for (const def of REPORTS) { + if (!def.idKey) continue; + expect(def.filters.some((f) => f.key === def.idKey!.key)).toBe(false); + } + }); + + it('chart.x and chart.y, when declared, point at real column keys', () => { + for (const def of REPORTS) { + if (!def.chart) continue; + const columnKeys = new Set(def.columns.map((c) => c.key)); + expect(columnKeys.has(def.chart.x)).toBe(true); + for (const y of def.chart.y) { + expect(columnKeys.has(y)).toBe(true); + } + } + }); +}); diff --git a/apps/edr-freight-api/src/modules/reports/report.registry.ts b/apps/edr-freight-api/src/modules/reports/report.registry.ts new file mode 100644 index 000000000..004b61e5b --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/report.registry.ts @@ -0,0 +1,62 @@ +import { ReportKey } from '../../seed/freight-permissions.registry'; +import { bookingsListReport } from './definitions/bookings-list.report'; +import { revenueByCustomerReport } from './definitions/revenue-by-customer.report'; +import { agingReceivablesReport } from './definitions/aging-receivables.report'; +import { contractUtilizationReport } from './definitions/contract-utilization.report'; +import { wagonFleetStatusReport } from './definitions/wagon-fleet-status.report'; +import { wagonStatusDurationReport } from './definitions/wagon-status-duration.report'; +import { wagonRequestsReport } from './definitions/wagon-requests.report'; +import { locomotiveFleetStatusReport } from './definitions/locomotive-fleet-status.report'; +import { bookingStatusBreakdownReport } from './definitions/booking-status-breakdown.report'; +import { trainScheduleStatusReport } from './definitions/train-schedule-status.report'; +import { trainTurnaroundReport } from './definitions/train-turnaround.report'; +import { wagonTeuUtilizationReport } from './definitions/wagon-teu-utilization.report'; +import { loadedCapacityReport } from './definitions/loaded-capacity.report'; +import { globalLogisticsWagonsReport } from './definitions/global-logistics-wagons.report'; +import { customerStatusReport } from './definitions/customer-status.report'; +import { contractLifecycleReport } from './definitions/contract-lifecycle.report'; +import { customsDocumentsReport } from './definitions/customs-documents.report'; +import { invoicingPipelineReport } from './definitions/invoicing-pipeline.report'; +import { firstLastMileBookingsReport } from './definitions/first-last-mile-bookings.report'; +import { invoicesByStatusReport } from './definitions/invoices-by-status.report'; +import { paymentsByStatusReport } from './definitions/payments-by-status.report'; +import { revenueSummaryReport } from './definitions/revenue-summary.report'; +import { cargoSummaryReport } from './definitions/cargo-summary.report'; +import { ReportDefinition } from './report.types'; + +/** + * Every report the platform knows about. Adding one = a new file under + * definitions/ + a key in REPORT_KEYS (freight-permissions.registry.ts) + + * an entry here. Nothing else — no frontend edit, no route, no sidebar edit. + */ +export const REPORTS: ReportDefinition[] = [ + bookingsListReport, + revenueByCustomerReport, + agingReceivablesReport, + contractUtilizationReport, + wagonFleetStatusReport, + wagonStatusDurationReport, + wagonRequestsReport, + locomotiveFleetStatusReport, + bookingStatusBreakdownReport, + trainScheduleStatusReport, + trainTurnaroundReport, + wagonTeuUtilizationReport, + loadedCapacityReport, + globalLogisticsWagonsReport, + customerStatusReport, + contractLifecycleReport, + customsDocumentsReport, + invoicingPipelineReport, + firstLastMileBookingsReport, + invoicesByStatusReport, + paymentsByStatusReport, + revenueSummaryReport, + cargoSummaryReport, +]; + +const BY_KEY = new Map(REPORTS.map((r) => [r.key, r])); + +export function getReport(key: string): ReportDefinition | undefined { + return BY_KEY.get(key as ReportKey); +} diff --git a/apps/edr-freight-api/src/modules/reports/report.types.ts b/apps/edr-freight-api/src/modules/reports/report.types.ts new file mode 100644 index 000000000..a709ac654 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/report.types.ts @@ -0,0 +1,112 @@ +import { DataSource, ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { ReportKey } from '../../seed/freight-permissions.registry'; + +export type { ReportKey }; + +export type ReportColumnType = + | 'string' + | 'number' + | 'money' + | 'tons' + | 'percent' + | 'date'; + +export interface ReportColumn { + key: string; + label: string; + type: ReportColumnType; + sortable?: boolean; + /** SQL to ORDER BY when this column is sorted, if different from `key`. */ + sortExpr?: string; +} + +export type ReportFilterType = 'daterange' | 'date' | 'select' | 'multiselect' | 'text'; + +export interface ReportFilterOption { + value: string; + label: string; +} + +export interface ReportFilterDef { + key: string; + label: string; + type: ReportFilterType; + /** Static option list for select/multiselect. */ + options?: ReportFilterOption[]; +} + +export interface ReportKpi { + label: string; + value: number; + unit?: string; +} + +export type ReportChartType = 'line' | 'bar'; + +/** + * Plots the SAME rows the table gets — no separate query. `x` and `y` are + * column keys from `columns`. A report whose group-by has dimensions beyond + * `x` will render one mark per row (e.g. two rows sharing a date because they + * differ by direction), which is a busier chart, not a wrong one. Pivoting + * rows into one-per-x series is a later add if a report actually needs it. + */ +export interface ReportChartDef { + type: ReportChartType; + x: string; + y: string[]; +} + +/** + * Optional entity scope a report can be embedded against — e.g. a + * contract-utilization report shown on a single contract's detail page. + * Purely descriptive; `query()` reads the resolved value off `ctx.params` + * like any other filter. + */ +export interface ReportIdKey { + key: string; + label: string; +} + +export interface ReportContext { + ds: DataSource; + /** Filter values, already coerced against `def.filters` (CSV → array, etc). */ + params: Record; + /** Trade-scope-resolved directions. null = unrestricted, [] = show nothing. */ + directions: string[] | null; +} + +export interface ReportDefinition { + key: ReportKey; + title: string; + description: string; + group: 'Commercial' | 'Operations' | 'Finance'; + idKey?: ReportIdKey; + filters: ReportFilterDef[]; + columns: ReportColumn[]; + defaultSort?: { key: string; dir: 'ASC' | 'DESC' }; + query(ctx: ReportContext): SelectQueryBuilder; + /** KPIs over the same filtered set; shown above the table and in exports. */ + summary?(ctx: ReportContext): Promise; + /** Optional chart view of the same rows. Table remains the default view. */ + chart?: ReportChartDef; +} + +/** Catalog shape served by GET /reports — metadata only, no rows. */ +export type ReportCatalogEntry = Omit & { + hasSummary: boolean; +}; + +export interface ReportRunResult { + columns: ReportColumn[]; + items: Record[]; + meta: { + page: number; + pageSize: number; + total: number; + totalPages: number; + hasNextPage: boolean; + hasPreviousPage: boolean; + }; + kpis: ReportKpi[]; +} diff --git a/apps/edr-freight-api/src/modules/reports/reports.controller.ts b/apps/edr-freight-api/src/modules/reports/reports.controller.ts index dc64773d8..4d213bc2b 100644 --- a/apps/edr-freight-api/src/modules/reports/reports.controller.ts +++ b/apps/edr-freight-api/src/modules/reports/reports.controller.ts @@ -1,34 +1,92 @@ -import { Controller, Get, Param, Query } from '@nestjs/common'; -import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { Controller, Get, NotFoundException, Param, Query, Res } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { CurrentUser } from '@edr/api-common'; +import type { Response } from 'express'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; import { BookingStaff } from '../../common/booking-guards'; -import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; +import { assertFreightPermission, hasFreightPermission } from '../../common/freight-permission.util'; +import { FREIGHT_PERMS, reportPermissionKey } from '../../seed/freight-permissions.registry'; import { UserTradeAccessService } from '../user-trade-access/user-trade-access.service'; -import { ReportQueryDto } from './dto/report-query.dto'; -import { ReportResultDto } from './dto/report-result.dto'; -import { ReportsService } from './reports.service'; +import { ReportExportService } from './report-export.service'; +import { resolveExportCap, resolveExportColumns, resolveExportFormat } from './report-export-request.util'; +import { RawReportQuery, ReportRunnerService } from './report-runner.service'; +import { REPORTS, getReport } from './report.registry'; +import { ReportCatalogEntry, ReportDefinition } from './report.types'; + +const toCatalogEntry = (def: ReportDefinition): ReportCatalogEntry => { + const { query: _query, summary, ...meta } = def; + return { ...meta, hasSummary: Boolean(summary) }; +}; @ApiTags('Reports') @ApiBearerAuth() @Controller('reports') +@BookingStaff(FREIGHT_PERMS.reports.view) export class ReportsController { constructor( - private readonly reportsService: ReportsService, + private readonly runner: ReportRunnerService, + private readonly exportService: ReportExportService, private readonly userTradeAccessService: UserTradeAccessService, ) {} + @Get() + @ApiOperation({ summary: 'List reports the caller has permission to run' }) + async catalog(@CurrentUser() user: TCurrentUser): Promise { + return REPORTS.filter((def) => hasFreightPermission(user, reportPermissionKey(def.key))).map( + toCatalogEntry, + ); + } + @Get(':key') - @BookingStaff(FREIGHT_PERMS.reports.view) - @ApiOperation({ summary: 'Run a canned report by key with optional filters' }) - @ApiOkResponse({ type: ReportResultDto }) + @ApiOperation({ summary: 'Run a report by key, paginated/sorted/filtered' }) async run( @Param('key') key: string, - @Query() query: ReportQueryDto, + @Query() query: RawReportQuery, @CurrentUser() user: TCurrentUser, - ): Promise { - const allowed = await this.userTradeAccessService.resolveAllowedDirections(user); - return this.reportsService.run(key, query, allowed); + ) { + const def = this.resolve(key, user); + const directions = await this.userTradeAccessService.resolveAllowedDirections(user); + return this.runner.run(def, query, directions); + } + + @Get(':key/export') + @ApiOperation({ summary: 'Export a report to xlsx or pdf' }) + async export( + @Param('key') key: string, + @Query() query: RawReportQuery & { format?: string; fields?: string; limit?: string }, + @CurrentUser() user: TCurrentUser, + @Res() res: Response, + ): Promise { + const def = this.resolve(key, user); + const directions = await this.userTradeAccessService.resolveAllowedDirections(user); + const format = resolveExportFormat(query.format); + const cap = resolveExportCap(format, query.limit); + const exportColumns = resolveExportColumns(def, query.fields); + + const { items, kpis } = await this.runner.runAll(def, query, directions, cap); + const buffer = + format === 'pdf' + ? await this.exportService.toPdf(def, items, kpis, exportColumns) + : await this.exportService.toXlsx(def, items, kpis, exportColumns); + + const filename = `${def.key}.${format === 'pdf' ? 'pdf' : 'xlsx'}`; + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + res.setHeader( + 'Content-Type', + format === 'pdf' + ? 'application/pdf' + : 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ); + res.send(buffer); + } + + private resolve(key: string, user: TCurrentUser): ReportDefinition { + const def = getReport(key); + if (!def) throw new NotFoundException(`Unknown report: ${key}`); + // Exact-match on purpose — unlike FreightPermissionGuard's :view/:read + // fallback, a report's own key is the only thing that opens it. + assertFreightPermission(user, reportPermissionKey(def.key)); + return def; } } diff --git a/apps/edr-freight-api/src/modules/reports/reports.module.ts b/apps/edr-freight-api/src/modules/reports/reports.module.ts index a7fe792a5..2f98e9e04 100644 --- a/apps/edr-freight-api/src/modules/reports/reports.module.ts +++ b/apps/edr-freight-api/src/modules/reports/reports.module.ts @@ -1,13 +1,14 @@ import { Module } from '@nestjs/common'; +import { DocumentsModule } from '../billing/documents/documents.module'; import { UserTradeAccessModule } from '../user-trade-access/user-trade-access.module'; +import { ReportExportService } from './report-export.service'; +import { ReportRunnerService } from './report-runner.service'; import { ReportsController } from './reports.controller'; -import { ReportsRepository } from './reports.repository'; -import { ReportsService } from './reports.service'; @Module({ - imports: [UserTradeAccessModule], + imports: [UserTradeAccessModule, DocumentsModule], controllers: [ReportsController], - providers: [ReportsService, ReportsRepository], + providers: [ReportRunnerService, ReportExportService], }) export class ReportsModule {} diff --git a/apps/edr-freight-api/src/modules/reports/reports.repository.ts b/apps/edr-freight-api/src/modules/reports/reports.repository.ts deleted file mode 100644 index 65f154b22..000000000 --- a/apps/edr-freight-api/src/modules/reports/reports.repository.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { InjectDataSource } from '@nestjs/typeorm'; -import { DataSource } from 'typeorm'; - -import { REPORT_QUERIES, ReportFilters, ReportResult } from './report-queries'; - -@Injectable() -export class ReportsRepository { - constructor(@InjectDataSource() private readonly dataSource: DataSource) {} - - run(key: keyof typeof REPORT_QUERIES, filters: ReportFilters): Promise { - return REPORT_QUERIES[key](this.dataSource, filters); - } -} diff --git a/apps/edr-freight-api/src/modules/reports/reports.service.ts b/apps/edr-freight-api/src/modules/reports/reports.service.ts deleted file mode 100644 index 04e6e9a60..000000000 --- a/apps/edr-freight-api/src/modules/reports/reports.service.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; - -import { scopedDirections } from '../user-trade-access/trade-scope.util'; -import { ReportQueryDto } from './dto/report-query.dto'; -import { REPORT_QUERIES, ReportFilters, ReportResult } from './report-queries'; -import { ReportsRepository } from './reports.repository'; -import type { Freight } from '@edr/types'; - -const DAY_MS = 24 * 60 * 60 * 1000; - -const list = (csv?: string): string[] | null => { - const items = csv?.split(',').map((s) => s.trim()).filter(Boolean) ?? []; - return items.length ? items : null; -}; - -@Injectable() -export class ReportsService { - constructor(private readonly repository: ReportsRepository) {} - - run( - key: string, - dto: ReportQueryDto, - allowedDirections: Freight.ScheduleTradeDirection[] | null, - ): Promise { - if (!(key in REPORT_QUERIES)) { - throw new NotFoundException(`Unknown report: ${key}`); - } - // No default range: absent dates mean all time, so exports cover everything. - const to = dto.dateTo ? new Date(dto.dateTo) : null; - const from = dto.dateFrom ? new Date(dto.dateFrom) : null; - const filters: ReportFilters = { - dateFrom: from ? from.toISOString() : null, - // dateTo is inclusive in the API; queries treat the bound as exclusive. - dateTo: to ? new Date(to.getTime() + DAY_MS).toISOString() : null, - granularity: dto.granularity ?? 'day', - companyIds: list(dto.companyIds), - routeIds: list(dto.routeIds), - yardIds: list(dto.yardIds), - cargoTypeIds: list(dto.cargoTypeIds), - statuses: list(dto.statuses), - directions: scopedDirections(allowedDirections, dto.direction), - freightType: dto.freightType ?? null, - }; - return this.repository.run(key, filters); - } -} diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts index eccb14017..73ae42a0d 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts @@ -75,6 +75,14 @@ export class CreateRateDto { @IsUUID() destinationYardId?: string; + @ApiPropertyOptional({ + description: + 'FK to shipping_line_companies.id — set to price this rate for one shipping line only. Omitted/null = the standard rate every customer pays. A line rate overrides the standard one for that line\'s bookings.', + }) + @IsOptional() + @IsUUID() + shippingLineCompanyId?: string; + @ApiPropertyOptional({ enum: CURRENCIES }) @IsOptional() @IsIn([...CURRENCIES]) diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/list-rule-engine-query.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/list-rule-engine-query.dto.ts index d22bd84a7..774037e36 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/list-rule-engine-query.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/list-rule-engine-query.dto.ts @@ -141,6 +141,22 @@ export class ListRatesQueryDto extends PaginationQueryDto { @IsString() @MaxLength(200) trigger?: string; + + @ApiPropertyOptional({ + description: 'Filter to one shipping line\'s rates.', + }) + @IsOptional() + @IsUUID() + shippingLineCompanyId?: string; + + @ApiPropertyOptional({ + description: + 'true = only shipping-line rates (any line), false = only standard customer rates. Omitted = both. Powers the Shipping line tab.', + }) + @IsOptional() + @Transform(toOptionalBoolean) + @IsBoolean() + isShippingLineRate?: boolean; } export class ListWeightLimitRulesQueryDto extends PaginationQueryDto { diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts index 925a3555a..c60d03d31 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts @@ -1,5 +1,6 @@ import { BaseEntity } from '@edr/api-common'; import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; +import { ShippingLineCompany } from '../../shipping-lines/entities/shipping-line-company.entity'; import { CargoType } from './cargo-type.entity'; import { ContainerType } from './container-type.entity'; import { Yard } from './yard.entity'; @@ -108,6 +109,7 @@ export type RateTrigger = typeof RATE_TRIGGERS[number]; @Index(['trigger']) @Index(['originYardId']) @Index(['destinationYardId']) +@Index(['shippingLineCompanyId']) export class Rate extends BaseEntity { @Column({ name: 'rate_type', type: 'varchar', length: 50 }) rateType!: RateType; @@ -155,6 +157,23 @@ export class Rate extends BaseEntity { @JoinColumn({ name: 'destination_yard_id' }) destinationYard?: Yard | null; + /** + * The shipping line this rate belongs to, or NULL for the standard rate every + * customer pays. A booking owned by a shipping line prices exclusively off + * that line's rates — the standard rate is NOT a fallback, so a missing line + * rate hard-blocks the booking rather than quietly billing the customer price. + * + * Points at `shipping_line_companies` (the portal account that books capacity), + * not `shipping_lines` (carrier reference data behind the SHIPPING_LINE + * trigger). The two are unrelated despite the similar names. + */ + @Column({ name: 'shipping_line_company_id', type: 'uuid', nullable: true }) + shippingLineCompanyId?: string | null; + + @ManyToOne(() => ShippingLineCompany, { nullable: true, eager: false }) + @JoinColumn({ name: 'shipping_line_company_id' }) + shippingLineCompany?: ShippingLineCompany | null; + @Column({ name: 'currency', type: 'varchar', length: 5 }) currency!: string; diff --git a/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts b/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts index 9797ff715..ba2509068 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts @@ -16,6 +16,8 @@ export interface IRatesRepository { rateType: string; /** Omitted for singly-resolved rates — see the repository implementation. */ rateUnit?: string; + /** Owning shipping line; null/omitted = the standard customer rate. */ + shippingLineCompanyId?: string | null; containerTypeId?: string | null; cargoTypeId?: string | null; tradeDirection?: string | null; diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts index 7417987dc..8a739095e 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts @@ -69,6 +69,7 @@ export class RatesRepository implements IRatesRepository { findByPattern(pattern: { rateType: string; rateUnit?: string; + shippingLineCompanyId?: string | null; containerTypeId?: string | null; cargoTypeId?: string | null; tradeDirection?: string | null; @@ -85,6 +86,16 @@ export class RatesRepository implements IRatesRepository { qb.andWhere('rate.rate_unit = :rateUnit', { rateUnit: pattern.rateUnit }); } + // The owner is part of the identity: a line's rate for a lane is a + // different rate from the standard one, not a duplicate of it. + if (pattern.shippingLineCompanyId) { + qb.andWhere('rate.shipping_line_company_id = :shippingLineCompanyId', { + shippingLineCompanyId: pattern.shippingLineCompanyId, + }); + } else { + qb.andWhere('rate.shipping_line_company_id IS NULL'); + } + if (pattern.containerTypeId) { qb.andWhere('rate.container_type_id = :containerTypeId', { containerTypeId: pattern.containerTypeId }); } else { @@ -139,8 +150,22 @@ export class RatesRepository implements IRatesRepository { // yards joined the route columns have only ids to render. .leftJoinAndSelect('rate.originYard', 'originYard') .leftJoinAndSelect('rate.destinationYard', 'destinationYard') + // The shipping-line tab renders the owning line's name, not its id. + .leftJoinAndSelect('rate.shippingLineCompany', 'shippingLineCompany') .orderBy('rate.createdAt', query.sortOrder ?? 'DESC'); + if (query.shippingLineCompanyId) { + qb.andWhere('rate.shippingLineCompanyId = :shippingLineCompanyId', { + shippingLineCompanyId: query.shippingLineCompanyId, + }); + } else if (query.isShippingLineRate !== undefined) { + // Tab filter: shipping-line rates (any line) vs standard customer rates. + qb.andWhere( + query.isShippingLineRate + ? 'rate.shippingLineCompanyId IS NOT NULL' + : 'rate.shippingLineCompanyId IS NULL', + ); + } if (query.status) { qb.andWhere('rate.status = :status', { status: query.status }); } @@ -164,7 +189,7 @@ export class RatesRepository implements IRatesRepository { } if (query.search) { qb.andWhere( - '(rate.rateType ILIKE :search OR rate.status ILIKE :search OR rate.rateUnit ILIKE :search OR rate.currency ILIKE :search)', + '(rate.rateType ILIKE :search OR rate.status ILIKE :search OR rate.rateUnit ILIKE :search OR rate.currency ILIKE :search OR shippingLineCompany.name ILIKE :search)', { search: `%${query.search}%` }, ); } diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts index d487387b8..549a35fa2 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts @@ -69,6 +69,7 @@ import { YardFacilitiesService } from './services/yard-facilities.service'; import { RuleEngineService } from './rule-engine.service'; import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module'; +import { ShippingLineCompaniesModule } from '../shipping-lines/shipping-line-companies.module'; import { WagonTypesModule } from '../wagon-types/wagon-types.module'; import { BookingCargoModifier } from '../bookings/entities/booking-cargo-modifier.entity'; @@ -102,6 +103,9 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. // Rated wagon capacities — cargo types validate their per-wagon tonnage cap // against them (a cap above the rating is a typo, not a policy). WagonTypesModule, + // Rates may be scoped to one shipping line; creating such a rate validates + // the line exists and is active. + ShippingLineCompaniesModule, ], controllers: [ CargoTypesController, diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.spec.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.spec.ts index 281f2d1b2..c530e5314 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.spec.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.spec.ts @@ -527,3 +527,145 @@ describe('RuleEngineService — fuel surcharge (per lane + cargo type)', () => { expect(fuelMods(result)).toHaveLength(0); }); }); + +describe('RuleEngineService — shipping-line rates override the standard ones', () => { + const LINE = 'slc-msc'; + + /** Standard customer container-import rate on the lane. */ + const standardBase: Rate = { + id: 'rate-standard-20', + rateType: 'CONTAINER_IMPORT', + trigger: 'ALWAYS', + rateValue: 1000, + rateUnit: 'PER_CONTAINER', + currency: 'USD', + status: 'LIVE', + containerTypeId: 'ct-20', + cargoTypeId: null, + shippingLineCompanyId: null, + originYardId: 'yard-dj', + destinationYardId: 'yard-adama', + } as Rate; + + /** The same lane, priced for one shipping line. */ + const lineBase: Rate = { + ...standardBase, + id: 'rate-line-20', + rateValue: 1200, + shippingLineCompanyId: LINE, + } as Rate; + + const standardHazard: Rate = { + id: 'rate-hazard-standard', + rateType: 'HAZARD_SURCHARGE', + trigger: 'HAZARDOUS', + rateValue: 50, + rateUnit: 'PER_CONTAINER', + currency: 'USD', + status: 'LIVE', + containerTypeId: null, + cargoTypeId: null, + shippingLineCompanyId: null, + } as Rate; + + const lineHazard: Rate = { + ...standardHazard, + id: 'rate-hazard-line', + rateValue: 80, + shippingLineCompanyId: LINE, + } as Rate; + + const buildService = (rates: Rate[]) => + new RuleEngineService( + { findById: jest.fn().mockResolvedValue(null) } as never, + { findById: jest.fn().mockResolvedValue(null) } as never, + { + findActiveByContainerTypeId: jest + .fn() + .mockResolvedValue([{ id: 'wlr-20', maxVgmTons: 20, maxCapacityTons: null }]), + } as never, + { findAllActive: jest.fn().mockResolvedValue([]) } as never, + { findLiveRates: jest.fn().mockResolvedValue(rates) } as never, + { findById: jest.fn().mockResolvedValue(null) } as never, + {} as never, + ); + + // One 20ft at 25 t against a 20 t limit → 5 t excess. + const bookingInput = ( + overrides: Partial = {}, + ): BookingEvaluationInput => ({ + serviceTypeId: 'svc-1', + paymentCurrency: 'USD', + tradeDirection: 'IMPORT', + isHazardous: false, + totalWagons: 1, + originYardId: 'yard-dj', + destinationYardId: 'yard-adama', + containers: [ + { containerTypeId: 'ct-20', quantity: 1, vgmPerUnitTons: 25, totalVgmTons: 25 }, + ], + ...overrides, + }); + + const overweightOf = (result: { appliedModifiers: Array<{ surchargeCode: string }> }) => + result.appliedModifiers.filter((m) => m.surchargeCode === 'OVERWEIGHT_PER_TON'); + + it('derives a line booking\'s overweight from the LINE\'s base rate, not the standard one', async () => { + const result = await buildService([standardBase, lineBase]).evaluate( + bookingInput({ shippingLineCompanyId: LINE }), + ); + const ow = overweightOf(result); + expect(ow).toHaveLength(1); + // The line's 1200 / (2 × 20) = 30 USD/t, not the standard 1000 → 25 USD/t. + expect(ow[0]).toMatchObject({ + rateId: lineBase.id, + unitPriceUsd: 30, + calculatedAmount: 150, + }); + }); + + it('keeps a customer booking on the standard rate even when a line rate exists', async () => { + const result = await buildService([standardBase, lineBase]).evaluate(bookingInput()); + const ow = overweightOf(result); + expect(ow).toHaveLength(1); + expect(ow[0]).toMatchObject({ + rateId: standardBase.id, + unitPriceUsd: 25, + calculatedAmount: 125, + }); + }); + + it('does not fall back to the standard rate when the line has none for the lane', async () => { + const result = await buildService([standardBase]).evaluate( + bookingInput({ shippingLineCompanyId: LINE }), + ); + // No line rate on the lane → nothing to derive from. Base freight is what + // hard-blocks the booking; the standard 1000 must never be borrowed here. + expect(overweightOf(result)).toHaveLength(0); + }); + + it('bills the line\'s own surcharge and never the standard one alongside it', async () => { + const result = await buildService([ + standardBase, + lineBase, + standardHazard, + lineHazard, + ]).evaluate(bookingInput({ shippingLineCompanyId: LINE, isHazardous: true })); + + const hazard = result.appliedModifiers.filter( + (m) => m.surchargeCode === 'HAZARD_SURCHARGE', + ); + expect(hazard).toHaveLength(1); + expect(hazard[0]).toMatchObject({ rateId: lineHazard.id, calculatedAmount: 80 }); + }); + + it('hard-blocks a requested service the line has no surcharge rate for', async () => { + const result = await buildService([standardBase, lineBase, standardHazard]).evaluate( + bookingInput({ shippingLineCompanyId: LINE, isHazardous: true }), + ); + // The standard hazard rate exists but belongs to customers, so the line's + // hazardous booking must block rather than borrow it. + expect(result.hardBlocked).toHaveLength(1); + expect(result.hardBlocked[0]).toContain('hazardous'); + }); +}); diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts index 908d00ecc..5c9055415 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts @@ -74,6 +74,16 @@ export interface BookingEvaluationInput { isGovernment?: boolean; allowConsolidation?: boolean; shippingLineId?: string | null; + /** + * The shipping line that OWNS this booking (`bookings.shipping_line_company_id`), + * when it is a shipping-line booking rather than a customer one. Such a booking + * prices exclusively off that line's own rates — see {@link ratesForOwner}. + * + * Not to be confused with `shippingLineId` above, which is cargo metadata + * naming the carrier that physically moves the goods and only feeds the + * SHIPPING_LINE double-handling trigger. + */ + shippingLineCompanyId?: string | null; /** * The booking's rail leg. Import overweight derives its per-ton price from * this route's own container freight rate, so the engine needs the yards. @@ -282,7 +292,10 @@ export class RuleEngineService { // scope) must contribute exactly ONE line. Duplicate LIVE rate rows — e.g. // from a non-idempotent seeder — would otherwise repeat the same surcharge // many times and inflate the total, so we collapse them to one row each. - const liveRates = await this.ratesRepo.findLiveRates(); + const liveRates = this.ratesForOwner( + await this.ratesRepo.findLiveRates(), + input.shippingLineCompanyId, + ); const surchargeRates = this.dedupeRatesBySignature( liveRates.filter((r) => r.trigger && r.trigger !== 'ALWAYS'), ); @@ -812,6 +825,28 @@ export class RuleEngineService { return rate.rateType ?? rate.trigger; } + /** + * Narrow the LIVE rate pool to the ones this booking's owner may price off. + * + * A customer booking sees only standard rates (no owner) — a shipping line's + * negotiated price must never leak into a customer quote. A shipping-line + * booking sees only that line's own rates: line rates OVERRIDE the standard + * ones rather than stacking on them, and the standard rate is deliberately + * NOT a fallback, so a lane the line has no rate for hard-blocks downstream + * (base freight already blocks on "no rate for this route") instead of + * quietly billing the line at the customer price. + * + * Filtering once, here, is what makes the override apply uniformly: every + * downstream lookup (base freight, derived overweight, empty return, lashing, + * fuel, and the additive surcharges) reads from this same pool, so none of + * them needs its own owner check. + */ + private ratesForOwner(rates: Rate[], shippingLineCompanyId?: string | null): Rate[] { + return shippingLineCompanyId + ? rates.filter((r) => r.shippingLineCompanyId === shippingLineCompanyId) + : rates.filter((r) => !r.shippingLineCompanyId); + } + /** * Collapse rates that describe the same charge to a single representative. * diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rates.duplicate-pattern.spec.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rates.duplicate-pattern.spec.ts index 23d96da89..0a4108a4e 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/rates.duplicate-pattern.spec.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rates.duplicate-pattern.spec.ts @@ -61,6 +61,8 @@ describe('RatesService — one rate per pattern', () => { })), } as never, { findById: jest.fn().mockResolvedValue(null) } as never, + // Shipping line companies — these rates carry no owner, so it is never hit. + { findById: jest.fn() } as never, ); }); diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts index 232cd9299..1902a757a 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts @@ -8,6 +8,8 @@ import { } from '@nestjs/common'; import { PaginatedResponse, YardCountry } from '@edr/types'; import { IsNull, Not } from 'typeorm'; +import { ShippingLineStatus } from '../../shipping-lines/entities/shipping-line-company.entity'; +import { ShippingLineCompaniesService } from '../../shipping-lines/shipping-line-companies.service'; import { CreateRateDto } from '../dto/create-rate.dto'; import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto'; import { UpdateRateDto } from '../dto/update-rate.dto'; @@ -39,6 +41,7 @@ export class RatesService { private readonly yardsRepository: IYardsRepository, @Inject(CARGO_TYPES_REPOSITORY) private readonly cargoTypesRepository: ICargoTypesRepository, + private readonly shippingLineCompaniesService: ShippingLineCompaniesService, ) {} /** List rates — standard paginated envelope with server-side search. */ @@ -491,6 +494,7 @@ export class RatesService { rateType: string; /** Passed only for additive surcharges — see {@link resolvesSingleRate}. */ rateUnit?: string; + shippingLineCompanyId: string | null; containerTypeId: string | null; cargoTypeId: string | null; tradeDirection: string | null; @@ -508,6 +512,35 @@ export class RatesService { } } + /** + * Validate the shipping line a rate is scoped to, when any. + * + * A shipping line only ever ships import — the export leg is sold through the + * customer's contract — so a line rate carrying an EXPORT direction is + * rejected here as well as by `CK_rates_shipping_line_import_only`. + * Returns the owner id to store (null = the standard customer rate). + */ + private async resolveShippingLineScope( + shippingLineCompanyId: string | null | undefined, + tradeDirection: string | null, + ): Promise { + if (!shippingLineCompanyId) return null; + + // Throws NotFoundException when the line does not exist. + const line = await this.shippingLineCompaniesService.findById(shippingLineCompanyId); + if (line.status !== ShippingLineStatus.Active) { + throw new BadRequestException( + `${line.name} is ${line.status} — rates can only be configured for an active shipping line.`, + ); + } + if (tradeDirection && tradeDirection !== 'IMPORT') { + throw new BadRequestException( + 'Shipping line rates are import-only — the export leg is priced through the customer contract.', + ); + } + return shippingLineCompanyId; + } + /** Create a rate in DRAFT status. */ async create(dto: CreateRateDto, proposedByStaffId: string): Promise { const appliesTo = dto.appliesTo as Rate['appliesTo']; @@ -568,6 +601,11 @@ export class RatesService { destinationYardId: dto.destinationYardId, }); + const shippingLineCompanyId = await this.resolveShippingLineScope( + dto.shippingLineCompanyId, + tradeDirection, + ); + const rateType = deriveRateType({ appliesTo, trigger, @@ -602,6 +640,7 @@ export class RatesService { await this.assertNoDuplicatePattern({ rateType, ...(this.resolvesSingleRate(appliesTo, trigger) ? {} : { rateUnit }), + shippingLineCompanyId, containerTypeId, cargoTypeId, tradeDirection, @@ -614,6 +653,7 @@ export class RatesService { appliesTo, trigger, rateType, + shippingLineCompanyId, containerTypeId, cargoTypeId, tradeDirection, @@ -791,6 +831,17 @@ export class RatesService { updates.originYardId = yardScope.originYardId; updates.destinationYardId = yardScope.destinationYardId; + // The owning line is re-validated on every edit: a patch that flips the + // direction to EXPORT has to be refused for a line rate, and a patch that + // moves the rate to a suspended line too. + const shippingLineCompanyId = await this.resolveShippingLineScope( + dto.shippingLineCompanyId !== undefined + ? dto.shippingLineCompanyId + : existing.shippingLineCompanyId, + updates.tradeDirection, + ); + updates.shippingLineCompanyId = shippingLineCompanyId; + // Keep the derived rateType in sync with whatever changed. const rateType = deriveRateType({ appliesTo, @@ -839,6 +890,7 @@ export class RatesService { await this.assertNoDuplicatePattern({ rateType, ...(this.resolvesSingleRate(appliesTo, trigger) ? {} : { rateUnit }), + shippingLineCompanyId, containerTypeId: updates.containerTypeId, cargoTypeId: updates.cargoTypeId, tradeDirection: updates.tradeDirection, diff --git a/apps/edr-freight-api/src/modules/shipping-lines/dto/cancel-shipping-line-booking.dto.ts b/apps/edr-freight-api/src/modules/shipping-lines/dto/cancel-shipping-line-booking.dto.ts new file mode 100644 index 000000000..1064cdeb4 --- /dev/null +++ b/apps/edr-freight-api/src/modules/shipping-lines/dto/cancel-shipping-line-booking.dto.ts @@ -0,0 +1,15 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsOptional, IsString, MaxLength } from "class-validator"; + +/** Payload for a shipping line cancelling its own booking. */ +export class CancelShippingLineBookingDto { + @ApiProperty({ + required: false, + description: + "Why the booking is being cancelled. Recorded on the booking's review-note log.", + }) + @IsOptional() + @IsString() + @MaxLength(500) + reason?: string; +} diff --git a/apps/edr-freight-api/src/modules/shipping-lines/dto/complete-shipping-line-booking.dto.ts b/apps/edr-freight-api/src/modules/shipping-lines/dto/complete-shipping-line-booking.dto.ts new file mode 100644 index 000000000..90e31e42d --- /dev/null +++ b/apps/edr-freight-api/src/modules/shipping-lines/dto/complete-shipping-line-booking.dto.ts @@ -0,0 +1,193 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { Transform, Type } from "class-transformer"; +import { + IsArray, + IsBoolean, + IsDateString, + IsIn, + IsInt, + IsNumber, + IsOptional, + IsString, + IsUUID, + Min, + ValidateNested, +} from "class-validator"; + +import { PAYMENT_CURRENCIES } from "../../contracts/dto/create-contract.dto"; + +/** + * One physical container on a line — number, seal, VGM and its per-container + * handling switches. Same shape the customer shipment form submits. + */ +export class CompleteShippingLineContainerUnitDto { + @ApiProperty({ example: "MSCU1234567" }) + @IsString() + containerNumber!: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + sealNumber?: string; + + @ApiProperty({ minimum: 0, description: "VGM of this container, tons." }) + @IsNumber() + @Min(0) + @Transform(({ value }) => Number(value)) + vgmTons!: number; + + @ApiPropertyOptional({ default: false }) + @IsOptional() + @IsBoolean() + isHazardous?: boolean; + + @ApiPropertyOptional({ default: false }) + @IsOptional() + @IsBoolean() + isReefer?: boolean; +} + +/** + * One container line the shipping line ships — container type + count, the + * same shape the customer one-time form collects. When `units` is sent (the + * full booking page), per-container numbers/seals/VGM and handling switches + * are persisted exactly like the customer shipment form; without it (legacy + * modal shape) the line-level counts stand alone. + */ +export class CompleteShippingLineContainerLineDto { + @ApiPropertyOptional({ + format: "uuid", + description: + "Container type being shipped. Optional when containerSize is sent — the server resolves the type from the size.", + }) + @IsOptional() + @IsUUID() + containerTypeId?: string; + + @ApiPropertyOptional({ + description: + 'Container size, e.g. "20ft" | "40ft". The server maps it to the configured container type (reefer variant when the line carries reefer boxes) — so the client never needs the type catalog.', + }) + @IsOptional() + @IsString() + containerSize?: string; + + @ApiProperty({ minimum: 1 }) + @IsInt() + @Min(1) + @Transform(({ value }) => Number(value)) + quantity!: number; + + @ApiPropertyOptional({ minimum: 0, description: "VGM per container, tons." }) + @IsOptional() + @IsNumber() + @Min(0) + @Transform(({ value }) => Number(value)) + vgmPerUnitTons?: number; + + @ApiPropertyOptional({ minimum: 0 }) + @IsOptional() + @IsInt() + @Min(0) + @Transform(({ value }) => Number(value)) + hazardousQuantity?: number; + + @ApiPropertyOptional({ minimum: 0 }) + @IsOptional() + @IsInt() + @Min(0) + @Transform(({ value }) => Number(value)) + reeferQuantity?: number; + + @ApiPropertyOptional({ + type: [CompleteShippingLineContainerUnitDto], + description: + "Per-container details. When present, the handling counts and VGM are derived from these rows.", + }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => CompleteShippingLineContainerUnitDto) + units?: CompleteShippingLineContainerUnitDto[]; +} + +/** + * Completion payload for a shipping-line booking whose documents Operations + * has approved (CLEARANCE_READY): the cargo and the binding shipment day — + * the two things `initiate` deliberately left empty. + */ +export class CompleteShippingLineBookingDto { + @ApiProperty({ + description: "Binding shipment day (train departure day).", + example: "2026-09-01", + }) + @IsDateString() + scheduledDate!: string; + + @ApiPropertyOptional({ + format: "uuid", + description: + "Which of the line's dedicated trains this booking rides. Required when more than one departs on the chosen day; implicit with a single departure.", + }) + @IsOptional() + @IsUUID() + trainScheduleId?: string; + + @ApiPropertyOptional({ enum: PAYMENT_CURRENCIES }) + @IsOptional() + @IsIn([...PAYMENT_CURRENCIES]) + paymentCurrency?: string; + + @ApiPropertyOptional({ + type: [CompleteShippingLineContainerLineDto], + description: "Container freight: what ships. Required for CONTAINER bookings.", + }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => CompleteShippingLineContainerLineDto) + containers?: CompleteShippingLineContainerLineDto[]; + + @ApiPropertyOptional({ + format: "uuid", + description: "Bulk freight: the cargo type. Required for BULK bookings.", + }) + @IsOptional() + @IsUUID() + cargoTypeId?: string; + + @ApiPropertyOptional({ + minimum: 0, + description: "Bulk freight: total weight in tons. Required for BULK bookings.", + }) + @IsOptional() + @IsNumber() + @Min(0) + @Transform(({ value }) => Number(value)) + cargoWeightTons?: number; + + @ApiPropertyOptional({ + minimum: 0, + description: "Bulk freight: hazardous portion of the cargo.", + }) + @IsOptional() + @IsNumber() + @Min(0) + @Transform(({ value }) => Number(value)) + bulkHazardousQuantity?: number; + + @ApiPropertyOptional({ + minimum: 0, + description: "Bulk freight: refrigerated portion of the cargo.", + }) + @IsOptional() + @IsNumber() + @Min(0) + @Transform(({ value }) => Number(value)) + bulkReeferQuantity?: number; + + @ApiPropertyOptional({ description: "What the containers carry." }) + @IsOptional() + @IsString() + cargoFreeText?: string; +} diff --git a/apps/edr-freight-api/src/modules/shipping-lines/dto/create-shipping-line.dto.ts b/apps/edr-freight-api/src/modules/shipping-lines/dto/create-shipping-line.dto.ts new file mode 100644 index 000000000..5f89c916c --- /dev/null +++ b/apps/edr-freight-api/src/modules/shipping-lines/dto/create-shipping-line.dto.ts @@ -0,0 +1,68 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { + IsEmail, + IsNotEmpty, + IsOptional, + IsString, + Matches, + MaxLength, +} from "class-validator"; + +import { IsValidPhone } from "../../../common/validators/is-phone-number.validator"; + +export class CreateShippingLineDto { + @ApiProperty({ example: "Ethiopian Shipping Lines" }) + @IsString() + @IsNotEmpty() + @MaxLength(200) + name!: string; + + /** + * Becomes the IAM account's email — the activation link is sent here, so it + * is required even though the customer equivalent is optional. + */ + @ApiProperty({ example: "ops@esl.com.et" }) + @IsEmail() + @MaxLength(150) + email!: string; + + @ApiPropertyOptional({ example: "+251911223344" }) + @IsOptional() + @IsString() + @MaxLength(30) + @IsValidPhone() + phoneNumber?: string; + + @ApiPropertyOptional({ + example: "ESLK", + description: "Standard Carrier Alpha Code — 2-4 letters", + }) + @IsOptional() + @IsString() + @Matches(/^[A-Za-z]{2,4}$/, { + message: "SCAC must be 2-4 letters", + }) + scacCode?: string; + + @ApiPropertyOptional({ example: "IMO9074729" }) + @IsOptional() + @IsString() + @MaxLength(20) + imoNumber?: string; + + @ApiPropertyOptional({ example: "ESLU" }) + @IsOptional() + @IsString() + @MaxLength(20) + bicCode?: string; + + /** + * Login name. Optional — defaults to the email, which is what the line will + * naturally try first. + */ + @ApiPropertyOptional({ example: "esl-ops" }) + @IsOptional() + @IsString() + @MaxLength(100) + username?: string; +} diff --git a/apps/edr-freight-api/src/modules/shipping-lines/dto/initiate-shipping-line-booking.dto.ts b/apps/edr-freight-api/src/modules/shipping-lines/dto/initiate-shipping-line-booking.dto.ts new file mode 100644 index 000000000..3b0615fe4 --- /dev/null +++ b/apps/edr-freight-api/src/modules/shipping-lines/dto/initiate-shipping-line-booking.dto.ts @@ -0,0 +1,47 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsDateString, IsIn, IsOptional, IsUUID } from "class-validator"; + +import { FREIGHT_TYPES } from "../../bookings/entities/booking.entity"; + +/** + * Payload for initiating a bare shipping-line booking. + * + * A customer's bare booking inherits its lane from the contract it is initiated + * under. Shipping lines have no contract, so the lane comes from a route the + * caller picks — one choice that yields origin, destination and trade direction + * together, rather than three fields that can contradict each other. + */ +export class InitiateShippingLineBookingDto { + @ApiProperty({ + description: + "The lane being booked. Supplies the booking's origin yard, destination yard and trade direction.", + }) + @IsUUID() + routeId!: string; + + @ApiProperty({ + required: false, + description: "Service type being booked.", + }) + @IsOptional() + @IsUUID() + serviceTypeId?: string; + + @ApiProperty({ + required: false, + enum: FREIGHT_TYPES, + description: "Freight type. Defaults to CONTAINER.", + }) + @IsOptional() + @IsIn(FREIGHT_TYPES) + freightType?: string; + + @ApiProperty({ + required: false, + description: + "Intended shipment day (YYYY-MM-DD). Unlike the customer flow it is picked up front — a shipping line has no later operation-request step to choose it at.", + }) + @IsOptional() + @IsDateString() + scheduledDate?: string; +} diff --git a/apps/edr-freight-api/src/modules/shipping-lines/dto/shipping-line-credit.dto.ts b/apps/edr-freight-api/src/modules/shipping-lines/dto/shipping-line-credit.dto.ts new file mode 100644 index 000000000..7ad524f01 --- /dev/null +++ b/apps/edr-freight-api/src/modules/shipping-lines/dto/shipping-line-credit.dto.ts @@ -0,0 +1,85 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { + ArrayNotEmpty, + IsArray, + IsInt, + IsOptional, + IsString, + IsUUID, + MaxLength, + Min, + MinLength, +} from "class-validator"; + +/** Finance's request to bill a batch of unbilled credits as one invoice. */ +export class GenerateCreditInvoiceDto { + @ApiProperty({ + description: + "The unbilled credits to bill. All must belong to the same shipping line and share one currency.", + type: [String], + format: "uuid", + }) + @IsArray() + @ArrayNotEmpty() + @IsUUID("4", { each: true }) + creditIds!: string[]; + + @ApiPropertyOptional({ + description: + "Pay window in days from issue. Defaults to the standard invoice term.", + minimum: 1, + example: 14, + }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + dueInDays?: number; +} + +/** Finance's request for a manual action on a credit invoice (maker step). */ +export class RequestInvoiceActionDto { + @ApiProperty({ + description: + "Why the action is needed. Shown to the approver and kept for audit.", + example: "Paid by bank transfer, slip #TT-4491", + }) + @IsString() + @MinLength(3) + @MaxLength(500) + reason!: string; + + @ApiPropertyOptional({ + description: + "Offline payment reference (bank slip / transfer number). MARK_PAID requests only.", + example: "TT-4491", + }) + @IsOptional() + @IsString() + @MaxLength(255) + paymentReference?: string; +} + +/** The decision on a pending request (approve and reject routes). */ +export class DecideInvoiceActionDto { + @ApiPropertyOptional({ + description: "Decision note. Required when rejecting.", + }) + @IsOptional() + @IsString() + @MaxLength(500) + note?: string; +} + +/** Write-off of a single unbilled credit. */ +export class CancelCreditDto { + @ApiProperty({ + description: "Why the credit is being written off. Recorded on the row.", + example: "Booking voided before departure", + }) + @IsString() + @MinLength(3) + @MaxLength(255) + reason!: string; +} diff --git a/apps/edr-freight-api/src/modules/shipping-lines/dto/shipping-line-response.dto.ts b/apps/edr-freight-api/src/modules/shipping-lines/dto/shipping-line-response.dto.ts new file mode 100644 index 000000000..051c3af28 --- /dev/null +++ b/apps/edr-freight-api/src/modules/shipping-lines/dto/shipping-line-response.dto.ts @@ -0,0 +1,64 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; + +import { + ShippingLineCompany, + ShippingLineStatus, +} from "../entities/shipping-line-company.entity"; + +export class ShippingLineResponseDto { + @ApiProperty() + id: string; + + @ApiProperty() + name: string; + + @ApiProperty() + email: string; + + @ApiPropertyOptional() + phoneNumber?: string | null; + + @ApiPropertyOptional() + scacCode?: string | null; + + @ApiPropertyOptional() + imoNumber?: string | null; + + @ApiPropertyOptional() + bicCode?: string | null; + + @ApiProperty({ enum: ShippingLineStatus }) + status: ShippingLineStatus; + + @ApiProperty() + createdAt: Date; + + constructor(entity: ShippingLineCompany) { + this.id = entity.id; + this.name = entity.name; + this.email = entity.email; + this.phoneNumber = entity.phoneNumber ?? null; + this.scacCode = entity.scacCode ?? null; + this.imoNumber = entity.imoNumber ?? null; + this.bicCode = entity.bicCode ?? null; + this.status = entity.status; + this.createdAt = entity.createdAt; + } +} + +export class RegisterShippingLineResponseDto { + @ApiProperty({ type: ShippingLineResponseDto }) + shippingLine: ShippingLineResponseDto; + + @ApiPropertyOptional({ + description: + "Masked destination the activation link was sent to, or null if delivery failed.", + example: "o**@esl.com.et", + }) + activationSentTo: string | null; + + constructor(shippingLine: ShippingLineCompany, activationSentTo: string | null) { + this.shippingLine = new ShippingLineResponseDto(shippingLine); + this.activationSentTo = activationSentTo; + } +} diff --git a/apps/edr-freight-api/src/modules/shipping-lines/entities/shipping-line-company.entity.ts b/apps/edr-freight-api/src/modules/shipping-lines/entities/shipping-line-company.entity.ts new file mode 100644 index 000000000..fac8c00a6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/shipping-lines/entities/shipping-line-company.entity.ts @@ -0,0 +1,64 @@ +import { BaseEntity } from "@edr/api-common"; +import { Column, Entity, Index } from "typeorm"; + +export enum ShippingLineStatus { + Active = "active", + Suspended = "suspended", +} + +/** + * A shipping line — a carrier that books rail capacity directly, registered by + * backoffice staff rather than self-signing up. + * + * Deliberately NOT a {@link Company} of a new {@link CompanyType}: a shipping + * line carries none of what `companies` exists to hold — no TIN, no business + * licence, no eTrade authenticity lookup, no operational `company_profiles`, no + * onboarding wizard state. Modelling it there would mean making all of that + * nullable for one row shape that never uses it. + * + * The company IS the account: there is no contact-person row (customers get one + * via `external_profiles`), so `user_id` lives here and the login credentials + * are the company's own. That is also why the password-reset flow resolves a + * shipping line straight off this table instead of through a primary contact. + */ +@Entity({ schema: "freight", name: "shipping_line_companies" }) +@Index(["status"]) +export class ShippingLineCompany extends BaseEntity { + /** + * The IAM account (`iam.users`, userType `individual`) that signs in as this + * shipping line. No FK: `iam` is a separate schema owned by the IAM service, + * and the rest of the codebase reaches it by query rather than by relation. + */ + @Column({ name: "user_id", type: "uuid", unique: true }) + userId!: string; + + @Column({ name: "name", type: "varchar", length: 200 }) + name!: string; + + /** Standard Carrier Alpha Code — 2-4 letters identifying the carrier. */ + @Column({ name: "scac_code", type: "varchar", length: 4, nullable: true }) + scacCode?: string | null; + + /** IMO number of the vessel operator. */ + @Column({ name: "imo_number", type: "varchar", length: 20, nullable: true }) + imoNumber?: string | null; + + /** BIC code — the container prefix the line's equipment is registered under. */ + @Column({ name: "bic_code", type: "varchar", length: 20, nullable: true }) + bicCode?: string | null; + + /** Mirrors the IAM account's email; the activation link is sent here. */ + @Column({ name: "email", type: "varchar", length: 150 }) + email!: string; + + @Column({ name: "phone_number", type: "varchar", length: 30, nullable: true }) + phoneNumber?: string | null; + + @Column({ + name: "status", + type: "enum", + enum: ShippingLineStatus, + default: ShippingLineStatus.Active, + }) + status!: ShippingLineStatus; +} diff --git a/apps/edr-freight-api/src/modules/shipping-lines/entities/shipping-line-credit.entity.ts b/apps/edr-freight-api/src/modules/shipping-lines/entities/shipping-line-credit.entity.ts new file mode 100644 index 000000000..3c81968f6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/shipping-lines/entities/shipping-line-credit.entity.ts @@ -0,0 +1,110 @@ +import { BaseEntity } from "@edr/api-common"; +import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm"; + +import { Booking } from "../../bookings/entities/booking.entity"; +import { Invoice } from "../../billing/entities/invoice.entity"; +import { ShippingLineCompany } from "./shipping-line-company.entity"; + +/** Where a credit sits between "service used" and "money received". */ +export enum ShippingLineCreditStatus { + /** Service used, priced, not yet on any invoice. Counts as debt. */ + Unbilled = "UNBILLED", + /** Finance put it on an invoice; awaiting payment. Still counts as debt. */ + Billed = "BILLED", + /** The invoice settled. Terminal — no longer debt, and never re-billed. */ + Paid = "PAID", + /** Written off / booking voided. Terminal, excluded from every total. */ + Cancelled = "CANCELLED", +} + +/** Statuses a shipping line still owes money for. */ +export const OUTSTANDING_CREDIT_STATUSES = [ + ShippingLineCreditStatus.Unbilled, + ShippingLineCreditStatus.Billed, +] as const; + +/** + * What a shipping line owes for one booking. + * + * Shipping lines get the service first and pay later, so a booking of theirs + * raises no invoice and passes no payment gate — it raises one of these. The + * amount is frozen when the booking is priced and is never recalculated, so a + * later rate change cannot silently alter a debt already incurred. + * + * Finance batches unbilled credits into one invoice (see + * `ShippingLineCreditsService.generateInvoice`); the line pays that invoice + * through the ordinary CBE flow; settlement flips the batch to PAID and the + * debt disappears. The outstanding figure is always derived by summing + * {@link OUTSTANDING_CREDIT_STATUSES} rows — there is no balance column, + * because a stored balance is one missed UPDATE away from being a lie. + */ +@Entity({ schema: "freight", name: "shipping_line_credits" }) +@Index(["shippingLineCompanyId", "status"]) +@Index(["invoiceId"]) +export class ShippingLineCredit extends BaseEntity { + /** The line that owes this. */ + @Column({ name: "shipping_line_company_id", type: "uuid" }) + shippingLineCompanyId!: string; + + @ManyToOne(() => ShippingLineCompany) + @JoinColumn({ name: "shipping_line_company_id" }) + shippingLineCompany?: ShippingLineCompany; + + /** + * The booking that incurred the charge. Unique among live rows (partial + * index excludes soft-deleted and CANCELLED), so one booking can never be + * billed twice. + */ + @Column({ name: "booking_id", type: "uuid" }) + bookingId!: string; + + @ManyToOne(() => Booking) + @JoinColumn({ name: "booking_id" }) + booking?: Booking; + + /** Frozen at pricing time. Never recalculated. */ + @Column({ name: "amount", type: "numeric", precision: 14, scale: 2 }) + amount!: number; + + @Column({ name: "currency", type: "varchar", length: 8, default: "ETB" }) + currency!: string; + + @Column({ + name: "status", + type: "enum", + enum: ShippingLineCreditStatus, + default: ShippingLineCreditStatus.Unbilled, + }) + status!: ShippingLineCreditStatus; + + /** What the charge is for; becomes the invoice line description. */ + @Column({ name: "description", type: "varchar", length: 255, nullable: true }) + description?: string | null; + + /** The invoice this credit was billed on; null while UNBILLED. */ + @Column({ name: "invoice_id", type: "uuid", nullable: true }) + invoiceId?: string | null; + + @ManyToOne(() => Invoice) + @JoinColumn({ name: "invoice_id" }) + invoice?: Invoice; + + /** When finance put it on an invoice. */ + @Column({ name: "billed_at", type: "timestamptz", nullable: true }) + billedAt?: Date | null; + + /** When that invoice settled. */ + @Column({ name: "paid_at", type: "timestamptz", nullable: true }) + paidAt?: Date | null; + + @Column({ name: "cancelled_at", type: "timestamptz", nullable: true }) + cancelledAt?: Date | null; + + @Column({ + name: "cancellation_reason", + type: "varchar", + length: 255, + nullable: true, + }) + cancellationReason?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/shipping-lines/entities/shipping-line-invoice-approval.entity.ts b/apps/edr-freight-api/src/modules/shipping-lines/entities/shipping-line-invoice-approval.entity.ts new file mode 100644 index 000000000..54ef7338d --- /dev/null +++ b/apps/edr-freight-api/src/modules/shipping-lines/entities/shipping-line-invoice-approval.entity.ts @@ -0,0 +1,82 @@ +import { BaseEntity } from "@edr/api-common"; +import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm"; + +import { Invoice } from "../../billing/entities/invoice.entity"; + +/** What finance asked to do to a shipping-line credit invoice. */ +export enum ShippingLineInvoiceActionType { + /** Record a full offline settlement (paid outside the gateway). */ + MarkPaid = "MARK_PAID", + /** Void the invoice; its credits return to the unbilled pool. */ + Cancel = "CANCEL", +} + +export enum ShippingLineInvoiceActionStatus { + Pending = "PENDING", + Approved = "APPROVED", + Rejected = "REJECTED", +} + +/** + * Maker–checker for manual actions on shipping-line credit invoices. + * + * Marking an invoice paid by hand erases real debt, and cancelling one + * releases its credits back to the unbilled pool — either done unilaterally is + * a one-person fraud path. So finance REQUESTS the action (one permission) + * and a chief APPROVES or REJECTS it (a separate permission, different + * person). Every request is kept, decided or not: the table is the audit + * trail of who asked, who decided, and why. + */ +@Entity({ schema: "freight", name: "shipping_line_invoice_approvals" }) +@Index(["invoiceId", "status"]) +export class ShippingLineInvoiceApproval extends BaseEntity { + @Column({ name: "invoice_id", type: "uuid" }) + invoiceId!: string; + + @ManyToOne(() => Invoice) + @JoinColumn({ name: "invoice_id" }) + invoice?: Invoice; + + @Column({ name: "action", type: "enum", enum: ShippingLineInvoiceActionType }) + action!: ShippingLineInvoiceActionType; + + @Column({ + name: "status", + type: "enum", + enum: ShippingLineInvoiceActionStatus, + default: ShippingLineInvoiceActionStatus.Pending, + }) + status!: ShippingLineInvoiceActionStatus; + + /** IAM user id of the finance staff who raised the request. */ + @Column({ name: "requested_by", type: "uuid" }) + requestedBy!: string; + + /** Why the action is needed; shown to the approver, kept for audit. */ + @Column({ name: "reason", type: "varchar", length: 500 }) + reason!: string; + + /** Offline payment reference (bank slip no. etc.) for MARK_PAID requests. */ + @Column({ + name: "payment_reference", + type: "varchar", + length: 255, + nullable: true, + }) + paymentReference?: string | null; + + /** IAM user id of the chief who approved/rejected; null while pending. */ + @Column({ name: "decided_by", type: "uuid", nullable: true }) + decidedBy?: string | null; + + @Column({ name: "decided_at", type: "timestamptz", nullable: true }) + decidedAt?: Date | null; + + @Column({ + name: "decision_note", + type: "varchar", + length: 500, + nullable: true, + }) + decisionNote?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-booking-completion.controller.ts b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-booking-completion.controller.ts new file mode 100644 index 000000000..b2417a08f --- /dev/null +++ b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-booking-completion.controller.ts @@ -0,0 +1,109 @@ +import { CurrentUser } from "@edr/api-common"; +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Post, + Query, +} from "@nestjs/common"; +import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; + +import { PortalCustomer } from "../../common/booking-guards"; +import { CompleteShippingLineBookingDto } from "./dto/complete-shipping-line-booking.dto"; +import { ShippingLineBookingCompletionService } from "./shipping-line-booking-completion.service"; + +interface CurrentIamUser { + id: string; +} + +/** + * The completion half of shipping-line bookings, sharing the + * `/shipping-line-bookings` prefix with {@link ShippingLineBookingsController}. + * Separate controller because it lives in its own module — see + * {@link ShippingLineBookingCompletionService} for why the module split exists. + */ +@ApiTags("shipping-line-bookings") +@Controller("shipping-line-bookings") +@ApiBearerAuth() +export class ShippingLineBookingCompletionController { + constructor( + private readonly completionService: ShippingLineBookingCompletionService, + ) {} + + @Get(":id/available-days") + @PortalCustomer() + @ApiOperation({ + summary: + "Days with an open departure that can carry this booking's cargo — for the completion form's day picker.", + }) + async availableDays( + @CurrentUser() user: CurrentIamUser, + @Param("id", ParseUUIDPipe) id: string, + ) { + return this.completionService.availableDaysMine(user.id, id); + } + + @Get(":id/trains") + @PortalCustomer() + @ApiOperation({ + summary: + "The line's dedicated trains on the booking's lane for a shipment day, each with per-wagon-type free space — for the completion form's train picker. Cargo context (sizes/cargoTypeId/wagons) refines the availability.", + }) + async trainsForDay( + @CurrentUser() user: CurrentIamUser, + @Param("id", ParseUUIDPipe) id: string, + @Query("date") date?: string, + @Query("sizes") sizes?: string, + @Query("cargoTypeId") cargoTypeId?: string, + @Query("wagons") wagons?: string, + ) { + return this.completionService.trainsForDayMine(user.id, id, date, { + containerSizes: sizes ? sizes.split(",").filter(Boolean) : undefined, + cargoTypeId: cargoTypeId || undefined, + wagons: wagons ? Number(wagons) : undefined, + }); + } + + @Post(":id/price-preview") + @PortalCustomer() + @ApiOperation({ + summary: + "Authoritative price quote for the completion payload — same compute as /complete, saved as the booking's breakdown + rate snapshots (refreshed on every re-preview). Persists nothing else.", + }) + async pricePreview( + @CurrentUser() user: CurrentIamUser, + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: CompleteShippingLineBookingDto, + ) { + return this.completionService.previewPriceMine(user.id, id, dto); + } + + @Get(":id/operations") + @PortalCustomer() + @ApiOperation({ + summary: + "Operations view of the booking: the train it rides (assigned or requested) and the wagons allocated to it.", + }) + async operations( + @CurrentUser() user: CurrentIamUser, + @Param("id", ParseUUIDPipe) id: string, + ) { + return this.completionService.operationsMine(user.id, id); + } + + @Post(":id/complete") + @PortalCustomer() + @ApiOperation({ + summary: + "Complete an approved (CLEARANCE_READY) booking: cargo + binding shipment day. Prices off the line's rates, records the charge on the credit ledger and requests operation.", + }) + async completeMine( + @CurrentUser() user: CurrentIamUser, + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: CompleteShippingLineBookingDto, + ) { + return this.completionService.completeMine(user.id, id, dto); + } +} diff --git a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-booking-completion.module.ts b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-booking-completion.module.ts new file mode 100644 index 000000000..470984c70 --- /dev/null +++ b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-booking-completion.module.ts @@ -0,0 +1,29 @@ +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; + +import { BookingsModule } from "../bookings/bookings.module"; +import { Booking } from "../bookings/entities/booking.entity"; +import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.module"; +import { ShippingLineBookingCompletionController } from "./shipping-line-booking-completion.controller"; +import { ShippingLineBookingCompletionService } from "./shipping-line-booking-completion.service"; +import { ShippingLineCompaniesModule } from "./shipping-line-companies.module"; + +/** + * Deliberately a LEAF module — registered in AppModule and imported by + * nothing. Completion needs BookingsModule (pricing + the operation-request + * transition), but ShippingLineCompaniesModule sits under rule-engine and + * companies, which sit under BookingsModule; importing bookings from there + * closes a module cycle Nest cannot construct. Keeping the completion flow + * here keeps the graph acyclic with no forwardRef chains. + */ +@Module({ + imports: [ + TypeOrmModule.forFeature([Booking]), + BookingsModule, + TrainSchedulingModule, + ShippingLineCompaniesModule, + ], + controllers: [ShippingLineBookingCompletionController], + providers: [ShippingLineBookingCompletionService], +}) +export class ShippingLineBookingCompletionModule {} diff --git a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-booking-completion.service.ts b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-booking-completion.service.ts new file mode 100644 index 000000000..0f7be3f18 --- /dev/null +++ b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-booking-completion.service.ts @@ -0,0 +1,766 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { In, Repository } from "typeorm"; + +import { BookingPricingService } from "../bookings/booking-pricing.service"; +import { BookingTransitionService } from "../bookings/booking-transition.service"; +import { BookingsService } from "../bookings/bookings.service"; +import { BookingContainer } from "../bookings/entities/booking-container.entity"; +import { BookingContainerUnit } from "../bookings/entities/booking-container-unit.entity"; +import { Booking } from "../bookings/entities/booking.entity"; +import { wagonsPerUnitForSize } from "../rule-engine/container-type.util"; +import { CargoType } from "../rule-engine/entities/cargo-type.entity"; +import { ContainerType } from "../rule-engine/entities/container-type.entity"; +import { eatDay } from "../train-scheduling/batch-window.util"; +import { + BookingBatchService, + type TrainOptionCargoOverrides, +} from "../train-scheduling/booking-batch.service"; +import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity"; +import { WagonBookingAllocation } from "../train-schedules/entities/wagon-booking-allocation.entity"; +import { TrainSchedulingService } from "../train-scheduling/services/train-scheduling.service"; +import { CompleteShippingLineBookingDto } from "./dto/complete-shipping-line-booking.dto"; +import { + ShippingLineCredit, + ShippingLineCreditStatus, +} from "./entities/shipping-line-credit.entity"; +import { ShippingLineCompaniesService } from "./shipping-line-companies.service"; +import { ShippingLineCreditsService } from "./shipping-line-credits.service"; + +/** + * Completion of a shipping-line booking — the step after Operations approves + * its documents, mirroring what a customer does at that point: cargo + binding + * shipment day go in, the booking prices off the line's negotiated rates and + * the request lands with Operations. + * + * Its own module (not part of {@link ShippingLineBookingsService}) because it + * needs BookingsModule (pricing, the operation-request transition) and + * TrainSchedulingModule — and ShippingLineCompaniesModule is imported by + * rule-engine/companies, which sit UNDER BookingsModule. Importing bookings + * from there closes a module cycle Nest cannot construct; a leaf module that + * nothing imports keeps the graph acyclic. + */ +@Injectable() +export class ShippingLineBookingCompletionService { + constructor( + @InjectRepository(Booking) + private readonly bookingsRepository: Repository, + private readonly shippingLineCompaniesService: ShippingLineCompaniesService, + private readonly bookingsService: BookingsService, + private readonly bookingPricingService: BookingPricingService, + private readonly bookingTransitionService: BookingTransitionService, + private readonly trainSchedulingService: TrainSchedulingService, + private readonly bookingBatchService: BookingBatchService, + private readonly creditsService: ShippingLineCreditsService, + ) {} + + /** Same session→owner resolution every shipping-line entry point uses. */ + private async requireShippingLine(userId: string) { + const shippingLine = + await this.shippingLineCompaniesService.findByUserId(userId); + if (!shippingLine) { + throw new ForbiddenException("This account is not a shipping line."); + } + if (shippingLine.status !== "active") { + throw new ForbiddenException( + "This shipping-line account is suspended and cannot create bookings.", + ); + } + return shippingLine; + } + + private async requireOwnBooking( + userId: string, + bookingId: string, + relations?: { bookingContainers?: boolean }, + ) { + const shippingLine = await this.requireShippingLine(userId); + const booking = await this.bookingsRepository.findOne({ + where: { id: bookingId, shippingLineCompanyId: shippingLine.id }, + relations, + }); + if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); + return booking; + } + + /** + * The line's dedicated departures on the booking's lane (DRAFT/SCHEDULED, + * soonest first). These trains run NO booking-window cycle — the line books + * whenever it wants until the close offset stamped in `windowClosesAt` — and + * they are excluded from every customer pool, so this is the only source + * that can offer them. + */ + private async dedicatedTrainsForBooking(booking: Booking) { + if (!booking.shippingLineCompanyId) return []; + return this.bookingsRepository.manager.getRepository(TrainSchedule).find({ + where: { + shippingLineCompanyId: booking.shippingLineCompanyId, + originStationId: booking.originYardId ?? undefined, + destinationStationId: booking.destinationYardId ?? undefined, + status: In(["DRAFT", "SCHEDULED"]), + }, + order: { scheduledDepartureDate: "ASC" }, + }); + } + + /** Still bookable: the close offset before departure has not passed yet. */ + private isStillOpen(schedule: TrainSchedule): boolean { + const closesAt = + schedule.windowClosesAt ?? schedule.scheduledDepartureDate; + return closesAt.getTime() > Date.now(); + } + + /** + * The line's dedicated trains on the booking's lane for one shipment day, + * each with per-wagon-type free space — the completion form's train picker. + * A booking rides ONE schedule, so with several departures that day the + * line picks which; the pick is validated again at complete time. + */ + async trainsForDayMine( + userId: string, + bookingId: string, + date: string | undefined, + overrides?: TrainOptionCargoOverrides, + ) { + const booking = await this.requireOwnBooking(userId, bookingId); + if (!booking.shippingLineCompanyId) return []; + return this.bookingBatchService.dedicatedTrainOptionsForDay( + booking, + date ? eatDay(new Date(date)) : null, + booking.shippingLineCompanyId, + overrides, + ); + } + + /** + * Days the shipping line may pick as the shipment day. + * + * Lanes with trains DEDICATED to this line offer exactly those trains' days, + * open until each train's close offset — no window cycle. Lanes without a + * dedicated train fall back to the shared customer day pool, exactly as + * before. Ownership is checked first so one line cannot probe another's + * booking. + */ + async availableDaysMine(userId: string, bookingId: string) { + const booking = await this.requireOwnBooking(userId, bookingId); + const dedicated = await this.dedicatedTrainsForBooking(booking); + if (dedicated.length === 0) { + return this.bookingsService.availableDaysForBooking(bookingId); + } + const days = [ + ...new Set( + dedicated + .filter((s) => this.isStillOpen(s)) + .map((s) => eatDay(s.scheduledDepartureDate)), + ), + ]; + return { days }; + } + + /** + * Complete a bare shipping-line booking once Operations has approved its + * documents (CLEARANCE_READY), or after Operations returned the request + * (OPERATION_CHANGES_REQUESTED). The cargo and the binding shipment day go + * in, the booking is priced off the line's negotiated rates, and the request + * lands with Operations (OPERATION_REQUEST_PENDING) through the same + * transition customers use. + * + * Payment differs from customers by design: no invoice is issued here. + * Shipping lines run on the credit ledger — the priced amount is recorded as + * an UNBILLED credit and Finance bills a batch later, so the booking + * proceeds without a payment gate. + */ + async completeMine( + userId: string, + bookingId: string, + dto: CompleteShippingLineBookingDto, + ) { + const booking = await this.requireOwnBooking(userId, bookingId, { + bookingContainers: true, + }); + if ( + !["CLEARANCE_READY", "OPERATION_CHANGES_REQUESTED"].includes( + booking.status, + ) + ) { + throw new BadRequestException( + "Your documents must be approved before the booking can be completed.", + ); + } + + // Completion is booking time. A lane with trains DEDICATED to this line + // has no window concept at all: the line books whenever it wants until the + // train's close offset. Only a lane with no dedicated train falls back to + // the customer window gate, unchanged. + const dedicated = await this.dedicatedTrainsForBooking(booking); + const pickedDay = eatDay(new Date(dto.scheduledDate)); + const dedicatedOnDay = dedicated.filter( + (s) => eatDay(s.scheduledDepartureDate) === pickedDay, + ); + let bypassDayPool = false; + let requestedTrainScheduleId: string | null = null; + if (dedicatedOnDay.length > 0) { + const openOnDay = dedicatedOnDay.filter((s) => this.isStillOpen(s)); + if (openOnDay.length === 0) { + throw new BadRequestException( + "Booking for your train on this day has closed — the cut-off before departure has passed.", + ); + } + // A booking rides ONE schedule. Several departures that day → the line + // must say which; a single one is picked implicitly. The id comes from + // the request, so it is validated against the day's own trains. + if (dto.trainScheduleId) { + const picked = openOnDay.find((s) => s.id === dto.trainScheduleId); + if (!picked) { + throw new BadRequestException( + "The selected train does not run your route on that day (or its booking cut-off has passed) — pick another train.", + ); + } + requestedTrainScheduleId = picked.id; + } else if (openOnDay.length === 1) { + requestedTrainScheduleId = openOnDay[0].id; + } else { + throw new BadRequestException( + "More than one of your trains departs that day — select which train this booking rides.", + ); + } + // The day is backed by the line's own train, which every customer pool + // deliberately excludes — so the day-pool gate downstream must not run. + bypassDayPool = true; + } else if (dedicated.length > 0) { + throw new BadRequestException( + "Pick one of your assigned train days for this route.", + ); + } else { + await this.trainSchedulingService.assertBookingWindowOpen({ + originYardId: booking.originYardId ?? null, + destinationYardId: booking.destinationYardId ?? null, + scheduledDate: dto.scheduledDate, + direction: booking.tradeDirection ?? null, + }); + } + + let hasCargo = + (booking.bookingContainers?.length ?? 0) > 0 || + Number(booking.cargoTotalWeightVgm) > 0; + const restatesCargo = Boolean( + dto.containers?.length || dto.cargoTypeId || dto.cargoWeightTons, + ); + + // Operations may return the request asking for the CARGO to change, not + // just the day. A resubmit that restates cargo starts completion over: + // the recorded (unbilled) credit is written off and the persisted cargo + // wiped, so the fresh path below re-persists, re-prices and re-records. + // Once the credit is on an issued invoice the cargo is frozen — the + // invoice total must keep matching what it bills. + if (hasCargo && restatesCargo) { + const credit = await this.bookingsRepository.manager + .getRepository(ShippingLineCredit) + .findOne({ where: { bookingId } }); + if (credit && credit.status === ShippingLineCreditStatus.Unbilled) { + await this.creditsService.cancelCredit( + credit.id, + "Cargo changed before billing — booking re-priced on completion.", + ); + } else if ( + credit && + credit.status !== ShippingLineCreditStatus.Cancelled + ) { + throw new BadRequestException( + "This booking's charge has already been invoiced — contact Operations to change its cargo.", + ); + } + await this.wipeCargo(bookingId); + hasCargo = false; + } + + // First completion persists cargo and prices the booking; a day-only + // resubmit after OPERATION_CHANGES_REQUESTED skips straight to the + // operation request with the cargo (and price) it already carries. + if (!hasCargo) { + if (booking.freightType === "CONTAINER") { + await this.persistContainerLines(booking, dto); + } else { + if (!dto.cargoTypeId || !(Number(dto.cargoWeightTons) > 0)) { + throw new BadRequestException( + "Bulk bookings need a cargo type and a total weight in tons.", + ); + } + const cargoType = await this.bookingsRepository.manager + .getRepository(CargoType) + .findOne({ where: { id: dto.cargoTypeId, isActive: true } }); + if (!cargoType) { + throw new NotFoundException( + `Cargo type ${dto.cargoTypeId} not found`, + ); + } + } + + await this.bookingsRepository.update(bookingId, { + cargoTypeId: + booking.freightType === "BULK" ? (dto.cargoTypeId ?? null) : null, + cargoFreeText: dto.cargoFreeText?.trim() || null, + cargoTotalWeightVgm: + booking.freightType === "BULK" ? Number(dto.cargoWeightTons) : 0, + bulkTotalWeightTons: + booking.freightType === "BULK" ? Number(dto.cargoWeightTons) : null, + // Bulk handling portions — sized against the cargo, billed by pricing. + ...(booking.freightType === "BULK" + ? { + bulkHazardousQuantity: Number(dto.bulkHazardousQuantity ?? 0), + bulkReeferQuantity: Number(dto.bulkReeferQuantity ?? 0), + } + : {}), + // Hazard is per-line for containers; the booking-level flag is what + // pricing bills the surcharge from. + isHazardous: + (dto.containers ?? []).some( + (line) => + Number(line.hazardousQuantity ?? 0) > 0 || + (line.units ?? []).some((u) => u.isHazardous), + ) || Number(dto.bulkHazardousQuantity ?? 0) > 0, + // Same for reefer: the rule engine's REEFER trigger fires on the + // booking-level flag (or a reefer container TYPE) — a ticked reefer + // switch on a standard box only sets the per-line count, so without + // this flag the surcharge silently never bills. + isReefer: + (dto.containers ?? []).some( + (line) => + Number(line.reeferQuantity ?? 0) > 0 || + (line.units ?? []).some((u) => u.isReefer), + ) || Number(dto.bulkReeferQuantity ?? 0) > 0, + // Shipping lines are always billed in ETB: the charge lands on the + // ETB credit ledger, so the currency is enforced here rather than + // trusted from the payload. + paymentCurrency: "ETB", + } as never); + + const loaded = await this.bookingsRepository.findOne({ + where: { id: bookingId }, + relations: { bookingContainers: true, serviceType: true }, + }); + const computed = await this.bookingPricingService.computePriceForBooking( + loaded ?? booking, + ); + // A zero price or hard block means no rate is configured for this line + // on this lane. Roll the cargo back so the booking stays completable — + // the approved clearance is not lost — and surface why. + if (!(computed.totalAmount > 0) || computed.hardBlocked.length > 0) { + await this.wipeCargo(bookingId); + throw new BadRequestException( + computed.hardBlocked.length > 0 + ? computed.hardBlocked.join("; ") + : "No rate is configured for your shipping line on this route/cargo — please contact Operations.", + ); + } + + await this.bookingsRepository.update(bookingId, { + totalAmount: computed.totalAmount, + priorityScore: computed.priorityScore, + pricingBreakdown: { + lineItems: computed.lineItems, + totalAmount: computed.totalAmount, + currency: computed.currency, + generatedAt: new Date().toISOString(), + }, + } as never); + await this.bookingPricingService.createPricingSnapshots( + bookingId, + computed.usedRates, + computed.appliedModifiers, + ); + + // No credit is recorded here: completion only REQUESTS the operation. + // The charge lands on the line's ledger when Operations accepts — + // `shipping_line_booking.accepted` → ShippingLineCreditsService — so a + // request that is returned or never accepted creates no debt. + } + + // Binding day, OPERATION_REQUEST_PENDING and the staff notification — the + // machine a customer booking uses. When the day is backed by a dedicated + // train, the customer day-pool gate is skipped (validated above instead). + return this.bookingTransitionService.requestOperation( + bookingId, + dto.scheduledDate, + requestedTrainScheduleId, + bypassDayPool ? { bypassDayPool: true } : undefined, + ); + } + + /** + * Authoritative price preview for the completion form's confirm step: the + * SAME compute the completion itself runs, over an in-memory probe shaped + * exactly like completeMine would persist the booking — so the figure the + * shipping line confirms is line-for-line what it will owe. + * + * The result is not advisory-only: the breakdown is saved on the booking and + * the rate snapshots are (re)written, so every re-preview refreshes them. + * Nothing else is persisted — no cargo rows, no credit, no transition. + */ + async previewPriceMine( + userId: string, + bookingId: string, + dto: CompleteShippingLineBookingDto, + ) { + const booking = await this.requireOwnBooking(userId, bookingId, { + bookingContainers: true, + }); + if ( + !["CLEARANCE_READY", "OPERATION_CHANGES_REQUESTED"].includes( + booking.status, + ) + ) { + throw new BadRequestException( + "Your documents must be approved before the booking can be priced.", + ); + } + + // In-memory cargo, mirroring what completeMine persists. + let probeContainers: Partial[] = []; + let bulkFields: Record = {}; + if (booking.freightType === "CONTAINER") { + const lines = dto.containers ?? []; + if (!lines.length) { + throw new BadRequestException( + "At least one container line is required.", + ); + } + for (const line of lines) { + const containerType = await this.resolveContainerType(line); + const figures = this.lineFigures(line); + probeContainers.push({ + containerTypeId: containerType.id, + containerSize: containerType.sizeFt + ? `${containerType.sizeFt}ft` + : null, + quantity: line.quantity, + hazardousQuantity: figures.hazardous, + reeferQuantity: figures.reefer, + returnQuantity: 0, + vgmPerUnitTons: figures.vgmPerUnit, + totalVgmTons: figures.totalVgm, + wagonsRequired: Math.ceil( + line.quantity * wagonsPerUnitForSize(containerType.sizeFt), + ), + }); + } + } else { + if (!dto.cargoTypeId || !(Number(dto.cargoWeightTons) > 0)) { + throw new BadRequestException( + "Bulk bookings need a cargo type and a total weight in tons.", + ); + } + const cargoType = await this.bookingsRepository.manager + .getRepository(CargoType) + .findOne({ where: { id: dto.cargoTypeId, isActive: true } }); + if (!cargoType) { + throw new NotFoundException(`Cargo type ${dto.cargoTypeId} not found`); + } + bulkFields = { + cargoTypeId: dto.cargoTypeId, + cargoTotalWeightVgm: Number(dto.cargoWeightTons), + bulkTotalWeightTons: Number(dto.cargoWeightTons), + bulkHazardousQuantity: Number(dto.bulkHazardousQuantity ?? 0), + bulkReeferQuantity: Number(dto.bulkReeferQuantity ?? 0), + }; + probeContainers = []; + } + + // Prototype-preserving clone so entity getters keep working — the same + // probe trick the contract preview uses. + const probe = Object.assign( + Object.create(Object.getPrototypeOf(booking)), + booking, + { + bookingContainers: probeContainers, + paymentCurrency: "ETB", + isHazardous: + (dto.containers ?? []).some( + (line) => + Number(line.hazardousQuantity ?? 0) > 0 || + (line.units ?? []).some((u) => u.isHazardous), + ) || Number(dto.bulkHazardousQuantity ?? 0) > 0, + // Mirrors completeMine: without the booking-level flag the engine's + // REEFER trigger never fires for reefer opt-ins on standard boxes, + // and the quote would show base freight only. + isReefer: + (dto.containers ?? []).some( + (line) => + Number(line.reeferQuantity ?? 0) > 0 || + (line.units ?? []).some((u) => u.isReefer), + ) || Number(dto.bulkReeferQuantity ?? 0) > 0, + ...bulkFields, + }, + ) as Booking; + + const computed = + await this.bookingPricingService.computePriceForBooking(probe); + if (!(computed.totalAmount > 0) || computed.hardBlocked.length > 0) { + throw new BadRequestException( + computed.hardBlocked.length > 0 + ? computed.hardBlocked.join("; ") + : "No rate is configured for your shipping line on this route/cargo — please contact Operations.", + ); + } + + // Persist the quoted figure: breakdown on the booking, snapshots of the + // rates it was built from. createPricingSnapshots clears the previous + // artifacts first, so a re-preview replaces the old quote rather than + // stacking a second one. + await this.bookingsRepository.update(bookingId, { + pricingBreakdown: { + lineItems: computed.lineItems, + totalAmount: computed.totalAmount, + currency: computed.currency, + generatedAt: new Date().toISOString(), + }, + } as never); + await this.bookingPricingService.createPricingSnapshots( + bookingId, + computed.usedRates, + computed.appliedModifiers, + ); + + return { + totalAmount: computed.totalAmount, + currency: computed.currency, + lineItems: computed.lineItems, + warnings: computed.warnings, + }; + } + + /** + * What operations has done with the booking so far: the train it rides + * (assigned, or the requested one before assignment) and the wagons the + * batch engine allocated to it, with any container numbers loaded per wagon. + * Read-only, owner-scoped — feeds the detail page's Wagons & Train tab. + */ + async operationsMine(userId: string, bookingId: string) { + const booking = await this.requireOwnBooking(userId, bookingId); + const manager = this.bookingsRepository.manager; + + const scheduleId = + booking.trainScheduleId ?? booking.requestedTrainScheduleId ?? null; + let train: Record | null = null; + if (scheduleId) { + const schedule = await manager.getRepository(TrainSchedule).findOne({ + where: { id: scheduleId }, + relations: { originStation: true, destinationStation: true }, + }); + if (schedule) { + train = { + id: schedule.id, + reference: schedule.reference, + trainNumber: schedule.trainNumber, + status: schedule.status, + direction: schedule.direction, + scheduledDepartureDate: schedule.scheduledDepartureDate, + scheduledArrivalDate: schedule.scheduledArrivalDate, + originLabel: + schedule.originStation?.label ?? + schedule.originStation?.code ?? + "Origin", + destinationLabel: + schedule.destinationStation?.label ?? + schedule.destinationStation?.code ?? + "Destination", + // Whether this is the confirmed assignment or still the request. + assigned: Boolean(booking.trainScheduleId), + }; + } + } + + const allocations = await manager + .getRepository(WagonBookingAllocation) + .find({ + where: { bookingId }, + relations: { + trainSetWagon: { wagonType: true, physicalWagon: true }, + containerItems: true, + }, + order: { createdAt: "ASC" }, + }); + + const wagons = allocations.map((allocation) => ({ + id: allocation.id, + status: allocation.status, + loadType: allocation.loadType, + allocatedWeightTons: Number(allocation.allocatedWeightTons), + sequenceNo: allocation.trainSetWagon?.sequenceNo ?? null, + wagonNumber: allocation.trainSetWagon?.physicalWagon?.wagonNumber ?? null, + wagonType: + allocation.trainSetWagon?.wagonType?.name ?? + allocation.trainSetWagon?.wagonType?.code ?? + null, + capacityTons: Number(allocation.trainSetWagon?.capacityTons ?? 0), + containerNumbers: (allocation.containerItems ?? []) + .map((item) => item.containerNumber) + .filter((n): n is string => Boolean(n)), + })); + + return { train, wagons }; + } + + /** + * Resolve a line's container type: by id when the payload carries one, else + * from the size string ("40ft" → the active 40ft type, preferring the reefer + * variant when the line ships reefer boxes). Resolution lives HERE, not in + * the portal, so a slow or failed catalog fetch can never block a booking + * with a phantom "type not configured" error — mirrors the customer flow's + * server-side size→type mapping. + */ + private async resolveContainerType(line: { + containerTypeId?: string; + containerSize?: string; + reeferQuantity?: number; + units?: { isReefer?: boolean }[]; + }): Promise { + const containerTypeRepo = + this.bookingsRepository.manager.getRepository(ContainerType); + + if (line.containerTypeId) { + const byId = await containerTypeRepo.findOne({ + where: { id: line.containerTypeId, isActive: true }, + }); + if (!byId) { + throw new NotFoundException( + `Container type ${line.containerTypeId} not found`, + ); + } + return byId; + } + + const sizeFt = parseInt(line.containerSize ?? "", 10); + if (!Number.isFinite(sizeFt)) { + throw new BadRequestException( + "Each container line needs a containerTypeId or a containerSize.", + ); + } + const candidates = await containerTypeRepo.find({ + where: { isActive: true }, + }); + const ofSize = candidates.filter((ct) => Number(ct.sizeFt) === sizeFt); + if (!ofSize.length) { + throw new BadRequestException( + `No ${sizeFt}ft container type is configured — please contact Operations.`, + ); + } + const wantsReefer = + Number(line.reeferQuantity ?? 0) > 0 || + (line.units ?? []).some((u) => u.isReefer); + if (wantsReefer) { + const reefer = ofSize.find((ct) => ct.isReefer); + if (reefer) return reefer; + } + return ofSize.find((ct) => !ct.isReefer) ?? ofSize[0]; + } + + /** + * A line's derived figures. With per-container rows (the full booking page), + * counts and VGM come FROM the rows — each container's switches are the + * source of truth. Without them, the line-level figures stand alone. + */ + private lineFigures(line: { + quantity: number; + vgmPerUnitTons?: number; + hazardousQuantity?: number; + reeferQuantity?: number; + units?: { vgmTons?: number; isHazardous?: boolean; isReefer?: boolean }[]; + }) { + const units = line.units ?? []; + const hazardous = units.length + ? units.filter((u) => u.isHazardous).length + : Math.min(Number(line.hazardousQuantity ?? 0), line.quantity); + const reefer = units.length + ? units.filter((u) => u.isReefer).length + : Math.min(Number(line.reeferQuantity ?? 0), line.quantity); + const totalVgm = units.length + ? units.reduce((s, u) => s + Number(u.vgmTons ?? 0), 0) + : Number(line.vgmPerUnitTons ?? 0) * line.quantity; + const vgmPerUnit = units.length + ? totalVgm / units.length + : Number(line.vgmPerUnitTons ?? 0); + return { hazardous, reefer, totalVgm, vgmPerUnit }; + } + + /** + * Persist the container lines of a CONTAINER completion. Same row shape the + * customer paths write (quantity per type, VGM totals, wagon share) — the + * per-unit ISO numbers customers also skip at booking time arrive later at + * yard operations. + */ + private async persistContainerLines( + booking: Booking, + dto: CompleteShippingLineBookingDto, + ): Promise { + const lines = dto.containers ?? []; + if (!lines.length) { + throw new BadRequestException("At least one container line is required."); + } + + const containerRepo = + this.bookingsRepository.manager.getRepository(BookingContainer); + const unitRepo = + this.bookingsRepository.manager.getRepository(BookingContainerUnit); + + for (const line of lines) { + const containerType = await this.resolveContainerType(line); + // Counts and VGM derived by lineFigures — the same math the price + // preview runs, so the persisted cargo always matches the quote. + const units = line.units ?? []; + const figures = this.lineFigures(line); + const containerRow = await containerRepo.save( + containerRepo.create({ + bookingId: booking.id, + containerTypeId: containerType.id, + containerSize: containerType.sizeFt + ? `${containerType.sizeFt}ft` + : null, + quantity: line.quantity, + hazardousQuantity: figures.hazardous, + reeferQuantity: figures.reefer, + returnQuantity: 0, + vgmPerUnitTons: figures.vgmPerUnit, + totalVgmTons: figures.totalVgm, + wagonsRequired: Math.ceil( + line.quantity * wagonsPerUnitForSize(containerType.sizeFt), + ), + }), + ); + let sortOrder = 0; + for (const unit of units) { + await unitRepo.save( + unitRepo.create({ + bookingContainerId: containerRow.id, + containerNumber: unit.containerNumber.trim().toUpperCase(), + sealNumber: unit.sealNumber?.trim() || null, + vgmTons: Number(unit.vgmTons ?? 0), + isHazardous: unit.isHazardous ?? false, + isReefer: unit.isReefer ?? false, + isReturn: false, + sortOrder: sortOrder++, + }), + ); + } + } + } + + /** Roll a failed/superseded completion back to the bare-booking shape. */ + private async wipeCargo(bookingId: string): Promise { + await this.bookingsRepository.manager + .getRepository(BookingContainer) + .softDelete({ bookingId }); + await this.bookingsRepository.update(bookingId, { + cargoTypeId: null, + cargoTotalWeightVgm: 0, + bulkTotalWeightTons: null, + totalAmount: 0, + pricingBreakdown: null, + } as never); + } +} diff --git a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-bookings.controller.ts b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-bookings.controller.ts new file mode 100644 index 000000000..8a8e8ef2b --- /dev/null +++ b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-bookings.controller.ts @@ -0,0 +1,107 @@ +import { CurrentUser } from "@edr/api-common"; +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Post, +} from "@nestjs/common"; +import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; + +import { PortalCustomer } from "../../common/booking-guards"; +import { CancelShippingLineBookingDto } from "./dto/cancel-shipping-line-booking.dto"; +import { InitiateShippingLineBookingDto } from "./dto/initiate-shipping-line-booking.dto"; +import { ShippingLineBookingsService } from "./shipping-line-bookings.service"; + +interface CurrentIamUser { + id: string; +} + +/** + * Bookings a shipping line makes for itself, from the portal. + * + * Separate from `/bookings` (customers) on purpose — see + * {@link ShippingLineBookingsService} for why the two flows are not merged. + * `PortalCustomer` only proves a valid portal session; the service resolves the + * shipping-line account from that session and rejects anyone else, so the owner + * is never taken from the request body. + */ +@ApiTags("shipping-line-bookings") +@Controller("shipping-line-bookings") +@ApiBearerAuth() +export class ShippingLineBookingsController { + constructor( + private readonly shippingLineBookingsService: ShippingLineBookingsService, + ) {} + + @Post("initiate") + @PortalCustomer() + @ApiOperation({ + summary: + "Initiate a bare booking (no contract). Starts at AWAITING_DOCUMENTS so the shipping line can upload its documents for Operations to approve.", + }) + async initiate( + @CurrentUser() user: CurrentIamUser, + @Body() dto: InitiateShippingLineBookingDto, + ) { + return this.shippingLineBookingsService.initiate(user.id, dto); + } + + // Declared before @Get(":id") so the path isn't captured as a booking id. + @Get("reference-data") + @PortalCustomer() + @ApiOperation({ + summary: + "Catalog for the initiate form: bookable routes (each carrying its trade direction) and service types.", + }) + async referenceData(@CurrentUser() user: CurrentIamUser) { + return this.shippingLineBookingsService.referenceData(user.id); + } + + @Get("my") + @PortalCustomer() + @ApiOperation({ summary: "List the signed-in shipping line's bookings." }) + async listMine(@CurrentUser() user: CurrentIamUser) { + return this.shippingLineBookingsService.listMine(user.id); + } + + // Declared before @Get(":id") so the path isn't captured as a booking id. + @Get("my-trains") + @PortalCustomer() + @ApiOperation({ + summary: + "Train departures dedicated to the signed-in shipping line. These trains are hidden from customers; this is the only portal read that surfaces them.", + }) + async listMyTrains(@CurrentUser() user: CurrentIamUser) { + return this.shippingLineBookingsService.listMyTrains(user.id); + } + + @Get(":id") + @PortalCustomer() + @ApiOperation({ summary: "Get one of the signed-in shipping line's bookings." }) + async findMine( + @CurrentUser() user: CurrentIamUser, + @Param("id", ParseUUIDPipe) id: string, + ) { + return this.shippingLineBookingsService.findMine(user.id, id); + } + + @Post(":id/cancel") + @PortalCustomer() + @ApiOperation({ + summary: + "Cancel one of the signed-in shipping line's own bookings. Allowed only before the booking is priced.", + }) + async cancelMine( + @CurrentUser() user: CurrentIamUser, + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: CancelShippingLineBookingDto, + ) { + return this.shippingLineBookingsService.cancelMine( + user.id, + id, + dto.reason, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-bookings.service.ts b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-bookings.service.ts new file mode 100644 index 000000000..da1b90129 --- /dev/null +++ b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-bookings.service.ts @@ -0,0 +1,473 @@ +import { insertWithGeneratedReference } from "@edr/api-common"; +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { In, MoreThanOrEqual, Repository } from "typeorm"; + +import { BookingDocumentReview } from "../bookings/entities/booking-document-review.entity"; +import { BookingReviewNote } from "../bookings/entities/booking-review-note.entity"; +import { Booking } from "../bookings/entities/booking.entity"; +import { formatRouteLabel, Route } from "../routes/entities/route.entity"; +import { CargoType } from "../rule-engine/entities/cargo-type.entity"; +import { ContainerType } from "../rule-engine/entities/container-type.entity"; +import { ServiceType } from "../rule-engine/entities/service-type.entity"; +import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity"; +import { InitiateShippingLineBookingDto } from "./dto/initiate-shipping-line-booking.dto"; +import { ShippingLineCompaniesService } from "./shipping-line-companies.service"; + +/** + * The only trade direction a shipping line books. + * + * Their cargo arrives by sea at Djibouti and moves inland to Ethiopia, which is + * IMPORT by the rule routes are stamped with (DJ→ET = IMPORT, ET→DJ = EXPORT, + * same country = DOMESTIC). Export and intercity lanes are therefore neither + * offered nor accepted. + */ +const SHIPPING_LINE_DIRECTION = "IMPORT"; + +/** + * Statuses a shipping line may cancel its own booking from — everything before + * the booking is priced. Past this point cancelling has billing consequences + * (fees, credit notes) and belongs with Operations. + */ +const SHIPPING_LINE_CANCELLABLE_STATUSES: string[] = [ + "AWAITING_DOCUMENTS", + "DOCUMENTS_UNDER_REVIEW", + "CLEARANCE_READY", + "CHANGES_REQUESTED", +]; + +/** + * Booking creation for shipping lines. + * + * Deliberately separate from `BookingsService` / `ContractBookingService` + * rather than a branch inside them. Those are built end to end around a + * customer: a `companies` row, an approved operational `company_profile`, a + * contract supplying route/quantities, and contract-capacity accounting. A + * shipping line has none of that — it books directly, without a contract — so + * branching there would mean threading "no company, no profile, no contract" + * through every method a customer booking passes through. Keeping it here means + * the customer paths are not touched at all. + * + * What IS shared is the table and the downstream lifecycle: the row lands in + * `freight.bookings` at `AWAITING_DOCUMENTS`, the shipping line uploads its + * documents against the `shipping_line_booking_documents` file-upload setting, + * and Operations reviews and finalizes them through the same clearance flow + * customers already use. + */ +@Injectable() +export class ShippingLineBookingsService { + constructor( + @InjectRepository(Booking) + private readonly bookingsRepository: Repository, + private readonly shippingLineCompaniesService: ShippingLineCompaniesService, + ) {} + + /** + * Resolve the shipping-line account for a signed-in user, or reject. Every + * entry point goes through this: the owner is taken from the session, never + * from the request body, so one shipping line cannot book as another. + */ + private async requireShippingLine(userId: string) { + const shippingLine = + await this.shippingLineCompaniesService.findByUserId(userId); + if (!shippingLine) { + throw new ForbiddenException("This account is not a shipping line."); + } + if (shippingLine.status !== "active") { + throw new ForbiddenException( + "This shipping-line account is suspended and cannot create bookings.", + ); + } + return shippingLine; + } + + /** + * The catalog the initiate form needs: the lanes EDR actually runs, and the + * services that can be booked on their own. + * + * Routes are offered instead of two loose yard pickers so a shipping line + * cannot invent a lane that does not exist — and because the route already + * carries its trade direction, which is otherwise guesswork. + * + * Read-only and scoped to bookable rows, which is why it lives here rather + * than reusing the staff `/routes` controller (gated behind fleet + * permissions a shipping line does not and should not hold). + */ + async referenceData(userId: string) { + await this.requireShippingLine(userId); + + const [routes, serviceTypes, containerTypes, cargoTypes] = + await Promise.all([ + this.bookingsRepository.manager.getRepository(Route).find({ + // Shipping lines only move inbound cargo: it lands at the Djibouti port + // and runs inland to Ethiopia. Filtering here rather than in the portal + // means an export or intercity lane is never offered AND never + // accepted — `initiate` re-checks the same rule below. + where: { status: "AVAILABLE", direction: SHIPPING_LINE_DIRECTION }, + relations: { originYard: true, destinationYard: true }, + }), + // Customs-bundled services are excluded: those run the phased ET/DJ + // customs workflow, which is a contract-backed flow a shipping line has + // no part in. Their clearance is the single document set Operations + // reviews on the booking itself. + this.bookingsRepository.manager.getRepository(ServiceType).find({ + where: { + canBeBookedAlone: true, + includesCustoms: false, + isActive: true, + }, + order: { displayOrder: "ASC" }, + }), + // For the completion form: what ships. Container types for CONTAINER + // bookings, cargo types for BULK ones. + this.bookingsRepository.manager.getRepository(ContainerType).find({ + where: { isActive: true }, + }), + this.bookingsRepository.manager.getRepository(CargoType).find({ + where: { isActive: true }, + order: { displayOrder: "ASC" }, + }), + ]); + + return { + routes: routes.map((route) => ({ + id: route.id, + label: formatRouteLabel(route), + direction: route.direction, + originYardId: route.originYardId, + // Per-yard labels so the portal can offer origin and destination as two + // separate pickers (the shape the customer form uses) while still + // resolving the pair back to one of these routes. + originLabel: + route.originYard?.label ?? route.originYard?.code ?? "Origin", + destinationYardId: route.destinationYardId, + destinationLabel: + route.destinationYard?.label ?? + route.destinationYard?.code ?? + "Destination", + })), + serviceTypes: serviceTypes.map((service) => ({ + id: service.id, + name: service.serviceName, + })), + containerTypes: containerTypes.map((ct) => ({ + id: ct.id, + label: ct.label ?? ct.code, + sizeFt: ct.sizeFt, + isReefer: ct.isReefer, + })), + // parentGroupId lets the portal tell leaf types from grouping rows. + cargoTypes: cargoTypes.map((cargo) => ({ + id: cargo.id, + name: cargo.cargoTypeName, + parentGroupId: cargo.parentGroupId ?? null, + unitOfMeasure: cargo.unitOfMeasure ?? null, + })), + }; + } + + /** + * Create a BARE booking for a shipping line — no contract, no cargo, no date + * and no price. It exists so documents have something to hang off: the + * shipping line uploads them next, Operations approves, and only then is the + * booking completed with its cargo and shipment day. + */ + async initiate(userId: string, dto: InitiateShippingLineBookingDto) { + const shippingLine = await this.requireShippingLine(userId); + + // The route is the single source of origin, destination AND direction — + // resolved server-side so the three can never disagree, and so a caller + // cannot post a lane EDR does not run. + const route = await this.bookingsRepository.manager + .getRepository(Route) + .findOne({ where: { id: dto.routeId } }); + if (!route) { + throw new NotFoundException(`Route ${dto.routeId} not found`); + } + if (route.status !== "AVAILABLE") { + throw new BadRequestException( + "This route is not currently available for booking.", + ); + } + // Enforced here too, not just by filtering the picker: the route id comes + // from the request, so an export or intercity lane could otherwise be + // posted directly. + if (route.direction !== SHIPPING_LINE_DIRECTION) { + throw new BadRequestException( + "Shipping lines can only book inbound (Djibouti to Ethiopia) routes.", + ); + } + + // Only a forward-looking day makes sense; train validation happens later + // when Operations schedules it, so only the past is rejected here. + let scheduledDate: Date | null = null; + if (dto.scheduledDate) { + scheduledDate = new Date(dto.scheduledDate); + const today = new Date(); + today.setHours(0, 0, 0, 0); + if (scheduledDate < today) { + throw new BadRequestException( + "The scheduled date cannot be in the past.", + ); + } + } + + // Same reasoning as the picker filter: a customs-bundled service would put + // the booking into the phased customs workflow, which has no contract to + // hang off here. Checked server-side because the id comes from the request. + if (dto.serviceTypeId) { + const serviceType = await this.bookingsRepository.manager + .getRepository(ServiceType) + .findOne({ where: { id: dto.serviceTypeId } }); + if (!serviceType) { + throw new NotFoundException( + `Service type ${dto.serviceTypeId} not found`, + ); + } + if (serviceType.includesCustoms) { + throw new BadRequestException( + "Shipping lines cannot book a service that bundles customs clearance.", + ); + } + } + + return insertWithGeneratedReference( + () => this.generateReference(), + (reference) => + this.bookingsRepository.save({ + reference, + // The owner columns: a shipping-line booking has no company and no + // operational profile, which is exactly what the `chk_bookings_ + // single_owner` CHECK expects alongside a set shippingLineCompanyId. + companyId: null, + companyProfileId: null, + shippingLineCompanyId: shippingLine.id, + status: "AWAITING_DOCUMENTS", + bookingType: "ONE_TIME", + contractId: null, + contractType: "NEW", + createdByRole: "SHIPPING_LINE", + createdByUserId: userId, + // Taken from the chosen route, never from the request body: the + // direction is frozen on the route from its yard countries, so + // deriving it here keeps it consistent with scheduling and booking + // windows, which read the same field. + originYardId: route.originYardId, + destinationYardId: route.destinationYardId, + tradeDirection: route.direction, + serviceTypeId: dto.serviceTypeId ?? null, + freightType: dto.freightType ?? "CONTAINER", + // The shipping line picks its shipment day up front (no later + // operation-request step exists for them); cargo is still filled in + // when the booking is completed. + scheduledDate, + cargoTypeId: null, + cargoTotalWeightVgm: 0, + } as never), + ); + } + + /** + * List the bookings belonging to the signed-in shipping line, newest first. + * + * Each row carries `hasQueriedDocuments`: a reviewer querying a document sets + * that document's review status but leaves the BOOKING on + * DOCUMENTS_UNDER_REVIEW, so status alone cannot tell the list which bookings + * need the shipping line to act. Resolved in one grouped query rather than a + * clearance call per row. + */ + async listMine(userId: string) { + const shippingLine = await this.requireShippingLine(userId); + + const bookings = await this.bookingsRepository.find({ + where: { shippingLineCompanyId: shippingLine.id }, + relations: { originYard: true, destinationYard: true }, + order: { createdAt: "DESC" }, + }); + if (bookings.length === 0) return []; + + const queried = await this.bookingsRepository.manager + .getRepository(BookingDocumentReview) + .find({ + where: { + bookingId: In(bookings.map((b) => b.id)), + status: "QUERIED", + }, + select: { bookingId: true }, + }); + + const queriedIds = new Set(queried.map((row) => row.bookingId)); + + return bookings.map((booking) => ({ + ...booking, + hasQueriedDocuments: queriedIds.has(booking.id), + })); + } + + /** + * Fetch one of the signed-in shipping line's own bookings. Scoped by owner so + * an id belonging to a customer (or another shipping line) reads as missing. + */ + async findMine(userId: string, bookingId: string) { + const shippingLine = await this.requireShippingLine(userId); + + const booking = await this.bookingsRepository.findOne({ + where: { id: bookingId, shippingLineCompanyId: shippingLine.id }, + // Yards are loaded so the portal can render the lane without a second + // lookup — they are set at initiate time from the chosen route. Cargo + // (container lines + units, bulk cargo type) rides along for the detail + // page's cargo tab once the booking is completed. + relations: { + originYard: true, + destinationYard: true, + serviceType: true, + cargoType: true, + bookingContainers: { containerType: true, units: true }, + }, + }); + if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); + + // Same flag as the list — see listMine for why booking status alone is not + // enough to tell whether the shipping line has something to fix. + const queriedCount = await this.bookingsRepository.manager + .getRepository(BookingDocumentReview) + .count({ where: { bookingId, status: "QUERIED" } }); + + // The note Operations wrote when returning the request — the line has to + // read it to know what to fix. Only the latest CHANGES_REQUESTED note is + // exposed; the other review-note types are staff-internal. + const changeNote = + booking.status === "OPERATION_CHANGES_REQUESTED" + ? await this.bookingsRepository.manager + .getRepository(BookingReviewNote) + .findOne({ + where: { bookingId, type: "CHANGES_REQUESTED" }, + order: { createdAt: "DESC" }, + }) + : null; + + return { + ...booking, + hasQueriedDocuments: queriedCount > 0, + operationChangeNote: changeNote?.note ?? null, + }; + } + + /** + * Train departures dedicated to the signed-in shipping line: schedules whose + * `shippingLineCompanyId` is this line's. These trains are hidden from every + * customer-facing read, so this endpoint is the ONLY place they surface in + * the portal — the home page lists them and the booking detail matches them + * to a booking by lane + day. + */ + async listMyTrains(userId: string) { + const shippingLine = await this.requireShippingLine(userId); + + // Recent past kept (48h) so a just-departed train is still visible while + // its cargo is on the rails; CANCELLED never shows. + const horizon = new Date(Date.now() - 48 * 60 * 60 * 1000); + const schedules = await this.bookingsRepository.manager + .getRepository(TrainSchedule) + .find({ + where: { + shippingLineCompanyId: shippingLine.id, + status: In(["DRAFT", "SCHEDULED", "DISPATCHED"]), + scheduledDepartureDate: MoreThanOrEqual(horizon), + }, + relations: { originStation: true, destinationStation: true }, + order: { scheduledDepartureDate: "ASC" }, + }); + + return schedules.map((s) => ({ + id: s.id, + reference: s.reference, + trainNumber: s.trainNumber, + status: s.status, + direction: s.direction, + scheduledDepartureDate: s.scheduledDepartureDate, + scheduledArrivalDate: s.scheduledArrivalDate, + originYardId: s.originStationId, + originLabel: s.originStation?.label ?? s.originStation?.code ?? "Origin", + destinationYardId: s.destinationStationId, + destinationLabel: + s.destinationStation?.label ?? + s.destinationStation?.code ?? + "Destination", + })); + } + + /** + * Cancel one of the signed-in shipping line's own bookings. + * + * Its own method rather than the customer `customerCancel`: that path routes + * into `BookingTransitionService.cancel`, whose status whitelist covers the + * contract-backed lifecycle (DRAFT, SUBMITTED, PENDING_APPROVAL…) and does + * not include the document-clearance statuses a shipping-line booking lives + * in — so it would reject every one of them. + * + * Only allowed before the booking is priced and paid. Once it carries a + * charge, cancelling is a billing decision (fees, credit notes) that belongs + * with Operations, not a self-service button. + */ + async cancelMine(userId: string, bookingId: string, reason?: string) { + const shippingLine = await this.requireShippingLine(userId); + + const booking = await this.bookingsRepository.findOne({ + where: { id: bookingId, shippingLineCompanyId: shippingLine.id }, + }); + if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); + + if (booking.status === "CANCELLED") { + throw new BadRequestException("This booking is already cancelled."); + } + if (!SHIPPING_LINE_CANCELLABLE_STATUSES.includes(booking.status)) { + throw new BadRequestException( + "This booking can no longer be cancelled — please contact Operations.", + ); + } + // Belt and braces: the statuses above are all pre-pricing, so a charge here + // would mean the booking moved on in a way this guard did not anticipate. + if (Number(booking.totalAmount ?? 0) > 0) { + throw new BadRequestException( + "This booking has already been priced — please contact Operations to cancel it.", + ); + } + + // The reason lives on the booking's review-note log, the same place the + // customer cancel path records it — there is no column for it. + await this.bookingsRepository.manager + .getRepository(BookingReviewNote) + .save({ + bookingId, + note: reason?.trim() || "Cancelled by the shipping line", + type: "REJECTION", + authorId: userId, + } as never); + + await this.bookingsRepository.update(bookingId, { + status: "CANCELLED", + } as never); + + return this.findMine(userId, bookingId); + } + + /** Mirrors the customer reference format — one booking sequence per year. */ + private async generateReference(): Promise { + const year = new Date().getFullYear(); + const { max } = (await this.bookingsRepository + .createQueryBuilder("b") + .select( + `COALESCE(MAX(NULLIF(regexp_replace(b.reference, '^BK-${year}-', ''), b.reference)::int), 0)`, + "max", + ) + .where("b.reference LIKE :prefix", { prefix: `BK-${year}-%` }) + .getRawOne<{ max: number }>()) ?? { max: 0 }; + + return `BK-${year}-${String(Number(max) + 1).padStart(6, "0")}`; + } +} diff --git a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.controller.ts b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.controller.ts new file mode 100644 index 000000000..3d11e2a29 --- /dev/null +++ b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.controller.ts @@ -0,0 +1,100 @@ +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Post, + Query, +} from "@nestjs/common"; +import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; + +import { BookingStaff } from "../../common/booking-guards"; +import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; +import { BackofficeResetPasswordDto } from "../auth/dto/forgot-password.dto"; +import { CreateShippingLineDto } from "./dto/create-shipping-line.dto"; +import { + RegisterShippingLineResponseDto, + ShippingLineResponseDto, +} from "./dto/shipping-line-response.dto"; +import { ShippingLineCompaniesService } from "./shipping-line-companies.service"; + +/** + * Shipping line *companies* — carriers with a portal login, registered by staff + * (there is no self-signup). The line receives a single-use activation link and + * sets its own password, so staff never see or handle a credential. + * + * Distinct from `freight.shipping_lines` behind `/shipping-lines` + * (rule-engine): that is a pricing lookup list — a code/label a booking points + * at via `shipping_line_id` — with no account, no user and no login. Same words, + * different concept, hence the separate route. + */ +@ApiTags("shipping-line-companies") +@Controller("shipping-line-companies") +@ApiBearerAuth() +export class ShippingLineCompaniesController { + constructor(private readonly shippingLineCompaniesService: ShippingLineCompaniesService) {} + + @Post() + @BookingStaff(FREIGHT_PERMS.shippingLines.create) + @ApiOperation({ + summary: "Register a shipping line and send its activation link", + }) + async register( + @Body() dto: CreateShippingLineDto, + ): Promise { + const { shippingLine, activationSentTo } = + await this.shippingLineCompaniesService.register(dto); + return new RegisterShippingLineResponseDto(shippingLine, activationSentTo); + } + + @Get() + // OR'd: the credits view needs this list as its line picker, so holding + // shipping_line_credits:view alone is enough to read it. + @BookingStaff([ + FREIGHT_PERMS.shippingLines.view, + FREIGHT_PERMS.shippingLineCredits.view, + ]) + @ApiOperation({ summary: "List shipping lines (paginated)" }) + async list( + @Query("page") page?: string, + @Query("limit") limit?: string, + ): Promise<{ + items: ShippingLineResponseDto[]; + total: number; + page: number; + limit: number; + }> { + const result = await this.shippingLineCompaniesService.list( + page ? Number(page) : undefined, + limit ? Number(limit) : undefined, + ); + return { + ...result, + items: result.items.map((item) => new ShippingLineResponseDto(item)), + }; + } + + @Get(":id") + @BookingStaff(FREIGHT_PERMS.shippingLines.view) + @ApiOperation({ summary: "Get a shipping line by id" }) + async findOne( + @Param("id", ParseUUIDPipe) id: string, + ): Promise { + return new ShippingLineResponseDto( + await this.shippingLineCompaniesService.findById(id), + ); + } + + @Post(":id/resend-activation") + @BookingStaff(FREIGHT_PERMS.shippingLines.resetPassword) + @ApiOperation({ + summary: "Resend a shipping line's activation / password-reset link", + }) + async resendActivation( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: BackofficeResetPasswordDto, + ) { + return this.shippingLineCompaniesService.resendActivation(id, dto.channel); + } +} diff --git a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.module.ts b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.module.ts new file mode 100644 index 000000000..888a996a6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.module.ts @@ -0,0 +1,59 @@ +import { Module, forwardRef } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; + +import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity"; + +import { FreightAuthModule } from "../auth/freight-auth.module"; +import { BillingModule } from "../billing/billing.module"; +import { Booking } from "../bookings/entities/booking.entity"; +import { OtpModule } from "../otp/otp.module"; +import { ShippingLineCompany } from "./entities/shipping-line-company.entity"; +import { ShippingLineCredit } from "./entities/shipping-line-credit.entity"; +import { ShippingLineInvoiceApproval } from "./entities/shipping-line-invoice-approval.entity"; +import { ShippingLineInvoiceApprovalsRepository } from "./shipping-line-invoice-approvals.repository"; +import { ShippingLineBookingsController } from "./shipping-line-bookings.controller"; +import { ShippingLineBookingsService } from "./shipping-line-bookings.service"; +import { ShippingLineCompaniesController } from "./shipping-line-companies.controller"; +import { ShippingLineCompaniesRepository } from "./shipping-line-companies.repository"; +import { ShippingLineCompaniesService } from "./shipping-line-companies.service"; +import { ShippingLineCreditsController } from "./shipping-line-credits.controller"; +import { ShippingLineCreditsRepository } from "./shipping-line-credits.repository"; +import { ShippingLineCreditsService } from "./shipping-line-credits.service"; + +@Module({ + imports: [ + // Booking is registered here only so this module can create shipping-line + // rows in `freight.bookings`; the customer BookingsModule is untouched. + TypeOrmModule.forFeature([ + ShippingLineCompany, + ShippingLineCredit, + ShippingLineInvoiceApproval, + User, + Booking, + ]), + // CustomerResetService — activation links reuse the staff-triggered reset path. + FreightAuthModule, + OtpModule, + // Credits are billed by generating an ordinary invoice. Billing still knows + // nothing about credits and hears about settlement only by emitting its own + // `shipping_line_credit.invoice.paid` event, but the module graph now cycles + // (billing -> companies -> here -> billing), so this edge needs forwardRef. + forwardRef(() => BillingModule), + ], + controllers: [ + ShippingLineCompaniesController, + ShippingLineBookingsController, + ShippingLineCreditsController, + ], + providers: [ + ShippingLineCompaniesService, + ShippingLineCompaniesRepository, + ShippingLineBookingsService, + ShippingLineCreditsService, + ShippingLineCreditsRepository, + ShippingLineInvoiceApprovalsRepository, + ], + // Exported so whatever prices a shipping-line booking can record the charge. + exports: [ShippingLineCompaniesService, ShippingLineCreditsService], +}) +export class ShippingLineCompaniesModule {} diff --git a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.repository.ts b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.repository.ts new file mode 100644 index 000000000..997550172 --- /dev/null +++ b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.repository.ts @@ -0,0 +1,61 @@ +import { BaseRepository } from "@edr/api-common"; +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { EntityManager, Repository } from "typeorm"; + +import { ShippingLineCompany } from "./entities/shipping-line-company.entity"; + +@Injectable() +export class ShippingLineCompaniesRepository extends BaseRepository { + constructor( + @InjectRepository(ShippingLineCompany) + private readonly shippingLineRepo: Repository, + ) { + super(shippingLineRepo); + } + + findByUserId(userId: string): Promise { + return this.shippingLineRepo.findOne({ where: { userId } }); + } + + /** Case-insensitive, matching the `lower(email)` unique index. */ + async existsByEmail(email: string): Promise { + const count = await this.shippingLineRepo + .createQueryBuilder("sl") + .where("lower(sl.email) = lower(:email)", { email }) + .getCount(); + return count > 0; + } + + async existsByScac(scacCode: string): Promise { + const count = await this.shippingLineRepo + .createQueryBuilder("sl") + .where("upper(sl.scacCode) = upper(:scacCode)", { scacCode }) + .getCount(); + return count > 0; + } + + findAllPaginated( + skip: number, + take: number, + ): Promise<[ShippingLineCompany[], number]> { + return this.shippingLineRepo.findAndCount({ + order: { createdAt: "DESC" }, + skip, + take, + }); + } + + /** + * Insert inside a caller-supplied transaction, so the shipping-line row and + * the IAM user it points at commit together — a row referencing a user that + * was rolled back (or vice versa) is an account nobody can sign in to. + */ + createInTransaction( + manager: EntityManager, + data: Partial, + ): Promise { + const repo = manager.getRepository(ShippingLineCompany); + return repo.save(repo.create(data)); + } +} diff --git a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.service.spec.ts b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.service.spec.ts new file mode 100644 index 000000000..d9d429dc9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.service.spec.ts @@ -0,0 +1,233 @@ +import { ConflictException } from "@nestjs/common"; +import { + EUserStatus, + EUserType, +} from "@tria-plc/api-common/utils/enums/user.enum"; + +import { ResetChannel } from "../auth/dto/forgot-password.dto"; +import { ShippingLineCompaniesService } from "./shipping-line-companies.service"; + +/** + * Registration is the whole feature: an IAM account and a carrier record + * created together, then an activation link the line uses to set its own + * password. These lock the parts that would silently break the login. + */ +describe("ShippingLineCompaniesService.register", () => { + const savedUser = { id: "user-1" }; + + let shippingLinesRepo: { + existsByEmail: jest.Mock; + existsByScac: jest.Mock; + createInTransaction: jest.Mock; + findById: jest.Mock; + findByUserId: jest.Mock; + }; + let userRepository: { findOne: jest.Mock }; + let customerResetService: { sendResetLinkToUser: jest.Mock }; + let dataSource: { transaction: jest.Mock }; + let userRepoInTx: { create: jest.Mock; save: jest.Mock }; + let service: ShippingLineCompaniesService; + + const dto = { + name: "Ethiopian Shipping Lines", + email: "Ops@ESL.com.et", + phoneNumber: "+251911223344", + scacCode: "eslk", + }; + + beforeEach(() => { + userRepoInTx = { + create: jest.fn((v) => v), + save: jest.fn().mockResolvedValue(savedUser), + }; + + shippingLinesRepo = { + existsByEmail: jest.fn().mockResolvedValue(false), + existsByScac: jest.fn().mockResolvedValue(false), + createInTransaction: jest + .fn() + .mockImplementation((_m, data) => ({ id: "sl-1", ...data })), + findById: jest.fn(), + findByUserId: jest.fn(), + }; + userRepository = { findOne: jest.fn().mockResolvedValue(null) }; + customerResetService = { + sendResetLinkToUser: jest + .fn() + .mockResolvedValue({ maskedTarget: "o**@esl.com.et", channel: "email" }), + }; + dataSource = { + transaction: jest.fn(async (cb) => + cb({ getRepository: () => userRepoInTx }), + ), + }; + + service = new ShippingLineCompaniesService( + shippingLinesRepo as never, + userRepository as never, + customerResetService as never, + dataSource as never, + ); + }); + + it("creates the IAM account with no password set", async () => { + await service.register(dto as never); + + const created = userRepoInTx.create.mock.calls[0][0]; + expect(created).toMatchObject({ + userType: EUserType.INDIVIDUAL, + isActive: true, + status: EUserStatus.ACCEPTED, + // The line sets its own password from the activation link. Employee + // creation seeds a shared default here; a shipping line must not get one. + hasSetPassword: false, + }); + }); + + it("never writes a credential row", async () => { + await service.register(dto as never); + + // Only the User repository is touched inside the transaction — a + // UserCredential insert would mean the account has a password nobody chose. + for (const call of userRepoInTx.save.mock.calls) { + expect(call[0]).not.toHaveProperty("password"); + } + }); + + it("normalises email and SCAC before storing", async () => { + const result = await service.register(dto as never); + + expect(result.shippingLine).toMatchObject({ + email: "ops@esl.com.et", + scacCode: "ESLK", + }); + }); + + it("creates the account and the record in one transaction", async () => { + await service.register(dto as never); + + expect(dataSource.transaction).toHaveBeenCalledTimes(1); + expect(shippingLinesRepo.createInTransaction).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ userId: "user-1" }), + ); + }); + + it("sends the activation link outside the transaction, after commit", async () => { + const order: string[] = []; + dataSource.transaction.mockImplementation(async (cb: never) => { + order.push("tx"); + return (cb as unknown as (m: unknown) => Promise)({ + getRepository: () => userRepoInTx, + }); + }); + customerResetService.sendResetLinkToUser.mockImplementation(async () => { + order.push("send"); + return { maskedTarget: "o**@esl.com.et", channel: "email" }; + }); + + await service.register(dto as never); + + expect(order[0]).toBe("tx"); + expect(order).toContain("send"); + }); + + it("emails the link, and also texts it when the number is domestic", async () => { + await service.register(dto as never); + + const channels = customerResetService.sendResetLinkToUser.mock.calls.map( + (c) => c[1], + ); + expect(channels).toContain(ResetChannel.Email); + expect(channels).toContain(ResetChannel.Phone); + }); + + it("emails only when the number is foreign — the SMS gateway is domestic-only", async () => { + await service.register({ ...dto, phoneNumber: "+441234567890" } as never); + + const channels = customerResetService.sendResetLinkToUser.mock.calls.map( + (c) => c[1], + ); + expect(channels).toEqual([ResetChannel.Email]); + }); + + it("keeps the registration when the activation link fails to send", async () => { + customerResetService.sendResetLinkToUser.mockResolvedValue(null); + + const result = await service.register(dto as never); + + // The account is valid without the link and the link is resendable — + // a delivery failure must not roll back the registration. + expect(result.shippingLine).toMatchObject({ id: "sl-1" }); + expect(result.activationSentTo).toBeNull(); + }); + + it("refuses a duplicate email", async () => { + shippingLinesRepo.existsByEmail.mockResolvedValue(true); + + await expect(service.register(dto as never)).rejects.toBeInstanceOf( + ConflictException, + ); + expect(dataSource.transaction).not.toHaveBeenCalled(); + }); + + it("refuses a duplicate SCAC", async () => { + shippingLinesRepo.existsByScac.mockResolvedValue(true); + + await expect(service.register(dto as never)).rejects.toBeInstanceOf( + ConflictException, + ); + expect(dataSource.transaction).not.toHaveBeenCalled(); + }); + + it("refuses credentials already belonging to another account", async () => { + // Reusing an existing IAM user would let one login resolve to both a + // customer and a shipping line. + userRepository.findOne.mockResolvedValue({ id: "existing" }); + + await expect(service.register(dto as never)).rejects.toBeInstanceOf( + ConflictException, + ); + expect(dataSource.transaction).not.toHaveBeenCalled(); + }); + + it("defaults the username to the email", async () => { + await service.register(dto as never); + + expect(userRepoInTx.create.mock.calls[0][0]).toMatchObject({ + username: "ops@esl.com.et", + }); + }); + + /** + * The default reset lookup inner-joins an active `user_credentials` row so a + * reset cannot revive a suspended account. A shipping line has no credential + * until it uses the activation link, so without this flag the account is + * excluded from its own activation — the link is never minted, never logged, + * and resend answers 404. + */ + it("requests the credential-less lookup for every activation send", async () => { + await service.register(dto as never); + + expect(customerResetService.sendResetLinkToUser).toHaveBeenCalled(); + for (const call of customerResetService.sendResetLinkToUser.mock.calls) { + expect(call[2]).toMatchObject({ allowWithoutCredential: true }); + } + }); + + it("requests the credential-less lookup when resending", async () => { + shippingLinesRepo.findById.mockResolvedValue({ + id: "sl-1", + userId: "user-1", + phoneNumber: "+251911223344", + }); + + await service.resendActivation("sl-1", ResetChannel.Email); + + expect(customerResetService.sendResetLinkToUser).toHaveBeenCalledWith( + "user-1", + ResetChannel.Email, + expect.objectContaining({ allowWithoutCredential: true }), + ); + }); +}); diff --git a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.service.ts b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.service.ts new file mode 100644 index 000000000..16b2836e1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.service.ts @@ -0,0 +1,223 @@ +import { + BadRequestException, + ConflictException, + Injectable, + Logger, + NotFoundException, +} from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { + EUserStatus, + EUserType, +} from "@tria-plc/api-common/utils/enums/user.enum"; +// Subpath import (not the package root) so ts-jest can resolve it when this +// file lands in a spec's compile graph — same reason as backoffice.service.ts. +import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity"; +import { DataSource, Repository } from "typeorm"; + +import { CustomerResetService } from "../auth/customer-reset.service"; +import { ResetChannel } from "../auth/dto/forgot-password.dto"; +import { isDomesticPhone } from "../otp/otp.service"; +import { CreateShippingLineDto } from "./dto/create-shipping-line.dto"; +import { ShippingLineCompany } from "./entities/shipping-line-company.entity"; +import { ShippingLineCompaniesRepository } from "./shipping-line-companies.repository"; + +export interface RegisteredShippingLine { + shippingLine: ShippingLineCompany; + /** Masked destination of the activation link, or null if none was sent. */ + activationSentTo: string | null; + activationChannel: ResetChannel | null; +} + +@Injectable() +export class ShippingLineCompaniesService { + private readonly logger = new Logger(ShippingLineCompaniesService.name); + + constructor( + private readonly shippingLineCompaniesRepo: ShippingLineCompaniesRepository, + @InjectRepository(User) + private readonly userRepository: Repository, + private readonly customerResetService: CustomerResetService, + private readonly dataSource: DataSource, + ) {} + + /** + * Register a shipping line: create its IAM account and its record together, + * then send an activation link so the line sets its own password. + * + * The IAM mechanics follow `BackofficeService.createOrganizationUser` — same + * entities, same transaction shape — with one deliberate difference: no + * `UserCredential` row is written and `hasSetPassword` stays false. Staff + * creating an employee seed a shared default password; a shipping line must + * come through the activation link instead, so no credential exists until the + * line sets one. + */ + async register(dto: CreateShippingLineDto): Promise { + const email = dto.email.trim().toLowerCase(); + const username = (dto.username?.trim() || email).toLowerCase(); + const phoneNumber = dto.phoneNumber?.trim() || undefined; + const scacCode = dto.scacCode?.trim().toUpperCase(); + + if (await this.shippingLineCompaniesRepo.existsByEmail(email)) { + throw new ConflictException( + `A shipping line with email ${email} already exists`, + ); + } + + if (scacCode && (await this.shippingLineCompaniesRepo.existsByScac(scacCode))) { + throw new ConflictException( + `A shipping line with SCAC ${scacCode} already exists`, + ); + } + + // An existing IAM account means these credentials already belong to a + // customer or an employee. Reusing it would let one login resolve to two + // different account kinds, so this is refused rather than merged — unlike + // employee creation, which legitimately re-uses a person's existing user. + const existingUser = await this.userRepository.findOne({ + where: [{ email }, { username }], + select: { id: true }, + }); + if (existingUser) { + throw new ConflictException( + "email_or_username_already_in_use", + ); + } + + const shippingLine = await this.dataSource.transaction(async (manager) => { + const userRepo = manager.getRepository(User); + const user = await userRepo.save( + userRepo.create({ + email, + username, + phoneNumber, + name: { en: dto.name.trim() }, + userType: EUserType.INDIVIDUAL, + isActive: true, + // No credential row is written: the account has no password until the + // activation link is used. `hasSetPassword` must stay false or the + // portal treats the account as ready to sign in with a password that + // does not exist. + hasSetPassword: false, + status: EUserStatus.ACCEPTED, + }), + ); + + return this.shippingLineCompaniesRepo.createInTransaction(manager, { + userId: user.id as string, + name: dto.name.trim(), + email, + phoneNumber: phoneNumber ?? null, + scacCode: scacCode ?? null, + imoNumber: dto.imoNumber?.trim() || null, + bicCode: dto.bicCode?.trim() || null, + }); + }); + + // Outside the transaction on purpose: a delivery failure must not roll back + // a registered line. The link is resendable, and the account is already + // valid without it. + const activation = await this.sendActivationLink(shippingLine); + + return { + shippingLine, + activationSentTo: activation?.maskedTarget ?? null, + activationChannel: activation?.channel ?? null, + }; + } + + /** + * Send the activation link on registration. + * + * Email always goes out — it is required at registration and is the only + * channel guaranteed to reach a foreign-registered line. SMS is sent in + * addition when the number is domestic, since the gateway silently drops + * anything else (see `CustomerResetService`). Two links are two independent + * single-use tickets; whichever the line opens first works. + * + * Reports the email send, as that is the one that is always attempted. + */ + async sendActivationLink(shippingLine: ShippingLineCompany) { + const scope = `shipping line ${shippingLine.id}`; + + const emailed = await this.customerResetService.sendResetLinkToUser( + shippingLine.userId, + ResetChannel.Email, + { scope, allowWithoutCredential: true }, + ); + + if (!emailed) { + this.logger.error( + `Activation email not sent for shipping line ${shippingLine.id} — no reachable address`, + ); + } + + if (shippingLine.phoneNumber && isDomesticPhone(shippingLine.phoneNumber)) { + const texted = await this.customerResetService.sendResetLinkToUser( + shippingLine.userId, + ResetChannel.Phone, + { scope, allowWithoutCredential: true }, + ); + if (!texted) { + this.logger.warn( + `Activation SMS not sent for shipping line ${shippingLine.id}`, + ); + } + } + + return emailed; + } + + async resendActivation(id: string, channel: ResetChannel) { + const shippingLine = await this.shippingLineCompaniesRepo.findById(id); + if (!shippingLine) { + throw new NotFoundException("Shipping line not found"); + } + + if ( + channel === ResetChannel.Phone && + (!shippingLine.phoneNumber || !isDomesticPhone(shippingLine.phoneNumber)) + ) { + throw new BadRequestException( + "This shipping line has no domestic phone number — the SMS gateway cannot reach it", + ); + } + + const sent = await this.customerResetService.sendResetLinkToUser( + shippingLine.userId, + channel, + { scope: `shipping line ${shippingLine.id}`, allowWithoutCredential: true }, + ); + + if (!sent) { + throw new NotFoundException( + `No active account with ${ + channel === ResetChannel.Email ? "an email address" : "a phone number" + } for this shipping line`, + ); + } + + return sent; + } + + async findById(id: string): Promise { + const shippingLine = await this.shippingLineCompaniesRepo.findById(id); + if (!shippingLine) { + throw new NotFoundException("Shipping line not found"); + } + return shippingLine; + } + + /** The shipping line signed in as `userId`, or null for any other account. */ + findByUserId(userId: string): Promise { + return this.shippingLineCompaniesRepo.findByUserId(userId); + } + + async list(page = 1, limit = 20) { + const [items, total] = await this.shippingLineCompaniesRepo.findAllPaginated( + (page - 1) * limit, + limit, + ); + return { items, total, page, limit }; + } +} diff --git a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-credits.controller.ts b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-credits.controller.ts new file mode 100644 index 000000000..7a2c8418f --- /dev/null +++ b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-credits.controller.ts @@ -0,0 +1,284 @@ +import { CurrentUser } from "@edr/api-common"; +import { Freight } from "@edr/types"; +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Post, + Query, +} from "@nestjs/common"; +import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; + +import { BookingStaff, PortalCustomer } from "../../common/booking-guards"; +import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; +import { + CancelCreditDto, + DecideInvoiceActionDto, + GenerateCreditInvoiceDto, + RequestInvoiceActionDto, +} from "./dto/shipping-line-credit.dto"; +import { ShippingLineCreditStatus } from "./entities/shipping-line-credit.entity"; +import { ShippingLineInvoiceActionType } from "./entities/shipping-line-invoice-approval.entity"; +import { ShippingLineCreditsService } from "./shipping-line-credits.service"; + +interface CurrentIamUser { + id: string; +} + +/** + * Finance's view of what shipping lines owe. + * + * A shipping line books and ships without paying — the charge is recorded as a + * credit instead. Finance reads the unbilled list here, batches it into an + * invoice, and the line then pays that invoice through the ordinary + * `/billing` + CBE routes; nothing in this controller touches money directly. + */ +@ApiTags("shipping-line-credits") +@Controller("shipping-line-credits") +@ApiBearerAuth() +export class ShippingLineCreditsController { + constructor(private readonly credits: ShippingLineCreditsService) {} + + @Get() + @BookingStaff(FREIGHT_PERMS.shippingLineCredits.view) + @ApiOperation({ + summary: + "The whole credit ledger across every shipping line (paginated), optionally filtered by line and/or status.", + }) + async listAll( + @Query("page") page?: string, + @Query("pageSize") pageSize?: string, + @Query("status") status?: ShippingLineCreditStatus, + @Query("shippingLineId", new ParseUUIDPipe({ optional: true })) + shippingLineId?: string, + ) { + return this.credits.listAll( + page ? Number(page) : 1, + pageSize ? Number(pageSize) : 20, + status, + shippingLineId, + ); + } + + // Declared before the parameterised staff routes so "summary" is never + // captured as a shipping-line id. + @Get("summary") + @BookingStaff(FREIGHT_PERMS.shippingLineCredits.view) + @ApiOperation({ + summary: + "Outstanding totals across every shipping line, or one line when shippingLineId is given.", + }) + async summaryAll( + @Query("shippingLineId", new ParseUUIDPipe({ optional: true })) + shippingLineId?: string, + ) { + return this.credits.summary(shippingLineId); + } + + // Declared before ":shippingLineId" so "invoices" is never captured as an id. + @Get("invoices") + @BookingStaff(FREIGHT_PERMS.shippingLineCredits.view) + @ApiOperation({ + summary: + "Credit invoices across every shipping line (paginated), each with any pending manual-action request.", + }) + async listInvoices( + @Query("page") page?: string, + @Query("pageSize") pageSize?: string, + @Query("status") status?: string, + @Query("shippingLineId", new ParseUUIDPipe({ optional: true })) + shippingLineId?: string, + ) { + return this.credits.listCreditInvoices( + page ? Number(page) : 1, + pageSize ? Number(pageSize) : 20, + status as Freight.InvoiceStatus | undefined, + shippingLineId, + ); + } + + @Get("invoice-actions/pending") + @BookingStaff(FREIGHT_PERMS.shippingLineCredits.view) + @ApiOperation({ + summary: + "Undecided manual-action requests for a batch of invoices (one lookup for a list page).", + }) + async pendingInvoiceActions(@Query("invoiceIds") invoiceIds?: string) { + const ids = (invoiceIds ?? "") + .split(",") + .map((id) => id.trim()) + .filter(Boolean); + return this.credits.pendingInvoiceActions(ids); + } + + // ── Maker–checker on credit invoices ────────────────────────────────────── + // Request and approve are DIFFERENT permissions, and the service refuses a + // decision by the requester — marking debt paid or voiding an invoice is + // never a one-person action. + + @Post("invoices/:invoiceId/mark-paid-request") + @BookingStaff(FREIGHT_PERMS.shippingLineCredits.invoiceMarkPaid) + @ApiOperation({ + summary: + "Request recording a full offline payment against a credit invoice (awaits chief approval).", + }) + async requestMarkPaid( + @Param("invoiceId", ParseUUIDPipe) invoiceId: string, + @Body() dto: RequestInvoiceActionDto, + @CurrentUser() user: CurrentIamUser, + ) { + return this.credits.requestInvoiceAction( + invoiceId, + ShippingLineInvoiceActionType.MarkPaid, + user.id, + dto.reason, + dto.paymentReference, + ); + } + + @Post("invoices/:invoiceId/cancel-request") + @BookingStaff(FREIGHT_PERMS.shippingLineCredits.invoiceCancel) + @ApiOperation({ + summary: + "Request voiding a credit invoice — its credits return to the unbilled pool (awaits chief approval).", + }) + async requestCancel( + @Param("invoiceId", ParseUUIDPipe) invoiceId: string, + @Body() dto: RequestInvoiceActionDto, + @CurrentUser() user: CurrentIamUser, + ) { + return this.credits.requestInvoiceAction( + invoiceId, + ShippingLineInvoiceActionType.Cancel, + user.id, + dto.reason, + ); + } + + @Post("invoice-actions/:approvalId/approve") + @BookingStaff(FREIGHT_PERMS.shippingLineCredits.invoiceApprove) + @ApiOperation({ + summary: + "Approve a pending invoice request — executes the offline settlement or the cancellation.", + }) + async approveInvoiceAction( + @Param("approvalId", ParseUUIDPipe) approvalId: string, + @Body() dto: DecideInvoiceActionDto, + @CurrentUser() user: CurrentIamUser, + ) { + return this.credits.decideInvoiceAction( + approvalId, + user.id, + true, + dto.note, + ); + } + + @Post("invoice-actions/:approvalId/reject") + @BookingStaff(FREIGHT_PERMS.shippingLineCredits.invoiceReject) + @ApiOperation({ + summary: "Reject a pending invoice request — nothing is changed.", + }) + async rejectInvoiceAction( + @Param("approvalId", ParseUUIDPipe) approvalId: string, + @Body() dto: DecideInvoiceActionDto, + @CurrentUser() user: CurrentIamUser, + ) { + return this.credits.decideInvoiceAction( + approvalId, + user.id, + false, + dto.note, + ); + } + + // Declared before the parameterised staff routes so "me" is never captured + // as a shipping-line id. + @Get("me") + @PortalCustomer() + @ApiOperation({ + summary: + "The signed-in shipping line's own statement: outstanding balance plus its credit ledger.", + }) + async myStatement( + @CurrentUser() user: CurrentIamUser, + @Query("page") page?: string, + @Query("pageSize") pageSize?: string, + ) { + return this.credits.myStatement( + user.id, + page ? Number(page) : 1, + pageSize ? Number(pageSize) : 20, + ); + } + + @Get(":shippingLineId/outstanding") + @BookingStaff(FREIGHT_PERMS.shippingLineCredits.view) + @ApiOperation({ + summary: + "What one shipping line owes: unbilled + billed totals, derived from the ledger.", + }) + async outstanding( + @Param("shippingLineId", ParseUUIDPipe) shippingLineId: string, + ) { + return this.credits.outstanding(shippingLineId); + } + + @Get(":shippingLineId/unbilled") + @BookingStaff(FREIGHT_PERMS.shippingLineCredits.view) + @ApiOperation({ + summary: + "Credits that can go on an invoice for this line, oldest first. This is the selection list.", + }) + async listUnbilled( + @Param("shippingLineId", ParseUUIDPipe) shippingLineId: string, + ) { + return this.credits.listUnbilled(shippingLineId); + } + + @Get(":shippingLineId") + @BookingStaff(FREIGHT_PERMS.shippingLineCredits.view) + @ApiOperation({ + summary: "Full credit ledger for one shipping line (paginated).", + }) + async listCredits( + @Param("shippingLineId", ParseUUIDPipe) shippingLineId: string, + @Query("page") page?: string, + @Query("pageSize") pageSize?: string, + @Query("status") status?: ShippingLineCreditStatus, + ) { + return this.credits.listCredits( + shippingLineId, + page ? Number(page) : 1, + pageSize ? Number(pageSize) : 20, + status, + ); + } + + @Post("invoice") + @BookingStaff(FREIGHT_PERMS.shippingLineCredits.invoice) + @ApiOperation({ + summary: + "Bill a batch of unbilled credits as one invoice. All credits must belong to the same shipping line.", + }) + async generateInvoice(@Body() dto: GenerateCreditInvoiceDto) { + return this.credits.generateInvoice(dto.creditIds, { + dueInDays: dto.dueInDays, + }); + } + + @Post(":creditId/cancel") + @BookingStaff(FREIGHT_PERMS.shippingLineCredits.cancel) + @ApiOperation({ + summary: + "Write off an unbilled credit. Once billed, cancel the invoice instead.", + }) + async cancel( + @Param("creditId", ParseUUIDPipe) creditId: string, + @Body() dto: CancelCreditDto, + ) { + return this.credits.cancelCredit(creditId, dto.reason); + } +} diff --git a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-credits.repository.ts b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-credits.repository.ts new file mode 100644 index 000000000..f1413cbaf --- /dev/null +++ b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-credits.repository.ts @@ -0,0 +1,148 @@ +import { BaseRepository } from "@edr/api-common"; +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { EntityManager, In, Repository } from "typeorm"; + +import { + OUTSTANDING_CREDIT_STATUSES, + ShippingLineCredit, + ShippingLineCreditStatus, +} from "./entities/shipping-line-credit.entity"; + +/** What one shipping line currently owes, split by billing stage. */ +export interface OutstandingTotals { + /** Priced but not yet on an invoice. */ + unbilledAmount: number; + /** On an issued invoice, awaiting payment. */ + billedAmount: number; + /** `unbilledAmount + billedAmount` — the full debt. */ + totalOutstanding: number; + unbilledCount: number; + billedCount: number; + currency: string; +} + +@Injectable() +export class ShippingLineCreditsRepository extends BaseRepository { + constructor( + @InjectRepository(ShippingLineCredit) + private readonly credits: Repository, + ) { + super(credits); + } + + findByBookingId(bookingId: string): Promise { + return this.credits.findOne({ where: { bookingId } }); + } + + /** + * Finance's worklist: everything for one line that can go on an invoice, + * oldest first so the longest-standing debt is billed before newer charges. + */ + findUnbilled(shippingLineCompanyId: string): Promise { + return this.credits.find({ + where: { + shippingLineCompanyId, + status: ShippingLineCreditStatus.Unbilled, + }, + relations: { booking: true }, + order: { createdAt: "ASC" }, + }); + } + + findByInvoiceId( + invoiceId: string, + manager?: EntityManager, + ): Promise { + const repo = manager + ? manager.getRepository(ShippingLineCredit) + : this.credits; + return repo.find({ where: { invoiceId } }); + } + + /** + * Load a specific batch inside the caller's transaction and lock it, so two + * concurrent invoice generations cannot both claim the same credits. + */ + findByIdsForUpdate( + manager: EntityManager, + ids: string[], + ): Promise { + return manager.getRepository(ShippingLineCredit).find({ + where: { id: In(ids) }, + lock: { mode: "pessimistic_write" }, + }); + } + + /** + * Derived debt — never a stored column. Grouped in one query so the detail + * page does not fan out per status. Without a line id it totals every line — + * the back-office overview figure. + */ + async outstandingFor( + shippingLineCompanyId?: string, + ): Promise { + const qb = this.credits + .createQueryBuilder("credit") + .select("credit.status", "status") + .addSelect("COALESCE(SUM(credit.amount), 0)", "amount") + .addSelect("COUNT(*)", "count") + .where("credit.status IN (:...statuses)", { + statuses: [...OUTSTANDING_CREDIT_STATUSES], + }) + .andWhere("credit.deletedAt IS NULL") + .groupBy("credit.status"); + if (shippingLineCompanyId) { + qb.andWhere("credit.shippingLineCompanyId = :shippingLineCompanyId", { + shippingLineCompanyId, + }); + } + const rows = await qb.getRawMany<{ + status: string; + amount: string; + count: string; + }>(); + + const totals = (status: ShippingLineCreditStatus) => { + const row = rows.find((r) => r.status === status); + return { + amount: row ? Number(row.amount) : 0, + count: row ? Number(row.count) : 0, + }; + }; + + const unbilled = totals(ShippingLineCreditStatus.Unbilled); + const billed = totals(ShippingLineCreditStatus.Billed); + + return { + unbilledAmount: unbilled.amount, + billedAmount: billed.amount, + totalOutstanding: unbilled.amount + billed.amount, + unbilledCount: unbilled.count, + billedCount: billed.count, + currency: "ETB", + }; + } + + /** + * Paginated ledger — every credit, whatever its status. Scoped to one line + * when an id is given, across all lines otherwise. + */ + findAllPaginated( + shippingLineCompanyId: string | undefined, + skip: number, + take: number, + status?: ShippingLineCreditStatus, + ): Promise<[ShippingLineCredit[], number]> { + return this.credits.findAndCount({ + where: { + ...(shippingLineCompanyId ? { shippingLineCompanyId } : {}), + ...(status ? { status } : {}), + }, + relations: { booking: true, invoice: true, shippingLineCompany: true }, + order: { createdAt: "DESC" }, + skip, + take, + }); + } +} diff --git a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-credits.service.spec.ts b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-credits.service.spec.ts new file mode 100644 index 000000000..43e0b5c40 --- /dev/null +++ b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-credits.service.spec.ts @@ -0,0 +1,304 @@ +import { BadRequestException, NotFoundException } from "@nestjs/common"; +import { Freight } from "@edr/types"; + +import { Booking } from "../bookings/entities/booking.entity"; +import { + ShippingLineCredit, + ShippingLineCreditStatus, +} from "./entities/shipping-line-credit.entity"; +import { ShippingLineCreditsService } from "./shipping-line-credits.service"; + +/** + * The money path: a shipping line ships without paying, so the debt lives + * entirely in these three transitions. Each test below locks one way the debt + * could be lost or double-counted. + */ +describe("ShippingLineCreditsService", () => { + let creditsRepo: { + findByIdsForUpdate: jest.Mock; + findUnbilled: jest.Mock; + outstandingFor: jest.Mock; + findAllPaginated: jest.Mock; + }; + let billing: { generateInvoice: jest.Mock }; + let shippingLines: { findById: jest.Mock; findByUserId: jest.Mock }; + let dataSource: { transaction: jest.Mock; getRepository: jest.Mock }; + let mg: { + findOne: jest.Mock; + getRepository: jest.Mock; + update: jest.Mock; + }; + let txRepo: { findOne: jest.Mock; save: jest.Mock; create: jest.Mock }; + let updateResult: { affected: number }; + let service: ShippingLineCreditsService; + + const booking = { + id: "booking-1", + reference: "BK-2026-000001", + shippingLineCompanyId: "sl-1", + } as Booking; + + beforeEach(() => { + txRepo = { + findOne: jest.fn().mockResolvedValue(null), + create: jest.fn((v) => v), + save: jest.fn(async (v) => ({ id: "credit-1", ...v })), + }; + mg = { + findOne: jest.fn().mockResolvedValue(booking), + getRepository: jest.fn(() => txRepo), + update: jest.fn().mockResolvedValue({ affected: 1 }), + }; + updateResult = { affected: 2 }; + dataSource = { + transaction: jest.fn(async (cb) => cb(mg)), + getRepository: jest.fn(() => ({ + update: jest.fn().mockResolvedValue(updateResult), + })), + }; + creditsRepo = { + findByIdsForUpdate: jest.fn(), + findUnbilled: jest.fn(), + outstandingFor: jest.fn(), + findAllPaginated: jest.fn(), + }; + billing = { + generateInvoice: jest.fn().mockResolvedValue({ + id: "inv-1", + invoiceNumber: "INV-20260813-00001", + totalAmount: 50000, + }), + }; + shippingLines = { + findById: jest.fn().mockResolvedValue({ id: "sl-1", name: "ESL" }), + findByUserId: jest.fn(), + }; + + service = new ShippingLineCreditsService( + dataSource as never, + creditsRepo as never, + // Approvals repo — only the invoice maker–checker paths touch it. + { + findPendingByInvoice: jest.fn(), + findPendingByInvoiceIds: jest.fn().mockResolvedValue([]), + findByIdForUpdate: jest.fn(), + create: jest.fn(), + update: jest.fn(), + } as never, + billing as never, + shippingLines as never, + ); + }); + + describe("recordCredit", () => { + it("records the charge against the booking's own shipping line", async () => { + const credit = await service.recordCredit({ + bookingId: "booking-1", + amount: 20000, + }); + + expect(txRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ + // Taken from the booking, never from the caller. + shippingLineCompanyId: "sl-1", + bookingId: "booking-1", + amount: 20000, + status: ShippingLineCreditStatus.Unbilled, + }), + ); + expect(credit.id).toBe("credit-1"); + }); + + it("is idempotent per booking — a retried pricing step cannot double the debt", async () => { + const existing = { + id: "credit-existing", + status: ShippingLineCreditStatus.Unbilled, + amount: 20000, + currency: "ETB", + }; + txRepo.findOne.mockResolvedValue(existing); + + const credit = await service.recordCredit({ + bookingId: "booking-1", + amount: 20000, + }); + + expect(credit).toBe(existing); + expect(txRepo.save).not.toHaveBeenCalled(); + }); + + it("refuses a customer booking — those are paid up front, not on credit", async () => { + mg.findOne.mockResolvedValue({ + ...booking, + shippingLineCompanyId: null, + }); + + await expect( + service.recordCredit({ bookingId: "booking-1", amount: 100 }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("rejects a negative amount", async () => { + await expect( + service.recordCredit({ bookingId: "booking-1", amount: -1 }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + }); + + describe("generateInvoice", () => { + const unbilled = (id: string, amount: number) => ({ + id, + shippingLineCompanyId: "sl-1", + bookingId: `booking-${id}`, + amount, + currency: "ETB", + status: ShippingLineCreditStatus.Unbilled, + description: `Freight service — ${id}`, + }); + + it("bills the batch as one invoice and flips the credits to BILLED", async () => { + creditsRepo.findByIdsForUpdate.mockResolvedValue([ + unbilled("c1", 20000), + unbilled("c2", 30000), + ]); + + const invoice = await service.generateInvoice(["c1", "c2"]); + + expect(billing.generateInvoice).toHaveBeenCalledWith( + expect.objectContaining({ + source: Freight.InvoiceSource.ShippingLineCredit, + // The payer, not a customer — invoices.company_id stays null. + shippingLineCompanyId: "sl-1", + sourceId: "sl-1", + status: Freight.InvoiceStatus.Issued, + lines: [ + expect.objectContaining({ amount: 20000 }), + expect.objectContaining({ amount: 30000 }), + ], + }), + mg, + ); + expect(mg.update).toHaveBeenCalledWith( + ShippingLineCredit, + expect.anything(), + expect.objectContaining({ + status: ShippingLineCreditStatus.Billed, + invoiceId: "inv-1", + }), + ); + expect(invoice.id).toBe("inv-1"); + }); + + it("refuses to bill a credit that is already on an invoice", async () => { + creditsRepo.findByIdsForUpdate.mockResolvedValue([ + { ...unbilled("c1", 20000), status: ShippingLineCreditStatus.Billed }, + ]); + + await expect(service.generateInvoice(["c1"])).rejects.toBeInstanceOf( + BadRequestException, + ); + expect(billing.generateInvoice).not.toHaveBeenCalled(); + }); + + it("refuses to mix two shipping lines on one invoice", async () => { + creditsRepo.findByIdsForUpdate.mockResolvedValue([ + unbilled("c1", 20000), + { ...unbilled("c2", 30000), shippingLineCompanyId: "sl-2" }, + ]); + + await expect( + service.generateInvoice(["c1", "c2"]), + ).rejects.toBeInstanceOf(BadRequestException); + expect(billing.generateInvoice).not.toHaveBeenCalled(); + }); + + it("refuses to mix currencies", async () => { + creditsRepo.findByIdsForUpdate.mockResolvedValue([ + unbilled("c1", 20000), + { ...unbilled("c2", 300), currency: "USD" }, + ]); + + await expect( + service.generateInvoice(["c1", "c2"]), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("reports ids that do not exist rather than silently billing the rest", async () => { + creditsRepo.findByIdsForUpdate.mockResolvedValue([unbilled("c1", 20000)]); + + await expect( + service.generateInvoice(["c1", "missing"]), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it("rejects an empty selection", async () => { + await expect(service.generateInvoice([])).rejects.toBeInstanceOf( + BadRequestException, + ); + }); + }); + + describe("onInvoicePaid", () => { + it("clears every billed credit on the settled invoice", async () => { + const update = jest.fn().mockResolvedValue({ affected: 2 }); + dataSource.getRepository = jest.fn(() => ({ update })); + + await service.onInvoicePaid({ + invoiceId: "inv-1", + invoiceNumber: "INV-20260813-00001", + } as never); + + expect(update).toHaveBeenCalledWith( + // Scoped to BILLED so a redelivered webhook cannot re-stamp paidAt. + { invoiceId: "inv-1", status: ShippingLineCreditStatus.Billed }, + expect.objectContaining({ status: ShippingLineCreditStatus.Paid }), + ); + }); + + it("is a no-op on webhook redelivery", async () => { + const update = jest.fn().mockResolvedValue({ affected: 0 }); + dataSource.getRepository = jest.fn(() => ({ update })); + + await expect( + service.onInvoicePaid({ + invoiceId: "inv-1", + invoiceNumber: "INV-1", + } as never), + ).resolves.toBeUndefined(); + }); + }); + + describe("cancelCredit", () => { + it("writes off an unbilled credit", async () => { + creditsRepo.findByIdsForUpdate.mockResolvedValue([ + { id: "c1", status: ShippingLineCreditStatus.Unbilled }, + ]); + + const result = await service.cancelCredit("c1", "Booking voided"); + + expect(result.status).toBe(ShippingLineCreditStatus.Cancelled); + expect(mg.update).toHaveBeenCalledWith( + ShippingLineCredit, + { id: "c1" }, + expect.objectContaining({ + status: ShippingLineCreditStatus.Cancelled, + cancellationReason: "Booking voided", + }), + ); + }); + + it("refuses to write off a credit already on an invoice", async () => { + creditsRepo.findByIdsForUpdate.mockResolvedValue([ + { + id: "c1", + status: ShippingLineCreditStatus.Billed, + invoiceId: "inv-1", + }, + ]); + + await expect( + service.cancelCredit("c1", "oops"), + ).rejects.toBeInstanceOf(BadRequestException); + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-credits.service.ts b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-credits.service.ts new file mode 100644 index 000000000..2fffc4c2e --- /dev/null +++ b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-credits.service.ts @@ -0,0 +1,780 @@ +import { logCtx } from "@edr/api-common"; +import { Freight } from "@edr/types"; +import { + BadRequestException, + ForbiddenException, + Injectable, + Logger, + NotFoundException, +} from "@nestjs/common"; +import { OnEvent } from "@nestjs/event-emitter"; +import { DataSource, EntityManager, In } from "typeorm"; + +import { + BillingService, + InvoiceEventPayload, + InvoiceLineInput, +} from "../billing/billing.service"; +import { Invoice } from "../billing/entities/invoice.entity"; +import { Booking } from "../bookings/entities/booking.entity"; +import { + ShippingLineCredit, + ShippingLineCreditStatus, +} from "./entities/shipping-line-credit.entity"; +import { + ShippingLineInvoiceApproval, + ShippingLineInvoiceActionStatus, + ShippingLineInvoiceActionType, +} from "./entities/shipping-line-invoice-approval.entity"; +import { ShippingLineCreditsRepository } from "./shipping-line-credits.repository"; +import { ShippingLineInvoiceApprovalsRepository } from "./shipping-line-invoice-approvals.repository"; +import { ShippingLineCompany } from "./entities/shipping-line-company.entity"; +import { ShippingLineCompaniesService } from "./shipping-line-companies.service"; + +/** A charge to record against a shipping line's booking. */ +export interface RecordCreditInput { + bookingId: string; + /** Frozen at this value; never recalculated afterwards. */ + amount: number; + currency?: string; + description?: string; +} + +/** Payment terms for a generated shipping-line invoice. */ +export interface GenerateCreditInvoiceOptions { + /** Pay window in days; defaults to the billing module's own default. */ + dueInDays?: number; +} + +/** + * Emitted by the booking-transition accept path for shipping-line bookings. + * An event rather than a service call: BookingsModule cannot import the + * shipping-line modules without closing a module cycle. + */ +export interface ShippingLineBookingAcceptedPayload { + bookingId: string; + reference: string; + /** The booking's priced total, frozen at completion time. */ + amount: number; +} + +/** + * The credit ledger for shipping lines — "use the service now, pay later". + * + * Three moments, in order: + * + * 1. **Charge.** A shipping line's booking is priced, and + * {@link recordCredit} writes an UNBILLED credit. No invoice, no payment + * intent, no gate on the booking — it proceeds regardless. + * 2. **Bill.** Finance picks a batch of unbilled credits for ONE line and + * {@link generateInvoice} turns them into a single invoice, one line per + * credit. The credits become BILLED. + * 3. **Settle.** The line pays that invoice through the ordinary CBE flow. + * Billing emits `shipping_line_credit.invoice.paid`, {@link onInvoicePaid} + * marks the batch PAID, and the debt disappears. + * + * Nothing here decrements a balance: the amount owed is always + * `SUM(amount)` over non-terminal credits. Payment is settled by the gateway + * webhook alone — no manual approval step — so a credit only ever leaves debt + * because real money arrived. + */ +@Injectable() +export class ShippingLineCreditsService { + private readonly logger = new Logger(ShippingLineCreditsService.name); + + constructor( + private readonly dataSource: DataSource, + private readonly credits: ShippingLineCreditsRepository, + private readonly approvals: ShippingLineInvoiceApprovalsRepository, + private readonly billing: BillingService, + private readonly shippingLines: ShippingLineCompaniesService, + ) {} + + // ── 1. Charge ────────────────────────────────────────────────────────────── + + /** + * Record what a shipping line owes for one booking. + * + * Called when the booking is priced. The owner is read off the booking + * itself rather than passed in, so a credit can never be filed against the + * wrong line. Idempotent per booking: a second call returns the existing + * credit untouched rather than doubling the debt — safe against a retried + * pricing step, and the partial unique index backs it at the DB level. + * + * Pass `manager` to enlist in the caller's transaction, so the credit and + * whatever priced the booking commit together. + */ + async recordCredit( + input: RecordCreditInput, + manager?: EntityManager, + ): Promise { + if (!(input.amount >= 0)) { + throw new BadRequestException("Credit amount cannot be negative."); + } + + const run = async (mg: EntityManager): Promise => { + const booking = await mg.findOne(Booking, { + where: { id: input.bookingId }, + }); + if (!booking) { + throw new NotFoundException(`Booking ${input.bookingId} not found`); + } + if (!booking.shippingLineCompanyId) { + throw new BadRequestException( + `Booking ${booking.reference} is not a shipping-line booking — customer bookings are billed up front, not on credit.`, + ); + } + + const repo = mg.getRepository(ShippingLineCredit); + const existing = await repo.findOne({ + where: { bookingId: input.bookingId }, + }); + if (existing && existing.status !== ShippingLineCreditStatus.Cancelled) { + this.logger.warn( + `Credit already exists for booking ${booking.reference} (${existing.status}, ${existing.amount} ${existing.currency}) — leaving it unchanged.`, + ); + return existing; + } + + const credit = await repo.save( + repo.create({ + shippingLineCompanyId: booking.shippingLineCompanyId, + bookingId: input.bookingId, + amount: input.amount, + currency: input.currency ?? "ETB", + description: + input.description ?? `Freight service — booking ${booking.reference}`, + status: ShippingLineCreditStatus.Unbilled, + }), + ); + + logCtx( + { + creditId: credit.id, + bookingId: credit.bookingId, + shippingLineCompanyId: credit.shippingLineCompanyId, + amount: credit.amount, + }, + { path: "shippingLineCredit.recorded" }, + ); + + return credit; + }; + + return manager ? run(manager) : this.dataSource.transaction(run); + } + + /** + * The moment a shipping-line booking becomes debt: Operations accepted it. + * Swallows its own failures with a loud log instead of throwing — the accept + * has already committed, and failing the staff response for a ledger write + * would present a succeeded accept as an error. `recordCredit` is idempotent + * per booking, so a re-accepted (previously reverted) booking cannot double + * the debt. + */ + @OnEvent("shipping_line_booking.accepted") + async onBookingAccepted( + payload: ShippingLineBookingAcceptedPayload, + ): Promise { + try { + await this.recordCredit({ + bookingId: payload.bookingId, + amount: payload.amount, + // Shipping lines are always billed in ETB (enforced at completion). + currency: "ETB", + }); + } catch (err) { + this.logger.error( + `Failed to record credit for accepted shipping-line booking ${payload.reference} (${payload.bookingId}): ${(err as Error).message} — the debt is NOT on the ledger; record it manually or re-trigger.`, + ); + } + } + + // ── 2. Bill ──────────────────────────────────────────────────────────────── + + /** + * Turn a batch of unbilled credits into one invoice. + * + * Every credit must belong to the SAME shipping line — one invoice has one + * payer, so a mixed batch is rejected rather than silently split. The whole + * thing runs in one transaction with the credits locked FOR UPDATE, so two + * finance users clicking at once cannot bill the same credit twice: the + * second transaction blocks, then finds the rows already BILLED and fails. + */ + async generateInvoice( + creditIds: string[], + options: GenerateCreditInvoiceOptions = {}, + ): Promise { + if (creditIds.length === 0) { + throw new BadRequestException( + "Select at least one credit to invoice.", + ); + } + const uniqueIds = [...new Set(creditIds)]; + + return this.dataSource.transaction(async (mg) => { + const credits = await this.credits.findByIdsForUpdate(mg, uniqueIds); + + const missing = uniqueIds.filter( + (id) => !credits.some((c) => c.id === id), + ); + if (missing.length > 0) { + throw new NotFoundException( + `Credit(s) not found: ${missing.join(", ")}`, + ); + } + + const alreadyBilled = credits.filter( + (c) => c.status !== ShippingLineCreditStatus.Unbilled, + ); + if (alreadyBilled.length > 0) { + throw new BadRequestException( + `These credits are no longer unbilled and cannot be invoiced: ${alreadyBilled + .map((c) => `${c.id} (${c.status})`) + .join(", ")}`, + ); + } + + const lineIds = new Set(credits.map((c) => c.shippingLineCompanyId)); + if (lineIds.size > 1) { + throw new BadRequestException( + "All selected credits must belong to the same shipping line — one invoice has one payer.", + ); + } + const shippingLineCompanyId = credits[0].shippingLineCompanyId; + + const currencies = new Set(credits.map((c) => c.currency)); + if (currencies.size > 1) { + throw new BadRequestException( + `Cannot mix currencies on one invoice: ${[...currencies].join(", ")}.`, + ); + } + const currency = credits[0].currency; + + const shippingLine = await this.shippingLines.findById( + shippingLineCompanyId, + ); + if (!shippingLine) { + throw new NotFoundException( + `Shipping line ${shippingLineCompanyId} not found`, + ); + } + + const lines: InvoiceLineInput[] = credits.map((credit) => ({ + chargeType: "SHIPPING_LINE_SERVICE", + description: credit.description ?? undefined, + quantity: 1, + unitRate: Number(credit.amount), + amount: Number(credit.amount), + currency: credit.currency, + metadata: { creditId: credit.id, bookingId: credit.bookingId }, + })); + + const invoice = await this.billing.generateInvoice( + { + source: Freight.InvoiceSource.ShippingLineCredit, + // Unlike other sources this is the payer, not a single billed + // record: the invoice spans many bookings, and each credit keeps its + // own booking link. + sourceId: shippingLineCompanyId, + type: "SHIPPING_LINE_CREDIT", + shippingLineCompanyId, + currency, + lines, + dueInDays: options.dueInDays, + status: Freight.InvoiceStatus.Issued, + }, + mg, + ); + + const billedAt = new Date(); + await mg.update( + ShippingLineCredit, + { id: In(credits.map((c) => c.id)) }, + { + status: ShippingLineCreditStatus.Billed, + invoiceId: invoice.id, + billedAt, + }, + ); + + logCtx( + { + invoiceId: invoice.id, + invoiceNumber: invoice.invoiceNumber, + shippingLineCompanyId, + creditCount: credits.length, + totalAmount: invoice.totalAmount, + }, + { path: "shippingLineCredit.invoiced" }, + ); + + return invoice; + }); + } + + // ── 3. Settle ────────────────────────────────────────────────────────────── + + /** + * Clear the batch once its invoice is paid. + * + * Driven by the billing event rather than a call inside the payment path, so + * the CBE webhook flow needs no knowledge of credits: whatever settles the + * invoice — gateway webhook, or a finance-recorded offline payment — this + * fires. Idempotent, because a redelivered webhook re-emits the event. + */ + @OnEvent("shipping_line_credit.invoice.paid") + async onInvoicePaid(payload: InvoiceEventPayload): Promise { + const result = await this.dataSource + .getRepository(ShippingLineCredit) + .update( + { + invoiceId: payload.invoiceId, + status: ShippingLineCreditStatus.Billed, + }, + { status: ShippingLineCreditStatus.Paid, paidAt: new Date() }, + ); + + logCtx( + { + invoiceId: payload.invoiceId, + invoiceNumber: payload.invoiceNumber, + creditsCleared: result.affected ?? 0, + }, + { path: "shippingLineCredit.settled" }, + ); + + // Zero is the ordinary idempotent no-op on webhook redelivery. It is only + // worth a line in the log, not an error: the invoice is paid either way. + if (!result.affected) { + this.logger.log( + `Invoice ${payload.invoiceNumber} paid — no BILLED credits left to clear (already settled).`, + ); + } + } + + // ── Reads ────────────────────────────────────────────────────────────────── + + /** Finance's worklist: what can go on an invoice for this line right now. */ + async listUnbilled(shippingLineCompanyId: string) { + await this.requireShippingLine(shippingLineCompanyId); + const credits = await this.credits.findUnbilled(shippingLineCompanyId); + return { + items: credits, + totalAmount: credits.reduce((sum, c) => sum + Number(c.amount), 0), + currency: credits[0]?.currency ?? "ETB", + }; + } + + /** The debt figure shown on the shipping-line detail page. */ + async outstanding(shippingLineCompanyId: string) { + await this.requireShippingLine(shippingLineCompanyId); + return this.credits.outstandingFor(shippingLineCompanyId); + } + + /** + * Back-office overview: outstanding totals across every line, or one line + * when an id is given. + */ + async summary(shippingLineCompanyId?: string) { + if (shippingLineCompanyId) { + await this.requireShippingLine(shippingLineCompanyId); + } + return this.credits.outstandingFor(shippingLineCompanyId); + } + + /** + * The whole ledger across every shipping line, newest first — finance's + * landing list. Optionally narrowed to one line and/or one status. + */ + async listAll( + page = 1, + pageSize = 20, + status?: ShippingLineCreditStatus, + shippingLineCompanyId?: string, + ) { + if (shippingLineCompanyId) { + await this.requireShippingLine(shippingLineCompanyId); + } + const [items, total] = await this.credits.findAllPaginated( + shippingLineCompanyId, + (page - 1) * pageSize, + pageSize, + status, + ); + return { items, total, page, pageSize }; + } + + /** Full ledger for one line, newest first. */ + async listCredits( + shippingLineCompanyId: string, + page = 1, + pageSize = 20, + status?: ShippingLineCreditStatus, + ) { + await this.requireShippingLine(shippingLineCompanyId); + const [items, total] = await this.credits.findAllPaginated( + shippingLineCompanyId, + (page - 1) * pageSize, + pageSize, + status, + ); + return { items, total, page, pageSize }; + } + + /** + * The signed-in shipping line's own statement: what it owes and why. + * Resolves the line from the session, so one line can never read another's. + */ + async myStatement(userId: string, page = 1, pageSize = 20) { + const shippingLine = await this.shippingLines.findByUserId(userId); + if (!shippingLine) { + throw new ForbiddenException("This account is not a shipping line."); + } + const [outstanding, ledger] = await Promise.all([ + this.credits.outstandingFor(shippingLine.id), + this.credits.findAllPaginated( + shippingLine.id, + (page - 1) * pageSize, + pageSize, + ), + ]); + return { + outstanding, + items: ledger[0], + total: ledger[1], + page, + pageSize, + }; + } + + // ── Credit invoices: list + maker–checker manual actions ───────────────── + + /** + * Staff list of the invoices minted from credit batches, each with its line + * name and any undecided manual-action request attached — the data the + * back-office actions column renders from. + */ + async listCreditInvoices( + page = 1, + pageSize = 20, + status?: Freight.InvoiceStatus, + shippingLineCompanyId?: string, + ) { + const [invoices, total] = await this.dataSource + .getRepository(Invoice) + .findAndCount({ + where: { + source: Freight.InvoiceSource.ShippingLineCredit, + ...(status ? { status } : {}), + ...(shippingLineCompanyId ? { shippingLineCompanyId } : {}), + }, + order: { createdAt: "DESC" }, + skip: (page - 1) * pageSize, + take: pageSize, + }); + + const lineIds = [ + ...new Set( + invoices + .map((inv) => inv.shippingLineCompanyId) + .filter((id): id is string => !!id), + ), + ]; + const lines = lineIds.length + ? await this.dataSource + .getRepository(ShippingLineCompany) + .find({ where: { id: In(lineIds) } }) + : []; + const nameById = new Map(lines.map((l) => [l.id, l.name])); + + const pending = await this.approvals.findPendingByInvoiceIds( + invoices.map((inv) => inv.id), + ); + const pendingByInvoice = new Map(pending.map((p) => [p.invoiceId, p])); + + return { + items: invoices.map((inv) => ({ + ...inv, + shippingLineName: inv.shippingLineCompanyId + ? (nameById.get(inv.shippingLineCompanyId) ?? null) + : null, + pendingAction: pendingByInvoice.get(inv.id) ?? null, + })), + total, + page, + pageSize, + }; + } + + /** Undecided requests for a batch of invoices — feeds any invoice list. */ + async pendingInvoiceActions( + invoiceIds: string[], + ): Promise { + // Bounded to a list page's worth of ids; anything larger is a misuse. + return this.approvals.findPendingByInvoiceIds(invoiceIds.slice(0, 100)); + } + + /** + * Finance raises a manual action on a credit invoice: record an offline + * payment (MARK_PAID) or void it (CANCEL). Nothing happens to the invoice + * yet — a chief with the matching approve permission decides it. One + * undecided request per invoice (backed by a partial unique index). + */ + async requestInvoiceAction( + invoiceId: string, + action: ShippingLineInvoiceActionType, + requestedBy: string, + reason: string, + paymentReference?: string, + ): Promise { + const invoice = await this.dataSource + .getRepository(Invoice) + .findOne({ where: { id: invoiceId } }); + if (!invoice) { + throw new NotFoundException(`Invoice ${invoiceId} not found`); + } + if (invoice.source !== Freight.InvoiceSource.ShippingLineCredit) { + throw new BadRequestException( + "Manual actions here apply only to shipping-line credit invoices.", + ); + } + // Fast feedback only — the billing service re-validates authoritatively + // (under lock) when the request is approved. + if ( + action === ShippingLineInvoiceActionType.MarkPaid && + invoice.status === Freight.InvoiceStatus.Paid + ) { + throw new BadRequestException("Invoice is already paid."); + } + if (invoice.status === Freight.InvoiceStatus.Cancelled) { + throw new BadRequestException("Invoice is already cancelled."); + } + if ( + action === ShippingLineInvoiceActionType.Cancel && + Number(invoice.paidAmount) > 0 + ) { + throw new BadRequestException( + "Cannot cancel an invoice that has payments recorded against it.", + ); + } + const existing = await this.approvals.findPendingByInvoice(invoiceId); + if (existing) { + throw new BadRequestException( + `A ${existing.action} request is already awaiting decision on this invoice.`, + ); + } + + const approval = await this.approvals.create({ + invoiceId, + action, + status: ShippingLineInvoiceActionStatus.Pending, + requestedBy, + reason, + paymentReference: paymentReference ?? null, + }); + + logCtx( + { + approvalId: approval.id, + invoiceId, + invoiceNumber: invoice.invoiceNumber, + action, + requestedBy, + }, + { path: "shippingLineCredit.invoiceAction.requested" }, + ); + + return approval; + } + + /** + * Decide a pending request. Gated purely by permission (the approve/reject + * grants on the controller routes) — a decider holding the grant may decide + * ANY pending request, their own included; that trade-off is deliberate. + * + * Approval executes the real action through the billing service AFTER the + * decision row commits — its settlement/cancellation events must fire from + * billing's own committed transaction (the credits listeners react to + * them). If billing then rejects the action, the decision is compensated + * back to PENDING so the request is not silently lost. + */ + async decideInvoiceAction( + approvalId: string, + decidedBy: string, + approve: boolean, + note?: string, + ): Promise { + if (!approve && !note?.trim()) { + throw new BadRequestException( + "A note is required when rejecting a request.", + ); + } + + const decided = await this.dataSource.transaction(async (mg) => { + const approval = await this.approvals.findByIdForUpdate(mg, approvalId); + if (!approval) { + throw new NotFoundException(`Request ${approvalId} not found`); + } + if (approval.status !== ShippingLineInvoiceActionStatus.Pending) { + throw new BadRequestException( + `This request was already ${approval.status.toLowerCase()}.`, + ); + } + + const status = approve + ? ShippingLineInvoiceActionStatus.Approved + : ShippingLineInvoiceActionStatus.Rejected; + await mg.update( + ShippingLineInvoiceApproval, + { id: approvalId }, + { + status, + decidedBy, + decidedAt: new Date(), + decisionNote: note ?? null, + }, + ); + return { ...approval, status, decidedBy, decisionNote: note ?? null }; + }); + + if (!approve) { + logCtx( + { approvalId, invoiceId: decided.invoiceId, decidedBy }, + { path: "shippingLineCredit.invoiceAction.rejected" }, + ); + return decided; + } + + try { + if (decided.action === ShippingLineInvoiceActionType.MarkPaid) { + const invoice = await this.dataSource + .getRepository(Invoice) + .findOne({ where: { id: decided.invoiceId } }); + if (!invoice) { + throw new NotFoundException(`Invoice ${decided.invoiceId} not found`); + } + // Full settlement of the outstanding balance; billing emits + // `shipping_line_credit.invoice.paid`, which marks the credits PAID. + await this.billing.recordPayment(decided.invoiceId, { + amount: Number(invoice.balanceAmount ?? invoice.totalAmount), + method: "OFFLINE", + reference: decided.paymentReference ?? undefined, + metadata: { + approvalId: decided.id, + requestedBy: decided.requestedBy, + approvedBy: decidedBy, + }, + }); + } else { + // Billing emits `shipping_line_credit.invoice.cancelled`; + // onInvoiceCancelled releases the credits back to the unbilled pool. + await this.billing.cancelInvoice(decided.invoiceId); + } + } catch (err) { + // The action was refused (state changed since the request — e.g. the + // line paid through CBE in the meantime). Put the request back so it is + // not recorded as approved-but-unexecuted. + await this.approvals.update(approvalId, { + status: ShippingLineInvoiceActionStatus.Pending, + decidedBy: null, + decidedAt: null, + decisionNote: null, + }); + throw err; + } + + logCtx( + { + approvalId, + invoiceId: decided.invoiceId, + action: decided.action, + decidedBy, + }, + { path: "shippingLineCredit.invoiceAction.approved" }, + ); + + return decided; + } + + /** + * When a credit invoice is cancelled — through the approval flow or any + * other billing path — its BILLED credits return to the unbilled pool so + * the debt can be re-billed. The debt itself never disappears on invoice + * cancellation; only {@link cancelCredit} writes debt off. + */ + @OnEvent("shipping_line_credit.invoice.cancelled") + async onInvoiceCancelled(payload: InvoiceEventPayload): Promise { + const result = await this.dataSource + .getRepository(ShippingLineCredit) + .update( + { + invoiceId: payload.invoiceId, + status: ShippingLineCreditStatus.Billed, + }, + { + status: ShippingLineCreditStatus.Unbilled, + invoiceId: null, + billedAt: null, + }, + ); + + logCtx( + { + invoiceId: payload.invoiceId, + invoiceNumber: payload.invoiceNumber, + creditsReleased: result.affected ?? 0, + }, + { path: "shippingLineCredit.invoiceCancelled.released" }, + ); + } + + // ── Cancellation ─────────────────────────────────────────────────────────── + + /** + * Write off an unbilled credit (booking voided, charge raised in error). + * Only UNBILLED credits can be cancelled — once a credit is on an issued + * invoice, the invoice is what has to be cancelled or credited, otherwise + * the invoice total would stop matching the sum of its lines. + */ + async cancelCredit( + creditId: string, + reason: string, + ): Promise { + return this.dataSource.transaction(async (mg) => { + const [credit] = await this.credits.findByIdsForUpdate(mg, [creditId]); + if (!credit) { + throw new NotFoundException(`Credit ${creditId} not found`); + } + if (credit.status !== ShippingLineCreditStatus.Unbilled) { + throw new BadRequestException( + `Only an unbilled credit can be cancelled; this one is ${credit.status}. Cancel or credit invoice ${credit.invoiceId} instead.`, + ); + } + + await mg.update( + ShippingLineCredit, + { id: creditId }, + { + status: ShippingLineCreditStatus.Cancelled, + cancelledAt: new Date(), + cancellationReason: reason, + }, + ); + + return { ...credit, status: ShippingLineCreditStatus.Cancelled }; + }); + } + + private async requireShippingLine(shippingLineCompanyId: string) { + const shippingLine = await this.shippingLines.findById( + shippingLineCompanyId, + ); + if (!shippingLine) { + throw new NotFoundException( + `Shipping line ${shippingLineCompanyId} not found`, + ); + } + return shippingLine; + } +} diff --git a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-invoice-approvals.repository.ts b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-invoice-approvals.repository.ts new file mode 100644 index 000000000..dcd904fa2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-invoice-approvals.repository.ts @@ -0,0 +1,51 @@ +import { BaseRepository } from "@edr/api-common"; +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { EntityManager, In, Repository } from "typeorm"; + +import { + ShippingLineInvoiceApproval, + ShippingLineInvoiceActionStatus, +} from "./entities/shipping-line-invoice-approval.entity"; + +@Injectable() +export class ShippingLineInvoiceApprovalsRepository extends BaseRepository { + constructor( + @InjectRepository(ShippingLineInvoiceApproval) + private readonly approvals: Repository, + ) { + super(approvals); + } + + findPendingByInvoice( + invoiceId: string, + ): Promise { + return this.approvals.findOne({ + where: { invoiceId, status: ShippingLineInvoiceActionStatus.Pending }, + }); + } + + /** Pending requests for a page of invoices — one query, no N+1. */ + findPendingByInvoiceIds( + invoiceIds: string[], + ): Promise { + if (!invoiceIds.length) return Promise.resolve([]); + return this.approvals.find({ + where: { + invoiceId: In(invoiceIds), + status: ShippingLineInvoiceActionStatus.Pending, + }, + }); + } + + /** Load one request inside the caller's transaction, locked for decision. */ + findByIdForUpdate( + manager: EntityManager, + id: string, + ): Promise { + return manager.getRepository(ShippingLineInvoiceApproval).findOne({ + where: { id }, + lock: { mode: "pessimistic_write" }, + }); + } +} diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts index cd8fe1bb9..393378740 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts @@ -7,6 +7,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany, OneToOne } fro import { Yard } from '../../rule-engine/entities/yard.entity'; import { Route } from '../../routes/entities/route.entity'; +import { ShippingLineCompany } from '../../shipping-lines/entities/shipping-line-company.entity'; import { TrainSet } from '../../train-sets/entities/train-set.entity'; import { TrainScheduleBooking } from './train-schedule-booking.entity'; @@ -81,6 +82,19 @@ export class TrainSchedule extends BaseEntity { @Column({ name: 'direction', type: 'varchar', length: 10, nullable: true }) direction?: string | null; + /** + * Dedicates this departure to one shipping line. NULL = a normal train, + * visible to customers as today. Set = the train is HIDDEN from every + * customer-facing read (windows, day pools, home cards) and shown only to + * this shipping line in its portal. + */ + @Column({ name: 'shipping_line_company_id', type: 'uuid', nullable: true }) + shippingLineCompanyId?: string | null; + + @ManyToOne(() => ShippingLineCompany) + @JoinColumn({ name: 'shipping_line_company_id' }) + shippingLineCompany?: ShippingLineCompany | null; + /** * Reverse the wagon ORDER on this train: when true, the built wagon plan is * flipped at build so the physically-last wagon sits at position 1. Only the diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index cb568171a..4418eed3d 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -17,6 +17,7 @@ import { FindOptionsWhere, ILike, In, + IsNull, LessThanOrEqual, MoreThanOrEqual, } from 'typeorm'; @@ -25,6 +26,7 @@ import { Booking } from '../bookings/entities/booking.entity'; import { BookingsRepository } from '../bookings/bookings.repository'; import { BookingPricingService } from '../bookings/booking-pricing.service'; import { formatRouteLabel } from '../routes/entities/route.entity'; +import { isRoadService } from '../bookings/road.util'; import { RouteMilestone } from '../routes/entities/route-milestone.entity'; import { Yard } from '../rule-engine/entities/yard.entity'; import { CargoType } from '../rule-engine/entities/cargo-type.entity'; @@ -153,6 +155,19 @@ export interface ExportTrainOption { }>; } +/** Form-entered cargo for a train-options probe (nothing persisted yet). */ +export interface TrainOptionCargoOverrides { + /** Container types drive the per-type space. */ + containerTypeIds?: string[]; + /** Size labels ("20ft"/"40ft") when the form has no type ids. */ + containerSizes?: string[]; + /** Bulk counterparts of the container inputs. */ + cargoTypeId?: string; + cargoTypeCode?: string; + /** Needed wagons estimate from the form (drives the `fits` flag). */ + wagons?: number; +} + /** A train a paid-unallocated booking can board (route + capacity verified). */ export interface AllocationCandidate { id: string; @@ -820,8 +835,9 @@ export class BookingBatchService implements OnModuleInit { // stop order, so we fetch the day's open trains without endpoint filters. const corridor = await this.trainSchedulesRepository.findAll({ where: [ - { status: TrainScheduleStatusEnum.Draft }, - { status: TrainScheduleStatusEnum.Scheduled }, + // Dedicated shipping-line trains are never customer-booking targets. + { status: TrainScheduleStatusEnum.Draft, shippingLineCompanyId: IsNull() }, + { status: TrainScheduleStatusEnum.Scheduled, shippingLineCompanyId: IsNull() }, ], }); // A customer-picked train narrows the scan to that ONE schedule: export @@ -983,8 +999,9 @@ export class BookingBatchService implements OnModuleInit { ): Promise> { const corridor = await this.trainSchedulesRepository.findAll({ where: [ - { status: TrainScheduleStatusEnum.Draft }, - { status: TrainScheduleStatusEnum.Scheduled }, + // Dedicated shipping-line trains are never customer-booking targets. + { status: TrainScheduleStatusEnum.Draft, shippingLineCompanyId: IsNull() }, + { status: TrainScheduleStatusEnum.Scheduled, shippingLineCompanyId: IsNull() }, ], }); const candidates = corridor @@ -1048,19 +1065,84 @@ export class BookingBatchService implements OnModuleInit { async exportTrainOptionsForDay( booking: Booking, day: string, - overrides?: { - /** Cargo the customer is entering on a form (bare contract instance — - * nothing persisted yet): container types drive the per-type space. */ - containerTypeIds?: string[]; - /** Size labels ("20ft"/"40ft") when the form has no type ids. */ - containerSizes?: string[]; - /** Bulk counterparts of the container inputs. */ - cargoTypeId?: string; - cargoTypeCode?: string; - /** Needed wagons estimate from the form (drives the `fits` flag). */ - wagons?: number; - }, + overrides?: TrainOptionCargoOverrides, ): Promise { + booking = await this.withCargoOverrides(booking, overrides); + const corridor = await this.trainSchedulesRepository.findAll({ + where: [ + // Dedicated shipping-line trains are never customer-booking targets. + { status: TrainScheduleStatusEnum.Draft, shippingLineCompanyId: IsNull() }, + { status: TrainScheduleStatusEnum.Scheduled, shippingLineCompanyId: IsNull() }, + ], + }); + const candidates = corridor + .filter( + (s) => + s.scheduledDepartureDate != null && + eatDay(s.scheduledDepartureDate) === day && + s.direction === 'EXPORT', + ) + .sort( + (a, b) => + a.scheduledDepartureDate!.getTime() - + b.scheduledDepartureDate!.getTime(), + ); + return this.buildTrainOptions(booking, candidates); + } + + /** + * The same per-train wagon-availability cards, but for the trains DEDICATED + * to a shipping line on the booking's lane + day. Same option shape as the + * export picker so the portal reuses the same component; `isOpen` + * additionally respects the dedicated close offset (windowClosesAt), since + * these trains run no window cycle. + */ + async dedicatedTrainOptionsForDay( + booking: Booking, + day: string | null, + shippingLineCompanyId: string, + overrides?: TrainOptionCargoOverrides, + ): Promise { + booking = await this.withCargoOverrides(booking, overrides); + const dedicated = await this.trainSchedulesRepository.findAll({ + where: [ + { status: TrainScheduleStatusEnum.Draft, shippingLineCompanyId }, + { status: TrainScheduleStatusEnum.Scheduled, shippingLineCompanyId }, + ], + }); + const candidates = dedicated + .filter( + (s) => + s.scheduledDepartureDate != null && + // A day narrows to that departure day; without one, every upcoming + // departure on the lane is listed (the picker's full card list). + (day + ? eatDay(s.scheduledDepartureDate) === day + : s.scheduledDepartureDate.getTime() > Date.now() - 3_600_000) && + (!booking.originYardId || s.originStationId === booking.originYardId) && + (!booking.destinationYardId || + s.destinationStationId === booking.destinationYardId), + ) + .sort( + (a, b) => + a.scheduledDepartureDate!.getTime() - + b.scheduledDepartureDate!.getTime(), + ); + const options = await this.buildTrainOptions(booking, candidates); + const now = Date.now(); + return options.map((o) => ({ + ...o, + isOpen: + o.isOpen && + (o.bookingClosesAt == null || o.bookingClosesAt.getTime() > now), + })); + } + + /** Resolve form-entered cargo onto an (unpersisted) booking probe. */ + private async withCargoOverrides( + booking: Booking, + overrides?: TrainOptionCargoOverrides, + ): Promise { const sizeFts = (overrides?.containerSizes ?? []) .map((s) => parseInt(s, 10)) .filter((n) => Number.isFinite(n) && n > 0); @@ -1092,25 +1174,14 @@ export class BookingBatchService implements OnModuleInit { if (overrides?.wagons && overrides.wagons > 0) { booking = { ...booking, wagonsRequired: overrides.wagons } as Booking; } - const corridor = await this.trainSchedulesRepository.findAll({ - where: [ - { status: TrainScheduleStatusEnum.Draft }, - { status: TrainScheduleStatusEnum.Scheduled }, - ], - }); - const candidates = corridor - .filter( - (s) => - s.scheduledDepartureDate != null && - eatDay(s.scheduledDepartureDate) === day && - s.direction === 'EXPORT', - ) - .sort( - (a, b) => - a.scheduledDepartureDate!.getTime() - - b.scheduledDepartureDate!.getTime(), - ); + return booking; + } + /** One availability card per candidate schedule — the export picker's math. */ + private async buildTrainOptions( + booking: Booking, + candidates: TrainSchedule[], + ): Promise { const wagonDims = await this.loadWagonDims(); const allowed = this.allowedDimsWithTypes(booking, wagonDims); const neededWagons = this.wagonsFor(booking, wagonDims); @@ -1219,8 +1290,9 @@ export class BookingBatchService implements OnModuleInit { ): Promise<{ freeWagons: number; need: number; trainsForDay: boolean }> { const corridor = await this.trainSchedulesRepository.findAll({ where: [ - { status: TrainScheduleStatusEnum.Draft }, - { status: TrainScheduleStatusEnum.Scheduled }, + // Dedicated shipping-line trains are never customer-booking targets. + { status: TrainScheduleStatusEnum.Draft, shippingLineCompanyId: IsNull() }, + { status: TrainScheduleStatusEnum.Scheduled, shippingLineCompanyId: IsNull() }, ], }); const candidates = corridor.filter( @@ -2365,11 +2437,14 @@ export class BookingBatchService implements OnModuleInit { originStationId: originYardId, destinationStationId: destinationYardId, status: TrainScheduleStatusEnum.Draft, + // Dedicated shipping-line trains never join the customer day pool. + shippingLineCompanyId: IsNull(), }, { originStationId: originYardId, destinationStationId: destinationYardId, status: TrainScheduleStatusEnum.Scheduled, + shippingLineCompanyId: IsNull(), }, ], }); @@ -3100,8 +3175,9 @@ export class BookingBatchService implements OnModuleInit { if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); const schedules = await this.trainSchedulesRepository.findAll({ where: [ - { status: TrainScheduleStatusEnum.Draft }, - { status: TrainScheduleStatusEnum.Scheduled }, + // Dedicated shipping-line trains are never customer-booking targets. + { status: TrainScheduleStatusEnum.Draft, shippingLineCompanyId: IsNull() }, + { status: TrainScheduleStatusEnum.Scheduled, shippingLineCompanyId: IsNull() }, ], }); const today = eatDay(new Date()); @@ -3176,6 +3252,99 @@ export class BookingBatchService implements OnModuleInit { } } + /** + * Auto-allocate an accepted SHIPPING-LINE booking onto its company's + * dedicated train for the booking's lane and shipment day. + * + * Runs at operation-accept: shipping lines pay later on the credit ledger, + * so there is no pay window between accept and wagon placement — the + * booking boards its train immediately. Customer bookings never come here; + * they keep the batch pool → reserve → pay → allocate pipeline. + * + * Wagon shortage parks the booking WAITING_FOR_WAGON on the schedule + * (without the PAID stamps the customer hold writes — nothing was paid). + * No dedicated train on the day is not an error: the booking simply stays + * in the ordinary day pool for the batch engine. + */ + async allocateShippingLineAccepted(bookingId: string): Promise { + const booking = await this.dataSource.getRepository(Booking).findOne({ + where: { id: bookingId }, + relations: { bookingContainers: { containerType: true }, cargoType: true }, + }); + if (!booking?.shippingLineCompanyId || !booking.scheduledDate) return; + if (isRoadService(booking.serviceType)) return; + + const day = eatDay(booking.scheduledDate); + const dedicated = await this.dataSource.getRepository(TrainSchedule).find({ + where: [ + { + shippingLineCompanyId: booking.shippingLineCompanyId, + originStationId: booking.originYardId, + destinationStationId: booking.destinationYardId, + status: TrainScheduleStatusEnum.Draft, + }, + { + shippingLineCompanyId: booking.shippingLineCompanyId, + originStationId: booking.originYardId, + destinationStationId: booking.destinationYardId, + status: TrainScheduleStatusEnum.Scheduled, + }, + ], + }); + const target = dedicated.find( + (s) => + s.scheduledDepartureDate && eatDay(s.scheduledDepartureDate) === day, + ); + if (!target) { + this.logger.log( + `[BATCH] shipping-line booking ${booking.reference} has no dedicated ` + + `train on ${day} — left in the day pool for the batch engine`, + ); + return; + } + + // Point the booking at its train BEFORE the shortage probe — the probe + // reads the link to size the need against that schedule's wagons. + await this.dataSource.getRepository(Booking).update(booking.id, { + trainScheduleId: target.id, + } as never); + booking.trainScheduleId = target.id; + + // One dedicated train carries ONE booking: the accept claims the train by + // closing its booking window on the spot. Both gates a later booking + // passes — the day picker (isStillOpen on windowClosesAt) and the + // completion's dedicated-day check — read these fields, so a second + // booking can never pick this train. + await this.dataSource.getRepository(TrainSchedule).update(target.id, { + bookingWindowStatus: "CLOSED", + windowClosesAt: new Date(), + } as never); + this.notifyBoardChanged(target.id, "shipping_line_train_claimed"); + + const shortage = + await this.trainSchedulingService.previewPaidBookingWagonShortage( + target.id, + booking.id, + ); + if (shortage) { + // Parked for staff to attach wagons — WITHOUT the customer hold's PAID + // stamps: a shipping line has paid nothing, its debt sits on the ledger. + await this.dataSource.getRepository(Booking).update(booking.id, { + schedulingStatus: "WAITING_FOR_WAGON", + } as never); + this.logger.warn( + `Shipping-line booking ${booking.reference} WAITING FOR WAGON on its ` + + `dedicated train ${target.reference ?? target.id}: needs ` + + `${shortage.wagonsNeeded} × ${shortage.wagonTypeCodes}, ` + + `${shortage.wagonsAvailable} available (short ${shortage.wagonsShort}).`, + ); + this.notifyBoardChanged(target.id, "booking_waiting_wagon"); + return; + } + + await this.allocate(target.id, booking, "shipping_line"); + } + /** * One reminder per hold, shortly before its pay deadline (the window tick * calls this every pass; `payment_reminder_sent_at` dedups). Skips paid @@ -3535,7 +3704,7 @@ export class BookingBatchService implements OnModuleInit { private async allocate( scheduleId: string, booking: Booking, - reason: "paid" | "gov", + reason: "paid" | "gov" | "shipping_line", ): Promise { // Stamp the computed wagon need on the link. Several callers pass a booking // loaded without cargo relations (ensurePaidBookingAllocated), and a NULL diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.spec.ts index 0a8cc99fe..ea936465e 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.spec.ts @@ -11,6 +11,8 @@ describe('BookingJourneyService.autoPlaceOnFreedWagons', () => { {} as never, // yardFacilities {} as never, // facilityHandling { emit: jest.fn() } as never, // events + {} as never, // notifications + {} as never, // inbox ); const schedule = { id: 'sched-1', trainSetId: 'ts-1' }; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts index bf6ab5069..56b28fb7f 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts @@ -24,7 +24,10 @@ import { WagonBookingAllocation } from '../train-schedules/entities/wagon-bookin import { Wagon } from '../wagons/entities/wagon.entity'; import { WagonMovement } from '../wagons/entities/wagon-movement.entity'; import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity'; -import { assertExportReceivedWithGrn } from '../../common/export-received-gate'; +import { assertExportReceivedWithGrn, DIRECT_TO_TRAIN } from '../../common/export-received-gate'; +import { NotificationsService } from '../notifications/notifications.service'; +import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; +import { notifyCarriageAcceptanceReady } from '../notifications/notify-company.util'; /** * Per-booking journey along a train's corridor — for EVERY trade direction. @@ -52,6 +55,8 @@ export class BookingJourneyService { private readonly yardFacilities: YardFacilitiesService, private readonly facilityHandling: FacilityHandlingService, private readonly events: EventEmitter2, + private readonly notifications: NotificationsService, + private readonly inbox: NotificationInboxService, @Optional() private readonly milestoneService?: ClearanceMilestoneService, ) {} @@ -76,6 +81,21 @@ export class BookingJourneyService { // Export cargo must be in the warehouse with a GRN before it can be loaded, // however it arrived and whatever it is allocated to. await assertExportReceivedWithGrn(this.dataSource, booking); + // Direct truck-to-train cargo never sees the warehouse, so loading IS its + // handover moment — the carriage acceptance sheet must go out to the + // customer right here, not on a receive event that will never fire. + if ( + booking.tradeDirection === 'EXPORT' && + booking.exportHandoverMode === DIRECT_TO_TRAIN + ) { + await notifyCarriageAcceptanceReady( + this.dataSource, + this.notifications, + this.inbox, + booking.id, + this.logger, + ); + } const now = new Date(); await this.dataSource.transaction(async (manager) => { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts index 9468387c5..1c2d99401 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts @@ -12,6 +12,7 @@ import { Booking } from '../bookings/entities/booking.entity'; import { NotificationsService } from '../notifications/notifications.service'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; import { resolveCompanyNotifyContact } from '../notifications/resolve-company-phone.util'; +import { resolveShippingLineNotifyTarget } from '../notifications/resolve-shipping-line-contact.util'; import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository'; import { BATCH_TIMEZONE } from './booking-batch.constants'; @@ -66,10 +67,16 @@ export class BookingNotifierService { ): Promise { this.logger.log(`${logLabel} — ${this.ref(b)}`); // One resolver for both channels — the company row's own email column is - // only set for a Fayda-verified owner (see companyNotifyEmailExpr). - const { phone, email } = b.companyId - ? await resolveCompanyNotifyContact(this.dataSource, b.companyId) - : { phone: null, email: null }; + // only set for a Fayda-verified owner (see companyNotifyEmailExpr). A + // shipping-line booking has no company; its contact is the line's row. + const { phone, email } = b.shippingLineCompanyId + ? await resolveShippingLineNotifyTarget( + this.dataSource, + b.shippingLineCompanyId, + ) + : b.companyId + ? await resolveCompanyNotifyContact(this.dataSource, b.companyId) + : { phone: null, email: null }; if (phone) { try { @@ -90,13 +97,42 @@ export class BookingNotifierService { } } - /** Persist + push an in-app item to all portal users of the booking's company. */ + /** + * Persist + push an in-app item to the booking's portal owner: every portal + * user of the company, or — for a shipping-line booking — the line's own + * account, deep-linked into the shipping-line app (/shipping-line/*). + */ private inApp( b: Booking, title: string, body: string, overrides: Partial = {}, ): void { + if (b.shippingLineCompanyId) { + void (async () => { + const { userId } = await resolveShippingLineNotifyTarget( + this.dataSource, + b.shippingLineCompanyId!, + ); + if (!userId) return; + void this.inbox.notify({ + recipients: { userIds: [userId] }, + audience: NotificationAudience.PORTAL, + type: NotificationType.SCHEDULE_UPDATE, + title, + body, + data: { bookingId: b.id, reference: b.reference }, + ...overrides, + // After the spread: the bell must land the line on ITS booking page. + link: `/shipping-line/bookings/${b.id}`, + }); + })().catch((err) => + this.logger.warn( + `shipping-line inApp failed for ${this.ref(b)}: ${(err as Error).message}`, + ), + ); + return; + } if (!b.companyId) return; // government/unlinked bookings have no portal users void this.inbox.notify({ recipients: { companyId: b.companyId }, @@ -209,11 +245,19 @@ export class BookingNotifierService { }); } - secured(b: Booking, reason: 'paid' | 'gov', scheduleId?: string | null): void { + secured( + b: Booking, + reason: 'paid' | 'gov' | 'shipping_line', + scheduleId?: string | null, + ): void { void (async () => { const label = await this.scheduleLabel(scheduleId ?? b.trainScheduleId); const msg = `Booking ${b.reference ?? b.id} allocated on ${label}${ - reason === 'gov' ? ' (government)' : '' + reason === 'gov' + ? ' (government)' + : reason === 'shipping_line' + ? ' (shipping line)' + : '' }.`; void this.notifyContact(b, msg, 'ALLOCATED'); this.inApp(b, 'Wagon allocated', msg); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts index e8716eb99..18904c032 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts @@ -172,6 +172,17 @@ export class CreateContainerTrainScheduleDto { @IsBoolean() reverseWagonOrder?: boolean; + @ApiPropertyOptional({ + format: 'uuid', + description: + 'Dedicate this departure to one shipping line. The schedule is then hidden ' + + 'from every customer-facing read (windows, day pools, home cards) and shown ' + + 'only to that shipping line in its portal. Omit for a normal customer train.', + }) + @IsOptional() + @IsUUID() + shippingLineCompanyId?: string; + @ApiPropertyOptional({ type: CreateScheduleWindowRuleDto, description: diff --git a/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts b/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts index 94882833b..50debc6c0 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts @@ -57,9 +57,10 @@ export class TrainSchedulingGlobalRules extends BaseEntity { /** * Local (Africa/Addis_Ababa) hour the booking desk shuts each day. A not-yet-full * train whose next cycle would reopen at/after this hour pauses until the next - * morning's windowOpenHour. Set equal to windowOpenHour for a 24-hour desk. + * morning's windowOpenHour. Set equal to windowOpenHour for a 24-hour desk + * (the default). */ - @Column({ name: 'window_close_hour', type: 'int', default: 17 }) + @Column({ name: 'window_close_hour', type: 'int', default: 8 }) windowCloseHour!: number; // Stored in hours; 4 decimals so sub-minute UI durations (4 min = 0.0667h) diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts index e6b3a5040..10ebaf6ee 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts @@ -181,6 +181,7 @@ describe('TrainSchedulingService', () => { autoArriveAtFinalYard: jest.fn().mockResolvedValue([]), } as never, // bookingJourneyService { dispatched: jest.fn(), arrived: jest.fn() } as never, // bookingNotifier + { getLogoImageUrl: jest.fn().mockResolvedValue(null) } as never, // logoSettings ); const defaultFleetWagons = [ diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts index fc9395ffb..12fb3dec6 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts @@ -47,6 +47,7 @@ import { Container } from '../../container-management/entities/container.entity' import { Locomotive } from '../../locomotives/entities/locomotive.entity'; import { LocomotivesRepository } from '../../locomotives/locomotives.repository'; import { formatRouteLabel, Route } from '../../routes/entities/route.entity'; +import { ShippingLineCompany } from '../../shipping-lines/entities/shipping-line-company.entity'; import { WagonMovement } from '../../wagons/entities/wagon-movement.entity'; import { Train } from '../../trains/entities/train.entity'; import { TrainSetLocomotive } from '../../train-sets/entities/train-set-locomotive.entity'; @@ -177,6 +178,8 @@ import { RouteMilestone } from '../../routes/entities/route-milestone.entity'; import { deriveTradeDirection } from '../../../common/derive-trade-direction.util'; import { WarehouseInventoryService } from '../../warehouses/warehouse-inventory.service'; import { WarehouseReleaseDocumentService } from '../../warehouses/warehouse-release-document.service'; +import { LogoSettingsService } from '../../logo-settings/logo-settings.service'; +import { logoImageCss, logoMarkup } from '../../billing/documents/logo-markup.util'; import { autoFillPlacements, findMissingContainerNumberIssues, @@ -376,6 +379,7 @@ export class TrainSchedulingService { private readonly bookingWindowGateway: BookingWindowGateway, private readonly bookingJourneyService: BookingJourneyService, private readonly bookingNotifier: BookingNotifierService, + private readonly logoSettings: LogoSettingsService, @Optional() private readonly milestoneService?: ClearanceMilestoneService, private readonly configService?: ConfigService, // forwardRef: BookingBatchService injects this service back; @Optional so @@ -467,7 +471,11 @@ export class TrainSchedulingService { private async emitWindowState(scheduleId: string): Promise { try { const fresh = await this.trainSchedulesRepository.findById(scheduleId); - if (fresh) this.bookingWindowGateway.emitPhase(fresh); + // Dedicated shipping-line departures are never announced to the portal — + // the broadcast reaches every customer client. + if (fresh && !fresh.shippingLineCompanyId) { + this.bookingWindowGateway.emitPhase(fresh); + } } catch (err) { this.logger.warn( `Booking-window push failed for ${scheduleId}: ${(err as Error).message}`, @@ -508,7 +516,11 @@ export class TrainSchedulingService { // and a newborn anchoring to it would inherit that dead window verbatim. .andWhere('s.status != :cancelledStatus', { cancelledStatus: TrainScheduleStatusEnum.Cancelled, - }); + }) + // A dedicated shipping-line departure is never a sibling either: it runs + // no window cycle, so it must neither anchor a customer group nor be + // dragged through one's open/doc-review/payment instants. + .andWhere('s.shippingLineCompanyId IS NULL'); if (excludeScheduleId) { qb.andWhere('s.id != :excludeScheduleId', { excludeScheduleId }); } @@ -1458,6 +1470,24 @@ export class TrainSchedulingService { const scheduleWarnings: string[] = []; + // Dedicating the departure to a shipping line: the id comes from the + // request, so verify it is a real, active line before stamping it. + if (dto.shippingLineCompanyId) { + const line = await this.dataSource + .getRepository(ShippingLineCompany) + .findOne({ where: { id: dto.shippingLineCompanyId } }); + if (!line) { + throw new NotFoundException( + `Shipping line ${dto.shippingLineCompanyId} not found`, + ); + } + if (line.status !== 'active') { + throw new BadRequestException( + `Shipping line ${line.name} is suspended — it cannot be assigned a train`, + ); + } + } + // The pulling set comes either from a built train (Train Builder) or from // hand-picked locomotive ids (legacy path). A built train also links the // schedule's train set back to it (`train_sets.train_id`) so its lifecycle @@ -1590,8 +1620,11 @@ export class TrainSchedulingService { // doc-review/payment phase — so there is no cross-expiry to fix, and two // export trains departing the same day at different times must keep their // own departure-anchored windows. + // Dedicated shipping-line departures never group either: they run no + // window cycle at all, so sharing a customer group's timeline (or + // anchoring one) would drag them into phases they must not have. const groupAnchor = - direction === 'EXPORT' + direction === 'EXPORT' || dto.shippingLineCompanyId ? null : await this.findGroupWindowAnchor( manager, @@ -1656,45 +1689,71 @@ export class TrainSchedulingService { // only re-derives NOT-YET-OPEN schedules (see restampPendingWindows); an // already-open schedule keeps this snapshot, and the batch board draws its // windows from it rather than the live config. - const ruleSnapshot = windowRuleSnapshot(windowCfg); - const computedTimes = - direction === 'EXPORT' - ? { ...ruleSnapshot, ...computeExportWindowTimes(departure, windowCfg) } - : { - // IMPORT and DOMESTIC share the import booking-day window cycle. - ...ruleSnapshot, - ...computeImportWindowTimes(departure, windowCfg, new Date()), - }; - // Inside-lead departure (e.g. a huge configured lead): the raw open lands - // in the past — clamp it to `now` so the window tick opens it immediately. - if (computedTimes.windowOpensAt.getTime() < Date.now()) { - computedTimes.windowOpensAt = new Date(); + let windowFields: Partial; + if (dto.shippingLineCompanyId) { + // Dedicated shipping-line departure: NO window cycle at all. The line + // books whenever it wants from creation until the close offset before + // departure. windowPhase stays NULL, so the window engine, restamp and + // the customer window lists all skip this schedule; the close-offset + // gate is enforced by the shipping-line completion path, which reads + // windowClosesAt stamped here. + const offsetMinutes = windowCfg.importCloseOffsetMinutes ?? 0; + const closesAt = new Date(departure.getTime() - offsetMinutes * 60_000); + if (closesAt.getTime() <= Date.now()) { + throw new BadRequestException( + 'With the booking-close offset applied, this departure would already be ' + + 'closed for shipping-line booking — pick a later departure.', + ); + } + windowFields = { + bookingWindowStatus: 'OPEN', + windowPhase: null, + windowOpensAt: new Date(), + windowClosesAt: closesAt, + ruleImportCloseOffsetMinutes: offsetMinutes || null, + windowRuleCustom: dto.windowRule != null, + }; + } else { + const ruleSnapshot = windowRuleSnapshot(windowCfg); + const computedTimes = + direction === 'EXPORT' + ? { ...ruleSnapshot, ...computeExportWindowTimes(departure, windowCfg) } + : { + // IMPORT and DOMESTIC share the import booking-day window cycle. + ...ruleSnapshot, + ...computeImportWindowTimes(departure, windowCfg, new Date()), + }; + // Inside-lead departure (e.g. a huge configured lead): the raw open lands + // in the past — clamp it to `now` so the window tick opens it immediately. + if (computedTimes.windowOpensAt.getTime() < Date.now()) { + computedTimes.windowOpensAt = new Date(); + } + if ( + computedTimes.windowOpensAt.getTime() >= computedTimes.windowClosesAt.getTime() + ) { + throw new BadRequestException( + 'These booking-window settings leave no window before departure — with the ' + + 'desk hours and close offset applied, the window would only open once the ' + + 'train has left.', + ); + } + windowFields = { + bookingWindowStatus: 'CLOSED', + windowPhase: 'PRE_WINDOW', + ...(groupAnchor + ? this.groupWindowFieldsFrom(groupAnchor, departure) + : computedTimes), + // `windowRuleSnapshot` never stamps the pay window (NULL = follow the + // live global value for the direction), so an explicit staff override is + // persisted here — the same field the post-creation override writes. + ...(dto.windowRule?.paymentWindowMinutes !== undefined + ? { rulePaymentWindowMinutes: dto.windowRule.paymentWindowMinutes } + : {}), + // Hand-configured windows opt OUT of the global re-stamp, or the next + // global-rules edit would overwrite exactly what staff chose here. + windowRuleCustom: dto.windowRule != null, + }; } - if ( - computedTimes.windowOpensAt.getTime() >= computedTimes.windowClosesAt.getTime() - ) { - throw new BadRequestException( - 'These booking-window settings leave no window before departure — with the ' + - 'desk hours and close offset applied, the window would only open once the ' + - 'train has left.', - ); - } - const windowFields = { - bookingWindowStatus: 'CLOSED', - windowPhase: 'PRE_WINDOW', - ...(groupAnchor - ? this.groupWindowFieldsFrom(groupAnchor, departure) - : computedTimes), - // `windowRuleSnapshot` never stamps the pay window (NULL = follow the - // live global value for the direction), so an explicit staff override is - // persisted here — the same field the post-creation override writes. - ...(dto.windowRule?.paymentWindowMinutes !== undefined - ? { rulePaymentWindowMinutes: dto.windowRule.paymentWindowMinutes } - : {}), - // Hand-configured windows opt OUT of the global re-stamp, or the next - // global-rules edit would overwrite exactly what staff chose here. - windowRuleCustom: dto.windowRule != null, - }; // A built train's own consist is the schedule's capacity: full when all // its wagons are allocated. Trains built without wagons yet fall back to // the configured limit. @@ -1722,6 +1781,7 @@ export class TrainSchedulingService { trainNumber: pairTrainNumber ?? undefined, maxWagons, reverseWagonOrder: dto.reverseWagonOrder ?? false, + shippingLineCompanyId: dto.shippingLineCompanyId ?? null, ...windowFields, }), ); @@ -3002,6 +3062,9 @@ export class TrainSchedulingService { .map((wagon) => ({ sequenceNo: wagon.sequenceNo, wagonNumber: wagon.physicalWagon?.wagonNumber ?? null, + wagonType: wagon.wagonType?.code ?? wagon.wagonType?.name ?? null, + tareWeightTons: wagon.wagonType?.tareWeightTons ?? null, + equatedLengthM: wagon.wagonType?.equatedLengthM ?? null, allocations: (wagon.allocations ?? []).map((allocation) => ({ bookingId: allocation.bookingId, bookingReference: allocation.booking?.reference ?? null, @@ -3022,7 +3085,7 @@ export class TrainSchedulingService { const loadList = await this.generateImportLoadList(scheduleId, { performedBy: 'DOCUMENT_GENERATION', }); - const html = this.buildImportLoadListHtml(loadList); + const html = this.buildImportLoadListHtml(loadList, await this.logoSettings.getLogoImageUrl()); // Styled table-aware fallback (marshalling grid) when Chromium is unavailable — // NOT the release-order fallback (would mislabel this as a gate-clearance order). const buffer = await this.pdfDocuments.renderTabularDocument(html, 'Import marshalling / load list'); @@ -3042,7 +3105,9 @@ export class TrainSchedulingService { throw new BadRequestException('Export marshalling document applies only to EXPORT schedules'); } - const html = this.buildExportLoadListHtml(schedule); + const html = this.buildExportLoadListHtml(schedule, { + logoImageUrl: await this.logoSettings.getLogoImageUrl(), + }); // Styled table-aware fallback (marshalling grid) — see importLoadListDocument. const buffer = await this.pdfDocuments.renderTabularDocument(html, 'Export marshalling / load list'); const reference = schedule.trainNumber ?? schedule.id; @@ -3114,6 +3179,7 @@ export class TrainSchedulingService { positionLabel, wagons, unassignedBookings, + logoImageUrl: await this.logoSettings.getLogoImageUrl(), }); // Styled table-aware fallback (marshalling grid) — see importLoadListDocument. const buffer = await this.pdfDocuments.renderTabularDocument(html, 'Intercity marshalling / load list'); @@ -3157,6 +3223,7 @@ export class TrainSchedulingService { positionLabel?: string; wagons?: TrainSetWagon[]; unassignedBookings?: Booking[]; + logoImageUrl?: string | null; }, ): string { const esc = (value: unknown) => @@ -3275,6 +3342,7 @@ export class TrainSchedulingService { .tile { border: 1px solid #cbd5e1; padding: 8px; min-height: 50px; } .tile span { display: block; color: #64748b; font-size: 9px; text-transform: uppercase; letter-spacing: .05em; margin-bottom: 4px; } .tile strong { font-size: 11px; } + ${logoImageCss()} table { width: 100%; border-collapse: collapse; } th { background: #f8fafc; color: #475569; text-align: left; } th, td { border: 1px solid #cbd5e1; padding: 5px 6px; font-size: 9.5px; vertical-align: top; } @@ -3289,6 +3357,7 @@ export class TrainSchedulingService {
+ ${logoMarkup(opts?.logoImageUrl)}
Ethio-Djibouti Railway S.C.

${esc(opts?.title ?? 'Export Marshalling Document / Load List')}

@@ -3468,7 +3537,10 @@ export class TrainSchedulingService { } } - private buildImportLoadListHtml(loadList: Awaited>): string { + private buildImportLoadListHtml( + loadList: Awaited>, + logoImageUrl?: string | null, + ): string { const esc = (value: unknown) => String(value ?? '-') .replace(/&/g, '&') @@ -3501,26 +3573,37 @@ export class TrainSchedulingService { const allocationRows = loadList.wagons .flatMap((wagon) => { const wagonCells = `${esc(wagon.sequenceNo)} - ${esc(wagon.wagonNumber)}`; + ${esc(wagon.wagonNumber)} + ${esc(wagon.wagonType)} + ${wagon.tareWeightTons == null ? '-' : esc(Number(wagon.tareWeightTons).toFixed(2))} + ${wagon.equatedLengthM == null ? '-' : esc(Number(wagon.equatedLengthM).toFixed(3))} + ${esc(loadList.origin)} + ${esc(loadList.destination)}`; // An empty wagon still runs in the consist, so it still gets a line — see // buildExportLoadListHtml. if (wagon.allocations.length === 0) { return [ ` ${wagonCells} - EMPTY — no cargo allocated + EMPTY — no cargo allocated `, ]; } return wagon.allocations.map( (allocation) => { const companyName = (allocation.booking as unknown as { company?: { name?: string } } | undefined)?.company?.name ?? '-'; + const sealNumbers = (allocation.containerItems ?? []) + .map((item) => item.sealNumber) + .filter(Boolean) + .join(', '); return ` ${wagonCells} ${esc(allocation.bookingReference ?? allocation.bookingId)} ${esc(companyName)} ${esc(allocation.loadType)} ${esc(allocation.containerNumbers.length ? allocation.containerNumbers.join(', ') : '-')} + ${esc(sealNumbers || '-')} + ${esc(Number(allocation.allocatedWeightTons || 0).toFixed(3))} `; }, @@ -3548,6 +3631,7 @@ export class TrainSchedulingService { .tile { border: 1px solid #cbd5e1; padding: 10px; min-height: 58px; } .tile span { display: block; color: #64748b; font-size: 10px; text-transform: uppercase; letter-spacing: .05em; margin-bottom: 5px; } .tile strong { font-size: 13px; } + ${logoImageCss()} .status { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; margin-top: 14px; } .step { border: 1px solid #cbd5e1; padding: 8px; font-size: 10px; text-align: center; min-height: 48px; } .done { background: #ecfdf5; border-color: #22c55e; color: #14532d; font-weight: 700; } @@ -3569,6 +3653,7 @@ export class TrainSchedulingService {
+ ${logoMarkup(logoImageUrl)}
Ethio-Djibouti Railway S.C.

Import Load List /
Marshalling Document

Djibouti-side gatepass, loading, and departure manifest
@@ -3609,15 +3694,22 @@ export class TrainSchedulingService { Seq Wagon + Wagon Type + Tare + Equated + Departure Station + Arrival Station Booking Company Load Container numbers + Seal No + Note Weight T - ${allocationRows || 'No wagons on this train set.'} + ${allocationRows || 'No wagons on this train set.'} @@ -4439,7 +4531,10 @@ export class TrainSchedulingService { (b) => !(targetScheduleId && b.trainScheduleId === targetScheduleId) && !SCHEDULABLE_BOOKING_STATUSES.includes(b.status as 'PAID') && - !b.isGovernment, + !b.isGovernment && + // Shipping-line bookings pay later on the credit ledger — never PAID + // up front, schedulable from accept (FULLY_EXECUTED) like government. + !b.shippingLineCompanyId, ); if (invalidStatus.length) { const statuses = [...new Set(invalidStatus.map((b) => b.status))]; @@ -6952,6 +7047,7 @@ export class TrainSchedulingService { LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id WHERE ts.deleted_at IS NULL AND ts.status IN ('DRAFT', 'SCHEDULED') + AND ts.shipping_line_company_id IS NULL AND ts.window_phase IS NOT NULL AND ts.window_phase NOT IN ('DONE', 'CLOSED_FOR_DAY') AND ts.scheduled_departure_date >= now() @@ -7008,6 +7104,7 @@ export class TrainSchedulingService { LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id WHERE ts.deleted_at IS NULL AND ts.status IN ('DRAFT', 'SCHEDULED') + AND ts.shipping_line_company_id IS NULL AND ts.window_phase IS NOT NULL AND ts.window_phase NOT IN ('DONE', 'CLOSED_FOR_DAY') AND ts.scheduled_departure_date >= now() @@ -7117,6 +7214,8 @@ export class TrainSchedulingService { const schedules = await this.trainSchedulesRepository.findAll({ where: { bookingWindowStatus: 'OPEN', + // Dedicated shipping-line trains never surface to customer booking. + shippingLineCompanyId: IsNull(), }, relations: { trainSet: { locomotive: true, locomotives: { locomotive: true }, train: true }, @@ -8551,7 +8650,12 @@ export class TrainSchedulingService { .map((sb) => sb.booking) .filter((b): b is Booking => Boolean(b)); const eligible = linkedBookings.filter( - (b) => SCHEDULABLE_BOOKING_STATUSES.includes(b.status as 'PAID') || b.isGovernment, + (b) => + SCHEDULABLE_BOOKING_STATUSES.includes(b.status as 'PAID') || + b.isGovernment || + // Shipping-line bookings board without paying up front — their charge + // sits on the credit ledger, so accept (FULLY_EXECUTED) is boardable. + Boolean(b.shippingLineCompanyId), ); if (!eligible.length) return empty; diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 239485d3c..7412f0269 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -28,13 +28,18 @@ import type { InterchangeDocument } from '../interchange-documents/entities/inte import { LastMileService } from '../last-mile/last-mile.service'; import { UpdateLastMileDto } from '../last-mile/dto/update-last-mile.dto'; import { NotificationsService } from '../notifications/notifications.service'; -import { sendCompanyChannels } from '../notifications/notify-company.util'; +import { + sendCompanyChannels, + notifyCarriageAcceptanceReady as notifyCarriageAcceptanceReadyShared, +} from '../notifications/notify-company.util'; import { companyNotifyPhoneExpr, primaryContactUserJoin, } from '../notifications/resolve-company-phone.util'; import { SignaturesService } from '../signatures/signatures.service'; import { StampSettingsService } from '../stamp-settings/stamp-settings.service'; +import { LogoSettingsService } from '../logo-settings/logo-settings.service'; +import { logoImageCss, logoMarkup } from '../billing/documents/logo-markup.util'; import { sealClass, sealImageCss, sealMarkup } from '../billing/documents/seal-markup.util'; import { BulkInspectDto } from './dto/bulk-inspect.dto'; import { BulkReceiveDto, TruckEntranceDto } from './dto/bulk-receive.dto'; @@ -417,6 +422,7 @@ export class WarehouseInventoryService { private readonly inbox: NotificationInboxService, private readonly events: EventEmitter2, private readonly stampSettings: StampSettingsService, + private readonly logoSettings: LogoSettingsService, ) {} /** @@ -3748,6 +3754,7 @@ export class WarehouseInventoryService { reference, issuedAt, stampImageUrl: await this.stampSettings.getStampImageUrl(), + logoImageUrl: await this.logoSettings.getLogoImageUrl(), bookingReference, bookingStatus: row?.bookingStatus ?? null, customerName: row?.customerName ?? null, @@ -4219,6 +4226,7 @@ export class WarehouseInventoryService { const html = this.buildGrnDocumentHtml({ grnNumber: row.grnNumber, + logoImageUrl: await this.logoSettings.getLogoImageUrl(), receivedAt: row.receivedAt ? new Date(row.receivedAt) : new Date(), bookingReference: row.bookingReference ?? row.bookingId ?? 'N/A', bookingStatus: row.bookingStatus ?? null, @@ -4803,6 +4811,7 @@ export class WarehouseInventoryService { reference, handedOverAt, stampImageUrl: await this.stampSettings.getStampImageUrl(), + logoImageUrl: await this.logoSettings.getLogoImageUrl(), bookingReference, bookingStatus: row.bookingStatus ?? null, customerName: row.customerName ?? null, @@ -5152,6 +5161,7 @@ export class WarehouseInventoryService { activityType: 'INVENTORY_DISPATCHED', description: 'Inventory dispatched', performedBy, + freeCapacity: true, }); } @@ -5436,6 +5446,8 @@ export class WarehouseInventoryService { description: string; performedBy?: string; preloaded?: WarehouseInventory; + /** Cargo physically leaves the warehouse on this transition — free up capacity (mirrors deliver()). */ + freeCapacity?: boolean; }, ): Promise { const item = opts.preloaded ?? (await this.findById(id)); @@ -5446,6 +5458,20 @@ export class WarehouseInventoryService { status: to, [opts.timestampField]: new Date(), }); + + if (opts.freeCapacity) { + const weight = Number(item.weight) || 0; + const volume = Number(item.volume) || 0; + const containerCount = item.containerId ? Math.round(Number(item.quantity) || 0) : 0; + await this.applyCapacityDelta( + manager, + { warehouseId: item.warehouseId, yardId: item.yardId, zoneId: item.zoneId }, + -weight, + -volume, + -containerCount, + ); + } + await this.activityLog.record( { activityType: opts.activityType, @@ -5484,6 +5510,8 @@ export class WarehouseInventoryService { zone: string | null; inventoryStatus: string | null; receiveSummary: string | null; + /** The one global company logo; null renders the plain text brand. */ + logoImageUrl?: string | null; }): string { const esc = (value: unknown) => String(value ?? '-') @@ -5540,6 +5568,7 @@ export class WarehouseInventoryService { .ref { text-align: right; font-size: 11px; color: #334155; padding-top: 8px; } .ref strong { display: block; color: #061323; font-size: 18px; margin: 5px 0 8px; letter-spacing: .02em; } .rule { height: 3px; background: #0f766e; margin: 16px 0 22px; } + ${logoImageCss()} .notice { width: 76%; margin: 0 0 18px; padding: 13px 18px; background: #f0fdfa; border: 1px solid #5eead4; border-left: 5px solid #0f766e; font-size: 13px; line-height: 1.45; } .section-title { margin: 18px 0 8px; font-size: 13px; font-weight: 800; color: #0f766e; text-transform: uppercase; letter-spacing: .12em; } table { width: 100%; border-collapse: collapse; } @@ -5553,6 +5582,7 @@ export class WarehouseInventoryService {
+ ${logoMarkup(data.logoImageUrl)}
Ethio-Djibouti Railway S.C.

Goods Received Note

Warehouse receiving confirmation
@@ -5610,6 +5640,8 @@ export class WarehouseInventoryService { truckWeightTons?: number | null; /** The one global company stamp; null falls back to the drawn text seal. */ stampImageUrl?: string | null; + /** The one global company logo; null renders the plain text brand. */ + logoImageUrl?: string | null; }): string { const esc = (value: unknown) => String(value ?? '-') @@ -5697,12 +5729,14 @@ export class WarehouseInventoryService { .seal::before { content: ""; position: absolute; width: 78px; height: 78px; border: 1px solid #17633a; border-radius: 999px; } .seal span { position: relative; } ${sealImageCss()} + ${logoImageCss()}
+ ${logoMarkup(data.logoImageUrl)}
Ethio-Djibouti Railway S.C.

Warehouse Release / Exit Paper

Official gate clearance and warehouse exit authorization
@@ -5772,6 +5806,8 @@ export class WarehouseInventoryService { } | null; /** The one global company stamp; null falls back to the drawn text seal. */ stampImageUrl?: string | null; + /** The one global company logo; null renders the plain text brand. */ + logoImageUrl?: string | null; }): string { const esc = (value: unknown) => String(value ?? '-') @@ -5850,11 +5886,13 @@ export class WarehouseInventoryService { .seal::before { content: ""; position: absolute; width: 78px; height: 78px; border: 1px solid #17633a; border-radius: 999px; } .seal span { position: relative; } ${sealImageCss()} + ${logoImageCss()}
+ ${logoMarkup(data.logoImageUrl)}
Ethio-Djibouti Railway S.C.

Import Goods Handover Document

EDR to customer warehouse handover
@@ -6167,26 +6205,13 @@ export class WarehouseInventoryService { * fires right after receive, not at marshalling. */ private async notifyCarriageAcceptanceReady(bookingId: string): Promise { - try { - const [b]: Array<{ companyId: string | null; reference: string }> = await this.dataSource.query( - `SELECT company_id AS "companyId", reference FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`, - [bookingId], - ); - if (!b?.companyId) return; - const body = `Your carriage acceptance sheet for booking ${b.reference} is ready to download from the portal.`; - await this.inbox.notify({ - recipients: { companyId: b.companyId }, - audience: NotificationAudience.PORTAL, - type: NotificationType.DOCUMENT_ACTION, - title: 'Carriage acceptance sheet ready', - body, - link: `/bookings/${bookingId}`, - data: { bookingId, reference: b.reference }, - }); - await sendCompanyChannels(this.dataSource, this.notifications, b.companyId, body); - } catch (err) { - this.logger.warn(`Carriage acceptance ready notify failed for ${bookingId}: ${(err as Error).message}`); - } + await notifyCarriageAcceptanceReadyShared( + this.dataSource, + this.notifications, + this.inbox, + bookingId, + this.logger, + ); } private async notifyOwnerInventoryReceived(params: { diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts index 6bf03c93d..558ffc1a9 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts @@ -99,7 +99,9 @@ interface InventoryContext { interface ViewSource { id: string; invoiceNumber: string; - companyId: string; + /** Nullable on the entity (shipping-line invoices have no company); every + * warehouse invoice is customer-billed, so in practice this is always set. */ + companyId: string | null; sourceId: string; type: string; status: Freight.InvoiceStatus | string; diff --git a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts index dd8f9bd20..53f7ad2d2 100644 --- a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts +++ b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts @@ -643,6 +643,21 @@ const INTERCITY_DOCUMENT_SETTINGS: OnboardingDocumentSetting[] = [ }, ]; +// ── Shipping line booking documents ───────────────────────────────────────── +// Collected on a shipping line's booking right after it is initiated. Shipping +// lines book without a contract, so this set — not a contract — is what +// Operations reviews before the booking may be completed. Fields start empty +// and are configured in the backoffice file-settings editor, like the sets +// above. `entity: "booking"` puts it alongside the other per-booking sets. +const SHIPPING_LINE_DOCUMENT_SETTINGS: OnboardingDocumentSetting[] = [ + { + code: "shipping_line_booking_documents", + label: "Shipping line booking documents", + entity: "booking", + fields: [], + }, +]; + // ── Hazardous cargo documents ─────────────────────────────────────────────── // Asked for in the contract wizard the moment the customer flags the cargo as // hazardous (ONE_TIME contracts only). Fields start empty and are configured in @@ -701,6 +716,11 @@ export class FileUploadSettingsSeeder { description: "Intercity shipment documents — contract-level for ONE_TIME (after both signatures), per booking for GENERAL; reviewed by Operations.", })), + ...SHIPPING_LINE_DOCUMENT_SETTINGS.map((s) => ({ + ...s, + description: + "Documents a shipping line uploads on a booking it initiated. Reviewed by Operations; the booking can only be completed once they are approved.", + })), ]; const missing = allSettings.filter((s) => !existingCodes.has(s.code)); diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index cdbffd961..adbd22584 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -47,6 +47,55 @@ const perm = (id: string, key: string, en: string): FreightPermissionSeed => ({ applicationKey: EDR_FREIGHT_APP_KEY, }); +/** + * One entry per report definition (see modules/reports/definitions). Each + * gets its own permission, gated behind the `reports:view` master key that + * opens the Reports section itself. + * Keep new keys at the END: reportPermId derives ids from list index, so a + * mid-list insert would shift ids already seeded for later keys. + */ +export const REPORT_KEYS = [ + "bookings-list", + "revenue-by-customer", + "aging-receivables", + "contract-utilization", + "wagon-fleet-status", + "wagon-status-duration", + "wagon-requests", + "locomotive-fleet-status", + "booking-status-breakdown", + "train-schedule-status", + "train-turnaround", + "wagon-teu-utilization", + "loaded-capacity", + "global-logistics-wagons", + "customer-status", + "contract-lifecycle", + "customs-documents", + "invoicing-pipeline", + "first-last-mile-bookings", + "invoices-by-status", + "payments-by-status", + "revenue-summary", + "cargo-summary", +] as const; + +export type ReportKey = (typeof REPORT_KEYS)[number]; + +export const reportPermissionKey = (key: ReportKey): string => + `edr_freight_app:reports:${key.replace(/-/g, "_")}:view`; + +const reportPermId = (index: number): string => + `a4f00002-0001-4000-8000-${(index + 1).toString(16).padStart(12, "0")}`; + +const titleCase = (slug: string): string => + slug.split("-").map((w) => w[0].toUpperCase() + w.slice(1)).join(" "); + +export const REPORT_PERMISSIONS: FreightPermissionSeed[] = REPORT_KEYS.map( + (key, index) => + perm(reportPermId(index), reportPermissionKey(key), `Report: ${titleCase(key)}`), +); + export const BOOKING_PERMISSIONS: FreightPermissionSeed[] = [ perm( "a1000001-0001-4000-8000-000000000001", @@ -450,6 +499,30 @@ export const CUSTOMER_PERMISSIONS: FreightPermissionSeed[] = [ ), ]; +// C2. Shipping lines — carriers registered by staff (no self-signup). +export const SHIPPING_LINE_PERMISSIONS: FreightPermissionSeed[] = [ + perm( + "d1a00002-0001-4000-8000-000000000001", + "edr_freight_app:shipping_lines:view", + "View shipping lines", + ), + perm( + "d1a00002-0001-4000-8000-000000000002", + "edr_freight_app:shipping_lines:create", + "Register shipping line", + ), + perm( + "d1a00002-0001-4000-8000-000000000003", + "edr_freight_app:shipping_lines:update", + "Update shipping line", + ), + perm( + "d1a00002-0001-4000-8000-000000000004", + "edr_freight_app:shipping_lines:reset-password", + "Resend shipping line activation link", + ), +]; + // D. Finance — payments + invoices export const FINANCE_PERMISSIONS: FreightPermissionSeed[] = [ perm( @@ -503,6 +576,47 @@ export const FINANCE_PERMISSIONS: FreightPermissionSeed[] = [ "edr_freight_app:invoices:confirm_offline", "Confirm offline (bank transfer) invoice payment", ), + // Shipping lines consume services on credit and are invoiced after the fact, + // so what they owe is its own Finance surface, separate from invoices:view — + // an unbilled credit is not an invoice yet. + perm( + "d2c00001-0001-4000-8000-000000000001", + "edr_freight_app:shipping_line_credits:view", + "View shipping-line credits and outstanding balance", + ), + perm( + "d2c00001-0001-4000-8000-000000000002", + "edr_freight_app:shipping_line_credits:invoice", + "Generate an invoice from shipping-line credits", + ), + // Erases a debt outright, which is why it is not folded into :invoice. + perm( + "d2c00001-0001-4000-8000-000000000003", + "edr_freight_app:shipping_line_credits:cancel", + "Cancel (write off) an unbilled shipping-line credit", + ), + // Two-step manual actions on credit invoices: request grants per action, + // decision grants that apply to any pending request. + perm( + "d2c00001-0001-4000-8000-000000000004", + "edr_freight_app:shipping_line_credits:invoice_mark_paid", + "Request marking a shipping-line credit invoice paid (offline payment)", + ), + perm( + "d2c00001-0001-4000-8000-000000000005", + "edr_freight_app:shipping_line_credits:invoice_approve", + "Approve any pending shipping-line credit invoice request", + ), + perm( + "d2c00001-0001-4000-8000-000000000006", + "edr_freight_app:shipping_line_credits:invoice_cancel", + "Request cancelling a shipping-line credit invoice", + ), + perm( + "d2c00001-0001-4000-8000-000000000007", + "edr_freight_app:shipping_line_credits:invoice_reject", + "Reject any pending shipping-line credit invoice request", + ), ]; // E. First / last mile operations @@ -1211,6 +1325,16 @@ export const CONFIG_SETTINGS_PERMISSIONS: FreightPermissionSeed[] = [ "edr_freight_app:settings:stamp:manage", "Manage the company stamp", ), + perm( + "b4b00004-0001-4000-8000-000000000001", + "edr_freight_app:settings:logo:view", + "View the company logo", + ), + perm( + "b4b00004-0001-4000-8000-000000000002", + "edr_freight_app:settings:logo:manage", + "Manage the company logo", + ), // The per-officer approval teeter (ማህተም) — an individual's own stamp + // signature, not the company seal. It used to ride on settings:stamp:*, which // now gates the ONE company stamp; this key was split out when the two were @@ -1506,7 +1630,9 @@ export const NOTIFICATION_PERMISSIONS: FreightPermissionSeed[] = [ ]; export const ADVANCED_BACKOFFICE_PERMISSIONS: FreightPermissionSeed[] = [ + ...REPORT_PERMISSIONS, ...CUSTOMER_PERMISSIONS, + ...SHIPPING_LINE_PERMISSIONS, ...FINANCE_PERMISSIONS, ...MILE_PERMISSIONS, ...FLEET_RAIL_PERMISSIONS, @@ -1713,6 +1839,31 @@ export const FREIGHT_PERMS = { // Notification selector, not a route guard — see NOTIFICATION_PERMISSIONS. getNotification: "edr_freight_app:customers:get_notification", }, + shippingLines: { + view: "edr_freight_app:shipping_lines:view", + create: "edr_freight_app:shipping_lines:create", + update: "edr_freight_app:shipping_lines:update", + resetPassword: "edr_freight_app:shipping_lines:reset-password", + }, + shippingLineCredits: { + view: "edr_freight_app:shipping_line_credits:view", + /** Turn a batch of unbilled credits into an invoice. */ + invoice: "edr_freight_app:shipping_line_credits:invoice", + /** Write off an unbilled credit — separate grant: it erases a debt. */ + cancel: "edr_freight_app:shipping_line_credits:cancel", + // Two-step manual actions on credit invoices, gated purely by permission: + // finance-level REQUEST grants (per action) and decision grants that apply + // to ANY pending request — including the holder's own. + /** Request recording an offline payment against a credit invoice. */ + invoiceMarkPaid: + "edr_freight_app:shipping_line_credits:invoice_mark_paid", + /** Request voiding a credit invoice (credits return to unbilled). */ + invoiceCancel: "edr_freight_app:shipping_line_credits:invoice_cancel", + /** Approve any pending invoice request (mark-paid or cancel). */ + invoiceApprove: "edr_freight_app:shipping_line_credits:invoice_approve", + /** Reject any pending invoice request. */ + invoiceReject: "edr_freight_app:shipping_line_credits:invoice_reject", + }, payments: { view: "edr_freight_app:payments:view", }, @@ -1935,6 +2086,12 @@ export const FREIGHT_PERMS = { view: "edr_freight_app:settings:stamp:view", manage: "edr_freight_app:settings:stamp:manage", }, + // The ONE company logo, applied to every generated document (invoices, + // receipts, contracts, warehouse papers, train-scheduling manifests). + logo: { + view: "edr_freight_app:settings:logo:view", + manage: "edr_freight_app:settings:logo:manage", + }, // The per-officer approval teeter (ማህተም) + signature — genuinely per-person, // and NOT the company seal above. Retired: `invoiceStamp`, which used to // gate the company stamp before the two were untangled. @@ -1987,6 +2144,7 @@ export const FREIGHT_PERMS = { }, reports: { view: "edr_freight_app:reports:view", + report: (key: ReportKey): string => reportPermissionKey(key), }, staff: { users: { @@ -2131,11 +2289,17 @@ const FLEET_GRANULAR_KEYS: string[] = [ FREIGHT_PERMS.consignments.create, ]; +const allReportKeys = (): string[] => REPORT_KEYS.map((k) => reportPermissionKey(k)); + // Everyone who works the booking desk also opens the overview dashboard and // the canned reports — granted alongside bookings:view in every preset below. +// Each report also carries its own key (see REPORT_PERMISSIONS); spreading +// allReportKeys() here keeps every existing preset seeing every report, same +// as when reports:view alone gated the whole section. const STAFF_DASHBOARD_KEYS: string[] = [ FREIGHT_PERMS.overview.view, FREIGHT_PERMS.reports.view, + ...allReportKeys(), ]; // Notification desks — recipient selectors, not access. A preset gets a desk @@ -2247,6 +2411,13 @@ export const ROLE_PERMISSION_PRESETS = { // exceptional operations, and are assigned to named admins rather than a role preset. FREIGHT_PERMS.payments.view, FREIGHT_PERMS.bookings.wagonCancellationView, + // Shipping-line credit ledger is a Finance surface: bill batches into + // invoices and RAISE manual invoice actions. Approval of those actions is + // deliberately absent — it sits with the chief (maker–checker). + FREIGHT_PERMS.shippingLineCredits.view, + FREIGHT_PERMS.shippingLineCredits.invoice, + FREIGHT_PERMS.shippingLineCredits.invoiceMarkPaid, + FREIGHT_PERMS.shippingLineCredits.invoiceCancel, ], // Global Logistics: manages ONLY the customs-clearance queue. Scoped out of // the general booking-request list (no bookings:view) — instead a dedicated @@ -2349,6 +2520,11 @@ export const POSITION_PERMISSION_PRESETS = { FREIGHT_PERMS.invoices.view, FREIGHT_PERMS.invoices.export, FREIGHT_PERMS.payments.view, + // Decision side of the credit-invoice two-step: finance raises + // mark-paid/cancel requests, the chief approves or rejects them. + FREIGHT_PERMS.shippingLineCredits.view, + FREIGHT_PERMS.shippingLineCredits.invoiceApprove, + FREIGHT_PERMS.shippingLineCredits.invoiceReject, ]), // Director additionally manages train scheduling + rail fleet (same block the // operation officer/chief hold), on top of the approval-chain role preset, diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 64f91da72..9ac730bda 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -1,4 +1,5 @@ -import { useEffect } from "react"; +import { useEffect, useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; import { Navigate, Outlet, @@ -10,6 +11,7 @@ import { } from "react-router-dom"; import { FreightDashboardLayout, type SidebarItem } from "@/components/layout"; +import { api } from "@/services/api"; import { useAuth } from "./auth/useAuth"; import LoadingScreen from "./components/LoadingScreen"; import LoginPage from "./pages/auth/LoginPage"; @@ -34,16 +36,18 @@ import DocumentClearanceDetailPage from "./pages/bookings/DocumentClearanceDetai import DocumentClearanceListPage from "./pages/bookings/DocumentClearanceListPage"; import CustomerDetailPage from "./pages/customers/CustomerDetailPage"; import CustomersPage from "./pages/customers/CustomersPage"; +import ShippingLineCompaniesPage from "./pages/shipping-lines/ShippingLineCompaniesPage"; +import ShippingLineCreditsPage from "./pages/shipping-lines/ShippingLineCreditsPage"; import InvoiceDetailPage from "./pages/invoices/InvoiceDetailPage"; -import InvoicesPage from "./pages/invoices/InvoicesPage"; -import UsdPaymentsPage from "./pages/invoices/UsdPaymentsPage"; +import FinanceHubPage from "./pages/invoices/FinanceHubPage"; import MyProfilePage from "./pages/dashboard/MyProfilePage"; import OverviewPage from "./pages/dashboard/OverviewPage"; -import ReportsHubPage from "./pages/reports/ReportsHubPage"; +import OverviewDomainPage from "./pages/dashboard/OverviewDomainPage"; +import { OVERVIEW_DOMAINS } from "./components/overview/overview-domains.config"; +import ReportsIndexRedirect from "./pages/reports/ReportsIndexRedirect"; import ReportPage from "./pages/reports/ReportPage"; import AuditLogsPage from "./pages/AuditLogsPage"; import AiBookingMockTestPage from "./pages/ai/AiBookingMockTestPage"; -import PaymentsPage from "./pages/payments/PaymentsPage"; //import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage"; import { RequirePermission } from "./components/auth/RequirePermission"; import { FREIGHT_PERMS } from "./lib/permissions"; @@ -52,6 +56,7 @@ import NoAccessPage from "./pages/NoAccessPage"; import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage"; import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage"; import CompanyStampSettingsPage from "./pages/settings/CompanyStampSettingsPage"; +import LogoSettingsPage from "./pages/settings/LogoSettingsPage"; import ContractTemplatesPage from "./pages/contract_templates/ContractTemplatesPage"; import PortalContentPage from "./pages/portal_content/PortalContentPage"; import ContractTemplateEditorPage from "./pages/contract_templates/ContractTemplateEditorPage"; @@ -81,7 +86,6 @@ import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPa import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage"; import TradeAccessPage from "./pages/configuration/TradeAccessPage"; import ExchangeRateSettingsCard from "./pages/settings/ExchangeRateSettingsCard"; -import ContractValidityPeriodsPage from "./pages/configuration/ContractValidityPeriodsPage"; import FirstMilePage from "./pages/operations/FirstMilePage"; import LastMilePage from "./pages/operations/LastMilePage"; import TrainDetailPage from "./pages/trains/TrainDetailPage"; @@ -139,8 +143,18 @@ const DashboardShell = () => { const demoItems: SidebarItem[] = []; + const { data: reportCatalog } = useQuery(api.reports.catalog.queryOptions()); + const reportItems: SidebarItem[] = useMemo( + () => + (reportCatalog ?? []).map((report) => ({ + label: report.title, + href: `/dashboard/reports/${report.key}`, + })), + [reportCatalog], + ); + const sidebarSections = filterSidebarByPermission( - buildSidebarSections(demoItems), + buildSidebarSections(demoItems, reportItems), user, ); const displayName = user?.name?.en || user?.username || user?.email || "User"; @@ -199,12 +213,58 @@ const App = () => { {/* Landing is per-user: /dashboard/overview is gated on overview:view, so a fixed target strands anyone without that key on a blank page. */} } /> - } /> + } + /> }> - } /> - } /> - } /> - } /> + + + + } + /> + {/* One drill-down route per overview domain — the old per-tab charts, + now each on its own page. Single source of truth for the + permission gate is OVERVIEW_DOMAINS, shared with the summary + page's "View all" links. */} + {OVERVIEW_DOMAINS.map((domain) => ( + + + + } + /> + ))} + + + + } + /> + + + + } + /> + + + + } + /> {/* Dev/testing page for the mock AI booking assistant. */} { /> } /> - } /> - + + + + } + /> + {/* Payments used to be its own page; it's now the "payments" tab on + the merged Invoices hub. Old bookmarks/links still land there. */} + } + /> + + } /> - } /> { } /> + {/* Merged Invoices / Payments / USD Payments hub — tabs switch via + ?tab=invoices|payments|usd-payments (default invoices). Access is + OR'd across both keys so a user with just one still gets in; each + tab hides itself if the user lacks the permission it used to be + routed on. */} + + + + } + /> + + + + } + /> - + + } /> - - - } + element={} /> { } /> - } /> + + + + } + /> { path="bookings/:id/milestones" element={} /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> { + } @@ -780,7 +1055,9 @@ const App = () => { + } @@ -795,9 +1072,7 @@ const App = () => { + } @@ -807,6 +1082,15 @@ const App = () => { path="invoice-stamp-settings" element={} /> + {/* The ONE company logo, shown in the header of every generated document. */} + + + + } + /> { +
@@ -934,4 +1220,3 @@ function LegacyGlEthiopiaClearanceRedirect() { } export default App; - diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx index b2d260e37..a50028e90 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx @@ -1,5 +1,5 @@ import { useNavigate } from "react-router-dom"; -import { ChevronRight, ExternalLink, MoreHorizontal } from "lucide-react"; +import { ExternalLink, MoreHorizontal } from "lucide-react"; import { Button, Menu, ActionIcon, Group, Text } from "@mantine/core"; import { BookingConfirmDialog } from "./BookingConfirmDialog"; @@ -64,17 +64,9 @@ export function BookingActionsMenu({ const hasMenu = listRowHasActions(row, user); + // Row click already opens the detail page — no chevron affordance needed. if (!hasMenu && variant === "table") { - return ( - navigate(`/dashboard/booking-requests/${row.id}`)} - aria-label="View booking" - > - - - ); + return null; } // Toolbar: lay every action out as a button row. diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingStatusBadge.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingStatusBadge.tsx index 7d1fff022..fa21d0d9f 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/BookingStatusBadge.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingStatusBadge.tsx @@ -1,6 +1,7 @@ import { Badge, Group } from "@mantine/core"; import { Link2 } from "lucide-react"; import { BOOKING_STATUS_STYLES } from "@/features/bookings/booking-status.config"; +import { humanize } from "@/lib/format"; const statusColorMap: Record = { DRAFT: "gray", @@ -40,7 +41,7 @@ export function BookingStatusBadge({ partnerReference, }: BookingStatusBadgeProps) { const style = BOOKING_STATUS_STYLES[status] ?? { - label: status, + label: humanize(status), color: "gray", }; const color = statusColorMap[status] ?? "gray"; diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingCargoCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingCargoCard.tsx index ecd903128..1070aa050 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingCargoCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingCargoCard.tsx @@ -1,5 +1,5 @@ import { Package } from "lucide-react"; -import { SimpleGrid, Divider, Box, Table, Text } from "@mantine/core"; +import { SimpleGrid, Divider, Box, Table, Text, Badge } from "@mantine/core"; import type { BookingDetail } from "@/types/booking"; import { cargoTonsAndItems } from "@/utils/cargoWeight"; @@ -16,6 +16,19 @@ export function BookingCargoCard({ booking }: BookingCargoCardProps) { const containers = booking.bookingContainers ?? []; const { tons, items } = cargoTonsAndItems(booking); + // Booking-level flags OR any container line carrying a count — the flag can + // lag the lines (per-line opt-ins), so either alone must light the tile. + const isHazardous = + booking.isHazardous || + containers.some((c) => Number(c.hazardousQuantity ?? 0) > 0); + const isReefer = + booking.isReefer || + containers.some((c) => Number(c.reeferQuantity ?? 0) > 0); + const showHandlingColumns = containers.some( + (c) => + Number(c.hazardousQuantity ?? 0) > 0 || Number(c.reeferQuantity ?? 0) > 0, + ); + return ( @@ -27,11 +40,33 @@ export function BookingCargoCard({ booking }: BookingCargoCardProps) { {items != null && } + + {/* Handling that changes how the yard treats the shipment is flagged + loudly, not buried in the grid. */} + {(isHazardous || isReefer) && ( + + {isHazardous && ( + + Hazardous cargo + + )} + {isReefer && ( + + Refrigerated cargo + + )} + + )} + {containers.length > 0 && ( <> @@ -42,6 +77,8 @@ export function BookingCargoCard({ booking }: BookingCargoCardProps) { Container type Qty VGM / unit + {showHandlingColumns && Hazardous} + {showHandlingColumns && Reefer} @@ -54,6 +91,28 @@ export function BookingCargoCard({ booking }: BookingCargoCardProps) { {c.quantity} {c.vgmPerUnitTons} t + {showHandlingColumns && ( + + {Number(c.hazardousQuantity ?? 0) > 0 ? ( + + {c.hazardousQuantity} + + ) : ( + "—" + )} + + )} + {showHandlingColumns && ( + + {Number(c.reeferQuantity ?? 0) > 0 ? ( + + {c.reeferQuantity} + + ) : ( + "—" + )} + + )} ))} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingCompanyCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingCompanyCard.tsx index 06fafc099..92df02ab6 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingCompanyCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingCompanyCard.tsx @@ -1,44 +1,16 @@ -import type { LucideIcon } from "lucide-react"; -import { - Building2, - FileCheck, - Mail, - MapPin, - Phone, - User, -} from "lucide-react"; -import { Group, Stack, Text, Divider } from "@mantine/core"; +import { Building2, FileCheck, Mail, MapPin, Phone, User } from "lucide-react"; +import { Text } from "@mantine/core"; import type { BookingDetail } from "@/types/booking"; +import { LinkedEntityCard } from "@/components/detail"; +import type { FieldRowProps } from "@/components/detail"; import { SectionCard } from "./SectionCard"; -interface InfoRowProps { - icon: LucideIcon; - label: string; - value?: string | null; -} - -function InfoRow({ icon: Icon, label, value }: InfoRowProps) { - return ( - - - - - {label} - - - - {value || "—"} - - - ); -} - export interface BookingCompanyCardProps { booking: BookingDetail; } -/** Customer (company) information for the booking. */ +/** Customer (company) quick info for the booking, linking to its detail page. */ export function BookingCompanyCard({ booking }: BookingCompanyCardProps) { const company = booking.company; @@ -46,11 +18,9 @@ export function BookingCompanyCard({ booking }: BookingCompanyCardProps) { if (!company && booking.isGovernment) { return ( - + + {booking.governmentInstitution ?? "Government"} + ); } @@ -67,36 +37,24 @@ export function BookingCompanyCard({ booking }: BookingCompanyCardProps) { const companyName = company.companyName ?? company.name ?? company.label; - const rows: InfoRowProps[] = [ + const rows: FieldRowProps[] = [ { icon: FileCheck, label: "TIN", value: company.tin }, { icon: Mail, label: "Email", value: company.email }, { icon: Phone, label: "Phone", value: company.phone }, { icon: MapPin, label: "Address", value: company.address }, { icon: User, label: "Contact person", value: company.contactPersonName }, { icon: Phone, label: "Contact phone", value: company.contactPersonPhone }, - ].filter((r) => r.value); + ]; return ( - - - {rows.length === 0 ? ( - - No additional company details available. - - ) : ( - rows.map((row, index) => ( -
- {index > 0 && } - -
- )) - )} -
-
+ rows={rows} + emptyMessage="No additional company details available." + /> ); } diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingContractCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingContractCard.tsx new file mode 100644 index 000000000..f5fc3bf2a --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingContractCard.tsx @@ -0,0 +1,49 @@ +import { Anchor as AnchorIcon } from "lucide-react"; +import { Code } from "@mantine/core"; + +import type { BookingDetail } from "@/types/booking"; +import { LinkedEntityCard } from "@/components/detail"; +import type { FieldRowProps } from "@/components/detail"; + +export interface BookingContractCardProps { + booking: BookingDetail; +} + +/** Parent contract quick info for the booking, linking to its detail page. */ +export function BookingContractCard({ booking }: BookingContractCardProps) { + if (!booking.contractId || !booking.contractReference) return null; + + const rows: FieldRowProps[] = [ + { + label: "Kind", + value: booking.contractKind === "GENERAL" ? "General" : "One-time", + }, + ]; + + return ( + + {booking.contractSummary} + + ) : undefined + } + /> + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingContractSummaryCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingContractSummaryCard.tsx deleted file mode 100644 index 1e86a7d2a..000000000 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingContractSummaryCard.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { Anchor } from "lucide-react"; -import { Code } from "@mantine/core"; - -import { SectionCard } from "./SectionCard"; - -export interface BookingContractSummaryCardProps { - summary: string; -} - -/** Generated contract terms, shown verbatim. */ -export function BookingContractSummaryCard({ summary }: BookingContractSummaryCardProps) { - return ( - - - {summary} - - - ); -} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingMileServicesCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingMileServicesCard.tsx index 3236e5cae..d3bee348d 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingMileServicesCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingMileServicesCard.tsx @@ -1,6 +1,10 @@ -import { Truck } from "lucide-react"; -import { SimpleGrid } from "@mantine/core"; +import type { ReactNode } from "react"; +import { Download, Truck } from "lucide-react"; +import { Button, Group, SimpleGrid, Stack, Text } from "@mantine/core"; +import { useQuery } from "@tanstack/react-query"; +import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; +import { lastMileRequestsService } from "@/services/last-mile-requests.service"; import type { BookingDetail } from "@/types/booking"; import { SectionCard } from "./SectionCard"; @@ -8,24 +12,92 @@ import { MetricTile } from "./MetricTile"; export interface BookingMileServicesCardProps { booking: BookingDetail; + /** Export handover-mode control — how the cargo reaches the train. Lives + * here because it's the other "how does the cargo physically travel" fact; + * shown even when no mile address is set, since EXPORT bookings still need + * the choice made. */ + handoverSection?: ReactNode; } -/** First / last mile addresses. Renders nothing when neither is present. */ -export function BookingMileServicesCard({ booking }: BookingMileServicesCardProps) { - if (!booking.firstMilePickupAddress && !booking.lastMileDeliveryAddress) { +/** + * First / last mile addresses, plus the export handover control and the + * stored last-mile contract reference (signed status + PDF download) for + * Truck & Machinery once a request on this booking is approved. Renders + * nothing when none of the three are present. + */ +export function BookingMileServicesCard({ + booking, + handoverSection, +}: BookingMileServicesCardProps) { + const hasAddresses = + Boolean(booking.firstMilePickupAddress) || Boolean(booking.lastMileDeliveryAddress); + + const { data: requestsResponse } = useQuery({ + queryKey: QUERY_KEYS.LAST_MILE_REQUESTS.list({ bookingId: booking.id }), + queryFn: async () => + (await lastMileRequestsService.list({ bookingId: booking.id })).data, + enabled: Boolean(booking.lastMileDeliveryAddress), + }); + const approvedRequest = (requestsResponse?.data ?? []).find( + (r) => r.status === "APPROVED", + ); + + if (!hasAddresses && !handoverSection) { return null; } + const downloadContract = async () => { + if (!approvedRequest) return; + const blob = (await lastMileRequestsService.contractDocument(approvedRequest.id)).data; + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `last-mile-contract-${booking.reference ?? booking.id}.pdf`; + a.click(); + URL.revokeObjectURL(url); + }; + return ( - - {booking.firstMilePickupAddress && ( - + + {hasAddresses && ( + + {booking.firstMilePickupAddress && ( + + )} + {booking.lastMileDeliveryAddress && ( + + )} + )} - {booking.lastMileDeliveryAddress && ( - + {approvedRequest && ( + + + + Last-mile contract + + + {approvedRequest.customerSignedAt + ? `Signed ${new Date(approvedRequest.customerSignedAt).toLocaleDateString()}${ + approvedRequest.signerDisplayName + ? ` by ${approvedRequest.signerDisplayName}` + : "" + }` + : "Awaiting customer signature"} + + + + )} - + {handoverSection} + ); } diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRequestHero.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRequestHero.tsx deleted file mode 100644 index ed9802150..000000000 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRequestHero.tsx +++ /dev/null @@ -1,258 +0,0 @@ -import type { ReactNode } from "react"; -import { - ArrowLeft, - Building2, - Calendar, - Clock, - Container as ContainerIcon, - Flame, - RefreshCw, - Wallet, - Weight, -} from "lucide-react"; -import { - Button, - Group, - Paper, - Stack, - Text, - ThemeIcon, - Title, -} from "@mantine/core"; -import type { LucideIcon } from "lucide-react"; - -import type { BookingDetail } from "@/types/booking"; -import { cargoTonsAndItems } from "@/utils/cargoWeight"; -import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge"; -import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge"; -import { ContractReferenceLink } from "@/components/bookings/ContractReferenceLink"; -import { SchedulingStatusBadge } from "@/components/trainScheduling/ScheduleStatusBadge"; -import { NextStepBanner } from "@/components/bookings/NextStepBanner"; - -import { formatDate } from "./booking-detail.styles"; - -export interface BookingRequestHeroProps { - booking: BookingDetail; - customerLabel: string; - onBack: () => void; - onRefresh: () => void; - isFetching?: boolean; -} - -/** Top hero for the request detail page: identity, status, next step, key figures. */ -export function BookingRequestHero({ - booking, - customerLabel, - onBack, - onRefresh, - isFetching, -}: BookingRequestHeroProps) { - const amount = Number(booking.totalAmount); - const containers = booking.bookingContainers ?? []; - const containerCount = containers.reduce( - (sum, c) => sum + Number(c.quantity ?? 0), - 0, - ); - const { tons: weight, items: itemCount } = cargoTonsAndItems(booking); - - return ( - - - - - - - - - - - Booking reference - - - - - {booking.reference} - - - - - - {booking.schedulingStatus ? ( - - ) : null} - - - {booking.holdExpiresAt && booking.schedulingStatus === "HOLDING" ? ( - - Hold expires {new Date(booking.holdExpiresAt).toLocaleString()} - - ) : null} - - - - - - - - - - {booking.nextStep ? ( - - - - ) : null} - - - - - - - - - - ); -} - -function MetaItem({ - icon: Icon, - text, - strong, -}: { - icon: LucideIcon; - text: ReactNode; - strong?: boolean; -}) { - return ( - - - - {text} - - - ); -} - -function HeroTile({ - icon: Icon, - label, - value, - hint, - accent = "edr-green", -}: { - icon: LucideIcon; - label: string; - value: ReactNode; - hint?: ReactNode; - accent?: string; -}) { - return ( - - - - - - - - {label} - - - {value} - - {hint ? ( - - {hint} - - ) : null} - - - - ); -} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts b/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts index b0b024977..23f9b2bd8 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts @@ -16,10 +16,9 @@ export * from "./BookingPaymentCard"; export * from "./BookingPaymentCountdownCard"; export * from "./BookingFactsCard"; export * from "./BookingDocumentsCard"; -export * from "./BookingRequestHero"; export * from "./BookingRouteServiceCard"; export * from "./BookingMileServicesCard"; export * from "./BookingCargoCard"; -export * from "./BookingContractSummaryCard"; +export * from "./BookingContractCard"; export * from "./BookingCompanyCard"; export * from "./BookingSchedulingWindowCard"; diff --git a/apps/edr-freight-web/backoffice/src/components/common/FilterToggle.tsx b/apps/edr-freight-web/backoffice/src/components/common/FilterToggle.tsx new file mode 100644 index 000000000..b97f827d4 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/common/FilterToggle.tsx @@ -0,0 +1,34 @@ +import { ActionIcon, Indicator } from "@mantine/core"; +import { Filter } from "lucide-react"; + +export interface FilterToggleProps { + /** Number of active advanced filters — shown as a badge on the button. */ + count: number; + expanded: boolean; + onClick: () => void; +} + +/** Toggle for the collapsible advanced-filters row on list pages. */ +export function FilterToggle({ count, expanded, onClick }: FilterToggleProps) { + return ( + + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/common/ListControls.tsx b/apps/edr-freight-web/backoffice/src/components/common/ListControls.tsx index 92ab514ad..8ca604ba4 100644 --- a/apps/edr-freight-web/backoffice/src/components/common/ListControls.tsx +++ b/apps/edr-freight-web/backoffice/src/components/common/ListControls.tsx @@ -2,6 +2,7 @@ import { Button, Group, TextInput } from "@mantine/core"; import { DatePickerInput } from "@mantine/dates"; import { Search, X } from "lucide-react"; import type { ReactNode } from "react"; +import { getDateRangePresets } from "./dateRangePresets"; export interface ListControlsProps { search: string; @@ -54,28 +55,19 @@ const ListControls = ({ )} {showDateRange && ( - <> - - - + { + onDateFromChange(from); + onDateToChange(to); + }} + presets={getDateRangePresets()} + clearable + w={230} + /> )} {children} diff --git a/apps/edr-freight-web/backoffice/src/components/common/dateRangePresets.ts b/apps/edr-freight-web/backoffice/src/components/common/dateRangePresets.ts new file mode 100644 index 000000000..e59e3bb84 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/common/dateRangePresets.ts @@ -0,0 +1,41 @@ +import { + format, + startOfDay, + endOfDay, + startOfMonth, + endOfMonth, + startOfYear, + subDays, + subMonths, +} from "date-fns"; +import type { DatePickerPreset } from "@mantine/dates"; + +const iso = (date: Date) => format(date, "yyyy-MM-dd"); + +/** + * Shared "Today / Last 7 days / …" presets for every Mantine + * `` in the app, + * so every from/to filter offers the same shortcuts. Computed fresh per call + * (not a module-level constant) so "Today" stays today. + */ +export function getDateRangePresets(): DatePickerPreset<"range">[] { + const today = new Date(); + return [ + { label: "Today", value: [iso(startOfDay(today)), iso(endOfDay(today))] }, + { + label: "Yesterday", + value: [iso(startOfDay(subDays(today, 1))), iso(endOfDay(subDays(today, 1)))], + }, + { label: "Last 7 days", value: [iso(startOfDay(subDays(today, 6))), iso(endOfDay(today))] }, + { label: "Last 30 days", value: [iso(startOfDay(subDays(today, 29))), iso(endOfDay(today))] }, + { label: "This month", value: [iso(startOfMonth(today)), iso(endOfDay(today))] }, + { + label: "Last month", + value: [ + iso(startOfMonth(subMonths(today, 1))), + iso(endOfMonth(subMonths(today, 1))), + ], + }, + { label: "Year to date", value: [iso(startOfYear(today)), iso(endOfDay(today))] }, + ]; +} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/BookingRequestStatusBadge.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/BookingRequestStatusBadge.tsx new file mode 100644 index 000000000..dc742df4f --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/contracts/BookingRequestStatusBadge.tsx @@ -0,0 +1,16 @@ +import { Badge } from "@mantine/core"; + +const STATUS_COLOR: Record = { + PENDING: "edr-green", + ACCEPTED: "blue", + REJECTED: "red", +}; + +/** Status of a customer-submitted shipment (booking) request against a contract. */ +export function BookingRequestStatusBadge({ status }: { status: string }) { + return ( + + {status} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ContractMilestonesTimeline.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ContractMilestonesTimeline.tsx index 1bf9b55e7..3a3c71449 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ContractMilestonesTimeline.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ContractMilestonesTimeline.tsx @@ -10,6 +10,7 @@ import { import { Group, Stack, Text, Timeline, Tooltip } from "@mantine/core"; import type { Freight } from "@edr/types"; +import { formatDate } from "@/lib/format"; import { CONTRACT_APPROVAL_ROLE_LABELS, HAZARDOUS_APPROVAL_ROLE_PERMISSION, @@ -54,10 +55,6 @@ function formatAgo(iso: string): string { return "just now"; } -function formatDate(iso: string): string { - return new Date(iso).toLocaleDateString(undefined, { dateStyle: "medium" }); -} - type MilestoneIcon = typeof Send; interface Milestone { diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ContractStatusTabs.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ContractStatusTabs.tsx deleted file mode 100644 index 862bc1562..000000000 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ContractStatusTabs.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import { Badge, ScrollArea, Tabs } from "@mantine/core"; -import { - ClipboardCheck, - FileSignature, - Inbox, - LayoutGrid, - ShieldCheck, - Truck, - XCircle, -} from "lucide-react"; - -import "@/components/overview/overview.css"; -import { - CONTRACT_LIST_TABS, - type ContractStatusTabKey, -} from "@/features/contracts/contract-status.config"; - -const TAB_ICONS: Record = { - all: , - intake: , - in_approval: , - approved_contract: , - clearance: , - active: , - closed: , -}; - -interface ContractStatusTabsProps { - active: ContractStatusTabKey; - onChange: (tab: ContractStatusTabKey) => void; - counts?: Partial>; -} - -export function ContractStatusTabs({ - active, - onChange, - counts, -}: ContractStatusTabsProps) { - return ( - onChange((value as ContractStatusTabKey) ?? "all")} - variant="pills" - color="edr-green" - keepMounted={false} - classNames={{ list: "ov-tablist", tab: "ov-tab" }} - > - - - {CONTRACT_LIST_TABS.map((tab) => { - const isActive = active === tab.key; - const count = counts?.[tab.key]; - return ( - - {count} - - ) : undefined - } - > - {tab.label} - - ); - })} - - - - ); -} - -export type { ContractStatusTabKey }; diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/LogoUpload.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/LogoUpload.tsx new file mode 100644 index 000000000..f8ffe0fd0 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/contracts/LogoUpload.tsx @@ -0,0 +1,172 @@ +import { useRef, useState } from "react"; +import { Box, Button, Group, Image, Paper, Stack, Text } from "@mantine/core"; +import { ImageIcon, RefreshCw, X } from "lucide-react"; + +const MAX_LOGO_MB = 10; + +export interface LogoUploadProps { + /** Logo image as a data URL, or null when none is attached yet. */ + value: string | null; + onChange: (dataUrl: string | null) => void; + label?: string; + description?: string; +} + +/** + * Company logo picker — reads the picked image straight into a data URL, + * same transport as {@link StampUpload}. Kept as its own component (not a + * generalized image-upload) matching how stamp/teeter are already separate + * files here despite the near-identical shape. + */ +export function LogoUpload({ + value, + onChange, + label = "Company logo", + description = "Attach the official company logo.", +}: LogoUploadProps) { + const inputRef = useRef(null); + const [dragging, setDragging] = useState(false); + const [error, setError] = useState(null); + const [fileName, setFileName] = useState(null); + + const readFile = (file: File | undefined | null) => { + if (!file) return; + if (!file.type.startsWith("image/")) { + setError("The logo must be an image file (PNG or JPG)."); + return; + } + if (file.size > MAX_LOGO_MB * 1024 * 1024) { + setError(`The logo image must be under ${MAX_LOGO_MB} MB.`); + return; + } + const reader = new FileReader(); + reader.onload = () => { + setError(null); + setFileName(file.name); + onChange(typeof reader.result === "string" ? reader.result : null); + }; + reader.onerror = () => setError("Could not read that file. Try another."); + reader.readAsDataURL(file); + }; + + const openPicker = () => inputRef.current?.click(); + + const clear = () => { + setFileName(null); + setError(null); + onChange(null); + if (inputRef.current) inputRef.current.value = ""; + }; + + return ( + + + {label} + + + readFile(e.currentTarget.files?.[0])} + /> + + {value ? ( + + + + Company logo + + + + {fileName ?? "Logo attached"} + + + Shown in the header of every generated document. + + + + + + + + + ) : ( + { + e.preventDefault(); + setDragging(true); + }} + onDragLeave={() => setDragging(false)} + onDrop={(e) => { + e.preventDefault(); + setDragging(false); + readFile(e.dataTransfer.files?.[0]); + }} + style={{ + borderColor: dragging + ? "var(--mantine-color-edr-green-6)" + : undefined, + borderStyle: "dashed", + backgroundColor: dragging + ? "var(--mantine-color-edr-green-0)" + : undefined, + cursor: "pointer", + }} + > + + + + Upload company logo + + + {description} Drop an image here or click to browse — PNG or JPG, + up to {MAX_LOGO_MB} MB. + + + + )} + + {error && ( + + {error} + + )} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/detail/ContractDetailTabCards.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/detail/ContractDetailTabCards.tsx index d29e045c4..5d875d98e 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/detail/ContractDetailTabCards.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/detail/ContractDetailTabCards.tsx @@ -30,6 +30,7 @@ import { clearanceWorkflowFileLabel } from "@edr/types"; import { SectionCard } from "@/components/bookings/detail/SectionCard"; import { detailStyles } from "@/components/bookings/detail/booking-detail.styles"; +import { LinkedEntityCard } from "@/components/detail"; import { customersService } from "@/services/customers.service"; type ContractFile = NonNullable[number]; @@ -141,25 +142,23 @@ export function ContractCustomerCard({ return ( - - - + rows={[ + { icon: FileCheck, label: "TIN", value: company.tin }, + { icon: Hash, label: "VAT number", value: company.vatNumber }, + { icon: ShieldCheck, label: "FAN number", value: company.fanNumber }, + { icon: Globe, label: "Country", value: company.country }, + { icon: Mail, label: "Email", value: company.email }, + { icon: Phone, label: "Phone", value: company.phone }, + { icon: MapPin, label: "Address", value: company.address }, + { icon: Globe, label: "Website", value: company.website }, + ]} + /> ; -interface InfoRowProps { - icon: LucideIcon; - label: string; - value?: string | null; -} - -function InfoRow({ icon: Icon, label, value }: InfoRowProps) { - return ( - - - - - {label} - - - - {value || "—"} - - - ); -} - -function InfoRows({ rows }: { rows: InfoRowProps[] }) { - const visible = rows.filter((r) => r.value); - if (visible.length === 0) { - return ( - - No details available. - - ); - } - return ( - - {visible.map((row, i) => ( -
- {i > 0 && } - -
- ))} -
- ); -} - /** Customer (company) on the request's contract. */ export function RequestCustomerCard({ contract }: { contract?: ReqContract | null }) { const company = contract?.company; @@ -75,23 +33,21 @@ export function RequestCustomerCard({ contract }: { contract?: ReqContract | nul ); } return ( - - - + rows={[ + { icon: FileCheck, label: "TIN", value: company.tin }, + { icon: Mail, label: "Email", value: company.email }, + { icon: Phone, label: "Phone", value: company.phone }, + { icon: MapPin, label: "Address", value: company.address }, + { icon: User, label: "Contact", value: company.contactPersonName }, + { icon: Phone, label: "Contact phone", value: company.contactPersonPhone }, + ]} + /> ); } @@ -119,43 +75,41 @@ export function RequestContractSummaryCard({ }) { if (!contract) return null; return ( - - - + rows={[ + { + icon: FileText, + label: "Kind", + value: contract.contractKind === "GENERAL" ? "General" : "One-time", + }, + { + icon: Package, + label: "Cargo", + value: contract.freightType === "CONTAINER" ? "Container" : "Bulk", + }, + { icon: Ship, label: "Trade", value: titleCase(contract.tradeDirection) }, + { icon: FileCheck, label: "Currency", value: contract.paymentCurrency }, + { + icon: FileCheck, + label: "Customs", + value: contract.customsClearingEnabled + ? "Included (Global Logistics)" + : "Not included", + }, + { + icon: FileText, + label: "Valid until", + value: contract.contractValidUntil + ? fmtDate(contract.contractValidUntil) + : "Not active yet", + }, + ]} + /> ); } diff --git a/apps/edr-freight-web/backoffice/src/components/customers/ResetPasswordAction.tsx b/apps/edr-freight-web/backoffice/src/components/customers/ResetPasswordAction.tsx deleted file mode 100644 index f0e9b266a..000000000 --- a/apps/edr-freight-web/backoffice/src/components/customers/ResetPasswordAction.tsx +++ /dev/null @@ -1,151 +0,0 @@ -import { Alert, Button, Loader, Modal, Radio, Stack, Text } from "@mantine/core"; -import { useMutation, useQuery } from "@tanstack/react-query"; -import { KeyRound } from "lucide-react"; -import { useState } from "react"; - -import { useAuth } from "@/auth/useAuth"; -import { useToast } from "@/hooks/use-toast"; -import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; -import { api } from "@/services/api"; -import type { Company, ResetChannel } from "@/types/customer"; - -export interface ResetPasswordActionProps { - company: Pick; -} - -/** - * Staff-triggered password reset. Sends a single-use link to the customer's - * primary contact; the customer opens it and picks their own new password. No - * credential is ever shown to or handled by staff. - */ -export default function ResetPasswordAction({ - company, -}: ResetPasswordActionProps) { - const { user } = useAuth(); - const { toast } = useToast(); - const [opened, setOpened] = useState(false); - const [channel, setChannel] = useState("phone"); - - const allowed = hasPermission(user, FREIGHT_PERMS.customers.resetPassword); - - // The destination is the primary contact's IAM account, not the company - // record — those are different fields and routinely hold different values, so - // showing `company.phone` here would tell staff the wrong number. Only fetched - // once the modal is open. - const targetQuery = useQuery( - api.customers.resetTarget.queryOptions({ - input: { companyId: company.id }, - enabled: allowed && opened, - }), - ); - const target = targetQuery.data; - - const { mutate, isPending } = useMutation( - api.customers.resetPassword.mutationOptions({ - onSuccess: (result) => { - setOpened(false); - toast({ - title: "Reset link sent", - description: `The customer can set a new password using the link sent to ${result.maskedTarget}. It expires in 24 hours.`, - }); - }, - onError: (error) => { - toast({ - title: "Could not send reset link", - description: error.message, - variant: "destructive", - }); - }, - }), - ); - - if (!allowed) return null; - - // SMS is domestic-only: a foreign number counts as unavailable, same as a - // missing one, so staff can't send a link that will never arrive. - const phoneUsable = !!target?.phone && target.phoneIsDomestic !== false; - const channelMissing = - !!target && (channel === "email" ? !target.email : !phoneUsable); - - return ( - <> - - - setOpened(false)} - title="Send a password-reset link" - centered - > - - - We'll send a single-use link to this customer's primary - contact. They choose their own new password — you will not see it. - The link expires in 24 hours. - - - {targetQuery.isLoading ? ( - - - - ) : targetQuery.isError ? ( - - {targetQuery.error.message} - - ) : target ? ( - <> - setChannel(v as ResetChannel)} - label={`Send the link to ${target.name || "the primary contact"} via`} - > - - - - - - - - These are the primary contact's own login details, which may - differ from the company contact details on the profile. - - - - - ) : null} - - - - ); -} diff --git a/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx b/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx index edcc4a517..90dea5adb 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx +++ b/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx @@ -116,8 +116,9 @@ export function CompanyNationalityBadge({ /** * Profile chips for a company row: one chip per role (Importer / Exporter / …) - * carrying its reference code. Caps at three (a company has at most three - * profiles); any extra collapse into a `+N` chip. + * carrying its reference code, colored by the profile's status (green active, + * amber pending, red rejected/blacklisted). Caps at three (a company has at + * most three profiles); any extra collapse into a `+N` chip. */ export function ProfileChips({ profiles, @@ -152,14 +153,15 @@ export function ProfileChips({ withArrow > - {humanize(profile.type)} · {profile.reference} + {humanize(profile.type)} + {profile.reference ? ` · ${profile.reference}` : ""} ))} diff --git a/apps/edr-freight-web/backoffice/src/components/customers/format.ts b/apps/edr-freight-web/backoffice/src/components/customers/format.ts index 0397c1cee..341170931 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/format.ts +++ b/apps/edr-freight-web/backoffice/src/components/customers/format.ts @@ -1,38 +1,2 @@ -/** Shared formatting helpers for the customer-management pages. */ - -/** snake_case / SCREAMING_CASE → Title Case. */ -export function humanize(value: string): string { - return value - .toLowerCase() - .split(/[_\s]+/) - .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) - .join(" "); -} - -export function formatDate(value: string | null | undefined): string { - if (!value) return "—"; - const d = new Date(value); - return Number.isNaN(d.getTime()) - ? "—" - : d.toLocaleDateString(undefined, { - year: "numeric", - month: "short", - day: "numeric", - }); -} - -export function formatMoney(amount: number, currency: string): string { - return new Intl.NumberFormat(undefined, { - style: "currency", - currency, - maximumFractionDigits: 0, - }).format(amount); -} - -export function formatBytes(bytes: number): string { - if (!bytes) return "0 B"; - const units = ["B", "KB", "MB", "GB"]; - const i = Math.floor(Math.log(bytes) / Math.log(1024)); - const value = bytes / Math.pow(1024, i); - return `${value.toFixed(i === 0 ? 0 : 1)} ${units[i]}`; -} +/** @deprecated import from "@/lib/format" (or ../../lib/format) instead. */ +export { humanize, formatDate, formatDateTime, formatMoney, formatBytes } from "../../lib/format"; diff --git a/apps/edr-freight-web/backoffice/src/components/customers/index.ts b/apps/edr-freight-web/backoffice/src/components/customers/index.ts index daeb11311..6f869173c 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/index.ts +++ b/apps/edr-freight-web/backoffice/src/components/customers/index.ts @@ -19,10 +19,6 @@ export { RequestDocumentChangeModal, type RequestDocumentChangeModalProps, } from "./RequestDocumentChangeModal"; -export { - default as ResetPasswordAction, - type ResetPasswordActionProps, -} from "./ResetPasswordAction"; export { formatBytes, formatDate, formatMoney, humanize } from "./format"; export { PersonCard, diff --git a/apps/edr-freight-web/backoffice/src/components/detail/EntityLink.tsx b/apps/edr-freight-web/backoffice/src/components/detail/EntityLink.tsx new file mode 100644 index 000000000..497c59d6c --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/detail/EntityLink.tsx @@ -0,0 +1,63 @@ +import type { ReactNode } from "react"; +import type { LucideIcon } from "lucide-react"; +import { ArrowUpRight } from "lucide-react"; +import { Anchor, Group, Text } from "@mantine/core"; +import { Link } from "react-router-dom"; + +export interface EntityLinkProps { + /** Route to the related record's detail page. Renders nothing if falsy — a + * link with no id would be a dead one (e.g. a government booking with no + * company). */ + to?: string | null; + label: ReactNode; + icon?: LucideIcon; + /** Monospace label — for references/codes (e.g. "CT-2024-0117"). */ + mono?: boolean; + size?: "xs" | "sm" | "md"; + fw?: number; + className?: string; +} + +/** + * Inline link to another record's detail page, with a small "go to" glyph so + * it reads as navigation rather than plain emphasis. `stopPropagation` matters + * wherever this sits inside a clickable table row (booking/invoice rows + * navigate on click) — without it a nested link races the row handler. + */ +export function EntityLink({ + to, + label, + icon: Icon, + mono, + size = "sm", + fw = 600, + className, +}: EntityLinkProps) { + if (!to) { + return ( + + {label} + + ); + } + + return ( + e.stopPropagation()} + underline="hover" + c="edr-green" + fw={fw} + fz={size} + ff={mono ? "monospace" : undefined} + className={className} + > + + {Icon ? : null} + {label} + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/detail/Field.tsx b/apps/edr-freight-web/backoffice/src/components/detail/Field.tsx new file mode 100644 index 000000000..7300005b9 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/detail/Field.tsx @@ -0,0 +1,59 @@ +import type { ReactNode } from "react"; +import type { LucideIcon } from "lucide-react"; +import { Group, Stack, Text } from "@mantine/core"; + +export interface FieldProps { + label: string; + value?: ReactNode; +} + +/** + * Stacked label-over-value pair — uppercase dimmed label, value below. Used in + * grids of facts (e.g. an invoice summary, a contract's key figures). + */ +export function Field({ label, value }: FieldProps) { + const isEmpty = value === undefined || value === null || value === ""; + return ( + + + {label} + + + {isEmpty ? "—" : value} + + + ); +} + +export interface FieldRowProps { + icon?: LucideIcon; + label: string; + value?: ReactNode; +} + +/** + * Left icon+label / right bold value row, divider-separated when stacked in a + * list. Used inside quick-info cards (see `LinkedEntityCard`). + */ +export function FieldRow({ icon: Icon, label, value }: FieldRowProps) { + const isEmpty = value === undefined || value === null || value === ""; + return ( + + + {Icon ? : null} + + {label} + + + + {isEmpty ? "—" : value} + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/detail/LinkedEntityCard.tsx b/apps/edr-freight-web/backoffice/src/components/detail/LinkedEntityCard.tsx new file mode 100644 index 000000000..931932fb5 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/detail/LinkedEntityCard.tsx @@ -0,0 +1,67 @@ +import type { ReactNode } from "react"; +import type { LucideIcon } from "lucide-react"; +import { Divider, Stack, Text } from "@mantine/core"; + +import { SectionCard } from "@/components/bookings/detail/SectionCard"; +import { FieldRow, type FieldRowProps } from "./Field"; +import { EntityLink } from "./EntityLink"; + +export interface LinkedEntityCardProps { + icon: LucideIcon; + /** Card title, e.g. "Customer" or "Contract". */ + title: string; + /** The entity's own name/reference, rendered as the linked subtitle. */ + name: ReactNode; + /** Route to the entity's detail page. Omit when there's nothing to link to + * (e.g. a government booking with no company) — the name renders as plain + * dimmed text instead of a dead link. */ + to?: string | null; + accent?: string; + /** Quick-info rows shown below the linked name — empty ones are dropped. */ + rows?: FieldRowProps[]; + /** Extra content under the rows (e.g. a summary paragraph, an action). */ + footer?: ReactNode; + /** Shown instead of rows/footer when there's nothing to display at all. */ + emptyMessage?: string; +} + +/** + * "Customer at a glance" / "Contract at a glance" card for a detail page's + * sticky rail: a linked title plus a handful of quick-info rows, so the + * related record's essentials are visible without navigating away. + */ +export function LinkedEntityCard({ + icon, + title, + name, + to, + accent = "blue", + rows = [], + footer, + emptyMessage, +}: LinkedEntityCardProps) { + const visibleRows = rows.filter((r) => r.value !== undefined && r.value !== null && r.value !== ""); + + return ( + + + + {visibleRows.length > 0 ? ( + + {visibleRows.map((row, index) => ( +
+ {index > 0 && } + +
+ ))} +
+ ) : emptyMessage ? ( + + {emptyMessage} + + ) : null} + {footer} +
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/detail/index.ts b/apps/edr-freight-web/backoffice/src/components/detail/index.ts new file mode 100644 index 000000000..15e379099 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/detail/index.ts @@ -0,0 +1,14 @@ +export { Field, FieldRow } from "./Field"; +export type { FieldProps, FieldRowProps } from "./Field"; +export { EntityLink } from "./EntityLink"; +export type { EntityLinkProps } from "./EntityLink"; +export { LinkedEntityCard } from "./LinkedEntityCard"; +export type { LinkedEntityCardProps } from "./LinkedEntityCard"; + +// Re-exported so pages under this restructure have one import path for both +// the new quick-info primitives and the existing section-card shell. Imported +// from the file directly (not the bookings/detail barrel) — that barrel also +// re-exports cards that import from this module, and going through it would +// create a circular import. +export { SectionCard } from "@/components/bookings/detail/SectionCard"; +export type { SectionCardProps } from "@/components/bookings/detail/SectionCard"; diff --git a/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts b/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts index 9b75f35f5..636310e38 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts +++ b/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts @@ -36,6 +36,34 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [ subtitle: "Dashboard summary and key metrics", }, }, + { + prefix: "/dashboard/overview/bookings", + meta: { title: "Bookings", subtitle: "Booking volume, pipeline, and recent activity" }, + }, + { + prefix: "/dashboard/overview/contracts", + meta: { title: "Contracts", subtitle: "Contract volume, pipeline, and recent activity" }, + }, + { + prefix: "/dashboard/overview/billing", + meta: { title: "Billing", subtitle: "Revenue, payments, and collection status" }, + }, + { + prefix: "/dashboard/overview/operations", + meta: { title: "Operations", subtitle: "Trains, schedules, containers, and cargo" }, + }, + { + prefix: "/dashboard/overview/fleet", + meta: { title: "Fleet", subtitle: "Wagon and train fleet status" }, + }, + { + prefix: "/dashboard/overview/customers", + meta: { title: "Customers", subtitle: "Customer growth and top accounts" }, + }, + { + prefix: "/dashboard/overview/staff", + meta: { title: "Staff", subtitle: "Employee and user account status" }, + }, { prefix: "/dashboard/profile", meta: { @@ -44,10 +72,12 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [ }, }, { - prefix: "/dashboard/payments", + // Invoices, Payments, and USD Payments are tabs on one page now + // (FinanceHubPage); the header title itself is set per-tab there. + prefix: "/dashboard/invoices", meta: { - title: "Payments", - subtitle: "View booking payment transactions", + title: "Invoices", + subtitle: "Invoices, payments, and USD bank transfers", }, }, { diff --git a/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx b/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx index 95608b6db..293e82450 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx +++ b/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx @@ -9,7 +9,7 @@ import { FileText, Hammer, History, - Landmark, + Image as ImageIcon, LayoutDashboard, LayoutGrid, MapPin, @@ -24,6 +24,7 @@ import { Send, Settings, ShieldCheck, + HandCoins, Ship, SlidersHorizontal, Train, @@ -51,516 +52,532 @@ import { getCategorySidebarChildren } from "@/pages/ruleEngine/config/resources" * a user's first reachable route without importing the route tree (App.tsx * imports RequirePermission, which imports landing — that would cycle). */ -export const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ - { - title: "Main menu", - items: [ - { - label: "Overview", - href: "/dashboard/overview", - icon: , - permission: FREIGHT_PERMS.overview.view, - }, - { - label: "Reports", - href: "/dashboard/reports", - icon: , - permission: FREIGHT_PERMS.reports.view, - }, - { - label: "Customers", - href: "/dashboard/customers", - icon: , - permission: FREIGHT_PERMS.customers.view, - }, - { - label: "Contracts", - href: "/dashboard/contract-requests", - icon: , - permission: FREIGHT_PERMS.contracts.view, - }, - { - label: "Bookings", - href: "/dashboard/booking-requests", - icon: , - permission: FREIGHT_PERMS.bookings.view, - }, - { - label: "Wagon cancellations", - href: "/dashboard/wagon-cancellations", - icon: , - permission: FREIGHT_PERMS.bookings.wagonCancellationView, - }, - // Operations hub: per-shipment clearance-document review for services - // WITHOUT customs clearing (self-clearance) — bookings only. - { - label: "Clearance Documents", - href: "/dashboard/contracts/clearance-documents", - icon: , - permission: FREIGHT_PERMS.contracts.opsClearanceReview, - }, - { - label: "Payments", - href: "/dashboard/payments", - icon: , - permission: FREIGHT_PERMS.payments.view, - }, - { - label: "Invoices", - href: "/dashboard/invoices", - icon: , - permission: FREIGHT_PERMS.invoices.view, - }, - { - label: "USD Payments", - href: "/dashboard/usd-payments", - icon: , - permission: FREIGHT_PERMS.invoices.view, - }, - { - label: "Support", - href: "/dashboard/support", - icon: , - permission: FREIGHT_PERMS.support.agentView, - }, - ...demoItems, - ], - }, - { - // title: "Port & Terminal", - items: [ - { - label: "Operations", - icon: , - children: [ - { - label: "Clearance", - href: "/dashboard/contracts/clearance", - icon: , - permission: [ - FREIGHT_PERMS.contracts.clearanceReview, - FREIGHT_PERMS.contracts.clearanceEtActions, - ], - }, - // { - // label: "Shipment Requests", - // href: "/dashboard/shipment-requests", - // icon: , - // permission: FREIGHT_PERMS.contracts.createBooking, - // }, - // Operations Path A queue: per-booking self-clearance review for - // GENERAL non-customs booking instances (and legacy self-clear bookings). - // { - // label: "Self-Clearance Review", - // href: "/dashboard/contracts/ops-clearance", - // icon: , - // permission: FREIGHT_PERMS.contracts.opsClearanceReview, - // }, - { - label: "GL Djibouti Clearance", - href: "/dashboard/gl-djibouti/clearance", - icon: , - permission: FREIGHT_PERMS.contracts.clearanceDjActions, - }, - { - label: "Train Schedules", - href: "/dashboard/operations/train-scheduling-v2", - icon: , - permission: FREIGHT_PERMS.trainScheduling.view, - }, - { - label: "Batch Board", - href: "/dashboard/operations/batch-board", - icon: , - permission: FREIGHT_PERMS.trainScheduling.view, - }, - { - label: "First Mile", - href: "/dashboard/operations/first-mile", - icon: , - permission: FREIGHT_PERMS.firstMile.view, - }, - { - label: "Last Mile", - href: "/dashboard/operations/last-mile", - icon: , - permission: FREIGHT_PERMS.lastMile.view, - }, - ], - }, - { - label: "Fleet Management", - icon: , - children: [ - { - label: "Fleet Dashboard", - href: "/dashboard/fleet-dashboard", - icon: , - permission: FREIGHT_PERMS.fleetDashboard.view, - }, - { - label: "Routes", - href: "/dashboard/routes", - icon: , - permission: FREIGHT_PERMS.routes.view, - }, - { - label: "Locomotives", - href: "/dashboard/locomotives", - icon: , - permission: FREIGHT_PERMS.locomotives.view, - }, - { - label: "Train Builder", - href: "/dashboard/train-builder", - icon: , - permission: FREIGHT_PERMS.trains.view, - }, +export const buildSidebarSections = ( + demoItems: SidebarItem[], + reportItems: SidebarItem[] = [], +): SidebarSection[] => [ + { + title: "Main menu", + items: [ + { + label: "Overview", + href: "/dashboard/overview", + icon: , + permission: FREIGHT_PERMS.overview.view, + }, + { + label: "Customers", + href: "/dashboard/customers", + icon: , + permission: FREIGHT_PERMS.customers.view, + }, + { + label: "Shipping Lines", + href: "/dashboard/shipping-lines", + icon: , + permission: FREIGHT_PERMS.shippingLines.view, + }, + { + label: "Shipping Line Credits", + href: "/dashboard/shipping-line-credits", + icon: , + permission: FREIGHT_PERMS.shippingLineCredits.view, + }, + { + label: "Contracts", + href: "/dashboard/contract-requests", + icon: , + permission: FREIGHT_PERMS.contracts.view, + }, + { + label: "Bookings", + href: "/dashboard/booking-requests", + icon: , + permission: FREIGHT_PERMS.bookings.view, + }, + { + label: "Wagon cancellations", + href: "/dashboard/wagon-cancellations", + icon: , + permission: FREIGHT_PERMS.bookings.wagonCancellationView, + }, + // Operations hub: per-shipment clearance-document review for services + // WITHOUT customs clearing (self-clearance) — bookings only. + { + label: "Clearance Documents", + href: "/dashboard/contracts/clearance-documents", + icon: , + permission: FREIGHT_PERMS.contracts.opsClearanceReview, + }, + { + // Invoices, Payments, and USD Payments live on one page as tabs + // (FinanceHubPage) — single nav entry, OR'd across both keys so + // either permission alone still gets a user in. + label: "Transactions", + href: "/dashboard/invoices", + icon: , + permission: [FREIGHT_PERMS.invoices.view, FREIGHT_PERMS.payments.view], + }, + { + label: "Support", + href: "/dashboard/support", + icon: , + permission: FREIGHT_PERMS.support.agentView, + }, + ...demoItems, + ], + }, + { + // title: "Port & Terminal", + items: [ + { + label: "Operations", + icon: , + children: [ + { + label: "Clearance", + href: "/dashboard/contracts/clearance", + icon: , + permission: [ + FREIGHT_PERMS.contracts.clearanceReview, + FREIGHT_PERMS.contracts.clearanceEtActions, + ], + }, + // { + // label: "Shipment Requests", + // href: "/dashboard/shipment-requests", + // icon: , + // permission: FREIGHT_PERMS.contracts.createBooking, + // }, + // Operations Path A queue: per-booking self-clearance review for + // GENERAL non-customs booking instances (and legacy self-clear bookings). + // { + // label: "Self-Clearance Review", + // href: "/dashboard/contracts/ops-clearance", + // icon: , + // permission: FREIGHT_PERMS.contracts.opsClearanceReview, + // }, + { + label: "GL Djibouti Clearance", + href: "/dashboard/gl-djibouti/clearance", + icon: , + permission: FREIGHT_PERMS.contracts.clearanceDjActions, + }, + { + label: "Train Schedules", + href: "/dashboard/operations/train-scheduling-v2", + icon: , + permission: FREIGHT_PERMS.trainScheduling.view, + }, + { + label: "Batch Board", + href: "/dashboard/operations/batch-board", + icon: , + permission: FREIGHT_PERMS.trainScheduling.view, + }, + { + label: "First Mile", + href: "/dashboard/operations/first-mile", + icon: , + permission: FREIGHT_PERMS.firstMile.view, + }, + { + label: "Last Mile", + href: "/dashboard/operations/last-mile", + icon: , + permission: FREIGHT_PERMS.lastMile.view, + }, + ], + }, + { + label: "Fleet Management", + icon: , + children: [ + { + label: "Fleet Dashboard", + href: "/dashboard/fleet-dashboard", + icon: , + permission: FREIGHT_PERMS.fleetDashboard.view, + }, + { + label: "Routes", + href: "/dashboard/routes", + icon: , + permission: FREIGHT_PERMS.routes.view, + }, + { + label: "Locomotives", + href: "/dashboard/locomotives", + icon: , + permission: FREIGHT_PERMS.locomotives.view, + }, + { + label: "Train Builder", + href: "/dashboard/train-builder", + icon: , + permission: FREIGHT_PERMS.trains.view, + }, - // { - // label: "Wagon types", - // href: "/dashboard/wagon-types", - // icon: , - // }, - { - label: "Wagons", - href: "/dashboard/wagons", - icon: , - permission: FREIGHT_PERMS.wagons.view, - }, - { - label: "Wagon Transfers", - href: "/dashboard/wagon-transfers", - icon: , - permission: [ - FREIGHT_PERMS.wagons.transferView, - FREIGHT_PERMS.wagons.view, - ], - }, - { - label: "Vehicles", - href: "/dashboard/vehicles", - icon: , - permission: FREIGHT_PERMS.vehicles.view, - }, - { - label: "Drivers", - href: "/dashboard/drivers", - icon: , - permission: FREIGHT_PERMS.drivers.view, - }, - { - label: "Track Vehicles", - href: "/dashboard/tracking", - icon: , - permission: FREIGHT_PERMS.tracking.view, - }, - { - label: "Fuel Purchases", - href: "/dashboard/fuel-purchases", - icon: , - permission: FREIGHT_PERMS.fuel.view, - }, - { - label: "Fuel Analytics", - href: "/dashboard/fuel-stats", - icon: , - permission: FREIGHT_PERMS.fuel.view, - }, - { - label: "Maintenance", - href: "/dashboard/maintenance", - icon: , - permission: FREIGHT_PERMS.maintenance.view, - }, - { - label: "Work Orders", - href: "/dashboard/work-orders", - icon: , - permission: FREIGHT_PERMS.maintenance.view, - }, - { - label: "Compliance & Alerts", - href: "/dashboard/compliance", - icon: , - permission: FREIGHT_PERMS.compliance.view, - }, - { - label: "Incidents", - href: "/dashboard/incidents", - icon: , - // No dedicated backend key exists for incidents yet. Not part of - // the fleet.view/admin fallback cleanup — removing fleet.view - // here with nothing to replace it would lock the page to - // super-admin only, so it stays as the sole (if coarse) gate. - permission: FREIGHT_PERMS.fleet.view, - }, - { - label: "Procurement", - href: "/dashboard/procurement", - icon: , - permission: FREIGHT_PERMS.procurement.view, - }, - { - label: "Financial Reports", - href: "/dashboard/financial-reports", - icon: , - permission: FREIGHT_PERMS.fleetReports.view, - }, - // { - // label: "Containers", - // href: "/dashboard/containers", - // icon: , - // }, - // { - // label: "Cargoes", - // href: "/dashboard/cargoes", - // icon: , - // }, - ], - }, - { - label: "Imports", - href: "/dashboard/import-warehouse", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - children: [ - { - label: "Import Overview", - href: "/dashboard/import-warehouse", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - { - label: "Arrival Queue", - href: "/dashboard/arrival-queue", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - { - label: "Import Trucks", - href: "/dashboard/import-trucks", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - { - label: "Container Returns", - href: "/dashboard/container-returns", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - { - label: "Terminal Inventory", - href: "/dashboard/warehouse-inventory?direction=IMPORT", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - { - label: "Inventory Inquiry", - href: "/dashboard/inventory-inquiry", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - ], - }, - { - label: "Exports", - href: "/dashboard/export-warehouse", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - children: [ - { - label: "Export Overview", - href: "/dashboard/export-warehouse", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - { - label: "Loading Queue", - href: "/dashboard/loading-queue", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - { - label: "Loaded Inventory", - href: "/dashboard/loaded-inventory", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - { - label: "Dispatch Queue", - href: "/dashboard/dispatch-queue", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - { - label: "Djibouti Unloading", - href: "/dashboard/export-djibouti-unloading", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - { - label: "Interchange Documents", - href: "/dashboard/interchange-documents", - icon: , - permission: FREIGHT_PERMS.interchangeDocuments.view, - }, - { - label: "Terminal Inventory", - href: "/dashboard/warehouse-inventory?direction=EXPORT", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - ], - }, - { - label: "Intercity", - href: "/dashboard/intercity", - icon: , - permission: FREIGHT_PERMS.trainScheduling.view, - children: [ - { - label: "Intercity Cargo", - href: "/dashboard/intercity", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - ], - }, - { - label: "Warehouse Management", - icon: , - children: [ - { - label: "Warehouse Dashboard", - href: "/dashboard/warehouse-dashboard", - icon: , - permission: FREIGHT_PERMS.warehouseDashboard.view, - }, - { - // Yard-wide, not per-direction: the gate sees import and export - // trucks at the same barrier. - label: "Trucks on Site", - href: "/dashboard/trucks-on-site", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - { - label: "Warehouses", - href: "/dashboard/warehouses", - icon: , - permission: FREIGHT_PERMS.warehouses.view, - }, - { - label: "Allocation & Fees", - href: "/dashboard/warehouse-rules", - icon: , - permission: [ - FREIGHT_PERMS.warehouseAllocationRules.view, - FREIGHT_PERMS.warehouseFeeRules.view, - ], - }, - { - label: "Fee Invoices", - href: "/dashboard/warehouse-fee-invoices", - icon: , - permission: FREIGHT_PERMS.warehouseFeeInvoices.view, - }, - ], - }, - ], - }, - { - title: "Freight configuration", - mutedTitle: true, - items: [ - { - label: "File settings", - href: "/dashboard/file-settings", - icon: , - permission: FREIGHT_PERMS.settings.fileUpload.view, - }, - { - label: "Dropdown settings", - href: "/dashboard/dropdown-settings", - icon: , - permission: FREIGHT_PERMS.settings.dropdown.view, - }, - { - // One entry, one stamp. The former "Stamp settings" entry here pointed - // at the per-officer teeter (ማህተም), not a company seal — it moved to - // /user-management/teeter-and-signature. - label: "Company stamp", - href: "/dashboard/stamp-settings", - icon: , - permission: FREIGHT_PERMS.settings.stamp.view, - }, - { - label: "Contract templates", - href: "/dashboard/contract-templates", - icon: , - // `view` opens the page; `read` alone is API-only and shows no menu. - permission: FREIGHT_PERMS.settings.contractTemplates.view, - }, - { - label: "Portal content", - href: "/dashboard/portal-content", - icon: , - permission: [ - FREIGHT_PERMS.settings.supportContent.view, - FREIGHT_PERMS.settings.supportContent.manage, - ], - }, - { - label: "Audit logs", - href: "/dashboard/audit-logs", - icon: , - permission: FREIGHT_PERMS.auditLog.view, - }, - { - label: "Configuration", - href: "/dashboard/configuration", - icon: , - children: [ - ...getCategorySidebarChildren("configuration"), - { - label: "Train scheduling rules", - href: "/dashboard/configuration/train-scheduling-rules", - permission: FREIGHT_PERMS.trainScheduling.rulesManage, - }, - { - label: "Trade access", - href: "/dashboard/configuration/trade-access", - permission: FREIGHT_PERMS.tradeAccess.view, - }, - { - label: "Exchange rate", - href: "/dashboard/configuration/exchange-rate", - permission: FREIGHT_PERMS.settings.exchangeRate.view, - }, - ], - }, - { - label: "Rules", - href: "/dashboard/rules", - icon: , - children: getCategorySidebarChildren("rules"), - }, + // { + // label: "Wagon types", + // href: "/dashboard/wagon-types", + // icon: , + // }, + { + label: "Wagons", + href: "/dashboard/wagons", + icon: , + permission: FREIGHT_PERMS.wagons.view, + }, + { + label: "Wagon Transfers", + href: "/dashboard/wagon-transfers", + icon: , + permission: [ + FREIGHT_PERMS.wagons.transferView, + FREIGHT_PERMS.wagons.view, + ], + }, + { + label: "Vehicles", + href: "/dashboard/vehicles", + icon: , + permission: FREIGHT_PERMS.vehicles.view, + }, + { + label: "Drivers", + href: "/dashboard/drivers", + icon: , + permission: FREIGHT_PERMS.drivers.view, + }, + { + label: "Track Vehicles", + href: "/dashboard/tracking", + icon: , + permission: FREIGHT_PERMS.tracking.view, + }, + { + label: "Fuel Purchases", + href: "/dashboard/fuel-purchases", + icon: , + permission: FREIGHT_PERMS.fuel.view, + }, + { + label: "Fuel Analytics", + href: "/dashboard/fuel-stats", + icon: , + permission: FREIGHT_PERMS.fuel.view, + }, + { + label: "Maintenance", + href: "/dashboard/maintenance", + icon: , + permission: FREIGHT_PERMS.maintenance.view, + }, + { + label: "Work Orders", + href: "/dashboard/work-orders", + icon: , + permission: FREIGHT_PERMS.maintenance.view, + }, + { + label: "Compliance & Alerts", + href: "/dashboard/compliance", + icon: , + permission: FREIGHT_PERMS.compliance.view, + }, + { + label: "Incidents", + href: "/dashboard/incidents", + icon: , + // No dedicated backend key exists for incidents yet. Not part of + // the fleet.view/admin fallback cleanup — removing fleet.view + // here with nothing to replace it would lock the page to + // super-admin only, so it stays as the sole (if coarse) gate. + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Procurement", + href: "/dashboard/procurement", + icon: , + permission: FREIGHT_PERMS.procurement.view, + }, + { + label: "Financial Reports", + href: "/dashboard/financial-reports", + icon: , + permission: FREIGHT_PERMS.fleetReports.view, + }, + // { + // label: "Containers", + // href: "/dashboard/containers", + // icon: , + // }, + // { + // label: "Cargoes", + // href: "/dashboard/cargoes", + // icon: , + // }, + ], + }, + { + label: "Imports", + href: "/dashboard/import-warehouse", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + children: [ + { + label: "Import Overview", + href: "/dashboard/import-warehouse", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + { + label: "Arrival Queue", + href: "/dashboard/arrival-queue", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + { + label: "Import Trucks", + href: "/dashboard/import-trucks", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + { + label: "Container Returns", + href: "/dashboard/container-returns", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + { + label: "Terminal Inventory", + href: "/dashboard/warehouse-inventory?direction=IMPORT", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + { + label: "Inventory Inquiry", + href: "/dashboard/inventory-inquiry", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + ], + }, + { + label: "Exports", + href: "/dashboard/export-warehouse", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + children: [ + { + label: "Export Overview", + href: "/dashboard/export-warehouse", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + { + label: "Loading Queue", + href: "/dashboard/loading-queue", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + { + label: "Loaded Inventory", + href: "/dashboard/loaded-inventory", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + { + label: "Dispatch Queue", + href: "/dashboard/dispatch-queue", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + { + label: "Djibouti Unloading", + href: "/dashboard/export-djibouti-unloading", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + { + label: "Interchange Documents", + href: "/dashboard/interchange-documents", + icon: , + permission: FREIGHT_PERMS.interchangeDocuments.view, + }, + { + label: "Terminal Inventory", + href: "/dashboard/warehouse-inventory?direction=EXPORT", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + ], + }, + { + label: "Intercity", + href: "/dashboard/intercity", + icon: , + permission: FREIGHT_PERMS.trainScheduling.view, + children: [ + { + label: "Intercity Cargo", + href: "/dashboard/intercity", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + ], + }, + { + label: "Warehouse Management", + icon: , + children: [ + { + label: "Warehouse Dashboard", + href: "/dashboard/warehouse-dashboard", + icon: , + permission: FREIGHT_PERMS.warehouseDashboard.view, + }, + { + // Yard-wide, not per-direction: the gate sees import and export + // trucks at the same barrier. + label: "Trucks on Site", + href: "/dashboard/trucks-on-site", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + { + label: "Warehouses", + href: "/dashboard/warehouses", + icon: , + permission: FREIGHT_PERMS.warehouses.view, + }, + { + label: "Allocation & Fees", + href: "/dashboard/warehouse-rules", + icon: , + permission: [ + FREIGHT_PERMS.warehouseAllocationRules.view, + FREIGHT_PERMS.warehouseFeeRules.view, + ], + }, + { + label: "Fee Invoices", + href: "/dashboard/warehouse-fee-invoices", + icon: , + permission: FREIGHT_PERMS.warehouseFeeInvoices.view, + }, + ], + }, - { - label: "Staff", - href: "/user-management", - icon: , - permission: [ - FREIGHT_PERMS.admin, - FREIGHT_PERMS.staff.roles.view, - FREIGHT_PERMS.staff.employeeRegistration.view, - FREIGHT_PERMS.staff.roleAssignment.view, - ], - }, - ], - }, -]; + { + label: "Reports", + href: "/dashboard/reports", + icon: , + permission: FREIGHT_PERMS.reports.view, + // Populated from the live GET /reports catalog (already permission- + // filtered server-side) — no report key is ever hand-listed here. + ...(reportItems.length ? { children: reportItems } : {}), + }, + ], + }, + { + title: "Freight configuration", + mutedTitle: true, + items: [ + { + label: "File settings", + href: "/dashboard/file-settings", + icon: , + permission: FREIGHT_PERMS.settings.fileUpload.view, + }, + { + label: "Dropdown settings", + href: "/dashboard/dropdown-settings", + icon: , + permission: FREIGHT_PERMS.settings.dropdown.view, + }, + { + // One entry, one stamp. The former "Stamp settings" entry here pointed + // at the per-officer teeter (ማህተም), not a company seal — it moved to + // /user-management/teeter-and-signature. + label: "Company stamp", + href: "/dashboard/stamp-settings", + icon: , + permission: FREIGHT_PERMS.settings.stamp.view, + }, + { + label: "Company logo", + href: "/dashboard/logo-settings", + icon: , + permission: FREIGHT_PERMS.settings.logo.view, + }, + { + label: "Contract templates", + href: "/dashboard/contract-templates", + icon: , + // `view` opens the page; `read` alone is API-only and shows no menu. + permission: FREIGHT_PERMS.settings.contractTemplates.view, + }, + { + label: "Portal content", + href: "/dashboard/portal-content", + icon: , + permission: [ + FREIGHT_PERMS.settings.supportContent.view, + FREIGHT_PERMS.settings.supportContent.manage, + ], + }, + { + label: "Audit logs", + href: "/dashboard/audit-logs", + icon: , + permission: FREIGHT_PERMS.auditLog.view, + }, + { + label: "Configuration", + href: "/dashboard/configuration", + icon: , + children: [ + ...getCategorySidebarChildren("configuration"), + { + label: "Train scheduling rules", + href: "/dashboard/configuration/train-scheduling-rules", + permission: FREIGHT_PERMS.trainScheduling.rulesManage, + }, + { + label: "Trade access", + href: "/dashboard/configuration/trade-access", + permission: FREIGHT_PERMS.tradeAccess.view, + }, + { + label: "Exchange rate", + href: "/dashboard/configuration/exchange-rate", + permission: FREIGHT_PERMS.settings.exchangeRate.view, + }, + ], + }, + { + label: "Rules", + href: "/dashboard/rules", + icon: , + children: getCategorySidebarChildren("rules"), + }, + + { + label: "Staff", + href: "/user-management", + icon: , + permission: [ + FREIGHT_PERMS.admin, + FREIGHT_PERMS.staff.roles.view, + FREIGHT_PERMS.staff.employeeRegistration.view, + FREIGHT_PERMS.staff.roleAssignment.view, + ], + }, + ], + }, + ]; /** * Keep only items the user is permitted to see; drop now-empty sections. diff --git a/apps/edr-freight-web/backoffice/src/components/overview/OverviewKpiSection.tsx b/apps/edr-freight-web/backoffice/src/components/overview/OverviewKpiSection.tsx deleted file mode 100644 index ea99d098e..000000000 --- a/apps/edr-freight-web/backoffice/src/components/overview/OverviewKpiSection.tsx +++ /dev/null @@ -1,167 +0,0 @@ -import { - AlertCircle, - Banknote, - Box, - Clock, - Container, - CreditCard, - FileText, - Train, - Truck, - UserCheck, - Users, - Wallet, -} from "lucide-react"; -import { Group, Paper, Stack, Text } from "@mantine/core"; - -import type { IOverviewKpis } from "@/types/overview"; -import { OverviewKpiCard } from "./OverviewKpiCard"; - -function formatCurrency(amount: number, currency: "ETB" | "USD") { - return new Intl.NumberFormat("en-US", { - style: "currency", - currency, - maximumFractionDigits: 0, - }).format(amount); -} - -export function OverviewKpiSection({ kpis }: { kpis: IOverviewKpis }) { - const bookingItems = [ - { - label: "Active bookings", - value: kpis.bookings.totalActive, - icon: FileText, - accent: "emerald" as const, - }, - { - label: "Needs action", - value: kpis.bookings.needsAction, - icon: AlertCircle, - accent: "amber" as const, - }, - { - label: "Urgent", - value: kpis.bookings.urgent, - icon: Clock, - accent: "rose" as const, - }, - { - label: "In approval", - value: kpis.bookings.inApproval, - icon: UserCheck, - accent: "sky" as const, - }, - { - label: "Submitted today", - value: kpis.bookings.submittedToday, - icon: FileText, - }, - ]; - - const operationsItems = [ - { - label: "Active trains", - value: kpis.operations.trainsActive, - icon: Train, - accent: "emerald" as const, - }, - { - label: "Wagons available", - value: kpis.operations.wagonsAvailable, - icon: Truck, - }, - { - label: "Containers in transit", - value: kpis.operations.containersInTransit, - icon: Container, - }, - { - label: "Cargoes loaded", - value: kpis.operations.cargoesLoaded, - icon: Box, - }, - ]; - - const billingItems = [ - { - label: "Revenue MTD (ETB)", - value: formatCurrency(kpis.billing.revenueMtdEtb, "ETB"), - icon: Banknote, - accent: "emerald" as const, - }, - { - label: "Revenue MTD (USD)", - value: formatCurrency(kpis.billing.revenueMtdUsd, "USD"), - icon: Wallet, - }, - { - label: "Pending payments", - value: kpis.billing.pendingPayments, - icon: CreditCard, - accent: "amber" as const, - }, - { - label: "Successful MTD", - value: kpis.billing.successfulPaymentsMtd, - icon: Banknote, - }, - ]; - - const peopleItems = [ - { - label: "Total customers", - value: kpis.customers.totalCustomers, - icon: Users, - }, - { - label: "New this month", - value: kpis.customers.newCustomersThisMonth, - icon: Users, - accent: "emerald" as const, - }, - { - label: "Active employees", - value: kpis.staff.activeEmployees, - icon: UserCheck, - }, - { - label: "Active users", - value: kpis.staff.activeUsers, - icon: Users, - }, - ]; - - const sections = [ - { title: "Bookings", items: bookingItems }, - { title: "Operations", items: operationsItems }, - { title: "Billing", items: billingItems }, - { title: "Customers & staff", items: peopleItems }, - ]; - - return ( - - {sections.map((section) => ( - - - {section.title} - - - {section.items.map((item) => ( - - ))} - - - ))} - - ); -} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/OverviewPageHeader.tsx b/apps/edr-freight-web/backoffice/src/components/overview/OverviewPageHeader.tsx deleted file mode 100644 index 529981add..000000000 --- a/apps/edr-freight-web/backoffice/src/components/overview/OverviewPageHeader.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { ActionIcon, Group, SegmentedControl, Text } from "@mantine/core"; -import { RefreshCw } from "lucide-react"; - -import type { OverviewRange } from "@/types/overview"; - -const RANGE_OPTIONS = [ - { label: "7 days", value: "7d" }, - { label: "30 days", value: "30d" }, - { label: "90 days", value: "90d" }, -]; - -function formatRelativeTime(iso: string | undefined) { - if (!iso) return "—"; - const diffMs = Date.now() - new Date(iso).getTime(); - const minutes = Math.floor(diffMs / 60_000); - if (minutes < 1) return "just now"; - if (minutes < 60) return `${minutes}m ago`; - const hours = Math.floor(minutes / 60); - if (hours < 24) return `${hours}h ago`; - return new Date(iso).toLocaleString(); -} - -interface OverviewPageHeaderProps { - range: OverviewRange; - onRangeChange: (range: OverviewRange) => void; - generatedAt?: string; - onRefresh: () => void; - isRefreshing?: boolean; -} - -export function OverviewPageHeader({ - range, - onRangeChange, - generatedAt, - onRefresh, - isRefreshing, -}: OverviewPageHeaderProps) { - return ( - - - Updated {formatRelativeTime(generatedAt)} - - - onRangeChange(value as OverviewRange)} - data={RANGE_OPTIONS} - size="sm" - radius="lg" - color="edr-green" - /> - - - - - - ); -} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/OverviewRecentBookingsTable.tsx b/apps/edr-freight-web/backoffice/src/components/overview/OverviewRecentBookingsTable.tsx index b13f5c473..e1ceaec1c 100644 --- a/apps/edr-freight-web/backoffice/src/components/overview/OverviewRecentBookingsTable.tsx +++ b/apps/edr-freight-web/backoffice/src/components/overview/OverviewRecentBookingsTable.tsx @@ -1,9 +1,11 @@ -import { useNavigate } from "react-router-dom"; -import { Paper, Stack, Table, Text } from "@mantine/core"; +import { History } from "lucide-react"; +import { Link, useNavigate } from "react-router-dom"; +import { Table, Text } from "@mantine/core"; import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge"; import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge"; import type { IOverviewRecentBooking } from "@/types/overview"; +import { SummaryCard } from "./summary/SummaryCard"; function formatAmount(amount: number | null, currency: string | null) { if (amount == null) return "—"; @@ -23,56 +25,90 @@ export function OverviewRecentBookingsTable({ const navigate = useNavigate(); return ( - - - Recent bookings - {bookings.length === 0 ? ( - - No recent bookings - - ) : ( - - - - Reference - Customer - Status - Priority - Amount - Created - - - - {bookings.map((booking) => ( - navigate(`/dashboard/booking-requests/${booking.id}`)} - > - - - {booking.reference} - - - {booking.customerLabel} - - - - - - - + + View all → + + } + > + {bookings.length === 0 ? ( + + No recent bookings + + ) : ( +
+ + + {["Reference", "Customer", "Status", "Priority", "Amount", "Created"].map( + (header) => ( + + {header} + + ), + )} + + + + {bookings.map((booking) => ( + navigate(`/dashboard/booking-requests/${booking.id}`)} + > + + + {booking.reference} + + + + + {booking.customerLabel} + + + + + + + + + + {formatAmount(booking.totalAmount, booking.paymentCurrency)} - - + + + + {new Date(booking.createdAt).toLocaleDateString()} - - - ))} - -
- )} -
-
+ + + + ))} + + + )} + ); } diff --git a/apps/edr-freight-web/backoffice/src/components/overview/OverviewTabContent.tsx b/apps/edr-freight-web/backoffice/src/components/overview/OverviewTabContent.tsx deleted file mode 100644 index cf71198a6..000000000 --- a/apps/edr-freight-web/backoffice/src/components/overview/OverviewTabContent.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import { AlertCircle } from "lucide-react"; -import { Alert, Button, Center, Loader, Paper, Skeleton, Stack } from "@mantine/core"; - -import { - useOverviewBillingTab, - useOverviewBookingsTab, - useOverviewContractsTab, - useOverviewCustomersTab, - useOverviewOperationsTab, - useOverviewStaffTab, -} from "@/hooks/useOverview"; -import type { OverviewRange, OverviewTabKey } from "@/types/overview"; -import { OverviewBillingTabPanel } from "./tabs/OverviewBillingTabPanel"; -import { OverviewBookingsTabPanel } from "./tabs/OverviewBookingsTabPanel"; -import { OverviewContractsTabPanel } from "./tabs/OverviewContractsTabPanel"; -import { OverviewCustomersTabPanel } from "./tabs/OverviewCustomersTabPanel"; -import { OverviewFleetTabPanel } from "./tabs/OverviewFleetTabPanel"; -import { OverviewOperationsTabPanel } from "./tabs/OverviewOperationsTabPanel"; -import { OverviewStaffTabPanel } from "./tabs/OverviewStaffTabPanel"; - -function TabSkeleton() { - return ( - - - - - - ); -} - -interface OverviewTabContentProps { - tab: OverviewTabKey; - range: OverviewRange; -} - -export function OverviewTabContent({ tab, range }: OverviewTabContentProps) { - const bookings = useOverviewBookingsTab(range, tab === "bookings"); - const contracts = useOverviewContractsTab(range, tab === "contracts"); - const billing = useOverviewBillingTab(range, tab === "billing"); - // Fleet reuses the operations dataset — same query key, so switching between - // the two tabs costs one fetch. - const operations = useOverviewOperationsTab( - range, - tab === "operations" || tab === "fleet", - ); - const customers = useOverviewCustomersTab(range, tab === "customers"); - const staff = useOverviewStaffTab(range, tab === "staff"); - - const query = - tab === "bookings" - ? bookings - : tab === "contracts" - ? contracts - : tab === "billing" - ? billing - : tab === "operations" || tab === "fleet" - ? operations - : tab === "customers" - ? customers - : staff; - - const { isLoading, isError, refetch, isFetching } = query; - - if (isLoading) { - return ; - } - - if (isError || !query.data) { - return ( - - } - color="red" - title="Failed to load tab data" - variant="light" - > - - Could not load {tab} metrics. Please try again. - - - - - ); - } - - return ( - - {isFetching && ( -
- -
- )} - - {tab === "bookings" && bookings.data && ( - - )} - {tab === "contracts" && contracts.data && ( - - )} - {tab === "billing" && billing.data && ( - - )} - {tab === "operations" && operations.data && ( - - )} - {tab === "fleet" && operations.data && ( - - )} - {tab === "customers" && customers.data && ( - - )} - {tab === "staff" && staff.data && ( - - )} -
- ); -} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/layouts/ClearanceOverview.tsx b/apps/edr-freight-web/backoffice/src/components/overview/layouts/ClearanceOverview.tsx new file mode 100644 index 000000000..1484cb5e9 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/layouts/ClearanceOverview.tsx @@ -0,0 +1,154 @@ +import { Grid, Stack } from "@mantine/core"; + +import { OverviewDonutChart } from "@/components/overview/OverviewDonutChart"; +import { OverviewHorizontalBarChart } from "@/components/overview/OverviewHorizontalBarChart"; +import { OverviewStackedBarChart } from "@/components/overview/OverviewStackedBarChart"; +import { OverviewAttentionCard } from "@/components/overview/summary/OverviewAttentionCard"; +import { OverviewPipelineFunnel } from "@/components/overview/summary/OverviewPipelineFunnel"; +import { + useOverviewClearanceTab, + useOverviewOperationsTab, +} from "@/hooks/useOverview"; +import { + AsyncBand, + Band, + DIRECTION_SERIES, + formatDayLabel, + labelsToDonut, + pivotMatrix, + toDonut, + type RoleOverviewProps, +} from "./layout-kit"; + +/** + * The GL desks (Ethiopia / Djibouti) work cargo through clearance: containers + * and cargo state first, the trains carrying them second, and the bookings + * waiting on a human third. Clearance-document counts are not aggregated by + * the overview API yet, so this stops at cargo state. + */ +export function ClearanceOverview({ data, range }: RoleOverviewProps) { + const ops = useOverviewOperationsTab(range, true); + const clearance = useOverviewClearanceTab(true); + const handovers = pivotMatrix(clearance.data?.handoversByMile); + + return ( + + {/* Work queue first: it is what a GL desk acts on, and it is the band + that always has rows even when no cargo is in the yard. */} + + + + + + + + + + + + + {(tab) => ( + + {/* Donuts stay at lg 4 — their legends ellipsise below ~300px. */} + + + + + + + + + + + + + + )} + + + + {(tab) => ( + + + + + + + + + + + + ({ + label: item.label, + value: item.tons, + }))} + valueLabel="Tons" + emptyMessage="No cargo recorded" + /> + + + )} + + + + {(tab) => ( + + + + + + + + + )} + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/layouts/ExecutiveOverview.tsx b/apps/edr-freight-web/backoffice/src/components/overview/layouts/ExecutiveOverview.tsx new file mode 100644 index 000000000..6b6e12fa2 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/layouts/ExecutiveOverview.tsx @@ -0,0 +1,76 @@ +import { Grid, Stack } from "@mantine/core"; + +import { OverviewActivityHeatmap } from "@/components/overview/summary/OverviewActivityHeatmap"; +import { OverviewAttentionCard } from "@/components/overview/summary/OverviewAttentionCard"; +import { OverviewNetworkCard } from "@/components/overview/summary/OverviewNetworkCard"; +import { OverviewPipelineFunnel } from "@/components/overview/summary/OverviewPipelineFunnel"; +import { OverviewRevenueMix } from "@/components/overview/summary/OverviewRevenueMix"; +import { OverviewRevenueVolumeChart } from "@/components/overview/summary/OverviewRevenueVolumeChart"; +import { OverviewSankeyFlow } from "@/components/overview/summary/OverviewSankeyFlow"; +import { Band, type RoleOverviewProps } from "./layout-kit"; + +const RANGE_DAYS: Record = { "7d": 7, "30d": 30, "90d": 90 }; + +/** + * The default layout — money, attention, network, pipeline. Kept for the CEO, + * director and org-manager roles, and for anyone whose role has no dedicated + * dashboard (superadmin, IAM admins). + */ +export function ExecutiveOverview({ data, range }: RoleOverviewProps) { + return ( + + {/* Band 1 — revenue & volume: growing, making money, pacing vs last period. */} + + + + + + + + + + + + {/* Band 2 — where the money runs, and what's waiting on someone. */} + + + + + + + + + + + + {/* Band 3 — the network now, and when demand arrives. */} + + + + + + + + + + + + {/* Band 4 — the booking pipeline, full width so every stage bar has room. */} + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/layouts/FinanceOverview.tsx b/apps/edr-freight-web/backoffice/src/components/overview/layouts/FinanceOverview.tsx new file mode 100644 index 000000000..78205d29b --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/layouts/FinanceOverview.tsx @@ -0,0 +1,58 @@ +import { Grid, Stack } from "@mantine/core"; + +import { OverviewBillingTabPanel } from "@/components/overview/tabs/OverviewBillingTabPanel"; +import { OverviewAttentionCard } from "@/components/overview/summary/OverviewAttentionCard"; +import { OverviewRevenueMix } from "@/components/overview/summary/OverviewRevenueMix"; +import { OverviewRevenueVolumeChart } from "@/components/overview/summary/OverviewRevenueVolumeChart"; +import { OverviewSankeyFlow } from "@/components/overview/summary/OverviewSankeyFlow"; +import { useOverviewBillingTab } from "@/hooks/useOverview"; +import { AsyncBand, Band, type RoleOverviewProps } from "./layout-kit"; + +const RANGE_DAYS: Record = { "7d": 7, "30d": 30, "90d": 90 }; + +/** Money only: what was earned, how it was collected, and what is still owed. */ +export function FinanceOverview({ data, range }: RoleOverviewProps) { + const billing = useOverviewBillingTab(range, true); + + return ( + + + + + + + + + + + + + + {(tab) => } + + + + + + + + + + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/layouts/MarketingOverview.tsx b/apps/edr-freight-web/backoffice/src/components/overview/layouts/MarketingOverview.tsx new file mode 100644 index 000000000..892f351e6 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/layouts/MarketingOverview.tsx @@ -0,0 +1,154 @@ +import { Grid, Stack } from "@mantine/core"; + +import { OverviewDonutChart } from "@/components/overview/OverviewDonutChart"; +import { OverviewHorizontalBarChart } from "@/components/overview/OverviewHorizontalBarChart"; +import { OverviewStackedBarChart } from "@/components/overview/OverviewStackedBarChart"; +import { OverviewCustomersTabPanel } from "@/components/overview/tabs/OverviewCustomersTabPanel"; +import { OverviewPipelineFunnel } from "@/components/overview/summary/OverviewPipelineFunnel"; +import { OverviewRevenueMix } from "@/components/overview/summary/OverviewRevenueMix"; +import { + useOverviewClearanceTab, + useOverviewContractsTab, + useOverviewCustomersTab, + useOverviewOperationsTab, +} from "@/hooks/useOverview"; +import { humanize } from "@/lib/format"; +import { + AsyncBand, + Band, + DIRECTION_SERIES, + enumLabelsToBars, + enumLabelsToDonut, + formatDayLabel, + pivotMatrix, + toDonut, + type RoleOverviewProps, +} from "./layout-kit"; + +/** + * Customer-facing view: who is buying, what they booked, what they signed and + * what it earned. Customs-declaration document counts and the Djibouti invoice + * queue are part of the marketing brief but have no overview aggregation yet. + */ +export function MarketingOverview({ data, range }: RoleOverviewProps) { + const customers = useOverviewCustomersTab(range, true); + const contracts = useOverviewContractsTab(range, true); + const ops = useOverviewOperationsTab(range, true); + const clearance = useOverviewClearanceTab(true); + const profilesByType = pivotMatrix(customers.data?.profilesByTypeStatus); + + return ( + + + {(tab) => ( + + + {/* Statuses live on the profile, not the company — so active vs + pending vs suspended is only meaningful per trade role. */} + + + )} + + + + + + + + + + + + + + + {(tab) => ( + + + + + + + + + + + + )} + + + + {(tab) => ( + + + + + + + + + + + + )} + + + + + + + + + {ops.data ? ( + + ) : null} + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/layouts/OccOverview.tsx b/apps/edr-freight-web/backoffice/src/components/overview/layouts/OccOverview.tsx new file mode 100644 index 000000000..f30468c48 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/layouts/OccOverview.tsx @@ -0,0 +1,181 @@ +import { CalendarClock, Send, Timer, Train, Truck } from "lucide-react"; +import { Grid, Stack } from "@mantine/core"; + +import { OverviewDonutChart } from "@/components/overview/OverviewDonutChart"; +import { OverviewHorizontalBarChart } from "@/components/overview/OverviewHorizontalBarChart"; +import { OverviewKpiStrip } from "@/components/overview/OverviewKpiStrip"; +import { OverviewStackedBarChart } from "@/components/overview/OverviewStackedBarChart"; +import { useOverviewFleetTab, useOverviewOperationsTab } from "@/hooks/useOverview"; +import { + AsyncBand, + DIRECTION_SERIES, + formatDayLabel, + labelsToBars, + pivotMatrix, + sumCounts, + toDonut, + type RoleOverviewProps, +} from "./layout-kit"; + +/** + * Operation control centre view: what rolling stock sits where, and what is + * moving today. Locomotive availability per station and train turn-around are + * part of the OCC brief but have no overview aggregation yet — they are absent + * rather than approximated. + */ +export function OccOverview({ range }: RoleOverviewProps) { + const ops = useOverviewOperationsTab(range, true); + const fleet = useOverviewFleetTab(range, true); + const wagonsByYard = pivotMatrix(fleet.data?.wagonStatusByYard); + const locomotivesByYard = pivotMatrix(fleet.data?.locomotivesByYard); + + return ( + + + {(tab) => ( + + + {/* Yard and type are long-tailed lists — bars read better than + donuts, and the donuts stay at lg 4 so their legends fit. */} + + + {/* Per-yard status, not a plain per-yard count: the OCC needs to + know which of the wagons standing at a yard can actually run. */} + + + + + + + + + + + + + + + + + )} + + + + {(tab) => ( + + + + + + + + + ({ + label: row.trainSet, + value: row.hours, + }))} + valueLabel="Hours" + emptyMessage="No train set has two recorded departures in this period" + /> + + + )} + + + + {(tab) => ( + + + + + + + + + )} + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/layouts/OperationsOverview.tsx b/apps/edr-freight-web/backoffice/src/components/overview/layouts/OperationsOverview.tsx new file mode 100644 index 000000000..544def510 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/layouts/OperationsOverview.tsx @@ -0,0 +1,181 @@ +import { CircleCheck, Link2, OctagonAlert, Truck, Wrench } from "lucide-react"; +import { Grid, Stack } from "@mantine/core"; + +import { OverviewDonutChart } from "@/components/overview/OverviewDonutChart"; +import { OverviewHorizontalBarChart } from "@/components/overview/OverviewHorizontalBarChart"; +import { OverviewKpiStrip } from "@/components/overview/OverviewKpiStrip"; +import { OverviewStackedBarChart } from "@/components/overview/OverviewStackedBarChart"; +import { OverviewPipelineFunnel } from "@/components/overview/summary/OverviewPipelineFunnel"; +import { useOverviewFleetTab, useOverviewOperationsTab } from "@/hooks/useOverview"; +import { TrainLoadCard } from "./TrainLoadCard"; +import { + AsyncBand, + Band, + DIRECTION_SERIES, + countByStatus, + formatDayLabel, + labelsToBars, + labelsToDonut, + pivotMatrix, + sumCounts, + toDonut, + type RoleOverviewProps, +} from "./layout-kit"; + +/** + * Wagon fleet, booking demand and freight moved — the operations officer's + * three questions. Wagon counts come from the status breakdown rather than the + * headline KPI so every lifecycle state (including detained / out of service) + * is accounted for against the same total. + */ +export function OperationsOverview({ data, range }: RoleOverviewProps) { + const ops = useOverviewOperationsTab(range, true); + const fleet = useOverviewFleetTab(range, true); + const statusByType = pivotMatrix(fleet.data?.wagonStatusByType); + const bookingsByPort = pivotMatrix(ops.data?.bookingStatusByPort); + + return ( + + + {(tab) => { + const wagons = tab.wagonStatusBreakdown ?? []; + const total = sumCounts(wagons); + const available = countByStatus(wagons, "AVAILABLE", "IMPORT_READY", "EXPORT_READY"); + const assigned = countByStatus(wagons, "ASSIGNED"); + + return ( + + {/* Five cells fit a 1440px screen only without hints — the + status donut below carries the same detail anyway. */} + + + + + + + {/* Status per type answers "how many flat wagons can I use + today", which the plain per-type count could not. */} + + + + + + + + ); + }} + + + + + + + + + + + + + + + {(tab) => ( + + + + + + + + + ({ + label: item.label, + value: item.tons, + }))} + valueLabel="Tons" + emptyMessage="No cargo recorded" + /> + + + + + + )} + + + + {(tab) => ( + + + + + + + + + )} + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/layouts/TrainLoadCard.tsx b/apps/edr-freight-web/backoffice/src/components/overview/layouts/TrainLoadCard.tsx new file mode 100644 index 000000000..334b59624 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/layouts/TrainLoadCard.tsx @@ -0,0 +1,74 @@ +import { TrainFront } from "lucide-react"; +import { Badge, Group, Progress, Stack, Text } from "@mantine/core"; + +import { humanize } from "@/lib/format"; +import type { IOverviewTrainLoad } from "@/types/overview"; +import { SummaryCard } from "@/components/overview/summary/SummaryCard"; + +const DIRECTION_COLOR: Record = { + IMPORT: "blue", + EXPORT: "yellow", + DOMESTIC: "violet", +}; + +/** + * Wagon fill per scheduled train: the bar is allocated slots against the train + * set's own wagon count, so a short train at 100% reads as full rather than as + * a small number next to a big one. + */ +export function TrainLoadCard({ loads = [] }: { loads: IOverviewTrainLoad[] }) { + return ( + + {loads.length === 0 ? ( + + No scheduled trains + + ) : ( + + {loads.map((load) => { + const percent = load.wagonsTotal + ? Math.round((load.wagonsAllocated / load.wagonsTotal) * 100) + : 0; + return ( + + + + + {load.trainNumber} + + + {humanize(load.direction)} + + + {load.date} + + + + {load.wagonsAllocated}/{load.wagonsTotal} wagons ·{" "} + {Math.round(load.tons).toLocaleString()} t + + + = 90 ? "edr-green" : percent > 0 ? "yellow" : "gray"} + size="sm" + radius="xl" + /> + + ); + })} + + )} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/layouts/layout-kit.tsx b/apps/edr-freight-web/backoffice/src/components/overview/layouts/layout-kit.tsx new file mode 100644 index 000000000..421284689 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/layouts/layout-kit.tsx @@ -0,0 +1,164 @@ +import type { ReactNode } from "react"; +import { AlertCircle } from "lucide-react"; +import { Alert, Skeleton, Stack, Text } from "@mantine/core"; + +import { humanize } from "@/lib/format"; +import { overviewChartColors } from "@/components/overview/overview.styles"; +import type { + IOverviewDashboard, + IOverviewLabelCount, + IOverviewMatrixCell, + IOverviewStatusCount, + OverviewRange, +} from "@/types/overview"; + +/** Every role layout takes the same summary payload + the selected range. */ +export interface RoleOverviewProps { + data: IOverviewDashboard; + range: OverviewRange; +} + +/** Uppercase section eyebrow — matches the WarehouseDashboardPage convention. */ +export function SectionTitle({ children }: { children: string }) { + return ( + + {children} + + ); +} + +/** One page band: eyebrow + content, with a staggered entrance by index. */ +export function Band({ + index, + title, + children, +}: { + index: number; + title: string; + children: ReactNode; +}) { + return ( + + {title} + {children} + + ); +} + +/** Minimal shape of the react-query result a band consumes. */ +interface BandQuery { + data?: T; + isLoading: boolean; + isError: boolean; +} + +/** + * A band fed by one of the per-domain overview endpoints. Loading shows a + * skeleton, a failure degrades to an inline notice — a role dashboard stitches + * several endpoints together and one 403 (a role without that domain's + * permission) must not blank the whole page. + */ +export function AsyncBand({ + index, + title, + query, + children, +}: { + index: number; + title: string; + query: BandQuery; + children: (data: T) => ReactNode; +}) { + return ( + + {query.isLoading ? ( + + ) : query.isError || !query.data ? ( + } + color="gray" + variant="light" + title={`${title} unavailable`} + > + This section could not be loaded for your account. + + ) : ( + children(query.data) + )} + + ); +} + +/** Fixed direction colors (CVD-validated pair + violet): color follows the entity. */ +export const DIRECTION_SERIES = [ + { key: "exportCount", label: "Export", color: "#D98A0B" }, + { key: "importCount", label: "Import", color: "#0369a1" }, + { key: "domesticCount", label: "Domestic", color: "#7c3aed" }, +]; + +export function formatDayLabel(date: string) { + return new Date(`${date}T00:00:00`).toLocaleDateString(undefined, { + month: "short", + day: "numeric", + }); +} + +export function sumCounts(items: IOverviewStatusCount[] = []) { + return items.reduce((sum, item) => sum + item.count, 0); +} + +export function countByStatus(items: IOverviewStatusCount[] = [], ...statuses: string[]) { + return items + .filter((item) => statuses.includes(item.status)) + .reduce((sum, item) => sum + item.count, 0); +} + +/** Status breakdown → donut slices; statuses are enum keys, so humanize them. */ +export function toDonut(items: IOverviewStatusCount[] = []) { + return items.map((item) => ({ name: humanize(item.status), value: item.count })); +} + +/** Label breakdown → donut slices. Labels are names (yards, sizes) — left verbatim. */ +export function labelsToDonut(items: IOverviewLabelCount[] = []) { + return items.map((item) => ({ name: item.label, value: item.count })); +} + +export function labelsToBars(items: IOverviewLabelCount[] = []) { + return items.map((item) => ({ label: item.label, value: item.count })); +} + +/** + * Matrix cells → the row/series shape OverviewStackedBarChart wants: one row + * per `group`, one series per distinct `series` value, colors fixed by the + * series' position so a status keeps its color across charts. + */ +export function pivotMatrix(cells: IOverviewMatrixCell[] = []) { + const seriesKeys = [...new Set(cells.map((cell) => cell.series))].sort(); + const groups = [...new Set(cells.map((cell) => cell.group))]; + + const rows = groups.map((group) => { + const row: Record = { group }; + for (const key of seriesKeys) row[key] = 0; + for (const cell of cells) { + if (cell.group === group) row[cell.series] = cell.count; + } + return row; + }); + + const series = seriesKeys.map((key, index) => ({ + key, + label: humanize(key), + color: overviewChartColors.pipeline[index % overviewChartColors.pipeline.length], + })); + + return { rows, series }; +} + +/** Same, for breakdowns whose labels are enum values (ONE_TIME, BULK, …). */ +export function enumLabelsToDonut(items: IOverviewLabelCount[] = []) { + return items.map((item) => ({ name: humanize(item.label), value: item.count })); +} + +export function enumLabelsToBars(items: IOverviewLabelCount[] = []) { + return items.map((item) => ({ label: humanize(item.label), value: item.count })); +} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/overview-domains.config.ts b/apps/edr-freight-web/backoffice/src/components/overview/overview-domains.config.ts new file mode 100644 index 000000000..4d106eeeb --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/overview-domains.config.ts @@ -0,0 +1,88 @@ +import { + Banknote, + FileSignature, + FileText, + Train, + TrainFront, + UserCheck, + Users, + type LucideIcon, +} from "lucide-react"; + +import { FREIGHT_PERMS } from "@/lib/permissions"; +import type { OverviewTabKey } from "@/types/overview"; + +/** + * Single source of truth for the seven overview drill-down pages — used both + * to build the `/dashboard/overview/:domain` routes in App.tsx and to render + * each page's header in OverviewDomainPage. One list, no duplicated permission + * arrays to drift out of sync. + */ +export const OVERVIEW_DOMAINS: Array<{ + key: OverviewTabKey; + label: string; + subtitle: string; + icon: LucideIcon; + /** Any of these keys grants the page. */ + permission: string[]; +}> = [ + { + key: "bookings", + label: "Bookings", + subtitle: "Booking volume, pipeline, and recent activity", + icon: FileText, + permission: [FREIGHT_PERMS.bookings.view], + }, + { + key: "contracts", + label: "Contracts", + subtitle: "Contract volume, pipeline, and recent activity", + icon: FileSignature, + permission: [FREIGHT_PERMS.contracts.view], + }, + { + key: "billing", + label: "Billing", + subtitle: "Revenue, payments, and collection status", + icon: Banknote, + permission: [FREIGHT_PERMS.bookings.view, FREIGHT_PERMS.payments.view], + }, + { + key: "operations", + label: "Operations", + subtitle: "Trains, schedules, containers, and cargo", + icon: Train, + permission: [ + FREIGHT_PERMS.trainScheduling.view, + FREIGHT_PERMS.warehouseInventory.view, + FREIGHT_PERMS.firstMile.view, + FREIGHT_PERMS.lastMile.view, + ], + }, + { + key: "fleet", + label: "Fleet", + subtitle: "Wagon and train fleet status", + icon: TrainFront, + permission: [FREIGHT_PERMS.wagons.view, FREIGHT_PERMS.trainScheduling.view], + }, + { + key: "customers", + label: "Customers", + subtitle: "Customer growth and top accounts", + icon: Users, + permission: [FREIGHT_PERMS.customers.view], + }, + { + key: "staff", + label: "Staff", + subtitle: "Employee and user account status", + icon: UserCheck, + permission: [ + FREIGHT_PERMS.admin, + FREIGHT_PERMS.staff.roles.view, + FREIGHT_PERMS.staff.employeeRegistration.view, + FREIGHT_PERMS.staff.roleAssignment.view, + ], + }, +]; diff --git a/apps/edr-freight-web/backoffice/src/components/overview/overview.css b/apps/edr-freight-web/backoffice/src/components/overview/overview.css index 86b468df3..10b358bbd 100644 --- a/apps/edr-freight-web/backoffice/src/components/overview/overview.css +++ b/apps/edr-freight-web/backoffice/src/components/overview/overview.css @@ -1,5 +1,8 @@ /* ============================================================ - EDR Freight — Overview page styles (hero controls + tabs) + EDR Freight — shared "premium" tab bar + segmented control styles. + Named after the overview page they were first built for, but now shared + by BookingStatusTabs, ContractStatusTabs, and ReceiveInventoryModal — + do not remove `.ov-tablist` / `.ov-tab` without checking those importers. ============================================================ */ /* ---- Hero range segmented control (on gradient) ---- */ @@ -19,7 +22,7 @@ color: var(--mantine-color-edr-green-7); } -/* ---- Premium tab bar ---- */ +/* ---- Premium tab bar (BookingStatusTabs, ContractStatusTabs, ReceiveInventoryModal) ---- */ .ov-tablist { display: flex; flex-wrap: wrap; diff --git a/apps/edr-freight-web/backoffice/src/components/overview/role-dashboards.config.ts b/apps/edr-freight-web/backoffice/src/components/overview/role-dashboards.config.ts new file mode 100644 index 000000000..509f17a3c --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/role-dashboards.config.ts @@ -0,0 +1,50 @@ +import type { AuthUser } from "@/auth/types"; +import { getPositionKeys } from "@/lib/permissions"; + +/** One overview composition. Every backoffice user lands on exactly one of these. */ +export type OverviewLayoutKey = + | "executive" + | "operations" + | "occ" + | "marketing" + | "finance" + | "clearance"; + +export const OVERVIEW_LAYOUT_LABEL: Record = { + executive: "Executive dashboard", + operations: "Operations dashboard", + occ: "Control centre dashboard", + marketing: "Marketing dashboard", + finance: "Finance dashboard", + clearance: "Clearance & logistics dashboard", +}; + +/** + * Role/position key → layout, in match priority order: a user holding several + * of these keys gets the first match, so the specific operational view wins + * over the broad executive one. Position keys are matched too because the IAM + * payload models the GL desks as positions (`ethiopian_gl`) on some accounts + * and as roles (`edr_gl_ethiopia`) on others — see `getPositionKeys`. + */ +const ROLE_LAYOUTS: Array<[key: string, layout: OverviewLayoutKey]> = [ + ["edr_operations_officer", "operations"], + ["truck_machinery_chief", "operations"], + ["edr_line_staff", "occ"], + ["edr_gl_ethiopia", "clearance"], + ["edr_gl_djibouti", "clearance"], + ["ethiopian_gl", "clearance"], + ["djibouti_gl", "clearance"], + ["edr_marketing", "marketing"], + ["edr_finance", "finance"], + ["edr_director", "executive"], + ["edr_ceo", "executive"], + ["edr_org_manager", "executive"], +]; + +/** Unmapped roles (superadmin, IAM admins, new roles) keep the executive layout. */ +export function resolveOverviewLayout( + user: AuthUser | null | undefined, +): OverviewLayoutKey { + const held = new Set(getPositionKeys(user)); + return ROLE_LAYOUTS.find(([key]) => held.has(key))?.[1] ?? "executive"; +} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/summary/CountUp.tsx b/apps/edr-freight-web/backoffice/src/components/overview/summary/CountUp.tsx new file mode 100644 index 000000000..28802e321 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/summary/CountUp.tsx @@ -0,0 +1,44 @@ +import { useEffect, useRef, useState } from "react"; + +const DURATION_MS = 750; + +function prefersReducedMotion() { + return ( + typeof window !== "undefined" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches + ); +} + +/** + * Animates a number from 0 to `value` (ease-out) on mount and whenever the + * value changes — the hero-KPI count-up. Renders the final value immediately + * when the user prefers reduced motion. + */ +export function CountUp({ + value, + format = (n) => Math.round(n).toLocaleString(), +}: { + value: number; + format?: (n: number) => string; +}) { + const [display, setDisplay] = useState(() => (prefersReducedMotion() ? value : 0)); + const frame = useRef(0); + + useEffect(() => { + if (prefersReducedMotion()) { + setDisplay(value); + return; + } + const start = performance.now(); + const tick = (now: number) => { + const t = Math.min(1, (now - start) / DURATION_MS); + const eased = 1 - (1 - t) ** 3; + setDisplay(value * eased); + if (t < 1) frame.current = requestAnimationFrame(tick); + }; + frame.current = requestAnimationFrame(tick); + return () => cancelAnimationFrame(frame.current); + }, [value]); + + return <>{format(display)}; +} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewActivityHeatmap.tsx b/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewActivityHeatmap.tsx new file mode 100644 index 000000000..7524c1f20 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewActivityHeatmap.tsx @@ -0,0 +1,125 @@ +import { Fragment } from "react"; +import { CalendarClock } from "lucide-react"; +import { Badge, Group, Stack, Text, Tooltip } from "@mantine/core"; + +import type { IOverviewHeatmapCell } from "@/types/overview"; +import { SummaryCard } from "./SummaryCard"; + +/** ISO weekday order, 1 = Monday. */ +const DAY_LABELS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; +/** 3-hour blocks, 0 = 00–03 … 7 = 21–24. */ +const BLOCK_LABELS = ["12a", "3a", "6a", "9a", "12p", "3p", "6p", "9p"]; + +/** Map an intensity in (0, 1] to the brand-green ramp; zero stays neutral. */ +function cellColor(count: number, max: number) { + if (count === 0) return "var(--mantine-color-gray-1)"; + const shade = Math.max(1, Math.min(7, Math.ceil((count / max) * 7))); + return `var(--mantine-color-edr-green-${shade})`; +} + +interface OverviewActivityHeatmapProps { + cells: IOverviewHeatmapCell[]; +} + +/** + * When demand arrives: booking submissions by weekday × 3-hour block for the + * selected range. The bright cells (and the peak badge) are the hours the + * intake team needs to be staffed for. + */ +export function OverviewActivityHeatmap({ cells = [] }: OverviewActivityHeatmapProps) { + const countByCell = new Map(cells.map((c) => [`${c.dow}-${c.block}`, c.count])); + const max = Math.max(0, ...cells.map((c) => c.count)); + const peak = cells.reduce( + (best, c) => (c.count > (best?.count ?? 0) ? c : best), + null, + ); + + return ( + + Peak {DAY_LABELS[peak.dow - 1]} {BLOCK_LABELS[peak.block]} + + ) : null + } + > + {max === 0 ? ( + + No bookings in this period + + ) : ( + +
+ + {BLOCK_LABELS.map((label) => ( + + {label} + + ))} + {DAY_LABELS.map((day, dayIndex) => ( + + + {day} + + {BLOCK_LABELS.map((_, block) => { + const count = countByCell.get(`${dayIndex + 1}-${block}`) ?? 0; + return ( + +
+ + ); + })} + + ))} +
+ + + Less + + {[0, 2, 4, 6].map((shade) => ( +
+ ))} + + More + + + + )} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewAttentionCard.tsx b/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewAttentionCard.tsx new file mode 100644 index 000000000..c36db5e57 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewAttentionCard.tsx @@ -0,0 +1,149 @@ +import { + AlertCircle, + Banknote, + BellRing, + Check, + ChevronRight, + Clock, + FileSignature, + ShieldCheck, +} from "lucide-react"; +import { Link } from "react-router-dom"; +import { Badge, Group, Stack, Text, ThemeIcon } from "@mantine/core"; + +import type { IOverviewBillingKpis, IOverviewBookingKpis, IOverviewContractKpis } from "@/types/overview"; +import { SummaryCard } from "./SummaryCard"; + +interface OverviewAttentionCardProps { + bookings: IOverviewBookingKpis; + contracts: IOverviewContractKpis; + billing: IOverviewBillingKpis; +} + +/** + * The work queue, demoted below the money/volume story but still one glance + * away — this page is read by executives first, staff second. Every row + * links to the real filtered (or closest available) list; none are dead ends. + */ +export function OverviewAttentionCard({ bookings, contracts, billing }: OverviewAttentionCardProps) { + const rows = [ + { + key: "needsAction", + label: "Bookings needing action", + count: bookings.needsAction ?? 0, + icon: AlertCircle, + href: "/dashboard/booking-requests", + }, + { + key: "urgent", + label: "Urgent bookings", + count: bookings.urgent ?? 0, + icon: Clock, + href: "/dashboard/booking-requests", + }, + { + key: "contractsApproval", + label: "Contracts in approval", + count: contracts.inApproval ?? 0, + icon: FileSignature, + href: "/dashboard/contract-requests", + }, + { + key: "contractsClearance", + label: "Contracts in clearance", + count: contracts.inClearance ?? 0, + icon: ShieldCheck, + href: "/dashboard/contracts/clearance", + }, + { + key: "pendingPayments", + label: "Pending payments", + count: billing.pendingPayments ?? 0, + icon: Banknote, + href: "/dashboard/payments", + }, + ]; + const openItems = rows.reduce((sum, row) => sum + row.count, 0); + const allClear = openItems === 0; + + return ( + + {openItems.toLocaleString()} open + + ) + } + > + {allClear ? ( + + + + + All clear + + Nothing waiting on you right now. + + + ) : ( + + {rows.map((row) => { + const Icon = row.icon; + const active = row.count > 0; + return ( + + + + + + + + {row.label} + + + + + {row.count} + + + + + + ); + })} + + )} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewHero.tsx b/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewHero.tsx new file mode 100644 index 000000000..4e0fcdc10 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewHero.tsx @@ -0,0 +1,157 @@ +import { RefreshCw } from "lucide-react"; +import { ActionIcon, Badge, Group, SegmentedControl, Stack, Text } from "@mantine/core"; + +import { useAuth } from "@/auth/useAuth"; +import { formatDateTime } from "@/lib/format"; +import { freightBrand } from "@/theme/freight-brand"; +import type { OverviewRange } from "@/types/overview"; +import "@/components/overview/overview.css"; + +const RANGE_OPTIONS = [ + { label: "7 days", value: "7d" }, + { label: "30 days", value: "30d" }, + { label: "90 days", value: "90d" }, +]; + +function formatRelativeTime(iso: string | undefined) { + if (!iso) return "—"; + const diffMs = Date.now() - new Date(iso).getTime(); + const minutes = Math.floor(diffMs / 60_000); + if (minutes < 1) return "just now"; + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + return formatDateTime(iso); +} + +function greeting(hour: number) { + if (hour < 12) return "Good morning"; + if (hour < 18) return "Good afternoon"; + return "Good evening"; +} + +interface OverviewHeroProps { + range: OverviewRange; + onRangeChange: (range: OverviewRange) => void; + generatedAt?: string; + onRefresh: () => void; + isRefreshing?: boolean; + /** Name of the role-specific layout rendered below, e.g. "Operations dashboard". */ + label?: string; +} + +/** + * Brand-gradient greeting banner: time-of-day greeting with the signed-in + * user's first name, today's date, freshness badge, and the range controls. + * Extra bottom padding leaves room for the KPI strip to overlap it. + */ +export function OverviewHero({ + range, + onRangeChange, + generatedAt, + onRefresh, + isRefreshing, + label, +}: OverviewHeroProps) { + const { user } = useAuth(); + const fullName = (user?.name?.en ?? user?.name?.am)?.trim(); + const firstName = fullName ? fullName.split(/\s+/)[0] : undefined; + const now = new Date(); + const dateLabel = now.toLocaleDateString(undefined, { + weekday: "long", + day: "numeric", + month: "long", + year: "numeric", + }); + + return ( +
+ {/* Soft highlight so the flat gradient reads as a lit surface. */} +
+ + + + {greeting(now.getHours())} + {firstName ? `, ${firstName}` : ""} 👋 + + + + {dateLabel} + + {label ? ( + + {label} + + ) : null} + + Updated {formatRelativeTime(generatedAt)} + + + + + onRangeChange(value as OverviewRange)} + data={RANGE_OPTIONS} + size="sm" + radius="lg" + classNames={{ + root: "ov-seg-root", + indicator: "ov-seg-indicator", + label: "ov-seg-label", + }} + /> + + + + + +
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewHeroKpis.tsx b/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewHeroKpis.tsx new file mode 100644 index 000000000..e076b1f18 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewHeroKpis.tsx @@ -0,0 +1,83 @@ +import { Banknote, FileSignature, FileText, Package } from "lucide-react"; + +import { KpiStrip, type KpiItem } from "@/components/page"; +import type { IOverviewKpis, IOverviewPeriodTotals } from "@/types/overview"; +import { CountUp } from "./CountUp"; + +function formatCurrency(amount: number, currency: "ETB" | "USD") { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency, + maximumFractionDigits: 0, + }).format(amount); +} + +/** Compact form ("ETB 58.6M") — the hero cell is too narrow for nine digits. */ +function formatCompactCurrency(amount: number, currency: "ETB" | "USD") { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency, + notation: "compact", + maximumFractionDigits: 1, + }).format(amount); +} + +/** Period-over-period % change, or undefined when there's no prior baseline to compare against. */ +function pctDelta(current: number, previous: number): number | undefined { + if (previous === 0) return undefined; + return Math.round(((current - previous) / previous) * 100); +} + +interface OverviewHeroKpisProps { + kpis: IOverviewKpis; + current: IOverviewPeriodTotals; + previous: IOverviewPeriodTotals; + rangeLabel: string; +} + +/** + * The four numbers an executive reads first: money and volume for the + * selected range, plus what's currently in flight. Revenue and cargo carry a + * real vs-prior-period delta; the two workflow snapshots don't, because + * "active bookings/contracts" is a point-in-time gauge, not a period total — + * showing a delta for it would mean inventing a comparison that isn't real. + */ +export function OverviewHeroKpis({ kpis, current, previous, rangeLabel }: OverviewHeroKpisProps) { + const items: KpiItem[] = [ + // An API deployed before the overview revamp omits the period totals — + // drop the two tiles that need them rather than crash (or hide the strip). + ...(current + ? [ + { + label: `Revenue (${rangeLabel})`, + value: formatCompactCurrency(n, "ETB")} />, + hint: formatCurrency(current.revenueUsd, "USD"), + icon: Banknote, + color: "yellow", + delta: pctDelta(current.revenueEtb, previous?.revenueEtb ?? 0), + }, + { + label: "Cargo moved", + value: `${Math.round(n).toLocaleString()} t`} />, + icon: Package, + color: "edr-green", + delta: pctDelta(current.tons, previous?.tons ?? 0), + }, + ] + : []), + { + label: "Active bookings", + value: , + icon: FileText, + color: "edr-green", + }, + { + label: "Active contracts", + value: , + icon: FileSignature, + color: "edr-green", + }, + ]; + + return ; +} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewNetworkCard.tsx b/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewNetworkCard.tsx new file mode 100644 index 000000000..9c9bef563 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewNetworkCard.tsx @@ -0,0 +1,91 @@ +import { + CalendarClock, + Container as ContainerIcon, + Send, + Train, + TrainFront, +} from "lucide-react"; +import { Group, SimpleGrid, Stack, Text } from "@mantine/core"; + +import { MiniRing } from "@/components/common/MiniGraph"; +import type { IOverviewOperationsKpis } from "@/types/overview"; +import { CountUp } from "./CountUp"; +import { SummaryCard } from "./SummaryCard"; + +const STATS: Array<{ + key: keyof IOverviewOperationsKpis; + label: string; + icon: typeof Train; +}> = [ + { key: "trainsActive", label: "Trains active", icon: Train }, + { key: "dispatchedToday", label: "Dispatched today", icon: Send }, + { key: "schedulesUpcoming", label: "Upcoming departures", icon: CalendarClock }, + { key: "containersInTransit", label: "Containers in transit", icon: ContainerIcon }, +]; + +/** Network snapshot: four operational stats plus real wagon-utilization (available / total), not a decorative gauge. */ +export function OverviewNetworkCard({ kpis }: { kpis: IOverviewOperationsKpis }) { + // An API deployed before the overview revamp omits the wagon counters. + const wagonsTotal = kpis.wagonsTotal ?? 0; + const wagonsAvailable = kpis.wagonsAvailable ?? 0; + const utilizationPct = wagonsTotal > 0 ? (wagonsAvailable / wagonsTotal) * 100 : null; + + return ( + + View fleet → + + } + > + + + + + {utilizationPct != null ? `${Math.round(utilizationPct)}%` : "—"} + + + + + Wagons available + + + {wagonsAvailable.toLocaleString()} + + {" "} + / {wagonsTotal.toLocaleString()} + + + + + + + {STATS.map((stat) => { + const Icon = stat.icon; + return ( + + + + + + + + {stat.label} + + + + ); + })} + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewPipelineFunnel.tsx b/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewPipelineFunnel.tsx new file mode 100644 index 000000000..a1c18c281 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewPipelineFunnel.tsx @@ -0,0 +1,113 @@ +import { Filter } from "lucide-react"; +import { Link } from "react-router-dom"; +import { Badge, Stack, Text } from "@mantine/core"; + +import { BOOKING_LIST_TABS } from "@/features/bookings/booking-status.config"; +import type { IOverviewPipelineCount } from "@/types/overview"; +import { SummaryCard } from "./SummaryCard"; + +/** Sequential green ramp from the theme's own edr-green scale — rising intensity as bookings progress through the pipeline. */ +const RAMP_SHADES = [3, 4, 5, 5, 6, 6, 7, 8, 9]; + +interface OverviewPipelineFunnelProps { + data: IOverviewPipelineCount[]; +} + +/** Booking pipeline by stage, in workflow order. Each row deep-links to the exact statuses it represents. */ +export function OverviewPipelineFunnel({ data = [] }: OverviewPipelineFunnelProps) { + const rows = data + .map((item) => ({ + ...item, + tab: BOOKING_LIST_TABS.find((t) => t.key === item.stage), + })) + .filter((row) => row.tab); + const maxCount = Math.max(1, ...rows.map((r) => r.count)); + const total = rows.reduce((sum, r) => sum + r.count, 0); + const hasData = total > 0; + + return ( + + {total.toLocaleString()} in pipeline + + ) : null + } + > + {!hasData ? ( + + No bookings in pipeline + + ) : ( + + {rows.map((row, index) => { + const statuses = row.tab?.statuses; + const href = statuses?.length + ? `/dashboard/booking-requests?statuses=${statuses.join(",")}` + : "/dashboard/booking-requests"; + const shade = RAMP_SHADES[Math.min(index, RAMP_SHADES.length - 1)]; + + return ( + + + {row.tab?.label ?? row.stage} + +
+
+
+ + {row.count} + + + ); + })} + + )} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewRevenueMix.tsx b/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewRevenueMix.tsx new file mode 100644 index 000000000..0603ac095 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewRevenueMix.tsx @@ -0,0 +1,143 @@ +import { PieChart } from "lucide-react"; +import { Stack, Text } from "@mantine/core"; + +import type { IOverviewRevenueSlice } from "@/types/overview"; +import { DIRECTION_COLORS, FLOW_FALLBACK_COLOR, FREIGHT_TYPE_COLORS } from "./flow-colors"; +import { CountUp } from "./CountUp"; +import { SummaryCard } from "./SummaryCard"; + +function formatEtb(amount: number) { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "ETB", + maximumFractionDigits: 0, + }).format(amount); +} + +const DIRECTION_LABELS: Record = { + IMPORT: "Import", + EXPORT: "Export", + DOMESTIC: "Domestic", +}; + +const FREIGHT_TYPE_LABELS: Record = { + CONTAINER: "Container", + BULK: "Bulk", +}; + +/** One breakdown's proportion bar + legend, sized by ETB revenue (no FX rate exists to fold USD in). */ +function MixRow({ + title, + slices, + labels, + colors, +}: { + title: string; + slices: IOverviewRevenueSlice[]; + labels: Record; + colors: Record; +}) { + const total = slices.reduce((sum, s) => sum + s.amountEtb, 0); + + return ( + + + {title} + + {total === 0 ? ( + + No revenue in this period + + ) : ( + <> +
+ {slices + .filter((s) => s.amountEtb > 0) + .map((s) => ( +
+ ))} +
+ + {slices + .filter((s) => s.amountEtb > 0) + .map((s) => ( +
+ + + {labels[s.label] ?? s.label} + + {formatEtb(s.amountEtb)} + + {Math.round((s.amountEtb / total) * 100)}% + +
+ ))} +
+ + )} + + ); +} + +interface OverviewRevenueMixProps { + byDirection: IOverviewRevenueSlice[]; + byFreightType: IOverviewRevenueSlice[]; +} + +/** ETB revenue split two ways — trade direction and freight type — anchored by the range total. */ +export function OverviewRevenueMix({ byDirection = [], byFreightType = [] }: OverviewRevenueMixProps) { + const total = byDirection.reduce((sum, s) => sum + s.amountEtb, 0); + + return ( + + +
+ + + + + attributed to a trade direction + +
+ + +
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewRevenueVolumeChart.tsx b/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewRevenueVolumeChart.tsx new file mode 100644 index 000000000..04a359e92 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewRevenueVolumeChart.tsx @@ -0,0 +1,172 @@ +import { + Area, + Bar, + CartesianGrid, + ComposedChart, + Line, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import { ChartColumnBig } from "lucide-react"; +import { Group, Text } from "@mantine/core"; + +import type { IOverviewPaymentTrendPoint, IOverviewTrendPoint } from "@/types/overview"; +import { overviewChartColors } from "../overview.styles"; +import { chartAxisTick, chartGridStroke, chartTooltipStyle } from "./chart-style"; +import { mergeTrend } from "./mergeTrend"; +import { SummaryCard } from "./SummaryCard"; + +function formatDateLabel(date: string) { + return new Date(`${date}T00:00:00`).toLocaleDateString(undefined, { + month: "short", + day: "numeric", + }); +} + +function formatEtb(amount: number) { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "ETB", + maximumFractionDigits: 0, + }).format(amount); +} + +const compact = new Intl.NumberFormat("en-US", { notation: "compact" }); + +/** Dot-and-label legend row rendered in the card header instead of recharts' default. */ +function LegendDot({ color, dashed, label }: { color: string; dashed?: boolean; label: string }) { + return ( + + {dashed ? ( + + + + ) : ( + + )} + + {label} + + + ); +} + +interface OverviewRevenueVolumeChartProps { + bookingTrend: IOverviewTrendPoint[]; + paymentTrend: IOverviewPaymentTrendPoint[]; + previousPaymentTrend: IOverviewPaymentTrendPoint[]; + rangeDays: number; +} + +/** + * Volume and money in one read: bars are bookings created per day, the gold + * area is ETB revenue per day, and the dashed ghost line is the preceding + * period's revenue shifted onto the same axis — "are we pacing ahead of last + * period" at a glance. + */ +export function OverviewRevenueVolumeChart({ + bookingTrend = [], + paymentTrend = [], + previousPaymentTrend = [], + rangeDays, +}: OverviewRevenueVolumeChartProps) { + const data = mergeTrend(bookingTrend, paymentTrend, previousPaymentTrend, rangeDays); + const hasData = data.some((point) => point.bookings > 0 || point.revenueEtb > 0); + + return ( + + + + + + } + > + {!hasData ? ( + + No activity in this period + + ) : ( + + + + + + + + + + + + compact.format(Number(value))} + tick={chartAxisTick} + axisLine={false} + tickLine={false} + width={44} + /> + formatDateLabel(String(value))} + formatter={(value, name) => + name === "Bookings" ? [value, name] : [formatEtb(Number(value)), name] + } + contentStyle={chartTooltipStyle} + /> + + + + + + )} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewSankeyFlow.tsx b/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewSankeyFlow.tsx new file mode 100644 index 000000000..b7974ea48 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewSankeyFlow.tsx @@ -0,0 +1,170 @@ +import { Waypoints } from "lucide-react"; +import { ResponsiveContainer, Sankey, Tooltip, type SankeyLinkProps } from "recharts"; +import { Text } from "@mantine/core"; + +import type { IOverviewRevenueFlow } from "@/types/overview"; +import { chartTooltipStyle } from "./chart-style"; +import { SummaryCard } from "./SummaryCard"; +import { + DIRECTION_COLORS, + FLOW_FALLBACK_COLOR, + FLOW_LABELS, + FREIGHT_TYPE_COLORS, +} from "./flow-colors"; + +function formatEtb(amount: number) { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "ETB", + maximumFractionDigits: 0, + }).format(amount); +} + +interface SankeyNodeDatum { + name: string; + key: string; + color: string; +} + +/** Build recharts Sankey data: direction nodes on the left, freight types on the right. */ +function toSankeyData(flows: IOverviewRevenueFlow[]) { + const active = flows.filter((f) => f.amountEtb > 0); + const nodes: SankeyNodeDatum[] = []; + const indexByKey = new Map(); + const nodeIndex = (key: string, color: string) => { + const existing = indexByKey.get(key); + if (existing != null) return existing; + nodes.push({ name: FLOW_LABELS[key] ?? key, key, color }); + indexByKey.set(key, nodes.length - 1); + return nodes.length - 1; + }; + + // Register directions first so they all land on the left column. + for (const flow of active) { + nodeIndex(flow.direction, DIRECTION_COLORS[flow.direction] ?? FLOW_FALLBACK_COLOR); + } + const links = active.map((flow) => ({ + source: indexByKey.get(flow.direction)!, + target: nodeIndex( + flow.freightType, + FREIGHT_TYPE_COLORS[flow.freightType] ?? FLOW_FALLBACK_COLOR, + ), + value: flow.amountEtb, + })); + + return { nodes, links }; +} + +function FlowNode({ + x, + y, + width, + height, + index, + payload, +}: { + x: number; + y: number; + width: number; + height: number; + index: number; + payload: { name?: string; value?: number; color?: string }; +}) { + // Labels sit to the right of every bar: the right margin reserves room for + // the last column, and the pale ribbons stay readable under the left one. + return ( + + + + {payload.name} + + + {formatEtb(payload.value ?? 0)} + + + ); +} + +/** Ribbon tinted by its source direction — the corridor keeps its color across the chart. */ +function FlowLink({ + sourceX, + targetX, + sourceY, + targetY, + sourceControlX, + targetControlX, + linkWidth, + index, + payload, +}: SankeyLinkProps) { + // Custom node fields (color) ride along on the layout node recharts hands back. + const source = payload.source as { color?: string }; + return ( + + ); +} + +interface OverviewSankeyFlowProps { + flows: IOverviewRevenueFlow[]; +} + +/** + * Where the money runs: ETB revenue as ribbons from trade direction to + * freight type. Ribbon thickness is proportional to revenue, so the biggest + * corridor is unmissable. + */ +export function OverviewSankeyFlow({ flows = [] }: OverviewSankeyFlowProps) { + const data = toSankeyData(flows); + + return ( + + {data.links.length === 0 ? ( + + No revenue in this period + + ) : ( + + + formatEtb(Number(value))} + contentStyle={chartTooltipStyle} + /> + + + )} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/summary/SummaryCard.tsx b/apps/edr-freight-web/backoffice/src/components/overview/summary/SummaryCard.tsx new file mode 100644 index 000000000..cfd022fe9 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/summary/SummaryCard.tsx @@ -0,0 +1,65 @@ +import type { LucideIcon } from "lucide-react"; +import { Group, Stack, Text, ThemeIcon } from "@mantine/core"; +import type { ElementType, ReactNode } from "react"; +import { Link } from "react-router-dom"; + +import "./overview-summary.css"; + +interface SummaryCardProps { + icon: LucideIcon; + /** Mantine color for the icon chip. */ + accent?: string; + title: string; + subtitle?: string; + /** Right side of the header — a legend, badge, or link. */ + action?: ReactNode; + /** Makes the whole card a link (adds the hover lift). */ + to?: string; + minHeight?: number; + children: ReactNode; +} + +/** + * Shared chrome for every overview card: soft gradient surface, layered + * shadow, icon-chip header with title/subtitle, optional action slot. + * One look for the whole page instead of eight flat white boxes. + */ +export function SummaryCard({ + icon: Icon, + accent = "edr-green", + title, + subtitle, + action, + to, + minHeight = 340, + children, +}: SummaryCardProps) { + const Root: ElementType = to ? Link : "div"; + return ( + + + + + + + + + {title} + + {subtitle ? ( + + {subtitle} + + ) : null} + + + {action} + + {children} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/summary/chart-style.ts b/apps/edr-freight-web/backoffice/src/components/overview/summary/chart-style.ts new file mode 100644 index 000000000..9f7dbae8d --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/summary/chart-style.ts @@ -0,0 +1,14 @@ +import type { CSSProperties } from "react"; + +/** Shared recharts styling for the overview summary charts. */ +export const chartGridStroke = "#EEF1F5"; + +export const chartAxisTick = { fontSize: 11, fill: "#8fa0b2" } as const; + +export const chartTooltipStyle: CSSProperties = { + borderRadius: 12, + border: "1px solid #EEF1F5", + boxShadow: "0 8px 24px rgba(16, 32, 47, 0.1)", + fontSize: 12, + padding: "8px 12px", +}; diff --git a/apps/edr-freight-web/backoffice/src/components/overview/summary/flow-colors.ts b/apps/edr-freight-web/backoffice/src/components/overview/summary/flow-colors.ts new file mode 100644 index 000000000..f3c7a2ace --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/summary/flow-colors.ts @@ -0,0 +1,25 @@ +/** + * Fixed colors + labels for trade directions and freight types, shared by the + * revenue mix and Sankey so the same entity is always the same color (and + * matches the operations departure chart's direction hexes). + */ +export const DIRECTION_COLORS: Record = { + EXPORT: "#D98A0B", + IMPORT: "#0369a1", + DOMESTIC: "#7c3aed", +}; + +export const FREIGHT_TYPE_COLORS: Record = { + CONTAINER: "#1B9E7A", + BULK: "#34D9AE", +}; + +export const FLOW_LABELS: Record = { + IMPORT: "Import", + EXPORT: "Export", + DOMESTIC: "Domestic", + CONTAINER: "Container", + BULK: "Bulk", +}; + +export const FLOW_FALLBACK_COLOR = "#94a3b8"; diff --git a/apps/edr-freight-web/backoffice/src/components/overview/summary/mergeTrend.test.ts b/apps/edr-freight-web/backoffice/src/components/overview/summary/mergeTrend.test.ts new file mode 100644 index 000000000..369b19237 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/summary/mergeTrend.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; + +import { mergeTrend } from "./mergeTrend"; + +describe("mergeTrend", () => { + it("unions dates from both trends, zero-filling the side that has no row", () => { + const result = mergeTrend( + [ + { date: "2026-06-01", count: 3 }, + { date: "2026-06-02", count: 5 }, + ], + [{ date: "2026-06-02", amountEtb: 1000, amountUsd: 0 }], + ); + + expect(result).toEqual([ + { date: "2026-06-01", bookings: 3, revenueEtb: 0 }, + { date: "2026-06-02", bookings: 5, revenueEtb: 1000 }, + ]); + }); + + it("sorts chronologically regardless of input order", () => { + const result = mergeTrend( + [{ date: "2026-06-03", count: 1 }], + [{ date: "2026-06-01", amountEtb: 500, amountUsd: 0 }], + ); + + expect(result.map((p) => p.date)).toEqual(["2026-06-01", "2026-06-03"]); + }); + + it("returns an empty series when both trends are empty", () => { + expect(mergeTrend([], [])).toEqual([]); + }); + + it("shifts the previous period forward so day N lands on day N of the current window", () => { + const result = mergeTrend( + [{ date: "2026-06-08", count: 2 }], + [], + [ + // 7d range: 2026-06-01 + 7 = 2026-06-08 (existing row), 06-02 + 7 = 06-09 (new row) + { date: "2026-06-01", amountEtb: 400, amountUsd: 0 }, + { date: "2026-06-02", amountEtb: 250, amountUsd: 0 }, + ], + 7, + ); + + expect(result).toEqual([ + { date: "2026-06-08", bookings: 2, revenueEtb: 0, prevRevenueEtb: 400 }, + { date: "2026-06-09", bookings: 0, revenueEtb: 0, prevRevenueEtb: 250 }, + ]); + }); + + it("shifts across a month boundary without timezone drift", () => { + const result = mergeTrend([], [], [{ date: "2026-05-28", amountEtb: 100, amountUsd: 0 }], 7); + expect(result[0].date).toBe("2026-06-04"); + }); +}); diff --git a/apps/edr-freight-web/backoffice/src/components/overview/summary/mergeTrend.ts b/apps/edr-freight-web/backoffice/src/components/overview/summary/mergeTrend.ts new file mode 100644 index 000000000..0ddc0d705 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/summary/mergeTrend.ts @@ -0,0 +1,63 @@ +import type { IOverviewPaymentTrendPoint, IOverviewTrendPoint } from "@/types/overview"; + +export interface RevenueVolumePoint { + date: string; + bookings: number; + revenueEtb: number; + /** Same-offset day of the preceding period — the ghost comparison line. */ + prevRevenueEtb?: number; +} + +/** `date` (YYYY-MM-DD) plus `days` days, in UTC so no DST/timezone drift. */ +export function shiftDate(date: string, days: number): string { + const parsed = new Date(`${date}T00:00:00Z`); + parsed.setUTCDate(parsed.getUTCDate() + days); + return parsed.toISOString().slice(0, 10); +} + +/** + * Merge the booking-count trend and the payment trend into one date-keyed + * series for the combined volume/revenue chart. Both trends only carry rows + * for days with activity (no zero-filled gaps), so this unions the dates + * rather than assuming they line up. + * + * When the previous period's payment trend is provided, each of its days is + * shifted forward by `shiftDays` (the range length) so day N of the prior + * window lands on day N of the current one, and lands in `prevRevenueEtb`. + */ +export function mergeTrend( + bookingTrend: IOverviewTrendPoint[], + paymentTrend: IOverviewPaymentTrendPoint[], + previousPaymentTrend: IOverviewPaymentTrendPoint[] = [], + shiftDays = 0, +): RevenueVolumePoint[] { + const byDate = new Map(); + + for (const point of bookingTrend) { + byDate.set(point.date, { date: point.date, bookings: point.count, revenueEtb: 0 }); + } + for (const point of paymentTrend) { + const existing = byDate.get(point.date); + if (existing) { + existing.revenueEtb = point.amountEtb; + } else { + byDate.set(point.date, { date: point.date, bookings: 0, revenueEtb: point.amountEtb }); + } + } + for (const point of previousPaymentTrend) { + const date = shiftDate(point.date, shiftDays); + const existing = byDate.get(date); + if (existing) { + existing.prevRevenueEtb = point.amountEtb; + } else { + byDate.set(date, { + date, + bookings: 0, + revenueEtb: 0, + prevRevenueEtb: point.amountEtb, + }); + } + } + + return [...byDate.values()].sort((a, b) => a.date.localeCompare(b.date)); +} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/summary/overview-summary.css b/apps/edr-freight-web/backoffice/src/components/overview/summary/overview-summary.css new file mode 100644 index 000000000..0391010b8 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/summary/overview-summary.css @@ -0,0 +1,65 @@ +/* Staggered fade-up entrance for the overview bands. Delay is set inline per + band; disabled entirely for reduced-motion users. */ +.ov-band { + animation: ov-rise 420ms ease-out both; +} + +@keyframes ov-rise { + from { + opacity: 0; + transform: translateY(14px); + } + to { + opacity: 1; + transform: none; + } +} + +@media (prefers-reduced-motion: reduce) { + .ov-band { + animation: none; + } +} + +/* ---- Shared card chrome (SummaryCard) ---- */ +.ov-card { + display: block; + height: 100%; + background: linear-gradient(180deg, #ffffff 0%, #fbfdfc 100%); + border: 1px solid var(--mantine-color-edr-border-0); + border-radius: 20px; + padding: 20px; + color: inherit; + text-decoration: none; + box-shadow: + 0 1px 2px rgba(16, 32, 47, 0.04), + 0 12px 32px -18px rgba(16, 32, 47, 0.14); + transition: + box-shadow 180ms ease, + transform 180ms ease, + border-color 180ms ease; +} +/* The lift is a click affordance — only linked cards get it. */ +.ov-card--link:hover { + box-shadow: + 0 2px 4px rgba(16, 32, 47, 0.05), + 0 20px 44px -18px rgba(16, 32, 47, 0.2); + border-color: var(--mantine-color-edr-green-2); + transform: translateY(-2px); +} + +/* Soft inset panel for grouping content inside a card. */ +.ov-inset { + background: var(--mantine-color-gray-0); + border: 1px solid var(--mantine-color-gray-1); + border-radius: 14px; +} + +/* Interactive list row inside a card. */ +.ov-row { + border-radius: 12px; + transition: background 140ms ease; +} +.ov-row:hover { + background: var(--mantine-color-gray-0); +} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewBookingsTabPanel.tsx b/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewBookingsTabPanel.tsx index cacba63e1..696741873 100644 --- a/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewBookingsTabPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewBookingsTabPanel.tsx @@ -59,12 +59,6 @@ export function OverviewBookingsTabPanel({ data }: OverviewBookingsTabPanelProps accent: "sky", hint: "Pending sign-off", }, - { - label: "Submitted today", - value: data.kpis.submittedToday, - icon: FileText, - hint: "New since midnight", - }, ]} /> diff --git a/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewContractsTabPanel.tsx b/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewContractsTabPanel.tsx index e185f40d2..9265da37d 100644 --- a/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewContractsTabPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewContractsTabPanel.tsx @@ -76,12 +76,6 @@ export function OverviewContractsTabPanel({ accent: "rose", hint: "Customs / documents", }, - { - label: "Created today", - value: data.kpis.createdToday, - icon: FileSignature, - hint: "New since midnight", - }, ]} /> diff --git a/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewOperationsTabPanel.tsx b/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewOperationsTabPanel.tsx index 675a81b32..b335ccf9c 100644 --- a/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewOperationsTabPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewOperationsTabPanel.tsx @@ -1,5 +1,4 @@ import { - Box, CalendarClock, Container as ContainerIcon, Send, @@ -78,11 +77,6 @@ export function OverviewOperationsTabPanel({ data }: OverviewOperationsTabPanelP value: data.kpis.containersInTransit, icon: ContainerIcon, }, - { - label: "Cargoes loaded", - value: data.kpis.cargoesLoaded, - icon: Box, - }, ]} /> diff --git a/apps/edr-freight-web/backoffice/src/components/page/KpiStrip.tsx b/apps/edr-freight-web/backoffice/src/components/page/KpiStrip.tsx index 6b90d6c8a..15e2f4425 100644 --- a/apps/edr-freight-web/backoffice/src/components/page/KpiStrip.tsx +++ b/apps/edr-freight-web/backoffice/src/components/page/KpiStrip.tsx @@ -19,8 +19,8 @@ export interface KpiItem { */ color?: string; /** - * Optional change vs a prior period, rendered as a ▲/▼ chip next to the value - * (green up, red down, muted zero). E.g. today's count minus yesterday's. + * Optional percent change vs a prior period, rendered as a tinted ▲/▼ pill + * next to the value (green up, red down; zero and null hidden). */ delta?: number; /** @@ -64,7 +64,9 @@ export function KpiStrip({ items, loading = false }: KpiStripProps) { key={item.label} {...(linkProps as Record)} className={cn( - "flex flex-1 items-center gap-3 px-5 py-4", + // min-w-0 lets a crowded strip (five cells, long labels) + // truncate its labels instead of overflowing the card. + "flex min-w-0 flex-1 items-center gap-3 px-5 py-4", index > 0 && "border-t border-edr-border sm:border-l sm:border-t-0", item.href && @@ -103,11 +105,19 @@ export function KpiStrip({ items, loading = false }: KpiStripProps) { component="span" fz="xs" fw={700} - c={item.delta > 0 ? "edr-green" : "red"} - style={{ whiteSpace: "nowrap" }} + c={item.delta > 0 ? "edr-green.7" : "red.7"} + style={{ + whiteSpace: "nowrap", + background: + item.delta > 0 + ? "var(--mantine-color-edr-green-0)" + : "var(--mantine-color-red-0)", + borderRadius: 999, + padding: "1px 7px", + }} > {item.delta > 0 ? "▲" : "▼"} - {Math.abs(item.delta)} + {Math.abs(item.delta)}% ) : null}
diff --git a/apps/edr-freight-web/backoffice/src/components/page/PageHeader.tsx b/apps/edr-freight-web/backoffice/src/components/page/PageHeader.tsx index 087567585..803d3f5f7 100644 --- a/apps/edr-freight-web/backoffice/src/components/page/PageHeader.tsx +++ b/apps/edr-freight-web/backoffice/src/components/page/PageHeader.tsx @@ -7,7 +7,7 @@ import Breadcrumbs, { type BreadcrumbItem } from "@/components/ui/Breadcrumbs"; export interface PageHeaderProps { title: string; - subtitle?: string; + subtitle?: ReactNode; /** Breadcrumb trail — pass only on nested pages (details, sub-resources). */ breadcrumbs?: BreadcrumbItem[]; /** Route to return to; renders a back arrow before the title. */ diff --git a/apps/edr-freight-web/backoffice/src/components/reports/ReportChart.tsx b/apps/edr-freight-web/backoffice/src/components/reports/ReportChart.tsx new file mode 100644 index 000000000..0be5384be --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/reports/ReportChart.tsx @@ -0,0 +1,83 @@ +import { Box, Text } from "@mantine/core"; +import { + Bar, + BarChart, + CartesianGrid, + Legend, + Line, + LineChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; + +import { overviewChartColors } from "@/components/overview/overview.styles"; +import type { ReportChartDef, ReportColumn } from "@/types/reports"; + +import { formatReportCell } from "./report-format"; + +interface ReportChartProps { + chart: ReportChartDef; + items: Record[]; + columns: ReportColumn[]; + /** Filtered row count on the server. Chart is capped at 100 rows (the API's + * page-size ceiling) — surface it plainly rather than silently truncate. */ + total?: number; +} + +const COLORS = overviewChartColors.pipeline; + +/** Plots the same rows the table gets — chart.x/chart.y are just column keys. */ +export function ReportChart({ chart, items, columns, total }: ReportChartProps) { + const columnByKey = new Map(columns.map((c) => [c.key, c])); + const yLabel = (key: string) => columnByKey.get(key)?.label ?? key; + const yType = (key: string) => columnByKey.get(key)?.type ?? "number"; + + if (!items.length) { + return ( + + No data for the selected filters. + + ); + } + + const Chart = chart.type === "line" ? LineChart : BarChart; + + const truncated = typeof total === "number" && total > items.length; + + return ( + + {truncated ? ( + + Showing first {items.length} of {total} rows. Narrow the filters to see the rest charted. + + ) : null} + + + + + + [formatReportCell(value, yType(String(name))), yLabel(String(name))]} /> + {chart.y.length > 1 ? yLabel(String(name))} /> : null} + {chart.y.map((key, i) => + chart.type === "line" ? ( + + ) : ( + + ), + )} + + + + ); +} + +export default ReportChart; diff --git a/apps/edr-freight-web/backoffice/src/components/reports/ReportExportButton.tsx b/apps/edr-freight-web/backoffice/src/components/reports/ReportExportButton.tsx new file mode 100644 index 000000000..76ff7d628 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/reports/ReportExportButton.tsx @@ -0,0 +1,158 @@ +import { Button, Checkbox, Group, Modal, Radio, Select, SimpleGrid, Stack, Text } from "@mantine/core"; +import { Download, FileSpreadsheet, FileText } from "lucide-react"; +import { useState } from "react"; + +import { reportsService } from "@/services/reports.service"; +import type { ReportCatalogEntry, ReportRunParams } from "@/types/reports"; + +interface ReportExportButtonProps { + def: ReportCatalogEntry; + /** Filters + sort currently applied on screen — no key/page/pageSize. */ + params: Omit; +} + +const RECORD_OPTIONS = [ + { value: "all", label: "All (up to format limit)" }, + { value: "100", label: "First 100" }, + { value: "500", label: "First 500" }, + { value: "1000", label: "First 1,000" }, +]; + +/** Triggers a browser save for a blob without leaving the SPA. */ +function saveBlob(blob: Blob, filename: string) { + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); +} + +/** One export button: format, which fields, how many records — applies the + * filters/sort already on screen. Record count defaults to all (capped + * server-side per format). */ +export function ReportExportButton({ def, params }: ReportExportButtonProps) { + const [opened, setOpened] = useState(false); + const [format, setFormat] = useState<"xlsx" | "pdf">("xlsx"); + const [fields, setFields] = useState(def.columns.map((c) => c.key)); + const [records, setRecords] = useState("all"); + const [exporting, setExporting] = useState(false); + + const allSelected = fields.length === def.columns.length; + const toggleField = (key: string) => + setFields((prev) => (prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key])); + const toggleAll = () => setFields(allSelected ? [] : def.columns.map((c) => c.key)); + + const handleDownload = async () => { + setExporting(true); + try { + const blob = await reportsService.download(def.key, format, { + ...params, + fields: allSelected ? undefined : fields.join(","), + limit: records === "all" ? undefined : records, + }); + saveBlob(blob, `${def.key}.${format}`); + setOpened(false); + } finally { + setExporting(false); + } + }; + + return ( + <> + + + setOpened(false)} title="Export report" radius="md" size="md"> + +
+ + Format + + setFormat(v as "xlsx" | "pdf")}> + + + + + + + Excel (.xlsx) + + + + + + + + + PDF + + + + + +
+ +
+ + + Fields + + + + + {def.columns.map((col) => ( + toggleField(col.key)} + /> + ))} + +
+ + set({ [filter.key]: v ?? undefined })} + radius="md" + size="sm" + clearable + w={170} + /> + ); + case "multiselect": + return ( + set({ [filter.key]: v.length ? v.join(",") : undefined })} + radius="md" + size="sm" + clearable + w={200} + /> + ); + case "text": + return ( + } + value={values[filter.key] ?? ""} + onChange={(e) => set({ [filter.key]: e.target.value || undefined })} + radius="md" + size="sm" + w={220} + /> + ); + default: + return null; + } + })} + + ); +} + +export default ReportFilters; diff --git a/apps/edr-freight-web/backoffice/src/components/reports/ReportSection.tsx b/apps/edr-freight-web/backoffice/src/components/reports/ReportSection.tsx new file mode 100644 index 000000000..99c6d30c3 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/reports/ReportSection.tsx @@ -0,0 +1,39 @@ +import { Stack, Text, Title } from "@mantine/core"; +import { useQuery } from "@tanstack/react-query"; + +import { api } from "@/services/api"; + +import { ReportView } from "./ReportView"; + +interface ReportSectionProps { + reportKey: string; + /** Scopes the report to one entity, e.g. the contract this page is showing. */ + idKeyValue?: string; +} + +/** + * Drops a report inline on any page — a contract detail page embedding + * `contract-utilization`, for instance. Renders nothing while the catalog is + * loading or if the caller lacks the report's permission, so pages can embed + * it unconditionally without their own permission check. + */ +export function ReportSection({ reportKey, idKeyValue }: ReportSectionProps) { + const { data: catalog } = useQuery(api.reports.catalog.queryOptions()); + const def = catalog?.find((r) => r.key === reportKey); + + if (!def) return null; + + return ( + +
+ {def.title} + + {def.description} + +
+ +
+ ); +} + +export default ReportSection; diff --git a/apps/edr-freight-web/backoffice/src/components/reports/ReportView.tsx b/apps/edr-freight-web/backoffice/src/components/reports/ReportView.tsx new file mode 100644 index 000000000..47f5a194a --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/reports/ReportView.tsx @@ -0,0 +1,226 @@ +import { ActionIcon, Alert, Box, Card, Group, SegmentedControl, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core"; +import { useDebouncedValue } from "@mantine/hooks"; +import { useQuery } from "@tanstack/react-query"; +import type { Column, SortingState } from "@tanstack/react-table"; +import { ArrowDown, ArrowUp, ArrowUpDown, LayoutGrid, LineChart, RefreshCw } from "lucide-react"; +import { useMemo, useState } from "react"; + +import { PageHeader } from "@/components/page"; +import { KpiStrip } from "@/components/page/KpiStrip"; +import { api } from "@/services/api"; +import type { ReportRunParams } from "@/types/reports"; +import { DataTable, DataTableFooter, usePagination, type ColumnDef } from "@edr/ui-common"; + +import { ReportChart } from "./ReportChart"; +import { ReportExportButton } from "./ReportExportButton"; +import { ReportFilters, type ReportFilterValues } from "./ReportFilters"; +import { formatKpiValue, formatReportCell } from "./report-format"; + +function SortableHeader({ label, column }: { label: string; column: Column, unknown> }) { + const sorted = column.getIsSorted(); + const Icon = sorted === "asc" ? ArrowUp : sorted === "desc" ? ArrowDown : ArrowUpDown; + return ( + + + {label} + + + + ); +} + +interface ReportViewProps { + reportKey: string; + /** Scopes the report to one entity when embedded (e.g. a contract detail page). */ + idKeyValue?: string; + /** Full-page usage: renders the title/description as a PageHeader (no back + * arrow) with export/refresh as its actions, instead of inline above the + * table. Off by default for embedded sections. */ + pageHeader?: boolean; +} + +/** + * The report engine: one component renders any report the catalog describes — + * filters, KPI strip, sortable/paginated table or chart, xlsx/pdf export. + * Adding a report never touches this file. + */ +export function ReportView({ reportKey, idKeyValue, pageHeader }: ReportViewProps) { + const { data: catalog } = useQuery(api.reports.catalog.queryOptions()); + const def = catalog?.find((r) => r.key === reportKey); + + const { pagination, setPagination } = usePagination({ pageSize: 20 }); + const [sorting, setSorting] = useState([]); + const [filterValues, setFilterValues] = useState({}); + const [debouncedFilters] = useDebouncedValue(filterValues, 300); + const [view, setView] = useState<"table" | "chart">("table"); + + // Filters + sort as the user currently has them — independent of the view + // toggle's paging, so export always matches what's on screen either way. + const appliedParams = useMemo(() => { + const sort = sorting[0]; + return { + sortBy: sort?.id, + sortOrder: sort ? (sort.desc ? "DESC" as const : "ASC" as const) : undefined, + ...debouncedFilters, + ...(def?.idKey && idKeyValue ? { [def.idKey.key]: idKeyValue } : {}), + }; + }, [def, sorting, debouncedFilters, idKeyValue]); + + const runParams: ReportRunParams | undefined = useMemo(() => { + if (!def) return undefined; + return { + key: def.key, + // Chart view isn't paginated on screen — pull the server's max page (100) + // in one shot instead of just whatever page the table happens to be on, + // so the chart doesn't silently plot a fraction of the filtered rows. + page: view === "chart" ? 1 : pagination.pageIndex + 1, + pageSize: view === "chart" ? 100 : pagination.pageSize, + ...appliedParams, + }; + }, [def, view, pagination, appliedParams]); + + const { data, isLoading, isError, isFetching, refetch } = useQuery({ + ...api.reports.run.queryOptions({ input: runParams as ReportRunParams }), + enabled: Boolean(runParams), + }); + + const total = data?.meta.total ?? 0; + const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize)); + + const columns: ColumnDef>[] = useMemo( + () => + (def?.columns ?? []).map((col) => ({ + id: col.key, + accessorKey: col.key, + header: col.sortable + ? ({ column }) => + : col.label, + enableSorting: col.sortable, + cell: ({ row }) => ( + + {formatReportCell(row.original[col.key], col.type)} + + ), + })), + [def?.columns], + ); + + if (!def) { + return catalog ? ( + You don't have access to this report. + ) : null; + } + + const chartToggle = def.chart ? ( + setView(v as "table" | "chart")} + data={[ + { label: , value: "table" }, + { label: , value: "chart" }, + ]} + /> + ) : null; + + const refreshButton = ( + + void refetch()} + aria-label="Refresh" + > + + + + ); + + const exportButton = ; + + return ( + + {pageHeader ? ( + + {exportButton} + {refreshButton} + + } + /> + ) : null} + + {data?.kpis.length ? ( + ({ label: k.label, value: formatKpiValue(k.value, k.unit) }))} + /> + ) : null} + + + + + + { + setFilterValues(v); + setPagination((prev) => ({ ...prev, pageIndex: 0 })); + }} + /> + + {chartToggle} + {pageHeader ? null : ( + <> + {exportButton} + {refreshButton} + + )} + + + + + {view === "chart" && def.chart ? ( + + ) : ( + + void refetch() } : undefined} + pagination={{ + pageIndex: pagination.pageIndex, + pageSize: pagination.pageSize, + pageCount, + totalCount: total, + }} + tableOptions={{ + state: { sorting }, + onSortingChange: setSorting, + onPaginationChange: setPagination, + manualPagination: true, + manualSorting: true, + pageCount, + }} + containerClassName="border-0 shadow-none bg-transparent" + footer={DataTableFooter} + /> + + )} + + + + ); +} + +export default ReportView; diff --git a/apps/edr-freight-web/backoffice/src/components/reports/report-format.ts b/apps/edr-freight-web/backoffice/src/components/reports/report-format.ts new file mode 100644 index 000000000..d9b174ef1 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/reports/report-format.ts @@ -0,0 +1,42 @@ +import type { ReportColumnType } from "@/types/reports"; + +/** Cell formatting shared by the on-screen table and (indirectly) exports. */ +export function formatReportCell(value: unknown, type: ReportColumnType): string { + if (value === null || value === undefined || value === "") return "—"; + switch (type) { + case "money": + return new Intl.NumberFormat(undefined, { + style: "currency", + currency: "ETB", + maximumFractionDigits: 2, + }).format(Number(value)); + case "tons": + return `${Number(value).toLocaleString()} t`; + case "percent": + return `${value}%`; + case "number": + return Number(value).toLocaleString(); + case "date": { + const d = new Date(String(value)); + return Number.isNaN(d.getTime()) + ? String(value) + : d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }); + } + default: + return String(value); + } +} + +export function formatKpiValue(value: number, unit?: string): string { + const formatted = value.toLocaleString(undefined, { maximumFractionDigits: 1 }); + if (unit === "ETB") { + return new Intl.NumberFormat(undefined, { + style: "currency", + currency: "ETB", + maximumFractionDigits: 0, + }).format(value); + } + if (unit === "%") return `${formatted}%`; + if (unit === "t") return `${formatted} t`; + return formatted; +} diff --git a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx index 397496fd2..e05e00917 100644 --- a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx @@ -227,6 +227,12 @@ const RuleEngineFormDialog = ({ current[name] ? { ...current, [name]: "" } : current, ); setValues((current) => { + // Mantine fires onChange even when the same option is re-picked, and the + // cascades below clear dependent answers (yards, unit, scope). Re-picking + // an unchanged value must be a no-op, or an untouched direction silently + // wipes the yard pair and the submit fails with "origin/destination + // missing" data the admin did fill in. + if (current[name] === value) return current; const next = { ...current, [name]: value }; // Changing what a rate applies to (or its surcharge trigger) can invalidate // the previously-chosen unit — reset it so the admin re-picks from the new @@ -257,6 +263,34 @@ const RuleEngineFormDialog = ({ next.cargoTypeId = ""; next.rateUnit = ""; } + // Turning the shipping-line toggle on or off swaps the entire form, so + // nothing answered under the other shape may survive into the payload. + if (name === "isShippingLineRate") { + next.shippingLineCompanyId = ""; + next.shippingLineRateKind = ""; + next.shippingLineCargoKind = ""; + next.appliesTo = ""; + next.trigger = ""; + next.containerTypeId = ""; + next.cargoTypeId = ""; + next.originYardId = ""; + next.destinationYardId = ""; + next.rateUnit = ""; + } + // Base-vs-surcharge and container-vs-bulk each decide the scope field and + // the legal units for a shipping-line rate, exactly as appliesTo and + // cargoKind do on the customer form. + if (name === "shippingLineRateKind" || name === "shippingLineCargoKind") { + next.containerTypeId = ""; + next.cargoTypeId = ""; + next.rateUnit = ""; + if (name === "shippingLineRateKind") { + next.shippingLineCargoKind = ""; + next.trigger = ""; + next.originYardId = ""; + next.destinationYardId = ""; + } + } return next; }); }; @@ -347,12 +381,23 @@ const RuleEngineFormDialog = ({ borderRadius: "var(--mantine-radius-md)", }} > - - {field.label} - + + + {field.label} + + {field.description ? ( + + {field.description} + + ) : null} + setField(field.name, e.currentTarget.checked)} + // A toggle that re-targets what an existing record means (e.g. who + // a rate is priced for) is create-only — flipping it on a saved row + // would silently change every booking that prices off it. + disabled={field.disabled || (field.disabledOnEdit && !!initialRecord)} size="md" color="edr-green" /> @@ -493,6 +538,12 @@ const RuleEngineFormDialog = ({ // Dynamic options (e.g. rate unit) resolve from the live form values so // the choices track the other fields the admin has picked. const options = field.optionsFromValues ? field.optionsFromValues(values) : (field.options ?? []); + // A derived select shows (and submits) its computed value and is locked, + // matching the text-input branch — used by fields the shape decides on the + // admin's behalf, e.g. a shipping-line rate's import-only direction. + const computedSelect = field.computeValue + ? String(field.computeValue(values) ?? "") + : undefined; return (