diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index 2c636eee4..5a145a0db 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -172,14 +172,23 @@ EIMS_SELLER_LOCALITY= # Tax treatment — REQUIRES FINANCE SIGN-OFF. The application models no tax at all # (invoice.taxAmount is always 0), so nothing here is defaulted: registration fails # locally, naming the missing variables, until these are set. -# Required, and deliberately unset: the choice is a tax position, not a default. +# Required, and deliberately unset here: the choice is a tax position, not a default. # MoR's enum (from its own 400): TOT10 TOT2 VAT15 VWHT TWHT VATEX VATWH WHOP2 WTHOI VAT0 VWTH -# Pending finance confirmation of VAT0 (zero-rated) vs VATEX (exempt). +# Finance confirmed 2026-08-12: VATEX (exempt) for EDR's freight business — set in .env. EIMS_TAX_CODE= EIMS_TAX_RATE_PERCENT=0 EIMS_EXCISE_TAX_VALUE=0 EIMS_INCOME_WITHHOLD_VALUE=0 EIMS_TRANSACTION_WITHHOLD_VALUE=0 +# Per-chargeType override, for an invoice whose lines need different MoR tax treatment (e.g. a +# zero-rated freight line next to a taxed accessorial) — IRC-P01 compliance-test material. +# A charge type not listed here falls back to EIMS_TAX_CODE / EIMS_TAX_RATE_PERCENT above. +# EIMS_TAX_CODE_BY_CHARGE_TYPE and EIMS_TAX_RATE_BY_CHARGE_TYPE must list the same charge types. +EIMS_TAX_CODE_BY_CHARGE_TYPE= +EIMS_TAX_RATE_BY_CHARGE_TYPE= +# Same mechanism; charge types not listed fall back to EIMS_EXCISE_TAX_VALUE / 0 respectively. +EIMS_EXCISE_BY_CHARGE_TYPE= +EIMS_DISCOUNT_BY_CHARGE_TYPE= # Document classification and payment presentation. EIMS_TRANSACTION_TYPE=B2B # Lowercase constant: MoR's oneOf branches require exactly 'goods' or 'service'. 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 4c4b9efe8..d869ddf2d 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -50,6 +50,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"; @@ -211,6 +212,7 @@ if (!process.env.APPLICATION_NAME) { DropdownSettingsModule, ExchangeSettingsModule, StampSettingsModule, + LogoSettingsModule, ContractTemplatesModule, SupportContentModule, OtpModule, diff --git a/apps/edr-freight-api/src/common/request-log-context.spec.ts b/apps/edr-freight-api/src/common/request-log-context.spec.ts index 403b1a137..e898cb930 100644 --- a/apps/edr-freight-api/src/common/request-log-context.spec.ts +++ b/apps/edr-freight-api/src/common/request-log-context.spec.ts @@ -65,10 +65,40 @@ describe("RequestLogMiddleware", () => { originalUrl: "/api/bookings/1/submit?dry=1", baseUrl: "/api/bookings", route: { path: "/:id/submit" }, - headers: { "user-agent": "jest", "x-request-id": "req-42" }, + headers: { + "user-agent": "jest", + "x-request-id": "req-42", + authorization: "Bearer tok", + "x-client-app": "freight-backoffice", + "current-project-id": "proj-3", + }, ip: "10.0.0.1", query: { dry: "1" }, - user: { id: "u-7" }, + user: { + id: "u-7", + sessionId: "sess-9", + userType: "STAFF", + status: "ACTIVE", + username: "nati", + email: "nati@example.com", + phoneNumber: "0911000000", + name: { en: "Nati" }, + roles: [{ key: "freight_operations" }], + permissions: [{ key: "a" }, { key: "b" }], + employee: { + id: "emp-1", + organizationId: "org-1", + unitId: "unit-2", + position: { + id: "pos-5", + key: "ops_officer", + employeePositionId: "ep-6", + isDelegate: true, + delegatorId: "pos-1", + positionType: { key: "operations" }, + }, + }, + }, }; const res = { statusCode: 409, @@ -105,6 +135,29 @@ describe("RequestLogMiddleware", () => { bookingId: "b-1", booking: { outcome: "REJECTED" }, }); + expect(JSON.parse(lines[0]).auth).toEqual({ + authenticated: true, + hasBearer: true, + clientApp: "freight-backoffice", + userId: "u-7", + sessionId: "sess-9", + userType: "STAFF", + userStatus: "ACTIVE", + roles: ["freight_operations"], + permissionCount: 2, + employeeId: "emp-1", + organizationId: "org-1", + unitId: "unit-2", + positionId: "pos-5", + positionKey: "ops_officer", + positionType: "operations", + employeePositionId: "ep-6", + isDelegate: true, + delegatorId: "pos-1", + projectId: "proj-3", + }); + // No personal data reaches the line, whatever the token carried. + expect(lines[0]).not.toMatch(/nati|example\.com|0911000000/); expect(res.setHeader).toHaveBeenCalledWith("x-request-id", "req-42"); jest.restoreAllMocks(); }); diff --git a/apps/edr-freight-api/src/config/eims.config.ts b/apps/edr-freight-api/src/config/eims.config.ts index a8a929ca5..cf77072e0 100644 --- a/apps/edr-freight-api/src/config/eims.config.ts +++ b/apps/edr-freight-api/src/config/eims.config.ts @@ -89,8 +89,30 @@ export interface EimsInvoiceConfig { buyerRegionCodes: Record; /** Same mechanism as `buyerRegionCodes`, for `EIMS_BUYER_WEREDA_CODES` ("Yeka=574"). */ buyerWeredaCodes: Record; + /** + * Per-`chargeType` tax treatment, e.g. `EIMS_TAX_CODE_BY_CHARGE_TYPE=RAIL_FREIGHT=VAT0` + + * `EIMS_TAX_RATE_BY_CHARGE_TYPE=RAIL_FREIGHT=0`. A charge type not listed here falls back to + * `taxCode`/`taxRatePercent`. Needed for an invoice whose lines carry different MoR tax + * treatment (e.g. zero-rated freight next to a taxed accessorial) — the flat `taxCode` above + * cannot express that. Values are raw strings; the context builder parses/validates them. + */ + taxCodeByChargeType: Record; + taxRateByChargeType: Record; + /** Same mechanism, for `EIMS_EXCISE_BY_CHARGE_TYPE` / `EIMS_DISCOUNT_BY_CHARGE_TYPE`. Charge + * types not listed fall back to `exciseTaxValue` / 0 respectively. */ + exciseByChargeType: Record; + discountByChargeType: Record; cashierName: string | null; salesPersonName: string | null; + /** + * TEMPORARY / experimental — `EIMS_BUYER_ID_TYPE` + `EIMS_BUYER_ID_NUMBER`, applied to every + * buyer regardless of who they are. Only exists to test whether rule 7004 ("Id types should be + * one of NID, KID, SID, WID, PST, DLS, MRS") is satisfied by *any* IdType/IdNumber pair, ahead + * of MoR's answer on whether it's required for a TIN-only corporate buyer and which value fits. + * Wrong for a real, non-self buyer — remove once MoR answers and a real per-buyer field exists. + */ + buyerIdType: string | null; + buyerIdNumber: string | null; } const REQUIRED_VARS = [ @@ -188,8 +210,14 @@ export default registerAs("eims", (): EimsConfig => { buyerCountryCode: process.env.EIMS_BUYER_COUNTRY_CODE || null, buyerRegionCodes: parseCodeMap(process.env.EIMS_BUYER_REGION_CODES), buyerWeredaCodes: parseCodeMap(process.env.EIMS_BUYER_WEREDA_CODES), + taxCodeByChargeType: parseCodeMap(process.env.EIMS_TAX_CODE_BY_CHARGE_TYPE), + taxRateByChargeType: parseCodeMap(process.env.EIMS_TAX_RATE_BY_CHARGE_TYPE), + exciseByChargeType: parseCodeMap(process.env.EIMS_EXCISE_BY_CHARGE_TYPE), + discountByChargeType: parseCodeMap(process.env.EIMS_DISCOUNT_BY_CHARGE_TYPE), cashierName: process.env.EIMS_CASHIER_NAME || null, salesPersonName: process.env.EIMS_SALESPERSON_NAME || null, + buyerIdType: process.env.EIMS_BUYER_ID_TYPE || null, + buyerIdNumber: process.env.EIMS_BUYER_ID_NUMBER || null, }, }; 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/3450000000000-WidenEimsIrn.ts b/apps/edr-freight-api/src/migrations/3450000000000-WidenEimsIrn.ts new file mode 100644 index 000000000..ec3faf9eb --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3450000000000-WidenEimsIrn.ts @@ -0,0 +1,33 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * `eims_irn varchar(64)` was sized for a guess; the real value MoR returns is longer. Confirmed + * live 2026-08-12 — a genuine `POST /v1/register` acceptance came back as + * `test-aeedcbf496f0b63bb035ba3ca1dc674e3c81c980cafeb1c5038421999a23a215` (69 chars: a `test-` + * prefix + a 64-hex-char body), which overflowed the column and threw *after* MoR had already + * accepted the document — `settleSuccess` never committed, leaving the invoice stuck `SUBMITTING` + * and the system-wide reservation stuck in-flight with no block/alert (see + * `EimsInvoiceRegistrationService` for the accompanying code fix). + * + * Widened to `text` rather than a new fixed length: MoR has never documented an IRN format or + * length, and a `test-` prefix on a *production* endpoint suggests this may not even be MoR's + * real production shape — guessing another fixed bound risks the exact same failure again. + */ +export class WidenEimsIrn3450000000000 implements MigrationInterface { + name = "WidenEimsIrn3450000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.invoices + ALTER COLUMN eims_irn TYPE text + `); + } + + /** Only safe if nothing stored so far exceeds 64 chars — true only until this migration ran. */ + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.invoices + ALTER COLUMN eims_irn TYPE varchar(64) + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3460000000000-AddEimsSignedQr.ts b/apps/edr-freight-api/src/migrations/3460000000000-AddEimsSignedQr.ts new file mode 100644 index 000000000..dedf2d05f --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3460000000000-AddEimsSignedQr.ts @@ -0,0 +1,20 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** `DocumentDetails`/register-response field `signedQR`, persisted alongside `eims_irn`. */ +export class AddEimsSignedQr3460000000000 implements MigrationInterface { + name = "AddEimsSignedQr3460000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.invoices + ADD COLUMN IF NOT EXISTS eims_signed_qr text + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.invoices + DROP COLUMN IF EXISTS eims_signed_qr + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3470000000000-EimsCancellation.ts b/apps/edr-freight-api/src/migrations/3470000000000-EimsCancellation.ts new file mode 100644 index 000000000..7092e5518 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3470000000000-EimsCancellation.ts @@ -0,0 +1,26 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** Columns for `POST /v1/cancel` — see `EimsCancellationService`. */ +export class EimsCancellation3470000000000 implements MigrationInterface { + name = "EimsCancellation3470000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.invoices + ADD COLUMN IF NOT EXISTS eims_cancelled_at timestamptz, + ADD COLUMN IF NOT EXISTS eims_cancellation_date varchar(64), + ADD COLUMN IF NOT EXISTS eims_cancellation_reason_code varchar(8), + ADD COLUMN IF NOT EXISTS eims_cancellation_remark text + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.invoices + DROP COLUMN IF EXISTS eims_cancelled_at, + DROP COLUMN IF EXISTS eims_cancellation_date, + DROP COLUMN IF EXISTS eims_cancellation_reason_code, + DROP COLUMN IF EXISTS eims_cancellation_remark + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3480000000000-WidenPreviousIrn.ts b/apps/edr-freight-api/src/migrations/3480000000000-WidenPreviousIrn.ts new file mode 100644 index 000000000..15a343f06 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3480000000000-WidenPreviousIrn.ts @@ -0,0 +1,24 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Same bug as `3450000000000-WidenEimsIrn`, same fix: `previous_irn` stores a real MoR IRN too + * (fed into the next registration's `ReferenceDetails.PreviousIrn`) and would overflow the same + * varchar(64) on the next successful registration. + */ +export class WidenPreviousIrn3480000000000 implements MigrationInterface { + name = "WidenPreviousIrn3480000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.eims_system_state + ALTER COLUMN previous_irn TYPE text + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.eims_system_state + ALTER COLUMN previous_irn TYPE varchar(64) + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3490000000000-EimsReceipts.ts b/apps/edr-freight-api/src/migrations/3490000000000-EimsReceipts.ts new file mode 100644 index 000000000..3d5ab02e9 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3490000000000-EimsReceipts.ts @@ -0,0 +1,34 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** `freight.eims_receipts` — see `EimsReceipt` entity. */ +export class EimsReceipts3490000000000 implements MigrationInterface { + name = "EimsReceipts3490000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.eims_receipts ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + invoice_id uuid NOT NULL REFERENCES freight.invoices(id), + kind varchar(16) NOT NULL, + status varchar(20) NOT NULL DEFAULT 'NOT_SUBMITTED', + receipt_number varchar(64) NOT NULL, + rrn text, + qr text, + ack_status varchar(8), + submitted_at timestamptz, + last_error jsonb, + request jsonb, + 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_eims_receipts_invoice_id ON freight.eims_receipts (invoice_id) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.eims_receipts`); + } +} 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/modules/billing/billing.service.spec.ts b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts index 576c7b166..d9eef5a43 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts @@ -80,6 +80,7 @@ describe("BillingService.generateInvoice", () => { {} as never, // companies {} as never, // invoiceDocuments {} as never, // files + { get: () => undefined } as never, // config ); }); @@ -142,6 +143,7 @@ describe("BillingService.markInvoiceAsPaid", () => { {} as never, // companies {} as never, // invoiceDocuments {} as never, // files + { get: () => undefined } as never, // config ); await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never); @@ -196,6 +198,7 @@ describe("BillingService.markInvoiceAsPaid", () => { {} as never, // companies {} as never, // invoiceDocuments {} as never, // files + { get: () => undefined } as never, // config ); await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never); @@ -240,6 +243,7 @@ describe("BillingService.settleByPaymentId", () => { {} as never, // companies {} as never, // invoiceDocuments {} as never, // files + { get: () => undefined } as never, // config ); return { service, mg, events }; } @@ -352,6 +356,7 @@ describe("BillingService.recordPayment", () => { {} as never, // companies {} as never, // invoiceDocuments {} as never, // files + { get: () => undefined } as never, // config ); return { service, mg, events }; } @@ -468,6 +473,7 @@ describe("BillingService.expirePayable — locked write runs in a transaction", {} as never, {} as never, {} as never, + {} as never, // config ); return { service, defaultManager, txManager, transaction }; }; @@ -540,6 +546,7 @@ describe("BillingService.issuePayable", () => { {} as never, {} as never, {} as never, + {} as never, // config ); return { service, manager }; }; @@ -630,6 +637,7 @@ describe("BillingService — CAC Bank (OTP debit)", () => { {} as never, {} as never, {} as never, + {} as never, // config ); return { service, repo }; }; @@ -712,6 +720,7 @@ describe("BillingService — CBE bill amounts carry cents, never rounded", () => {} as never, {} as never, {} as never, + {} as never, // config ); return { service, repo }; }; @@ -740,3 +749,102 @@ describe("BillingService — CBE bill amounts carry cents, never rounded", () => }); }); }); + +describe("BillingService.document", () => { + const invoiceRow = (over: Record = {}) => ({ + id: "inv-1", + invoiceNumber: "INV-20260812-00001", + source: "booking", + sourceId: "booking-1", + status: Freight.InvoiceStatus.Pending, + type: "freight", + currency: "ETB", + subtotalAmount: 100, + taxAmount: 0, + totalAmount: 100, + paidAmount: 0, + balanceAmount: 100, + issuedAt: new Date(2026, 7, 12), + dueAt: new Date(2026, 7, 19), + eimsIrn: null, + eimsSignedQr: null, + company: { name: "ABC Trading PLC", tin: "0999930000", vatNumber: "123475885858" }, + ...over, + }); + + const build = (invoice: Record) => { + const render = jest.fn().mockResolvedValue({ filename: "x.pdf", buffer: Buffer.from("") }); + const service = new BillingService( + {} as never, + { findById: jest.fn().mockResolvedValue(invoice) } as never, + { findAll: jest.fn().mockResolvedValue([]) } as never, + {} as never, + {} as never, + {} as never, + { render } as never, + {} as never, + { + get: (key: string) => + key === "eims" + ? { tin: "0053481357", invoice: { sellerVatNumber: "43256663343256663322" } } + : undefined, + } as never, // config + ); + return { service, render }; + }; + + it("adds no EIMS IRN row and no QR for an unregistered invoice", async () => { + const { service, render } = build(invoiceRow()); + + await service.document("inv-1"); + + const model = render.mock.calls[0][0]; + expect(model.summary.find((r: { label: string }) => r.label === "EIMS IRN")).toBeUndefined(); + expect(model.qrImageUrl).toBeNull(); + }); + + it("shows the buyer's name, TIN and VAT number on every invoice", async () => { + const { service, render } = build(invoiceRow()); + + await service.document("inv-1"); + + const model = render.mock.calls[0][0]; + expect(model.summary).toContainEqual({ label: "Buyer", value: "ABC Trading PLC" }); + expect(model.summary).toContainEqual({ label: "Buyer TIN", value: "0999930000" }); + expect(model.summary).toContainEqual({ label: "Buyer VAT No.", value: "123475885858" }); + }); + + it("omits the VAT row when the buyer company has none", async () => { + const { service, render } = build(invoiceRow({ company: { name: "Acme", tin: "0011223344" } })); + + await service.document("inv-1"); + + const model = render.mock.calls[0][0]; + expect(model.summary.find((r: { label: string }) => r.label === "Buyer VAT No.")).toBeUndefined(); + }); + + it("shows EDR's own seller TIN and VAT number from EIMS config", async () => { + const { service, render } = build(invoiceRow()); + + await service.document("inv-1"); + + const model = render.mock.calls[0][0]; + expect(model.summary).toContainEqual({ label: "Seller TIN", value: "0053481357" }); + expect(model.summary).toContainEqual({ + label: "Seller VAT No.", + value: "43256663343256663322", + }); + }); + + it("adds the EIMS IRN to the summary and renders the QR for a registered invoice", async () => { + const { service, render } = build( + invoiceRow({ eimsIrn: "IRN-123", eimsSignedQr: "signed-payload" }), + ); + + await service.document("inv-1"); + + const model = render.mock.calls[0][0]; + expect(model.summary).toContainEqual({ label: "EIMS IRN", value: "IRN-123" }); + expect(model.qrImageUrl).toBe("data:image/png;base64,signed-payload"); + }); +}); 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 7d0f1e962..6752b37c1 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -1,4 +1,5 @@ import { Freight, PaymentReferenceType } from "@edr/types"; +import { ConfigService } from "@nestjs/config"; import { BadRequestException, forwardRef, @@ -12,6 +13,7 @@ import { logCtx } from "@edr/api-common"; import { DataSource, EntityManager, In } from "typeorm"; import { Booking } from "../bookings/entities/booking.entity"; +import { EimsConfig } from "../../config/eims.config"; import { CompaniesService } from "../companies/companies.service"; import { FilesService } from "../files/files.service"; import { applyBookingRefDirectionScope } from "../user-trade-access/trade-scope.util"; @@ -172,6 +174,7 @@ export class BillingService { private readonly companies: CompaniesService, private readonly invoiceDocuments: InvoiceDocumentService, private readonly files: FilesService, + private readonly config: ConfigService, ) { } // ── Reads ────────────────────────────────────────────────────────────────── @@ -395,7 +398,7 @@ export class BillingService { async document(id: string): Promise<{ filename: string; buffer: Buffer }> { const invoice = await this.findById(id); return this.invoiceDocuments.render( - this.toDocumentModel(invoice, "INVOICE"), + await this.toDocumentModel(invoice, "INVOICE"), ); } @@ -408,15 +411,50 @@ export class BillingService { ); } return this.invoiceDocuments.render( - this.toDocumentModel(invoice, "RECEIPT"), + await this.toDocumentModel(invoice, "RECEIPT"), ); } + /** + * `Invoice.eimsSignedQr` is already a base64 PNG straight from MoR — confirmed against the + * Postman collection's `register` response (`signedQR` decodes to a PNG magic-byte header), + * not a payload we encode ourselves. Wrapped in a data URL, nothing more. + */ + private renderEimsQr(signedQr: string): string { + 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 toDocumentModel( + private async toDocumentModel( invoice: Invoice & { lines: InvoiceLine[] }, kind: "INVOICE" | "RECEIPT", - ): InvoiceDocumentModel { + ): Promise { const title = invoice.source ? invoice.source.charAt(0).toUpperCase() + invoice.source.slice(1) : "EDR"; @@ -434,6 +472,44 @@ export class BillingService { totals.push({ label: "Paid", amount: Number(invoice.paidAmount) }); totals.push({ label: "Balance", amount: Number(invoice.balanceAmount) }); + const summary: InvoiceDocumentModel["summary"] = [ + // Buyer identity — was missing entirely; a MoR-registered invoice must show who it was + // filed against, not just the seller. VatNumber shown only when the company has one. + { label: "Buyer", value: invoice.company?.name ?? null }, + { label: "Buyer TIN", value: invoice.company?.tin ?? null }, + ...(invoice.company?.vatNumber + ? [{ label: "Buyer VAT No.", value: invoice.company.vatNumber }] + : []), + { 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", + value: invoice.issuedAt + ? new Date(invoice.issuedAt).toLocaleDateString("en-GB") + : null, + }, + { + label: "Due", + value: invoice.dueAt + ? new Date(invoice.dueAt).toLocaleDateString("en-GB") + : null, + }, + ]; + + // Seller identity — EDR's own legal TIN/VAT live only in EIMS config (nowhere else in this + // codebase). Shown only when actually configured, same as the buyer VAT row. + const eimsCfg = this.config.get("eims"); + if (eimsCfg?.tin) summary.push({ label: "Seller TIN", value: eimsCfg.tin }); + if (eimsCfg?.invoice?.sellerVatNumber) { + summary.push({ label: "Seller VAT No.", value: eimsCfg.invoice.sellerVatNumber }); + } + + // MoR EIMS reference — only once actually registered, never a placeholder row. + if (invoice.eimsIrn) summary.push({ label: "EIMS IRN", value: invoice.eimsIrn }); + return { kind, title, @@ -441,24 +517,7 @@ export class BillingService { issuedAt: invoice.issuedAt ?? invoice.createdAt, status: invoice.status, currency: invoice.currency, - summary: [ - { label: "Status", value: invoice.status }, - { label: "Type", value: invoice.type }, - { label: "Reference", value: invoice.sourceId }, - { label: "Currency", value: invoice.currency }, - { - label: "Issued", - value: invoice.issuedAt - ? new Date(invoice.issuedAt).toLocaleDateString("en-GB") - : null, - }, - { - label: "Due", - value: invoice.dueAt - ? new Date(invoice.dueAt).toLocaleDateString("en-GB") - : null, - }, - ], + summary, categoryHeader: "Charge type", lines: invoice.lines.map((l) => ({ description: l.description ?? l.chargeType, @@ -469,6 +528,7 @@ export class BillingService { currency: l.currency, })), totals, + qrImageUrl: invoice.eimsSignedQr ? this.renderEimsQr(invoice.eimsSignedQr) : null, }; } 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 new file mode 100644 index 000000000..bc5250888 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.spec.ts @@ -0,0 +1,46 @@ +import { InvoiceDocumentModel, InvoiceDocumentService } from "./invoice-document.service"; + +const model = (over: Partial = {}): InvoiceDocumentModel => ({ + kind: "INVOICE", + title: "Freight", + documentNumber: "INV-20260812-00001", + issuedAt: new Date(2026, 7, 12), + status: "PENDING", + currency: "ETB", + summary: [{ label: "Status", value: "PENDING" }], + lines: [], + totals: [{ label: "Total", amount: 100, grand: true }], + ...over, +}); + +describe("InvoiceDocumentService.buildHtml — EIMS QR", () => { + const service = new InvoiceDocumentService({} as never, {} as never, {} as never); + + it("renders no QR block when qrImageUrl is unset", () => { + const html = service.buildHtml(model()); + expect(html).not.toContain('class="qr"'); + }); + + it("renders the QR image when qrImageUrl is set", () => { + const html = service.buildHtml(model({ qrImageUrl: "data:image/png;base64,QR" })); + expect(html).toContain('class="qr"'); + expect(html).toContain('src="data:image/png;base64,QR"'); + }); + + it("still shows the IRN text row via the ordinary summary grid", () => { + const html = service.buildHtml( + model({ summary: [{ label: "EIMS IRN", value: "IRN-123" }] }), + ); + expect(html).toContain("EIMS IRN"); + expect(html).toContain("IRN-123"); + }); + + it("widens the summary's right margin only when a QR is present, to clear the QR block", () => { + // "summary-with-qr" also appears in the always-present +

${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/rule-engine.module.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts index 549a35fa2..7d15ab889 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 @@ -1,4 +1,4 @@ -import { Global, Module } from '@nestjs/common'; +import { forwardRef, Global, Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { ApprovalRulesController } from './controllers/approval-rules.controller'; @@ -104,8 +104,9 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. // 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, + // the line exists and is active. forwardRef: shipping-lines now imports + // BookingsModule (booking completion), which imports this module back. + forwardRef(() => ShippingLineCompaniesModule), ], controllers: [ CargoTypesController, 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..c46829841 --- /dev/null +++ b/apps/edr-freight-api/src/modules/shipping-lines/dto/complete-shipping-line-booking.dto.ts @@ -0,0 +1,106 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { Transform, Type } from "class-transformer"; +import { + IsArray, + IsDateString, + IsIn, + IsInt, + IsNumber, + IsOptional, + IsString, + IsUUID, + Min, + ValidateNested, +} from "class-validator"; + +import { PAYMENT_CURRENCIES } from "../../contracts/dto/create-contract.dto"; + +/** + * One container line the shipping line ships — container type + count, the + * same shape the customer one-time form collects (no per-unit ISO numbers; + * those are captured downstream at yard operations, as for customers). + */ +export class CompleteShippingLineContainerLineDto { + @ApiProperty({ format: "uuid", description: "Container type being shipped." }) + @IsUUID() + containerTypeId!: 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; +} + +/** + * 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({ 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({ description: "What the containers carry." }) + @IsOptional() + @IsString() + cargoFreeText?: 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 index 6cdcdd1cf..3b0615fe4 100644 --- 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 @@ -1,5 +1,5 @@ import { ApiProperty } from "@nestjs/swagger"; -import { IsIn, IsOptional, IsUUID } from "class-validator"; +import { IsDateString, IsIn, IsOptional, IsUUID } from "class-validator"; import { FREIGHT_TYPES } from "../../bookings/entities/booking.entity"; @@ -35,4 +35,13 @@ export class InitiateShippingLineBookingDto { @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/shipping-line-bookings.controller.ts b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-bookings.controller.ts index 6c3fb60a0..243fd0a76 100644 --- 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 @@ -11,6 +11,7 @@ import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; import { PortalCustomer } from "../../common/booking-guards"; import { CancelShippingLineBookingDto } from "./dto/cancel-shipping-line-booking.dto"; +import { CompleteShippingLineBookingDto } from "./dto/complete-shipping-line-booking.dto"; import { InitiateShippingLineBookingDto } from "./dto/initiate-shipping-line-booking.dto"; import { ShippingLineBookingsService } from "./shipping-line-bookings.service"; @@ -66,6 +67,17 @@ export class ShippingLineBookingsController { 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." }) @@ -76,6 +88,33 @@ export class ShippingLineBookingsController { return this.shippingLineBookingsService.findMine(user.id, id); } + @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.shippingLineBookingsService.availableDaysMine(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.shippingLineBookingsService.completeMine(user.id, id, dto); + } + @Post(":id/cancel") @PortalCustomer() @ApiOperation({ 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 index 7a7ca457b..333cab619 100644 --- 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 @@ -6,15 +6,30 @@ import { NotFoundException, } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; -import { In, Repository } from "typeorm"; +import { In, MoreThanOrEqual, 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 { 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 { 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 { ServiceType } from "../rule-engine/entities/service-type.entity"; +import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity"; +import { TrainSchedulingService } from "../train-scheduling/services/train-scheduling.service"; +import { CompleteShippingLineBookingDto } from "./dto/complete-shipping-line-booking.dto"; import { InitiateShippingLineBookingDto } from "./dto/initiate-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"; /** * The only trade direction a shipping line books. @@ -62,6 +77,11 @@ export class ShippingLineBookingsService { @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 creditsService: ShippingLineCreditsService, ) {} /** @@ -98,28 +118,38 @@ export class ShippingLineBookingsService { async referenceData(userId: string) { await this.requireShippingLine(userId); - const [routes, serviceTypes] = 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" }, - }), - ]); + 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) => ({ @@ -142,6 +172,19 @@ export class ShippingLineBookingsService { 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, + })), }; } @@ -177,6 +220,20 @@ export class ShippingLineBookingsService { ); } + // 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. @@ -222,8 +279,10 @@ export class ShippingLineBookingsService { tradeDirection: route.direction, serviceTypeId: dto.serviceTypeId ?? null, freightType: dto.freightType ?? "CONTAINER", - // Bare instance — filled in when the booking is completed. - scheduledDate: null, + // 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), @@ -291,6 +350,310 @@ export class ShippingLineBookingsService { return { ...booking, hasQueriedDocuments: queriedCount > 0 }; } + /** + * 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", + })); + } + + /** + * Days the shipping line may pick as the shipment day — cargo-aware when the + * booking already carries cargo, departure-only before that. Same helper the + * customer day picker uses; ownership is checked first so one line cannot + * probe another's booking. + */ + async availableDaysMine(userId: string, bookingId: string) { + const shippingLine = await this.requireShippingLine(userId); + const owned = await this.bookingsRepository.exists({ + where: { id: bookingId, shippingLineCompanyId: shippingLine.id }, + }); + if (!owned) throw new NotFoundException(`Booking ${bookingId} not found`); + return this.bookingsService.availableDaysForBooking(bookingId); + } + + /** + * Complete a bare shipping-line booking once Operations has approved its + * documents (CLEARANCE_READY), or after Operations returned the request + * (OPERATION_CHANGES_REQUESTED). This is the deferred half of + * {@link initiate}, mirroring what a customer does at this point: 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 shippingLine = await this.requireShippingLine(userId); + + const booking = await this.bookingsRepository.findOne({ + where: { id: bookingId, shippingLineCompanyId: shippingLine.id }, + relations: { bookingContainers: true }, + }); + if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); + 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: the route's booking window must be open — + // the same config-driven gate a customer booking passes. + 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, + // 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, + ), + // Completion fixes the cargo — and therefore the price — so it is also + // where the billing currency is chosen. + paymentCurrency: dto.paymentCurrency ?? booking.paymentCurrency, + } 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, + ); + + // The charge goes on the line's credit ledger ("use now, pay later") — + // idempotent per booking, so a retried completion cannot double the debt. + await this.creditsService.recordCredit({ + bookingId, + amount: computed.totalAmount, + currency: computed.currency, + description: `Freight service — booking ${booking.reference}`, + }); + } + + // Binding day + open-departure validation, OPERATION_REQUEST_PENDING and + // the staff notification — the exact machine a customer booking uses. + await this.bookingTransitionService.requestOperation( + bookingId, + dto.scheduledDate, + null, + ); + return this.findMine(userId, bookingId); + } + + /** + * 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 containerTypeRepo = + this.bookingsRepository.manager.getRepository(ContainerType); + const containerRepo = + this.bookingsRepository.manager.getRepository(BookingContainer); + + for (const line of lines) { + const containerType = await containerTypeRepo.findOne({ + where: { id: line.containerTypeId, isActive: true }, + }); + if (!containerType) { + throw new NotFoundException( + `Container type ${line.containerTypeId} not found`, + ); + } + const hazardous = Math.min( + Number(line.hazardousQuantity ?? 0), + line.quantity, + ); + const reefer = Math.min(Number(line.reeferQuantity ?? 0), line.quantity); + const vgmPerUnit = Number(line.vgmPerUnitTons ?? 0); + await containerRepo.save( + containerRepo.create({ + bookingId: booking.id, + containerTypeId: containerType.id, + containerSize: containerType.sizeFt + ? `${containerType.sizeFt}ft` + : null, + quantity: line.quantity, + hazardousQuantity: hazardous, + reeferQuantity: reefer, + returnQuantity: 0, + vgmPerUnitTons: vgmPerUnit, + totalVgmTons: vgmPerUnit * line.quantity, + wagonsRequired: Math.ceil( + line.quantity * wagonsPerUnitForSize(containerType.sizeFt), + ), + }), + ); + } + } + + /** 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); + } + /** * Cancel one of the signed-in shipping line's own bookings. * 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 index 698dfb384..f8f682dde 100644 --- 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 @@ -5,8 +5,10 @@ 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 { BookingsModule } from "../bookings/bookings.module"; import { Booking } from "../bookings/entities/booking.entity"; import { OtpModule } from "../otp/otp.module"; +import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.module"; import { ShippingLineCompany } from "./entities/shipping-line-company.entity"; import { ShippingLineCredit } from "./entities/shipping-line-credit.entity"; import { ShippingLineBookingsController } from "./shipping-line-bookings.controller"; @@ -36,6 +38,12 @@ import { ShippingLineCreditsService } from "./shipping-line-credits.service"; // `shipping_line_credit.invoice.paid` event, but the module graph now cycles // (billing -> companies -> here -> billing), so this edge needs forwardRef. forwardRef(() => BillingModule), + // Booking completion reuses the customer machinery: pricing, the + // operation-request transition and the day picker. Both edges cycle back + // here (bookings -> rule-engine -> shipping-lines, train-scheduling -> + // bookings -> …), so both need forwardRef. + forwardRef(() => BookingsModule), + forwardRef(() => TrainSchedulingModule), ], controllers: [ ShippingLineCompaniesController, 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..b9c115d53 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'; @@ -820,8 +821,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 +985,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 @@ -1094,8 +1097,9 @@ export class BookingBatchService implements OnModuleInit { } 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 @@ -1219,8 +1223,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 +2370,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 +3108,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()); 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/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/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 cdc43c5b2..a908a6926 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'; @@ -129,6 +130,7 @@ import { perEdgeConsistUsage, validateContainerPlacements, validateMixedTrainLimitsPerEdge, + MAX_TEU_SLOTS_PER_WAGON, type ContainerPlacementInput, type WagonPlanSlot, } from '../utils/wagon-plan.util'; @@ -176,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, @@ -375,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 @@ -1457,6 +1462,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 @@ -1721,6 +1744,7 @@ export class TrainSchedulingService { trainNumber: pairTrainNumber ?? undefined, maxWagons, reverseWagonOrder: dto.reverseWagonOrder ?? false, + shippingLineCompanyId: dto.shippingLineCompanyId ?? null, ...windowFields, }), ); @@ -2930,6 +2954,10 @@ export class TrainSchedulingService { // dispatch pre-check keeps reporting these bookings as unloaded). const wagonAssignedIds = await this.getWagonAssignedBookingIds(scheduleId); if (wagonAssignedIds.size) { + // Re-check the 1×40ft/2×20ft-per-wagon packing rule right before loading + // is confirmed — allocation time already enforces it, but a wagon swap or + // an edited allocation since then could have broken it unnoticed. + await this.assertWagonContainerCapacity(scheduleId); // Export cargo must be received at the warehouse with a GRN before it can // be confirmed loaded — an allocation is not proof the goods are in hand. if (this.isExportSchedule(schedule)) { @@ -2997,6 +3025,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, @@ -3017,7 +3048,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'); @@ -3037,7 +3068,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; @@ -3109,6 +3142,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'); @@ -3152,6 +3186,7 @@ export class TrainSchedulingService { positionLabel?: string; wagons?: TrainSetWagon[]; unassignedBookings?: Booking[]; + logoImageUrl?: string | null; }, ): string { const esc = (value: unknown) => @@ -3270,6 +3305,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; } @@ -3284,6 +3320,7 @@ export class TrainSchedulingService {
+ ${logoMarkup(opts?.logoImageUrl)}
Ethio-Djibouti Railway S.C.

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

@@ -3397,7 +3434,76 @@ export class TrainSchedulingService { } } - private buildImportLoadListHtml(loadList: Awaited>): string { + /** + * Re-check the 1×40ft / 2×20ft-per-wagon packing rule at loading confirmation + * time. `validateContainerPlacements` already enforces this the moment a + * booking is allocated to a wagon, but nothing re-checks it afterwards — a + * wagon swap, an edited allocation, or a container item added out-of-band + * between allocation and loading could still leave a wagon over its 2-TEU + * capacity undetected until the train is already loaded. This closes that gap + * by summing the TEU actually persisted per wagon (40ft = 2 TEU, 20ft = 1 TEU, + * same size resolution as the marshalling document) right before loading is + * confirmed. + */ + private async assertWagonContainerCapacity(scheduleId: string): Promise { + const rows: Array<{ sequenceNo: number; wagonNumber: string | null; teuUsed: string }> = + await this.dataSource.query( + `SELECT tsw.sequence_no AS "sequenceNo", + pw.wagon_number AS "wagonNumber", + SUM( + CASE COALESCE(ct.size_ft, bct.size_ft) + WHEN 40 THEN 2 + WHEN 20 THEN 1 + ELSE + CASE + WHEN bc.container_size ILIKE '%40%' THEN 2 + WHEN bc.container_size ILIKE '%20%' THEN 1 + ELSE 0 + END + END + ) AS "teuUsed" + FROM freight.train_set_wagons tsw + JOIN freight.wagon_booking_allocations wba + ON wba.train_set_wagon_id = tsw.id AND wba.deleted_at IS NULL + JOIN freight.wagon_allocation_container_items wci + ON wci.wagon_booking_allocation_id = wba.id AND wci.deleted_at IS NULL + LEFT JOIN freight.container_types ct ON ct.id = wci.container_type_id + LEFT JOIN freight.booking_container bc ON bc.id = wci.booking_container_id + LEFT JOIN freight.container_types bct ON bct.id = bc.container_type_id + LEFT JOIN freight.wagons pw ON pw.id = tsw.physical_wagon_id + WHERE tsw.train_set_id = ( + SELECT train_set_id FROM freight.train_schedules WHERE id = $1 + ) + AND tsw.deleted_at IS NULL + GROUP BY tsw.id, tsw.sequence_no, pw.wagon_number + HAVING SUM( + CASE COALESCE(ct.size_ft, bct.size_ft) + WHEN 40 THEN 2 + WHEN 20 THEN 1 + ELSE + CASE + WHEN bc.container_size ILIKE '%40%' THEN 2 + WHEN bc.container_size ILIKE '%20%' THEN 1 + ELSE 0 + END + END + ) > $2`, + [scheduleId, MAX_TEU_SLOTS_PER_WAGON], + ); + if (rows.length) { + const labels = rows + .map((r) => `wagon #${r.sequenceNo}${r.wagonNumber ? ` (${r.wagonNumber})` : ''}`) + .join(', '); + throw new BadRequestException( + `These wagons exceed capacity (max 1×40ft or 2×20ft per wagon) — fix the container placement before confirming loading: ${labels}.`, + ); + } + } + + private buildImportLoadListHtml( + loadList: Awaited>, + logoImageUrl?: string | null, + ): string { const esc = (value: unknown) => String(value ?? '-') .replace(/&/g, '&') @@ -3430,26 +3536,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))} `; }, @@ -3477,6 +3594,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; } @@ -3498,6 +3616,7 @@ export class TrainSchedulingService {
+ ${logoMarkup(logoImageUrl)}
Ethio-Djibouti Railway S.C.

Import Load List /
Marshalling Document

Djibouti-side gatepass, loading, and departure manifest
@@ -3538,15 +3657,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.'} @@ -6881,6 +7007,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() @@ -6937,6 +7064,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() @@ -7046,6 +7174,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 }, 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/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index dbd996c63..81b3336de 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", @@ -506,6 +555,20 @@ export const FINANCE_PERMISSIONS: FreightPermissionSeed[] = [ "edr_freight_app:invoices:eims_resolve", "Resolve a blocked MoR EIMS submission", ), + // Cancellation is a separate irreversible-at-MoR action from registration — its own grant, + // same reasoning as eims_register. + perm( + "d2b00001-0001-4000-8000-000000000008", + "edr_freight_app:invoices:eims_cancel", + "Cancel a registered invoice with MoR EIMS", + ), + // Covers both sales and withholding receipts — same risk profile (filing a document with + // MoR), no reason to split further. + perm( + "d2b00001-0001-4000-8000-000000000009", + "edr_freight_app:invoices:eims_receipt_register", + "Register a sales or withholding receipt with MoR EIMS", + ), // USD bookings are paid by bank transfer; Finance uploads the slip and settles // the invoice. Moves money state, so it is its own grant, not part of view. perm( @@ -1240,6 +1303,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 @@ -1535,6 +1608,7 @@ export const NOTIFICATION_PERMISSIONS: FreightPermissionSeed[] = [ ]; export const ADVANCED_BACKOFFICE_PERMISSIONS: FreightPermissionSeed[] = [ + ...REPORT_PERMISSIONS, ...CUSTOMER_PERMISSIONS, ...SHIPPING_LINE_PERMISSIONS, ...FINANCE_PERMISSIONS, @@ -1764,6 +1838,8 @@ export const FREIGHT_PERMS = { export: "edr_freight_app:invoices:export", eimsRegister: "edr_freight_app:invoices:eims_register", eimsResolve: "edr_freight_app:invoices:eims_resolve", + eimsCancel: "edr_freight_app:invoices:eims_cancel", + eimsReceiptRegister: "edr_freight_app:invoices:eims_receipt_register", confirmOffline: "edr_freight_app:invoices:confirm_offline", }, firstMile: { @@ -1976,6 +2052,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. @@ -2028,6 +2110,7 @@ export const FREIGHT_PERMS = { }, reports: { view: "edr_freight_app:reports:view", + report: (key: ReportKey): string => reportPermissionKey(key), }, staff: { users: { @@ -2172,11 +2255,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 @@ -2282,10 +2371,10 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.bookings.view, FREIGHT_PERMS.invoices.view, FREIGHT_PERMS.invoices.export, - // Deliberately NOT granted here: invoices:eims_register and invoices:eims_resolve. - // Invoices are filed with MoR by the workflow, not by a person, so filing is not a - // Finance job function — the endpoints exist for controlled testing and exceptional - // operations, and are assigned to named admins rather than a role preset. + // Deliberately NOT granted here: invoices:eims_register, eims_resolve, eims_cancel, + // eims_receipt_register. Invoices are filed with MoR by the workflow, not by a person, so + // filing is not a Finance job function — the endpoints exist for controlled testing and + // exceptional operations, and are assigned to named admins rather than a role preset. FREIGHT_PERMS.payments.view, FREIGHT_PERMS.bookings.wagonCancellationView, ], diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 58bca3738..cb4699aa1 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"; @@ -36,15 +38,13 @@ import CustomerDetailPage from "./pages/customers/CustomerDetailPage"; import CustomersPage from "./pages/customers/CustomersPage"; import ShippingLineCompaniesPage from "./pages/shipping-lines/ShippingLineCompaniesPage"; 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 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"; @@ -53,6 +53,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"; @@ -82,7 +83,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"; @@ -140,8 +140,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"; @@ -200,12 +210,43 @@ 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. */} } /> - } /> + } + /> }> - } /> - } /> - } /> - } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> {/* 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={} /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> { + } @@ -789,7 +1027,9 @@ const App = () => { + } @@ -804,9 +1044,7 @@ const App = () => { + } @@ -816,6 +1054,15 @@ const App = () => { path="invoice-stamp-settings" element={} /> + {/* The ONE company logo, shown in the header of every generated document. */} + + + + } + /> { +
@@ -943,4 +1192,3 @@ function LegacyGlEthiopiaClearanceRedirect() { } export default App; - 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/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/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/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..20794f72b 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 @@ -44,10 +44,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 bf82a87d1..813e11d1c 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, @@ -51,522 +51,526 @@ 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: "Shipping Lines", - href: "/dashboard/shipping-lines", - icon: , - permission: FREIGHT_PERMS.shippingLines.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: "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/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/profile/ChangeEmailCard.tsx b/apps/edr-freight-web/backoffice/src/components/profile/ChangeEmailCard.tsx new file mode 100644 index 000000000..650616049 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/profile/ChangeEmailCard.tsx @@ -0,0 +1,172 @@ +import { useState } from "react"; +import { Mail, Loader2 } from "lucide-react"; +import { useMutation } from "@tanstack/react-query"; +import toast from "react-hot-toast"; + +import { api } from "@/services/api"; +import { useAuth } from "@/auth/useAuth"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + Input, + Label, +} from "@edr/ui-common"; + +/** + * Lets the signed-in backoffice user change their own email. Goes through + * /me/contact/otp + /me/contact rather than the generic (unverified) + * /auth/update-profile route, so the new address is proven before it's + * written — see account.controller.ts on the API side. + */ +export function ChangeEmailCard() { + const { user } = useAuth(); + const sendOtpMutation = useMutation( + api.account.sendContactOtp.mutationOptions(), + ); + const updateContactMutation = useMutation( + api.account.updateContact.mutationOptions(), + ); + + const [open, setOpen] = useState(false); + const [step, setStep] = useState<"enterEmail" | "enterOtp">("enterEmail"); + const [newEmail, setNewEmail] = useState(""); + const [otp, setOtp] = useState(""); + const [formError, setFormError] = useState(""); + + const closeDialog = () => { + setOpen(false); + setStep("enterEmail"); + setNewEmail(""); + setOtp(""); + setFormError(""); + }; + + const sendOtp = () => { + setFormError(""); + if (!newEmail.trim()) { + setFormError("Enter the new email address."); + return; + } + + sendOtpMutation.mutate( + { channel: "email", value: newEmail.trim() }, + { + onSuccess: (result) => { + toast.success(`Verification code sent to ${result.sentTo}`); + setStep("enterOtp"); + }, + }, + ); + }; + + const confirmOtp = () => { + setFormError(""); + if (!otp.trim()) { + setFormError("Enter the verification code."); + return; + } + + updateContactMutation.mutate( + { channel: "email", value: newEmail.trim(), otp: otp.trim() }, + { + onSuccess: () => { + toast.success("Email updated."); + closeDialog(); + // Refetches the session so the new email shows everywhere — simplest + // way to refresh the cached user without a dedicated context method. + window.location.reload(); + }, + }, + ); + }; + + return ( + + + + + Email + + + {user?.email ? `Current email: ${user.email}` : "Change your account email."} + + + + + + + !next && closeDialog()}> + + + Change email + + {step === "enterEmail" + ? "We'll send a verification code to the new address." + : `Enter the code sent to ${newEmail}.`} + + + + {step === "enterEmail" ? ( +
+ + setNewEmail(e.target.value)} + /> + {formError &&

{formError}

} +
+ ) : ( +
+ + setOtp(e.target.value)} + /> + {formError &&

{formError}

} +
+ )} + + + + {step === "enterEmail" ? ( + + ) : ( + + )} + +
+
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/profile/ChangePasswordCard.tsx b/apps/edr-freight-web/backoffice/src/components/profile/ChangePasswordCard.tsx new file mode 100644 index 000000000..fb5f9086e --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/profile/ChangePasswordCard.tsx @@ -0,0 +1,154 @@ +import { useState } from "react"; +import { KeyRound, Loader2 } from "lucide-react"; +import { useMutation } from "@tanstack/react-query"; +import toast from "react-hot-toast"; + +import { api } from "@/services/api"; +import { useAuth } from "@/auth/useAuth"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + Input, + Label, +} from "@edr/ui-common"; + +/** + * Lets the signed-in backoffice user change their own password. The account + * is logged out on success — the old token was issued under the old + * password, and this forces a clean re-login rather than trusting the + * server to keep the existing session valid. + */ +export function ChangePasswordCard() { + const { logout } = useAuth(); + const changePasswordMutation = useMutation( + api.account.changePassword.mutationOptions(), + ); + + const [open, setOpen] = useState(false); + const [oldPassword, setOldPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [formError, setFormError] = useState(""); + + const closeDialog = () => { + setOpen(false); + setOldPassword(""); + setNewPassword(""); + setConfirmPassword(""); + setFormError(""); + }; + + const submit = () => { + setFormError(""); + + if (!oldPassword || !newPassword || !confirmPassword) { + setFormError("All fields are required."); + return; + } + if (newPassword.length < 8) { + setFormError("New password must be at least 8 characters."); + return; + } + if (newPassword === oldPassword) { + setFormError("New password must be different from the current one."); + return; + } + if (newPassword !== confirmPassword) { + setFormError("New password and confirmation do not match."); + return; + } + + changePasswordMutation.mutate( + { oldPassword, newPassword, confirmPassword }, + { + onSuccess: () => { + toast.success("Password changed. Please sign in again."); + closeDialog(); + setTimeout(logout, 1200); + }, + }, + ); + }; + + return ( + + + + + Password + + Change the password for your account. + + + + + + !next && closeDialog()}> + + + Change password + + You'll be signed out and asked to log in again once it's changed. + + +
+
+ + setOldPassword(e.target.value)} + /> +
+
+ + setNewPassword(e.target.value)} + /> +
+
+ + setConfirmPassword(e.target.value)} + /> +
+ {formError &&

{formError}

} +
+ + + + +
+
+
+ ); +} 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/trainScheduling/LegCapacityPanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/LegCapacityPanel.tsx index c98a020a5..c006c9e03 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/LegCapacityPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/LegCapacityPanel.tsx @@ -14,6 +14,7 @@ import { } from "@mantine/core"; import { ChevronDown, ChevronRight, Info, Train, Weight } from "lucide-react"; +import { EntityLink } from "@/components/detail"; import type { TrainScheduleDetail } from "@/types/trainScheduling"; /** @@ -254,15 +255,19 @@ export function LegCapacityPanel({ schedule }: { schedule: TrainScheduleDetail } {e.bookings.map((b) => ( - - {b.reference} + + {b.route ? ( - {" "} ({b.route}) ) : null} - + diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx index de954742e..a023aeb88 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx @@ -29,6 +29,7 @@ import { // Repeat, // used by the hidden Move (reassign) button Train, TrainFront, + Truck, Weight, X, } from "lucide-react"; @@ -36,7 +37,9 @@ import { import { CountdownTimer } from "@edr/ui-common"; import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge"; +import { EntityLink } from "@/components/detail"; import { api } from "@/services/api"; +import { bookingsService } from "@/services/bookings.service"; import { useToast } from "@/hooks/use-toast"; import type { EligibleContainerBooking, @@ -411,6 +414,31 @@ export function ScheduleWorkspacePanel({ ); }; + // Export cargo that skipped the warehouse (customer truck straight onto the + // wagon) has no GRN and never will — loadBooking's GRN gate would keep + // rejecting it forever. Setting DIRECT_TO_TRAIN tells that gate the carriage + // acceptance sheet is the handover document instead, then loads in one click. + const [truckToTrainPending, setTruckToTrainPending] = useState(null); + const doTruckToTrain = (bookingId: string, ref: string) => { + setTruckToTrainPending(bookingId); + bookingsService + .setExportHandoverMode(bookingId, "DIRECT_TO_TRAIN") + .then(() => loadJourney.mutateAsync({ scheduleId: schedule.id, bookingId })) + .then(() => { + toast({ title: `${ref} loaded — direct truck-to-train handover` }); + onChanged(); + void yardWorkQuery.refetch(); + }) + .catch((error) => + toast({ + title: "Could not load as direct truck-to-train", + description: apiErrorMessage(error, "Please try again."), + variant: "destructive", + }), + ) + .finally(() => setTruckToTrainPending(null)); + }; + const doUnload = (bookingId: string, ref: string) => { unloadJourney .mutateAsync({ scheduleId: schedule.id, bookingId }) @@ -601,6 +629,7 @@ export function ScheduleWorkspacePanel({ {pool.map((b) => ( ) : null} + {showTruckToTrain ? ( + + + + ) : null} {showUnload ? ( - - {reference} - + {bookingId ? ( + + ) : ( + + {reference} + + )} {status ? : null} {intercity ? ( - - {alloc.bookingReference ?? alloc.bookingId} - + {label === "BULK" ? ( {alloc.allocatedWeightTons}T cargo diff --git a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts index f42702826..a51a6f135 100644 --- a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts @@ -89,6 +89,8 @@ export const QUERY_KEYS = { ["contracts", "clearance-history", region ?? "ET"] as const, milestones: (id: string) => ["contracts", "milestones", id] as const, capacity: (id: string) => ["contracts", "capacity", id] as const, + bookingRequests: (id: string) => + ["contracts", "booking-requests", id] as const, bookingMilestones: (bookingId: string) => ["contracts", "booking-milestones", bookingId] as const, bookingIncidents: (bookingId: string) => diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index f9bd29feb..71f80be3f 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -139,7 +139,9 @@ export const URL_CONSTANTS = { }, REPORTS: { + CATALOG: "/reports", RUN: (key: string) => `/reports/${key}`, + EXPORT: (key: string) => `/reports/${key}/export`, }, OVERVIEW: { diff --git a/apps/edr-freight-web/backoffice/src/hooks/useLogoSettings.ts b/apps/edr-freight-web/backoffice/src/hooks/useLogoSettings.ts new file mode 100644 index 000000000..662906f42 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/hooks/useLogoSettings.ts @@ -0,0 +1,45 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; + +import { logoSettingsService } from "@/services/logoSettings.service"; +import { useErrorHandler } from "@/shared/hooks/useErrorHandler"; + +const QUERY_KEY = ["logoSettings"]; + +export const useLogoSettingsQuery = () => + useQuery({ + queryKey: QUERY_KEY, + queryFn: () => logoSettingsService.get(), + }); + +export const useSetLogo = () => { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + const { handleError } = useErrorHandler(t); + + return useMutation({ + mutationFn: (logoImageBase64: string) => + logoSettingsService.set(logoImageBase64), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: QUERY_KEY }); + toast.success(t("logoSettings.updated", "Company logo updated")); + }, + onError: handleError, + }); +}; + +export const useClearLogo = () => { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + const { handleError } = useErrorHandler(t); + + return useMutation({ + mutationFn: () => logoSettingsService.clear(), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: QUERY_KEY }); + toast.success(t("logoSettings.cleared", "Company logo removed")); + }, + onError: handleError, + }); +}; diff --git a/apps/edr-freight-web/backoffice/src/layout/TopBar.tsx b/apps/edr-freight-web/backoffice/src/layout/TopBar.tsx index 708a5a3ca..c7bd1b197 100644 --- a/apps/edr-freight-web/backoffice/src/layout/TopBar.tsx +++ b/apps/edr-freight-web/backoffice/src/layout/TopBar.tsx @@ -319,7 +319,7 @@ export const TopBar = () => { {t("header.viewProfile")} navigate("/change-password")} + onClick={() => navigate("/dashboard/profile")} className="cursor-pointer hover:bg-primary-50 dark:hover:bg-primary-900/30 hover:text-primary-700 dark:hover:text-primary-400 py-2.5"> {t("header.changePassword")} diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts index ade07a511..8c7a1f84b 100644 --- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts @@ -340,6 +340,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. diff --git a/apps/edr-freight-web/backoffice/src/pages/AuditLog.tsx b/apps/edr-freight-web/backoffice/src/pages/AuditLog.tsx index 5374bcee7..1f2c9461e 100644 --- a/apps/edr-freight-web/backoffice/src/pages/AuditLog.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/AuditLog.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useState } from "react"; import { Input } from "@/shared/common/ui/input"; +import { DateRangePicker, parseDay, formatDay } from "@/shared/common/ui/date-range-picker"; import { Select, SelectTrigger, @@ -134,21 +135,12 @@ const buildQuery = (): CollectionQueryDTO => { className="w-64" /> -
- - setDateRange({ ...dateRange, start: e.target.value }) - } - /> - to - - setDateRange({ ...dateRange, end: e.target.value }) - } - /> -
+ + setDateRange({ start: formatDay(range.from), end: formatDay(range.to) }) + } + /> [] = useMemo( + () => [ + { + id: "reference", + header: "Contract", + cell: ({ row }) => ( + + {row.original.reference} + + ), + }, + { + id: "kind", + header: "Kind", + cell: ({ row }) => ( + + {row.original.contractKind === "GENERAL" ? "General" : "One-time"} + + ), + }, + { + id: "status", + header: "Status", + cell: ({ row }) => ( + + ), + }, + { + id: "validUntil", + header: "Valid until", + cell: ({ row }) => ( + + {formatDate(row.original.contractValidUntil)} + + ), + }, + { + id: "createdAt", + header: "Created", + meta: { headerClassName: "text-right", cellClassName: "text-right" }, + cell: ({ row }) => ( + + {formatDate(row.original.createdAt)} + + ), + }, + ], + [], + ); + const documentColumns: ColumnDef[] = useMemo( () => [ { @@ -709,7 +767,6 @@ export default function CustomerDetailPage() {
} - action={} /> @@ -720,6 +777,9 @@ export default function CustomerDetailPage() { }> Bookings + }> + Contracts + }> Documents @@ -1187,6 +1247,7 @@ export default function CustomerDetailPage() { status={tableStatus(bookingsQuery)} emptyMessage="No bookings for this customer." containerClassName="border-0 shadow-none bg-transparent" + onRowClick={(row) => navigate(`/dashboard/booking-requests/${row.id}`)} error={ bookingsQuery.isError ? { @@ -1199,6 +1260,28 @@ export default function CustomerDetailPage() { + {/* CONTRACTS */} + + + navigate(`/dashboard/contract-requests/${row.id}`)} + error={ + contractsQuery.isError + ? { + message: "Failed to load contracts.", + onRetry: () => void contractsQuery.refetch(), + } + : undefined + } + /> + + + {/* DOCUMENTS */} diff --git a/apps/edr-freight-web/backoffice/src/pages/dashboard/MyProfilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/dashboard/MyProfilePage.tsx index baf548d92..37f91909e 100644 --- a/apps/edr-freight-web/backoffice/src/pages/dashboard/MyProfilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/dashboard/MyProfilePage.tsx @@ -1,8 +1,12 @@ import { MySignatureCard } from "@/components/profile/MySignatureCard"; +import { ChangePasswordCard } from "@/components/profile/ChangePasswordCard"; +import { ChangeEmailCard } from "@/components/profile/ChangeEmailCard"; export default function MyProfilePage() { return (
+ +
diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx index 06d9a20ab..d837ee712 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx @@ -1,6 +1,7 @@ import type { ColumnDef } from "@edr/ui-common"; import { Box, Button, Card, Container, Group, Modal, Select, Stack, Text, TextInput, Title } from "@mantine/core"; import { DatePickerInput } from "@mantine/dates"; +import { getDateRangePresets } from "@/components/common/dateRangePresets"; import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query"; import { api } from "@/services/api"; @@ -613,26 +614,19 @@ const FleetResourcePage = () => { filters={ { + setDateFrom(from); + setDateTo(to); + }} + presets={getDateRangePresets()} clearable size="sm" radius="lg" - w={160} - /> - {listFilterSelects ? ( diff --git a/apps/edr-freight-web/backoffice/src/pages/invoices/FinanceHubPage.tsx b/apps/edr-freight-web/backoffice/src/pages/invoices/FinanceHubPage.tsx new file mode 100644 index 000000000..bd5169262 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/invoices/FinanceHubPage.tsx @@ -0,0 +1,100 @@ +import { Tabs } from "@mantine/core"; +import { Landmark, Receipt, Wallet } from "lucide-react"; +import { useSearchParams } from "react-router-dom"; + +import { useAuth } from "@/auth/useAuth"; +import { PageContainer, PageHeader } from "@/components/page"; +import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; + +import InvoicesPanel from "./InvoicesPage"; +import UsdPaymentsPanel from "./UsdPaymentsPage"; +import PaymentsPanel from "../payments/PaymentsPage"; + +/** + * Invoices, Payments, and USD Payments used to be three separate routes/pages + * with near-identical chrome. They're merged here as URL-linkable tabs + * (`?tab=`) on one page — each tab keeps the permission it was individually + * gated on before, and just doesn't render if the user lacks it. + */ +const TABS = [ + { + key: "invoices", + label: "Invoices", + icon: Receipt, + permission: FREIGHT_PERMS.invoices.view, + subtitle: + "Every invoice issued across bookings, warehouse fees and clearance charges.", + Panel: InvoicesPanel, + }, + { + key: "payments", + label: "Payments", + icon: Wallet, + permission: FREIGHT_PERMS.payments.view, + subtitle: "View and reconcile booking payment transactions.", + Panel: PaymentsPanel, + }, + { + key: "usd-payments", + label: "USD Payments", + icon: Landmark, + // Same gate as Invoices, not a dedicated key — mirrors the old route. + permission: FREIGHT_PERMS.invoices.view, + subtitle: + "USD invoices are paid by bank transfer. Upload the customer's slip and confirm the payment before the pay window closes.", + Panel: UsdPaymentsPanel, + }, +] as const; + +type TabKey = (typeof TABS)[number]["key"]; + +export default function FinanceHubPage() { + const { user } = useAuth(); + const [searchParams, setSearchParams] = useSearchParams(); + + const visibleTabs = TABS.filter((tab) => hasPermission(user, tab.permission)); + const requested = searchParams.get("tab"); + const active: TabKey = + visibleTabs.find((tab) => tab.key === requested)?.key ?? + visibleTabs[0]?.key ?? + "invoices"; + const activeTab = visibleTabs.find((tab) => tab.key === active); + + const handleChange = (value: string | null) => { + if (!value) return; + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev); + next.set("tab", value); + return next; + }, + { replace: true }, + ); + }; + + return ( + + + + + + {visibleTabs.map((tab) => ( + } + > + {tab.label} + + ))} + + + {visibleTabs.map((tab) => ( + + + + ))} + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/invoices/InvoiceDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/invoices/InvoiceDetailPage.tsx index 3188509c8..ef7f9d086 100644 --- a/apps/edr-freight-web/backoffice/src/pages/invoices/InvoiceDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/invoices/InvoiceDetailPage.tsx @@ -1,9 +1,11 @@ +import type { ReactNode } from "react"; import { ActionIcon, Button, Card, Center, Container, + Grid, Group, Loader, SimpleGrid, @@ -12,7 +14,7 @@ import { Text, } from "@mantine/core"; import { useQuery } from "@tanstack/react-query"; -import { ArrowLeft, Download } from "lucide-react"; +import { ArrowLeft, Building2, Download, FileText } from "lucide-react"; import { useAuth } from "@/auth/useAuth"; import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; import { EimsFilingCard } from "@/components/invoices/EimsFilingCard"; @@ -26,8 +28,11 @@ import { humanize, } from "@/components/customers"; import { PageContainer, PageHeader } from "@/components/page"; +import { LinkedEntityCard, type FieldRowProps } from "@/components/detail"; +import { useBookingDetail } from "@/hooks/bookings/useBookings"; import { api } from "@/services/api"; import { invoicesService } from "@/services/invoices.service"; +import type { Invoice } from "@/types/invoice"; function openPdfBlob(blob: Blob, filename: string) { const url = URL.createObjectURL(blob); @@ -43,7 +48,17 @@ function openPdfBlob(blob: Blob, filename: string) { setTimeout(() => URL.revokeObjectURL(url), 60_000); } -function InfoField({ label, value }: { label: string; value?: string | null }) { +function InfoField({ + label, + value, +}: { + label: string; + value?: ReactNode; +}) { + const isEmpty = + value === undefined || + value === null || + (typeof value === "string" && !value.trim()); return ( - {value && value.trim() ? value : "—"} + {isEmpty ? "—" : value} ); } +/** Billed-to company, with its contact/registration details as quick-info rows. */ +function RecipientCard({ invoice }: { invoice: Invoice }) { + const company = invoice.company; + const rows: FieldRowProps[] = [ + { label: "Profile", value: invoice.companyProfile?.reference }, + { label: "TIN", value: company?.tin }, + { label: "VAT No.", value: company?.vatNumber }, + { label: "Phone", value: company?.phone }, + { label: "Email", value: company?.email }, + { label: "Address", value: company?.address }, + ]; + return ( + + ); +} + +/** What the invoice was raised for — a booking's route/wagons when the + * source is a booking; otherwise just the source type and its raw id + * (warehouse/demurrage/first-mile/last-mile ids don't link anywhere). */ +function SourceCard({ invoice }: { invoice: Invoice }) { + const isBooking = invoice.source === "booking"; + const { data: booking } = useBookingDetail( + isBooking ? invoice.sourceId : undefined, + ); + + if (!isBooking) { + return ( + + ); + } + + const route = + booking?.originYard && booking?.destinationYard + ? `${booking.originYard.label} → ${booking.destinationYard.label}` + : undefined; + + return ( + + ); +} + export default function InvoiceDetailPage() { const { user } = useAuth(); const canExport = hasPermission(user, FREIGHT_PERMS.invoices.export); @@ -121,7 +199,7 @@ export default function InvoiceDetailPage() { ]} backTo="/dashboard/invoices" title={invoice.invoiceNumber} - subtitle={`${humanize(invoice.source)} · ${invoice.sourceId}`} + subtitle={humanize(invoice.source)} meta={} action={ - - + + - - Summary - - - - - - - - - - - + + + + Amounts + + + + + + + + + + + + + + + + + Line items + + + + + Description + Charge type + Quantity + Unit rate + Amount + + + + {(invoice.lines ?? []).map((line) => ( + + {line.description ?? line.chargeType} + + + {humanize(line.chargeType)} + + + {line.quantity} + + {formatMoney(line.unitRate, line.currency)} + + + {formatMoney(line.amount, line.currency)} + + + ))} + {(invoice.lines ?? []).length === 0 && ( + + + + No line items. + + + + )} + +
+ + + + + Subtotal + + + {formatMoney(invoice.subtotalAmount, invoice.currency)} + + + + + Tax + + + {formatMoney(invoice.taxAmount, invoice.currency)} + + + + + Paid + + + {formatMoney(invoice.paidAmount, invoice.currency)} + + + + + Total + + + {formatMoney(invoice.totalAmount, invoice.currency)} + + + +
+
-
+ - - - - - - Line items - - - - - Description - Charge type - Quantity - Unit rate - Amount - - - - {(invoice.lines ?? []).map((line) => ( - - {line.description ?? line.chargeType} - - - {humanize(line.chargeType)} - - - {line.quantity} - - {formatMoney(line.unitRate, line.currency)} - - - {formatMoney(line.amount, line.currency)} - - - ))} - {(invoice.lines ?? []).length === 0 && ( - - - - No line items. - - - - )} - -
- - - - - Subtotal - - - {formatMoney(invoice.subtotalAmount, invoice.currency)} - - - - - Tax - - - {formatMoney(invoice.taxAmount, invoice.currency)} - - - - - Paid - - - {formatMoney(invoice.paidAmount, invoice.currency)} - - - - - Total - - - {formatMoney(invoice.totalAmount, invoice.currency)} - - - + + + + -
-
+ + ); } diff --git a/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx index fd839de16..afa4fa603 100644 --- a/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx @@ -21,7 +21,6 @@ import { formatMoney, humanize, } from "@/components/customers"; -import { PageContainer, PageHeader } from "@/components/page"; import { api } from "@/services/api"; import type { Invoice } from "@/types/invoice"; import { @@ -31,7 +30,8 @@ import { type ColumnDef, } from "@edr/ui-common"; -export default function InvoicesPage() { +/** Invoices tab body of `FinanceHubPage` — page chrome lives in the parent. */ +export default function InvoicesPanel() { const navigate = useNavigate(); const { pagination, setPagination } = usePagination({ pageSize: 10 }); const [query, setQuery] = useState(""); @@ -127,112 +127,103 @@ export default function InvoicesPage() { ); return ( - - void refetch()} - > - -
- } - /> + + + + + } + value={query} + onChange={(e) => setQuery(e.target.value)} + rightSection={ + query ? ( + setQuery("")} + > + + + ) : null + } + style={{ flex: 1, minWidth: "240px" }} + radius="lg" + /> + { + setStatusFilter( + v === "all" ? "" : (v as Freight.InvoiceStatus), + ); + setPagination((prev) => ({ ...prev, pageIndex: 0 })); + }} + data={[ + { label: "All", value: "all" }, + { label: "Pending", value: "PENDING" }, + { label: "Payment processing", value: "PAYMENT_PROCESSING" }, + { label: "Paid", value: "PAID" }, + { label: "Overdue", value: "OVERDUE" }, + ]} + /> + + {total} record{total !== 1 ? "s" : ""} + + void refetch()} + > + + + + - - - - - } - value={query} - onChange={(e) => setQuery(e.target.value)} - rightSection={ - query ? ( - setQuery("")} - > - - - ) : null - } - style={{ flex: 1, minWidth: "240px" }} - radius="lg" - /> - { - setStatusFilter( - v === "all" ? "" : (v as Freight.InvoiceStatus), - ); - setPagination((prev) => ({ ...prev, pageIndex: 0 })); - }} - data={[ - { label: "All", value: "all" }, - { label: "Pending", value: "PENDING" }, - { label: "Payment processing", value: "PAYMENT_PROCESSING" }, - { label: "Paid", value: "PAID" }, - { label: "Overdue", value: "OVERDUE" }, - ]} - /> - - {total} record{total !== 1 ? "s" : ""} - - + + + navigate(`/dashboard/invoices/${row.id}`)} + emptyMessage={ + debouncedQuery + ? "No invoices match your search." + : "No invoices yet." + } + error={ + isError + ? { + message: "Failed to load invoices.", + onRetry: () => void refetch(), + } + : undefined + } + pagination={{ + pageIndex: pagination.pageIndex, + pageSize: pagination.pageSize, + pageCount, + totalCount: total, + }} + tableOptions={{ + state: { pagination }, + onPaginationChange: setPagination, + manualPagination: true, + pageCount, + }} + containerClassName="border-0 shadow-none bg-transparent" + footer={DataTableFooter} + /> - - - - navigate(`/dashboard/invoices/${row.id}`)} - emptyMessage={ - debouncedQuery - ? "No invoices match your search." - : "No invoices yet." - } - error={ - isError - ? { - message: "Failed to load invoices.", - onRetry: () => void refetch(), - } - : undefined - } - pagination={{ - pageIndex: pagination.pageIndex, - pageSize: pagination.pageSize, - pageCount, - totalCount: total, - }} - tableOptions={{ - state: { pagination }, - onPaginationChange: setPagination, - manualPagination: true, - pageCount, - }} - containerClassName="border-0 shadow-none bg-transparent" - footer={DataTableFooter} - /> - - - - - + + + ); } diff --git a/apps/edr-freight-web/backoffice/src/pages/invoices/UsdPaymentsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/invoices/UsdPaymentsPage.tsx index 585abea21..023b92e0d 100644 --- a/apps/edr-freight-web/backoffice/src/pages/invoices/UsdPaymentsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/invoices/UsdPaymentsPage.tsx @@ -25,7 +25,6 @@ import { humanize, } from "@/components/customers"; import { PhasedFileDropzone } from "@/components/contracts/PhasedFileDropzone"; -import { PageContainer, PageHeader } from "@/components/page"; import { useAuth } from "@/auth/useAuth"; import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; import { api } from "@/services/api"; @@ -95,7 +94,8 @@ function windowClosed(row: OfflineUsdInvoice): boolean { return Boolean(deadline && new Date(deadline).getTime() <= Date.now()); } -export default function UsdPaymentsPage() { +/** USD Payments tab body of `FinanceHubPage` — page chrome lives in the parent. */ +export default function UsdPaymentsPanel() { const navigate = useNavigate(); const { pagination, setPagination } = usePagination({ pageSize: 10 }); const [query, setQuery] = useState(""); @@ -262,24 +262,7 @@ export default function UsdPaymentsPage() { ); return ( - - void refetch()} - > - - - } - /> - + <> @@ -324,6 +307,16 @@ export default function UsdPaymentsPage() { {total} record{total !== 1 ? "s" : ""} + void refetch()} + > + +
@@ -421,6 +414,6 @@ export default function UsdPaymentsPage() { )} - + ); } diff --git a/apps/edr-freight-web/backoffice/src/pages/payments/PaymentsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/payments/PaymentsPage.tsx index 60044e550..2d98cc805 100644 --- a/apps/edr-freight-web/backoffice/src/pages/payments/PaymentsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/payments/PaymentsPage.tsx @@ -25,7 +25,7 @@ import { useMemo, useState } from "react"; import { useQuery } from "@tanstack/react-query"; -import { KpiStrip, PageContainer, PageHeader } from "@/components/page"; +import { KpiStrip } from "@/components/page"; import { api } from "@/services/api"; import type { PaymentMethod, PaymentRow } from "@/services/payments.service"; import { @@ -101,7 +101,8 @@ function formatDate(iso: string | null): string { const tableHeader = "text-xs font-semibold uppercase tracking-wide text-muted-foreground"; -export default function PaymentsPage() { +/** Payments tab body of `FinanceHubPage` — page chrome lives in the parent. */ +export default function PaymentsPanel() { const { pagination, setPagination } = usePagination({ pageSize: 10 }); const [query, setQuery] = useState(""); const [statusTab, setStatusTab] = useState("all"); @@ -205,12 +206,7 @@ export default function PaymentsPage() { ]; return ( - - - + - + ); } diff --git a/apps/edr-freight-web/backoffice/src/pages/reports/ReportPage.tsx b/apps/edr-freight-web/backoffice/src/pages/reports/ReportPage.tsx index 71a270a78..16651cdf2 100644 --- a/apps/edr-freight-web/backoffice/src/pages/reports/ReportPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/reports/ReportPage.tsx @@ -1,445 +1,14 @@ -import { - Button, - Card, - Group, - MultiSelect, - Select, - Text, -} from "@mantine/core"; -import { DateInput } from "@mantine/dates"; -import { keepPreviousData, useQuery } from "@tanstack/react-query"; -import { - DataTable, - DataTableFooter, - usePagination, - type ColumnDef, -} from "@edr/ui-common"; -import { Download, FileSpreadsheet, Printer, RotateCcw } from "lucide-react"; -import { useMemo } from "react"; -import { useParams, useSearchParams, Link } from "react-router-dom"; -import { - Area, - AreaChart, - Bar, - BarChart, - CartesianGrid, - Legend, - Line, - LineChart, - ResponsiveContainer, - Tooltip, - XAxis, - YAxis, -} from "recharts"; -import * as XLSX from "xlsx"; -import { ALL_TRADE_DIRECTIONS, TRADE_DIRECTION_LABELS } from "@edr/types"; +import { useParams } from "react-router-dom"; -import { KpiStrip, PageContainer, PageHeader } from "@/components/page"; -import { overviewChartColors } from "@/components/overview/overview.styles"; -import { api } from "@/services/api"; -import type { ReportQueryInput, ReportRow } from "@/types/reports"; -import { - REPORT_CONFIG_BY_KEY, - type ReportColumn, - type ReportConfig, -} from "./reportConfigs"; - -const compact = new Intl.NumberFormat("en", { notation: "compact" }); - -const UNIT_SUFFIX = { ETB: " ETB", t: " t", "%": "%", min: " min" } as const; - -function formatCell(value: unknown, col: ReportColumn): string { - if (value === null || value === undefined || value === "") return "—"; - if (col.unit || col.numeric) { - const n = Number(value); - if (!Number.isNaN(n)) { - return `${n.toLocaleString()}${col.unit ? UNIT_SUFFIX[col.unit] : ""}`; - } - } - return String(value); -} - -const toDate = (s: string | null): Date | null => (s ? new Date(s) : null); -// Mantine DateInput onChange emits a date string (or null). -const toParam = (d: Date | string | null): string | null => { - if (!d) return null; - return typeof d === "string" ? d.slice(0, 10) : d.toISOString().slice(0, 10); -}; - -function downloadBlob(content: BlobPart, type: string, filename: string) { - const url = URL.createObjectURL(new Blob([content], { type })); - const a = document.createElement("a"); - a.href = url; - a.download = filename; - a.click(); - URL.revokeObjectURL(url); -} - -function exportCsv(config: ReportConfig, rows: ReportRow[]) { - const esc = (v: unknown) => `"${String(v ?? "").replace(/"/g, '""')}"`; - const lines = [ - config.columns.map((c) => esc(c.label)).join(","), - ...rows.map((r) => config.columns.map((c) => esc(r[c.key])).join(",")), - ]; - downloadBlob(lines.join("\n"), "text/csv;charset=utf-8", `${config.key}.csv`); -} - -function exportXlsx(config: ReportConfig, rows: ReportRow[]) { - const sheetRows = rows.map((r) => - Object.fromEntries(config.columns.map((c) => [c.label, r[c.key] ?? ""])), - ); - const wb = XLSX.utils.book_new(); - XLSX.utils.book_append_sheet( - wb, - XLSX.utils.json_to_sheet(sheetRows), - config.title.slice(0, 31), - ); - XLSX.writeFile(wb, `${config.key}.xlsx`); -} - -function ReportChartView({ - config, - rows, -}: { - config: ReportConfig; - rows: ReportRow[]; -}) { - const chart = config.chart; - const data = useMemo(() => { - if (!chart) return []; - const sliced = chart.topN ? rows.slice(0, chart.topN) : rows; - // xKey "a+b" concatenates columns (e.g. origin+destination → "A → B"). - const keys = chart.xKey.split("+"); - return sliced.map((r) => ({ - ...r, - __x: - keys.length > 1 - ? keys.map((k) => String(r[k] ?? "")).join(" → ") - : String(r[chart.xKey] ?? ""), - })); - }, [chart, rows]); - - if (!chart) return null; - if (data.length === 0) { - return ( - - - No data for the selected filters - - - ); - } - - const ChartComponent = - chart.type === "bar" ? BarChart : chart.type === "line" ? LineChart : AreaChart; - - return ( - - - - - - compact.format(v)} - width={56} - /> - Number(value ?? 0).toLocaleString()} /> - {chart.series.length > 1 ? : null} - {chart.series.map((s, i) => { - const color = - overviewChartColors.pipeline[i % overviewChartColors.pipeline.length]; - if (chart.type === "bar") { - return ( - - ); - } - if (chart.type === "line") { - return ( - - ); - } - return ( - - ); - })} - - - - ); -} +import { ReportView } from "@/components/reports/ReportView"; +import { PageContainer } from "@/components/page"; export default function ReportPage() { const { reportKey = "" } = useParams<{ reportKey: string }>(); - const config = REPORT_CONFIG_BY_KEY.get(reportKey); - const [params, setParams] = useSearchParams(); - const { pagination, setPagination } = usePagination({ pageSize: 20 }); - - const setParam = (name: string, value: string | null) => { - setParams( - (prev) => { - if (value) prev.set(name, value); - else prev.delete(name); - return prev; - }, - { replace: true }, - ); - setPagination((p) => ({ ...p, pageIndex: 0 })); - }; - - const input: ReportQueryInput = { - key: reportKey, - dateFrom: params.get("dateFrom") ?? undefined, - dateTo: params.get("dateTo") ?? undefined, - granularity: - (params.get("granularity") as ReportQueryInput["granularity"]) ?? undefined, - yardIds: params.get("yardIds") ?? undefined, - statuses: params.get("statuses") ?? undefined, - direction: params.get("direction") ?? undefined, - freightType: params.get("freightType") ?? undefined, - }; - - const reportQuery = useQuery( - api.reports.run.queryOptions({ - input, - placeholderData: keepPreviousData, - staleTime: 30_000, - enabled: Boolean(config), - }), - ); - - const yardsQuery = useQuery( - api.routes.yards.queryOptions({ - staleTime: 5 * 60_000, - enabled: Boolean(config?.filters.includes("yards")), - }), - ); - - if (!config) { - return ( - - - - This report does not exist. Back to reports - - - ); - } - - const rows = reportQuery.data?.rows ?? []; - const kpis = reportQuery.data?.kpis ?? []; - const pageCount = Math.max(1, Math.ceil(rows.length / pagination.pageSize)); - - const columns: ColumnDef[] = config.columns.map((col) => ({ - accessorKey: col.key, - header: col.label, - cell: (info) => formatCell(info.getValue(), col), - })); - - const tableStatus = reportQuery.isLoading - ? "loading" - : reportQuery.isError - ? "error" - : "success"; return ( - - - - -
- } - /> - - - - setParam("dateFrom", toParam(d))} - placeholder="All time" - /> - setParam("dateTo", toParam(d))} - placeholder="All time" - /> - {config.filters.includes("granularity") ? ( - ({ - value: d, - label: TRADE_DIRECTION_LABELS[d], - }))} - value={params.get("direction")} - onChange={(v) => setParam("direction", v)} - placeholder="All" - /> - ) : null} - {config.filters.includes("freightType") ? ( - - - - - - - - - - - - - {schedule.reference ? ( - - {schedule.reference} - - ) : null} - - {schedule.route?.name ?? "Train schedule"} - - {schedule.train?.trainName ? ( - - {schedule.train.trainName} - - ) : null} - {schedule.train ? ( - - Train {schedule.train.code} - - ) : null} - - - {/* Voyage (train) number and trade direction — the two things - operations identify a run by, so they read at a glance - rather than as small badges among the rest. */} - - {schedule.trainNumber ? ( - - - Train No. - - - {schedule.trainNumber} - - - ) : null} - {schedule.voyageNumber ? ( - - - Voyage No. - - - {schedule.voyageNumber} - - - ) : null} - {/* Merging rewrites the consist, so it is offered only while - the departure can still be edited. */} - {canEditBookings ? ( - - ) : null} - {schedule.direction ? ( - - - Direction - - - {schedule.direction} - - - ) : null} - - {(schedule.stops?.length ?? 0) >= 3 || - (schedule.bookings ?? []).some( - (b) => b.tradeDirection === "DOMESTIC", - ) ? ( - + {schedule.train.trainName ?? `Train ${schedule.train.code}`} + {schedule.train.trainName ? ` · Train ${schedule.train.code}` : ""} + + ) : undefined + } + meta={ + + {schedule.reference ? ( + + {schedule.reference} + + ) : null} + + + {gatepassApplies && gatepassSecured ? ( + } + > + Gate pass secured + + ) : null} + {previewResult ? ( + - ) : ( - - - - )} - - - - - - - - {(schedule.trainSet?.wagons?.length ?? 0) > 0 ? ( - - ) : null} - {canPrintMarshalling ? ( - - ) : null} - {["DISPATCHED", "ARRIVED"].includes(schedule.status) ? ( - - ) : null} - {["DISPATCHED", "ARRIVED"].includes(schedule.status) ? ( - - ) : null} - {schedule.windowPhase === "PRE_WINDOW" ? ( - - ) : null} - {["DRAFT", "SCHEDULED"].includes(schedule.status) ? ( - - ) : null} - {gatepassApplies ? ( - gatepassSecured ? ( - + ) : null} + {(schedule.trainSet?.wagons?.length ?? 0) > 0 ? ( + + ) : null} + + + + + + + + {canPrintMarshalling ? ( + } + disabled={downloadMarshalling.isPending} + onClick={() => void openMarshallingDocument()} > - Gate pass secured - - ) : ( - - ) - ) : null} - + + ) : null} + + + } + /> - {previewResult ? ( - + + + {schedule.trainNumber ? ( + + + Train No. + + + {schedule.trainNumber} + + + ) : null} + {schedule.voyageNumber ? ( + + + Voyage No. + + + {schedule.voyageNumber} + + + ) : null} + {schedule.direction ? ( + + + Direction + + - } - > - Preview {previewResult.valid ? "valid" : "has issues"} - - ) : null} + > + {schedule.direction} + + + ) : null} + + {(schedule.stops?.length ?? 0) >= 3 || + (schedule.bookings ?? []).some( + (b) => b.tradeDirection === "DOMESTIC", + ) ? ( + + ) : ( + + + + )} diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx index 51899fc51..cfe1cc016 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx @@ -143,6 +143,9 @@ export default function TrainScheduleV2ListPage() { const [scheduleDate, setScheduleDate] = useState(""); const [trainId, setTrainId] = useState(""); const [reverseWagonOrder, setReverseWagonOrder] = useState(false); + // "" = a normal customer train; an id dedicates the departure to that + // shipping line and hides it from every customer-facing view. + const [shippingLineCompanyId, setShippingLineCompanyId] = useState(""); // Booking window for the schedule being created: off = inherit the live global // rules (the default), on = the values in `windowForm` are frozen onto it. const [configureWindow, setConfigureWindow] = useState(false); @@ -219,6 +222,15 @@ export default function TrainScheduleV2ListPage() { enabled: Boolean(routeId), }), ); + // For the create modal's dedication picker. 100 covers every line EDR deals + // with; fetched only while the modal is open. + const shippingLinesQuery = useQuery( + api.shippingLineCompanies.list.queryOptions({ + input: { page: 1, limit: 100 }, + enabled: createOpen, + staleTime: 5 * 60_000, + }), + ); const create = useMutation(api.trainScheduling.createSchedule.mutationOptions()); const dispatchSchedule = useMutation( api.trainScheduling.dispatchSchedule.mutationOptions(), @@ -559,12 +571,14 @@ export default function TrainScheduleV2ListPage() { scheduleDate: new Date(scheduleDate).toISOString(), trainId, reverseWagonOrder, + ...(shippingLineCompanyId ? { shippingLineCompanyId } : {}), ...(windowRule ? { windowRule } : {}), }, }); toast({ title: "Train schedule created" }); showScheduleWarnings(created.warnings); setReverseWagonOrder(false); + setShippingLineCompanyId(""); setConfigureWindow(false); setWindowForm(null); setCreateOpen(false); @@ -857,6 +871,19 @@ export default function TrainScheduleV2ListPage() { : "Select a route first" } /> + -