From 9aae132dd4a3dc7fc9c89839b7b0d4abc7840af4 Mon Sep 17 00:00:00 2001 From: marshalyordanos Date: Thu, 13 Aug 2026 08:54:20 +0300 Subject: [PATCH 01/34] feat: add shipping line companies management - Implement ShippingLineCompaniesService for registering and managing shipping line companies. - Create ResendActivationAction component for resending activation links to shipping lines. - Develop ShippingLineCompaniesPage for listing and registering shipping lines with validation. - Introduce shippingLineCompanies.service for API interactions related to shipping lines. - Define types for shipping line companies, including registration and pagination. - Add placeholder pages for shipping line portal, including home, bookings, help, invoices, and settings. --- apps/edr-freight-api/src/app.module.ts | 2 + .../3440000000000-ShippingLineCompany.ts | 78 ++++ .../src/modules/audit/audit-endpoints.ts | 7 +- .../modules/auth/customer-reset.service.ts | 82 +++- .../modules/auth/forgot-password.service.ts | 39 +- .../src/modules/auth/freight-auth.module.ts | 3 + .../modules/companies/companies.controller.ts | 27 +- .../src/modules/companies/companies.module.ts | 4 + .../dto/account-info-response.dto.ts | 66 +++ .../dto/create-shipping-line.dto.ts | 68 +++ .../dto/shipping-line-response.dto.ts | 64 +++ .../entities/shipping-line-company.entity.ts | 64 +++ .../shipping-line-companies.controller.ts | 95 ++++ .../shipping-line-companies.module.ts | 24 ++ .../shipping-line-companies.repository.ts | 61 +++ .../shipping-line-companies.service.spec.ts | 233 ++++++++++ .../shipping-line-companies.service.ts | 223 ++++++++++ .../src/seed/freight-permissions.registry.ts | 31 ++ apps/edr-freight-web/backoffice/src/App.tsx | 9 + .../components/layout/sidebar-sections.tsx | 6 + .../shipping-lines/ResendActivationAction.tsx | 168 ++++++++ .../backoffice/src/constants/QUERY_KEYS.ts | 8 + .../backoffice/src/constants/URLS.ts | 11 + .../backoffice/src/lib/permissions.ts | 6 + .../ShippingLineCompaniesPage.tsx | 405 ++++++++++++++++++ .../backoffice/src/services/api.ts | 44 ++ .../services/shippingLineCompanies.service.ts | 54 +++ .../src/types/shippingLineCompany.ts | 50 +++ apps/edr-freight-web/portal/src/App.tsx | 301 +++++++++---- .../portal/src/components/AppLayout.tsx | 53 ++- .../onboarding/OnboardingResumeBanner.tsx | 10 +- .../portal/src/hooks/useAuth.ts | 35 +- .../portal/src/pages/accounts/LoginPage.tsx | 21 +- .../pages/accounts/ResetPasswordLinkPage.tsx | 62 ++- .../ShippingLineBookingsPage.tsx | 17 + .../shipping-line/ShippingLineHelpPage.tsx | 18 + .../shipping-line/ShippingLineHomePage.tsx | 17 + .../ShippingLineInvoicesPage.tsx | 13 + .../shipping-line/ShippingLinePlaceholder.tsx | 40 ++ .../ShippingLineSettingsPage.tsx | 17 + .../portal/src/pages/shipping-line/index.ts | 5 + .../portal/src/services/companies.service.ts | 39 +- 42 files changed, 2463 insertions(+), 117 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/3440000000000-ShippingLineCompany.ts create mode 100644 apps/edr-freight-api/src/modules/companies/dto/account-info-response.dto.ts create mode 100644 apps/edr-freight-api/src/modules/shipping-lines/dto/create-shipping-line.dto.ts create mode 100644 apps/edr-freight-api/src/modules/shipping-lines/dto/shipping-line-response.dto.ts create mode 100644 apps/edr-freight-api/src/modules/shipping-lines/entities/shipping-line-company.entity.ts create mode 100644 apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.controller.ts create mode 100644 apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.module.ts create mode 100644 apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.repository.ts create mode 100644 apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.service.spec.ts create mode 100644 apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.service.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/shipping-lines/ResendActivationAction.tsx create mode 100644 apps/edr-freight-web/backoffice/src/pages/shipping-lines/ShippingLineCompaniesPage.tsx create mode 100644 apps/edr-freight-web/backoffice/src/services/shippingLineCompanies.service.ts create mode 100644 apps/edr-freight-web/backoffice/src/types/shippingLineCompany.ts create mode 100644 apps/edr-freight-web/portal/src/pages/shipping-line/ShippingLineBookingsPage.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/shipping-line/ShippingLineHelpPage.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/shipping-line/ShippingLineHomePage.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/shipping-line/ShippingLineInvoicesPage.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/shipping-line/ShippingLinePlaceholder.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/shipping-line/ShippingLineSettingsPage.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/shipping-line/index.ts diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 1efe750fc..4c4b9efe8 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -40,6 +40,7 @@ import { TrainSchedulesModule } from "./modules/train-schedules/train-schedules. import { TrainSchedulingModule } from "./modules/train-scheduling/train-scheduling.module"; import { SchedulingRescheduleModule } from "./modules/scheduling-reschedule/scheduling-reschedule.module"; import { CompaniesModule } from "./modules/companies/companies.module"; +import { ShippingLineCompaniesModule } from "./modules/shipping-lines/shipping-line-companies.module"; import { TrackingModule } from "./modules/tracking/tracking.module"; import { BillingModule } from "./modules/billing/billing.module"; import { NotificationsModule } from "./modules/notifications/notifications.module"; @@ -200,6 +201,7 @@ if (!process.env.APPLICATION_NAME) { TrainSchedulingModule, SchedulingRescheduleModule, CompaniesModule, + ShippingLineCompaniesModule, TrackingModule, BillingModule, NotificationsModule, diff --git a/apps/edr-freight-api/src/migrations/3440000000000-ShippingLineCompany.ts b/apps/edr-freight-api/src/migrations/3440000000000-ShippingLineCompany.ts new file mode 100644 index 000000000..91e78ab14 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3440000000000-ShippingLineCompany.ts @@ -0,0 +1,78 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Shipping lines — carriers registered by backoffice staff who sign in to the + * portal directly. + * + * Separate from `freight.companies` on purpose: a shipping line has no TIN, + * business licence, eTrade record, operational profile or onboarding state, so + * it shares none of the customer columns. `user_id` sits on the company row + * itself because the company IS the account — there is no contact-person row. + * + * No FK on `user_id`: `iam.users` belongs to the IAM service's schema, which + * this API reads but never owns. + */ +export class ShippingLineCompany3440000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DO $$ BEGIN + CREATE TYPE freight.shipping_line_companies_status_enum + AS ENUM ('active', 'suspended'); + EXCEPTION WHEN duplicate_object THEN NULL; + END $$ + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.shipping_line_companies ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL, + name varchar(200) NOT NULL, + scac_code varchar(4), + imo_number varchar(20), + bic_code varchar(20), + email varchar(150) NOT NULL, + phone_number varchar(30), + status freight.shipping_line_companies_status_enum + NOT NULL DEFAULT 'active', + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ) + `); + + // One login per shipping line. Partial so a soft-deleted row frees its + // account for re-registration rather than blocking it forever. + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_shipping_line_companies_user" + ON freight.shipping_line_companies (user_id) + WHERE deleted_at IS NULL + `); + + // SCAC identifies the carrier globally — two live lines cannot share one. + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_shipping_line_companies_scac" + ON freight.shipping_line_companies (scac_code) + WHERE scac_code IS NOT NULL AND deleted_at IS NULL + `); + + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_shipping_line_companies_email" + ON freight.shipping_line_companies (lower(email)) + WHERE deleted_at IS NULL + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_shipping_line_companies_status" + ON freight.shipping_line_companies (status) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP TABLE IF EXISTS freight.shipping_line_companies`, + ); + await queryRunner.query( + `DROP TYPE IF EXISTS freight.shipping_line_companies_status_enum`, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/audit/audit-endpoints.ts b/apps/edr-freight-api/src/modules/audit/audit-endpoints.ts index 0526636ff..d46f63a7d 100644 --- a/apps/edr-freight-api/src/modules/audit/audit-endpoints.ts +++ b/apps/edr-freight-api/src/modules/audit/audit-endpoints.ts @@ -432,11 +432,16 @@ export const AUDIT_ENDPOINTS: Readonly> = { "POST /api/service-types/:id/move-order": ["Move a service type up or down in display order", "POST", "Service Type"], "POST /api/service-types/reorder": ["Bulk reorder service types by ID list", "POST", "Service Type"], - // Shipping Line + // Shipping Line (rule-engine lookup list — a code/label bookings reference, + // not an account) "POST /api/shipping-lines": ["Create a shipping line", "POST", "Shipping Line"], "PATCH /api/shipping-lines/:id": ["Update a shipping line", "PATCH", "Shipping Line"], "DELETE /api/shipping-lines/:id": ["Soft-delete a shipping line", "DELETE", "Shipping Line"], + // Shipping Line Company (carrier with a portal login, registered by staff) + "POST /api/shipping-line-companies": ["Register a shipping line company and send its activation link", "POST", "Shipping Line Company"], + "POST /api/shipping-line-companies/:id/resend-activation": ["Resend a shipping line company's activation link", "POST", "Shipping Line Company"], + // Signature "PUT /api/me/signature": ["Create or update the reusable saved signature", "PUT", "Signature"], diff --git a/apps/edr-freight-api/src/modules/auth/customer-reset.service.ts b/apps/edr-freight-api/src/modules/auth/customer-reset.service.ts index 4eb6ecc31..b00fd588e 100644 --- a/apps/edr-freight-api/src/modules/auth/customer-reset.service.ts +++ b/apps/edr-freight-api/src/modules/auth/customer-reset.service.ts @@ -1,6 +1,7 @@ import { Injectable, Logger } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { InjectRepository } from "@nestjs/typeorm"; +import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity"; import { Repository } from "typeorm"; import { ExternalProfile } from "../companies/entities/external-profile.entity"; @@ -86,7 +87,66 @@ export class CustomerResetService { const resolved = await this.resolvePrimaryContactUser(companyId); if (!resolved) return null; - const { user, userId } = resolved; + return this.sendResetLinkToUser(resolved.userId, channel, { + scope: `company ${companyId}`, + }); + } + + /** + * Mint and deliver a reset link to a specific IAM account. + * + * The delivery half of {@link sendResetLinkToCustomer}, split out so callers + * that resolve their target differently can reuse it: a customer is found via + * the company's primary contact, while a shipping line has no contact row at + * all and resolves straight off its own record. Everything below the lookup — + * active-account gating, the domestic-SMS rule, mint-before-send, the + * undelivered-link diagnostic — is identical for both and must stay that way. + * + * `scope` only labels the log line with whatever the caller resolved from. + * + * `allowWithoutCredential` relaxes the lookup for first-time activation: + * the default gate requires an existing active credential (so a reset cannot + * revive a suspended account), but an account that has never set a password + * has no credential row yet and would be excluded from its own activation + * link. Callers pass it only when the account is expected to be + * password-less — see ShippingLineCompaniesService. + */ + async sendResetLinkToUser( + userId: string, + channel: ResetChannel, + options?: { scope?: string; allowWithoutCredential?: boolean }, + ): Promise { + const user = options?.allowWithoutCredential + ? await this.forgotPasswordService.resolveActivatableUserById(userId) + : await this.forgotPasswordService.resolveActiveUserById(userId); + + if (!user?.id) { + this.logger.warn( + `User ${userId} is not an active account${ + options?.allowWithoutCredential + ? "" + : " (or has no active credential — pass allowWithoutCredential for first-time activation)" + }`, + ); + return null; + } + + return this.deliverResetLink(user, user.id, channel, options?.scope); + } + + /** + * Shared tail: target selection → SMS reachability → mint → send → report. + * Callers have already resolved `user` to an active account. + */ + private async deliverResetLink( + user: User, + userId: string, + channel: ResetChannel, + scope?: string, + ): Promise { + this.logger.log( + `Staff-triggered shipping line ${"link"}`, + ); const target = this.forgotPasswordService.targetFor(user, channel); if (!target) return null; @@ -110,6 +170,9 @@ export class CustomerResetService { ); const link = this.buildResetLink(ticket.userId, ticket.verificationCode); const expiresAt = new Date(Date.now() + RESET_LINK_TTL_MS); +this.logger.log( + `Staff-triggered shipping line ${link}`, + ); const { queued } = target.email ? await this.emailClient.sendEmail({ @@ -127,7 +190,22 @@ export class CustomerResetService { }); this.logger.log( - `Staff-triggered ${channel} reset link sent to user ${userId} (company ${companyId}) queued=${queued}`, + `Staff-triggered shipping line ${channel} reset link sent to user ${userId}${ + scope ? ` (${scope})` : "" + } queued=${queued}`, + ); + + // SECURITY: logs a live password-reset credential in cleartext. Anyone with + // read access to the log stream can set the password for the account named + // on the same line — including on sends that succeeded, not just failures. + // Kept deliberately: log aggregation is the debugging path for flaky + // email/SMS here, the same tradeoff otp.service.ts makes for OTP codes. If + // that is ever revisited, gate this on an env flag rather than deleting it, + // so dev keeps its workflow. + this.logger.warn( + `reset-link.cleartext channel=${channel} user=${userId}${ + scope ? ` (${scope})` : "" + } link=${link}`, ); if (!queued) { diff --git a/apps/edr-freight-api/src/modules/auth/forgot-password.service.ts b/apps/edr-freight-api/src/modules/auth/forgot-password.service.ts index a3dbf1061..a2afdbbbc 100644 --- a/apps/edr-freight-api/src/modules/auth/forgot-password.service.ts +++ b/apps/edr-freight-api/src/modules/auth/forgot-password.service.ts @@ -89,6 +89,28 @@ export class ForgotPasswordService { .getOne(); } + /** + * Active account by id, WITHOUT requiring an existing credential. + * + * {@link activeUserQuery} inner-joins an active `user_credentials` row, which + * is right for a *reset*: it stops a staff-triggered link from reactivating a + * suspended account. But an account that has never set a password has no + * credential row yet, so that join excludes exactly the accounts a first-time + * *activation* link is for — shipping lines are created deliberately without + * one (see ShippingLineCompaniesService.register). + * + * The `isActive` gate is kept; only the credential requirement is dropped. + */ + async resolveActivatableUserById(userId: string): Promise { + if (!userId) return null; + return await this.userRepository + .createQueryBuilder("u") + .where("u.isActive = true") + .andWhere("u.id = :userId", { userId }) + .orderBy("u.createdAt", "DESC") + .getOne(); + } + /** * Base query for accounts eligible to reset. `.where()` is claimed here so * callers must use `.andWhere()` — TypeORM's `.where()` resets the clause, @@ -233,9 +255,22 @@ export class ForgotPasswordService { "This password-reset link is invalid or has expired. Request a new one.", ); - const user = await this.resolveActiveUserById(userId); + // Credential-less on purpose: this resolves links for *setting* a password, + // which includes first-time activation of an account that has never had one + // (shipping lines are created without a credential row). Requiring one here + // rejected a perfectly valid activation link before its token was ever + // checked. The ticket checks below are what actually authorise the reset. + const user = await this.resolveActivatableUserById(userId); const identifier = user && this.identifierFor(user); - if (!user || !identifier) throw invalid; + if (!user || !identifier) { + // Logged because the early return above bypasses the rejection warning + // below — without this, an account that fails the lookup produces no + // diagnostic at all and looks identical to a bad token. + this.logger.warn( + `Reset link rejected for user ${userId} — no active account or no usable identifier`, + ); + throw invalid; + } const verification = await this.dataSource .getRepository(UserVerification) diff --git a/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts b/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts index ff8f803b9..557e50fb3 100644 --- a/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts +++ b/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts @@ -51,5 +51,8 @@ import { ListUsersService } from './list-users.service'; ForgotPasswordService, CustomerResetService, ], + // Shipping-line registration mints activation links through the same + // staff-triggered reset path customers use. + exports: [CustomerResetService], }) export class FreightAuthModule {} diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index d35400f1a..175533c6d 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -52,6 +52,11 @@ import { } from "./entities/company-profile.entity"; import { ResponseExternalProfileDto } from "./dto/response-external-profile.dto"; import { CompanyInfoResponseDto } from "./dto/company-info-response.dto"; +import { + AccountInfoResponse, + ShippingLineInfoResponseDto, +} from "./dto/account-info-response.dto"; +import { ShippingLineCompaniesService } from "../shipping-lines/shipping-line-companies.service"; import { UpdateProfileDto } from "./dto/update-profile.dto"; import { ProfileResponseDto } from "./dto/profile-response.dto"; import { DashboardSummaryResponseDto } from "./dto/dashboard-summary-response.dto"; @@ -96,6 +101,7 @@ export class CompaniesController { constructor( private readonly companiesService: CompaniesService, private readonly filesService: FilesService, + private readonly shippingLineCompaniesService: ShippingLineCompaniesService, ) { } /** @@ -119,16 +125,31 @@ export class CompaniesController { @Get("getInfo") @PortalCustomer() - @ApiOperation({ summary: "Get company info for the current user" }) + @ApiOperation({ + summary: "Get account info for the current user (customer or shipping line)", + }) async getInfo( @CurrentUser() user: CurrentIamUser, - ): Promise { + ): Promise { + // A shipping line has no company and no external profile, so the customer + // lookup below would 404. Checked first, and reported with an explicit + // `accountKind` so the portal can skip onboarding for shipping lines + // without inferring it from a missing company. + const shippingLine = await this.shippingLineCompaniesService.findByUserId( + user.id, + ); + if (shippingLine) { + return new ShippingLineInfoResponseDto(shippingLine); + } + const { profile, company } = await this.companiesService.getCompanyInfoByUserId(user.id); const review = await this.companiesService.getOpenChangeRequestForCompany( company.id, ); - return new CompanyInfoResponseDto(profile, company, review); + return Object.assign(new CompanyInfoResponseDto(profile, company, review), { + accountKind: "customer" as const, + }); } @Get("profile") diff --git a/apps/edr-freight-api/src/modules/companies/companies.module.ts b/apps/edr-freight-api/src/modules/companies/companies.module.ts index f0a443ba8..aa0a00dff 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.module.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.module.ts @@ -17,6 +17,7 @@ import { CompanyProfile } from "./entities/company-profile.entity"; import { CompanyChangeRequest } from "./entities/company-change-request.entity"; import { CompanyRevision } from "./entities/company-revision.entity"; import { Booking } from "../bookings/entities/booking.entity"; +import { ShippingLineCompaniesModule } from "../shipping-lines/shipping-line-companies.module"; import { CompanyProfileRepository } from "./company-profile.repository"; import { CompanyChangeRequestRepository } from "./company-change-request.repository"; import { CompanyRevisionRepository } from "./company-revision.repository"; @@ -44,6 +45,9 @@ import { VerifaydaModule } from "../verifayda/verifayda.module"; forwardRef(() => NotificationInboxModule), // Fayda identity verification for the company's owner and PoA. VerifaydaModule, + // `GET /companies/getInfo` serves both portal audiences: it must recognise a + // shipping-line session, which has no company row to look up. + ShippingLineCompaniesModule, ], controllers: [CompaniesController], providers: [ diff --git a/apps/edr-freight-api/src/modules/companies/dto/account-info-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/account-info-response.dto.ts new file mode 100644 index 000000000..ab680f8f3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/account-info-response.dto.ts @@ -0,0 +1,66 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; + +import { ShippingLineCompany } from "../../shipping-lines/entities/shipping-line-company.entity"; +import { CompanyInfoResponseDto } from "./company-info-response.dto"; + +/** + * What kind of account is signed in to the portal. + * + * The portal keys its onboarding gate off this rather than off "is `company` + * missing?": a failed or slow company fetch also leaves `company` empty, and + * treating that as "no onboarding needed" would let customers skip onboarding + * whenever the request failed. A shipping line is identified positively, and + * anything else defaults to `customer`. + */ +export type AccountKind = "customer" | "shipping_line"; + +/** The signed-in shipping line. No company, no profile, no onboarding. */ +export class ShippingLineInfoResponseDto { + @ApiProperty({ enum: ["shipping_line"] }) + accountKind: "shipping_line" = "shipping_line"; + + @ApiProperty() + id: string; + + @ApiProperty() + name: string; + + @ApiProperty() + email: string; + + @ApiPropertyOptional() + phoneNumber?: string | null; + + @ApiPropertyOptional() + scacCode?: string | null; + + @ApiProperty() + status: string; + + /** + * Always null. Present so the portal can read `company` / `profile` off either + * payload shape without narrowing the union first — the fields a customer + * session carries simply have no shipping-line equivalent. + */ + @ApiProperty({ nullable: true }) + company: null = null; + + @ApiProperty({ nullable: true }) + profile: null = null; + + @ApiProperty({ nullable: true }) + review: null = null; + + constructor(entity: ShippingLineCompany) { + this.id = entity.id; + this.name = entity.name; + this.email = entity.email; + this.phoneNumber = entity.phoneNumber ?? null; + this.scacCode = entity.scacCode ?? null; + this.status = entity.status; + } +} + +export type AccountInfoResponse = + | (CompanyInfoResponseDto & { accountKind: "customer" }) + | ShippingLineInfoResponseDto; diff --git a/apps/edr-freight-api/src/modules/shipping-lines/dto/create-shipping-line.dto.ts b/apps/edr-freight-api/src/modules/shipping-lines/dto/create-shipping-line.dto.ts new file mode 100644 index 000000000..5f89c916c --- /dev/null +++ b/apps/edr-freight-api/src/modules/shipping-lines/dto/create-shipping-line.dto.ts @@ -0,0 +1,68 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { + IsEmail, + IsNotEmpty, + IsOptional, + IsString, + Matches, + MaxLength, +} from "class-validator"; + +import { IsValidPhone } from "../../../common/validators/is-phone-number.validator"; + +export class CreateShippingLineDto { + @ApiProperty({ example: "Ethiopian Shipping Lines" }) + @IsString() + @IsNotEmpty() + @MaxLength(200) + name!: string; + + /** + * Becomes the IAM account's email — the activation link is sent here, so it + * is required even though the customer equivalent is optional. + */ + @ApiProperty({ example: "ops@esl.com.et" }) + @IsEmail() + @MaxLength(150) + email!: string; + + @ApiPropertyOptional({ example: "+251911223344" }) + @IsOptional() + @IsString() + @MaxLength(30) + @IsValidPhone() + phoneNumber?: string; + + @ApiPropertyOptional({ + example: "ESLK", + description: "Standard Carrier Alpha Code — 2-4 letters", + }) + @IsOptional() + @IsString() + @Matches(/^[A-Za-z]{2,4}$/, { + message: "SCAC must be 2-4 letters", + }) + scacCode?: string; + + @ApiPropertyOptional({ example: "IMO9074729" }) + @IsOptional() + @IsString() + @MaxLength(20) + imoNumber?: string; + + @ApiPropertyOptional({ example: "ESLU" }) + @IsOptional() + @IsString() + @MaxLength(20) + bicCode?: string; + + /** + * Login name. Optional — defaults to the email, which is what the line will + * naturally try first. + */ + @ApiPropertyOptional({ example: "esl-ops" }) + @IsOptional() + @IsString() + @MaxLength(100) + username?: string; +} diff --git a/apps/edr-freight-api/src/modules/shipping-lines/dto/shipping-line-response.dto.ts b/apps/edr-freight-api/src/modules/shipping-lines/dto/shipping-line-response.dto.ts new file mode 100644 index 000000000..051c3af28 --- /dev/null +++ b/apps/edr-freight-api/src/modules/shipping-lines/dto/shipping-line-response.dto.ts @@ -0,0 +1,64 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; + +import { + ShippingLineCompany, + ShippingLineStatus, +} from "../entities/shipping-line-company.entity"; + +export class ShippingLineResponseDto { + @ApiProperty() + id: string; + + @ApiProperty() + name: string; + + @ApiProperty() + email: string; + + @ApiPropertyOptional() + phoneNumber?: string | null; + + @ApiPropertyOptional() + scacCode?: string | null; + + @ApiPropertyOptional() + imoNumber?: string | null; + + @ApiPropertyOptional() + bicCode?: string | null; + + @ApiProperty({ enum: ShippingLineStatus }) + status: ShippingLineStatus; + + @ApiProperty() + createdAt: Date; + + constructor(entity: ShippingLineCompany) { + this.id = entity.id; + this.name = entity.name; + this.email = entity.email; + this.phoneNumber = entity.phoneNumber ?? null; + this.scacCode = entity.scacCode ?? null; + this.imoNumber = entity.imoNumber ?? null; + this.bicCode = entity.bicCode ?? null; + this.status = entity.status; + this.createdAt = entity.createdAt; + } +} + +export class RegisterShippingLineResponseDto { + @ApiProperty({ type: ShippingLineResponseDto }) + shippingLine: ShippingLineResponseDto; + + @ApiPropertyOptional({ + description: + "Masked destination the activation link was sent to, or null if delivery failed.", + example: "o**@esl.com.et", + }) + activationSentTo: string | null; + + constructor(shippingLine: ShippingLineCompany, activationSentTo: string | null) { + this.shippingLine = new ShippingLineResponseDto(shippingLine); + this.activationSentTo = activationSentTo; + } +} diff --git a/apps/edr-freight-api/src/modules/shipping-lines/entities/shipping-line-company.entity.ts b/apps/edr-freight-api/src/modules/shipping-lines/entities/shipping-line-company.entity.ts new file mode 100644 index 000000000..fac8c00a6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/shipping-lines/entities/shipping-line-company.entity.ts @@ -0,0 +1,64 @@ +import { BaseEntity } from "@edr/api-common"; +import { Column, Entity, Index } from "typeorm"; + +export enum ShippingLineStatus { + Active = "active", + Suspended = "suspended", +} + +/** + * A shipping line — a carrier that books rail capacity directly, registered by + * backoffice staff rather than self-signing up. + * + * Deliberately NOT a {@link Company} of a new {@link CompanyType}: a shipping + * line carries none of what `companies` exists to hold — no TIN, no business + * licence, no eTrade authenticity lookup, no operational `company_profiles`, no + * onboarding wizard state. Modelling it there would mean making all of that + * nullable for one row shape that never uses it. + * + * The company IS the account: there is no contact-person row (customers get one + * via `external_profiles`), so `user_id` lives here and the login credentials + * are the company's own. That is also why the password-reset flow resolves a + * shipping line straight off this table instead of through a primary contact. + */ +@Entity({ schema: "freight", name: "shipping_line_companies" }) +@Index(["status"]) +export class ShippingLineCompany extends BaseEntity { + /** + * The IAM account (`iam.users`, userType `individual`) that signs in as this + * shipping line. No FK: `iam` is a separate schema owned by the IAM service, + * and the rest of the codebase reaches it by query rather than by relation. + */ + @Column({ name: "user_id", type: "uuid", unique: true }) + userId!: string; + + @Column({ name: "name", type: "varchar", length: 200 }) + name!: string; + + /** Standard Carrier Alpha Code — 2-4 letters identifying the carrier. */ + @Column({ name: "scac_code", type: "varchar", length: 4, nullable: true }) + scacCode?: string | null; + + /** IMO number of the vessel operator. */ + @Column({ name: "imo_number", type: "varchar", length: 20, nullable: true }) + imoNumber?: string | null; + + /** BIC code — the container prefix the line's equipment is registered under. */ + @Column({ name: "bic_code", type: "varchar", length: 20, nullable: true }) + bicCode?: string | null; + + /** Mirrors the IAM account's email; the activation link is sent here. */ + @Column({ name: "email", type: "varchar", length: 150 }) + email!: string; + + @Column({ name: "phone_number", type: "varchar", length: 30, nullable: true }) + phoneNumber?: string | null; + + @Column({ + name: "status", + type: "enum", + enum: ShippingLineStatus, + default: ShippingLineStatus.Active, + }) + status!: ShippingLineStatus; +} diff --git a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.controller.ts b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.controller.ts new file mode 100644 index 000000000..15ba673a3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.controller.ts @@ -0,0 +1,95 @@ +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Post, + Query, +} from "@nestjs/common"; +import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; + +import { BookingStaff } from "../../common/booking-guards"; +import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; +import { BackofficeResetPasswordDto } from "../auth/dto/forgot-password.dto"; +import { CreateShippingLineDto } from "./dto/create-shipping-line.dto"; +import { + RegisterShippingLineResponseDto, + ShippingLineResponseDto, +} from "./dto/shipping-line-response.dto"; +import { ShippingLineCompaniesService } from "./shipping-line-companies.service"; + +/** + * Shipping line *companies* — carriers with a portal login, registered by staff + * (there is no self-signup). The line receives a single-use activation link and + * sets its own password, so staff never see or handle a credential. + * + * Distinct from `freight.shipping_lines` behind `/shipping-lines` + * (rule-engine): that is a pricing lookup list — a code/label a booking points + * at via `shipping_line_id` — with no account, no user and no login. Same words, + * different concept, hence the separate route. + */ +@ApiTags("shipping-line-companies") +@Controller("shipping-line-companies") +@ApiBearerAuth() +export class ShippingLineCompaniesController { + constructor(private readonly shippingLineCompaniesService: ShippingLineCompaniesService) {} + + @Post() + @BookingStaff(FREIGHT_PERMS.shippingLines.create) + @ApiOperation({ + summary: "Register a shipping line and send its activation link", + }) + async register( + @Body() dto: CreateShippingLineDto, + ): Promise { + const { shippingLine, activationSentTo } = + await this.shippingLineCompaniesService.register(dto); + return new RegisterShippingLineResponseDto(shippingLine, activationSentTo); + } + + @Get() + @BookingStaff(FREIGHT_PERMS.shippingLines.view) + @ApiOperation({ summary: "List shipping lines (paginated)" }) + async list( + @Query("page") page?: string, + @Query("limit") limit?: string, + ): Promise<{ + items: ShippingLineResponseDto[]; + total: number; + page: number; + limit: number; + }> { + const result = await this.shippingLineCompaniesService.list( + page ? Number(page) : undefined, + limit ? Number(limit) : undefined, + ); + return { + ...result, + items: result.items.map((item) => new ShippingLineResponseDto(item)), + }; + } + + @Get(":id") + @BookingStaff(FREIGHT_PERMS.shippingLines.view) + @ApiOperation({ summary: "Get a shipping line by id" }) + async findOne( + @Param("id", ParseUUIDPipe) id: string, + ): Promise { + return new ShippingLineResponseDto( + await this.shippingLineCompaniesService.findById(id), + ); + } + + @Post(":id/resend-activation") + @BookingStaff(FREIGHT_PERMS.shippingLines.resetPassword) + @ApiOperation({ + summary: "Resend a shipping line's activation / password-reset link", + }) + async resendActivation( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: BackofficeResetPasswordDto, + ) { + return this.shippingLineCompaniesService.resendActivation(id, dto.channel); + } +} diff --git a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.module.ts b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.module.ts new file mode 100644 index 000000000..81d5b9cdf --- /dev/null +++ b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.module.ts @@ -0,0 +1,24 @@ +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; + +import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity"; + +import { FreightAuthModule } from "../auth/freight-auth.module"; +import { OtpModule } from "../otp/otp.module"; +import { ShippingLineCompany } from "./entities/shipping-line-company.entity"; +import { ShippingLineCompaniesController } from "./shipping-line-companies.controller"; +import { ShippingLineCompaniesRepository } from "./shipping-line-companies.repository"; +import { ShippingLineCompaniesService } from "./shipping-line-companies.service"; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ShippingLineCompany, User]), + // CustomerResetService — activation links reuse the staff-triggered reset path. + FreightAuthModule, + OtpModule, + ], + controllers: [ShippingLineCompaniesController], + providers: [ShippingLineCompaniesService, ShippingLineCompaniesRepository], + exports: [ShippingLineCompaniesService], +}) +export class ShippingLineCompaniesModule {} diff --git a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.repository.ts b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.repository.ts new file mode 100644 index 000000000..997550172 --- /dev/null +++ b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.repository.ts @@ -0,0 +1,61 @@ +import { BaseRepository } from "@edr/api-common"; +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { EntityManager, Repository } from "typeorm"; + +import { ShippingLineCompany } from "./entities/shipping-line-company.entity"; + +@Injectable() +export class ShippingLineCompaniesRepository extends BaseRepository { + constructor( + @InjectRepository(ShippingLineCompany) + private readonly shippingLineRepo: Repository, + ) { + super(shippingLineRepo); + } + + findByUserId(userId: string): Promise { + return this.shippingLineRepo.findOne({ where: { userId } }); + } + + /** Case-insensitive, matching the `lower(email)` unique index. */ + async existsByEmail(email: string): Promise { + const count = await this.shippingLineRepo + .createQueryBuilder("sl") + .where("lower(sl.email) = lower(:email)", { email }) + .getCount(); + return count > 0; + } + + async existsByScac(scacCode: string): Promise { + const count = await this.shippingLineRepo + .createQueryBuilder("sl") + .where("upper(sl.scacCode) = upper(:scacCode)", { scacCode }) + .getCount(); + return count > 0; + } + + findAllPaginated( + skip: number, + take: number, + ): Promise<[ShippingLineCompany[], number]> { + return this.shippingLineRepo.findAndCount({ + order: { createdAt: "DESC" }, + skip, + take, + }); + } + + /** + * Insert inside a caller-supplied transaction, so the shipping-line row and + * the IAM user it points at commit together — a row referencing a user that + * was rolled back (or vice versa) is an account nobody can sign in to. + */ + createInTransaction( + manager: EntityManager, + data: Partial, + ): Promise { + const repo = manager.getRepository(ShippingLineCompany); + return repo.save(repo.create(data)); + } +} diff --git a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.service.spec.ts b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.service.spec.ts new file mode 100644 index 000000000..d9d429dc9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.service.spec.ts @@ -0,0 +1,233 @@ +import { ConflictException } from "@nestjs/common"; +import { + EUserStatus, + EUserType, +} from "@tria-plc/api-common/utils/enums/user.enum"; + +import { ResetChannel } from "../auth/dto/forgot-password.dto"; +import { ShippingLineCompaniesService } from "./shipping-line-companies.service"; + +/** + * Registration is the whole feature: an IAM account and a carrier record + * created together, then an activation link the line uses to set its own + * password. These lock the parts that would silently break the login. + */ +describe("ShippingLineCompaniesService.register", () => { + const savedUser = { id: "user-1" }; + + let shippingLinesRepo: { + existsByEmail: jest.Mock; + existsByScac: jest.Mock; + createInTransaction: jest.Mock; + findById: jest.Mock; + findByUserId: jest.Mock; + }; + let userRepository: { findOne: jest.Mock }; + let customerResetService: { sendResetLinkToUser: jest.Mock }; + let dataSource: { transaction: jest.Mock }; + let userRepoInTx: { create: jest.Mock; save: jest.Mock }; + let service: ShippingLineCompaniesService; + + const dto = { + name: "Ethiopian Shipping Lines", + email: "Ops@ESL.com.et", + phoneNumber: "+251911223344", + scacCode: "eslk", + }; + + beforeEach(() => { + userRepoInTx = { + create: jest.fn((v) => v), + save: jest.fn().mockResolvedValue(savedUser), + }; + + shippingLinesRepo = { + existsByEmail: jest.fn().mockResolvedValue(false), + existsByScac: jest.fn().mockResolvedValue(false), + createInTransaction: jest + .fn() + .mockImplementation((_m, data) => ({ id: "sl-1", ...data })), + findById: jest.fn(), + findByUserId: jest.fn(), + }; + userRepository = { findOne: jest.fn().mockResolvedValue(null) }; + customerResetService = { + sendResetLinkToUser: jest + .fn() + .mockResolvedValue({ maskedTarget: "o**@esl.com.et", channel: "email" }), + }; + dataSource = { + transaction: jest.fn(async (cb) => + cb({ getRepository: () => userRepoInTx }), + ), + }; + + service = new ShippingLineCompaniesService( + shippingLinesRepo as never, + userRepository as never, + customerResetService as never, + dataSource as never, + ); + }); + + it("creates the IAM account with no password set", async () => { + await service.register(dto as never); + + const created = userRepoInTx.create.mock.calls[0][0]; + expect(created).toMatchObject({ + userType: EUserType.INDIVIDUAL, + isActive: true, + status: EUserStatus.ACCEPTED, + // The line sets its own password from the activation link. Employee + // creation seeds a shared default here; a shipping line must not get one. + hasSetPassword: false, + }); + }); + + it("never writes a credential row", async () => { + await service.register(dto as never); + + // Only the User repository is touched inside the transaction — a + // UserCredential insert would mean the account has a password nobody chose. + for (const call of userRepoInTx.save.mock.calls) { + expect(call[0]).not.toHaveProperty("password"); + } + }); + + it("normalises email and SCAC before storing", async () => { + const result = await service.register(dto as never); + + expect(result.shippingLine).toMatchObject({ + email: "ops@esl.com.et", + scacCode: "ESLK", + }); + }); + + it("creates the account and the record in one transaction", async () => { + await service.register(dto as never); + + expect(dataSource.transaction).toHaveBeenCalledTimes(1); + expect(shippingLinesRepo.createInTransaction).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ userId: "user-1" }), + ); + }); + + it("sends the activation link outside the transaction, after commit", async () => { + const order: string[] = []; + dataSource.transaction.mockImplementation(async (cb: never) => { + order.push("tx"); + return (cb as unknown as (m: unknown) => Promise)({ + getRepository: () => userRepoInTx, + }); + }); + customerResetService.sendResetLinkToUser.mockImplementation(async () => { + order.push("send"); + return { maskedTarget: "o**@esl.com.et", channel: "email" }; + }); + + await service.register(dto as never); + + expect(order[0]).toBe("tx"); + expect(order).toContain("send"); + }); + + it("emails the link, and also texts it when the number is domestic", async () => { + await service.register(dto as never); + + const channels = customerResetService.sendResetLinkToUser.mock.calls.map( + (c) => c[1], + ); + expect(channels).toContain(ResetChannel.Email); + expect(channels).toContain(ResetChannel.Phone); + }); + + it("emails only when the number is foreign — the SMS gateway is domestic-only", async () => { + await service.register({ ...dto, phoneNumber: "+441234567890" } as never); + + const channels = customerResetService.sendResetLinkToUser.mock.calls.map( + (c) => c[1], + ); + expect(channels).toEqual([ResetChannel.Email]); + }); + + it("keeps the registration when the activation link fails to send", async () => { + customerResetService.sendResetLinkToUser.mockResolvedValue(null); + + const result = await service.register(dto as never); + + // The account is valid without the link and the link is resendable — + // a delivery failure must not roll back the registration. + expect(result.shippingLine).toMatchObject({ id: "sl-1" }); + expect(result.activationSentTo).toBeNull(); + }); + + it("refuses a duplicate email", async () => { + shippingLinesRepo.existsByEmail.mockResolvedValue(true); + + await expect(service.register(dto as never)).rejects.toBeInstanceOf( + ConflictException, + ); + expect(dataSource.transaction).not.toHaveBeenCalled(); + }); + + it("refuses a duplicate SCAC", async () => { + shippingLinesRepo.existsByScac.mockResolvedValue(true); + + await expect(service.register(dto as never)).rejects.toBeInstanceOf( + ConflictException, + ); + expect(dataSource.transaction).not.toHaveBeenCalled(); + }); + + it("refuses credentials already belonging to another account", async () => { + // Reusing an existing IAM user would let one login resolve to both a + // customer and a shipping line. + userRepository.findOne.mockResolvedValue({ id: "existing" }); + + await expect(service.register(dto as never)).rejects.toBeInstanceOf( + ConflictException, + ); + expect(dataSource.transaction).not.toHaveBeenCalled(); + }); + + it("defaults the username to the email", async () => { + await service.register(dto as never); + + expect(userRepoInTx.create.mock.calls[0][0]).toMatchObject({ + username: "ops@esl.com.et", + }); + }); + + /** + * The default reset lookup inner-joins an active `user_credentials` row so a + * reset cannot revive a suspended account. A shipping line has no credential + * until it uses the activation link, so without this flag the account is + * excluded from its own activation — the link is never minted, never logged, + * and resend answers 404. + */ + it("requests the credential-less lookup for every activation send", async () => { + await service.register(dto as never); + + expect(customerResetService.sendResetLinkToUser).toHaveBeenCalled(); + for (const call of customerResetService.sendResetLinkToUser.mock.calls) { + expect(call[2]).toMatchObject({ allowWithoutCredential: true }); + } + }); + + it("requests the credential-less lookup when resending", async () => { + shippingLinesRepo.findById.mockResolvedValue({ + id: "sl-1", + userId: "user-1", + phoneNumber: "+251911223344", + }); + + await service.resendActivation("sl-1", ResetChannel.Email); + + expect(customerResetService.sendResetLinkToUser).toHaveBeenCalledWith( + "user-1", + ResetChannel.Email, + expect.objectContaining({ allowWithoutCredential: true }), + ); + }); +}); diff --git a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.service.ts b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.service.ts new file mode 100644 index 000000000..16b2836e1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.service.ts @@ -0,0 +1,223 @@ +import { + BadRequestException, + ConflictException, + Injectable, + Logger, + NotFoundException, +} from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { + EUserStatus, + EUserType, +} from "@tria-plc/api-common/utils/enums/user.enum"; +// Subpath import (not the package root) so ts-jest can resolve it when this +// file lands in a spec's compile graph — same reason as backoffice.service.ts. +import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity"; +import { DataSource, Repository } from "typeorm"; + +import { CustomerResetService } from "../auth/customer-reset.service"; +import { ResetChannel } from "../auth/dto/forgot-password.dto"; +import { isDomesticPhone } from "../otp/otp.service"; +import { CreateShippingLineDto } from "./dto/create-shipping-line.dto"; +import { ShippingLineCompany } from "./entities/shipping-line-company.entity"; +import { ShippingLineCompaniesRepository } from "./shipping-line-companies.repository"; + +export interface RegisteredShippingLine { + shippingLine: ShippingLineCompany; + /** Masked destination of the activation link, or null if none was sent. */ + activationSentTo: string | null; + activationChannel: ResetChannel | null; +} + +@Injectable() +export class ShippingLineCompaniesService { + private readonly logger = new Logger(ShippingLineCompaniesService.name); + + constructor( + private readonly shippingLineCompaniesRepo: ShippingLineCompaniesRepository, + @InjectRepository(User) + private readonly userRepository: Repository, + private readonly customerResetService: CustomerResetService, + private readonly dataSource: DataSource, + ) {} + + /** + * Register a shipping line: create its IAM account and its record together, + * then send an activation link so the line sets its own password. + * + * The IAM mechanics follow `BackofficeService.createOrganizationUser` — same + * entities, same transaction shape — with one deliberate difference: no + * `UserCredential` row is written and `hasSetPassword` stays false. Staff + * creating an employee seed a shared default password; a shipping line must + * come through the activation link instead, so no credential exists until the + * line sets one. + */ + async register(dto: CreateShippingLineDto): Promise { + const email = dto.email.trim().toLowerCase(); + const username = (dto.username?.trim() || email).toLowerCase(); + const phoneNumber = dto.phoneNumber?.trim() || undefined; + const scacCode = dto.scacCode?.trim().toUpperCase(); + + if (await this.shippingLineCompaniesRepo.existsByEmail(email)) { + throw new ConflictException( + `A shipping line with email ${email} already exists`, + ); + } + + if (scacCode && (await this.shippingLineCompaniesRepo.existsByScac(scacCode))) { + throw new ConflictException( + `A shipping line with SCAC ${scacCode} already exists`, + ); + } + + // An existing IAM account means these credentials already belong to a + // customer or an employee. Reusing it would let one login resolve to two + // different account kinds, so this is refused rather than merged — unlike + // employee creation, which legitimately re-uses a person's existing user. + const existingUser = await this.userRepository.findOne({ + where: [{ email }, { username }], + select: { id: true }, + }); + if (existingUser) { + throw new ConflictException( + "email_or_username_already_in_use", + ); + } + + const shippingLine = await this.dataSource.transaction(async (manager) => { + const userRepo = manager.getRepository(User); + const user = await userRepo.save( + userRepo.create({ + email, + username, + phoneNumber, + name: { en: dto.name.trim() }, + userType: EUserType.INDIVIDUAL, + isActive: true, + // No credential row is written: the account has no password until the + // activation link is used. `hasSetPassword` must stay false or the + // portal treats the account as ready to sign in with a password that + // does not exist. + hasSetPassword: false, + status: EUserStatus.ACCEPTED, + }), + ); + + return this.shippingLineCompaniesRepo.createInTransaction(manager, { + userId: user.id as string, + name: dto.name.trim(), + email, + phoneNumber: phoneNumber ?? null, + scacCode: scacCode ?? null, + imoNumber: dto.imoNumber?.trim() || null, + bicCode: dto.bicCode?.trim() || null, + }); + }); + + // Outside the transaction on purpose: a delivery failure must not roll back + // a registered line. The link is resendable, and the account is already + // valid without it. + const activation = await this.sendActivationLink(shippingLine); + + return { + shippingLine, + activationSentTo: activation?.maskedTarget ?? null, + activationChannel: activation?.channel ?? null, + }; + } + + /** + * Send the activation link on registration. + * + * Email always goes out — it is required at registration and is the only + * channel guaranteed to reach a foreign-registered line. SMS is sent in + * addition when the number is domestic, since the gateway silently drops + * anything else (see `CustomerResetService`). Two links are two independent + * single-use tickets; whichever the line opens first works. + * + * Reports the email send, as that is the one that is always attempted. + */ + async sendActivationLink(shippingLine: ShippingLineCompany) { + const scope = `shipping line ${shippingLine.id}`; + + const emailed = await this.customerResetService.sendResetLinkToUser( + shippingLine.userId, + ResetChannel.Email, + { scope, allowWithoutCredential: true }, + ); + + if (!emailed) { + this.logger.error( + `Activation email not sent for shipping line ${shippingLine.id} — no reachable address`, + ); + } + + if (shippingLine.phoneNumber && isDomesticPhone(shippingLine.phoneNumber)) { + const texted = await this.customerResetService.sendResetLinkToUser( + shippingLine.userId, + ResetChannel.Phone, + { scope, allowWithoutCredential: true }, + ); + if (!texted) { + this.logger.warn( + `Activation SMS not sent for shipping line ${shippingLine.id}`, + ); + } + } + + return emailed; + } + + async resendActivation(id: string, channel: ResetChannel) { + const shippingLine = await this.shippingLineCompaniesRepo.findById(id); + if (!shippingLine) { + throw new NotFoundException("Shipping line not found"); + } + + if ( + channel === ResetChannel.Phone && + (!shippingLine.phoneNumber || !isDomesticPhone(shippingLine.phoneNumber)) + ) { + throw new BadRequestException( + "This shipping line has no domestic phone number — the SMS gateway cannot reach it", + ); + } + + const sent = await this.customerResetService.sendResetLinkToUser( + shippingLine.userId, + channel, + { scope: `shipping line ${shippingLine.id}`, allowWithoutCredential: true }, + ); + + if (!sent) { + throw new NotFoundException( + `No active account with ${ + channel === ResetChannel.Email ? "an email address" : "a phone number" + } for this shipping line`, + ); + } + + return sent; + } + + async findById(id: string): Promise { + const shippingLine = await this.shippingLineCompaniesRepo.findById(id); + if (!shippingLine) { + throw new NotFoundException("Shipping line not found"); + } + return shippingLine; + } + + /** The shipping line signed in as `userId`, or null for any other account. */ + findByUserId(userId: string): Promise { + return this.shippingLineCompaniesRepo.findByUserId(userId); + } + + async list(page = 1, limit = 20) { + const [items, total] = await this.shippingLineCompaniesRepo.findAllPaginated( + (page - 1) * limit, + limit, + ); + return { items, total, page, limit }; + } +} diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index f0b719d63..6c891d830 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -450,6 +450,30 @@ export const CUSTOMER_PERMISSIONS: FreightPermissionSeed[] = [ ), ]; +// C2. Shipping lines — carriers registered by staff (no self-signup). +export const SHIPPING_LINE_PERMISSIONS: FreightPermissionSeed[] = [ + perm( + "d1a00002-0001-4000-8000-000000000001", + "edr_freight_app:shipping_lines:view", + "View shipping lines", + ), + perm( + "d1a00002-0001-4000-8000-000000000002", + "edr_freight_app:shipping_lines:create", + "Register shipping line", + ), + perm( + "d1a00002-0001-4000-8000-000000000003", + "edr_freight_app:shipping_lines:update", + "Update shipping line", + ), + perm( + "d1a00002-0001-4000-8000-000000000004", + "edr_freight_app:shipping_lines:reset-password", + "Resend shipping line activation link", + ), +]; + // D. Finance — payments + invoices export const FINANCE_PERMISSIONS: FreightPermissionSeed[] = [ perm( @@ -1493,6 +1517,7 @@ export const NOTIFICATION_PERMISSIONS: FreightPermissionSeed[] = [ export const ADVANCED_BACKOFFICE_PERMISSIONS: FreightPermissionSeed[] = [ ...CUSTOMER_PERMISSIONS, + ...SHIPPING_LINE_PERMISSIONS, ...FINANCE_PERMISSIONS, ...MILE_PERMISSIONS, ...FLEET_RAIL_PERMISSIONS, @@ -1699,6 +1724,12 @@ export const FREIGHT_PERMS = { // Notification selector, not a route guard — see NOTIFICATION_PERMISSIONS. getNotification: "edr_freight_app:customers:get_notification", }, + shippingLines: { + view: "edr_freight_app:shipping_lines:view", + create: "edr_freight_app:shipping_lines:create", + update: "edr_freight_app:shipping_lines:update", + resetPassword: "edr_freight_app:shipping_lines:reset-password", + }, payments: { view: "edr_freight_app:payments:view", }, diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 64f91da72..58bca3738 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -34,6 +34,7 @@ import DocumentClearanceDetailPage from "./pages/bookings/DocumentClearanceDetai import DocumentClearanceListPage from "./pages/bookings/DocumentClearanceListPage"; import CustomerDetailPage from "./pages/customers/CustomerDetailPage"; import CustomersPage from "./pages/customers/CustomersPage"; +import ShippingLineCompaniesPage from "./pages/shipping-lines/ShippingLineCompaniesPage"; import InvoiceDetailPage from "./pages/invoices/InvoiceDetailPage"; import InvoicesPage from "./pages/invoices/InvoicesPage"; import UsdPaymentsPage from "./pages/invoices/UsdPaymentsPage"; @@ -242,6 +243,14 @@ const App = () => { } /> + + + + } + /> , permission: FREIGHT_PERMS.customers.view, }, + { + label: "Shipping Lines", + href: "/dashboard/shipping-lines", + icon: , + permission: FREIGHT_PERMS.shippingLines.view, + }, { label: "Contracts", href: "/dashboard/contract-requests", diff --git a/apps/edr-freight-web/backoffice/src/components/shipping-lines/ResendActivationAction.tsx b/apps/edr-freight-web/backoffice/src/components/shipping-lines/ResendActivationAction.tsx new file mode 100644 index 000000000..ae271a57b --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/shipping-lines/ResendActivationAction.tsx @@ -0,0 +1,168 @@ +import { + ActionIcon, + Alert, + Button, + Modal, + Radio, + Stack, + Text, + Tooltip, +} from "@mantine/core"; +import { useMutation } from "@tanstack/react-query"; +import { Send } 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 { + ResetChannel, + ShippingLineCompany, +} from "@/types/shippingLineCompany"; + +/** + * Whether the SMS gateway can actually reach this number. + * + * The carrier integration is domestic-only: anything else is queued and + * silently lost, so a foreign number counts as unavailable rather than as a + * send that quietly fails. Mirrors `isDomesticPhone` in the API's otp.service. + */ +function isDomesticPhone(rawPhone: string): boolean { + const digits = rawPhone.trim().replace(/[^\d+]/g, ""); + const normalized = digits.startsWith("+") + ? digits + : /^251\d{9}$/.test(digits) + ? `+${digits}` + : /^9\d{8}$|^7\d{8}$/.test(digits.replace(/^0+/, "")) + ? `+251${digits.replace(/^0+/, "")}` + : digits; + return /^\+2519\d{8}$/.test(normalized); +} + +export interface ResendActivationActionProps { + shippingLine: Pick; +} + +/** + * Resend a shipping line's activation link. + * + * The same single-use link registration sends: the carrier opens it and picks + * their own password, so no credential is ever shown to or handled by staff. + * Needed whenever the original send failed, expired (24h), or never arrived. + */ +export default function ResendActivationAction({ + shippingLine, +}: ResendActivationActionProps) { + const { user } = useAuth(); + const { toast } = useToast(); + const [opened, setOpened] = useState(false); + const [channel, setChannel] = useState("email"); + + const allowed = hasPermission(user, FREIGHT_PERMS.shippingLines.resetPassword); + + const { mutate, isPending } = useMutation( + api.shippingLineCompanies.resendActivation.mutationOptions({ + onSuccess: (result) => { + setOpened(false); + toast({ + title: "Activation link sent", + description: `The shipping line can set their password using the link sent to ${result.maskedTarget}. It expires in 24 hours.`, + }); + }, + onError: (error) => { + toast({ + title: "Could not send activation link", + description: error.message, + variant: "destructive", + }); + }, + }), + ); + + if (!allowed) return null; + + const phoneUsable = + !!shippingLine.phoneNumber && isDomesticPhone(shippingLine.phoneNumber); + const channelMissing = channel === "phone" && !phoneUsable; + + return ( + <> + + { + // The row itself is not clickable today, but stop here anyway so + // adding a detail-page navigation later cannot swallow this click. + event.stopPropagation(); + setOpened(true); + }} + > + + + + + setOpened(false)} + title="Resend activation link" + centered + > + + + We'll send a single-use link to {shippingLine.name}. They choose + their own password — you will not see it. The link expires in 24 + hours, and sending a new one invalidates nothing they haven't + already used. + + + setChannel(v as ResetChannel)} + label="Send the link via" + > + + + + + + + {channelMissing ? ( + + + This account has no number the SMS gateway can reach. Send the + link by email instead. + + + ) : null} + + + + + + ); +} 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 b681a2909..f42702826 100644 --- a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts @@ -28,6 +28,14 @@ export const QUERY_KEYS = { byCode: (code: string) => ["dropdown-settings", "by-code", code] as const, }, + SHIPPING_LINE_COMPANIES: { + ROOT: ["shipping-line-companies"] as const, + list: (page: number, limit: number) => + ["shipping-line-companies", "list", page, limit] as const, + byId: (id: string) => + ["shipping-line-companies", "detail", id] as const, + }, + CUSTOMERS: { ROOT: ["customers"] as const, stats: ["customers", "stats"] as const, diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index d3566645e..f9bd29feb 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -77,6 +77,17 @@ export const URL_CONSTANTS = { BOOKINGS: (id: string | number) => `/customers/${id}/bookings`, }, + /** + * Carriers with a portal login. Distinct from `/shipping-lines`, which is the + * rule-engine's pricing lookup list (a code/label bookings reference). + */ + SHIPPING_LINE_COMPANIES: { + BASE: "/shipping-line-companies", + BY_ID: (id: string) => `/shipping-line-companies/${id}`, + RESEND_ACTIVATION: (id: string) => + `/shipping-line-companies/${id}/resend-activation`, + }, + COMPANIES: { BASE: "/companies", STATS: "/companies/stats", diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts index c69aa5d1b..ade07a511 100644 --- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts @@ -123,6 +123,12 @@ export const FREIGHT_PERMS = { verify: "edr_freight_app:customers:verify", resetPassword: "edr_freight_app:customers:reset-password", }, + shippingLines: { + view: "edr_freight_app:shipping_lines:view", + create: "edr_freight_app:shipping_lines:create", + update: "edr_freight_app:shipping_lines:update", + resetPassword: "edr_freight_app:shipping_lines:reset-password", + }, payments: { view: "edr_freight_app:payments:view", }, diff --git a/apps/edr-freight-web/backoffice/src/pages/shipping-lines/ShippingLineCompaniesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/shipping-lines/ShippingLineCompaniesPage.tsx new file mode 100644 index 000000000..d8e0bb0d6 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/shipping-lines/ShippingLineCompaniesPage.tsx @@ -0,0 +1,405 @@ +import { + Alert, + Badge, + Box, + Button, + Card, + Group, + Modal, + Stack, + Text, + TextInput, +} from "@mantine/core"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { Info, Mail, Phone, Plus, Ship } from "lucide-react"; +import { useMemo, useState } from "react"; + +import { useAuth } from "@/auth/useAuth"; +import { PageContainer, PageHeader } from "@/components/page"; +import ResendActivationAction from "@/components/shipping-lines/ResendActivationAction"; +import { useToast } from "@/hooks/use-toast"; +import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; +import { api } from "@/services/api"; +import type { ShippingLineCompany } from "@/types/shippingLineCompany"; +import { + DataTable, + DataTableFooter, + usePagination, + type ColumnDef, +} from "@edr/ui-common"; + +/** SCAC is 2-4 letters; the API enforces the same rule. */ +const SCAC_PATTERN = /^[A-Za-z]{2,4}$/; + +interface FormValues { + name: string; + email: string; + phoneNumber: string; + scacCode: string; + imoNumber: string; + bicCode: string; +} + +const EMPTY_FORM: FormValues = { + name: "", + email: "", + phoneNumber: "", + scacCode: "", + imoNumber: "", + bicCode: "", +}; + +const formatDate = (iso: string) => + new Date(iso).toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + }); + +/** + * Shipping line companies — carriers with their own portal login. + * + * Registration is staff-only: there is no self-signup. Staff never set a + * password; the system emails (and texts, when the number is domestic) a + * single-use activation link that the carrier uses to choose their own. + */ +export default function ShippingLineCompaniesPage() { + const { user } = useAuth(); + const { toast } = useToast(); + const { pagination, setPagination } = usePagination({ pageSize: 10 }); + const [registerOpen, setRegisterOpen] = useState(false); + + const canCreate = hasPermission(user, FREIGHT_PERMS.shippingLines.create); + + const { data, isLoading, isError, error, refetch } = useQuery( + api.shippingLineCompanies.list.queryOptions({ + input: { + page: pagination.pageIndex + 1, + limit: pagination.pageSize, + }, + }), + ); + + const rows = data?.items ?? []; + const total = data?.total ?? 0; + const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize)); + + const [values, setValues] = useState(EMPTY_FORM); + const [touched, setTouched] = useState(false); + + const setField = (field: keyof FormValues) => (value: string) => + setValues((prev) => ({ ...prev, [field]: value })); + + // Mirrors the API's own validation, so the obvious mistakes are caught before + // a round trip. The server still enforces all of it. + const errors = { + name: values.name.trim() ? null : "Company name is required", + // Required, unlike a customer's: the activation link is sent here, so an + // account without one could never be signed in to. + email: /^\S+@\S+\.\S+$/.test(values.email.trim()) + ? null + : "A valid email is required", + scacCode: + !values.scacCode.trim() || SCAC_PATTERN.test(values.scacCode.trim()) + ? null + : "SCAC must be 2-4 letters", + }; + const isValid = !errors.name && !errors.email && !errors.scacCode; + + const closeRegister = () => { + setRegisterOpen(false); + setValues(EMPTY_FORM); + setTouched(false); + }; + + const { mutate: register, isPending: isRegistering } = useMutation( + api.shippingLineCompanies.register.mutationOptions({ + onSuccess: (result) => { + closeRegister(); + toast({ + title: "Shipping line registered", + description: result.activationSentTo + ? `An activation link was sent to ${result.activationSentTo}. It expires in 24 hours.` + : // The account exists and is valid — only delivery failed, and the + // link can be resent, so this is a warning rather than an error. + "The account was created, but the activation link could not be sent. Use “Resend activation” to try again.", + variant: result.activationSentTo ? undefined : "destructive", + }); + }, + onError: (err) => { + toast({ + title: "Could not register shipping line", + description: err.message, + variant: "destructive", + }); + }, + }), + ); + + const columns: ColumnDef[] = useMemo( + () => [ + { + id: "name", + header: "Shipping line", + cell: ({ row }) => { + const sl = row.original; + return ( + + + + +
+ + {sl.name} + + {sl.scacCode ? ( + + SCAC {sl.scacCode} + + ) : null} +
+
+ ); + }, + }, + { + id: "contact", + header: "Contact", + cell: ({ row }) => { + const sl = row.original; + return ( + + + + + {sl.email} + + + {sl.phoneNumber ? ( + + + + {sl.phoneNumber} + + + ) : null} + + ); + }, + }, + { + id: "identifiers", + header: "Identifiers", + cell: ({ row }) => { + const { imoNumber, bicCode } = row.original; + if (!imoNumber && !bicCode) { + return ( + + — + + ); + } + return ( + + {imoNumber ? IMO {imoNumber} : null} + {bicCode ? ( + + BIC {bicCode} + + ) : null} + + ); + }, + }, + { + id: "status", + header: "Status", + cell: ({ row }) => ( + + {row.original.status === "active" ? "Active" : "Suspended"} + + ), + }, + { + id: "createdAt", + header: "Registered", + cell: ({ row }) => ( + + {formatDate(row.original.createdAt)} + + ), + }, + { + id: "actions", + header: "", + cell: ({ row }) => ( + + + + ), + }, + ], + [], + ); + + return ( + + + } + onClick={() => setRegisterOpen(true)} + > + Register shipping line + + ) : null + } + /> + + + 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} + /> + + + + +
{ + event.preventDefault(); + setTouched(true); + if (!isValid) return; + register({ + name: values.name.trim(), + email: values.email.trim(), + phoneNumber: values.phoneNumber.trim() || undefined, + scacCode: values.scacCode.trim() || undefined, + imoNumber: values.imoNumber.trim() || undefined, + bicCode: values.bicCode.trim() || undefined, + }); + }} + > + + } + color="blue" + variant="light" + p="sm" + > + + No password is set here. The shipping line receives a single-use + activation link and chooses their own. + + + + setField("name")(e.currentTarget.value)} + error={touched ? errors.name : null} + /> + + setField("email")(e.currentTarget.value)} + error={touched ? errors.email : null} + /> + + setField("phoneNumber")(e.currentTarget.value)} + /> + + + setField("scacCode")(e.currentTarget.value)} + error={touched ? errors.scacCode : null} + /> + setField("imoNumber")(e.currentTarget.value)} + /> + + + setField("bicCode")(e.currentTarget.value)} + /> + + + + + + +
+
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts index 6c9c69788..bbab2b253 100644 --- a/apps/edr-freight-web/backoffice/src/services/api.ts +++ b/apps/edr-freight-web/backoffice/src/services/api.ts @@ -44,6 +44,12 @@ import type { PaginatedOfflineUsdInvoices, } from "@/types/invoice"; import type { IOverviewDashboard, OverviewRange } from "@/types/overview"; +import type { + CreateShippingLineCompanyDto, + PaginatedShippingLineCompanies, + RegisterShippingLineCompanyResult, + ShippingLineCompany, +} from "@/types/shippingLineCompany"; import { RuleEngineListResult, RuleEngineRecord, @@ -150,6 +156,7 @@ import { import { containerTypesService } from "./container-types.service"; import { containerService, type Container } from "./containerService"; import { customersService } from "./customers.service"; +import { shippingLineCompaniesService } from "./shippingLineCompanies.service"; import { eimsService } from "./eims.service"; import type { EimsInvoiceStatusView, EimsVerifyResult } from "@/types/eims"; import { invoicesService } from "./invoices.service"; @@ -2763,6 +2770,43 @@ export const api = { ), }, + shippingLineCompanies: { + list: endpoint<{ page: number; limit: number }, PaginatedShippingLineCompanies>( + "shippingLineCompanies", + "list", + ({ page, limit }) => shippingLineCompaniesService.list(page, limit), + ({ page, limit }) => QUERY_KEYS.SHIPPING_LINE_COMPANIES.list(page, limit), + ), + + getById: endpoint<{ id: string }, ShippingLineCompany>( + "shippingLineCompanies", + "getById", + ({ id }) => shippingLineCompaniesService.getById(id), + ({ id }) => QUERY_KEYS.SHIPPING_LINE_COMPANIES.byId(id), + ), + + register: endpoint< + CreateShippingLineCompanyDto, + RegisterShippingLineCompanyResult + >( + "shippingLineCompanies", + "register", + (dto) => shippingLineCompaniesService.register(dto), + undefined, + () => [QUERY_KEYS.SHIPPING_LINE_COMPANIES.ROOT], + ), + + resendActivation: endpoint< + { id: string; channel: ResetChannel }, + ResetPasswordResult + >( + "shippingLineCompanies", + "resendActivation", + ({ id, channel }) => + shippingLineCompaniesService.resendActivation(id, channel), + ), + }, + customers: { stats: endpoint, CompanyStats>( "customers", diff --git a/apps/edr-freight-web/backoffice/src/services/shippingLineCompanies.service.ts b/apps/edr-freight-web/backoffice/src/services/shippingLineCompanies.service.ts new file mode 100644 index 000000000..82e97140f --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/services/shippingLineCompanies.service.ts @@ -0,0 +1,54 @@ +import { api as apiClient } from "@/auth/http"; +import { URL_CONSTANTS } from "@/constants/URLS"; +import type { + CreateShippingLineCompanyDto, + PaginatedShippingLineCompanies, + RegisterShippingLineCompanyResult, + ResetChannel, + ResetPasswordResult, + ShippingLineCompany, +} from "@/types/shippingLineCompany"; + +export const shippingLineCompaniesService = { + list(page = 1, limit = 20): Promise { + return apiClient + .get( + URL_CONSTANTS.SHIPPING_LINE_COMPANIES.BASE, + { params: { page, limit } }, + ) + .then((r) => r.data); + }, + + getById(id: string): Promise { + return apiClient + .get(URL_CONSTANTS.SHIPPING_LINE_COMPANIES.BY_ID(id)) + .then((r) => r.data); + }, + + /** + * Creates the carrier's account and record, then sends an activation link. + * Staff never set or see a password — the line chooses its own from the link. + */ + register( + dto: CreateShippingLineCompanyDto, + ): Promise { + return apiClient + .post( + URL_CONSTANTS.SHIPPING_LINE_COMPANIES.BASE, + dto, + ) + .then((r) => r.data); + }, + + resendActivation( + id: string, + channel: ResetChannel, + ): Promise { + return apiClient + .post( + URL_CONSTANTS.SHIPPING_LINE_COMPANIES.RESEND_ACTIVATION(id), + { channel }, + ) + .then((r) => r.data); + }, +}; diff --git a/apps/edr-freight-web/backoffice/src/types/shippingLineCompany.ts b/apps/edr-freight-web/backoffice/src/types/shippingLineCompany.ts new file mode 100644 index 000000000..9feb5c627 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/types/shippingLineCompany.ts @@ -0,0 +1,50 @@ +import type { ResetChannel, ResetPasswordResult } from "./customer"; + +export type ShippingLineStatus = "active" | "suspended"; + +/** + * A carrier with its own portal login, registered by staff. + * + * Not to be confused with the rule-engine's `ShippingLine` (types/rule-engine): + * that is a pricing lookup — a code/label a booking points at — with no account + * and no login. This one is the account. + */ +export interface ShippingLineCompany { + id: string; + name: string; + email: string; + phoneNumber: string | null; + scacCode: string | null; + imoNumber: string | null; + bicCode: string | null; + status: ShippingLineStatus; + createdAt: string; +} + +export interface CreateShippingLineCompanyDto { + name: string; + email: string; + phoneNumber?: string; + scacCode?: string; + imoNumber?: string; + bicCode?: string; + username?: string; +} + +export interface RegisterShippingLineCompanyResult { + shippingLine: ShippingLineCompany; + /** + * Masked destination the activation link went to, or null when delivery + * failed. The registration still succeeded — the link is resendable. + */ + activationSentTo: string | null; +} + +export interface PaginatedShippingLineCompanies { + items: ShippingLineCompany[]; + total: number; + page: number; + limit: number; +} + +export type { ResetChannel, ResetPasswordResult }; diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx index a8f616976..e47ec9faf 100644 --- a/apps/edr-freight-web/portal/src/App.tsx +++ b/apps/edr-freight-web/portal/src/App.tsx @@ -59,6 +59,13 @@ import CheckPaymentPage from "./pages/payments/CheckPaymentPage"; import PaymentFailurePage from "./pages/payments/PaymentFailurePage"; import FaydaCallbackPage from "./pages/FaydaCallbackPage"; import PaymentSuccessPage from "./pages/payments/PaymentSuccessPage"; +import { + ShippingLineBookingsPage, + ShippingLineHelpPage, + ShippingLineHomePage, + ShippingLineInvoicesPage, + ShippingLineSettingsPage, +} from "./pages/shipping-line"; import FaqPage from "./pages/support/FaqPage"; import HelpPage from "./pages/support/HelpPage"; import PrivacyPolicyPage from "./pages/support/PrivacyPolicyPage"; @@ -129,12 +136,20 @@ function isOnboardingAllowedPath(pathname: string): boolean { * can be dismissed to use those pages. Visiting any other page bounces back to * home and re-opens the wizard. New users (no company yet) are treated the same * as users who haven't completed onboarding. + * + * Shipping lines are exempt: staff register them with their details already + * captured, so there is nothing for them to onboard — they go straight to home. */ function OnboardingGate() { - const { company, onboardingCompleted } = useAuth(); + const { company, onboardingCompleted, isShippingLine } = useAuth(); const location = useLocation(); - const needsOnboarding = !company || !onboardingCompleted; + // Keyed off a positive shipping-line identification, never off "no company": + // that is also true mid-fetch and on error, which would let customers slip + // past onboarding whenever the request failed. + const needsOnboarding = isShippingLine + ? false + : !company || !onboardingCompleted; const allowedHere = isOnboardingAllowedPath(location.pathname); // Open by default while onboarding is pending (covers the login case). @@ -176,21 +191,69 @@ function OnboardingGate() { ); } +/** + * Customer-only routes. A shipping line that lands on one (an old link, a + * bookmark, a hand-typed URL) is sent to its own home rather than shown a + * contract/company-shaped page that has no meaning for it. + */ +function RequireCustomer() { + const { isShippingLine, customerQuery } = useAuth(); + + // RequireCompany already awaits this query, but guard anyway: a refetch can + // flip `isPending` back on, and redirecting on a half-loaded account would + // throw the user into the wrong app. + if (customerQuery.isPending) return ; + if (isShippingLine) return ; + return ; +} + +/** The mirror of RequireCustomer: shipping-line routes, closed to customers. */ +function RequireShippingLine() { + const { isShippingLine, customerQuery } = useAuth(); + + if (customerQuery.isPending) return ; + if (!isShippingLine) return ; + return ; +} + +/** + * Where a signed-in account belongs. Shipping lines and customers have separate + * apps, so every "you're already logged in" redirect has to pick between them. + * Waits for the company query: `isShippingLine` is false while that request is + * still in flight, which would land a shipping line on the customer home first. + */ +function useHomeRoute(): { ready: boolean; href: string } { + const { isShippingLine, customerQuery } = useAuth(); + + return { + ready: !customerQuery.isPending, + href: isShippingLine ? "/shipping-line" : "/portal", + }; +} + /** Keeps authenticated users off the login/signup pages. */ function RedirectIfAuthed() { const { isPending, isAuthenticated } = useAuth(); + const home = useHomeRoute(); if (isPending) return ; - if (isAuthenticated) return ; + if (isAuthenticated) { + if (!home.ready) return ; + return ; + } return ; } /** Landing page for visitors; authenticated users go straight to the portal. */ function LandingRoute() { const { isPending, isAuthenticated } = useAuth(); + const home = useHomeRoute(); if (isPending) return ; - if (isAuthenticated) return ; + if (isAuthenticated) { + if (!home.ready) return ; + return ; + } return ; } @@ -230,6 +293,37 @@ const sidebarItems: SidebarItem[] = [ }, ]; +/** + * Sidebar for shipping lines. Intentionally its own list rather than a filtered + * view of `sidebarItems`: shipping lines have no contracts, and their Home / + * Bookings / Invoices pages are different pages at different routes. + */ +const shippingLineSidebarItems: SidebarItem[] = [ + { label: "Home", href: "/shipping-line", icon: }, + { + label: "Bookings", + href: "/shipping-line/bookings", + icon: , + }, + { + label: "Invoices", + href: "/shipping-line/invoices", + icon: , + }, + { + section: "Account", + label: "Settings", + href: "/shipping-line/settings", + icon: , + }, + { + section: "Account", + label: "Help & Support", + href: "/shipping-line/help", + icon: , + }, +]; + const App = () => { const navigate = useNavigate(); const location = useLocation(); @@ -310,81 +404,134 @@ const App = () => { }> }> - - - - } - > - } /> - {/* Bookings are created against a contract, but the full list is + {/* Shipping-line app. Its own layout and sidebar, and its own pages + at their own routes — nothing here is shared with the customer + branch below beyond the shell component itself. Contracts are + absent by design: shipping lines request bookings directly. */} + }> + + + + } + > + } + /> + } + /> + } + /> + } + /> + } + /> + {/* Old shared links land on the shipping-line equivalents. */} + } + /> + + + + {/* Customer app — unchanged. */} + }> + + + + } + > + } /> + {/* Bookings are created against a contract, but the full list is browsable here. New-booking entry still routes via a contract. */} - } /> - } - /> - } /> - } /> - } - /> - } - /> - } - /> - } /> - } /> - } - /> - } - /> - } - /> - {/* Completion of an initiated (bare) booking after per-booking + } /> + } + /> + } + /> + } /> + } + /> + } + /> + } + /> + } /> + } /> + } + /> + } + /> + } + /> + {/* Completion of an initiated (bare) booking after per-booking clearance — same form, submits to the complete endpoint. */} - } - /> - } - /> - } /> - } /> - } /> - } /> - {/* Profile was merged into Settings — keep old links working. */} - } - /> - } /> - } /> + } + /> + } + /> + } /> + } /> + } /> + } /> + {/* Profile was merged into Settings — keep old links working. */} + } + /> + } /> + } /> + diff --git a/apps/edr-freight-web/portal/src/components/AppLayout.tsx b/apps/edr-freight-web/portal/src/components/AppLayout.tsx index 126c1fe2c..c5c7a0c31 100644 --- a/apps/edr-freight-web/portal/src/components/AppLayout.tsx +++ b/apps/edr-freight-web/portal/src/components/AppLayout.tsx @@ -107,23 +107,20 @@ function getActivePage( activePath: string, ): { label: string } | null { const path = activePath.toLowerCase(); - for (const item of items) { - if ( - path === item.href.toLowerCase() || - path.startsWith(item.href.toLowerCase() + "/") - ) { - return { label: item.label }; - } - if (item.children) { - const childMatch = item.children.find( - (c) => - path === c.href.toLowerCase() || - path.startsWith(c.href.toLowerCase() + "/"), - ); - if (childMatch) return { label: childMatch.label }; - } - } - return null; + + // Longest match wins, for the same reason as the sidebar's isItemActive: + // a nested href like "/shipping-line/bookings" must beat its "/shipping-line" + // parent, which a first-match-wins scan would report as "Home". + const best = items + .flatMap((item) => [item, ...(item.children ?? [])]) + .filter( + (item) => + path === item.href.toLowerCase() || + path.startsWith(item.href.toLowerCase() + "/"), + ) + .sort((a, b) => b.href.length - a.href.length)[0]; + + return best ? { label: best.label } : null; } const navClassNames = (active: boolean) => { @@ -262,9 +259,19 @@ export function AppLayout({ const serviceLabel = (m: ServiceType) => PROFILE_TYPE_LABELS[m] ?? m; const isSuspendedAppeal = reapplyStatus === "suspended"; + // Longest matching href wins. A plain prefix test would light up every + // ancestor: with a "/shipping-line" home item alongside "/shipping-line/ + // bookings", Home would stay highlighted on every page beneath it. Exact + // matches still win outright, so customer routes are unaffected — their + // sidebar hrefs are siblings, never nested inside one another. + const bestMatchHref = sidebarItems + .flatMap((item) => [item, ...(item.children ?? [])]) + .map((item) => item.href.toLowerCase()) + .filter((href) => activePath === href || activePath.startsWith(href + "/")) + .sort((a, b) => b.length - a.length)[0]; + const isItemActive = (item: SidebarItem) => - activePath === item.href.toLowerCase() || - activePath.startsWith(item.href.toLowerCase() + "/"); + bestMatchHref === item.href.toLowerCase(); return ( openServiceModal(p.type as ServiceType, p) } - leftSection={} + leftSection={ + + } > {serviceLabel(p.type as ServiceType)} @@ -893,7 +902,9 @@ export function AppLayout({ )} p.status === "pending"); const approved = profiles.filter((p) => p.status === "active"); diff --git a/apps/edr-freight-web/portal/src/hooks/useAuth.ts b/apps/edr-freight-web/portal/src/hooks/useAuth.ts index 3573750a9..a6e647918 100644 --- a/apps/edr-freight-web/portal/src/hooks/useAuth.ts +++ b/apps/edr-freight-web/portal/src/hooks/useAuth.ts @@ -1,6 +1,12 @@ import { api } from "@/services/api"; -import type { ProfileTypeValue } from "@/services/companies.service"; -import { companiesService } from "@/services/companies.service"; +import type { + CompanyInfoResponse, + ProfileTypeValue, +} from "@/services/companies.service"; +import { + companiesService, + isShippingLineAccount, +} from "@/services/companies.service"; import type { LoginPayload, LoginResponse, @@ -158,7 +164,21 @@ const useAuth = () => { } }; - const companyInfo = isAuthenticated ? (companyQuery.data ?? null) : null; + const accountInfo = isAuthenticated ? (companyQuery.data ?? null) : null; + + /** + * Shipping lines share the portal with customers but have no company, no + * external profile and no onboarding. Identified positively from the + * backend's discriminator — never from "company is missing", which is also + * true while the fetch is in flight or after it fails. + */ + const isShippingLine = isShippingLineAccount(accountInfo); + const shippingLine = isShippingLine ? accountInfo : null; + + // Every customer-shaped field below is null/empty for a shipping line. + const companyInfo = isShippingLine + ? null + : (accountInfo as CompanyInfoResponse | null); const companyType = companyInfo?.company?.type ?? null; const companyStatus = companyInfo?.company?.status ?? null; // A company can create bookings only once an admin has approved it (active). @@ -259,8 +279,11 @@ const useAuth = () => { isPending, isAuthenticated, user: isAuthenticated ? (authQuery.data ?? null) : null, - company: isAuthenticated ? (companyQuery.data ?? null) : null, - customer: isAuthenticated ? (companyQuery.data ?? null) : null, + // The whole getInfo payload, not the `company` field within it (historical + // name). Null for a shipping line: consumers read `.company` / `.profile` + // off it, and a shipping line has neither. + company: companyInfo, + customer: companyInfo, canBook, hasActiveProfile, hasPendingProfile, @@ -272,6 +295,8 @@ const useAuth = () => { isUnderReview, onboardingCompleted, onboardingStep, + isShippingLine, + shippingLine, createProfile, reapplyProfile, login, diff --git a/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx index 7e8e512dd..746d1648c 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx @@ -1,6 +1,6 @@ import { type FormEvent, useState } from "react"; import { Alert, Button, PasswordInput, Stack, TextInput } from "@mantine/core"; -import { AlertCircle } from "lucide-react"; +import { AlertCircle, CheckCircle2 } from "lucide-react"; import { Link, useLocation, useNavigate } from "react-router-dom"; import useAuth from "@/hooks/useAuth"; @@ -19,6 +19,10 @@ export default function LoginPage() { const [error, setError] = useState(null); const [loading, setLoading] = useState(false); + const passwordWasReset = + (location.state as { passwordReset?: boolean } | null)?.passwordReset === + true; + const handleSubmit = async (event: FormEvent) => { event.preventDefault(); setError(null); @@ -56,6 +60,21 @@ export default function LoginPage() { + {/* + Set by the reset flows on their way here. Without it the account + holder lands on a bare sign-in form with no sign the reset worked — + and the link is single-use, so there is no way back to check. + */} + {passwordWasReset ? ( + } + > + Your password has been updated. Sign in with your new password. + + ) : null} + (null); + const [succeeded, setSucceeded] = useState(false); useEffect(() => { if (!userId || !token) { @@ -82,7 +83,12 @@ export default function ResetPasswordLinkPage() { newPassword: password, confirmPassword, }); - navigate("/login", { replace: true, state: { passwordReset: true } }); + // Confirm in place rather than bouncing to /login: the old redirect + // passed `passwordReset: true` in route state that no page ever read, so + // the account holder landed on a bare sign-in form with no sign the reset + // had worked — and the link is single-use, so there is no way back to + // check. + setSucceeded(true); } catch (err) { setError(extractApiError(err).message); } finally { @@ -90,6 +96,58 @@ export default function ResetPasswordLinkPage() { } }; + // Password set — the link is now spent, so this is the last thing the account + // holder sees. It replaces the whole form rather than sitting above it. + if (succeeded) { + return ( + +
+
+ + + +
+ +
+

+ You're all set +

+

+ Your password has been updated + {account?.maskedIdentifier ? ` for ${account.maskedIdentifier}` : ""}. + Sign in with your new password to continue. +

+
+ + + + +

+ For your security, this reset link has now been used and will not + work again. +

+
+
+
+ ); + } + return ( } + /> + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/shipping-line/ShippingLineHelpPage.tsx b/apps/edr-freight-web/portal/src/pages/shipping-line/ShippingLineHelpPage.tsx new file mode 100644 index 000000000..84b605345 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/shipping-line/ShippingLineHelpPage.tsx @@ -0,0 +1,18 @@ +import { LifeBuoy } from "lucide-react"; +import ShippingLinePlaceholder from "./ShippingLinePlaceholder"; + +/** + * Shipping-line help & support. Separate from the customer `/help` page, which + * is public (the auth screens link to it) and carries its own doc chrome; this + * one lives inside the shipping-line app layout and will hold guidance written + * for shipping lines. + */ +export default function ShippingLineHelpPage() { + return ( + } + /> + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/shipping-line/ShippingLineHomePage.tsx b/apps/edr-freight-web/portal/src/pages/shipping-line/ShippingLineHomePage.tsx new file mode 100644 index 000000000..12a789ea7 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/shipping-line/ShippingLineHomePage.tsx @@ -0,0 +1,17 @@ +import { Home } from "lucide-react"; +import ShippingLinePlaceholder from "./ShippingLinePlaceholder"; + +/** + * Shipping-line home / dashboard. Deliberately separate from the customer + * dashboard (`MyPortalPage`): shipping lines have no company, no operational + * profiles and no contracts, so almost none of that page's data applies. + */ +export default function ShippingLineHomePage() { + return ( + } + /> + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/shipping-line/ShippingLineInvoicesPage.tsx b/apps/edr-freight-web/portal/src/pages/shipping-line/ShippingLineInvoicesPage.tsx new file mode 100644 index 000000000..8c0a4cb4a --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/shipping-line/ShippingLineInvoicesPage.tsx @@ -0,0 +1,13 @@ +import { Receipt } from "lucide-react"; +import ShippingLinePlaceholder from "./ShippingLinePlaceholder"; + +/** Shipping-line payments / invoices. */ +export default function ShippingLineInvoicesPage() { + return ( + } + /> + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/shipping-line/ShippingLinePlaceholder.tsx b/apps/edr-freight-web/portal/src/pages/shipping-line/ShippingLinePlaceholder.tsx new file mode 100644 index 000000000..20d647474 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/shipping-line/ShippingLinePlaceholder.tsx @@ -0,0 +1,40 @@ +import { Card, Stack, Text, Title } from "@mantine/core"; +import type { ReactNode } from "react"; + +export interface ShippingLinePlaceholderProps { + title: string; + description: string; + icon?: ReactNode; +} + +/** + * Shell for the shipping-line pages while they are still being built out. + * Each page owns its own file so it can be filled in independently; this only + * supplies the shared empty-state chrome and is meant to be deleted from a page + * once that page has real content. + */ +export default function ShippingLinePlaceholder({ + title, + description, + icon, +}: ShippingLinePlaceholderProps) { + return ( + + + {title} + + {description} + + + + + + {icon} + + Nothing here yet — this page is still being built. + + + + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/shipping-line/ShippingLineSettingsPage.tsx b/apps/edr-freight-web/portal/src/pages/shipping-line/ShippingLineSettingsPage.tsx new file mode 100644 index 000000000..9e2081e58 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/shipping-line/ShippingLineSettingsPage.tsx @@ -0,0 +1,17 @@ +import { Settings } from "lucide-react"; +import ShippingLinePlaceholder from "./ShippingLinePlaceholder"; + +/** + * Shipping-line settings. Separate from the customer `SettingsPage`, which is + * built around company profiles, business licenses and contact-person review — + * none of which a shipping line has. + */ +export default function ShippingLineSettingsPage() { + return ( + } + /> + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/shipping-line/index.ts b/apps/edr-freight-web/portal/src/pages/shipping-line/index.ts new file mode 100644 index 000000000..cb679c8cd --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/shipping-line/index.ts @@ -0,0 +1,5 @@ +export { default as ShippingLineHomePage } from "./ShippingLineHomePage"; +export { default as ShippingLineBookingsPage } from "./ShippingLineBookingsPage"; +export { default as ShippingLineInvoicesPage } from "./ShippingLineInvoicesPage"; +export { default as ShippingLineSettingsPage } from "./ShippingLineSettingsPage"; +export { default as ShippingLineHelpPage } from "./ShippingLineHelpPage"; diff --git a/apps/edr-freight-web/portal/src/services/companies.service.ts b/apps/edr-freight-web/portal/src/services/companies.service.ts index 4c1b93713..eda9d7e3d 100644 --- a/apps/edr-freight-web/portal/src/services/companies.service.ts +++ b/apps/edr-freight-web/portal/src/services/companies.service.ts @@ -89,7 +89,18 @@ export interface CompanyProfileResponse { updatedAt: string; } +/** + * Which kind of account is signed in. + * + * Read this instead of inferring from a missing `company`: a failed or + * in-flight company fetch also leaves `company` empty, and treating that as + * "shipping line" would skip onboarding for customers whenever the request + * failed. Absent (older responses) means `customer`. + */ +export type AccountKind = "customer" | "shipping_line"; + export interface CompanyInfoResponse { + accountKind?: AccountKind; profile: ExternalProfileResponse; company: CompanyResponse; /** @@ -104,6 +115,30 @@ export interface CompanyInfoResponse { } | null; } +/** + * A signed-in shipping line. It has no company, no external profile and no + * onboarding — the carrier record itself is the account. + */ +export interface ShippingLineInfoResponse { + accountKind: "shipping_line"; + id: string; + name: string; + email: string; + phoneNumber: string | null; + scacCode: string | null; + status: string; + company: null; + profile: null; + review: null; +} + +/** `GET /companies/getInfo` serves both portal audiences. */ +export type AccountInfoResponse = CompanyInfoResponse | ShippingLineInfoResponse; + +export const isShippingLineAccount = ( + info: AccountInfoResponse | null | undefined, +): info is ShippingLineInfoResponse => info?.accountKind === "shipping_line"; + /** A staged profile-edit review request (portal view). */ export interface ChangeRequestResponse { id: string; @@ -249,9 +284,9 @@ export interface DashboardSummary { } export const companiesService = { - getInfo: async (): Promise => { + getInfo: async (): Promise => { try { - const response = await client.get>( + const response = await client.get>( URL_CONSTANTS.COMPANIES_API.GET_INFO, ); return unwrap(response.data); From d261d6ea7c8b5aaa0e75fefe283647b0a2190e6c Mon Sep 17 00:00:00 2001 From: Nathnael Date: Thu, 13 Aug 2026 06:54:54 +0000 Subject: [PATCH 02/34] style: inter module integration --- .../src/modules/billing/billing.service.ts | 27 + .../modules/bookings/bookings.repository.ts | 6 + .../src/modules/bookings/bookings.service.ts | 1 + .../bookings/dto/filter-booking.dto.ts | 5 + .../bookings/detail/BookingCompanyCard.tsx | 74 +- .../bookings/detail/BookingContractCard.tsx | 49 + .../detail/BookingContractSummaryCard.tsx | 27 - .../detail/BookingMileServicesCard.tsx | 39 +- .../bookings/detail/BookingRequestHero.tsx | 258 ---- .../src/components/bookings/detail/index.ts | 3 +- .../contracts/BookingRequestStatusBadge.tsx | 16 + .../detail/ContractDetailTabCards.tsx | 31 +- .../contracts/detail/RequestDetailCards.tsx | 138 +-- .../customers/ResetPasswordAction.tsx | 151 --- .../src/components/customers/index.ts | 4 - .../src/components/detail/EntityLink.tsx | 63 + .../src/components/detail/Field.tsx | 59 + .../components/detail/LinkedEntityCard.tsx | 67 ++ .../backoffice/src/components/detail/index.ts | 14 + .../src/components/page/PageHeader.tsx | 2 +- .../trainScheduling/LegCapacityPanel.tsx | 13 +- .../ScheduleWorkspacePanel.tsx | 21 +- .../trainScheduling/WagonPlanGrid.tsx | 10 +- .../backoffice/src/constants/QUERY_KEYS.ts | 2 + .../bookings/BookingRequestDetailPage.tsx | 353 ++++-- .../bookings/DocumentClearanceDetailPage.tsx | 326 ++--- .../contracts/ContractClearanceDetailPage.tsx | 289 ++--- .../contracts/ContractRequestDetailPage.tsx | 1063 ++++++++++------- .../pages/contracts/GlClearanceDetailPage.tsx | 87 +- .../contracts/ShipmentRequestDetailPage.tsx | 13 +- .../pages/customers/CustomerDetailPage.tsx | 87 +- .../src/pages/invoices/InvoiceDetailPage.tsx | 321 +++-- .../TrainScheduleV2DetailPage.tsx | 508 ++++---- .../src/services/bookings.service.ts | 3 + packages/types/src/freight/index.ts | 11 +- 35 files changed, 2104 insertions(+), 2037 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingContractCard.tsx delete mode 100644 apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingContractSummaryCard.tsx delete mode 100644 apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRequestHero.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/contracts/BookingRequestStatusBadge.tsx delete mode 100644 apps/edr-freight-web/backoffice/src/components/customers/ResetPasswordAction.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/detail/EntityLink.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/detail/Field.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/detail/LinkedEntityCard.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/detail/index.ts diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index caa25fbaa..513d8f9e5 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -413,6 +413,32 @@ export class BillingService { return `data:image/png;base64,${signedQr}`; } + /** Route + wagon count summary rows for a booking-sourced invoice; empty for every other source. */ + private async bookingSummaryRows( + invoice: Invoice, + ): Promise { + if (invoice.source !== Freight.InvoiceSource.Booking) return []; + const booking = await this.dataSource.getRepository(Booking).findOne({ + where: { id: invoice.sourceId }, + relations: { originYard: true, destinationYard: true }, + }); + if (!booking) return []; + return [ + { + label: "Route", + value: + booking.originYard && booking.destinationYard + ? `${booking.originYard.label} → ${booking.destinationYard.label}` + : null, + }, + { + label: "Wagons", + value: + booking.wagonsRequired != null ? String(booking.wagonsRequired) : null, + }, + ]; + } + /** Map a global invoice (+ lines) onto the source-agnostic document model. */ private async toDocumentModel( invoice: Invoice & { lines: InvoiceLine[] }, @@ -446,6 +472,7 @@ export class BillingService { { label: "Status", value: invoice.status }, { label: "Type", value: invoice.type }, { label: "Reference", value: invoice.sourceId }, + ...(await this.bookingSummaryRows(invoice)), { label: "Currency", value: invoice.currency }, { label: "Issued", diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index 2b1463a95..ea1523882 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -59,6 +59,7 @@ export interface BookingListFilterOptions { assignedToSchedule?: 'true' | 'false'; companyId?: string; companyProfileId?: string; + contractId?: string; contractType?: string; serviceTypeId?: string; cargoTypeId?: string; @@ -936,6 +937,11 @@ export class BookingsRepository extends BaseRepository { companyProfileId: options.companyProfileId, }); } + if (options.contractId) { + qb.andWhere('booking.contract_id = :contractId', { + contractId: options.contractId, + }); + } if (options.contractType) { qb.andWhere('booking.contract_type = :contractType', { contractType: options.contractType, diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 6c3f14ecb..4f26c4415 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -1805,6 +1805,7 @@ export class BookingsService { // ANDs both, so cross-company access is impossible. companyId: forceCompanyId ?? filter.companyId, companyProfileId: forceCompanyProfileId ?? filter.companyProfileId, + contractId: filter.contractId, tradeDirections, contractType: filter.contractType, serviceTypeId: filter.serviceTypeId, diff --git a/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts index 2d3297af8..43d489bac 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts @@ -47,6 +47,11 @@ export class FilterBookingDto { @IsUUID() companyProfileId?: string; + @ApiPropertyOptional({ format: 'uuid', description: 'Filter bookings drawn down under this contract' }) + @IsOptional() + @IsUUID() + contractId?: string; + @ApiPropertyOptional() @IsOptional() contractType?: string; 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..3cebeacc5 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,5 +1,6 @@ +import type { ReactNode } from "react"; import { Truck } from "lucide-react"; -import { SimpleGrid } from "@mantine/core"; +import { SimpleGrid, Stack } from "@mantine/core"; import type { BookingDetail } from "@/types/booking"; @@ -8,24 +9,40 @@ 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. Renders + * nothing when none of the three are present. */ +export function BookingMileServicesCard({ + booking, + handoverSection, +}: BookingMileServicesCardProps) { + const hasAddresses = + Boolean(booking.firstMilePickupAddress) || Boolean(booking.lastMileDeliveryAddress); + if (!hasAddresses && !handoverSection) { return null; } return ( - - {booking.firstMilePickupAddress && ( - + + {hasAddresses && ( + + {booking.firstMilePickupAddress && ( + + )} + {booking.lastMileDeliveryAddress && ( + + )} + )} - {booking.lastMileDeliveryAddress && ( - - )} - + {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/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/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/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/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..47b94d69b 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx @@ -36,6 +36,7 @@ import { import { CountdownTimer } from "@edr/ui-common"; import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge"; +import { EntityLink } from "@/components/detail"; import { api } from "@/services/api"; import { useToast } from "@/hooks/use-toast"; import type { @@ -601,6 +602,7 @@ export function ScheduleWorkspacePanel({ {pool.map((b) => ( - - {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 b681a2909..1ee429bad 100644 --- a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts @@ -81,6 +81,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/pages/bookings/BookingRequestDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx index d64b07a45..fdade7980 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx @@ -2,43 +2,56 @@ import { useNavigate, useParams, useSearchParams } from "react-router-dom"; import toast from "react-hot-toast"; import { ArrowLeft, + Container as ContainerIcon, FileSignature, FileText, + Flame, FolderOpen, Layers, LayoutGrid, Milestone, + MoreHorizontal, Package, + RefreshCw, Truck, + Wallet, + Weight, } from "lucide-react"; import { - Container, - Stack, - Grid, + ActionIcon, + Box, + Button, Center, + Container, + Grid, + Group, Loader, + Menu, + Paper, + SegmentedControl, + Stack, Tabs, Text, - Paper, - Button, - Box, - SegmentedControl, } from "@mantine/core"; -import { PageContainer } from "@/components/page"; -import Breadcrumbs from "@/components/ui/Breadcrumbs"; +import { PageContainer, PageHeader, KpiStrip } from "@/components/page"; +import type { KpiItem } from "@/components/page"; +import { EntityLink } from "@/components/detail"; import { BookingActionsToolbar } from "@/components/bookings/BookingActionsToolbar"; import { BookingPricingSummary } from "@/components/bookings/BookingPricingSummary"; import { BookingWorkflowStepper } from "@/components/bookings/BookingWorkflowStepper"; +import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge"; +import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge"; +import { NextStepBanner } from "@/components/bookings/NextStepBanner"; +import { SchedulingStatusBadge } from "@/components/trainScheduling/ScheduleStatusBadge"; import { ConsolidationWaitingBanner } from "@/components/bookings/detail/ConsolidationWaitingBanner"; import { detailStyles, - BookingRequestHero, BookingRouteServiceCard, BookingMileServicesCard, BookingCargoCard, BookingCompanyCard, - BookingContractSummaryCard, + BookingContractCard, BookingContainerUnitsCard, BookingSchedulingWindowCard, BookingDocumentsPanel, @@ -48,6 +61,7 @@ import { import { WarehouseInfoCard } from "@/components/warehouses"; import { getStatusMeta } from "@/features/bookings/booking-status.config"; import { toBookingListRow } from "@/features/bookings/mapBookingListRow"; +import { cargoTonsAndItems } from "@/utils/cargoWeight"; import type { BookingDetail } from "@/types/booking"; import { useBookingDetail, @@ -133,7 +147,6 @@ export default function BookingRequestDetailPage() { ); } - const row = toBookingListRow(booking); const statusMeta = getStatusMeta(booking.status); // Clearance review + finalize now lives solely on the Operations "Clearance // Documents" hub (/dashboard/contracts/clearance-documents → detail page), so @@ -159,23 +172,176 @@ export default function BookingRequestDetailPage() { setSearchParams(next, { replace: true }); }; + const company = booking.company; + const customerName = toBookingListRow(booking).customerLabel; + + 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); + + const kpis: KpiItem[] = [ + { + label: "Total value", + value: `${booking.paymentCurrency} ${amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}`, + hint: booking.paymentStatus, + icon: Wallet, + color: "edr-green", + }, + { + label: "Cargo weight", + value: `${weight} T`, + hint: itemCount != null ? `${itemCount} items` : "VGM total", + icon: Weight, + color: "blue", + }, + { + label: "Containers", + value: containerCount || "—", + hint: `${containers.length} line${containers.length === 1 ? "" : "s"}`, + icon: ContainerIcon, + color: "teal", + }, + { + label: "Priority score", + value: booking.priorityScore ?? 0, + hint: booking.tradeDirection, + icon: Flame, + color: "orange", + }, + ]; + + const hasSignableContract = booking.isGovernment && booking.contractSummary; + return ( - + + + {booking.schedulingStatus ? ( + + ) : null} + + } + subtitle={ + + + + · Scheduled {booking.scheduledDate} + + + } + action={ + + refetch()} + > + + + + + + + + + + {hasSignableContract && ( + } + onClick={() => + navigate(`/dashboard/booking-requests/${booking.id}/contract`) + } + > + View / sign contract + + )} + } + onClick={async () => { + try { + const blob = + await bookingsService.downloadCarriageAcceptanceSheet( + booking.id, + ); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `carriage-acceptance-${booking.reference}.pdf`; + a.click(); + URL.revokeObjectURL(url); + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : "Carriage acceptance sheet is not available yet", + ); + } + }} + > + Carriage acceptance sheet + + {booking.customsClearingEnabled && ( + } + onClick={() => + navigate(`/dashboard/bookings/${booking.id}/clearance`) + } + > + View document clearance + + )} + + + + } /> - navigate("/dashboard/booking-requests")} - onRefresh={() => refetch()} - isFetching={isFetching} - /> + + + {booking.holdExpiresAt && booking.schedulingStatus === "HOLDING" ? ( + + Hold expires {new Date(booking.holdExpiresAt).toLocaleString()} + + ) : null} + + {booking.nextStep ? ( + + + + ) : null} - + {isGeneralContract && ( @@ -247,6 +413,7 @@ export default function BookingRequestDetailPage() { + - {booking.tradeDirection === "EXPORT" && ( - - - - How the cargo reaches the train - - { - try { - await bookingsService.setExportHandoverMode( - booking.id, - value as "DIRECT_TO_TRAIN" | "WAREHOUSE", - ); - await refetch(); - } catch (error) { - toast.error( - error instanceof Error - ? error.message - : "Could not change the handover mode", - ); - } - }} - /> - - {booking.exportHandoverMode === "DIRECT_TO_TRAIN" - ? "No warehouse receipt and no GRN — the carriage acceptance sheet is the handover document." - : "Cargo is received at the warehouse and issued a GRN before loading."} - - - - )} - {booking.isGovernment && booking.contractSummary && ( - - )} - - {booking.customsClearingEnabled && ( - - )} @@ -368,11 +444,13 @@ export default function BookingRequestDetailPage() { /** The booking's primary detail cards — route, services, cargo, containers. */ function OverviewPanel({ booking, - row, + onRefetch, }: { booking: BookingDetail; - row: ReturnType; + onRefetch: () => void; }) { + const row = toBookingListRow(booking); + return ( - + + + How the cargo reaches the train + + { + try { + await bookingsService.setExportHandoverMode( + booking.id, + value as "DIRECT_TO_TRAIN" | "WAREHOUSE", + ); + onRefetch(); + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : "Could not change the handover mode", + ); + } + }} + /> + + {booking.exportHandoverMode === "DIRECT_TO_TRAIN" + ? "No warehouse receipt and no GRN — the carriage acceptance sheet is the handover document." + : "Cargo is received at the warehouse and issued a GRN before loading."} + + + ) : null + } + /> - {booking.contractSummary && ( - - )} ); } diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceDetailPage.tsx index dd7513a42..1ed0dd470 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceDetailPage.tsx @@ -10,11 +10,9 @@ import { Group, Loader, Paper, - Progress, RingProgress, Stack, Text, - ThemeIcon, } from "@mantine/core"; import { AlertCircle, @@ -29,9 +27,13 @@ import { import type { Freight } from "@edr/types"; import { ClearanceOpsTabs } from "@/components/contracts/ClearanceOpsTabs"; -import { PageContainer } from "@/components/page/PageContainer"; -import { PageHeader } from "@/components/page/PageHeader"; -import { SectionCard } from "@/components/bookings/detail"; +import { PageContainer, PageHeader, KpiStrip } from "@/components/page"; +import type { KpiItem } from "@/components/page"; +import { + SectionCard, + BookingCompanyCard, + BookingContractCard, +} from "@/components/bookings/detail"; import { ClearanceReviewSection } from "@/components/bookings/detail/ClearanceReviewSection"; import { ClearancePhaseStepper } from "@/components/contracts/ClearancePhaseStepper"; import { PhasedClearanceActionPanel } from "@/components/contracts/PhasedClearanceActionPanel"; @@ -171,6 +173,18 @@ export default function DocumentClearanceDetailPage() { ); } + const direction = booking?.tradeDirection ?? "—"; + const origin = booking?.originYard?.label ?? booking?.originYard?.code ?? "Origin"; + const destination = + booking?.destinationYard?.label ?? booking?.destinationYard?.code ?? "Destination"; + + const kpis: KpiItem[] = [ + { label: "Approved", value: stats.approved, icon: CheckCircle2, color: "edr-green" }, + { label: "Queried", value: stats.queried, icon: AlertCircle, color: "red" }, + { label: "Pending", value: stats.pending, icon: Clock, color: "gray" }, + { label: "Review progress", value: `${stats.pct}%`, icon: PackageCheck, color: "blue" }, + ]; + return ( @@ -182,25 +196,46 @@ export default function DocumentClearanceDetailPage() { { label: reference }, ]} meta={ - clearance.allApproved ? ( - } - > - All approved + + + {direction} - ) : ( - } - > - Review pending - - ) + {clearance.includesCustoms ? ( + }> + Customs + + ) : null} + {clearance.allApproved ? ( + } + > + All approved + + ) : ( + } + > + Review pending + + )} + + } + subtitle={ + + + {origin} + + + + {destination} + + } action={ canCompleteBooking ? ( @@ -233,12 +268,16 @@ export default function DocumentClearanceDetailPage() { } /> - + + + {requestedLines ? ( + + + Requested cargo + + + + ) : null} {isPhasedGeneral ? ( @@ -273,67 +312,54 @@ export default function DocumentClearanceDetailPage() { - {isPhasedGeneral ? ( - 0} - bookingMilestones={bookingMilestones ?? []} - onChanged={() => void refetch()} - onViewFile={view} - onDownloadFile={(f) => void downloadBookingFile(f.id, f.name)} - /> - ) : ( - - - - - - {stats.pct}% - - - approved - - - } - /> - - + + {booking ? : null} + {booking ? : null} + {isPhasedGeneral ? ( + 0} + bookingMilestones={bookingMilestones ?? []} + onChanged={() => void refetch()} + onViewFile={view} + onDownloadFile={(f) => void downloadBookingFile(f.id, f.name)} + /> + ) : ( + + + + + {stats.pct}% + + + approved + + + } /> - - - - -
- - )} +
+ + )} + + } @@ -347,125 +373,3 @@ export default function DocumentClearanceDetailPage() { ); } - -function ClearanceHero({ - booking, - clearance, - stats, - requestedLines, -}: { - booking: ReturnType["data"]; - clearance: Freight.ClearanceView; - stats: { pct: number; approved: number; total: number }; - requestedLines?: Freight.RequestedShipmentLines | null; -}) { - const direction = booking?.tradeDirection ?? "—"; - const origin = - booking?.originYard?.label ?? booking?.originYard?.code ?? "Origin"; - const destination = - booking?.destinationYard?.label ?? - booking?.destinationYard?.code ?? - "Destination"; - - return ( - - - - - - - - - - {booking?.reference ?? "Clearance"} - - - {direction} - - {clearance.includesCustoms ? ( - } - > - Customs - - ) : null} - - - - {origin} - - - - {destination} - - - - - - - - - Document review - - - {stats.approved}/{stats.total} - - - - - - - {requestedLines ? ( - <> - - - - Requested cargo - - - - - ) : null} - - ); -} - -function ProgressStat({ - color, - label, - value, -}: { - color: string; - label: string; - value: number; -}) { - return ( - - - {value} - - - - - {label} - - - - ); -} diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx index b49eb419c..dacae6cea 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx @@ -10,12 +10,9 @@ import { Grid, Group, Loader, - Paper, - Progress, RingProgress, Stack, Text, - ThemeIcon, } from "@mantine/core"; import { AlertCircle, @@ -37,9 +34,11 @@ import { import { BookingChangesRequestedAlert } from "@/components/contracts/BookingChangesRequestedAlert"; import { ClearanceOpsTabs } from "@/components/contracts/ClearanceOpsTabs"; -import { PageContainer } from "@/components/page/PageContainer"; -import { PageHeader } from "@/components/page/PageHeader"; +import { PageContainer, PageHeader, KpiStrip } from "@/components/page"; +import type { KpiItem } from "@/components/page"; +import { EntityLink } from "@/components/detail"; import { SectionCard } from "@/components/bookings/detail/SectionCard"; +import { RequestCustomerCard } from "@/components/contracts/detail/RequestDetailCards"; import { ContractClearanceReviewSection } from "@/components/contracts/ContractClearanceReviewSection"; import { GlUpcomingWindowsSection } from "@/components/contracts/GlUpcomingWindowsSection"; import { PhasedClearanceActionPanel } from "@/components/contracts/PhasedClearanceActionPanel"; @@ -196,6 +195,29 @@ export default function ContractClearanceDetailPage() { const workflowFiles = clearance.workflowFiles ?? []; + const direction = contract?.tradeDirection ?? "—"; + const customs = + contract?.serviceType?.includesCustoms ?? + contract?.customsClearingEnabled ?? + false; + const routes = [...(contract?.routes ?? [])].sort( + (a, b) => a.sortOrder - b.sortOrder, + ); + const origin = + routes[0]?.originYard?.label ?? routes[0]?.originYard?.code ?? "Origin"; + const lastRoute = routes[routes.length - 1] ?? routes[0]; + const destination = + lastRoute?.destinationYard?.label ?? + lastRoute?.destinationYard?.code ?? + "Destination"; + + const kpis: KpiItem[] = [ + { label: "Approved", value: stats.approved, icon: CheckCircle2, color: "edr-green" }, + { label: "Queried", value: stats.queried, icon: AlertCircle, color: "red" }, + { label: "Pending", value: stats.pending, icon: Clock, color: "gray" }, + { label: "Review progress", value: `${stats.pct}%`, icon: PackageCheck, color: "blue" }, + ]; + return ( @@ -206,57 +228,81 @@ export default function ContractClearanceDetailPage() { { label: hubLabel, href: hubHref }, { label: reference }, ]} + subtitle={ + + {id ? ( + + ) : null} + + · {origin} + + + + {destination} + + + } meta={ - bookingExpired ? ( - } - > - Payment expired — rebook + + + {directionLabel(direction)} - ) : bookingAlreadyCreated ? ( - } - > - Booking created - - ) : ready ? ( - } - > - Ready — create booking - - ) : clearance.allApproved ? ( - } - > - All approved - - ) : ( - } - > - Review pending - - ) + {customs ? ( + }> + Customs + + ) : null} + {bookingExpired ? ( + } + > + Payment expired — rebook + + ) : bookingAlreadyCreated ? ( + } + > + Booking created + + ) : ready ? ( + } + > + Ready — create booking + + ) : clearance.allApproved ? ( + } + > + All approved + + ) : ( + } + > + Review pending + + )} + } /> - + {/* Windows on this contract's routes/direction only — tells GL ET when it can actually create the booking without checking the schedule board. */} @@ -383,6 +429,7 @@ export default function ContractClearanceDetailPage() { + {phasedCustoms ? ( } /> - - - - - @@ -457,126 +487,3 @@ export default function ContractClearanceDetailPage() { ); } -function ClearanceHero({ - contract, - stats, -}: { - contract: ReturnType["data"]; - stats: { pct: number; approved: number; total: number }; -}) { - const direction = contract?.tradeDirection ?? "—"; - const serviceName = contract?.serviceType?.serviceName ?? null; - const customs = - contract?.serviceType?.includesCustoms ?? - contract?.customsClearingEnabled ?? - false; - const routes = [...(contract?.routes ?? [])].sort( - (a, b) => a.sortOrder - b.sortOrder, - ); - const origin = - routes[0]?.originYard?.label ?? routes[0]?.originYard?.code ?? "Origin"; - const last = routes[routes.length - 1] ?? routes[0]; - const destination = - last?.destinationYard?.label ?? - last?.destinationYard?.code ?? - "Destination"; - - return ( - - - - - - - - - - {contract?.reference ?? "Clearance"} - - - {directionLabel(direction)} - - {customs ? ( - } - > - Customs - - ) : ( - - No customs - - )} - - {serviceName && ( - - {serviceName} - - )} - - - {origin} - - - - {destination} - - - - - - - - - Document review - - - {stats.approved}/{stats.total} - - - - - - - ); -} - -function ProgressStat({ - color, - label, - value, -}: { - color: string; - label: string; - value: number; -}) { - return ( - - - {value} - - - - - {label} - - - - ); -} diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestDetailPage.tsx index 8c0ef7b5c..5cb33c62a 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestDetailPage.tsx @@ -6,9 +6,8 @@ import { ArrowLeft, ArrowRight, Box as BoxIcon, - Building2, - Calendar, CalendarClock, + ClipboardList, Download, FileSignature, FileText, @@ -18,15 +17,16 @@ import { Info, LayoutGrid, Milestone, + MoreHorizontal, Package, Receipt, RefreshCw, Route as RouteIcon, - ShieldCheck, Snowflake, - Users, + Wallet, } from "lucide-react"; import { + ActionIcon, Alert, Badge, Box, @@ -36,21 +36,22 @@ import { Grid, Group, Loader, + Menu, Paper, SimpleGrid, Stack, Tabs, Text, - Title, } from "@mantine/core"; import toast from "react-hot-toast"; -import "@/components/overview/overview.css"; -import { PageContainer } from "@/components/page"; -import Breadcrumbs from "@/components/ui/Breadcrumbs"; +import { PageContainer, PageHeader, KpiStrip } from "@/components/page"; +import type { KpiItem } from "@/components/page"; +import { EntityLink } from "@/components/detail"; import { SectionCard } from "@/components/bookings/detail/SectionCard"; import { detailStyles } from "@/components/bookings/detail/booking-detail.styles"; +import { TableCard } from "@/components/customers"; import { ContractCourtBadge, ContractStatusBadge, @@ -60,14 +61,18 @@ import { ContractActionsToolbar } from "@/components/contracts/ContractActionsTo import { ContractApprovalStepsCard } from "@/components/contracts/ContractApprovalStepsCard"; import { HazardDeclarationPanel } from "@/components/contracts/HazardDeclarationPanel"; import { ClearanceWorkflowFilesPanel } from "@/components/contracts/ClearanceWorkflowFilesPanel"; +import { BookingRequestStatusBadge } from "@/components/contracts/BookingRequestStatusBadge"; import { ContractRevisionTimeline } from "@/components/contracts/ContractRevisionTimeline"; import { ContractMilestonesTimeline } from "@/components/contracts/ContractMilestonesTimeline"; import { ContractCustomerCard, ContractDocumentsCard, } from "@/components/contracts/detail/ContractDetailTabCards"; +import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge"; import { getContractStatusMeta } from "@/features/contracts/contract-status.config"; +import { toBookingListRow } from "@/features/bookings/mapBookingListRow"; import { useFileViewer } from "@/hooks/useFileViewer"; +import { useBookingList } from "@/hooks/bookings/useBookings"; import { useContractDetail, useContractMutations, @@ -75,11 +80,14 @@ import { import { contractsService } from "@/services/contracts.service"; import { api } from "@/services/api"; import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; +import { formatMoney } from "@/components/customers"; import { downloadBookingFile, fetchViewableFile, } from "@/services/files.service"; import type { CustomerDocument } from "@/types/customer"; +import type { BookingDetail } from "@/types/booking"; +import { DataTable, type ColumnDef } from "@edr/ui-common"; import type { Freight } from "@edr/types"; // Clearance phase — actionable (docs approve / query / finalize on the hub). @@ -147,11 +155,11 @@ export default function ContractRequestDetailPage() { const [searchParams, setSearchParams] = useSearchParams(); const { view, viewer } = useFileViewer(); const requestedTab = searchParams.get("tab"); - const setTab = (tab: string) => + const setTab = (tab: string | null) => setSearchParams( (prev) => { const next = new URLSearchParams(prev); - if (tab === "details") next.delete("tab"); + if (!tab || tab === "details") next.delete("tab"); else next.set("tab", tab); return next; }, @@ -212,6 +220,18 @@ export default function ContractRequestDetailPage() { }) satisfies NonNullable[number], ); + // Bookings drawn down under this contract, and the customer's raw shipment + // requests against it — the two halves of "what has this contract produced". + const { data: contractBookings, isLoading: bookingsLoading } = useBookingList( + { contractId: id, pageSize: 100 }, + Boolean(id), + ); + const bookingRequestsQuery = useQuery({ + queryKey: QUERY_KEYS.CONTRACTS.bookingRequests(id ?? ""), + queryFn: () => contractsService.listBookingRequests(id!), + enabled: Boolean(id), + }); + const downloadContractPdf = async () => { if (!contract?.id) return; try { @@ -313,12 +333,12 @@ export default function ContractRequestDetailPage() { contract.status === "SIGNED_CUSTOMER") && Boolean(contract.contractGeneratedAt); // Resolve the active tab from the URL, falling back to details when the - // requested tab isn't available for this contract (e.g. clearance pre-phase). + // requested tab isn't available for this contract. const currentTab = - requestedTab === "documents" - ? "documents" - : requestedTab === "customer" - ? "customer" + requestedTab === "shipments" + ? "shipments" + : requestedTab === "documents" + ? "documents" : requestedTab === "history" ? "history" : "details"; @@ -327,98 +347,111 @@ export default function ContractRequestDetailPage() { ? (contract.governmentInstitution ?? "Government") : (contract.company?.name ?? "—"); + const kpis: KpiItem[] = [ + { + label: "Shipments", + value: contract.activeBookingCount ?? 0, + hint: "active", + icon: Package, + color: "edr-green", + }, + { + label: "Valid until", + value: contract.contractValidUntil ? formatDate(contract.contractValidUntil) : "—", + icon: CalendarClock, + color: "blue", + }, + { + label: "Routes", + value: routes.length, + icon: RouteIcon, + color: "teal", + }, + { + label: "Currency", + value: contract.paymentCurrency, + icon: Wallet, + color: "orange", + }, + ]; + return ( - - - - {/* Hero */} - - - - - - - - - - Contract reference - - - - {contract.reference} - - - - - {contract.contractKind === "GENERAL" ? "General" : "One-time"} - - - - - - {contract.contractValidUntil ? ( - - ) : null} - - {hasContractDocument && ( - + backTo="/dashboard/contract-requests" + title={contract.reference} + meta={ + + + + + {contract.contractKind === "GENERAL" ? "General" : "One-time"} + + + } + subtitle={ + + + + · Created {formatDate(contract.createdAt)} + {contract.contractValidUntil + ? ` · Valid until ${formatDateTime(contract.contractValidUntil)}` + : ""} + + + } + action={ + + refetch()} + > + + + {hasContractDocument && ( + + + + + + + {canViewSign && ( - + )} {contractPdf && ( - + )} - - - )} - - - + + + + )} + + } + /> + + + ) : null} - setTab(v ?? "details")} - variant="pills" - color="edr-green" - classNames={{ list: "ov-tablist", tab: "ov-tab" }} - > - - }> - Details - - } - rightSection={ - contractDocuments.length + profileDocuments.length > 0 ? ( - - {contractDocuments.length + profileDocuments.length} - - ) : null - } - > - Documents - - }> - Customer - - }> - History - - - - {/* LEFT — primary content */} - {currentTab === "documents" ? ( - - - + + }> + Details + + }> + Shipments + + } + rightSection={ + contractDocuments.length + profileDocuments.length > 0 ? ( + + {contractDocuments.length + profileDocuments.length} + + ) : null } - onView={handleViewFile} - onDownload={handleDownloadFile} - /> - {(clearanceView?.workflowFiles?.length ?? 0) > 0 ? ( - void handleDownloadFile({ id: f.id, name: f.name } as never)} + > + Documents + + }> + History + + + + + + + + + + + {contract.equipmentReturn ? ( + + ) : null} + + {contract.contractValidityDays != null ? ( + + ) : null} + {contract.estimatedShipmentDate ? ( + + ) : null} + {contract.firstMilePickupAddress ? ( + + ) : null} + {contract.lastMileDeliveryAddress ? ( + + ) : null} + + {contract.financialTerms ? ( + + + Financial terms + + + {contract.financialTerms} + + + ) : null} + + + + {routes.length === 0 ? ( + + No routes on this contract. + + ) : ( + + {routes.map((r) => ( + + + + {r.originYard?.label ?? + r.originYard?.code ?? + "Origin"} + + + + {r.destinationYard?.label ?? + r.destinationYard?.code ?? + "Destination"} + + + {r.km != null ? ( + + {r.km} km + + ) : null} + + ))} + + )} + + + + + + {directionLabel(contract.tradeDirection)} + + + {contract.freightType} + + {contract.isHazardous ? ( + } + > + Hazardous + + ) : null} + {contract.isReefer ? ( + } + > + Reefer + + ) : null} + + {contract.isHazardous ? ( + + + + ) : null} + {(contract.cargoScope ?? []).length === 0 ? ( + + No cargo scope lines. + + ) : ( + + {(contract.cargoScope ?? []).map((s) => { + const isContainer = Boolean(s.containerSize); + // Bulk lines carry their commodity detail (name + unit); + // container lines carry the size (20ft / 40ft). + const title = isContainer + ? `${s.containerSize} container` + : (s.cargoType?.cargoTypeName ?? + s.cargoFreeText ?? + s.cargoType?.code ?? + "Bulk cargo"); + // quantityCap unit: containers for a size line, else the + // cargo type's unit of measure (tons / items / …), default tons. + const capUnit = isContainer + ? "containers" + : (s.cargoType?.unitOfMeasure?.toLowerCase() ?? "tons"); + return ( + + +
+ + {title} + + + + {isContainer ? "Container" : "Bulk"} + + {s.cargoType?.code ? ( + + Code: {s.cargoType.code} + + ) : null} + + {s.quantityCap != null + ? `Cap: ${s.quantityCap} ${capUnit}` + : "Cap: uncapped"} + + +
+
+ ); + })} +
+ )} +
+ + {contract.pricingBreakdown?.lineItems?.length ? ( + + + {contract.pricingBreakdown.lineItems.map((li) => ( + + + {li.label} + {li.containerSize ? ` · ${li.containerSize}` : ""} + + + {contract.pricingBreakdown?.currency} {li.unitPrice} /{" "} + {li.unit} + + + ))} + + + ) : null} + + {contract.contractSummary ? ( + + + {contract.contractSummary} + + + ) : null} +
+
+ + + + + + + navigate(`/dashboard/booking-requests/${row.id}`) + } + /> + + + + + + + navigate(`/dashboard/shipment-requests/${row.id}`) + } + /> + + + + + + + + - ) : null} - - ) : currentTab === "history" ? ( - + + {(clearanceView?.workflowFiles?.length ?? 0) > 0 ? ( + void handleDownloadFile({ id: f.id, name: f.name } as never)} + /> + ) : null} + + + + - - - -
- ) : currentTab === "customer" ? ( - - ) : ( - - - - - - - {contract.equipmentReturn ? ( - - ) : null} - - {contract.contractValidityDays != null ? ( - - ) : null} - {contract.estimatedShipmentDate ? ( - - ) : null} - {contract.firstMilePickupAddress ? ( - - ) : null} - {contract.lastMileDeliveryAddress ? ( - - ) : null} - - {contract.financialTerms ? ( - + - - Financial terms - - - {contract.financialTerms} - - - ) : null} - - - - - - - - {routes.length === 0 ? ( - - No routes on this contract. - - ) : ( - - {routes.map((r) => ( - - - - {r.originYard?.label ?? - r.originYard?.code ?? - "Origin"} - - - - {r.destinationYard?.label ?? - r.destinationYard?.code ?? - "Destination"} - - - {r.km != null ? ( - - {r.km} km - - ) : null} - - ))} - - )} - - - - - - {directionLabel(contract.tradeDirection)} - - - {contract.freightType} - - {contract.isHazardous ? ( - } - > - Hazardous - - ) : null} - {contract.isReefer ? ( - } - > - Reefer - - ) : null} - - {contract.isHazardous ? ( - - - - ) : null} - {(contract.cargoScope ?? []).length === 0 ? ( - - No cargo scope lines. - - ) : ( - - {(contract.cargoScope ?? []).map((s) => { - const isContainer = Boolean(s.containerSize); - // Bulk lines carry their commodity detail (name + unit); - // container lines carry the size (20ft / 40ft). - const title = isContainer - ? `${s.containerSize} container` - : (s.cargoType?.cargoTypeName ?? - s.cargoFreeText ?? - s.cargoType?.code ?? - "Bulk cargo"); - // quantityCap unit: containers for a size line, else the - // cargo type's unit of measure (tons / items / …), default tons. - const capUnit = isContainer - ? "containers" - : (s.cargoType?.unitOfMeasure?.toLowerCase() ?? "tons"); - return ( - - -
- - {title} - - - - {isContainer ? "Container" : "Bulk"} - - {s.cargoType?.code ? ( - - Code: {s.cargoType.code} - - ) : null} - - {s.quantityCap != null - ? `Cap: ${s.quantityCap} ${capUnit}` - : "Cap: uncapped"} - - -
-
- ); - })} -
- )} -
- - {contract.pricingBreakdown?.lineItems?.length ? ( - - - {contract.pricingBreakdown.lineItems.map((li) => ( - - - {li.label} - {li.containerSize ? ` · ${li.containerSize}` : ""} - - - {contract.pricingBreakdown?.currency} {li.unitPrice} /{" "} - {li.unit} - - - ))} - - - ) : null} - - {contract.contractSummary ? ( - - - {contract.contractSummary} - - - ) : null} -
- )} + + + + +
{/* RIGHT — sticky action rail */} + [] = [ + { + id: "reference", + header: "Booking", + cell: ({ row }) => ( + + {row.original.reference} + + ), + }, + { + id: "route", + header: "Route", + cell: ({ row }) => { + const r = toBookingListRow(row.original); + return ( + + + {r.originLabel} + + + + {r.destinationLabel} + + + ); + }, + }, + { + id: "status", + header: "Status", + cell: ({ row }) => , + }, + { + id: "amount", + header: "Amount", + meta: { headerClassName: "text-right", cellClassName: "text-right" }, + cell: ({ row }) => ( + + {formatMoney(Number(row.original.totalAmount), row.original.paymentCurrency)} + + ), + }, + { + id: "createdAt", + header: "Created", + meta: { headerClassName: "text-right", cellClassName: "text-right" }, + cell: ({ row }) => ( + + {formatDate(row.original.createdAt)} + + ), + }, +]; + +const requestColumns: ColumnDef[] = [ + { + id: "reference", + header: "Request", + cell: ({ row }) => ( + + {row.original.reference} + + ), + }, + { + id: "status", + header: "Status", + cell: ({ row }) => , + }, + { + id: "scheduledDate", + header: "Requested for", + cell: ({ row }) => ( + + {row.original.scheduledDate ? formatDate(row.original.scheduledDate) : "—"} + + ), + }, + { + id: "createdAt", + header: "Submitted", + meta: { headerClassName: "text-right", cellClassName: "text-right" }, + cell: ({ row }) => ( + + {formatDate(row.original.createdAt)} + + ), + }, +]; + function InfoRow({ label, value }: { label: string; value: string }) { return (
@@ -909,20 +1071,3 @@ function InfoRow({ label, value }: { label: string; value: string }) {
); } - -function MetaItem({ - icon: Icon, - text, -}: { - icon: typeof Building2; - text: string; -}) { - return ( - - - - {text} - - - ); -} diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx index a501cd50a..76e0ff4ad 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx @@ -29,9 +29,10 @@ import { useAuth } from "@/auth/useAuth"; import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; import type { BookingDetail } from "@/types/booking"; -import { PageContainer } from "@/components/page/PageContainer"; -import { PageHeader } from "@/components/page/PageHeader"; +import { PageContainer, PageHeader } from "@/components/page"; +import { EntityLink } from "@/components/detail"; import { ClearanceReviewSection } from "@/components/bookings/detail/ClearanceReviewSection"; +import { BookingCompanyCard } from "@/components/bookings/detail/BookingCompanyCard"; import { ContractClearanceReviewSection } from "@/components/contracts/ContractClearanceReviewSection"; import { ClearanceWorkflowFilesPanel } from "@/components/contracts/ClearanceWorkflowFilesPanel"; import { GlExchangePanel } from "@/components/contracts/GlExchangePanel"; @@ -42,6 +43,7 @@ import { import { PhasedClearanceActionPanel } from "@/components/contracts/PhasedClearanceActionPanel"; import { TransitAssigneePanel } from "@/components/contracts/TransitAssigneePanel"; import { SectionCard } from "@/components/bookings/detail/SectionCard"; +import { RequestCustomerCard } from "@/components/contracts/detail/RequestDetailCards"; import { IncidentReportCard } from "@/components/contracts/gl-actions/IncidentReportCard"; import { findWorkflowFile } from "@/components/contracts/PhasedUploadedFileRow"; import { useFileViewer } from "@/hooks/useFileViewer"; @@ -56,6 +58,7 @@ type GlClearanceDetail = reference: string; tradeDirection: string; clearance: Freight.ContractClearanceView; + contract: Freight.IContract; } | { kind: "booking"; @@ -79,6 +82,7 @@ async function loadGlClearanceDetail(id: string): Promise { reference: contract.reference, tradeDirection: contract.tradeDirection, clearance, + contract, }; } catch { const [clearance, booking] = await Promise.all([ @@ -181,6 +185,16 @@ export default function GlClearanceDetailPage() { { label: "GL Djibouti Clearance", href: backTo }, { label: data.reference }, ]} + subtitle={ + + } meta={ {directionLabel(data.tradeDirection)} @@ -278,37 +292,44 @@ export default function GlClearanceDetailPage() {
- setUploadKind("do")} - onUploadRoRequest={() => setUploadKind("ro")} - onChanged={() => { - void refetch(); - refetchBookingMilestonesIfLinked(); - }} - onViewFile={view} - onDownloadFile={(f) => void downloadBookingFile(f.id, f.name)} - /> + + {data.kind === "contract" ? ( + + ) : ( + + )} + setUploadKind("do")} + onUploadRoRequest={() => setUploadKind("ro")} + onChanged={() => { + void refetch(); + refetchBookingMilestonesIfLinked(); + }} + onViewFile={view} + onDownloadFile={(f) => void downloadBookingFile(f.id, f.name)} + /> +
diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ShipmentRequestDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ShipmentRequestDetailPage.tsx index 89237af1f..54d0fb419 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ShipmentRequestDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ShipmentRequestDetailPage.tsx @@ -25,6 +25,7 @@ import type { Freight } from "@edr/types"; import { PageContainer } from "@/components/page/PageContainer"; import { PageHeader } from "@/components/page/PageHeader"; import { SectionCard } from "@/components/bookings/detail/SectionCard"; +import { EntityLink } from "@/components/detail"; import { RequestCustomerCard, RequestContractSummaryCard, @@ -113,7 +114,17 @@ export default function ShipmentRequestDetailPage() { + + On contract + + +
+ } backTo="/dashboard/shipment-requests" breadcrumbs={[ { label: "Shipment Requests", href: "/dashboard/shipment-requests" }, diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx index 73f6a618b..7107ae7a5 100644 --- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx @@ -24,6 +24,7 @@ import { Contact, Download, Eye, + FileSignature, FileText, History, Hourglass, @@ -55,7 +56,6 @@ import { ProfileStatusBadge, ProfileTypeBadge, RequestDocumentChangeModal, - ResetPasswordAction, TableCard, formatBytes, formatDate, @@ -63,6 +63,8 @@ import { humanize, } from "@/components/customers"; import { KpiStrip, PageContainer, PageHeader } from "@/components/page"; +import { ContractStatusBadge } from "@/components/contracts/ContractStatusBadge"; +import { useContractList } from "@/hooks/contracts/useContracts"; import { useAuth } from "@/auth/useAuth"; import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; import { @@ -85,6 +87,7 @@ import { usePagination, type ColumnDef, } from "@edr/ui-common"; +import type { Freight } from "@edr/types"; /** Plain-text summary of the company's eTrade-sourced record, downloaded client-side (eTrade returns data, not a document). */ function downloadTinRecord(company: Company) { @@ -170,6 +173,7 @@ export default function CustomerDetailPage() { enabled: Boolean(id), }), ); + const contractsQuery = useContractList({ companyId: id, pageSize: 100 }, Boolean(id)); const { pagination: invoicePagination, setPagination: setInvoicePagination } = usePagination({ @@ -191,6 +195,7 @@ export default function CustomerDetailPage() { ); const bookings = Array.isArray(bookingsQuery.data) ? bookingsQuery.data : []; + const contracts = contractsQuery.data?.items ?? []; const documents = Array.isArray(documentsQuery.data) ? documentsQuery.data : []; @@ -402,6 +407,59 @@ export default function CustomerDetailPage() { [], ); + const contractColumns: ColumnDef[] = 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/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/trainScheduling/TrainScheduleV2DetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx index cf6c37e80..acfc3c375 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx @@ -1,4 +1,5 @@ import { + ActionIcon, Alert, Badge, Box, @@ -7,6 +8,7 @@ import { Group, List, Loader, + Menu, Modal, Paper, RingProgress, @@ -19,7 +21,6 @@ import { import { isAxiosError } from "axios"; import { AlertTriangle, - ArrowLeft, CalendarClock, CheckCircle2, Clock, @@ -29,6 +30,7 @@ import { FileText, History as HistoryIcon, LayoutGrid, + MoreHorizontal, Navigation, Package, PackageCheck, @@ -42,7 +44,7 @@ import { import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Link, useParams } from "react-router-dom"; -import { KpiStrip, PageContainer } from "@/components/page"; +import { KpiStrip, PageContainer, PageHeader } from "@/components/page"; import { useAuth } from "@/auth/useAuth"; import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; import { @@ -886,284 +888,248 @@ export default function TrainScheduleV2DetailPage() { return ( - - - - - - - - - - - - {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/services/bookings.service.ts b/apps/edr-freight-web/backoffice/src/services/bookings.service.ts index b49a11f83..6a86dba7d 100644 --- a/apps/edr-freight-web/backoffice/src/services/bookings.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/bookings.service.ts @@ -16,6 +16,8 @@ export interface BookingListFilter { tab?: string; // customerId?: string; companyId?: string; + /** Bookings drawn down under this contract (contract detail's Shipments tab). */ + contractId?: string; freightType?: string; /** ONE_TIME | GENERAL_CONTRACT — the booking-kind tab filter. */ bookingType?: string; @@ -169,6 +171,7 @@ export const bookingsService = { if (filter.schedulingStatuses) params.schedulingStatuses = filter.schedulingStatuses; if (filter.assignedToSchedule) params.assignedToSchedule = filter.assignedToSchedule; if (filter.companyId) params.companyId = filter.companyId; + if (filter.contractId) params.contractId = filter.contractId; if (filter.freightType) params.freightType = filter.freightType; if (filter.bookingType) params.bookingType = filter.bookingType; if (filter.tradeDirection) params.tradeDirection = filter.tradeDirection; diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index 68f48e187..2c9800836 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -930,10 +930,19 @@ export interface ClearanceView { importReleaseGranted?: boolean; } -/** Company an invoice is billed to (minimal projection). */ +/** + * Company an invoice is billed to. `findById` returns the full `Company` + * relation, not a stripped projection — these extra fields are what the + * backoffice invoice detail page's recipient card shows. + */ export interface IInvoiceCompany { id: string; name: string; + tin?: string | null; + vatNumber?: string | null; + phone?: string | null; + email?: string | null; + address?: string | null; } /** Company profile (importer/exporter/forwarder/…) an invoice is billed to. */ From 08804c84e993d52614f429dd159355b2b5fc7648 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Thu, 13 Aug 2026 07:52:10 +0000 Subject: [PATCH 03/34] feat(freight-api): seed a permission per report Each report key (REPORT_KEYS) mints its own edr_freight_app:reports::view permission, derived the same way rule-engine resource permissions are. reports:view stays the section master key; STAFF_DASHBOARD_KEYS grants every report key alongside it so existing presets don't regress on reseed. --- .../src/seed/freight-permissions.registry.ts | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index cdbffd961..7470ed2b5 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,36 @@ 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", +] 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", @@ -1506,6 +1536,7 @@ export const NOTIFICATION_PERMISSIONS: FreightPermissionSeed[] = [ ]; export const ADVANCED_BACKOFFICE_PERMISSIONS: FreightPermissionSeed[] = [ + ...REPORT_PERMISSIONS, ...CUSTOMER_PERMISSIONS, ...FINANCE_PERMISSIONS, ...MILE_PERMISSIONS, @@ -1987,6 +2018,7 @@ export const FREIGHT_PERMS = { }, reports: { view: "edr_freight_app:reports:view", + report: (key: ReportKey): string => reportPermissionKey(key), }, staff: { users: { @@ -2131,11 +2163,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 From b7583df426cfacd694b215fbb8d08e91495ae290 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Thu, 13 Aug 2026 07:52:20 +0000 Subject: [PATCH 04/34] feat(freight-api): replace canned reports with a generic report engine Nuke the 17 hand-written raw-SQL reports (no pagination, hard LIMITs) and the reports module built around them. Replace with a resolver contract: a report declares columns/filters/permission and a TypeORM QueryBuilder; ReportRunnerService applies filtering, a whitelisted sort, offset/limit paging, and a COUNT(*) FROM (query) wrapper for the total (getCount() is wrong for GROUP BY). ReportExportService re-runs the same resolver unpaginated for xlsx (exceljs) and pdf (existing PdfRenderService, now landscape-capable) exports. Ships with 4 reports: bookings-list, revenue-by-customer, aging-receivables, contract-utilization. Catalog + per-report permission checks live in the controller; adding a report is one new definitions/ file plus a REPORT_KEYS entry, no frontend change. --- apps/edr-freight-api/package.json | 1 + .../billing/documents/pdf-render.service.ts | 3 + .../definitions/aging-receivables.report.ts | 87 +++ .../definitions/bookings-list.report.ts | 129 ++++ .../contract-utilization.report.ts | 121 ++++ .../definitions/revenue-by-customer.report.ts | 91 +++ .../modules/reports/dto/report-query.dto.ts | 54 -- .../modules/reports/dto/report-result.dto.ts | 24 - .../modules/reports/report-export.service.ts | 114 +++ .../src/modules/reports/report-queries.ts | 669 ------------------ .../modules/reports/report-runner.service.ts | 141 ++++ .../modules/reports/report.registry.spec.ts | 41 ++ .../src/modules/reports/report.registry.ts | 24 + .../src/modules/reports/report.types.ts | 95 +++ .../src/modules/reports/reports.controller.ts | 84 ++- .../src/modules/reports/reports.module.ts | 9 +- .../src/modules/reports/reports.repository.ts | 14 - .../src/modules/reports/reports.service.ts | 46 -- pnpm-lock.yaml | 285 ++------ 19 files changed, 976 insertions(+), 1056 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/reports/definitions/aging-receivables.report.ts create mode 100644 apps/edr-freight-api/src/modules/reports/definitions/bookings-list.report.ts create mode 100644 apps/edr-freight-api/src/modules/reports/definitions/contract-utilization.report.ts create mode 100644 apps/edr-freight-api/src/modules/reports/definitions/revenue-by-customer.report.ts delete mode 100644 apps/edr-freight-api/src/modules/reports/dto/report-query.dto.ts delete mode 100644 apps/edr-freight-api/src/modules/reports/dto/report-result.dto.ts create mode 100644 apps/edr-freight-api/src/modules/reports/report-export.service.ts delete mode 100644 apps/edr-freight-api/src/modules/reports/report-queries.ts create mode 100644 apps/edr-freight-api/src/modules/reports/report-runner.service.ts create mode 100644 apps/edr-freight-api/src/modules/reports/report.registry.spec.ts create mode 100644 apps/edr-freight-api/src/modules/reports/report.registry.ts create mode 100644 apps/edr-freight-api/src/modules/reports/report.types.ts delete mode 100644 apps/edr-freight-api/src/modules/reports/reports.repository.ts delete mode 100644 apps/edr-freight-api/src/modules/reports/reports.service.ts 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/modules/billing/documents/pdf-render.service.ts b/apps/edr-freight-api/src/modules/billing/documents/pdf-render.service.ts index 447bc2516..e8c3de792 100644 --- a/apps/edr-freight-api/src/modules/billing/documents/pdf-render.service.ts +++ b/apps/edr-freight-api/src/modules/billing/documents/pdf-render.service.ts @@ -18,6 +18,8 @@ const PDF_PRINT_STYLES = ` export interface PdfRenderOptions { /** Label used in logs to identify the document kind. */ label?: string; + /** Landscape A4 instead of the default portrait — wide tables need it. */ + landscape?: boolean; /** * Degraded renderer used when Chromium is unavailable. Receives the * print-prepared HTML and must return a valid PDF buffer (≥ 2KB, `%PDF-` @@ -59,6 +61,7 @@ export class PdfRenderService { const pdf = await page.pdf({ format: "A4", + landscape: opts.landscape ?? false, printBackground: true, margin: { top: "16mm", bottom: "18mm", left: "14mm", right: "14mm" }, }); diff --git a/apps/edr-freight-api/src/modules/reports/definitions/aging-receivables.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/aging-receivables.report.ts new file mode 100644 index 000000000..d5709508c --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/aging-receivables.report.ts @@ -0,0 +1,87 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { Company } from '../../companies/entities/company.entity'; +import { Invoice } from '../../billing/entities/invoice.entity'; +import { applyBookingRefDirectionScope } from '../../user-trade-access/trade-scope.util'; +import { ReportContext, ReportDefinition } from '../report.types'; + +const OPEN_STATUSES = ['ISSUED', 'PENDING', 'PARTIALLY_PAID', 'OVERDUE']; + +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + const { params, directions } = ctx; + // "As of" — invoices due after this instant aren't overdue yet. Defaults + // to now() in SQL when the filter is unset (see the COALESCE below). + const asOf = (params.asOf as string | null) ?? null; + + const qb = ctx.ds + .createQueryBuilder() + .from(Invoice, 'i') + .innerJoin(Company, 'c', 'c.id = i.company_id') + .where('i.deleted_at IS NULL') + .andWhere('i.status IN (:...openStatuses)', { openStatuses: OPEN_STATUSES }) + .andWhere('i.balance_amount > 0') + .setParameter('asOf', asOf); + + // ACL: invoices.source_id is a varchar pointer at the originating booking. + // Rows not pointing at a booking (e.g. warehouse fee invoices) stay visible. + return applyBookingRefDirectionScope(qb, 'i.source_id', directions); +} + +export const agingReceivablesReport: ReportDefinition = { + key: 'aging-receivables', + title: 'Aging Receivables', + description: 'Outstanding customer balances bucketed by days overdue', + group: 'Finance', + filters: [{ key: 'asOf', label: 'As of', type: 'date' }], + columns: [ + { key: 'customer', label: 'Customer', type: 'string', sortable: true, sortExpr: 'c.name' }, + { key: 'invoices', label: 'Invoices', type: 'number' }, + { key: 'outstanding', label: 'Outstanding', type: 'money', sortable: true }, + { key: 'current', label: 'Current', type: 'money' }, + { key: 'overdue0to30', label: '0-30d', type: 'money' }, + { key: 'overdue31to60', label: '31-60d', type: 'money' }, + { key: 'overdue61to90', label: '61-90d', type: 'money' }, + { key: 'overdue90plus', label: '90d+', type: 'money' }, + ], + defaultSort: { key: 'outstanding', dir: 'DESC' }, + query(ctx) { + return baseQuery(ctx) + .select('c.name', 'customer') + .addSelect('COUNT(*)::int', 'invoices') + .addSelect('ROUND(SUM(i.balance_amount))::float8', 'outstanding') + .addSelect( + `ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at >= COALESCE(:asOf::timestamptz, now())), 0))::float8`, + 'current', + ) + .addSelect( + `ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < COALESCE(:asOf::timestamptz, now()) + AND i.due_at >= COALESCE(:asOf::timestamptz, now()) - interval '30 days'), 0))::float8`, + 'overdue0to30', + ) + .addSelect( + `ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < COALESCE(:asOf::timestamptz, now()) - interval '30 days' + AND i.due_at >= COALESCE(:asOf::timestamptz, now()) - interval '60 days'), 0))::float8`, + 'overdue31to60', + ) + .addSelect( + `ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < COALESCE(:asOf::timestamptz, now()) - interval '60 days' + AND i.due_at >= COALESCE(:asOf::timestamptz, now()) - interval '90 days'), 0))::float8`, + 'overdue61to90', + ) + .addSelect( + `ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < COALESCE(:asOf::timestamptz, now()) - interval '90 days'), 0))::float8`, + 'overdue90plus', + ) + .groupBy('c.name'); + }, + async summary(ctx) { + const row = await baseQuery(ctx) + .select('ROUND(COALESCE(SUM(i.balance_amount), 0))::float8', 'outstanding') + .addSelect('COUNT(DISTINCT c.id)::int', 'customers') + .getRawOne(); + return [ + { label: 'Outstanding', value: Number(row?.outstanding ?? 0), unit: 'ETB' }, + { label: 'Customers with balance', value: Number(row?.customers ?? 0) }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/bookings-list.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/bookings-list.report.ts new file mode 100644 index 000000000..ca476cea3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/bookings-list.report.ts @@ -0,0 +1,129 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { Booking } from '../../bookings/entities/booking.entity'; +import { CargoType } from '../../rule-engine/entities/cargo-type.entity'; +import { Yard } from '../../rule-engine/entities/yard.entity'; +import { Company } from '../../companies/entities/company.entity'; +import { ReportContext, ReportDefinition } from '../report.types'; + +// 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 +// (same guard as the retired report-queries.ts). +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. +const NOT_UMBRELLA = "(b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')"; +const DEAD_STATUSES = ['DRAFT', 'CANCELLED', 'REJECTED', 'EXPIRED']; + +function applyFilters( + ctx: ReportContext, + qb: SelectQueryBuilder, +): SelectQueryBuilder { + const { params, directions } = ctx; + qb.where(`b.deleted_at IS NULL AND ${NOT_UMBRELLA}`); + if (params.dateFrom) qb.andWhere('b.created_at >= :dateFrom', { dateFrom: params.dateFrom }); + if (params.dateTo) qb.andWhere('b.created_at < :dateTo', { dateTo: params.dateTo }); + if (params.direction) qb.andWhere('b.trade_direction = :direction', { direction: params.direction }); + if (params.freightType) qb.andWhere('b.freight_type = :freightType', { freightType: params.freightType }); + const statuses = params.statuses as string[] | null; + if (statuses) { + qb.andWhere('b.status IN (:...statuses)', { statuses }); + } else { + qb.andWhere('b.status NOT IN (:...deadStatuses)', { deadStatuses: DEAD_STATUSES }); + } + if (params.search) { + qb.andWhere('(b.reference ILIKE :search OR c.name ILIKE :search)', { + search: `%${params.search}%`, + }); + } + if (directions !== null) { + qb.andWhere(directions.length ? 'b.trade_direction IN (:...directions)' : '1 = 0', { + directions, + }); + } + return qb; +} + +export const bookingsListReport: ReportDefinition = { + key: 'bookings-list', + title: 'Bookings', + description: 'Every booking with customer, route, cargo and revenue', + group: 'Commercial', + filters: [ + { key: 'date', label: 'Created', type: 'daterange' }, + { + key: 'direction', + label: 'Direction', + type: 'select', + options: [ + { value: 'IMPORT', label: 'Import' }, + { value: 'EXPORT', label: 'Export' }, + { value: 'DOMESTIC', label: 'Domestic' }, + ], + }, + { + key: 'freightType', + label: 'Freight type', + type: 'select', + options: [ + { value: 'CONTAINER', label: 'Container' }, + { value: 'BULK', label: 'Bulk' }, + ], + }, + { key: 'statuses', label: 'Status', type: 'multiselect' }, + { key: 'search', label: 'Search reference or customer', type: 'text' }, + ], + columns: [ + { key: 'reference', label: 'Reference', type: 'string', sortable: true, sortExpr: 'b.reference' }, + { key: 'created', label: 'Created', type: 'date', sortable: true, sortExpr: 'b.created_at' }, + { key: 'customer', label: 'Customer', type: 'string', sortable: true, sortExpr: 'c.name' }, + { key: 'status', label: 'Status', type: 'string', sortable: true, sortExpr: 'b.status' }, + { key: 'direction', label: 'Direction', type: 'string' }, + { key: 'origin', label: 'Origin', type: 'string' }, + { key: 'destination', label: 'Destination', type: 'string' }, + { key: 'cargo', label: 'Cargo', type: 'string' }, + { key: 'tons', label: 'Tonnage', type: 'tons', sortable: true }, + { key: 'amount', label: 'Amount', type: 'money', sortable: true }, + ], + defaultSort: { key: 'created', dir: 'DESC' }, + query(ctx) { + const qb = ctx.ds + .createQueryBuilder() + .select('b.reference', 'reference') + .addSelect(`to_char(b.created_at, 'YYYY-MM-DD')`, 'created') + .addSelect('c.name', 'customer') + .addSelect('b.status', 'status') + .addSelect('b.trade_direction', 'direction') + .addSelect('o.label', 'origin') + .addSelect('d.label', 'destination') + .addSelect('COALESCE(cty.cargo_type_name, b.cargo_free_text)', 'cargo') + .addSelect(`ROUND(${TONS})::float8`, 'tons') + .addSelect(`ROUND(${REVENUE})::float8`, 'amount') + .from(Booking, 'b') + .innerJoin(Company, 'c', 'c.id = b.company_id') + .innerJoin(Yard, 'o', 'o.id = b.origin_yard_id') + .innerJoin(Yard, 'd', 'd.id = b.destination_yard_id') + .leftJoin(CargoType, 'cty', 'cty.id = b.cargo_type_id'); + return applyFilters(ctx, qb); + }, + async summary(ctx) { + const qb = applyFilters( + ctx, + ctx.ds + .createQueryBuilder() + .select('COUNT(*)::int', 'bookings') + .addSelect(`ROUND(COALESCE(SUM(${TONS}), 0))::float8`, 'tons') + .addSelect(`ROUND(COALESCE(SUM(${REVENUE}), 0))::float8`, 'revenue') + .from(Booking, 'b') + .innerJoin(Company, 'c', 'c.id = b.company_id'), + ); + const row = await qb.getRawOne(); + return [ + { label: 'Bookings', value: Number(row?.bookings ?? 0) }, + { label: 'Tonnage', value: Number(row?.tons ?? 0), unit: 't' }, + { label: 'Revenue', value: Number(row?.revenue ?? 0), unit: 'ETB' }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/contract-utilization.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/contract-utilization.report.ts new file mode 100644 index 000000000..03f98d306 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/contract-utilization.report.ts @@ -0,0 +1,121 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { Company } from '../../companies/entities/company.entity'; +import { Contract } from '../../contracts/entities/contract.entity'; +import { ReportContext, ReportDefinition } from '../report.types'; + +const TONS = 'COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)'; +const DEAD_STATUSES = ['DRAFT', 'CANCELLED', 'REJECTED', 'EXPIRED']; + +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + const { params, directions } = ctx; + const qb = ctx.ds + .createQueryBuilder() + .from(Contract, 'ct') + .leftJoin(Company, 'c', 'c.id = ct.company_id') + .leftJoin( + (sub) => + sub + .select('s.contract_id', 'contract_id') + .addSelect('COALESCE(SUM(s.quantity_cap), 0)', 'committed') + .from('freight.contract_cargo_scope', 's') + .where('s.deleted_at IS NULL') + .groupBy('s.contract_id'), + 'cap', + 'cap.contract_id = ct.id', + ) + .leftJoin( + (sub) => + sub + .select('b.contract_id', 'contract_id') + .addSelect(`COALESCE(SUM(${TONS}), 0)`, 'tons') + .addSelect('COUNT(*)::int', 'cnt') + .from('freight.bookings', 'b') + .where('b.deleted_at IS NULL') + .andWhere('b.status NOT IN (:...deadStatuses)', { deadStatuses: DEAD_STATUSES }) + .groupBy('b.contract_id'), + 'booked', + 'booked.contract_id = ct.id', + ) + .where('ct.deleted_at IS NULL') + .andWhere("ct.status <> 'DRAFT'"); + + if (params.dateFrom) { + qb.andWhere( + "(ct.contract_valid_until IS NULL OR ct.contract_valid_until >= :dateFrom::timestamptz)", + { dateFrom: params.dateFrom }, + ); + } + if (params.dateTo) { + qb.andWhere('ct.contract_valid_from < :dateTo::timestamptz', { dateTo: params.dateTo }); + } + const statuses = params.statuses as string[] | null; + if (statuses) qb.andWhere('ct.status IN (:...statuses)', { statuses }); + if (params.contractId) { + qb.andWhere('ct.id = :contractId', { contractId: params.contractId }); + } + if (directions !== null) { + qb.andWhere(directions.length ? 'ct.trade_direction IN (:...directions)' : '1 = 0', { + directions, + }); + } + return qb; +} + +export const contractUtilizationReport: ReportDefinition = { + key: 'contract-utilization', + title: 'Contract Utilization', + description: 'Committed volume vs. booked tonnage per contract', + group: 'Commercial', + idKey: { key: 'contractId', label: 'Contract' }, + filters: [ + { key: 'date', label: 'Active during', type: 'daterange' }, + { key: 'statuses', label: 'Status', type: 'multiselect' }, + ], + columns: [ + { key: 'reference', label: 'Reference', type: 'string', sortable: true, sortExpr: 'ct.reference' }, + { key: 'customer', label: 'Customer', type: 'string', sortable: true, sortExpr: 'c.name' }, + { key: 'status', label: 'Status', type: 'string' }, + { key: 'kind', label: 'Kind', type: 'string' }, + { key: 'validFrom', label: 'Valid from', type: 'date' }, + { key: 'validUntil', label: 'Valid until', type: 'date' }, + { key: 'committed', label: 'Committed', type: 'tons' }, + { key: 'bookedTons', label: 'Booked', type: 'tons', sortable: true }, + { key: 'bookings', label: 'Bookings', type: 'number' }, + { key: 'utilizationPct', label: 'Utilization', type: 'percent', sortable: true }, + ], + defaultSort: { key: 'utilizationPct', dir: 'DESC' }, + query(ctx) { + return baseQuery(ctx) + .select('ct.reference', 'reference') + .addSelect('c.name', 'customer') + .addSelect('ct.status', 'status') + .addSelect('ct.contract_kind', 'kind') + .addSelect(`to_char(ct.contract_valid_from, 'YYYY-MM-DD')`, 'validFrom') + .addSelect(`to_char(ct.contract_valid_until, 'YYYY-MM-DD')`, 'validUntil') + .addSelect('COALESCE(cap.committed, 0)::float8', 'committed') + .addSelect('COALESCE(booked.tons, 0)::float8', 'bookedTons') + .addSelect('COALESCE(booked.cnt, 0)', 'bookings') + .addSelect( + `CASE WHEN COALESCE(cap.committed, 0) > 0 + THEN ROUND(COALESCE(booked.tons, 0) / cap.committed * 100)::float8 END`, + 'utilizationPct', + ); + }, + async summary(ctx) { + const row = await baseQuery(ctx) + .select('COUNT(*)::int', 'contracts') + .addSelect('COALESCE(SUM(booked.tons), 0)::float8', 'bookedTons') + .addSelect( + `AVG(CASE WHEN COALESCE(cap.committed, 0) > 0 + THEN booked.tons / cap.committed * 100 END)::float8`, + 'avgUtilization', + ) + .getRawOne(); + return [ + { label: 'Contracts', value: Number(row?.contracts ?? 0) }, + { label: 'Booked tonnage', value: Number(row?.bookedTons ?? 0), unit: 't' }, + { label: 'Avg utilization', value: Math.round(Number(row?.avgUtilization ?? 0)), unit: '%' }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/revenue-by-customer.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/revenue-by-customer.report.ts new file mode 100644 index 000000000..31b9951b8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/revenue-by-customer.report.ts @@ -0,0 +1,91 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { Booking } from '../../bookings/entities/booking.entity'; +import { Company } from '../../companies/entities/company.entity'; +import { ReportContext, ReportDefinition } from '../report.types'; + +const TONS = 'COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)'; +const REVENUE = 'COALESCE(b.adjusted_total_amount, b.total_amount)'; +const NOT_UMBRELLA = "(b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')"; +const DEAD_STATUSES = ['DRAFT', 'CANCELLED', 'REJECTED', 'EXPIRED']; + +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + const { params, directions } = ctx; + const qb = ctx.ds + .createQueryBuilder() + .from(Booking, 'b') + .innerJoin(Company, 'c', 'c.id = b.company_id') + .where(`b.deleted_at IS NULL AND ${NOT_UMBRELLA}`); + + if (params.dateFrom) qb.andWhere('b.created_at >= :dateFrom', { dateFrom: params.dateFrom }); + if (params.dateTo) qb.andWhere('b.created_at < :dateTo', { dateTo: params.dateTo }); + if (params.direction) qb.andWhere('b.trade_direction = :direction', { direction: params.direction }); + if (params.freightType) qb.andWhere('b.freight_type = :freightType', { freightType: params.freightType }); + const statuses = params.statuses as string[] | null; + if (statuses) { + qb.andWhere('b.status IN (:...statuses)', { statuses }); + } else { + qb.andWhere('b.status NOT IN (:...deadStatuses)', { deadStatuses: DEAD_STATUSES }); + } + if (directions !== null) { + qb.andWhere(directions.length ? 'b.trade_direction IN (:...directions)' : '1 = 0', { + directions, + }); + } + return qb; +} + +export const revenueByCustomerReport: ReportDefinition = { + key: 'revenue-by-customer', + title: 'Revenue by Customer', + description: 'Ranked customers by booking revenue', + group: 'Commercial', + filters: [ + { key: 'date', label: 'Created', type: 'daterange' }, + { + key: 'direction', + label: 'Direction', + type: 'select', + options: [ + { value: 'IMPORT', label: 'Import' }, + { value: 'EXPORT', label: 'Export' }, + { value: 'DOMESTIC', label: 'Domestic' }, + ], + }, + { + key: 'freightType', + label: 'Freight type', + type: 'select', + options: [ + { value: 'CONTAINER', label: 'Container' }, + { value: 'BULK', label: 'Bulk' }, + ], + }, + { key: 'statuses', label: 'Status', type: 'multiselect' }, + ], + columns: [ + { key: 'customer', label: 'Customer', type: 'string', sortable: true, sortExpr: 'c.name' }, + { key: 'bookings', label: 'Bookings', type: 'number', sortable: true }, + { key: 'tons', label: 'Tonnage', type: 'tons', sortable: true }, + { key: 'revenue', label: 'Revenue', type: 'money', sortable: true }, + ], + defaultSort: { key: 'revenue', dir: 'DESC' }, + query(ctx) { + return baseQuery(ctx) + .select('c.name', 'customer') + .addSelect('COUNT(*)::int', 'bookings') + .addSelect(`ROUND(COALESCE(SUM(${TONS}), 0))::float8`, 'tons') + .addSelect(`ROUND(COALESCE(SUM(${REVENUE}), 0))::float8`, 'revenue') + .groupBy('c.name'); + }, + async summary(ctx) { + const row = await baseQuery(ctx) + .select('COUNT(DISTINCT c.name)::int', 'customers') + .addSelect(`ROUND(COALESCE(SUM(${REVENUE}), 0))::float8`, 'revenue') + .getRawOne(); + return [ + { label: 'Customers', value: Number(row?.customers ?? 0) }, + { label: 'Revenue', value: Number(row?.revenue ?? 0), unit: 'ETB' }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/dto/report-query.dto.ts b/apps/edr-freight-api/src/modules/reports/dto/report-query.dto.ts deleted file mode 100644 index 82b1a76ef..000000000 --- a/apps/edr-freight-api/src/modules/reports/dto/report-query.dto.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { ApiPropertyOptional } from '@nestjs/swagger'; -import { IsIn, IsOptional, IsString } from 'class-validator'; - -export class ReportQueryDto { - @ApiPropertyOptional({ description: 'Inclusive start date (YYYY-MM-DD). Default: 30 days ago.' }) - @IsOptional() - @IsString() - dateFrom?: string; - - @ApiPropertyOptional({ description: 'Inclusive end date (YYYY-MM-DD). Default: today.' }) - @IsOptional() - @IsString() - dateTo?: string; - - @ApiPropertyOptional({ enum: ['day', 'week', 'month'], default: 'day' }) - @IsOptional() - @IsIn(['day', 'week', 'month']) - granularity?: 'day' | 'week' | 'month'; - - @ApiPropertyOptional({ description: 'Comma-separated company UUIDs' }) - @IsOptional() - @IsString() - companyIds?: string; - - @ApiPropertyOptional({ description: 'Comma-separated route UUIDs' }) - @IsOptional() - @IsString() - routeIds?: string; - - @ApiPropertyOptional({ description: 'Comma-separated yard UUIDs (matches origin or destination)' }) - @IsOptional() - @IsString() - yardIds?: string; - - @ApiPropertyOptional({ description: 'Comma-separated cargo type UUIDs' }) - @IsOptional() - @IsString() - cargoTypeIds?: string; - - @ApiPropertyOptional({ description: 'Comma-separated status values (report-specific)' }) - @IsOptional() - @IsString() - statuses?: string; - - @ApiPropertyOptional({ description: 'Trade direction filter' }) - @IsOptional() - @IsString() - direction?: string; - - @ApiPropertyOptional({ enum: ['CONTAINER', 'BULK'] }) - @IsOptional() - @IsIn(['CONTAINER', 'BULK']) - freightType?: string; -} diff --git a/apps/edr-freight-api/src/modules/reports/dto/report-result.dto.ts b/apps/edr-freight-api/src/modules/reports/dto/report-result.dto.ts deleted file mode 100644 index cc1c215af..000000000 --- a/apps/edr-freight-api/src/modules/reports/dto/report-result.dto.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; - -export class ReportKpiDto { - @ApiProperty() - label!: string; - - @ApiProperty() - value!: number; - - @ApiPropertyOptional() - unit?: string; -} - -export class ReportResultDto { - @ApiProperty({ type: [ReportKpiDto] }) - kpis!: ReportKpiDto[]; - - @ApiProperty({ - type: 'array', - items: { type: 'object', additionalProperties: true }, - description: 'Report rows; columns vary per report key', - }) - rows!: Record[]; -} diff --git a/apps/edr-freight-api/src/modules/reports/report-export.service.ts b/apps/edr-freight-api/src/modules/reports/report-export.service.ts new file mode 100644 index 000000000..f07317134 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/report-export.service.ts @@ -0,0 +1,114 @@ +import { Injectable } from '@nestjs/common'; +import ExcelJS from 'exceljs'; + +import { PdfRenderService } from '../billing/documents/pdf-render.service'; +import { ReportColumn, ReportDefinition, ReportKpi } from './report.types'; + +// ponytail: in-memory Workbook, cap below. Switch to ExcelJS's streaming +// WorkbookWriter if a report ever needs to outgrow XLSX_ROW_CAP. +export const XLSX_ROW_CAP = 50_000; +// ponytail: HTML→PDF render cost grows with row count; larger exports must +// use XLSX instead. +export const PDF_ROW_CAP = 5_000; + +const NUMBER_FORMAT: Partial> = { + money: '#,##0.00', + tons: '#,##0.0', + percent: '0"%"', + number: '#,##0', +}; + +function formatCell(value: unknown, type: ReportColumn['type']): string { + if (value === null || value === undefined) return ''; + if (type === 'money' || type === 'number') { + return Number(value).toLocaleString('en-US', { maximumFractionDigits: 2 }); + } + if (type === 'tons') return `${Number(value).toLocaleString('en-US')} t`; + if (type === 'percent') return `${value}%`; + return String(value); +} + +@Injectable() +export class ReportExportService { + constructor(private readonly pdfRender: PdfRenderService) {} + + async toXlsx( + def: ReportDefinition, + rows: Record[], + kpis: ReportKpi[], + ): Promise { + const workbook = new ExcelJS.Workbook(); + const sheet = workbook.addWorksheet(def.title.slice(0, 31)); + + if (kpis.length) { + sheet.addRow(kpis.map((k) => `${k.label}: ${k.value.toLocaleString()}${k.unit ? ` ${k.unit}` : ''}`)); + sheet.addRow([]); + } + + const headerRow = sheet.addRow(def.columns.map((c) => c.label)); + headerRow.font = { bold: true }; + + for (const row of rows) { + sheet.addRow(def.columns.map((c) => row[c.key] ?? null)); + } + + def.columns.forEach((col, i) => { + const format = NUMBER_FORMAT[col.type]; + const excelCol = sheet.getColumn(i + 1); + excelCol.width = Math.max(col.label.length + 2, 12); + if (format) excelCol.numFmt = format; + }); + + const buffer = await workbook.xlsx.writeBuffer(); + return Buffer.from(buffer); + } + + async toPdf( + def: ReportDefinition, + rows: Record[], + kpis: ReportKpi[], + ): Promise { + const html = this.buildHtml(def, rows, kpis); + return this.pdfRender.htmlToPdfBuffer(html, { label: `report:${def.key}`, landscape: true }); + } + + private buildHtml( + def: ReportDefinition, + rows: Record[], + kpis: ReportKpi[], + ): string { + const esc = (v: unknown) => + String(v ?? '').replace(/&/g, '&').replace(//g, '>'); + + const kpiHtml = kpis.length + ? `
${kpis + .map( + (k) => + `
${esc(k.label)}
${k.value.toLocaleString()}${k.unit ? ` ${esc(k.unit)}` : ''}
`, + ) + .join('')}
` + : ''; + + const head = def.columns.map((c) => `${esc(c.label)}`).join(''); + const body = rows + .map( + (row) => + `${def.columns.map((c) => `${esc(formatCell(row[c.key], c.type))}`).join('')}`, + ) + .join(''); + + return ` +

${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..5df9ba2e1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/report-runner.service.ts @@ -0,0 +1,141 @@ +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; +} + +/** 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 ?? 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 ?? 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); + const sort = resolveSort(def, undefined, undefined); + 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..84bbfb27a --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/report.registry.spec.ts @@ -0,0 +1,41 @@ +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); + } + }); +}); 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..26474d025 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/report.registry.ts @@ -0,0 +1,24 @@ +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 { 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, +]; + +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..85b73040c --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/report.types.ts @@ -0,0 +1,95 @@ +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; +} + +/** + * 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; +} + +/** 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..49d0307eb 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,90 @@ -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 { PDF_ROW_CAP, ReportExportService, XLSX_ROW_CAP } from './report-export.service'; +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 }, + @CurrentUser() user: TCurrentUser, + @Res() res: Response, + ): Promise { + const def = this.resolve(key, user); + const directions = await this.userTradeAccessService.resolveAllowedDirections(user); + const format = query.format === 'pdf' ? 'pdf' : 'xlsx'; + const cap = format === 'pdf' ? PDF_ROW_CAP : XLSX_ROW_CAP; + + const { items, kpis } = await this.runner.runAll(def, query, directions, cap); + const buffer = + format === 'pdf' + ? await this.exportService.toPdf(def, items, kpis) + : await this.exportService.toXlsx(def, items, kpis); + + 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/pnpm-lock.yaml b/pnpm-lock.yaml index b0a9a550e..8e8768819 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -126,6 +126,9 @@ importers: dotenv-cli: specifier: ^11.0.0 version: 11.0.0 + exceljs: + specifier: ^4.4.0 + version: 4.4.0 handlebars: specifier: ^4.7.9 version: 4.7.9 @@ -601,7 +604,7 @@ importers: version: 5.101.0(react@19.2.6) '@tria-plc/iamui': specifier: file:../../../local-packages/tria-plc-iamui-0.1.1.tgz - version: file:local-packages/tria-plc-iamui-0.1.1.tgz(a5d0bdee55164ae56064fb273b530e00) + version: file:local-packages/tria-plc-iamui-0.1.1.tgz(0ce39b7e349029277dcd938d06eeb0f7) '@vis.gl/react-google-maps': specifier: ^1.8.3 version: 1.8.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -13056,11 +13059,11 @@ snapshots: '@babel/helpers': 7.29.7 '@babel/parser': 7.29.7 '@babel/template': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@5.5.0) '@babel/types': 7.29.7 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -13095,7 +13098,7 @@ snapshots: '@babel/helper-optimise-call-expression': 7.29.7 '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7) '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@5.5.0) semver: 6.3.1 transitivePeerDependencies: - supports-color @@ -13104,14 +13107,7 @@ snapshots: '@babel/helper-member-expression-to-functions@7.29.7': dependencies: - '@babel/traverse': 7.29.7 - '@babel/types': 7.29.7 - transitivePeerDependencies: - - supports-color - - '@babel/helper-module-imports@7.29.7': - dependencies: - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@5.5.0) '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color @@ -13126,9 +13122,9 @@ snapshots: '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-module-imports': 7.29.7 + '@babel/helper-module-imports': 7.29.7(supports-color@5.5.0) '@babel/helper-validator-identifier': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -13143,13 +13139,13 @@ snapshots: '@babel/core': 7.29.7 '@babel/helper-member-expression-to-functions': 7.29.7 '@babel/helper-optimise-call-expression': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@5.5.0) transitivePeerDependencies: - supports-color '@babel/helper-skip-transparent-expression-wrappers@7.29.7': dependencies: - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@5.5.0) '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color @@ -13302,18 +13298,6 @@ snapshots: '@babel/parser': 7.29.7 '@babel/types': 7.29.7 - '@babel/traverse@7.29.7': - dependencies: - '@babel/code-frame': 7.29.7 - '@babel/generator': 7.29.7 - '@babel/helper-globals': 7.29.7 - '@babel/parser': 7.29.7 - '@babel/template': 7.29.7 - '@babel/types': 7.29.7 - debug: 4.4.3(supports-color@8.1.1) - transitivePeerDependencies: - - supports-color - '@babel/traverse@7.29.7(supports-color@5.5.0)': dependencies: '@babel/code-frame': 7.29.7 @@ -13798,7 +13782,7 @@ snapshots: '@emotion/babel-plugin@11.13.5': dependencies: - '@babel/helper-module-imports': 7.29.7 + '@babel/helper-module-imports': 7.29.7(supports-color@5.5.0) '@babel/runtime': 7.29.7 '@emotion/hash': 0.9.2 '@emotion/memoize': 0.9.0 @@ -13964,7 +13948,7 @@ snapshots: '@eslint/eslintrc@2.1.4': dependencies: ajv: 6.15.0 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) espree: 9.6.1 globals: 13.24.0 ignore: 5.3.2 @@ -14124,7 +14108,7 @@ snapshots: '@humanwhocodes/config-array@0.13.0': dependencies: '@humanwhocodes/object-schema': 2.0.3 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) minimatch: 3.1.5 transitivePeerDependencies: - supports-color @@ -15723,7 +15707,7 @@ snapshots: '@puppeteer/browsers@2.13.2': dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) extract-zip: 2.0.1 progress: 2.0.3 proxy-agent: 6.5.0 @@ -17797,7 +17781,7 @@ snapshots: '@tokenizer/inflate@0.4.1': dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) token-types: 6.1.2 transitivePeerDependencies: - supports-color @@ -18084,130 +18068,6 @@ snapshots: - utf-8-validate - vite - '@tria-plc/iamui@file:local-packages/tria-plc-iamui-0.1.1.tgz(a5d0bdee55164ae56064fb273b530e00)': - dependencies: - '@emotion/react': 11.14.0(@types/react@18.3.31)(react@19.2.6) - '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@18.3.31)(react@19.2.6))(@types/react@18.3.31)(react@19.2.6) - '@hookform/resolvers': 5.4.0(react-hook-form@7.77.0(react@19.2.6)) - '@lottiefiles/react-lottie-player': 3.6.0(react@19.2.6) - '@mantine/charts': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(recharts@3.8.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)(redux@5.0.1)) - '@mantine/core': 7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@mantine/dates': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@mantine/hooks': 7.17.8(react@19.2.6) - '@mantine/notifications': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-accordion': 1.2.13(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-alert-dialog': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-avatar': 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-checkbox': 1.3.4(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-collapsible': 1.1.13(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-context-menu': 2.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-dialog': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-dropdown-menu': 2.1.17(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-hover-card': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-label': 2.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-navigation-menu': 1.2.15(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-popover': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-progress': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-radio-group': 1.4.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-scroll-area': 1.2.11(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-select': 2.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-separator': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-slot': 1.2.5(@types/react@18.3.31)(react@19.2.6) - '@radix-ui/react-switch': 1.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-tabs': 1.1.14(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-toast': 1.2.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-tooltip': 1.2.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@react-pdf-viewer/core': 3.12.0(pdfjs-dist@5.4.296)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@react-pdf-viewer/default-layout': 3.12.0(pdfjs-dist@5.4.296)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@react-pdf/renderer': 4.5.1(react@19.2.6) - '@reduxjs/toolkit': 2.12.0(react-redux@9.3.0(@types/react@18.3.31)(react@19.2.6)(redux@5.0.1))(react@19.2.6) - '@tabler/icons-react': 3.44.0(react@19.2.6) - '@tailwindcss/vite': 4.3.0(vite@5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0)) - '@tanstack/react-query': 5.101.0(react@19.2.6) - '@tanstack/react-query-devtools': 5.101.0(@tanstack/react-query@5.101.0(react@19.2.6))(react@19.2.6) - '@tanstack/react-table': 8.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@tinymce/tinymce-react': 6.3.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(tinymce@7.9.3) - '@types/dompurify': 3.2.0 - '@types/node': 24.13.1 - '@types/tinymce': 4.6.9 - axios: 1.17.0 - class-variance-authority: 0.7.1 - clsx: 2.1.1 - cmdk: 1.1.1(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - date-fns: 3.6.0 - dayjs: 1.11.21 - dompurify: 3.4.8 - ethiopian-calendar-date-converter: 2.1.6 - ethiopian-calendar-new: 1.1.0 - file-type: 18.7.0 - framer-motion: 12.40.0(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - html2canvas: 1.4.1 - i18next: 25.10.10(typescript@5.9.3) - i18next-browser-languagedetector: 8.2.1 - jquery: 3.7.1 - js-cookie: 3.0.8 - jspdf: 3.0.4 - lodash: 4.18.1 - lucide-react: 0.513.0(react@19.2.6) - mantine-react-table: 2.0.0-beta.9(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/dates@7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(@tabler/icons-react@3.44.0(react@19.2.6))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - mui-ethiopian-datepicker: 0.3.2(4b3af212eafdf0059f009b005d7e343d) - next-themes: 0.4.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - path: 0.12.7 - pdf-lib: 1.17.1 - qs: 6.15.2 - react: 19.2.6 - react-cookie: 8.1.2(@types/react@18.3.31)(react@19.2.6) - react-css-nocode-editor: 1.0.13(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6) - react-day-picker: 8.10.2(date-fns@3.6.0)(react@19.2.6) - react-dom: 19.2.6(react@19.2.6) - react-dropzone: 14.4.1(react@19.2.6) - react-hook-form: 7.77.0(react@19.2.6) - react-i18next: 15.7.4(i18next@25.10.10(typescript@5.9.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3) - react-icons: 5.6.0(react@19.2.6) - react-image-crop: 11.0.10(react@19.2.6) - react-intersection-observer: 9.16.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - react-pdf: 10.4.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - react-pdf-html: 2.1.5(@react-pdf/renderer@4.5.1(react@19.2.6))(react@19.2.6) - react-redux: 9.3.0(@types/react@18.3.31)(react@19.2.6)(redux@5.0.1) - react-resizable-panels: 3.0.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - react-router-dom: 7.17.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - react-signature-canvas: 1.1.0-alpha.2(@types/prop-types@15.7.15)(@types/react@18.3.31)(prop-types@15.8.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - recharts: 3.8.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)(redux@5.0.1) - rollup-plugin-visualizer: 7.0.1(rollup@4.61.1) - socket.io-client: 4.8.3 - sonner: 2.0.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - tailwind-merge: 3.6.0 - tailwind-scrollbar-hide: 4.0.0(tailwindcss@4.3.0) - tailwindcss: 4.3.0 - tailwindcss-animate: 1.0.7(tailwindcss@4.3.0) - tinymce: 7.9.3 - url: 0.11.4 - vaul: 1.1.2(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - xlsx: 0.18.5 - zod: 3.25.76 - transitivePeerDependencies: - - '@babel/core' - - '@emotion/is-prop-valid' - - '@mui/icons-material' - - '@mui/material' - - '@mui/x-date-pickers' - - '@types/prop-types' - - '@types/react' - - '@types/react-dom' - - bufferutil - - debug - - pdfjs-dist - - prop-types - - react-is - - react-native - - redux - - rolldown - - rollup - - supports-color - - typescript - - utf-8-validate - - vite - '@ts-morph/common@0.27.0': dependencies: fast-glob: 3.3.3 @@ -18605,7 +18465,7 @@ snapshots: '@typescript-eslint/types': 8.60.1 '@typescript-eslint/typescript-estree': 8.60.1(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.60.1 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) eslint: 8.57.1 typescript: 5.9.3 transitivePeerDependencies: @@ -18615,7 +18475,7 @@ snapshots: dependencies: '@typescript-eslint/tsconfig-utils': 8.60.1(typescript@5.9.3) '@typescript-eslint/types': 8.60.1 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -18634,7 +18494,7 @@ snapshots: '@typescript-eslint/types': 8.60.1 '@typescript-eslint/typescript-estree': 8.60.1(typescript@5.9.3) '@typescript-eslint/utils': 8.60.1(eslint@8.57.1)(typescript@5.9.3) - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) eslint: 8.57.1 ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 @@ -18649,7 +18509,7 @@ snapshots: '@typescript-eslint/tsconfig-utils': 8.60.1(typescript@5.9.3) '@typescript-eslint/types': 8.60.1 '@typescript-eslint/visitor-keys': 8.60.1 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) minimatch: 10.2.5 semver: 7.8.2 tinyglobby: 0.2.17 @@ -18938,7 +18798,7 @@ snapshots: agent-base@6.0.2: dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -19447,16 +19307,6 @@ snapshots: transitivePeerDependencies: - supports-color - babel-plugin-styled-components@2.3.0(styled-components@5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6))(supports-color@5.5.0): - dependencies: - '@babel/helper-annotate-as-pure': 7.29.7 - '@babel/helper-module-imports': 7.29.7(supports-color@5.5.0) - '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) - picomatch: 4.0.4 - styled-components: 5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6) - transitivePeerDependencies: - - supports-color - babel-polyfill@6.26.0: dependencies: babel-runtime: 6.26.0 @@ -19612,7 +19462,7 @@ snapshots: dependencies: bytes: 3.1.2 content-type: 1.0.5 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) http-errors: 2.0.1 iconv-lite: 0.7.2 on-finished: 2.4.1 @@ -20661,7 +20511,7 @@ snapshots: engine.io-client@6.6.5: dependencies: '@socket.io/component-emitter': 3.1.2 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) engine.io-parser: 5.2.3 ws: 8.20.1 xmlhttprequest-ssl: 2.1.2 @@ -20681,7 +20531,7 @@ snapshots: base64id: 2.0.0 cookie: 0.7.2 cors: 2.8.6 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) engine.io-parser: 5.2.3 ws: 8.21.0 transitivePeerDependencies: @@ -20912,7 +20762,7 @@ snapshots: eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1): dependencies: '@nolyfill/is-core-module': 1.0.39 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) eslint: 8.57.1 get-tsconfig: 4.14.0 is-bun-module: 2.0.0 @@ -21040,7 +20890,7 @@ snapshots: ajv: 6.15.0 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) doctrine: 3.0.0 escape-string-regexp: 4.0.0 eslint-scope: 7.2.2 @@ -21277,7 +21127,7 @@ snapshots: content-type: 1.0.5 cookie: 0.7.2 cookie-signature: 1.2.2 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) depd: 2.0.0 encodeurl: 2.0.0 escape-html: 1.0.3 @@ -21330,7 +21180,7 @@ snapshots: extract-zip@2.0.1: dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) get-stream: 5.2.0 yauzl: 2.10.0 optionalDependencies: @@ -21485,7 +21335,7 @@ snapshots: finalhandler@2.1.1: dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) encodeurl: 2.0.0 escape-html: 1.0.3 on-finished: 2.4.1 @@ -21733,7 +21583,7 @@ snapshots: dependencies: basic-ftp: 5.3.1 data-uri-to-buffer: 6.0.2 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -22042,7 +21892,7 @@ snapshots: http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -22055,14 +21905,14 @@ snapshots: https-proxy-agent@5.0.1: dependencies: agent-base: 6.0.2 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) transitivePeerDependencies: - supports-color https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -22502,7 +22352,7 @@ snapshots: istanbul-lib-source-maps@4.0.1: dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) istanbul-lib-coverage: 3.2.2 source-map: 0.6.1 transitivePeerDependencies: @@ -23162,7 +23012,7 @@ snapshots: dependencies: chalk: 5.6.2 commander: 13.1.0 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) execa: 8.0.1 lilconfig: 3.1.3 listr2: 8.3.3 @@ -23849,7 +23699,7 @@ snapshots: micromark@4.0.2: dependencies: '@types/debug': 4.1.13 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) decode-named-character-reference: 1.3.0 devlop: 1.1.0 micromark-core-commonmark: 2.0.3 @@ -24375,7 +24225,7 @@ snapshots: dependencies: '@tootallnate/quickjs-emscripten': 0.23.0 agent-base: 7.1.4 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) get-uri: 6.0.5 http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 @@ -24725,7 +24575,7 @@ snapshots: proxy-agent@6.5.0: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 lru-cache: 7.18.3 @@ -24754,7 +24604,7 @@ snapshots: dependencies: '@puppeteer/browsers': 2.13.2 chromium-bidi: 14.0.0(devtools-protocol@0.0.1608973) - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) devtools-protocol: 0.0.1608973 typed-query-selector: 2.12.2 webdriver-bidi-protocol: 0.4.1 @@ -25007,15 +24857,6 @@ snapshots: - '@babel/core' - react-is - react-css-nocode-editor@1.0.13(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6): - dependencies: - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - styled-components: 5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6) - transitivePeerDependencies: - - '@babel/core' - - react-is - react-day-picker@8.10.2(date-fns@3.6.0)(react@19.2.6): dependencies: date-fns: 3.6.0 @@ -25626,7 +25467,7 @@ snapshots: router@2.2.0: dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) depd: 2.0.0 is-promise: 4.0.0 parseurl: 1.3.3 @@ -25748,7 +25589,7 @@ snapshots: send@1.2.1: dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 @@ -25964,7 +25805,7 @@ snapshots: socket.io-adapter@2.5.8: dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) ws: 8.21.0 transitivePeerDependencies: - bufferutil @@ -25974,7 +25815,7 @@ snapshots: socket.io-client@4.8.3: dependencies: '@socket.io/component-emitter': 3.1.2 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) engine.io-client: 6.6.5 socket.io-parser: 4.2.6 transitivePeerDependencies: @@ -25985,7 +25826,7 @@ snapshots: socket.io-parser@4.2.6: dependencies: '@socket.io/component-emitter': 3.1.2 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -25994,7 +25835,7 @@ snapshots: accepts: 1.3.8 base64id: 2.0.0 cors: 2.8.6 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) engine.io: 6.6.9 socket.io-adapter: 2.5.8 socket.io-parser: 4.2.6 @@ -26006,7 +25847,7 @@ snapshots: socks-proxy-agent@8.0.5: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) socks: 2.8.9 transitivePeerDependencies: - supports-color @@ -26301,24 +26142,6 @@ snapshots: transitivePeerDependencies: - '@babel/core' - styled-components@5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6): - dependencies: - '@babel/helper-module-imports': 7.29.7(supports-color@5.5.0) - '@babel/traverse': 7.29.7(supports-color@5.5.0) - '@emotion/is-prop-valid': 1.4.0 - '@emotion/stylis': 0.8.5 - '@emotion/unitless': 0.7.5 - babel-plugin-styled-components: 2.3.0(styled-components@5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6))(supports-color@5.5.0) - css-to-react-native: 3.2.0 - hoist-non-react-statics: 3.3.2 - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - react-is: 19.2.7 - shallowequal: 1.1.0 - supports-color: 5.5.0 - transitivePeerDependencies: - - '@babel/core' - styled-jsx@5.1.1(babel-plugin-macros@3.1.0)(react@18.3.1): dependencies: client-only: 0.0.1 @@ -26344,7 +26167,7 @@ snapshots: dependencies: component-emitter: 1.3.1 cookiejar: 2.1.4 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) fast-safe-stringify: 2.1.1 form-data: 4.0.5 formidable: 3.5.4 @@ -26857,7 +26680,7 @@ snapshots: app-root-path: 3.1.0 buffer: 6.0.3 dayjs: 1.11.21 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) dedent: 1.7.2(babel-plugin-macros@3.1.0) dotenv: 16.6.1 glob: 10.5.0 @@ -26881,7 +26704,7 @@ snapshots: app-root-path: 3.1.0 buffer: 6.0.3 dayjs: 1.11.21 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) dedent: 1.7.2(babel-plugin-macros@3.1.0) dotenv: 16.6.1 glob: 10.5.0 @@ -27242,7 +27065,7 @@ snapshots: vite-node@2.1.9(@types/node@22.20.1)(lightningcss@1.32.0)(terser@5.48.0): dependencies: cac: 6.7.14 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) es-module-lexer: 1.7.0 pathe: 1.1.2 vite: 5.4.21(@types/node@22.20.1)(lightningcss@1.32.0)(terser@5.48.0) @@ -27260,7 +27083,7 @@ snapshots: vite-node@2.1.9(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0): dependencies: cac: 6.7.14 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) es-module-lexer: 1.7.0 pathe: 1.1.2 vite: 5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0) @@ -27307,7 +27130,7 @@ snapshots: '@vitest/spy': 2.1.9 '@vitest/utils': 2.1.9 chai: 5.3.3 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) expect-type: 1.3.0 magic-string: 0.30.21 pathe: 1.1.2 @@ -27343,7 +27166,7 @@ snapshots: '@vitest/spy': 2.1.9 '@vitest/utils': 2.1.9 chai: 5.3.3 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) expect-type: 1.3.0 magic-string: 0.30.21 pathe: 1.1.2 From 081b9945cb3bc8731f46b7413bc9e0272f7a8724 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Thu, 13 Aug 2026 07:52:29 +0000 Subject: [PATCH 05/34] feat(freight-backoffice): render reports from the server-driven catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop reportConfigs.ts (per-report FE config duplicating the backend) and the chart-drawing ReportPage. Replace with ReportView: one engine that renders any report the GET /reports catalog describes — filters, KPI strip, sortable/paginated DataTable, xlsx/pdf export via blob download. ReportSection embeds a report inline on any page, scoped by idKey, and renders nothing if the caller lacks that report's permission. Sidebar Reports submenu is now built from the live catalog (sidebar-sections.tsx + App.tsx) instead of a hand-listed key — no FE edit needed to add or hide a report. --- apps/edr-freight-web/backoffice/src/App.tsx | 16 +- .../components/layout/sidebar-sections.tsx | 8 +- .../src/components/reports/ReportFilters.tsx | 115 +++++ .../src/components/reports/ReportSection.tsx | 39 ++ .../src/components/reports/ReportView.tsx | 212 +++++++++ .../src/components/reports/report-format.ts | 42 ++ .../backoffice/src/constants/URLS.ts | 2 + .../src/pages/reports/ReportPage.tsx | 440 +---------------- .../src/pages/reports/ReportsHubPage.tsx | 193 ++------ .../src/pages/reports/reportConfigs.ts | 445 ------------------ .../backoffice/src/services/api.ts | 7 +- .../src/services/reports.service.ts | 29 +- .../backoffice/src/types/reports.ts | 88 +++- 13 files changed, 586 insertions(+), 1050 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/components/reports/ReportFilters.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/reports/ReportSection.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/reports/ReportView.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/reports/report-format.ts delete mode 100644 apps/edr-freight-web/backoffice/src/pages/reports/reportConfigs.ts diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 64f91da72..94ad11f1b 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"; @@ -139,8 +141,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"; diff --git a/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx b/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx index 95608b6db..4548dc134 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 @@ -51,7 +51,10 @@ 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[] => [ +export const buildSidebarSections = ( + demoItems: SidebarItem[], + reportItems: SidebarItem[] = [], +): SidebarSection[] => [ { title: "Main menu", items: [ @@ -66,6 +69,9 @@ export const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] 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 } : {}), }, { label: "Customers", diff --git a/apps/edr-freight-web/backoffice/src/components/reports/ReportFilters.tsx b/apps/edr-freight-web/backoffice/src/components/reports/ReportFilters.tsx new file mode 100644 index 000000000..9412d4db8 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/reports/ReportFilters.tsx @@ -0,0 +1,115 @@ +import { Group, MultiSelect, Select, TextInput } from "@mantine/core"; +import { DateInput } from "@mantine/dates"; +import { Search } from "lucide-react"; + +import type { ReportFilterDef } from "@/types/reports"; + +export interface ReportFilterValues { + [param: string]: string | undefined; +} + +interface ReportFiltersProps { + filters: ReportFilterDef[]; + values: ReportFilterValues; + onChange: (values: ReportFilterValues) => void; +} + +const toDate = (value: string | undefined): Date | null => (value ? new Date(value) : null); +const fromDate = (value: string | null): string | undefined => value ?? undefined; + +/** Renders one widget per report-declared filter and reports raw param values back up. */ +export function ReportFilters({ filters, values, onChange }: ReportFiltersProps) { + if (!filters.length) return null; + + const set = (patch: ReportFilterValues) => onChange({ ...values, ...patch }); + + return ( + + {filters.map((filter) => { + switch (filter.type) { + case "daterange": + return ( + + set({ [`${filter.key}From`]: fromDate(d) })} + radius="md" + size="sm" + clearable + w={150} + /> + set({ [`${filter.key}To`]: fromDate(d) })} + radius="md" + size="sm" + clearable + w={150} + /> + + ); + case "date": + return ( + set({ [filter.key]: fromDate(d) })} + radius="md" + size="sm" + clearable + w={150} + /> + ); + case "select": + return ( + setParam("granularity", v)} - allowDeselect={false} - /> - ) : null} - {config.filters.includes("yards") ? ( - ({ - value: y.id, - label: y.label, - }))} - value={params.get("yardIds")?.split(",").filter(Boolean) ?? []} - onChange={(v) => setParam("yardIds", v.length ? v.join(",") : null)} - placeholder="All yards" - /> - ) : null} - {config.filters.includes("direction") ? ( - setParam("freightType", v)} - placeholder="All" - /> - ) : null} - {config.filters.includes("statuses") && config.statusOptions ? ( - setParam("statuses", v.length ? v.join(",") : null)} - placeholder="Default (active)" - /> - ) : null} - - - - - ({ - label: k.label, - value: k.value.toLocaleString(), - hint: k.unit, - }))} - /> - - - - void reportQuery.refetch(), - } - : undefined - } - pagination={{ - pageIndex: pagination.pageIndex, - pageSize: pagination.pageSize, - pageCount, - totalCount: rows.length, - }} - tableOptions={{ - manualPagination: false, - state: { pagination }, - onPaginationChange: setPagination, - autoResetPageIndex: false, - }} - footer={({ table, pagination: p }) => ( - - )} /> +
); } diff --git a/apps/edr-freight-web/backoffice/src/pages/reports/ReportsHubPage.tsx b/apps/edr-freight-web/backoffice/src/pages/reports/ReportsHubPage.tsx index f8f6f161d..172afca82 100644 --- a/apps/edr-freight-web/backoffice/src/pages/reports/ReportsHubPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/reports/ReportsHubPage.tsx @@ -1,159 +1,64 @@ -import { - ActionIcon, - Badge, - Card, - Group, - SimpleGrid, - Stack, - Text, - TextInput, - Title, -} from "@mantine/core"; -import { Search, Star } from "lucide-react"; -import { useMemo, useState } from "react"; +import { Alert, Card, SimpleGrid, Skeleton, Stack, Text, Title } from "@mantine/core"; +import { useQuery } from "@tanstack/react-query"; import { useNavigate } from "react-router-dom"; import { PageContainer, PageHeader } from "@/components/page"; -import { - REPORT_CONFIGS, - REPORT_DOMAINS, - type ReportConfig, -} from "./reportConfigs"; +import { api } from "@/services/api"; +import type { ReportCatalogEntry } from "@/types/reports"; -const FAVORITES_KEY = "reports.favorites"; - -const loadFavorites = (): string[] => { - try { - return JSON.parse(localStorage.getItem(FAVORITES_KEY) ?? "[]"); - } catch { - return []; - } -}; - -function ReportCard({ - config, - favorite, - onToggleFavorite, -}: { - config: ReportConfig; - favorite: boolean; - onToggleFavorite: () => void; -}) { - const navigate = useNavigate(); - return ( - navigate(`/dashboard/reports/${config.key}`)} - > - -
- - {config.title} - - - {config.description} - -
- { - e.stopPropagation(); - onToggleFavorite(); - }} - > - - -
- - {config.domain} - -
- ); -} +const GROUP_ORDER: ReportCatalogEntry["group"][] = ["Commercial", "Operations", "Finance"]; export default function ReportsHubPage() { - const [search, setSearch] = useState(""); - const [favorites, setFavorites] = useState(loadFavorites); + const navigate = useNavigate(); + const { data: catalog, isLoading, isError } = useQuery(api.reports.catalog.queryOptions()); - const toggleFavorite = (key: string) => { - setFavorites((prev) => { - const next = prev.includes(key) - ? prev.filter((k) => k !== key) - : [...prev, key]; - localStorage.setItem(FAVORITES_KEY, JSON.stringify(next)); - return next; - }); - }; - - const visible = useMemo(() => { - const q = search.trim().toLowerCase(); - if (!q) return REPORT_CONFIGS; - return REPORT_CONFIGS.filter( - (c) => - c.title.toLowerCase().includes(q) || - c.description.toLowerCase().includes(q), - ); - }, [search]); - - const pinned = visible.filter((c) => favorites.includes(c.key)); - - const renderGrid = (configs: ReportConfig[]) => ( - - {configs.map((c) => ( - toggleFavorite(c.key)} - /> - ))} - - ); + const groups = GROUP_ORDER.map((group) => ({ + group, + reports: (catalog ?? []).filter((r) => r.group === group), + })).filter((g) => g.reports.length); return ( - } - placeholder="Search reports…" - value={search} - onChange={(e) => setSearch(e.currentTarget.value)} - /> - } - /> + - {pinned.length ? ( - - Favorites - {renderGrid(pinned)} + {isError ? Failed to load the report catalog. : null} + + {isLoading ? ( + + {Array.from({ length: 6 }).map((_, i) => ( + + ))} + + ) : null} + + {!isLoading && !isError && !groups.length ? ( + You don't have access to any reports yet. + ) : null} + + {groups.map(({ group, reports }) => ( + + {group} + + {reports.map((report) => ( + navigate(`/dashboard/reports/${report.key}`)} + > + + {report.title} + + + {report.description} + + + ))} + - ) : null} - - {REPORT_DOMAINS.map((domain) => { - const configs = visible.filter((c) => c.domain === domain); - if (!configs.length) return null; - return ( - - {domain} - {renderGrid(configs)} - - ); - })} - - {visible.length === 0 ? ( - - No reports match “{search}” - - ) : null} + ))} ); } diff --git a/apps/edr-freight-web/backoffice/src/pages/reports/reportConfigs.ts b/apps/edr-freight-web/backoffice/src/pages/reports/reportConfigs.ts deleted file mode 100644 index ced916fbb..000000000 --- a/apps/edr-freight-web/backoffice/src/pages/reports/reportConfigs.ts +++ /dev/null @@ -1,445 +0,0 @@ -import { BookingStatus } from "@edr/types"; - -export type ReportDomain = "Commercial" | "Operations" | "Finance" | "Data"; - -export type ReportColumnUnit = "ETB" | "t" | "%" | "min"; - -export interface ReportColumn { - key: string; - label: string; - /** Numeric unit — formats the cell (thousands separators, suffix). */ - unit?: ReportColumnUnit; - numeric?: boolean; -} - -export interface ReportChart { - type: "area" | "line" | "bar"; - xKey: string; - series: { key: string; label: string }[]; - /** Chart only the first N rows (rows arrive sorted by the backend). */ - topN?: number; -} - -export type ReportFilterKey = - | "granularity" - | "yards" - | "direction" - | "freightType" - | "statuses"; - -export interface ReportConfig { - key: string; - title: string; - description: string; - domain: ReportDomain; - filters: ReportFilterKey[]; - /** Options for the `statuses` filter, when enabled. */ - statusOptions?: string[]; - chart?: ReportChart; - columns: ReportColumn[]; -} - -// Full enum from @edr/types; Set dedupes the deprecated AwaitingPayment alias. -const BOOKING_STATUSES = [...new Set(Object.values(BookingStatus))]; - -// Full list mirroring CONTRACT_STATUSES in contract.entity.ts (no shared enum -// in @edr/types yet). -const CONTRACT_STATUSES = [ - "DRAFT", - "SUBMITTED", - "PRICE_CHANGED_PENDING_CONFIRM", - "CHANGES_REQUESTED", - "PENDING_APPROVAL", - "APPROVED", - "APPROVED_PENDING_SIGNATURE", - "CONTRACT_READY", - "SIGNED_CUSTOMER", - "FULLY_EXECUTED", - "CONTRACT_ACTIVE", - "AWAITING_CLEARANCE_DOCUMENTS", - "CLEARANCE_UNDER_REVIEW", - "CLEARANCE_READY_FOR_BOOKING", - "ACTIVE_SHIPMENT_IN_PROGRESS", - "SUSPENDED", - "CONTRACT_CLOSED", - "EXPIRED", - "REJECTED", - "CANCELLED", - "RENEWAL_DRAFT", - "RENEWAL_SUBMITTED", - "RENEWAL_PENDING_APPROVAL", - "AMENDMENTS_PROPOSED", - "ARCHIVED", -]; - -const INVOICE_STATUSES = [ - "ISSUED", - "PENDING", - "PAYMENT_PROCESSING", - "PARTIALLY_PAID", - "PAID", - "OVERDUE", - "REFUNDED", -]; - -export const REPORT_CONFIGS: ReportConfig[] = [ - { - key: "bookings-trend", - title: "Bookings Trend", - description: "Booking volume, tonnage and revenue over time", - domain: "Commercial", - filters: ["granularity", "yards", "direction", "freightType", "statuses"], - statusOptions: BOOKING_STATUSES, - chart: { - type: "area", - xKey: "period", - series: [{ key: "revenue", label: "Revenue (ETB)" }], - }, - columns: [ - { key: "period", label: "Period" }, - { key: "bookings", label: "Bookings", numeric: true }, - { key: "tons", label: "Tonnage", unit: "t" }, - { key: "revenue", label: "Revenue", unit: "ETB" }, - ], - }, - { - key: "revenue-by-customer", - title: "Revenue by Customer", - description: "Ranked customers by booking revenue", - domain: "Commercial", - filters: ["yards", "direction", "freightType", "statuses"], - statusOptions: BOOKING_STATUSES, - chart: { - type: "bar", - xKey: "customer", - series: [{ key: "revenue", label: "Revenue (ETB)" }], - topN: 10, - }, - columns: [ - { key: "customer", label: "Customer" }, - { key: "bookings", label: "Bookings", numeric: true }, - { key: "tons", label: "Tonnage", unit: "t" }, - { key: "revenue", label: "Revenue", unit: "ETB" }, - ], - }, - { - key: "revenue-by-lane", - title: "Revenue by Lane", - description: "Origin → destination lanes by tonnage and revenue", - domain: "Commercial", - filters: ["direction", "freightType", "statuses"], - statusOptions: BOOKING_STATUSES, - chart: { - type: "bar", - xKey: "origin+destination", - series: [{ key: "revenue", label: "Revenue (ETB)" }], - topN: 10, - }, - columns: [ - { key: "origin", label: "Origin" }, - { key: "destination", label: "Destination" }, - { key: "bookings", label: "Bookings", numeric: true }, - { key: "tons", label: "Tonnage", unit: "t" }, - { key: "revenue", label: "Revenue", unit: "ETB" }, - ], - }, - { - key: "contract-utilization", - title: "Contract Utilization", - description: "Committed scope caps vs booked tonnage per contract", - domain: "Commercial", - filters: ["direction", "statuses"], - statusOptions: CONTRACT_STATUSES, - columns: [ - { key: "reference", label: "Contract" }, - { key: "customer", label: "Customer" }, - { key: "status", label: "Status" }, - { key: "kind", label: "Kind" }, - { key: "valid_from", label: "Valid from" }, - { key: "valid_until", label: "Valid until" }, - { key: "committed", label: "Committed", unit: "t" }, - { key: "booked_tons", label: "Booked", unit: "t" }, - { key: "bookings", label: "Bookings", numeric: true }, - { key: "utilization_pct", label: "Utilization", unit: "%" }, - ], - }, - { - key: "train-on-time", - title: "Train On-Time Performance", - description: "Departure punctuality and delays by lane (60-min grace)", - domain: "Operations", - filters: ["yards", "direction"], - chart: { - type: "bar", - xKey: "origin+destination", - series: [{ key: "on_time_pct", label: "On-time %" }], - topN: 15, - }, - columns: [ - { key: "origin", label: "Origin" }, - { key: "destination", label: "Destination" }, - { key: "trips", label: "Trips", numeric: true }, - { key: "departed", label: "Departed", numeric: true }, - { key: "avg_dep_delay_min", label: "Avg dep. delay", unit: "min" }, - { key: "avg_arr_delay_min", label: "Avg arr. delay", unit: "min" }, - { key: "on_time_pct", label: "On-time", unit: "%" }, - ], - }, - { - key: "schedule-fill-rate", - title: "Schedule Fill Rate", - description: "Booked tonnage vs wagon capacity per train schedule", - domain: "Operations", - filters: ["yards", "direction"], - chart: { - type: "line", - xKey: "departure", - series: [{ key: "fill_pct", label: "Fill %" }], - }, - columns: [ - { key: "train_number", label: "Train" }, - { key: "departure", label: "Departure" }, - { key: "origin", label: "Origin" }, - { key: "destination", label: "Destination" }, - { key: "direction", label: "Direction" }, - { key: "status", label: "Status" }, - { key: "wagon_count", label: "Wagons", numeric: true }, - { key: "capacity_tons", label: "Capacity", unit: "t" }, - { key: "booked_tons", label: "Booked", unit: "t" }, - { key: "fill_pct", label: "Fill", unit: "%" }, - ], - }, - { - key: "trips-per-route", - title: "Trips per Route", - description: "Completed trips and tonnage hauled per lane", - domain: "Operations", - filters: ["yards", "direction"], - chart: { - type: "bar", - xKey: "origin+destination", - series: [{ key: "trips", label: "Trips" }], - topN: 15, - }, - columns: [ - { key: "origin", label: "Origin" }, - { key: "destination", label: "Destination" }, - { key: "direction", label: "Direction" }, - { key: "trips", label: "Trips", numeric: true }, - { key: "tons_hauled", label: "Tonnage hauled", unit: "t" }, - { key: "avg_tons_per_trip", label: "Avg per trip", unit: "t" }, - ], - }, - { - key: "invoiced-vs-collected", - title: "Invoiced vs Collected", - description: "Billing issued vs payments received over time", - domain: "Finance", - filters: ["granularity", "direction"], - chart: { - type: "line", - xKey: "period", - series: [ - { key: "invoiced", label: "Invoiced (ETB)" }, - { key: "collected", label: "Collected (ETB)" }, - ], - }, - columns: [ - { key: "period", label: "Period" }, - { key: "invoices", label: "Invoices", numeric: true }, - { key: "invoiced", label: "Invoiced", unit: "ETB" }, - { key: "collected", label: "Collected", unit: "ETB" }, - { key: "outstanding", label: "Outstanding", unit: "ETB" }, - ], - }, - { - key: "aging-receivables", - title: "Aging Receivables", - description: "Outstanding invoice balances by age bucket per customer", - domain: "Finance", - filters: ["direction", "statuses"], - statusOptions: INVOICE_STATUSES, - chart: { - type: "bar", - xKey: "customer", - series: [{ key: "outstanding", label: "Outstanding (ETB)" }], - topN: 10, - }, - columns: [ - { key: "customer", label: "Customer" }, - { key: "invoices", label: "Invoices", numeric: true }, - { key: "outstanding", label: "Outstanding", unit: "ETB" }, - { key: "current", label: "Current", unit: "ETB" }, - { key: "overdue_0_30", label: "0–30d", unit: "ETB" }, - { key: "overdue_31_60", label: "31–60d", unit: "ETB" }, - { key: "overdue_61_90", label: "61–90d", unit: "ETB" }, - { key: "overdue_90_plus", label: "90d+", unit: "ETB" }, - ], - }, - { - key: "revenue-by-payment-method", - title: "Revenue by Payment Method", - description: "Successful payments broken down by method", - domain: "Finance", - filters: ["direction"], - chart: { - type: "bar", - xKey: "method", - series: [{ key: "amount", label: "Amount (ETB)" }], - }, - columns: [ - { key: "method", label: "Method" }, - { key: "payments", label: "Payments", numeric: true }, - { key: "amount", label: "Amount", unit: "ETB" }, - ], - }, - // --- Record-level list exports (Data domain) — filtered or full dumps --- - { - key: "bookings-list", - title: "Bookings Export", - description: "Booking records with customer, lane, cargo, amounts", - domain: "Data", - filters: ["yards", "direction", "freightType", "statuses"], - statusOptions: BOOKING_STATUSES, - columns: [ - { key: "reference", label: "Reference" }, - { key: "created", label: "Created" }, - { key: "customer", label: "Customer" }, - { key: "status", label: "Status" }, - { key: "freight_type", label: "Freight" }, - { key: "direction", label: "Direction" }, - { key: "origin", label: "Origin" }, - { key: "destination", label: "Destination" }, - { key: "cargo", label: "Cargo" }, - { key: "tons", label: "Tonnage", unit: "t" }, - { key: "amount", label: "Amount", unit: "ETB" }, - { key: "payment_status", label: "Payment" }, - { key: "scheduling_status", label: "Scheduling" }, - ], - }, - { - key: "contracts-list", - title: "Contracts Export", - description: "Contract records with validity, status, customer", - domain: "Data", - filters: ["direction", "statuses"], - statusOptions: CONTRACT_STATUSES, - columns: [ - { key: "reference", label: "Reference" }, - { key: "customer", label: "Customer" }, - { key: "kind", label: "Kind" }, - { key: "status", label: "Status" }, - { key: "direction", label: "Direction" }, - { key: "freight_type", label: "Freight" }, - { key: "valid_from", label: "Valid from" }, - { key: "valid_until", label: "Valid until" }, - { key: "created", label: "Created" }, - ], - }, - { - key: "schedules-list", - title: "Train Schedules Export", - description: "Schedule records with planned vs actual times", - domain: "Data", - filters: ["yards", "direction", "statuses"], - statusOptions: ["DRAFT", "SCHEDULED", "DISPATCHED", "ARRIVED", "CANCELLED"], - columns: [ - { key: "train_number", label: "Train" }, - { key: "reference", label: "Reference" }, - { key: "direction", label: "Direction" }, - { key: "status", label: "Status" }, - { key: "origin", label: "Origin" }, - { key: "destination", label: "Destination" }, - { key: "scheduled_departure", label: "Sched. departure" }, - { key: "actual_departure", label: "Actual departure" }, - { key: "scheduled_arrival", label: "Sched. arrival" }, - { key: "actual_arrival", label: "Actual arrival" }, - { key: "max_wagons", label: "Max wagons", numeric: true }, - { key: "wagon_count", label: "Wagons", numeric: true }, - ], - }, - { - key: "fleet-wagons", - title: "Wagons Export", - description: "Wagon fleet with type, capacity, status, location", - domain: "Data", - filters: ["yards", "statuses"], - statusOptions: ["AVAILABLE", "ASSIGNED", "MAINTENANCE"], - columns: [ - { key: "wagon_number", label: "Wagon" }, - { key: "type", label: "Type" }, - { key: "capacity_tons", label: "Capacity", unit: "t" }, - { key: "status", label: "Status" }, - { key: "current_yard", label: "Current yard" }, - ], - }, - { - key: "fleet-locomotives", - title: "Locomotives Export", - description: "Locomotive fleet with type, pull capacity, status", - domain: "Data", - filters: ["yards", "statuses"], - statusOptions: ["AVAILABLE", "OUT_OF_SERVICE"], - columns: [ - { key: "code", label: "Code" }, - { key: "name", label: "Name" }, - { key: "locomotive_type", label: "Type" }, - { key: "max_pull_tons", label: "Max pull", unit: "t" }, - { key: "status", label: "Status" }, - { key: "current_yard", label: "Current yard" }, - ], - }, - { - key: "customers-list", - title: "Customers Export", - description: "Company records with type, status, TIN", - domain: "Data", - filters: ["statuses"], - statusOptions: ["pending", "active"], - columns: [ - { key: "name", label: "Name" }, - { key: "type", label: "Type" }, - { key: "kind", label: "Kind" }, - { key: "status", label: "Status" }, - { key: "tin", label: "TIN" }, - { key: "approved", label: "Approved" }, - { key: "created", label: "Created" }, - ], - }, - { - key: "payments-list", - title: "Payments Export", - description: "Payment transactions with method, status, references", - domain: "Data", - filters: ["direction", "statuses"], - statusOptions: [ - "action-required", - "processing", - "success", - "failed", - "canceled", - "refunded", - ], - columns: [ - { key: "created", label: "Created" }, - { key: "method", label: "Method" }, - { key: "status", label: "Status" }, - { key: "currency", label: "Currency" }, - { key: "amount", label: "Amount", unit: "ETB" }, - { key: "transaction_id", label: "Transaction" }, - { key: "merchant_order_id", label: "Merchant order" }, - { key: "paid", label: "Paid" }, - ], - }, -]; - -export const REPORT_CONFIG_BY_KEY = new Map( - REPORT_CONFIGS.map((c) => [c.key, c]), -); - -export const REPORT_DOMAINS: ReportDomain[] = [ - "Commercial", - "Operations", - "Finance", - "Data", -]; diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts index 2a1963f8d..8f8068a45 100644 --- a/apps/edr-freight-web/backoffice/src/services/api.ts +++ b/apps/edr-freight-web/backoffice/src/services/api.ts @@ -174,7 +174,7 @@ import { } from "./locomotives.service"; import { overviewService } from "./overview.service"; import { reportsService } from "./reports.service"; -import type { ReportQueryInput, ReportResult } from "@/types/reports"; +import type { ReportCatalogEntry, ReportRunParams, ReportRunResult } from "@/types/reports"; import { paymentsService, type PaginatedPayments, @@ -3030,7 +3030,10 @@ export const api = { }, reports: { - run: endpoint( + catalog: endpoint("reports", "catalog", () => + reportsService.catalog(), + ), + run: endpoint( "reports", "run", (input) => reportsService.run(input), diff --git a/apps/edr-freight-web/backoffice/src/services/reports.service.ts b/apps/edr-freight-web/backoffice/src/services/reports.service.ts index 6f6995614..f04bc8c8f 100644 --- a/apps/edr-freight-web/backoffice/src/services/reports.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/reports.service.ts @@ -1,14 +1,31 @@ import { api as client } from "../auth/http"; import { unwrap } from "@/utils/endpoint"; import { URL_CONSTANTS } from "@/constants/URLS"; -import type { ReportQueryInput, ReportResult } from "@/types/reports"; +import type { ReportCatalogEntry, ReportRunParams, ReportRunResult } from "@/types/reports"; + +const R = URL_CONSTANTS.REPORTS; export const reportsService = { - run: async ({ key, ...params }: ReportQueryInput): Promise => { - const response = await client.get( - URL_CONSTANTS.REPORTS.RUN(key), - { params }, - ); + catalog: async (): Promise => { + const response = await client.get(R.CATALOG); return unwrap(response.data); }, + + run: async ({ key, ...params }: ReportRunParams): Promise => { + const response = await client.get(R.RUN(key), { params }); + return unwrap(response.data); + }, + + /** Streams the export file as a blob — caller triggers the browser save. */ + download: async ( + key: string, + format: "xlsx" | "pdf", + params: Omit, + ): Promise => { + const response = await client.get(R.EXPORT(key), { + params: { ...params, format }, + responseType: "blob", + }); + return response.data as Blob; + }, }; diff --git a/apps/edr-freight-web/backoffice/src/types/reports.ts b/apps/edr-freight-web/backoffice/src/types/reports.ts index 72326788c..854b1a801 100644 --- a/apps/edr-freight-web/backoffice/src/types/reports.ts +++ b/apps/edr-freight-web/backoffice/src/types/reports.ts @@ -1,27 +1,77 @@ +export type ReportColumnType = + | "string" + | "number" + | "money" + | "tons" + | "percent" + | "date"; + +export interface ReportColumn { + key: string; + label: string; + type: ReportColumnType; + sortable?: boolean; +} + +export type ReportFilterType = "daterange" | "date" | "select" | "multiselect" | "text"; + +export interface ReportFilterOption { + value: string; + label: string; +} + +export interface ReportFilterDef { + key: string; + label: string; + type: ReportFilterType; + options?: ReportFilterOption[]; +} + +export interface ReportIdKey { + key: string; + label: string; +} + export interface ReportKpi { label: string; value: number; unit?: string; } -export type ReportRow = Record; - -export interface ReportResult { - kpis: ReportKpi[]; - rows: ReportRow[]; -} - -/** Query params for GET /reports/:key. List filters are comma-separated. */ -export interface ReportQueryInput { +/** Mirrors the backend's ReportCatalogEntry — one entry per GET /reports item. */ +export interface ReportCatalogEntry { key: string; - dateFrom?: string; - dateTo?: string; - granularity?: "day" | "week" | "month"; - companyIds?: string; - routeIds?: string; - yardIds?: string; - cargoTypeIds?: string; - statuses?: string; - direction?: string; - freightType?: string; + title: string; + description: string; + group: "Commercial" | "Operations" | "Finance"; + idKey?: ReportIdKey; + filters: ReportFilterDef[]; + columns: ReportColumn[]; + defaultSort?: { key: string; dir: "ASC" | "DESC" }; + hasSummary: boolean; } + +export interface ReportPageMeta { + page: number; + pageSize: number; + total: number; + totalPages: number; + hasNextPage: boolean; + hasPreviousPage: boolean; +} + +export interface ReportRunResult { + columns: ReportColumn[]; + items: Record[]; + meta: ReportPageMeta; + kpis: ReportKpi[]; +} + +/** Query params for GET /reports/:key — page/sort plus whatever filters the report declares. */ +export type ReportRunParams = Record & { + key: string; + page?: number; + pageSize?: number; + sortBy?: string; + sortOrder?: "ASC" | "DESC"; +}; From 9930bef8aafb09e13e94921c4ee784e5b3c87a94 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Thu, 13 Aug 2026 07:54:44 +0000 Subject: [PATCH 06/34] fix(freight-api): quote fallback sort aliases Sorting by a column with no explicit sortExpr fell back to the bare select alias unquoted. Postgres folds unquoted identifiers to lowercase, so any camelCase alias (utilizationPct, bookedTons) 42703'd. Quote the fallback to match the case TypeORM's addSelect actually emitted. --- .../src/modules/reports/report-runner.service.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) 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 index 5df9ba2e1..09933de42 100644 --- a/apps/edr-freight-api/src/modules/reports/report-runner.service.ts +++ b/apps/edr-freight-api/src/modules/reports/report-runner.service.ts @@ -48,6 +48,15 @@ function coerceParams( 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, @@ -57,14 +66,14 @@ function resolveSort( 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 ?? requested.key, dir }; + 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 ?? fallback.key, + expr: fallback.sortExpr ?? aliasSortExpr(fallback.key), dir: def.defaultSort.dir, }; } From c0cdf805601b1adc33e0b43baa1c2e791a361867 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Thu, 13 Aug 2026 08:08:13 +0000 Subject: [PATCH 07/34] feat(freight-api): add 4 fleet-ops reports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wagon-fleet-status, wagon-status-duration, wagon-requests, locomotive-fleet-status. First batch off the ITLMS dashboard spec — fleet data (wagons/locomotives/transfer-requests) needed no schema work, just resolvers. No frontend changes: catalog is server-driven. --- .../locomotive-fleet-status.report.ts | 67 +++++++++++++ .../definitions/wagon-fleet-status.report.ts | 76 +++++++++++++++ .../definitions/wagon-requests.report.ts | 85 +++++++++++++++++ .../wagon-status-duration.report.ts | 94 +++++++++++++++++++ .../src/modules/reports/report.registry.ts | 8 ++ .../src/seed/freight-permissions.registry.ts | 4 + 6 files changed, 334 insertions(+) create mode 100644 apps/edr-freight-api/src/modules/reports/definitions/locomotive-fleet-status.report.ts create mode 100644 apps/edr-freight-api/src/modules/reports/definitions/wagon-fleet-status.report.ts create mode 100644 apps/edr-freight-api/src/modules/reports/definitions/wagon-requests.report.ts create mode 100644 apps/edr-freight-api/src/modules/reports/definitions/wagon-status-duration.report.ts diff --git a/apps/edr-freight-api/src/modules/reports/definitions/locomotive-fleet-status.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/locomotive-fleet-status.report.ts new file mode 100644 index 000000000..f2b9377f6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/locomotive-fleet-status.report.ts @@ -0,0 +1,67 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { Locomotive, LOCOMOTIVE_STATUSES } from '../../locomotives/entities/locomotive.entity'; +import { Yard } from '../../rule-engine/entities/yard.entity'; +import { ReportContext, ReportDefinition } from '../report.types'; + +const STATUS_OPTIONS = LOCOMOTIVE_STATUSES.map((v) => ({ value: v, label: v.replace(/_/g, ' ') })); + +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + const { params } = ctx; + const qb = ctx.ds + .createQueryBuilder() + .from(Locomotive, 'l') + .leftJoin(Yard, 'y', 'y.id = l.current_yard_id') + .where('l.deleted_at IS NULL'); + + const statuses = params.statuses as string[] | null; + if (statuses) qb.andWhere('l.status IN (:...statuses)', { statuses }); + return qb; +} + +export const locomotiveFleetStatusReport: ReportDefinition = { + key: 'locomotive-fleet-status', + title: 'Locomotive Fleet Status', + description: 'Locomotive counts by type, station and status', + group: 'Operations', + filters: [{ key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS }], + columns: [ + { key: 'locomotiveType', label: 'Type', type: 'string', sortable: true }, + { key: 'station', label: 'Station', type: 'string', sortable: true }, + { key: 'status', label: 'Status', type: 'string', sortable: true }, + { key: 'count', label: 'Count', type: 'number', sortable: true }, + ], + defaultSort: { key: 'count', dir: 'DESC' }, + query(ctx) { + return baseQuery(ctx) + .select('l.locomotive_type', 'locomotiveType') + .addSelect("COALESCE(y.label, 'Unassigned')", 'station') + .addSelect('l.status', 'status') + .addSelect('COUNT(*)::int', 'count') + .groupBy('l.locomotive_type') + .addGroupBy('y.label') + .addGroupBy('l.status'); + }, + async summary(ctx) { + const row = await baseQuery(ctx) + .select('COUNT(*)::int', 'total') + .addSelect('COUNT(*) FILTER (WHERE l.status = :available)::int', 'available') + .addSelect('COUNT(*) FILTER (WHERE l.status = :assigned)::int', 'assigned') + .addSelect('COUNT(*) FILTER (WHERE l.status = :maintenance)::int', 'maintenance') + .addSelect('COUNT(*) FILTER (WHERE l.status = :outOfService)::int', 'outOfService') + .setParameters({ + available: 'AVAILABLE', + assigned: 'ASSIGNED', + maintenance: 'MAINTENANCE', + outOfService: 'OUT_OF_SERVICE', + }) + .getRawOne(); + return [ + { label: 'Total locomotives', value: Number(row?.total ?? 0) }, + { label: 'Available', value: Number(row?.available ?? 0) }, + { label: 'Assigned', value: Number(row?.assigned ?? 0) }, + { label: 'Under maintenance', value: Number(row?.maintenance ?? 0) }, + { label: 'Out of service', value: Number(row?.outOfService ?? 0) }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/wagon-fleet-status.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/wagon-fleet-status.report.ts new file mode 100644 index 000000000..1371936f5 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/wagon-fleet-status.report.ts @@ -0,0 +1,76 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { WagonStatus } from '@edr/types'; +import { Wagon } from '../../wagons/entities/wagon.entity'; +import { WagonType } from '../../wagon-types/entities/wagon-type.entity'; +import { Yard } from '../../rule-engine/entities/yard.entity'; +import { ReportContext, ReportDefinition } from '../report.types'; + +const STATUS_OPTIONS = Object.values(WagonStatus).map((v) => ({ + value: v, + label: v.replace(/_/g, ' '), +})); + +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + const { params } = ctx; + const qb = ctx.ds + .createQueryBuilder() + .from(Wagon, 'w') + .leftJoin(WagonType, 'wt', 'wt.id = w.wagon_type_id') + .leftJoin(Yard, 'y', 'y.id = w.current_yard_id') + .where('w.deleted_at IS NULL'); + + const statuses = params.statuses as string[] | null; + if (statuses) qb.andWhere('w.status IN (:...statuses)', { statuses }); + return qb; +} + +export const wagonFleetStatusReport: ReportDefinition = { + key: 'wagon-fleet-status', + title: 'Wagon Fleet Status', + description: 'Wagon counts by type, station and status', + group: 'Operations', + filters: [{ key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS }], + columns: [ + { key: 'wagonType', label: 'Wagon type', type: 'string', sortable: true }, + { key: 'station', label: 'Station', type: 'string', sortable: true }, + { key: 'status', label: 'Status', type: 'string', sortable: true }, + { key: 'count', label: 'Count', type: 'number', sortable: true }, + ], + defaultSort: { key: 'count', dir: 'DESC' }, + query(ctx) { + return baseQuery(ctx) + .select('COALESCE(wt.name, \'Unknown\')', 'wagonType') + .addSelect("COALESCE(y.label, 'Unassigned')", 'station') + .addSelect('w.status', 'status') + .addSelect('COUNT(*)::int', 'count') + .groupBy('wt.name') + .addGroupBy('y.label') + .addGroupBy('w.status'); + }, + async summary(ctx) { + const row = await baseQuery(ctx) + .select('COUNT(*)::int', 'total') + .addSelect('COUNT(*) FILTER (WHERE w.status = :available)::int', 'available') + .addSelect('COUNT(*) FILTER (WHERE w.status = :assigned)::int', 'assigned') + .addSelect('COUNT(*) FILTER (WHERE w.status = :maintenance)::int', 'maintenance') + .addSelect('COUNT(*) FILTER (WHERE w.status = :detained)::int', 'detained') + .addSelect('COUNT(*) FILTER (WHERE w.status = :outOfService)::int', 'outOfService') + .setParameters({ + available: WagonStatus.Available, + assigned: WagonStatus.Assigned, + maintenance: WagonStatus.Maintenance, + detained: WagonStatus.Detained, + outOfService: WagonStatus.OutOfService, + }) + .getRawOne(); + return [ + { label: 'Total wagons', value: Number(row?.total ?? 0) }, + { label: 'Available', value: Number(row?.available ?? 0) }, + { label: 'Assigned', value: Number(row?.assigned ?? 0) }, + { label: 'Under maintenance', value: Number(row?.maintenance ?? 0) }, + { label: 'Detained', value: Number(row?.detained ?? 0) }, + { label: 'Out of service', value: Number(row?.outOfService ?? 0) }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/wagon-requests.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/wagon-requests.report.ts new file mode 100644 index 000000000..df5ac364c --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/wagon-requests.report.ts @@ -0,0 +1,85 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { WagonTransferRequestStatus } from '@edr/types'; +import { WagonTransferRequest } from '../../wagons/entities/wagon-transfer-request.entity'; +import { WagonType } from '../../wagon-types/entities/wagon-type.entity'; +import { Yard } from '../../rule-engine/entities/yard.entity'; +import { ReportContext, ReportDefinition } from '../report.types'; + +const STATUS_OPTIONS = Object.values(WagonTransferRequestStatus).map((v) => ({ + value: v, + label: v.replace(/_/g, ' '), +})); + +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + const { params } = ctx; + const qb = ctx.ds + .createQueryBuilder() + .from(WagonTransferRequest, 'r') + .leftJoin(Yard, 'fy', 'fy.id = r.from_yard_id') + .leftJoin(Yard, 'ty', 'ty.id = r.to_yard_id') + .leftJoin(WagonType, 'wt', 'wt.id = r.wagon_type_id') + .where('r.deleted_at IS NULL'); + + if (params.dateFrom) qb.andWhere('r.created_at >= :dateFrom', { dateFrom: params.dateFrom }); + if (params.dateTo) qb.andWhere('r.created_at < :dateTo', { dateTo: params.dateTo }); + const statuses = params.statuses as string[] | null; + if (statuses) qb.andWhere('r.status IN (:...statuses)', { statuses }); + return qb; +} + +export const wagonRequestsReport: ReportDefinition = { + key: 'wagon-requests', + title: 'Wagon Requests', + description: 'Inter-yard wagon transfer requests and fulfilment delay', + group: 'Operations', + filters: [ + { key: 'date', label: 'Requested', type: 'daterange' }, + { key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS }, + ], + columns: [ + { key: 'fromYard', label: 'From', type: 'string', sortable: true, sortExpr: 'fy.label' }, + { key: 'toYard', label: 'To', type: 'string', sortable: true, sortExpr: 'ty.label' }, + { key: 'wagonType', label: 'Wagon type', type: 'string' }, + { key: 'quantity', label: 'Requested', type: 'number' }, + { key: 'fulfilledQuantity', label: 'Fulfilled', type: 'number' }, + { key: 'status', label: 'Status', type: 'string', sortable: true, sortExpr: 'r.status' }, + { key: 'requestedAt', label: 'Requested at', type: 'date', sortable: true, sortExpr: 'r.created_at' }, + { key: 'fulfilledAt', label: 'Fulfilled at', type: 'date' }, + { key: 'delayDays', label: 'Delay (days)', type: 'number', sortable: true }, + ], + defaultSort: { key: 'requestedAt', dir: 'DESC' }, + query(ctx) { + return baseQuery(ctx) + .select('fy.label', 'fromYard') + .addSelect('ty.label', 'toYard') + .addSelect("COALESCE(wt.name, 'Unknown')", 'wagonType') + .addSelect('r.quantity', 'quantity') + .addSelect('r.fulfilled_quantity', 'fulfilledQuantity') + .addSelect('r.status', 'status') + .addSelect(`to_char(r.created_at, 'YYYY-MM-DD')`, 'requestedAt') + .addSelect(`to_char(r.fulfilled_at, 'YYYY-MM-DD')`, 'fulfilledAt') + .addSelect( + `ROUND(EXTRACT(EPOCH FROM (COALESCE(r.fulfilled_at, now()) - r.created_at))::numeric / 86400, 1)::float8`, + 'delayDays', + ); + }, + async summary(ctx) { + const row = await baseQuery(ctx) + .select('COUNT(*)::int', 'requests') + .addSelect('COUNT(*) FILTER (WHERE r.status IN (:...openStatuses))::int', 'open') + .addSelect( + `ROUND(AVG(EXTRACT(EPOCH FROM (COALESCE(r.fulfilled_at, now()) - r.created_at))::numeric / 86400), 1)::float8`, + 'avgDelayDays', + ) + .setParameters({ + openStatuses: [WagonTransferRequestStatus.Pending, WagonTransferRequestStatus.PartiallyFulfilled], + }) + .getRawOne(); + return [ + { label: 'Requests', value: Number(row?.requests ?? 0) }, + { label: 'Still open', value: Number(row?.open ?? 0) }, + { label: 'Avg delay', value: Number(row?.avgDelayDays ?? 0), unit: 'd' }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/wagon-status-duration.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/wagon-status-duration.report.ts new file mode 100644 index 000000000..348f681d1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/wagon-status-duration.report.ts @@ -0,0 +1,94 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { WagonStatus } from '@edr/types'; +import { Wagon } from '../../wagons/entities/wagon.entity'; +import { WagonType } from '../../wagon-types/entities/wagon-type.entity'; +import { Yard } from '../../rule-engine/entities/yard.entity'; +import { ReportContext, ReportDefinition } from '../report.types'; + +// Only these two statuses have an operational "how long has it been stuck +// here" question — everything else (Available, Assigned, ...) turns over too +// fast for a days-in-status view to matter. +const TRACKED_STATUSES = [WagonStatus.Maintenance, WagonStatus.Detained]; + +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + const { params } = ctx; + const qb = ctx.ds + .createQueryBuilder() + .from(Wagon, 'w') + .leftJoin(WagonType, 'wt', 'wt.id = w.wagon_type_id') + .leftJoin(Yard, 'y', 'y.id = w.current_yard_id') + // Latest time each wagon flipped INTO its current status, per (wagon, status) + // pair — a plain (non-correlated) derived table, joined on both columns, so + // it stays a normal JOIN rather than needing a LATERAL correlated subquery. + .leftJoin( + (sub) => + sub + .select('l.wagon_id', 'wagon_id') + .addSelect('l.to_status', 'to_status') + .addSelect('MAX(l.created_at)', 'since') + .from('freight.wagon_status_logs', 'l') + .groupBy('l.wagon_id') + .addGroupBy('l.to_status'), + 'log', + 'log.wagon_id = w.id AND log.to_status = w.status', + ) + .where('w.deleted_at IS NULL') + .andWhere('w.status IN (:...trackedStatuses)', { trackedStatuses: TRACKED_STATUSES }); + + const status = params.status as string | null; + if (status) qb.andWhere('w.status = :status', { status }); + return qb; +} + +export const wagonStatusDurationReport: ReportDefinition = { + key: 'wagon-status-duration', + title: 'Wagon Status Duration', + description: 'How long each wagon has sat in Maintenance or Detained', + group: 'Operations', + filters: [ + { + key: 'status', + label: 'Status', + type: 'select', + options: TRACKED_STATUSES.map((v) => ({ value: v, label: v.replace(/_/g, ' ') })), + }, + ], + columns: [ + { key: 'wagonNumber', label: 'Wagon', type: 'string', sortable: true, sortExpr: 'w.wagon_number' }, + { key: 'wagonType', label: 'Wagon type', type: 'string' }, + { key: 'station', label: 'Station', type: 'string' }, + { key: 'status', label: 'Status', type: 'string', sortable: true, sortExpr: 'w.status' }, + { key: 'since', label: 'Since', type: 'date', sortable: true }, + { key: 'daysInStatus', label: 'Days in status', type: 'number', sortable: true }, + ], + defaultSort: { key: 'daysInStatus', dir: 'DESC' }, + query(ctx) { + return baseQuery(ctx) + .select('w.wagon_number', 'wagonNumber') + .addSelect("COALESCE(wt.name, 'Unknown')", 'wagonType') + .addSelect("COALESCE(y.label, 'Unassigned')", 'station') + .addSelect('w.status', 'status') + .addSelect(`to_char(COALESCE(log.since, w.updated_at), 'YYYY-MM-DD')`, 'since') + .addSelect( + `FLOOR(EXTRACT(EPOCH FROM (now() - COALESCE(log.since, w.updated_at))) / 86400)::int`, + 'daysInStatus', + ); + }, + async summary(ctx) { + const row = await baseQuery(ctx) + .select('COUNT(*) FILTER (WHERE w.status = :maintenance)::int', 'maintenance') + .addSelect('COUNT(*) FILTER (WHERE w.status = :detained)::int', 'detained') + .addSelect( + `MAX(FLOOR(EXTRACT(EPOCH FROM (now() - COALESCE(log.since, w.updated_at))) / 86400))::int`, + 'longest', + ) + .setParameters({ maintenance: WagonStatus.Maintenance, detained: WagonStatus.Detained }) + .getRawOne(); + return [ + { label: 'Under maintenance', value: Number(row?.maintenance ?? 0) }, + { label: 'Detained', value: Number(row?.detained ?? 0) }, + { label: 'Longest days in status', value: Number(row?.longest ?? 0), unit: 'd' }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/report.registry.ts b/apps/edr-freight-api/src/modules/reports/report.registry.ts index 26474d025..dfc6ff002 100644 --- a/apps/edr-freight-api/src/modules/reports/report.registry.ts +++ b/apps/edr-freight-api/src/modules/reports/report.registry.ts @@ -3,6 +3,10 @@ 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 { ReportDefinition } from './report.types'; /** @@ -15,6 +19,10 @@ export const REPORTS: ReportDefinition[] = [ revenueByCustomerReport, agingReceivablesReport, contractUtilizationReport, + wagonFleetStatusReport, + wagonStatusDurationReport, + wagonRequestsReport, + locomotiveFleetStatusReport, ]; const BY_KEY = new Map(REPORTS.map((r) => [r.key, r])); 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 7470ed2b5..f9a0f959f 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -59,6 +59,10 @@ export const REPORT_KEYS = [ "revenue-by-customer", "aging-receivables", "contract-utilization", + "wagon-fleet-status", + "wagon-status-duration", + "wagon-requests", + "locomotive-fleet-status", ] as const; export type ReportKey = (typeof REPORT_KEYS)[number]; From a53a9c71523b443961ef51f823087fc489166710 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Thu, 13 Aug 2026 08:14:17 +0000 Subject: [PATCH 08/34] feat(freight-api): add 6 booking/train-lifecycle reports booking-status-breakdown (dedupes the same 'status per port/train/ cargo/contract' ask across 4 dashboards), train-schedule-status, train-turnaround, wagon-teu-utilization, loaded-capacity, global-logistics-wagons. Dropped freight-weight-variance from this batch: the schema has no 'charged weight' distinct from VGM/actual, so a charged-vs-actual variance report isn't buildable without a product decision on what 'charged' means here. --- .../booking-status-breakdown.report.ts | 108 ++++++++++++++++++ .../global-logistics-wagons.report.ts | 68 +++++++++++ .../definitions/loaded-capacity.report.ts | 78 +++++++++++++ .../train-schedule-status.report.ts | 100 ++++++++++++++++ .../definitions/train-turnaround.report.ts | 86 ++++++++++++++ .../wagon-teu-utilization.report.ts | 76 ++++++++++++ .../src/modules/reports/report.registry.ts | 12 ++ .../src/seed/freight-permissions.registry.ts | 6 + 8 files changed, 534 insertions(+) create mode 100644 apps/edr-freight-api/src/modules/reports/definitions/booking-status-breakdown.report.ts create mode 100644 apps/edr-freight-api/src/modules/reports/definitions/global-logistics-wagons.report.ts create mode 100644 apps/edr-freight-api/src/modules/reports/definitions/loaded-capacity.report.ts create mode 100644 apps/edr-freight-api/src/modules/reports/definitions/train-schedule-status.report.ts create mode 100644 apps/edr-freight-api/src/modules/reports/definitions/train-turnaround.report.ts create mode 100644 apps/edr-freight-api/src/modules/reports/definitions/wagon-teu-utilization.report.ts diff --git a/apps/edr-freight-api/src/modules/reports/definitions/booking-status-breakdown.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/booking-status-breakdown.report.ts new file mode 100644 index 000000000..a01f714ec --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/booking-status-breakdown.report.ts @@ -0,0 +1,108 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { BookingStatus } from '@edr/types'; +import { Booking } from '../../bookings/entities/booking.entity'; +import { Yard } from '../../rule-engine/entities/yard.entity'; +import { CargoType } from '../../rule-engine/entities/cargo-type.entity'; +import { ReportContext, ReportDefinition } from '../report.types'; + +// One resolver behind "Booking per status, per port/train/date/cargo/contract +// type" — the same breakdown Operation, Marketing, Global Logistics and the +// Operation Report each ask for verbatim. Embed once, reuse everywhere. +const TONS = 'COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)'; +const REVENUE = 'COALESCE(b.adjusted_total_amount, b.total_amount)'; + +const STATUS_OPTIONS = [...new Set(Object.values(BookingStatus))].map((v) => ({ + value: v, + label: v.replace(/_/g, ' '), +})); + +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + const { params, directions } = ctx; + const qb = ctx.ds + .createQueryBuilder() + .from(Booking, 'b') + .leftJoin(Yard, 'o', 'o.id = b.origin_yard_id') + .leftJoin(CargoType, 'cty', 'cty.id = b.cargo_type_id') + .where('b.deleted_at IS NULL'); + + if (params.dateFrom) qb.andWhere('b.created_at >= :dateFrom', { dateFrom: params.dateFrom }); + if (params.dateTo) qb.andWhere('b.created_at < :dateTo', { dateTo: params.dateTo }); + if (params.direction) qb.andWhere('b.trade_direction = :direction', { direction: params.direction }); + if (params.freightType) qb.andWhere('b.freight_type = :freightType', { freightType: params.freightType }); + const statuses = params.statuses as string[] | null; + if (statuses) qb.andWhere('b.status IN (:...statuses)', { statuses }); + if (directions !== null) { + qb.andWhere(directions.length ? 'b.trade_direction IN (:...directions)' : '1 = 0', { directions }); + } + return qb; +} + +export const bookingStatusBreakdownReport: ReportDefinition = { + key: 'booking-status-breakdown', + title: 'Bookings by Status', + description: 'Booking counts by status, direction, origin station, cargo and contract type', + group: 'Commercial', + filters: [ + { key: 'date', label: 'Created', type: 'daterange' }, + { + key: 'direction', + label: 'Direction', + type: 'select', + options: [ + { value: 'IMPORT', label: 'Import' }, + { value: 'EXPORT', label: 'Export' }, + { value: 'DOMESTIC', label: 'Domestic' }, + ], + }, + { + key: 'freightType', + label: 'Freight type', + type: 'select', + options: [ + { value: 'CONTAINER', label: 'Container' }, + { value: 'BULK', label: 'Bulk' }, + ], + }, + { key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS }, + ], + columns: [ + { key: 'status', label: 'Status', type: 'string', sortable: true, sortExpr: 'b.status' }, + { key: 'direction', label: 'Direction', type: 'string', sortable: true, sortExpr: 'b.trade_direction' }, + { key: 'originStation', label: 'Origin', type: 'string', sortable: true }, + { key: 'cargoType', label: 'Cargo type', type: 'string', sortable: true }, + { key: 'contractKind', label: 'Contract type', type: 'string', sortable: true }, + { key: 'bookings', label: 'Bookings', type: 'number', sortable: true }, + { key: 'tons', label: 'Tonnage', type: 'tons', sortable: true }, + { key: 'amount', label: 'Amount', type: 'money', sortable: true }, + ], + defaultSort: { key: 'bookings', dir: 'DESC' }, + query(ctx) { + return baseQuery(ctx) + .select('b.status', 'status') + .addSelect('b.trade_direction', 'direction') + .addSelect("COALESCE(o.label, 'Unknown')", 'originStation') + .addSelect("COALESCE(cty.cargo_type_name, 'Other')", 'cargoType') + .addSelect("COALESCE(b.contract_kind, 'SPOT')", 'contractKind') + .addSelect('COUNT(*)::int', 'bookings') + .addSelect(`ROUND(COALESCE(SUM(${TONS}), 0))::float8`, 'tons') + .addSelect(`ROUND(COALESCE(SUM(${REVENUE}), 0))::float8`, 'amount') + .groupBy('b.status') + .addGroupBy('b.trade_direction') + .addGroupBy('o.label') + .addGroupBy('cty.cargo_type_name') + .addGroupBy('b.contract_kind'); + }, + async summary(ctx) { + const row = await baseQuery(ctx) + .select('COUNT(*)::int', 'bookings') + .addSelect(`ROUND(COALESCE(SUM(${TONS}), 0))::float8`, 'tons') + .addSelect(`ROUND(COALESCE(SUM(${REVENUE}), 0))::float8`, 'amount') + .getRawOne(); + return [ + { label: 'Bookings', value: Number(row?.bookings ?? 0) }, + { label: 'Tonnage', value: Number(row?.tons ?? 0), unit: 't' }, + { label: 'Amount', value: Number(row?.amount ?? 0), unit: 'ETB' }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/global-logistics-wagons.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/global-logistics-wagons.report.ts new file mode 100644 index 000000000..e23ce5469 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/global-logistics-wagons.report.ts @@ -0,0 +1,68 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { ScheduleWagonAdjustmentLog } from '../../train-schedules/entities/schedule-wagon-adjustment-log.entity'; +import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity'; +import { ReportContext, ReportDefinition } from '../report.types'; + +// ADD = allocated, REMOVE = cancelled. SWITCH (a physical wagon swap, net +// count unchanged) is excluded — it's neither an allocation nor a cancellation. +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + const { params } = ctx; + const qb = ctx.ds + .createQueryBuilder() + .from(ScheduleWagonAdjustmentLog, 'l') + .leftJoin(TrainSchedule, 'ts', 'ts.id = l.train_schedule_id') + .where('l.deleted_at IS NULL') + .andWhere("l.action IN ('ADD', 'REMOVE')"); + + if (params.dateFrom) qb.andWhere('l.occurred_at >= :dateFrom', { dateFrom: params.dateFrom }); + if (params.dateTo) qb.andWhere('l.occurred_at < :dateTo', { dateTo: params.dateTo }); + if (params.direction) qb.andWhere('ts.direction = :direction', { direction: params.direction }); + return qb; +} + +export const globalLogisticsWagonsReport: ReportDefinition = { + key: 'global-logistics-wagons', + title: 'Wagon Allocations by Day', + description: 'Wagons allocated vs. cancelled per day, by direction', + group: 'Operations', + filters: [ + { key: 'date', label: 'Date', type: 'daterange' }, + { + key: 'direction', + label: 'Direction', + type: 'select', + options: [ + { value: 'IMPORT', label: 'Import' }, + { value: 'EXPORT', label: 'Export' }, + { value: 'DOMESTIC', label: 'Domestic' }, + ], + }, + ], + columns: [ + { key: 'date', label: 'Date', type: 'date', sortable: true, sortExpr: `date_trunc('day', l.occurred_at)` }, + { key: 'direction', label: 'Direction', type: 'string', sortable: true }, + { key: 'allocated', label: 'Allocated', type: 'number', sortable: true }, + { key: 'cancelled', label: 'Cancelled', type: 'number', sortable: true }, + ], + defaultSort: { key: 'date', dir: 'DESC' }, + query(ctx) { + return baseQuery(ctx) + .select(`to_char(date_trunc('day', l.occurred_at), 'YYYY-MM-DD')`, 'date') + .addSelect("COALESCE(ts.direction, 'Unknown')", 'direction') + .addSelect("COUNT(*) FILTER (WHERE l.action = 'ADD')::int", 'allocated') + .addSelect("COUNT(*) FILTER (WHERE l.action = 'REMOVE')::int", 'cancelled') + .groupBy(`date_trunc('day', l.occurred_at)`) + .addGroupBy('ts.direction'); + }, + async summary(ctx) { + const row = await baseQuery(ctx) + .select("COUNT(*) FILTER (WHERE l.action = 'ADD')::int", 'allocated') + .addSelect("COUNT(*) FILTER (WHERE l.action = 'REMOVE')::int", 'cancelled') + .getRawOne(); + return [ + { label: 'Allocated', value: Number(row?.allocated ?? 0) }, + { label: 'Cancelled', value: Number(row?.cancelled ?? 0) }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/loaded-capacity.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/loaded-capacity.report.ts new file mode 100644 index 000000000..8827b77b8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/loaded-capacity.report.ts @@ -0,0 +1,78 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.entity'; +import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity'; +import { WagonType } from '../../wagon-types/entities/wagon-type.entity'; +import { ReportContext, ReportDefinition } from '../report.types'; + +// train_set_wagons.assigned_weight_tons is the planned load per slot, already +// maintained by the wagon-allocation flow — no need to re-derive it from +// bulk/container line items. +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + const { params } = ctx; + const qb = ctx.ds + .createQueryBuilder() + .from(TrainSetWagon, 'tsw') + .innerJoin(TrainSchedule, 'ts', 'ts.train_set_id = tsw.train_set_id') + .leftJoin(WagonType, 'wt', 'wt.id = tsw.wagon_type_id') + .where('tsw.deleted_at IS NULL AND ts.deleted_at IS NULL'); + + if (params.trainNumber) { + qb.andWhere('ts.train_number ILIKE :trainNumber', { trainNumber: `%${params.trainNumber}%` }); + } + if (params.dateFrom) { + qb.andWhere('ts.scheduled_departure_date >= :dateFrom', { dateFrom: params.dateFrom }); + } + if (params.dateTo) qb.andWhere('ts.scheduled_departure_date < :dateTo', { dateTo: params.dateTo }); + return qb; +} + +export const loadedCapacityReport: ReportDefinition = { + key: 'loaded-capacity', + title: 'Loaded Capacity', + description: 'Nameplate vs. loaded capacity per train, by wagon type', + group: 'Operations', + filters: [ + { key: 'trainNumber', label: 'Train No.', type: 'text' }, + { key: 'date', label: 'Departure', type: 'daterange' }, + ], + columns: [ + { key: 'trainNumber', label: 'Train No.', type: 'string', sortable: true, sortExpr: 'ts.train_number' }, + { key: 'departureDate', label: 'Departure', type: 'date' }, + { key: 'wagonType', label: 'Wagon type', type: 'string', sortable: true }, + { key: 'wagons', label: 'Wagons', type: 'number', sortable: true }, + { key: 'capacityTons', label: 'Capacity', type: 'tons', sortable: true }, + { key: 'loadedTons', label: 'Loaded', type: 'tons', sortable: true }, + { key: 'utilizationPct', label: 'Utilization', type: 'percent', sortable: true }, + ], + defaultSort: { key: 'loadedTons', dir: 'DESC' }, + query(ctx) { + return baseQuery(ctx) + .select('ts.train_number', 'trainNumber') + .addSelect(`to_char(ts.scheduled_departure_date, 'YYYY-MM-DD')`, 'departureDate') + .addSelect("COALESCE(wt.name, 'Unknown')", 'wagonType') + .addSelect('COUNT(*)::int', 'wagons') + .addSelect('COALESCE(SUM(tsw.capacity_tons), 0)::float8', 'capacityTons') + .addSelect('COALESCE(SUM(tsw.assigned_weight_tons), 0)::float8', 'loadedTons') + .addSelect( + `CASE WHEN COALESCE(SUM(tsw.capacity_tons), 0) > 0 + THEN ROUND(SUM(tsw.assigned_weight_tons) / SUM(tsw.capacity_tons) * 100)::float8 END`, + 'utilizationPct', + ) + .groupBy('ts.train_number') + .addGroupBy('ts.scheduled_departure_date') + .addGroupBy('wt.name'); + }, + async summary(ctx) { + const row = await baseQuery(ctx) + .select('COUNT(*)::int', 'wagons') + .addSelect('COALESCE(SUM(tsw.capacity_tons), 0)::float8', 'capacityTons') + .addSelect('COALESCE(SUM(tsw.assigned_weight_tons), 0)::float8', 'loadedTons') + .getRawOne(); + return [ + { label: 'Wagons', value: Number(row?.wagons ?? 0) }, + { label: 'Capacity', value: Number(row?.capacityTons ?? 0), unit: 't' }, + { label: 'Loaded', value: Number(row?.loadedTons ?? 0), unit: 't' }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/train-schedule-status.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/train-schedule-status.report.ts new file mode 100644 index 000000000..88a25d890 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/train-schedule-status.report.ts @@ -0,0 +1,100 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { TrainSchedule, TRAIN_SCHEDULE_STATUSES } from '../../train-schedules/entities/train-schedule.entity'; +import { Yard } from '../../rule-engine/entities/yard.entity'; +import { ReportContext, ReportDefinition } from '../report.types'; + +// ITLMS's spec lists Scheduled/Dispatched/In Transit/Arrived/Cancelled as the +// train lifecycle. The platform tracks DRAFT/SCHEDULED/DISPATCHED/ARRIVED/ +// CANCELLED — no separate "in transit" status exists (a dispatched schedule +// with no actual_arrival_at yet *is* in transit; reported as DISPATCHED). +const STATUS_OPTIONS = TRAIN_SCHEDULE_STATUSES.map((v) => ({ value: v, label: v })); + +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + const { params } = ctx; + const qb = ctx.ds + .createQueryBuilder() + .from(TrainSchedule, 'ts') + .leftJoin(Yard, 'o', 'o.id = ts.origin_station_id') + .leftJoin(Yard, 'd', 'd.id = ts.destination_station_id') + .where('ts.deleted_at IS NULL'); + + if (params.dateFrom) { + qb.andWhere('ts.scheduled_departure_date >= :dateFrom', { dateFrom: params.dateFrom }); + } + if (params.dateTo) { + qb.andWhere('ts.scheduled_departure_date < :dateTo', { dateTo: params.dateTo }); + } + if (params.direction) qb.andWhere('ts.direction = :direction', { direction: params.direction }); + const statuses = params.statuses as string[] | null; + if (statuses) qb.andWhere('ts.status IN (:...statuses)', { statuses }); + return qb; +} + +export const trainScheduleStatusReport: ReportDefinition = { + key: 'train-schedule-status', + title: 'Train Schedules', + description: 'Scheduled, dispatched, arrived and cancelled train departures', + group: 'Operations', + filters: [ + { key: 'date', label: 'Departure', type: 'daterange' }, + { + key: 'direction', + label: 'Direction', + type: 'select', + options: [ + { value: 'IMPORT', label: 'Import' }, + { value: 'EXPORT', label: 'Export' }, + { value: 'DOMESTIC', label: 'Domestic' }, + ], + }, + { key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS }, + ], + columns: [ + { key: 'trainNumber', label: 'Train No.', type: 'string', sortable: true, sortExpr: 'ts.train_number' }, + { key: 'reference', label: 'Reference', type: 'string' }, + { key: 'status', label: 'Status', type: 'string', sortable: true, sortExpr: 'ts.status' }, + { key: 'direction', label: 'Direction', type: 'string' }, + { key: 'origin', label: 'Origin', type: 'string' }, + { key: 'destination', label: 'Destination', type: 'string' }, + { + key: 'scheduledDeparture', + label: 'Scheduled dep.', + type: 'date', + sortable: true, + sortExpr: 'ts.scheduled_departure_date', + }, + { key: 'actualDeparture', label: 'Actual dep.', type: 'date' }, + { key: 'actualArrival', label: 'Actual arr.', type: 'date' }, + ], + defaultSort: { key: 'scheduledDeparture', dir: 'DESC' }, + query(ctx) { + return baseQuery(ctx) + .select('ts.train_number', 'trainNumber') + .addSelect('ts.reference', 'reference') + .addSelect('ts.status', 'status') + .addSelect('ts.direction', 'direction') + .addSelect("COALESCE(o.label, 'Unknown')", 'origin') + .addSelect("COALESCE(d.label, 'Unknown')", 'destination') + .addSelect(`to_char(ts.scheduled_departure_date, 'YYYY-MM-DD')`, 'scheduledDeparture') + .addSelect(`to_char(ts.actual_departure_at, 'YYYY-MM-DD HH24:MI')`, 'actualDeparture') + .addSelect(`to_char(ts.actual_arrival_at, 'YYYY-MM-DD HH24:MI')`, 'actualArrival'); + }, + async summary(ctx) { + const row = await baseQuery(ctx) + .select('COUNT(*)::int', 'total') + .addSelect('COUNT(*) FILTER (WHERE ts.status = :scheduled)::int', 'scheduled') + .addSelect('COUNT(*) FILTER (WHERE ts.status = :dispatched)::int', 'dispatched') + .addSelect('COUNT(*) FILTER (WHERE ts.status = :arrived)::int', 'arrived') + .addSelect('COUNT(*) FILTER (WHERE ts.status = :cancelled)::int', 'cancelled') + .setParameters({ scheduled: 'SCHEDULED', dispatched: 'DISPATCHED', arrived: 'ARRIVED', cancelled: 'CANCELLED' }) + .getRawOne(); + return [ + { label: 'Total', value: Number(row?.total ?? 0) }, + { label: 'Scheduled', value: Number(row?.scheduled ?? 0) }, + { label: 'Dispatched', value: Number(row?.dispatched ?? 0) }, + { label: 'Arrived', value: Number(row?.arrived ?? 0) }, + { label: 'Cancelled', value: Number(row?.cancelled ?? 0) }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/train-turnaround.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/train-turnaround.report.ts new file mode 100644 index 000000000..7dc7b8c3c --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/train-turnaround.report.ts @@ -0,0 +1,86 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity'; +import { Yard } from '../../rule-engine/entities/yard.entity'; +import { ReportContext, ReportDefinition } from '../report.types'; + +// "Turnaround" here is departure-to-arrival transit time on the actual (not +// scheduled) timestamps. Station dwell time (arrival -> the SAME train's next +// departure) would need pairing consecutive schedules by physical train, +// which isn't tracked directly — deferred, not modeled as a shortcut. +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + const { params } = ctx; + const qb = ctx.ds + .createQueryBuilder() + .from(TrainSchedule, 'ts') + .leftJoin(Yard, 'o', 'o.id = ts.origin_station_id') + .leftJoin(Yard, 'd', 'd.id = ts.destination_station_id') + .where('ts.deleted_at IS NULL') + .andWhere('ts.actual_departure_at IS NOT NULL') + .andWhere('ts.actual_arrival_at IS NOT NULL'); + + if (params.dateFrom) qb.andWhere('ts.actual_departure_at >= :dateFrom', { dateFrom: params.dateFrom }); + if (params.dateTo) qb.andWhere('ts.actual_departure_at < :dateTo', { dateTo: params.dateTo }); + if (params.direction) qb.andWhere('ts.direction = :direction', { direction: params.direction }); + return qb; +} + +export const trainTurnaroundReport: ReportDefinition = { + key: 'train-turnaround', + title: 'Train Turnaround', + description: 'Actual departure-to-arrival transit time per schedule', + group: 'Operations', + filters: [ + { key: 'date', label: 'Departed', type: 'daterange' }, + { + key: 'direction', + label: 'Direction', + type: 'select', + options: [ + { value: 'IMPORT', label: 'Import' }, + { value: 'EXPORT', label: 'Export' }, + { value: 'DOMESTIC', label: 'Domestic' }, + ], + }, + ], + columns: [ + { key: 'trainNumber', label: 'Train No.', type: 'string', sortable: true, sortExpr: 'ts.train_number' }, + { key: 'origin', label: 'Origin', type: 'string' }, + { key: 'destination', label: 'Destination', type: 'string' }, + { + key: 'actualDeparture', + label: 'Departed', + type: 'date', + sortable: true, + sortExpr: 'ts.actual_departure_at', + }, + { key: 'actualArrival', label: 'Arrived', type: 'date' }, + { key: 'transitHours', label: 'Transit (hrs)', type: 'number', sortable: true }, + ], + defaultSort: { key: 'actualDeparture', dir: 'DESC' }, + query(ctx) { + return baseQuery(ctx) + .select('ts.train_number', 'trainNumber') + .addSelect("COALESCE(o.label, 'Unknown')", 'origin') + .addSelect("COALESCE(d.label, 'Unknown')", 'destination') + .addSelect(`to_char(ts.actual_departure_at, 'YYYY-MM-DD HH24:MI')`, 'actualDeparture') + .addSelect(`to_char(ts.actual_arrival_at, 'YYYY-MM-DD HH24:MI')`, 'actualArrival') + .addSelect( + `ROUND(EXTRACT(EPOCH FROM (ts.actual_arrival_at - ts.actual_departure_at))::numeric / 3600, 1)::float8`, + 'transitHours', + ); + }, + async summary(ctx) { + const row = await baseQuery(ctx) + .select('COUNT(*)::int', 'trips') + .addSelect( + `ROUND(AVG(EXTRACT(EPOCH FROM (ts.actual_arrival_at - ts.actual_departure_at)))::numeric / 3600, 1)::float8`, + 'avgHours', + ) + .getRawOne(); + return [ + { label: 'Trips', value: Number(row?.trips ?? 0) }, + { label: 'Avg transit', value: Number(row?.avgHours ?? 0), unit: 'h' }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/wagon-teu-utilization.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/wagon-teu-utilization.report.ts new file mode 100644 index 000000000..7dabec37c --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/wagon-teu-utilization.report.ts @@ -0,0 +1,76 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { Wagon } from '../../wagons/entities/wagon.entity'; +import { WagonType } from '../../wagon-types/entities/wagon-type.entity'; +import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity'; +import { Container } from '../../container-management/entities/container.entity'; +import { ContainerType } from '../../rule-engine/entities/container-type.entity'; +import { ReportContext, ReportDefinition } from '../report.types'; + +// TEU = container size in feet / 20 (20ft -> 1 TEU, 40ft -> 2 TEU). Scoped to +// each wagon's CURRENT schedule pin — a live-state view, not a historical one. +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + const { params } = ctx; + const qb = ctx.ds + .createQueryBuilder() + .from(Wagon, 'w') + .innerJoin(TrainSchedule, 'ts', 'ts.id = w.current_train_schedule_id') + .leftJoin(WagonType, 'wt', 'wt.id = w.wagon_type_id') + .leftJoin(Container, 'c', 'c.wagon_id = w.id AND c.deleted_at IS NULL') + .leftJoin(ContainerType, 'ct', 'ct.id = c.container_type_id') + .where('w.deleted_at IS NULL'); + + if (params.trainNumber) { + qb.andWhere('ts.train_number ILIKE :trainNumber', { trainNumber: `%${params.trainNumber}%` }); + } + if (params.dateFrom) { + qb.andWhere('ts.scheduled_departure_date >= :dateFrom', { dateFrom: params.dateFrom }); + } + if (params.dateTo) qb.andWhere('ts.scheduled_departure_date < :dateTo', { dateTo: params.dateTo }); + return qb; +} + +export const wagonTeuUtilizationReport: ReportDefinition = { + key: 'wagon-teu-utilization', + title: 'Wagon TEU Utilization', + description: 'TEU loaded per wagon on its currently assigned train', + group: 'Operations', + filters: [ + { key: 'trainNumber', label: 'Train No.', type: 'text' }, + { key: 'date', label: 'Departure', type: 'daterange' }, + ], + columns: [ + { key: 'wagonNumber', label: 'Wagon', type: 'string', sortable: true, sortExpr: 'w.wagon_number' }, + { key: 'wagonType', label: 'Wagon type', type: 'string' }, + { key: 'trainNumber', label: 'Train No.', type: 'string', sortable: true, sortExpr: 'ts.train_number' }, + { key: 'departureDate', label: 'Departure', type: 'date' }, + { key: 'containers', label: 'Containers', type: 'number', sortable: true }, + { key: 'teu', label: 'TEU', type: 'number', sortable: true }, + ], + defaultSort: { key: 'teu', dir: 'DESC' }, + query(ctx) { + return baseQuery(ctx) + .select('w.wagon_number', 'wagonNumber') + .addSelect("COALESCE(wt.name, 'Unknown')", 'wagonType') + .addSelect('ts.train_number', 'trainNumber') + .addSelect(`to_char(ts.scheduled_departure_date, 'YYYY-MM-DD')`, 'departureDate') + .addSelect('COUNT(c.id)::int', 'containers') + .addSelect('(COALESCE(SUM(ct.size_ft), 0) / 20.0)::float8', 'teu') + .groupBy('w.wagon_number') + .addGroupBy('wt.name') + .addGroupBy('ts.train_number') + .addGroupBy('ts.scheduled_departure_date'); + }, + async summary(ctx) { + const row = await baseQuery(ctx) + .select('COUNT(DISTINCT w.id)::int', 'wagons') + .addSelect('COUNT(c.id)::int', 'containers') + .addSelect('(COALESCE(SUM(ct.size_ft), 0) / 20.0)::float8', 'teu') + .getRawOne(); + return [ + { label: 'Wagons', value: Number(row?.wagons ?? 0) }, + { label: 'Containers', value: Number(row?.containers ?? 0) }, + { label: 'Total TEU', value: Number(row?.teu ?? 0) }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/report.registry.ts b/apps/edr-freight-api/src/modules/reports/report.registry.ts index dfc6ff002..0d9b1d3a2 100644 --- a/apps/edr-freight-api/src/modules/reports/report.registry.ts +++ b/apps/edr-freight-api/src/modules/reports/report.registry.ts @@ -7,6 +7,12 @@ 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 { ReportDefinition } from './report.types'; /** @@ -23,6 +29,12 @@ export const REPORTS: ReportDefinition[] = [ wagonStatusDurationReport, wagonRequestsReport, locomotiveFleetStatusReport, + bookingStatusBreakdownReport, + trainScheduleStatusReport, + trainTurnaroundReport, + wagonTeuUtilizationReport, + loadedCapacityReport, + globalLogisticsWagonsReport, ]; const BY_KEY = new Map(REPORTS.map((r) => [r.key, r])); 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 f9a0f959f..9cddfde87 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -63,6 +63,12 @@ export const REPORT_KEYS = [ "wagon-status-duration", "wagon-requests", "locomotive-fleet-status", + "booking-status-breakdown", + "train-schedule-status", + "train-turnaround", + "wagon-teu-utilization", + "loaded-capacity", + "global-logistics-wagons", ] as const; export type ReportKey = (typeof REPORT_KEYS)[number]; From 29913259f60da2b3fe615303ea49655da65f7517 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Thu, 13 Aug 2026 08:23:19 +0000 Subject: [PATCH 09/34] feat(freight-api): add 9 commercial/finance reports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit customer-status (company-profile roles, not Company — importer/ exporter/forwarder lives there), contract-lifecycle, customs-documents (clearance milestones), invoicing-pipeline, first-last-mile-bookings (one resolver, UNION ALL over first_mile/last_mile — verified the raw-string .from() subquery against the live query builder, not just hand-written SQL, after the join-alias bug earlier this branch), invoices-by-status, payments-by-status, revenue-summary, cargo-summary. payments carries no deleted_at column despite extending BaseEntity — caught by column-checking against the live DB before shipping, dropped the soft-delete filter for that one query. Completes the ITLMS dashboard spec's 20-resolver dedup list (19 built, freight-weight-variance dropped — no charged-vs-actual weight distinction in the schema). --- .../definitions/cargo-summary.report.ts | 58 ++++++++++++ .../definitions/contract-lifecycle.report.ts | 83 +++++++++++++++++ .../definitions/customer-status.report.ts | 67 ++++++++++++++ .../definitions/customs-documents.report.ts | 66 ++++++++++++++ .../first-last-mile-bookings.report.ts | 90 +++++++++++++++++++ .../definitions/invoices-by-status.report.ts | 72 +++++++++++++++ .../definitions/invoicing-pipeline.report.ts | 59 ++++++++++++ .../definitions/payments-by-status.report.ts | 73 +++++++++++++++ .../definitions/revenue-summary.report.ts | 61 +++++++++++++ .../src/modules/reports/report.registry.ts | 18 ++++ .../src/seed/freight-permissions.registry.ts | 9 ++ 11 files changed, 656 insertions(+) create mode 100644 apps/edr-freight-api/src/modules/reports/definitions/cargo-summary.report.ts create mode 100644 apps/edr-freight-api/src/modules/reports/definitions/contract-lifecycle.report.ts create mode 100644 apps/edr-freight-api/src/modules/reports/definitions/customer-status.report.ts create mode 100644 apps/edr-freight-api/src/modules/reports/definitions/customs-documents.report.ts create mode 100644 apps/edr-freight-api/src/modules/reports/definitions/first-last-mile-bookings.report.ts create mode 100644 apps/edr-freight-api/src/modules/reports/definitions/invoices-by-status.report.ts create mode 100644 apps/edr-freight-api/src/modules/reports/definitions/invoicing-pipeline.report.ts create mode 100644 apps/edr-freight-api/src/modules/reports/definitions/payments-by-status.report.ts create mode 100644 apps/edr-freight-api/src/modules/reports/definitions/revenue-summary.report.ts diff --git a/apps/edr-freight-api/src/modules/reports/definitions/cargo-summary.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/cargo-summary.report.ts new file mode 100644 index 000000000..ee3063ee1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/cargo-summary.report.ts @@ -0,0 +1,58 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { Booking } from '../../bookings/entities/booking.entity'; +import { ReportContext, ReportDefinition } from '../report.types'; + +const TONS = 'COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)'; +const NOT_UMBRELLA = "(b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')"; +const DEAD_STATUSES = ['DRAFT', 'CANCELLED', 'REJECTED', 'EXPIRED']; + +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + const { params, directions } = ctx; + const qb = ctx.ds + .createQueryBuilder() + .from(Booking, 'b') + .where(`b.deleted_at IS NULL AND ${NOT_UMBRELLA}`) + .andWhere('b.status NOT IN (:...deadStatuses)', { deadStatuses: DEAD_STATUSES }); + + if (params.dateFrom) qb.andWhere('b.created_at >= :dateFrom', { dateFrom: params.dateFrom }); + if (params.dateTo) qb.andWhere('b.created_at < :dateTo', { dateTo: params.dateTo }); + if (directions !== null) { + qb.andWhere(directions.length ? 'b.trade_direction IN (:...directions)' : '1 = 0', { directions }); + } + return qb; +} + +export const cargoSummaryReport: ReportDefinition = { + key: 'cargo-summary', + title: 'Cargo Summary', + description: 'Cargo tonnage by direction and cargo type', + group: 'Operations', + filters: [{ key: 'date', label: 'Created', type: 'daterange' }], + columns: [ + { key: 'direction', label: 'Direction', type: 'string', sortable: true }, + { key: 'freightType', label: 'Cargo type', type: 'string', sortable: true }, + { key: 'bookings', label: 'Bookings', type: 'number', sortable: true }, + { key: 'tons', label: 'Tonnage', type: 'tons', sortable: true }, + ], + defaultSort: { key: 'tons', dir: 'DESC' }, + query(ctx) { + return baseQuery(ctx) + .select('b.trade_direction', 'direction') + .addSelect('b.freight_type', 'freightType') + .addSelect('COUNT(*)::int', 'bookings') + .addSelect(`ROUND(COALESCE(SUM(${TONS}), 0))::float8`, 'tons') + .groupBy('b.trade_direction') + .addGroupBy('b.freight_type'); + }, + async summary(ctx) { + const row = await baseQuery(ctx) + .select(`ROUND(COALESCE(SUM(${TONS}), 0))::float8`, 'tons') + .addSelect('COUNT(*)::int', 'bookings') + .getRawOne(); + return [ + { label: 'Bookings', value: Number(row?.bookings ?? 0) }, + { label: 'Total tonnage', value: Number(row?.tons ?? 0), unit: 't' }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/contract-lifecycle.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/contract-lifecycle.report.ts new file mode 100644 index 000000000..513abeda4 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/contract-lifecycle.report.ts @@ -0,0 +1,83 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { Contract, CONTRACT_KINDS, CONTRACT_STATUSES } from '../../contracts/entities/contract.entity'; +import { Company } from '../../companies/entities/company.entity'; +import { ReportContext, ReportDefinition } from '../report.types'; + +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + const { params, directions } = ctx; + const qb = ctx.ds + .createQueryBuilder() + .from(Contract, 'ct') + .leftJoin(Company, 'c', 'c.id = ct.company_id') + .where('ct.deleted_at IS NULL'); + + if (params.dateFrom) qb.andWhere('ct.contract_valid_from >= :dateFrom', { dateFrom: params.dateFrom }); + if (params.dateTo) qb.andWhere('ct.contract_valid_from < :dateTo', { dateTo: params.dateTo }); + if (params.kind) qb.andWhere('ct.contract_kind = :kind', { kind: params.kind }); + if (params.direction) qb.andWhere('ct.trade_direction = :direction', { direction: params.direction }); + const statuses = params.statuses as string[] | null; + if (statuses) qb.andWhere('ct.status IN (:...statuses)', { statuses }); + if (directions !== null) { + qb.andWhere(directions.length ? 'ct.trade_direction IN (:...directions)' : '1 = 0', { directions }); + } + return qb; +} + +export const contractLifecycleReport: ReportDefinition = { + key: 'contract-lifecycle', + title: 'Contracts', + description: 'Signed, active and cancelled contracts', + group: 'Commercial', + filters: [ + { key: 'date', label: 'Valid from', type: 'daterange' }, + { key: 'kind', label: 'Kind', type: 'select', options: CONTRACT_KINDS.map((v) => ({ value: v, label: v })) }, + { + key: 'direction', + label: 'Direction', + type: 'select', + options: [ + { value: 'IMPORT', label: 'Import' }, + { value: 'EXPORT', label: 'Export' }, + { value: 'DOMESTIC', label: 'Domestic' }, + ], + }, + { key: 'statuses', label: 'Status', type: 'multiselect', options: CONTRACT_STATUSES.map((v) => ({ value: v, label: v.replace(/_/g, ' ') })) }, + ], + columns: [ + { key: 'reference', label: 'Reference', type: 'string', sortable: true, sortExpr: 'ct.reference' }, + { key: 'customer', label: 'Customer', type: 'string', sortable: true, sortExpr: 'c.name' }, + { key: 'kind', label: 'Kind', type: 'string' }, + { key: 'direction', label: 'Direction', type: 'string' }, + { key: 'freightType', label: 'Freight type', type: 'string' }, + { key: 'status', label: 'Status', type: 'string', sortable: true, sortExpr: 'ct.status' }, + { key: 'validFrom', label: 'Valid from', type: 'date', sortable: true, sortExpr: 'ct.contract_valid_from' }, + { key: 'validUntil', label: 'Valid until', type: 'date' }, + { key: 'signedAt', label: 'Signed', type: 'date' }, + ], + defaultSort: { key: 'validFrom', dir: 'DESC' }, + query(ctx) { + return baseQuery(ctx) + .select('ct.reference', 'reference') + .addSelect("COALESCE(c.name, ct.government_institution, 'Unknown')", 'customer') + .addSelect('ct.contract_kind', 'kind') + .addSelect('ct.trade_direction', 'direction') + .addSelect('ct.freight_type', 'freightType') + .addSelect('ct.status', 'status') + .addSelect(`to_char(ct.contract_valid_from, 'YYYY-MM-DD')`, 'validFrom') + .addSelect(`to_char(ct.contract_valid_until, 'YYYY-MM-DD')`, 'validUntil') + .addSelect(`to_char(ct.fully_executed_at, 'YYYY-MM-DD')`, 'signedAt'); + }, + async summary(ctx) { + const row = await baseQuery(ctx) + .select('COUNT(*)::int', 'total') + .addSelect('COUNT(*) FILTER (WHERE ct.fully_executed_at IS NOT NULL)::int', 'signed') + .addSelect("COUNT(*) FILTER (WHERE ct.status = 'CANCELLED')::int", 'cancelled') + .getRawOne(); + return [ + { label: 'Contracts', value: Number(row?.total ?? 0) }, + { label: 'Signed', value: Number(row?.signed ?? 0) }, + { label: 'Cancelled', value: Number(row?.cancelled ?? 0) }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/customer-status.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/customer-status.report.ts new file mode 100644 index 000000000..60b6ff31a --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/customer-status.report.ts @@ -0,0 +1,67 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { CompanyProfile, ProfileStatus, ProfileType } from '../../companies/entities/company-profile.entity'; +import { Company } from '../../companies/entities/company.entity'; +import { ReportContext, ReportDefinition } from '../report.types'; + +// "Type (Importer, Exporter, Freight Forwarding)" and "Active/Suspended" are +// CompanyProfile fields, not Company's — a company can hold several profiles +// (e.g. importer AND exporter), each independently approved/suspended. +const TYPE_OPTIONS = Object.values(ProfileType).map((v) => ({ value: v, label: v.replace(/_/g, ' ') })); +const STATUS_OPTIONS = Object.values(ProfileStatus).map((v) => ({ value: v, label: v })); + +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + const { params } = ctx; + const qb = ctx.ds + .createQueryBuilder() + .from(CompanyProfile, 'cp') + .innerJoin(Company, 'c', 'c.id = cp.company_id') + .where('cp.deleted_at IS NULL'); + + if (params.type) qb.andWhere('cp.type = :type', { type: params.type }); + const statuses = params.statuses as string[] | null; + if (statuses) qb.andWhere('cp.status IN (:...statuses)', { statuses }); + return qb; +} + +export const customerStatusReport: ReportDefinition = { + key: 'customer-status', + title: 'Customer Profiles', + description: 'Company profiles by role type and approval status', + group: 'Commercial', + filters: [ + { key: 'type', label: 'Type', type: 'select', options: TYPE_OPTIONS }, + { key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS }, + ], + columns: [ + { key: 'company', label: 'Company', type: 'string', sortable: true, sortExpr: 'c.name' }, + { key: 'type', label: 'Type', type: 'string', sortable: true, sortExpr: 'cp.type' }, + { key: 'status', label: 'Status', type: 'string', sortable: true, sortExpr: 'cp.status' }, + { key: 'reference', label: 'Reference', type: 'string' }, + { key: 'note', label: 'Note', type: 'string' }, + { key: 'reviewedAt', label: 'Reviewed', type: 'date', sortable: true, sortExpr: 'cp.reviewed_at' }, + ], + defaultSort: { key: 'reviewedAt', dir: 'DESC' }, + query(ctx) { + return baseQuery(ctx) + .select('c.name', 'company') + .addSelect('cp.type', 'type') + .addSelect('cp.status', 'status') + .addSelect("COALESCE(cp.reference, '')", 'reference') + .addSelect("COALESCE(cp.review_note, '')", 'note') + .addSelect(`to_char(cp.reviewed_at, 'YYYY-MM-DD')`, 'reviewedAt'); + }, + async summary(ctx) { + const row = await baseQuery(ctx) + .select('COUNT(*)::int', 'total') + .addSelect('COUNT(*) FILTER (WHERE cp.status = :active)::int', 'active') + .addSelect('COUNT(*) FILTER (WHERE cp.status = :suspended)::int', 'suspended') + .setParameters({ active: ProfileStatus.Active, suspended: ProfileStatus.Suspended }) + .getRawOne(); + return [ + { label: 'Profiles', value: Number(row?.total ?? 0) }, + { label: 'Active', value: Number(row?.active ?? 0) }, + { label: 'Suspended', value: Number(row?.suspended ?? 0) }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/customs-documents.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/customs-documents.report.ts new file mode 100644 index 000000000..a0bcb32fc --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/customs-documents.report.ts @@ -0,0 +1,66 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { + ClearanceMilestone, + MILESTONE_OWNER_REGIONS, + MILESTONE_STATUSES, +} from '../../contracts/entities/clearance-milestone.entity'; +import { ReportContext, ReportDefinition } from '../report.types'; + +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + const { params } = ctx; + const qb = ctx.ds.createQueryBuilder().from(ClearanceMilestone, 'cm').where('cm.deleted_at IS NULL'); + + if (params.dateFrom) qb.andWhere('cm.created_at >= :dateFrom', { dateFrom: params.dateFrom }); + if (params.dateTo) qb.andWhere('cm.created_at < :dateTo', { dateTo: params.dateTo }); + if (params.ownerRegion) qb.andWhere('cm.owner_region = :ownerRegion', { ownerRegion: params.ownerRegion }); + const statuses = params.statuses as string[] | null; + if (statuses) qb.andWhere('cm.status IN (:...statuses)', { statuses }); + return qb; +} + +export const customsDocumentsReport: ReportDefinition = { + key: 'customs-documents', + title: 'Customs Clearance Milestones', + description: 'Clearance milestone volume by label, owner and status', + group: 'Operations', + filters: [ + { key: 'date', label: 'Created', type: 'daterange' }, + { + key: 'ownerRegion', + label: 'Owner', + type: 'select', + options: MILESTONE_OWNER_REGIONS.map((v) => ({ value: v, label: v })), + }, + { key: 'statuses', label: 'Status', type: 'multiselect', options: MILESTONE_STATUSES.map((v) => ({ value: v, label: v })) }, + ], + columns: [ + { key: 'milestone', label: 'Milestone', type: 'string', sortable: true }, + { key: 'ownerRegion', label: 'Owner', type: 'string', sortable: true }, + { key: 'status', label: 'Status', type: 'string', sortable: true }, + { key: 'count', label: 'Count', type: 'number', sortable: true }, + ], + defaultSort: { key: 'count', dir: 'DESC' }, + query(ctx) { + return baseQuery(ctx) + .select('cm.milestone_label', 'milestone') + .addSelect("COALESCE(cm.owner_region, 'Unassigned')", 'ownerRegion') + .addSelect('cm.status', 'status') + .addSelect('COUNT(*)::int', 'count') + .groupBy('cm.milestone_label') + .addGroupBy('cm.owner_region') + .addGroupBy('cm.status'); + }, + async summary(ctx) { + const row = await baseQuery(ctx) + .select('COUNT(*)::int', 'total') + .addSelect("COUNT(*) FILTER (WHERE cm.status = 'COMPLETED')::int", 'completed') + .addSelect("COUNT(*) FILTER (WHERE cm.status = 'PENDING')::int", 'pending') + .getRawOne(); + return [ + { label: 'Milestones', value: Number(row?.total ?? 0) }, + { label: 'Completed', value: Number(row?.completed ?? 0) }, + { label: 'Pending', value: Number(row?.pending ?? 0) }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/first-last-mile-bookings.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/first-last-mile-bookings.report.ts new file mode 100644 index 000000000..d3abe10dc --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/first-last-mile-bookings.report.ts @@ -0,0 +1,90 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { Booking } from '../../bookings/entities/booking.entity'; +import { Company } from '../../companies/entities/company.entity'; +import { Vehicle } from '../../vehicles/entities/vehicle.entity'; +import { ReportContext, ReportDefinition } from '../report.types'; + +// FirstMile and LastMile are separate tables with an identical shape (status, +// booking, optional vehicle). One resolver, unioned, with a `leg` column — +// beats shipping two near-duplicate reports for the two halves of the trip. +const LEG_UNION = `( + SELECT 'FIRST' AS leg, fm.id AS id, fm.booking_id AS booking_id, fm.status AS status, + fm.vehicle_id AS vehicle_id, fm.created_at AS created_at + FROM freight.first_mile fm WHERE fm.deleted_at IS NULL + UNION ALL + SELECT 'LAST' AS leg, lm.id AS id, lm.booking_id AS booking_id, lm.status AS status, + lm.vehicle_id AS vehicle_id, lm.created_at AS created_at + FROM freight.last_mile lm WHERE lm.deleted_at IS NULL +)`; + +const STATUS_OPTIONS = [ + { value: 'PAYMENT_PENDING', label: 'Payment pending' }, + { value: 'READY_TO_TRANSIT', label: 'Ready to transit' }, + { value: 'IN_TRANSIT', label: 'In transit' }, + { value: 'RECEIVED_TO_PORT', label: 'Received to port' }, + { value: 'DELIVERED', label: 'Delivered' }, +]; + +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + const { params } = ctx; + const qb = ctx.ds + .createQueryBuilder() + .from(LEG_UNION, 'fl') + .innerJoin(Booking, 'b', 'b.id = fl.booking_id') + .leftJoin(Company, 'c', 'c.id = b.company_id') + .leftJoin(Vehicle, 'v', 'v.id = fl.vehicle_id') + .where('1 = 1'); + + if (params.leg) qb.andWhere('fl.leg = :leg', { leg: params.leg }); + if (params.dateFrom) qb.andWhere('fl.created_at >= :dateFrom', { dateFrom: params.dateFrom }); + if (params.dateTo) qb.andWhere('fl.created_at < :dateTo', { dateTo: params.dateTo }); + const statuses = params.statuses as string[] | null; + if (statuses) qb.andWhere('fl.status IN (:...statuses)', { statuses }); + return qb; +} + +export const firstLastMileBookingsReport: ReportDefinition = { + key: 'first-last-mile-bookings', + title: 'First/Last Mile Trucking', + description: 'First- and last-mile bookings by status and truck assignment', + group: 'Operations', + filters: [ + { key: 'date', label: 'Created', type: 'daterange' }, + { key: 'leg', label: 'Leg', type: 'select', options: [{ value: 'FIRST', label: 'First mile' }, { value: 'LAST', label: 'Last mile' }] }, + { key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS }, + ], + columns: [ + { key: 'leg', label: 'Leg', type: 'string', sortable: true }, + { key: 'booking', label: 'Booking', type: 'string', sortable: true, sortExpr: 'b.reference' }, + { key: 'customer', label: 'Customer', type: 'string', sortable: true, sortExpr: 'c.name' }, + { key: 'status', label: 'Status', type: 'string', sortable: true, sortExpr: 'fl.status' }, + { key: 'truck', label: 'Truck', type: 'string' }, + { key: 'assigned', label: 'Assigned', type: 'string', sortable: true }, + { key: 'createdAt', label: 'Created', type: 'date', sortable: true, sortExpr: 'fl.created_at' }, + ], + defaultSort: { key: 'createdAt', dir: 'DESC' }, + query(ctx) { + return baseQuery(ctx) + .select('fl.leg', 'leg') + .addSelect('b.reference', 'booking') + .addSelect("COALESCE(c.name, 'Unknown')", 'customer') + .addSelect('fl.status', 'status') + .addSelect("COALESCE(v.plate_number, '—')", 'truck') + .addSelect("CASE WHEN fl.vehicle_id IS NOT NULL THEN 'Assigned' ELSE 'Unassigned' END", 'assigned') + .addSelect(`to_char(fl.created_at, 'YYYY-MM-DD')`, 'createdAt'); + }, + async summary(ctx) { + const row = await baseQuery(ctx) + .select('COUNT(*)::int', 'total') + .addSelect('COUNT(*) FILTER (WHERE fl.vehicle_id IS NOT NULL)::int', 'assigned') + .getRawOne(); + const total = Number(row?.total ?? 0); + const assigned = Number(row?.assigned ?? 0); + return [ + { label: 'Trips', value: total }, + { label: 'Assigned', value: assigned }, + { label: 'Unassigned', value: total - assigned }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/invoices-by-status.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/invoices-by-status.report.ts new file mode 100644 index 000000000..81b183f90 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/invoices-by-status.report.ts @@ -0,0 +1,72 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { Freight } from '@edr/types'; +import { Invoice } from '../../billing/entities/invoice.entity'; +import { Company } from '../../companies/entities/company.entity'; +import { CompanyProfile } from '../../companies/entities/company-profile.entity'; +import { ReportContext, ReportDefinition } from '../report.types'; + +const STATUS_OPTIONS = Object.values(Freight.InvoiceStatus).map((v) => ({ value: v, label: v })); + +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + const { params } = ctx; + const qb = ctx.ds + .createQueryBuilder() + .from(Invoice, 'i') + .innerJoin(Company, 'c', 'c.id = i.company_id') + .leftJoin(CompanyProfile, 'cp', 'cp.id = i.company_profile_id') + .where('i.deleted_at IS NULL'); + + if (params.dateFrom) qb.andWhere('i.issued_at >= :dateFrom', { dateFrom: params.dateFrom }); + if (params.dateTo) qb.andWhere('i.issued_at < :dateTo', { dateTo: params.dateTo }); + const statuses = params.statuses as string[] | null; + if (statuses) qb.andWhere('i.status IN (:...statuses)', { statuses }); + return qb; +} + +export const invoicesByStatusReport: ReportDefinition = { + key: 'invoices-by-status', + title: 'Invoices', + description: 'Every invoice with customer, profile type and settlement status', + group: 'Finance', + filters: [ + { key: 'date', label: 'Issued', type: 'daterange' }, + { key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS }, + ], + columns: [ + { key: 'invoiceNumber', label: 'Invoice No.', type: 'string', sortable: true, sortExpr: 'i.invoice_number' }, + { key: 'customer', label: 'Customer', type: 'string', sortable: true, sortExpr: 'c.name' }, + { key: 'profileType', label: 'Profile', type: 'string' }, + { key: 'status', label: 'Status', type: 'string', sortable: true, sortExpr: 'i.status' }, + { key: 'totalAmount', label: 'Total', type: 'money', sortable: true }, + { key: 'paidAmount', label: 'Paid', type: 'money' }, + { key: 'balanceAmount', label: 'Balance', type: 'money', sortable: true }, + { key: 'issuedAt', label: 'Issued', type: 'date', sortable: true, sortExpr: 'i.issued_at' }, + { key: 'dueAt', label: 'Due', type: 'date' }, + ], + defaultSort: { key: 'issuedAt', dir: 'DESC' }, + query(ctx) { + return baseQuery(ctx) + .select('i.invoice_number', 'invoiceNumber') + .addSelect('c.name', 'customer') + .addSelect("COALESCE(cp.type, 'Unknown')", 'profileType') + .addSelect('i.status', 'status') + .addSelect('ROUND(i.total_amount)::float8', 'totalAmount') + .addSelect('ROUND(i.paid_amount)::float8', 'paidAmount') + .addSelect('ROUND(i.balance_amount)::float8', 'balanceAmount') + .addSelect(`to_char(i.issued_at, 'YYYY-MM-DD')`, 'issuedAt') + .addSelect(`to_char(i.due_at, 'YYYY-MM-DD')`, 'dueAt'); + }, + async summary(ctx) { + const row = await baseQuery(ctx) + .select('COUNT(*)::int', 'invoices') + .addSelect('ROUND(COALESCE(SUM(i.total_amount), 0))::float8', 'total') + .addSelect('ROUND(COALESCE(SUM(i.balance_amount), 0))::float8', 'balance') + .getRawOne(); + return [ + { label: 'Invoices', value: Number(row?.invoices ?? 0) }, + { label: 'Total value', value: Number(row?.total ?? 0), unit: 'ETB' }, + { label: 'Outstanding', value: Number(row?.balance ?? 0), unit: 'ETB' }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/invoicing-pipeline.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/invoicing-pipeline.report.ts new file mode 100644 index 000000000..8907f4f5a --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/invoicing-pipeline.report.ts @@ -0,0 +1,59 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { Freight } from '@edr/types'; +import { Invoice } from '../../billing/entities/invoice.entity'; +import { ReportContext, ReportDefinition } from '../report.types'; + +const STATUS_OPTIONS = Object.values(Freight.InvoiceStatus).map((v) => ({ value: v, label: v })); + +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + const { params } = ctx; + const qb = ctx.ds.createQueryBuilder().from(Invoice, 'i').where('i.deleted_at IS NULL'); + + if (params.dateFrom) qb.andWhere('i.created_at >= :dateFrom', { dateFrom: params.dateFrom }); + if (params.dateTo) qb.andWhere('i.created_at < :dateTo', { dateTo: params.dateTo }); + const statuses = params.statuses as string[] | null; + if (statuses) qb.andWhere('i.status IN (:...statuses)', { statuses }); + return qb; +} + +export const invoicingPipelineReport: ReportDefinition = { + key: 'invoicing-pipeline', + title: 'Invoicing Pipeline', + description: 'Invoice volume and value by type and status', + group: 'Finance', + filters: [ + { key: 'date', label: 'Created', type: 'daterange' }, + { key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS }, + ], + columns: [ + { key: 'type', label: 'Type', type: 'string', sortable: true }, + { key: 'status', label: 'Status', type: 'string', sortable: true }, + { key: 'invoices', label: 'Invoices', type: 'number', sortable: true }, + { key: 'totalAmount', label: 'Total', type: 'money', sortable: true }, + { key: 'balance', label: 'Outstanding', type: 'money', sortable: true }, + ], + defaultSort: { key: 'invoices', dir: 'DESC' }, + query(ctx) { + return baseQuery(ctx) + .select('i.type', 'type') + .addSelect('i.status', 'status') + .addSelect('COUNT(*)::int', 'invoices') + .addSelect('ROUND(COALESCE(SUM(i.total_amount), 0))::float8', 'totalAmount') + .addSelect('ROUND(COALESCE(SUM(i.balance_amount), 0))::float8', 'balance') + .groupBy('i.type') + .addGroupBy('i.status'); + }, + async summary(ctx) { + const row = await baseQuery(ctx) + .select('COUNT(*)::int', 'invoices') + .addSelect('ROUND(COALESCE(SUM(i.total_amount), 0))::float8', 'totalAmount') + .addSelect('ROUND(COALESCE(SUM(i.balance_amount), 0))::float8', 'balance') + .getRawOne(); + return [ + { label: 'Invoices', value: Number(row?.invoices ?? 0) }, + { label: 'Total value', value: Number(row?.totalAmount ?? 0), unit: 'ETB' }, + { label: 'Outstanding', value: Number(row?.balance ?? 0), unit: 'ETB' }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/payments-by-status.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/payments-by-status.report.ts new file mode 100644 index 000000000..15e99a4b1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/payments-by-status.report.ts @@ -0,0 +1,73 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { PaymentEntity } from '../../payment/entities/payment.entity'; +import { ReportContext, ReportDefinition } from '../report.types'; + +// No direct company link on payments (refId points at whatever the intent was +// for — booking, demurrage, ...); breakdown stops at status/method/currency. +const STATUS_OPTIONS = [ + { value: 'action-required', label: 'Action required' }, + { value: 'processing', label: 'Processing' }, + { value: 'success', label: 'Success' }, + { value: 'failed', label: 'Failed' }, + { value: 'canceled', label: 'Canceled' }, + { value: 'refunded', label: 'Refunded' }, +]; +const METHOD_OPTIONS = ['telebirr', 'cbe-birr', 'ebirr', 'waafi', 'card', 'dmoney', 'cac-bank', 'cbe-bill'].map( + (v) => ({ value: v, label: v }), +); + +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + const { params } = ctx; + // payments carries no deleted_at column (unlike the rest of the schema) — + // confirmed against the live DB, not assumed from BaseEntity. + const qb = ctx.ds.createQueryBuilder().from(PaymentEntity, 'p').where('1 = 1'); + + if (params.dateFrom) qb.andWhere('p.created_at >= :dateFrom', { dateFrom: params.dateFrom }); + if (params.dateTo) qb.andWhere('p.created_at < :dateTo', { dateTo: params.dateTo }); + if (params.method) qb.andWhere('p.method = :method', { method: params.method }); + const statuses = params.statuses as string[] | null; + if (statuses) qb.andWhere('p.status IN (:...statuses)', { statuses }); + return qb; +} + +export const paymentsByStatusReport: ReportDefinition = { + key: 'payments-by-status', + title: 'Payments by Status', + description: 'Payment volume and value by status, method and currency', + group: 'Finance', + filters: [ + { key: 'date', label: 'Created', type: 'daterange' }, + { key: 'method', label: 'Method', type: 'select', options: METHOD_OPTIONS }, + { key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS }, + ], + columns: [ + { key: 'status', label: 'Status', type: 'string', sortable: true }, + { key: 'method', label: 'Method', type: 'string', sortable: true }, + { key: 'currency', label: 'Currency', type: 'string' }, + { key: 'payments', label: 'Payments', type: 'number', sortable: true }, + { key: 'amount', label: 'Amount', type: 'money', sortable: true }, + ], + defaultSort: { key: 'amount', dir: 'DESC' }, + query(ctx) { + return baseQuery(ctx) + .select('p.status', 'status') + .addSelect('p.method', 'method') + .addSelect('p.currency', 'currency') + .addSelect('COUNT(*)::int', 'payments') + .addSelect('ROUND(COALESCE(SUM(p.amount), 0))::float8', 'amount') + .groupBy('p.status') + .addGroupBy('p.method') + .addGroupBy('p.currency'); + }, + async summary(ctx) { + const row = await baseQuery(ctx) + .select('COUNT(*)::int', 'payments') + .addSelect("ROUND(COALESCE(SUM(p.amount) FILTER (WHERE p.status = 'success'), 0))::float8", 'paid') + .getRawOne(); + return [ + { label: 'Payments', value: Number(row?.payments ?? 0) }, + { label: 'Total paid', value: Number(row?.paid ?? 0), unit: 'ETB' }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/revenue-summary.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/revenue-summary.report.ts new file mode 100644 index 000000000..684d4e2e5 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/revenue-summary.report.ts @@ -0,0 +1,61 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { Booking } from '../../bookings/entities/booking.entity'; +import { ReportContext, ReportDefinition } from '../report.types'; + +const REVENUE = 'COALESCE(b.adjusted_total_amount, b.total_amount)'; +const NOT_UMBRELLA = "(b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')"; +const DEAD_STATUSES = ['DRAFT', 'CANCELLED', 'REJECTED', 'EXPIRED']; + +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + const { params, directions } = ctx; + const qb = ctx.ds + .createQueryBuilder() + .from(Booking, 'b') + .where(`b.deleted_at IS NULL AND ${NOT_UMBRELLA}`) + .andWhere('b.status NOT IN (:...deadStatuses)', { deadStatuses: DEAD_STATUSES }); + + if (params.dateFrom) qb.andWhere('b.created_at >= :dateFrom', { dateFrom: params.dateFrom }); + if (params.dateTo) qb.andWhere('b.created_at < :dateTo', { dateTo: params.dateTo }); + if (directions !== null) { + qb.andWhere(directions.length ? 'b.trade_direction IN (:...directions)' : '1 = 0', { directions }); + } + return qb; +} + +export const revenueSummaryReport: ReportDefinition = { + key: 'revenue-summary', + title: 'Revenue Summary', + description: 'Booking revenue by direction, cargo type and currency', + group: 'Finance', + filters: [{ key: 'date', label: 'Created', type: 'daterange' }], + columns: [ + { key: 'direction', label: 'Direction', type: 'string', sortable: true }, + { key: 'freightType', label: 'Cargo type', type: 'string', sortable: true }, + { key: 'currency', label: 'Currency', type: 'string' }, + { key: 'bookings', label: 'Bookings', type: 'number', sortable: true }, + { key: 'revenue', label: 'Revenue', type: 'money', sortable: true }, + ], + defaultSort: { key: 'revenue', dir: 'DESC' }, + query(ctx) { + return baseQuery(ctx) + .select('b.trade_direction', 'direction') + .addSelect('b.freight_type', 'freightType') + .addSelect('b.payment_currency', 'currency') + .addSelect('COUNT(*)::int', 'bookings') + .addSelect(`ROUND(COALESCE(SUM(${REVENUE}), 0))::float8`, 'revenue') + .groupBy('b.trade_direction') + .addGroupBy('b.freight_type') + .addGroupBy('b.payment_currency'); + }, + async summary(ctx) { + const row = await baseQuery(ctx) + .select(`ROUND(COALESCE(SUM(${REVENUE}), 0))::float8`, 'revenue') + .addSelect('COUNT(*)::int', 'bookings') + .getRawOne(); + return [ + { label: 'Bookings', value: Number(row?.bookings ?? 0) }, + { label: 'Total revenue', value: Number(row?.revenue ?? 0), unit: 'ETB' }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/report.registry.ts b/apps/edr-freight-api/src/modules/reports/report.registry.ts index 0d9b1d3a2..004b61e5b 100644 --- a/apps/edr-freight-api/src/modules/reports/report.registry.ts +++ b/apps/edr-freight-api/src/modules/reports/report.registry.ts @@ -13,6 +13,15 @@ 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'; /** @@ -35,6 +44,15 @@ export const REPORTS: ReportDefinition[] = [ wagonTeuUtilizationReport, loadedCapacityReport, globalLogisticsWagonsReport, + customerStatusReport, + contractLifecycleReport, + customsDocumentsReport, + invoicingPipelineReport, + firstLastMileBookingsReport, + invoicesByStatusReport, + paymentsByStatusReport, + revenueSummaryReport, + cargoSummaryReport, ]; const BY_KEY = new Map(REPORTS.map((r) => [r.key, r])); 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 9cddfde87..e98127042 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -69,6 +69,15 @@ export const REPORT_KEYS = [ "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]; From 833e62990e429b2ac4a79e05b746fffe88d1ace7 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Thu, 13 Aug 2026 08:22:51 +0000 Subject: [PATCH 10/34] feat(train-scheduling): add wagon type, tare, equated length, station, seal no and note columns to import marshalling doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Import Load List / Marshalling Document only rendered Seq, Wagon, Booking, Company, Load, Container numbers, Weight T — missing fields present on the physical marshaling sheet (wagon type, tare, equated length, departure/arrival station, seal no) and a blank note column for yard staff. Export marshalling doc already had most of these; import doc now matches. Existing columns kept in place, unchanged. --- .../last-mile-requests.controller.ts | 13 +++ .../last-mile-requests.service.ts | 22 +++++ .../services/train-scheduling.service.ts | 27 +++++- .../detail/BookingMileServicesCard.tsx | 60 +++++++++++- .../portal/src/constants/URLS.ts | 1 + .../components/MileSummaryCard.tsx | 97 +++++++++++++++++-- .../services/last-mile-requests.service.ts | 7 ++ 7 files changed, 215 insertions(+), 12 deletions(-) diff --git a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.controller.ts b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.controller.ts index 3d9fa4254..ff52ff9d7 100644 --- a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.controller.ts +++ b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.controller.ts @@ -48,6 +48,19 @@ export class LastMileRequestsController { return this.requestsService.freeTruckCount().then((count) => ({ count })); } + // Customer-facing like :id — booking detail (portal + backoffice) lists the + // booking's requests to link the stored LM contract. Ownership-checked in + // the service for portal callers. + @Get('by-booking/:bookingId') + @MixedAudience(FREIGHT_PERMS.lastMile.requestView) + @ApiOperation({ summary: "A booking's last-mile requests, newest first — LM contract reference" }) + findForBooking( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @CurrentUser() user: TCurrentUser, + ) { + return this.requestsService.findForBooking(bookingId, user?.id ?? null); + } + @Get(':id/price-estimate') @BookingStaff(FREIGHT_PERMS.lastMile.requestView) @ApiOperation({ diff --git a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts index ebdc93746..37612d318 100644 --- a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts @@ -221,6 +221,28 @@ export class LastMileRequestsService { return record; } + /** + * Every request on a booking, newest first — the booking-detail pages + * (portal + backoffice) use this to surface the LM contract later. Portal + * callers pass their userId and are ownership-checked against the booking's + * company, mirroring findById. + */ + async findForBooking(bookingId: string, userId?: string | null): Promise { + if (userId) { + const companyId = await this.bookingsService.resolveCustomerCompanyId(userId); + if (companyId) { + const booking = await this.bookingsRepository.findById(bookingId); + if (booking?.companyId && booking.companyId !== companyId) { + throw new BadRequestException('This booking does not belong to your company'); + } + } + } + return this.requestsRepository.findAll({ + where: { bookingId }, + order: { createdAt: 'DESC' }, + }); + } + /** * Rule-based price estimate for the approval dialog: estimated km (yard GPS → * delivery point, straight-line) × the LIVE last-mile rate rules against the diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts index fc9395ffb..6c89c6980 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 @@ -3002,6 +3002,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, @@ -3501,26 +3504,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))} `; }, @@ -3609,15 +3623,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.'} 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..0a079bb4d 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,9 @@ -import { Truck } from "lucide-react"; -import { SimpleGrid } from "@mantine/core"; +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"; @@ -10,12 +13,37 @@ export interface BookingMileServicesCardProps { booking: BookingDetail; } -/** First / last mile addresses. Renders nothing when neither is present. */ +/** + * First / last mile addresses, plus the stored last-mile contract reference + * (signed status + PDF download) for Truck & Machinery once a request on this + * booking is approved. Renders nothing when neither address is present. + */ export function BookingMileServicesCard({ booking }: BookingMileServicesCardProps) { + 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 (!booking.firstMilePickupAddress && !booking.lastMileDeliveryAddress) { 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 ( @@ -26,6 +54,32 @@ export function BookingMileServicesCard({ booking }: BookingMileServicesCardProp )} + {approvedRequest && ( + + + + Last-mile contract + + + {approvedRequest.customerSignedAt + ? `Signed ${new Date(approvedRequest.customerSignedAt).toLocaleDateString()}${ + approvedRequest.signerDisplayName + ? ` by ${approvedRequest.signerDisplayName}` + : "" + }` + : "Awaiting customer signature"} + + + + + )} ); } diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts index a0d33f5ba..c9576d192 100644 --- a/apps/edr-freight-web/portal/src/constants/URLS.ts +++ b/apps/edr-freight-web/portal/src/constants/URLS.ts @@ -224,6 +224,7 @@ export const URL_CONSTANTS = { }, LAST_MILE_REQUESTS: { + BY_BOOKING: (bookingId: string) => `/api/last-mile-requests/by-booking/${bookingId}`, BY_ID: (id: string) => `/api/last-mile-requests/${id}`, SUBMIT: (id: string) => `/api/last-mile-requests/${id}/submit`, CONTRACT_VIEW: (id: string) => `/api/last-mile-requests/${id}/contract/view`, diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/MileSummaryCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/MileSummaryCard.tsx index bfb57529f..6619dbd06 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/MileSummaryCard.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/MileSummaryCard.tsx @@ -1,5 +1,6 @@ -import { Box, Group, Stack, Text } from "@mantine/core"; +import { Box, Button, Group, Stack, Text } from "@mantine/core"; import { useQuery } from "@tanstack/react-query"; +import { useNavigate } from "react-router-dom"; import type { Freight } from "@edr/types"; @@ -8,6 +9,7 @@ import type { MileLegSummary, MileVehicleSummary, } from "@/services/bookings.service"; +import { lastMileRequestsService } from "@/services/last-mile-requests.service"; import { CardTitle, SectionCard } from "./layout"; @@ -171,12 +173,85 @@ function LegBlock({ ); } +/** + * Reference row for the stored last-mile contract: signed status, open the + * contract page (view / sign), download the PDF. + */ +function LastMileContractRow({ + bookingId, + requestId, + signedAt, + signerDisplayName, +}: { + bookingId: string; + requestId: string; + signedAt?: string | null; + signerDisplayName?: string | null; +}) { + const navigate = useNavigate(); + const download = async () => { + const blob = await lastMileRequestsService.downloadContractDocument(requestId); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "last-mile-contract.pdf"; + a.click(); + URL.revokeObjectURL(url); + }; + + return ( + + + + Last-mile contract + + + {signedAt + ? `Signed ${new Date(signedAt).toLocaleDateString()}${ + signerDisplayName ? ` by ${signerDisplayName}` : "" + }` + : "Awaiting your signature"} + + + + + + + + ); +} + export function MileSummaryCard({ booking }: { booking: Freight.IBooking }) { const { data } = useQuery({ queryKey: ["booking-mile-summary", booking.id], queryFn: () => bookingsService.mileSummary(booking.id), }); + // The stored LM contract lives on the booking's approved last-mile request. + const { data: lmRequests } = useQuery({ + queryKey: ["booking-last-mile-requests", booking.id], + queryFn: () => lastMileRequestsService.listForBooking(booking.id), + enabled: !!booking.lastMileDeliveryAddress, + }); + const approvedRequest = (lmRequests ?? []).find((r) => r.status === "APPROVED"); + const firstLeg = data?.firstMile ?? null; const lastLeg = data?.lastMile ?? null; @@ -202,11 +277,21 @@ export function MileSummaryCard({ booking }: { booking: Freight.IBooking }) { /> )} {showLast && ( - + + + {approvedRequest && ( + + )} + )} diff --git a/apps/edr-freight-web/portal/src/services/last-mile-requests.service.ts b/apps/edr-freight-web/portal/src/services/last-mile-requests.service.ts index 5069eafe3..08133ba08 100644 --- a/apps/edr-freight-web/portal/src/services/last-mile-requests.service.ts +++ b/apps/edr-freight-web/portal/src/services/last-mile-requests.service.ts @@ -12,6 +12,7 @@ export interface LastMileRequest { requestedContainerNumbers?: string[] | null; requestedDeliveryDate?: string | null; customerSignedAt?: string | null; + signerDisplayName?: string | null; rejectionReason?: string | null; createdAt: string; updatedAt: string; @@ -46,6 +47,12 @@ export const lastMileRequestsService = { return data.data ?? data; }, + /** The booking's requests, newest first — links the stored LM contract. */ + listForBooking: async (bookingId: string): Promise => { + const { data } = await client.get(L.BY_BOOKING(bookingId)); + return data.data ?? data; + }, + /** Confirm which containers go via EDR last-mile and the requested delivery date. */ submit: async ( id: string, From 6f14cf8bbf9141542ed71c9aa5baf8b92bf47860 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Thu, 13 Aug 2026 08:33:59 +0000 Subject: [PATCH 11/34] feat(freight-backoffice): add DateRangePicker, replace ad-hoc from/to date filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a shadcn Popover+Calendar range picker with presets (Today, Last 7/30 days, this/last month, YTD) and Apply/Cancel. Swaps it into every shadcn-family from-to date filter: activity log, sector reports, incoming records (EN branch), CombinedFilterBar (13 consumers), user records, audit log — also fixes an uncontrolled-input bug on the audit log date fields. Mantine-based ListControls/ReportFilters left untouched (different UI kit). --- .../backoffice/src/pages/AuditLog.tsx | 22 +- .../sectorReports/SectorReportFilters.tsx | 32 +-- .../externalIncomingRecordsV2.tsx | 25 +-- .../internalIncomingRecordsV2.tsx | 25 +-- .../components/userRecords/userRecords.tsx | 31 +-- .../shared/common/ui/date-range-picker.tsx | 197 ++++++++++++++++++ .../activity-log/activity-filters.tsx | 103 +-------- .../components/filters/CombinedFilterBar.tsx | 31 +-- 8 files changed, 252 insertions(+), 214 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/shared/common/ui/date-range-picker.tsx 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) }) + } + /> -