From 9aae132dd4a3dc7fc9c89839b7b0d4abc7840af4 Mon Sep 17 00:00:00 2001 From: marshalyordanos Date: Thu, 13 Aug 2026 08:54:20 +0300 Subject: [PATCH 1/8] 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 0df4be1820e4495e4ddd7cc284e478da0853fc26 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Thu, 13 Aug 2026 12:44:06 +0000 Subject: [PATCH 2/8] style: overview revamp --- .../overview/dto/overview-response.dto.ts | 61 ++- .../modules/overview/overview.repository.ts | 259 ++++++++++++- .../src/modules/overview/overview.service.ts | 30 +- apps/edr-freight-web/backoffice/src/App.tsx | 17 + .../src/components/layout/route-meta.ts | 28 ++ .../overview/OverviewKpiSection.tsx | 167 --------- .../overview/OverviewPageHeader.tsx | 66 ---- .../overview/OverviewRecentBookingsTable.tsx | 138 ++++--- .../overview/OverviewTabContent.tsx | 119 ------ .../overview/overview-domains.config.ts | 88 +++++ .../src/components/overview/overview.css | 7 +- .../components/overview/summary/CountUp.tsx | 44 +++ .../summary/OverviewActivityHeatmap.tsx | 125 +++++++ .../summary/OverviewAttentionCard.tsx | 149 ++++++++ .../overview/summary/OverviewHero.tsx | 140 +++++++ .../overview/summary/OverviewHeroKpis.tsx | 67 ++++ .../overview/summary/OverviewNetworkCard.tsx | 88 +++++ .../summary/OverviewPipelineFunnel.tsx | 113 ++++++ .../overview/summary/OverviewRevenueMix.tsx | 143 +++++++ .../summary/OverviewRevenueVolumeChart.tsx | 172 +++++++++ .../overview/summary/OverviewSankeyFlow.tsx | 170 +++++++++ .../overview/summary/SummaryCard.tsx | 65 ++++ .../overview/summary/chart-style.ts | 14 + .../overview/summary/flow-colors.ts | 25 ++ .../overview/summary/mergeTrend.test.ts | 56 +++ .../components/overview/summary/mergeTrend.ts | 63 ++++ .../overview/summary/overview-summary.css | 65 ++++ .../tabs/OverviewBookingsTabPanel.tsx | 6 - .../tabs/OverviewContractsTabPanel.tsx | 6 - .../tabs/OverviewOperationsTabPanel.tsx | 6 - .../src/components/page/KpiStrip.tsx | 12 +- .../pages/dashboard/OverviewDomainPage.tsx | 145 ++++++++ .../src/pages/dashboard/OverviewPage.tsx | 352 +++++++----------- packages/types/src/freight/overview.ts | 50 ++- 34 files changed, 2412 insertions(+), 644 deletions(-) delete mode 100644 apps/edr-freight-web/backoffice/src/components/overview/OverviewKpiSection.tsx delete mode 100644 apps/edr-freight-web/backoffice/src/components/overview/OverviewPageHeader.tsx delete mode 100644 apps/edr-freight-web/backoffice/src/components/overview/OverviewTabContent.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/overview/overview-domains.config.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/overview/summary/CountUp.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewActivityHeatmap.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewAttentionCard.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewHero.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewHeroKpis.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewNetworkCard.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewPipelineFunnel.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewRevenueMix.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewRevenueVolumeChart.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewSankeyFlow.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/overview/summary/SummaryCard.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/overview/summary/chart-style.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/overview/summary/flow-colors.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/overview/summary/mergeTrend.test.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/overview/summary/mergeTrend.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/overview/summary/overview-summary.css create mode 100644 apps/edr-freight-web/backoffice/src/pages/dashboard/OverviewDomainPage.tsx diff --git a/apps/edr-freight-api/src/modules/overview/dto/overview-response.dto.ts b/apps/edr-freight-api/src/modules/overview/dto/overview-response.dto.ts index 005dad08a..1bd22c7c0 100644 --- a/apps/edr-freight-api/src/modules/overview/dto/overview-response.dto.ts +++ b/apps/edr-freight-api/src/modules/overview/dto/overview-response.dto.ts @@ -21,6 +21,7 @@ export class OverviewContractKpisDto { export class OverviewOperationsKpisDto { @ApiProperty() trainsActive!: number; @ApiProperty() wagonsAvailable!: number; + @ApiProperty() wagonsTotal!: number; @ApiProperty() containersInTransit!: number; @ApiProperty() cargoesLoaded!: number; @ApiProperty() schedulesUpcoming!: number; @@ -108,6 +109,41 @@ export class OverviewRecentContractDto { @ApiProperty() createdAt!: string; } +export class OverviewPeriodTotalsDto { + @ApiProperty() bookingsCreated!: number; + @ApiProperty() revenueEtb!: number; + @ApiProperty() revenueUsd!: number; + @ApiProperty() tons!: number; +} + +export class OverviewRevenueSliceDto { + @ApiProperty() label!: string; + @ApiProperty() amountEtb!: number; + @ApiProperty() amountUsd!: number; +} + +export class OverviewTonsTrendPointDto { + @ApiProperty({ example: '2026-06-01' }) date!: string; + @ApiProperty() tons!: number; +} + +export class OverviewRevenueFlowDto { + @ApiProperty() direction!: string; + @ApiProperty() freightType!: string; + @ApiProperty() amountEtb!: number; + @ApiProperty() amountUsd!: number; +} + +export class OverviewHeatmapCellDto { + @ApiProperty({ description: 'ISO weekday, 1 = Monday … 7 = Sunday' }) + dow!: number; + + @ApiProperty({ description: '3-hour block, 0 = 00–03 … 7 = 21–24' }) + block!: number; + + @ApiProperty() count!: number; +} + export class OverviewResponseDto { @ApiProperty({ type: OverviewKpisDto }) kpis!: OverviewKpisDto; @@ -124,8 +160,29 @@ export class OverviewResponseDto { @ApiProperty({ type: [OverviewPaymentTrendPointDto] }) paymentTrend!: OverviewPaymentTrendPointDto[]; - @ApiProperty({ type: [OverviewRecentBookingDto] }) - recentBookings!: OverviewRecentBookingDto[]; + @ApiProperty({ type: OverviewPeriodTotalsDto }) + current!: OverviewPeriodTotalsDto; + + @ApiProperty({ type: OverviewPeriodTotalsDto }) + previous!: OverviewPeriodTotalsDto; + + @ApiProperty({ type: [OverviewRevenueSliceDto] }) + revenueByDirection!: OverviewRevenueSliceDto[]; + + @ApiProperty({ type: [OverviewRevenueSliceDto] }) + revenueByFreightType!: OverviewRevenueSliceDto[]; + + @ApiProperty({ type: [OverviewPaymentTrendPointDto] }) + previousPaymentTrend!: OverviewPaymentTrendPointDto[]; + + @ApiProperty({ type: [OverviewTonsTrendPointDto] }) + tonsTrend!: OverviewTonsTrendPointDto[]; + + @ApiProperty({ type: [OverviewRevenueFlowDto] }) + revenueFlows!: OverviewRevenueFlowDto[]; + + @ApiProperty({ type: [OverviewHeatmapCellDto] }) + bookingHeatmap!: OverviewHeatmapCellDto[]; @ApiProperty() generatedAt!: string; } diff --git a/apps/edr-freight-api/src/modules/overview/overview.repository.ts b/apps/edr-freight-api/src/modules/overview/overview.repository.ts index c61320d8c..18b6bb54d 100644 --- a/apps/edr-freight-api/src/modules/overview/overview.repository.ts +++ b/apps/edr-freight-api/src/modules/overview/overview.repository.ts @@ -155,6 +155,7 @@ export class OverviewRepository { async getOperationsKpis(): Promise<{ trainsActive: number; wagonsAvailable: number; + wagonsTotal: number; containersInTransit: number; cargoesLoaded: number; schedulesUpcoming: number; @@ -163,6 +164,7 @@ export class OverviewRepository { const [ trainsActive, wagonsAvailable, + wagonsTotal, containersInTransit, cargoesLoaded, schedulesUpcoming, @@ -185,6 +187,10 @@ export class OverviewRepository { status: Freight.WagonStatus.Available, }) .getCount(), + this.wagonRepository + .createQueryBuilder("wagon") + .where("wagon.deleted_at IS NULL") + .getCount(), this.containerRepository .createQueryBuilder("container") .where("container.deleted_at IS NULL") @@ -218,6 +224,7 @@ export class OverviewRepository { return { trainsActive, wagonsAvailable, + wagonsTotal, containersInTransit, cargoesLoaded, schedulesUpcoming, @@ -345,9 +352,16 @@ export class OverviewRepository { ); } + /** + * Daily successful-payment revenue for a `days`-wide window shifted back by + * `offsetDays` — `0` (default) is the current window ending today, + * `offsetDays: days` is the immediately preceding window (the ghost-line + * comparison series on the overview chart). + */ async getPaymentTrend( days: number, dirs?: string[], + offsetDays = 0, ): Promise<{ date: string; amountEtb: number; amountUsd: number }[]> { const scope = bookingRefScopeSql("payment.ref_id", dirs); const rows = await this.paymentRepository @@ -366,8 +380,8 @@ export class OverviewRepository { ) .where("payment.status = :status", { status: "success" }) .andWhere( - `COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`, - { days }, + `COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :offsetDays::int - :days::int + 1 AND COALESCE(payment.paid_at, payment.created_at) < CURRENT_DATE - :offsetDays::int + 1`, + { days, offsetDays }, ) .andWhere(scope.sql, scope.params) .groupBy(`COALESCE(payment.paid_at, payment.created_at)::date`) @@ -546,6 +560,247 @@ export class OverviewRepository { })); } + /** + * Bookings created, revenue and tonnage for one `days`-wide window, shifted + * back by `offsetDays`. Called twice by the service — `offsetDays: 0` for + * the current period, `offsetDays: days` for the immediately preceding + * one-of-the-same-length period — so the page can show a real vs-prior-period + * delta instead of a bare count. + */ + async getPeriodTotals( + days: number, + offsetDays: number, + dirs?: string[], + ): Promise<{ + bookingsCreated: number; + revenueEtb: number; + revenueUsd: number; + tons: number; + }> { + const bookingScope = directionScopeSql("booking.trade_direction", dirs); + const paymentScope = bookingRefScopeSql("payment.ref_id", dirs); + const cargoScope = directionScopeSql("booking.trade_direction", dirs); + const windowSql = (column: string) => + `${column} >= CURRENT_DATE - :offsetDays::int - :days::int + 1 AND ${column} < CURRENT_DATE - :offsetDays::int + 1`; + + const [bookingsCreated, revenueRow, tonsRow] = await Promise.all([ + this.bookingRepository + .createQueryBuilder("booking") + .where("booking.deleted_at IS NULL") + .andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS) + .andWhere(bookingScope.sql, bookingScope.params) + .andWhere(windowSql("booking.created_at"), { days, offsetDays }) + .getCount(), + this.paymentRepository + .createQueryBuilder("payment") + .select( + `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB'), 0)`, + "revenueEtb", + ) + .addSelect( + `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`, + "revenueUsd", + ) + .where("payment.status = :status", { status: "success" }) + .andWhere( + windowSql("COALESCE(payment.paid_at, payment.created_at)"), + { days, offsetDays }, + ) + .andWhere(paymentScope.sql, paymentScope.params) + .getRawOne<{ revenueEtb: string; revenueUsd: string }>(), + this.cargoRepository + .createQueryBuilder("cargo") + .leftJoin(Booking, "booking", "booking.id = cargo.booking_id") + .select(`COALESCE(SUM(cargo.weight), 0) / 1000`, "tons") + .where("cargo.deleted_at IS NULL") + .andWhere(windowSql("cargo.created_at"), { days, offsetDays }) + .andWhere(cargoScope.sql, cargoScope.params) + .getRawOne<{ tons: string }>(), + ]); + + return { + bookingsCreated, + revenueEtb: Number(revenueRow?.revenueEtb ?? 0), + revenueUsd: Number(revenueRow?.revenueUsd ?? 0), + tons: Number(tonsRow?.tons ?? 0), + }; + } + + /** Revenue for the selected range, split by booking trade direction. */ + async getRevenueByDirection( + days: number, + dirs?: string[], + ): Promise<{ label: string; amountEtb: number; amountUsd: number }[]> { + const scope = bookingRefScopeSql("payment.ref_id", dirs); + const rows = await this.paymentRepository + .createQueryBuilder("payment") + .leftJoin(Booking, "booking", "booking.id::text = payment.ref_id") + .select("booking.trade_direction", "label") + .addSelect( + `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB'), 0)`, + "amountEtb", + ) + .addSelect( + `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`, + "amountUsd", + ) + .where("payment.status = :status", { status: "success" }) + .andWhere( + `COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`, + { days }, + ) + .andWhere(scope.sql, scope.params) + .andWhere("booking.trade_direction IS NOT NULL") + .groupBy("booking.trade_direction") + .getRawMany<{ label: string; amountEtb: string; amountUsd: string }>(); + + return rows.map((row) => ({ + label: row.label, + amountEtb: Number(row.amountEtb), + amountUsd: Number(row.amountUsd), + })); + } + + /** Revenue for the selected range, split by booking freight type. */ + async getRevenueByFreightType( + days: number, + dirs?: string[], + ): Promise<{ label: string; amountEtb: number; amountUsd: number }[]> { + const scope = bookingRefScopeSql("payment.ref_id", dirs); + const rows = await this.paymentRepository + .createQueryBuilder("payment") + .leftJoin(Booking, "booking", "booking.id::text = payment.ref_id") + .select("booking.freight_type", "label") + .addSelect( + `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB'), 0)`, + "amountEtb", + ) + .addSelect( + `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`, + "amountUsd", + ) + .where("payment.status = :status", { status: "success" }) + .andWhere( + `COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`, + { days }, + ) + .andWhere(scope.sql, scope.params) + .andWhere("booking.freight_type IS NOT NULL") + .groupBy("booking.freight_type") + .getRawMany<{ label: string; amountEtb: string; amountUsd: string }>(); + + return rows.map((row) => ({ + label: row.label, + amountEtb: Number(row.amountEtb), + amountUsd: Number(row.amountUsd), + })); + } + + /** Daily cargo tonnage for the selected range — hero sparkline series. */ + async getTonsTrend( + days: number, + dirs?: string[], + ): Promise<{ date: string; tons: number }[]> { + const scope = directionScopeSql("booking.trade_direction", dirs); + const rows = await this.cargoRepository + .createQueryBuilder("cargo") + .leftJoin(Booking, "booking", "booking.id = cargo.booking_id") + .select(`to_char(cargo.created_at::date, 'YYYY-MM-DD')`, "date") + .addSelect(`COALESCE(SUM(cargo.weight), 0) / 1000`, "tons") + .where("cargo.deleted_at IS NULL") + .andWhere(scope.sql, scope.params) + .andWhere(`cargo.created_at >= CURRENT_DATE - :days::int + 1`, { days }) + .groupBy("cargo.created_at::date") + .orderBy("cargo.created_at::date", "ASC") + .getRawMany<{ date: string; tons: string }>(); + + return rows.map((row) => ({ date: row.date, tons: Number(row.tons) })); + } + + /** + * Revenue for the selected range as direction → freight-type flows — the + * Sankey on the overview. One row per (direction, freight type) pair. + */ + async getRevenueFlows( + days: number, + dirs?: string[], + ): Promise< + { + direction: string; + freightType: string; + amountEtb: number; + amountUsd: number; + }[] + > { + const scope = bookingRefScopeSql("payment.ref_id", dirs); + const rows = await this.paymentRepository + .createQueryBuilder("payment") + .leftJoin(Booking, "booking", "booking.id::text = payment.ref_id") + .select("booking.trade_direction", "direction") + .addSelect("booking.freight_type", "freightType") + .addSelect( + `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB'), 0)`, + "amountEtb", + ) + .addSelect( + `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`, + "amountUsd", + ) + .where("payment.status = :status", { status: "success" }) + .andWhere( + `COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`, + { days }, + ) + .andWhere(scope.sql, scope.params) + .andWhere("booking.trade_direction IS NOT NULL") + .andWhere("booking.freight_type IS NOT NULL") + .groupBy("booking.trade_direction") + .addGroupBy("booking.freight_type") + .getRawMany<{ + direction: string; + freightType: string; + amountEtb: string; + amountUsd: string; + }>(); + + return rows.map((row) => ({ + direction: row.direction, + freightType: row.freightType, + amountEtb: Number(row.amountEtb), + amountUsd: Number(row.amountUsd), + })); + } + + /** + * Booking arrivals bucketed by ISO weekday (1 = Mon … 7 = Sun) and 3-hour + * block (0 = 00–03 … 7 = 21–24) — the demand-rhythm heatmap. Buckets use + * the database server's timezone, same as every ::date grouping here. + */ + async getBookingHeatmap( + days: number, + dirs?: string[], + ): Promise<{ dow: number; block: number; count: number }[]> { + const scope = directionScopeSql("booking.trade_direction", dirs); + const rows = await this.bookingRepository + .createQueryBuilder("booking") + .select("EXTRACT(ISODOW FROM booking.created_at)::int", "dow") + .addSelect("FLOOR(EXTRACT(HOUR FROM booking.created_at) / 3)::int", "block") + .addSelect("COUNT(*)::int", "count") + .where("booking.deleted_at IS NULL") + .andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS) + .andWhere(scope.sql, scope.params) + .andWhere(`booking.created_at >= CURRENT_DATE - :days::int + 1`, { days }) + .groupBy("EXTRACT(ISODOW FROM booking.created_at)::int") + .addGroupBy("FLOOR(EXTRACT(HOUR FROM booking.created_at) / 3)::int") + .getRawMany<{ dow: string; block: string; count: string }>(); + + return rows.map((row) => ({ + dow: Number(row.dow), + block: Number(row.block), + count: Number(row.count), + })); + } + async getTrainStatusBreakdown(): Promise< { status: string; count: number }[] > { diff --git a/apps/edr-freight-api/src/modules/overview/overview.service.ts b/apps/edr-freight-api/src/modules/overview/overview.service.ts index 399bf4f72..a10bb8816 100644 --- a/apps/edr-freight-api/src/modules/overview/overview.service.ts +++ b/apps/edr-freight-api/src/modules/overview/overview.service.ts @@ -56,7 +56,14 @@ export class OverviewService { bookingTrend, statusCounts, paymentTrend, - recentBookings, + current, + previous, + revenueByDirection, + revenueByFreightType, + previousPaymentTrend, + tonsTrend, + revenueFlows, + bookingHeatmap, ] = await Promise.all([ this.overviewRepository.getBookingKpis(dirs), this.overviewRepository.getContractKpis(dirs), @@ -67,7 +74,14 @@ export class OverviewService { this.overviewRepository.getBookingTrend(days, dirs), this.overviewRepository.getStatusCounts(dirs), this.overviewRepository.getPaymentTrend(days, dirs), - this.overviewRepository.getRecentBookings(8, dirs), + this.overviewRepository.getPeriodTotals(days, 0, dirs), + this.overviewRepository.getPeriodTotals(days, days, dirs), + this.overviewRepository.getRevenueByDirection(days, dirs), + this.overviewRepository.getRevenueByFreightType(days, dirs), + this.overviewRepository.getPaymentTrend(days, dirs, days), + this.overviewRepository.getTonsTrend(days, dirs), + this.overviewRepository.getRevenueFlows(days, dirs), + this.overviewRepository.getBookingHeatmap(days, dirs), ]); const { bookingsByPipeline, bookingsByStatus } = @@ -86,10 +100,14 @@ export class OverviewService { bookingsByStatus, bookingsByPipeline, paymentTrend, - recentBookings: recentBookings.map((row) => ({ - ...row, - createdAt: row.createdAt.toISOString(), - })), + current, + previous, + revenueByDirection, + revenueByFreightType, + previousPaymentTrend, + tonsTrend, + revenueFlows, + bookingHeatmap, generatedAt: new Date().toISOString(), }; } diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index a9b9db226..b9b96de08 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -40,6 +40,8 @@ import InvoiceDetailPage from "./pages/invoices/InvoiceDetailPage"; import FinanceHubPage from "./pages/invoices/FinanceHubPage"; import MyProfilePage from "./pages/dashboard/MyProfilePage"; import OverviewPage from "./pages/dashboard/OverviewPage"; +import OverviewDomainPage from "./pages/dashboard/OverviewDomainPage"; +import { OVERVIEW_DOMAINS } from "./components/overview/overview-domains.config"; import ReportsIndexRedirect from "./pages/reports/ReportsIndexRedirect"; import ReportPage from "./pages/reports/ReportPage"; import AuditLogsPage from "./pages/AuditLogsPage"; @@ -222,6 +224,21 @@ const App = () => { } /> + {/* One drill-down route per overview domain — the old per-tab charts, + now each on its own page. Single source of truth for the + permission gate is OVERVIEW_DOMAINS, shared with the summary + page's "View all" links. */} + {OVERVIEW_DOMAINS.map((domain) => ( + + + + } + /> + ))} = [ subtitle: "Dashboard summary and key metrics", }, }, + { + prefix: "/dashboard/overview/bookings", + meta: { title: "Bookings", subtitle: "Booking volume, pipeline, and recent activity" }, + }, + { + prefix: "/dashboard/overview/contracts", + meta: { title: "Contracts", subtitle: "Contract volume, pipeline, and recent activity" }, + }, + { + prefix: "/dashboard/overview/billing", + meta: { title: "Billing", subtitle: "Revenue, payments, and collection status" }, + }, + { + prefix: "/dashboard/overview/operations", + meta: { title: "Operations", subtitle: "Trains, schedules, containers, and cargo" }, + }, + { + prefix: "/dashboard/overview/fleet", + meta: { title: "Fleet", subtitle: "Wagon and train fleet status" }, + }, + { + prefix: "/dashboard/overview/customers", + meta: { title: "Customers", subtitle: "Customer growth and top accounts" }, + }, + { + prefix: "/dashboard/overview/staff", + meta: { title: "Staff", subtitle: "Employee and user account status" }, + }, { prefix: "/dashboard/profile", meta: { diff --git a/apps/edr-freight-web/backoffice/src/components/overview/OverviewKpiSection.tsx b/apps/edr-freight-web/backoffice/src/components/overview/OverviewKpiSection.tsx deleted file mode 100644 index ea99d098e..000000000 --- a/apps/edr-freight-web/backoffice/src/components/overview/OverviewKpiSection.tsx +++ /dev/null @@ -1,167 +0,0 @@ -import { - AlertCircle, - Banknote, - Box, - Clock, - Container, - CreditCard, - FileText, - Train, - Truck, - UserCheck, - Users, - Wallet, -} from "lucide-react"; -import { Group, Paper, Stack, Text } from "@mantine/core"; - -import type { IOverviewKpis } from "@/types/overview"; -import { OverviewKpiCard } from "./OverviewKpiCard"; - -function formatCurrency(amount: number, currency: "ETB" | "USD") { - return new Intl.NumberFormat("en-US", { - style: "currency", - currency, - maximumFractionDigits: 0, - }).format(amount); -} - -export function OverviewKpiSection({ kpis }: { kpis: IOverviewKpis }) { - const bookingItems = [ - { - label: "Active bookings", - value: kpis.bookings.totalActive, - icon: FileText, - accent: "emerald" as const, - }, - { - label: "Needs action", - value: kpis.bookings.needsAction, - icon: AlertCircle, - accent: "amber" as const, - }, - { - label: "Urgent", - value: kpis.bookings.urgent, - icon: Clock, - accent: "rose" as const, - }, - { - label: "In approval", - value: kpis.bookings.inApproval, - icon: UserCheck, - accent: "sky" as const, - }, - { - label: "Submitted today", - value: kpis.bookings.submittedToday, - icon: FileText, - }, - ]; - - const operationsItems = [ - { - label: "Active trains", - value: kpis.operations.trainsActive, - icon: Train, - accent: "emerald" as const, - }, - { - label: "Wagons available", - value: kpis.operations.wagonsAvailable, - icon: Truck, - }, - { - label: "Containers in transit", - value: kpis.operations.containersInTransit, - icon: Container, - }, - { - label: "Cargoes loaded", - value: kpis.operations.cargoesLoaded, - icon: Box, - }, - ]; - - const billingItems = [ - { - label: "Revenue MTD (ETB)", - value: formatCurrency(kpis.billing.revenueMtdEtb, "ETB"), - icon: Banknote, - accent: "emerald" as const, - }, - { - label: "Revenue MTD (USD)", - value: formatCurrency(kpis.billing.revenueMtdUsd, "USD"), - icon: Wallet, - }, - { - label: "Pending payments", - value: kpis.billing.pendingPayments, - icon: CreditCard, - accent: "amber" as const, - }, - { - label: "Successful MTD", - value: kpis.billing.successfulPaymentsMtd, - icon: Banknote, - }, - ]; - - const peopleItems = [ - { - label: "Total customers", - value: kpis.customers.totalCustomers, - icon: Users, - }, - { - label: "New this month", - value: kpis.customers.newCustomersThisMonth, - icon: Users, - accent: "emerald" as const, - }, - { - label: "Active employees", - value: kpis.staff.activeEmployees, - icon: UserCheck, - }, - { - label: "Active users", - value: kpis.staff.activeUsers, - icon: Users, - }, - ]; - - const sections = [ - { title: "Bookings", items: bookingItems }, - { title: "Operations", items: operationsItems }, - { title: "Billing", items: billingItems }, - { title: "Customers & staff", items: peopleItems }, - ]; - - return ( - - {sections.map((section) => ( - - - {section.title} - - - {section.items.map((item) => ( - - ))} - - - ))} - - ); -} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/OverviewPageHeader.tsx b/apps/edr-freight-web/backoffice/src/components/overview/OverviewPageHeader.tsx deleted file mode 100644 index 529981add..000000000 --- a/apps/edr-freight-web/backoffice/src/components/overview/OverviewPageHeader.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { ActionIcon, Group, SegmentedControl, Text } from "@mantine/core"; -import { RefreshCw } from "lucide-react"; - -import type { OverviewRange } from "@/types/overview"; - -const RANGE_OPTIONS = [ - { label: "7 days", value: "7d" }, - { label: "30 days", value: "30d" }, - { label: "90 days", value: "90d" }, -]; - -function formatRelativeTime(iso: string | undefined) { - if (!iso) return "—"; - const diffMs = Date.now() - new Date(iso).getTime(); - const minutes = Math.floor(diffMs / 60_000); - if (minutes < 1) return "just now"; - if (minutes < 60) return `${minutes}m ago`; - const hours = Math.floor(minutes / 60); - if (hours < 24) return `${hours}h ago`; - return new Date(iso).toLocaleString(); -} - -interface OverviewPageHeaderProps { - range: OverviewRange; - onRangeChange: (range: OverviewRange) => void; - generatedAt?: string; - onRefresh: () => void; - isRefreshing?: boolean; -} - -export function OverviewPageHeader({ - range, - onRangeChange, - generatedAt, - onRefresh, - isRefreshing, -}: OverviewPageHeaderProps) { - return ( - - - Updated {formatRelativeTime(generatedAt)} - - - onRangeChange(value as OverviewRange)} - data={RANGE_OPTIONS} - size="sm" - radius="lg" - color="edr-green" - /> - - - - - - ); -} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/OverviewRecentBookingsTable.tsx b/apps/edr-freight-web/backoffice/src/components/overview/OverviewRecentBookingsTable.tsx index b13f5c473..e1ceaec1c 100644 --- a/apps/edr-freight-web/backoffice/src/components/overview/OverviewRecentBookingsTable.tsx +++ b/apps/edr-freight-web/backoffice/src/components/overview/OverviewRecentBookingsTable.tsx @@ -1,9 +1,11 @@ -import { useNavigate } from "react-router-dom"; -import { Paper, Stack, Table, Text } from "@mantine/core"; +import { History } from "lucide-react"; +import { Link, useNavigate } from "react-router-dom"; +import { Table, Text } from "@mantine/core"; import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge"; import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge"; import type { IOverviewRecentBooking } from "@/types/overview"; +import { SummaryCard } from "./summary/SummaryCard"; function formatAmount(amount: number | null, currency: string | null) { if (amount == null) return "—"; @@ -23,56 +25,90 @@ export function OverviewRecentBookingsTable({ const navigate = useNavigate(); return ( - - - Recent bookings - {bookings.length === 0 ? ( - - No recent bookings - - ) : ( - - - - Reference - Customer - Status - Priority - Amount - Created - - - - {bookings.map((booking) => ( - navigate(`/dashboard/booking-requests/${booking.id}`)} - > - - - {booking.reference} - - - {booking.customerLabel} - - - - - - - + + View all → + + } + > + {bookings.length === 0 ? ( + + No recent bookings + + ) : ( +
+ + + {["Reference", "Customer", "Status", "Priority", "Amount", "Created"].map( + (header) => ( + + {header} + + ), + )} + + + + {bookings.map((booking) => ( + navigate(`/dashboard/booking-requests/${booking.id}`)} + > + + + {booking.reference} + + + + + {booking.customerLabel} + + + + + + + + + + {formatAmount(booking.totalAmount, booking.paymentCurrency)} - - + + + + {new Date(booking.createdAt).toLocaleDateString()} - - - ))} - -
- )} -
-
+ + + + ))} + + + )} + ); } diff --git a/apps/edr-freight-web/backoffice/src/components/overview/OverviewTabContent.tsx b/apps/edr-freight-web/backoffice/src/components/overview/OverviewTabContent.tsx deleted file mode 100644 index cf71198a6..000000000 --- a/apps/edr-freight-web/backoffice/src/components/overview/OverviewTabContent.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import { AlertCircle } from "lucide-react"; -import { Alert, Button, Center, Loader, Paper, Skeleton, Stack } from "@mantine/core"; - -import { - useOverviewBillingTab, - useOverviewBookingsTab, - useOverviewContractsTab, - useOverviewCustomersTab, - useOverviewOperationsTab, - useOverviewStaffTab, -} from "@/hooks/useOverview"; -import type { OverviewRange, OverviewTabKey } from "@/types/overview"; -import { OverviewBillingTabPanel } from "./tabs/OverviewBillingTabPanel"; -import { OverviewBookingsTabPanel } from "./tabs/OverviewBookingsTabPanel"; -import { OverviewContractsTabPanel } from "./tabs/OverviewContractsTabPanel"; -import { OverviewCustomersTabPanel } from "./tabs/OverviewCustomersTabPanel"; -import { OverviewFleetTabPanel } from "./tabs/OverviewFleetTabPanel"; -import { OverviewOperationsTabPanel } from "./tabs/OverviewOperationsTabPanel"; -import { OverviewStaffTabPanel } from "./tabs/OverviewStaffTabPanel"; - -function TabSkeleton() { - return ( - - - - - - ); -} - -interface OverviewTabContentProps { - tab: OverviewTabKey; - range: OverviewRange; -} - -export function OverviewTabContent({ tab, range }: OverviewTabContentProps) { - const bookings = useOverviewBookingsTab(range, tab === "bookings"); - const contracts = useOverviewContractsTab(range, tab === "contracts"); - const billing = useOverviewBillingTab(range, tab === "billing"); - // Fleet reuses the operations dataset — same query key, so switching between - // the two tabs costs one fetch. - const operations = useOverviewOperationsTab( - range, - tab === "operations" || tab === "fleet", - ); - const customers = useOverviewCustomersTab(range, tab === "customers"); - const staff = useOverviewStaffTab(range, tab === "staff"); - - const query = - tab === "bookings" - ? bookings - : tab === "contracts" - ? contracts - : tab === "billing" - ? billing - : tab === "operations" || tab === "fleet" - ? operations - : tab === "customers" - ? customers - : staff; - - const { isLoading, isError, refetch, isFetching } = query; - - if (isLoading) { - return ; - } - - if (isError || !query.data) { - return ( - - } - color="red" - title="Failed to load tab data" - variant="light" - > - - Could not load {tab} metrics. Please try again. - - - - - ); - } - - return ( - - {isFetching && ( -
- -
- )} - - {tab === "bookings" && bookings.data && ( - - )} - {tab === "contracts" && contracts.data && ( - - )} - {tab === "billing" && billing.data && ( - - )} - {tab === "operations" && operations.data && ( - - )} - {tab === "fleet" && operations.data && ( - - )} - {tab === "customers" && customers.data && ( - - )} - {tab === "staff" && staff.data && ( - - )} -
- ); -} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/overview-domains.config.ts b/apps/edr-freight-web/backoffice/src/components/overview/overview-domains.config.ts new file mode 100644 index 000000000..4d106eeeb --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/overview-domains.config.ts @@ -0,0 +1,88 @@ +import { + Banknote, + FileSignature, + FileText, + Train, + TrainFront, + UserCheck, + Users, + type LucideIcon, +} from "lucide-react"; + +import { FREIGHT_PERMS } from "@/lib/permissions"; +import type { OverviewTabKey } from "@/types/overview"; + +/** + * Single source of truth for the seven overview drill-down pages — used both + * to build the `/dashboard/overview/:domain` routes in App.tsx and to render + * each page's header in OverviewDomainPage. One list, no duplicated permission + * arrays to drift out of sync. + */ +export const OVERVIEW_DOMAINS: Array<{ + key: OverviewTabKey; + label: string; + subtitle: string; + icon: LucideIcon; + /** Any of these keys grants the page. */ + permission: string[]; +}> = [ + { + key: "bookings", + label: "Bookings", + subtitle: "Booking volume, pipeline, and recent activity", + icon: FileText, + permission: [FREIGHT_PERMS.bookings.view], + }, + { + key: "contracts", + label: "Contracts", + subtitle: "Contract volume, pipeline, and recent activity", + icon: FileSignature, + permission: [FREIGHT_PERMS.contracts.view], + }, + { + key: "billing", + label: "Billing", + subtitle: "Revenue, payments, and collection status", + icon: Banknote, + permission: [FREIGHT_PERMS.bookings.view, FREIGHT_PERMS.payments.view], + }, + { + key: "operations", + label: "Operations", + subtitle: "Trains, schedules, containers, and cargo", + icon: Train, + permission: [ + FREIGHT_PERMS.trainScheduling.view, + FREIGHT_PERMS.warehouseInventory.view, + FREIGHT_PERMS.firstMile.view, + FREIGHT_PERMS.lastMile.view, + ], + }, + { + key: "fleet", + label: "Fleet", + subtitle: "Wagon and train fleet status", + icon: TrainFront, + permission: [FREIGHT_PERMS.wagons.view, FREIGHT_PERMS.trainScheduling.view], + }, + { + key: "customers", + label: "Customers", + subtitle: "Customer growth and top accounts", + icon: Users, + permission: [FREIGHT_PERMS.customers.view], + }, + { + key: "staff", + label: "Staff", + subtitle: "Employee and user account status", + icon: UserCheck, + permission: [ + FREIGHT_PERMS.admin, + FREIGHT_PERMS.staff.roles.view, + FREIGHT_PERMS.staff.employeeRegistration.view, + FREIGHT_PERMS.staff.roleAssignment.view, + ], + }, +]; diff --git a/apps/edr-freight-web/backoffice/src/components/overview/overview.css b/apps/edr-freight-web/backoffice/src/components/overview/overview.css index 86b468df3..10b358bbd 100644 --- a/apps/edr-freight-web/backoffice/src/components/overview/overview.css +++ b/apps/edr-freight-web/backoffice/src/components/overview/overview.css @@ -1,5 +1,8 @@ /* ============================================================ - EDR Freight — Overview page styles (hero controls + tabs) + EDR Freight — shared "premium" tab bar + segmented control styles. + Named after the overview page they were first built for, but now shared + by BookingStatusTabs, ContractStatusTabs, and ReceiveInventoryModal — + do not remove `.ov-tablist` / `.ov-tab` without checking those importers. ============================================================ */ /* ---- Hero range segmented control (on gradient) ---- */ @@ -19,7 +22,7 @@ color: var(--mantine-color-edr-green-7); } -/* ---- Premium tab bar ---- */ +/* ---- Premium tab bar (BookingStatusTabs, ContractStatusTabs, ReceiveInventoryModal) ---- */ .ov-tablist { display: flex; flex-wrap: wrap; diff --git a/apps/edr-freight-web/backoffice/src/components/overview/summary/CountUp.tsx b/apps/edr-freight-web/backoffice/src/components/overview/summary/CountUp.tsx new file mode 100644 index 000000000..28802e321 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/summary/CountUp.tsx @@ -0,0 +1,44 @@ +import { useEffect, useRef, useState } from "react"; + +const DURATION_MS = 750; + +function prefersReducedMotion() { + return ( + typeof window !== "undefined" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches + ); +} + +/** + * Animates a number from 0 to `value` (ease-out) on mount and whenever the + * value changes — the hero-KPI count-up. Renders the final value immediately + * when the user prefers reduced motion. + */ +export function CountUp({ + value, + format = (n) => Math.round(n).toLocaleString(), +}: { + value: number; + format?: (n: number) => string; +}) { + const [display, setDisplay] = useState(() => (prefersReducedMotion() ? value : 0)); + const frame = useRef(0); + + useEffect(() => { + if (prefersReducedMotion()) { + setDisplay(value); + return; + } + const start = performance.now(); + const tick = (now: number) => { + const t = Math.min(1, (now - start) / DURATION_MS); + const eased = 1 - (1 - t) ** 3; + setDisplay(value * eased); + if (t < 1) frame.current = requestAnimationFrame(tick); + }; + frame.current = requestAnimationFrame(tick); + return () => cancelAnimationFrame(frame.current); + }, [value]); + + return <>{format(display)}; +} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewActivityHeatmap.tsx b/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewActivityHeatmap.tsx new file mode 100644 index 000000000..4f1958265 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewActivityHeatmap.tsx @@ -0,0 +1,125 @@ +import { Fragment } from "react"; +import { CalendarClock } from "lucide-react"; +import { Badge, Group, Stack, Text, Tooltip } from "@mantine/core"; + +import type { IOverviewHeatmapCell } from "@/types/overview"; +import { SummaryCard } from "./SummaryCard"; + +/** ISO weekday order, 1 = Monday. */ +const DAY_LABELS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; +/** 3-hour blocks, 0 = 00–03 … 7 = 21–24. */ +const BLOCK_LABELS = ["12a", "3a", "6a", "9a", "12p", "3p", "6p", "9p"]; + +/** Map an intensity in (0, 1] to the brand-green ramp; zero stays neutral. */ +function cellColor(count: number, max: number) { + if (count === 0) return "var(--mantine-color-gray-1)"; + const shade = Math.max(1, Math.min(7, Math.ceil((count / max) * 7))); + return `var(--mantine-color-edr-green-${shade})`; +} + +interface OverviewActivityHeatmapProps { + cells: IOverviewHeatmapCell[]; +} + +/** + * When demand arrives: booking submissions by weekday × 3-hour block for the + * selected range. The bright cells (and the peak badge) are the hours the + * intake team needs to be staffed for. + */ +export function OverviewActivityHeatmap({ cells }: OverviewActivityHeatmapProps) { + const countByCell = new Map(cells.map((c) => [`${c.dow}-${c.block}`, c.count])); + const max = Math.max(0, ...cells.map((c) => c.count)); + const peak = cells.reduce( + (best, c) => (c.count > (best?.count ?? 0) ? c : best), + null, + ); + + return ( + + Peak {DAY_LABELS[peak.dow - 1]} {BLOCK_LABELS[peak.block]} + + ) : null + } + > + {max === 0 ? ( + + No bookings in this period + + ) : ( + +
+ + {BLOCK_LABELS.map((label) => ( + + {label} + + ))} + {DAY_LABELS.map((day, dayIndex) => ( + + + {day} + + {BLOCK_LABELS.map((_, block) => { + const count = countByCell.get(`${dayIndex + 1}-${block}`) ?? 0; + return ( + +
+ + ); + })} + + ))} +
+ + + Less + + {[0, 2, 4, 6].map((shade) => ( +
+ ))} + + More + + + + )} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewAttentionCard.tsx b/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewAttentionCard.tsx new file mode 100644 index 000000000..142b8814f --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewAttentionCard.tsx @@ -0,0 +1,149 @@ +import { + AlertCircle, + Banknote, + BellRing, + Check, + ChevronRight, + Clock, + FileSignature, + ShieldCheck, +} from "lucide-react"; +import { Link } from "react-router-dom"; +import { Badge, Group, Stack, Text, ThemeIcon } from "@mantine/core"; + +import type { IOverviewBillingKpis, IOverviewBookingKpis, IOverviewContractKpis } from "@/types/overview"; +import { SummaryCard } from "./SummaryCard"; + +interface OverviewAttentionCardProps { + bookings: IOverviewBookingKpis; + contracts: IOverviewContractKpis; + billing: IOverviewBillingKpis; +} + +/** + * The work queue, demoted below the money/volume story but still one glance + * away — this page is read by executives first, staff second. Every row + * links to the real filtered (or closest available) list; none are dead ends. + */ +export function OverviewAttentionCard({ bookings, contracts, billing }: OverviewAttentionCardProps) { + const rows = [ + { + key: "needsAction", + label: "Bookings needing action", + count: bookings.needsAction, + icon: AlertCircle, + href: "/dashboard/booking-requests", + }, + { + key: "urgent", + label: "Urgent bookings", + count: bookings.urgent, + icon: Clock, + href: "/dashboard/booking-requests", + }, + { + key: "contractsApproval", + label: "Contracts in approval", + count: contracts.inApproval, + icon: FileSignature, + href: "/dashboard/contract-requests", + }, + { + key: "contractsClearance", + label: "Contracts in clearance", + count: contracts.inClearance, + icon: ShieldCheck, + href: "/dashboard/contracts/clearance", + }, + { + key: "pendingPayments", + label: "Pending payments", + count: billing.pendingPayments, + icon: Banknote, + href: "/dashboard/payments", + }, + ]; + const openItems = rows.reduce((sum, row) => sum + row.count, 0); + const allClear = openItems === 0; + + return ( + + {openItems.toLocaleString()} open + + ) + } + > + {allClear ? ( + + + + + All clear + + Nothing waiting on you right now. + + + ) : ( + + {rows.map((row) => { + const Icon = row.icon; + const active = row.count > 0; + return ( + + + + + + + + {row.label} + + + + + {row.count} + + + + + + ); + })} + + )} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewHero.tsx b/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewHero.tsx new file mode 100644 index 000000000..04e2b273f --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewHero.tsx @@ -0,0 +1,140 @@ +import { RefreshCw } from "lucide-react"; +import { ActionIcon, Badge, Group, SegmentedControl, Stack, Text } from "@mantine/core"; + +import { useAuth } from "@/auth/useAuth"; +import { freightBrand } from "@/theme/freight-brand"; +import type { OverviewRange } from "@/types/overview"; +import "@/components/overview/overview.css"; + +const RANGE_OPTIONS = [ + { label: "7 days", value: "7d" }, + { label: "30 days", value: "30d" }, + { label: "90 days", value: "90d" }, +]; + +function formatRelativeTime(iso: string | undefined) { + if (!iso) return "—"; + const diffMs = Date.now() - new Date(iso).getTime(); + const minutes = Math.floor(diffMs / 60_000); + if (minutes < 1) return "just now"; + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + return new Date(iso).toLocaleString(); +} + +function greeting(hour: number) { + if (hour < 12) return "Good morning"; + if (hour < 18) return "Good afternoon"; + return "Good evening"; +} + +interface OverviewHeroProps { + range: OverviewRange; + onRangeChange: (range: OverviewRange) => void; + generatedAt?: string; + onRefresh: () => void; + isRefreshing?: boolean; +} + +/** + * Brand-gradient greeting banner: time-of-day greeting with the signed-in + * user's first name, today's date, freshness badge, and the range controls. + * Extra bottom padding leaves room for the KPI strip to overlap it. + */ +export function OverviewHero({ + range, + onRangeChange, + generatedAt, + onRefresh, + isRefreshing, +}: OverviewHeroProps) { + const { user } = useAuth(); + const fullName = (user?.name?.en ?? user?.name?.am)?.trim(); + const firstName = fullName ? fullName.split(/\s+/)[0] : undefined; + const now = new Date(); + const dateLabel = now.toLocaleDateString(undefined, { + weekday: "long", + day: "numeric", + month: "long", + year: "numeric", + }); + + return ( +
+ {/* Soft highlight so the flat gradient reads as a lit surface. */} +
+ + + + {greeting(now.getHours())} + {firstName ? `, ${firstName}` : ""} 👋 + + + + {dateLabel} + + + Updated {formatRelativeTime(generatedAt)} + + + + + onRangeChange(value as OverviewRange)} + data={RANGE_OPTIONS} + size="sm" + radius="lg" + classNames={{ + root: "ov-seg-root", + indicator: "ov-seg-indicator", + label: "ov-seg-label", + }} + /> + + + + + +
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewHeroKpis.tsx b/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewHeroKpis.tsx new file mode 100644 index 000000000..32ee768d1 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewHeroKpis.tsx @@ -0,0 +1,67 @@ +import { Banknote, FileSignature, FileText, Package } from "lucide-react"; + +import { KpiStrip, type KpiItem } from "@/components/page"; +import type { IOverviewKpis, IOverviewPeriodTotals } from "@/types/overview"; +import { CountUp } from "./CountUp"; + +function formatCurrency(amount: number, currency: "ETB" | "USD") { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency, + maximumFractionDigits: 0, + }).format(amount); +} + +/** Period-over-period % change, or undefined when there's no prior baseline to compare against. */ +function pctDelta(current: number, previous: number): number | undefined { + if (previous === 0) return undefined; + return Math.round(((current - previous) / previous) * 100); +} + +interface OverviewHeroKpisProps { + kpis: IOverviewKpis; + current: IOverviewPeriodTotals; + previous: IOverviewPeriodTotals; + rangeLabel: string; +} + +/** + * The four numbers an executive reads first: money and volume for the + * selected range, plus what's currently in flight. Revenue and cargo carry a + * real vs-prior-period delta; the two workflow snapshots don't, because + * "active bookings/contracts" is a point-in-time gauge, not a period total — + * showing a delta for it would mean inventing a comparison that isn't real. + */ +export function OverviewHeroKpis({ kpis, current, previous, rangeLabel }: OverviewHeroKpisProps) { + const items: KpiItem[] = [ + { + label: `Revenue (${rangeLabel})`, + value: formatCurrency(n, "ETB")} />, + hint: formatCurrency(current.revenueUsd, "USD"), + icon: Banknote, + color: "yellow", + delta: pctDelta(current.revenueEtb, previous.revenueEtb), + }, + { + label: "Cargo moved", + value: `${Math.round(n).toLocaleString()} t`} />, + icon: Package, + color: "edr-green", + delta: pctDelta(current.tons, previous.tons), + }, + { + label: "Active bookings", + value: , + icon: FileText, + color: "edr-green", + }, + { + label: "Active contracts", + value: , + icon: FileSignature, + color: "edr-green", + }, + ]; + + return ; +} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewNetworkCard.tsx b/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewNetworkCard.tsx new file mode 100644 index 000000000..57a1a1fdc --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewNetworkCard.tsx @@ -0,0 +1,88 @@ +import { + CalendarClock, + Container as ContainerIcon, + Send, + Train, + TrainFront, +} from "lucide-react"; +import { Group, SimpleGrid, Stack, Text } from "@mantine/core"; + +import { MiniRing } from "@/components/common/MiniGraph"; +import type { IOverviewOperationsKpis } from "@/types/overview"; +import { CountUp } from "./CountUp"; +import { SummaryCard } from "./SummaryCard"; + +const STATS: Array<{ + key: keyof IOverviewOperationsKpis; + label: string; + icon: typeof Train; +}> = [ + { key: "trainsActive", label: "Trains active", icon: Train }, + { key: "dispatchedToday", label: "Dispatched today", icon: Send }, + { key: "schedulesUpcoming", label: "Upcoming departures", icon: CalendarClock }, + { key: "containersInTransit", label: "Containers in transit", icon: ContainerIcon }, +]; + +/** Network snapshot: four operational stats plus real wagon-utilization (available / total), not a decorative gauge. */ +export function OverviewNetworkCard({ kpis }: { kpis: IOverviewOperationsKpis }) { + const utilizationPct = kpis.wagonsTotal > 0 ? (kpis.wagonsAvailable / kpis.wagonsTotal) * 100 : null; + + return ( + + View fleet → + + } + > + + + + + {utilizationPct != null ? `${Math.round(utilizationPct)}%` : "—"} + + + + + Wagons available + + + {kpis.wagonsAvailable.toLocaleString()} + + {" "} + / {kpis.wagonsTotal.toLocaleString()} + + + + + + + {STATS.map((stat) => { + const Icon = stat.icon; + return ( + + + + + + + + {stat.label} + + + + ); + })} + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewPipelineFunnel.tsx b/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewPipelineFunnel.tsx new file mode 100644 index 000000000..f28540298 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewPipelineFunnel.tsx @@ -0,0 +1,113 @@ +import { Filter } from "lucide-react"; +import { Link } from "react-router-dom"; +import { Badge, Stack, Text } from "@mantine/core"; + +import { BOOKING_LIST_TABS } from "@/features/bookings/booking-status.config"; +import type { IOverviewPipelineCount } from "@/types/overview"; +import { SummaryCard } from "./SummaryCard"; + +/** Sequential green ramp from the theme's own edr-green scale — rising intensity as bookings progress through the pipeline. */ +const RAMP_SHADES = [3, 4, 5, 5, 6, 6, 7, 8, 9]; + +interface OverviewPipelineFunnelProps { + data: IOverviewPipelineCount[]; +} + +/** Booking pipeline by stage, in workflow order. Each row deep-links to the exact statuses it represents. */ +export function OverviewPipelineFunnel({ data }: OverviewPipelineFunnelProps) { + const rows = data + .map((item) => ({ + ...item, + tab: BOOKING_LIST_TABS.find((t) => t.key === item.stage), + })) + .filter((row) => row.tab); + const maxCount = Math.max(1, ...rows.map((r) => r.count)); + const total = rows.reduce((sum, r) => sum + r.count, 0); + const hasData = total > 0; + + return ( + + {total.toLocaleString()} in pipeline + + ) : null + } + > + {!hasData ? ( + + No bookings in pipeline + + ) : ( + + {rows.map((row, index) => { + const statuses = row.tab?.statuses; + const href = statuses?.length + ? `/dashboard/booking-requests?statuses=${statuses.join(",")}` + : "/dashboard/booking-requests"; + const shade = RAMP_SHADES[Math.min(index, RAMP_SHADES.length - 1)]; + + return ( + + + {row.tab?.label ?? row.stage} + +
+
+
+ + {row.count} + + + ); + })} + + )} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewRevenueMix.tsx b/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewRevenueMix.tsx new file mode 100644 index 000000000..c2839350b --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewRevenueMix.tsx @@ -0,0 +1,143 @@ +import { PieChart } from "lucide-react"; +import { Stack, Text } from "@mantine/core"; + +import type { IOverviewRevenueSlice } from "@/types/overview"; +import { DIRECTION_COLORS, FLOW_FALLBACK_COLOR, FREIGHT_TYPE_COLORS } from "./flow-colors"; +import { CountUp } from "./CountUp"; +import { SummaryCard } from "./SummaryCard"; + +function formatEtb(amount: number) { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "ETB", + maximumFractionDigits: 0, + }).format(amount); +} + +const DIRECTION_LABELS: Record = { + IMPORT: "Import", + EXPORT: "Export", + DOMESTIC: "Domestic", +}; + +const FREIGHT_TYPE_LABELS: Record = { + CONTAINER: "Container", + BULK: "Bulk", +}; + +/** One breakdown's proportion bar + legend, sized by ETB revenue (no FX rate exists to fold USD in). */ +function MixRow({ + title, + slices, + labels, + colors, +}: { + title: string; + slices: IOverviewRevenueSlice[]; + labels: Record; + colors: Record; +}) { + const total = slices.reduce((sum, s) => sum + s.amountEtb, 0); + + return ( + + + {title} + + {total === 0 ? ( + + No revenue in this period + + ) : ( + <> +
+ {slices + .filter((s) => s.amountEtb > 0) + .map((s) => ( +
+ ))} +
+ + {slices + .filter((s) => s.amountEtb > 0) + .map((s) => ( +
+ + + {labels[s.label] ?? s.label} + + {formatEtb(s.amountEtb)} + + {Math.round((s.amountEtb / total) * 100)}% + +
+ ))} +
+ + )} + + ); +} + +interface OverviewRevenueMixProps { + byDirection: IOverviewRevenueSlice[]; + byFreightType: IOverviewRevenueSlice[]; +} + +/** ETB revenue split two ways — trade direction and freight type — anchored by the range total. */ +export function OverviewRevenueMix({ byDirection, byFreightType }: OverviewRevenueMixProps) { + const total = byDirection.reduce((sum, s) => sum + s.amountEtb, 0); + + return ( + + +
+ + + + + attributed to a trade direction + +
+ + +
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewRevenueVolumeChart.tsx b/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewRevenueVolumeChart.tsx new file mode 100644 index 000000000..12b20a45e --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewRevenueVolumeChart.tsx @@ -0,0 +1,172 @@ +import { + Area, + Bar, + CartesianGrid, + ComposedChart, + Line, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import { ChartColumnBig } from "lucide-react"; +import { Group, Text } from "@mantine/core"; + +import type { IOverviewPaymentTrendPoint, IOverviewTrendPoint } from "@/types/overview"; +import { overviewChartColors } from "../overview.styles"; +import { chartAxisTick, chartGridStroke, chartTooltipStyle } from "./chart-style"; +import { mergeTrend } from "./mergeTrend"; +import { SummaryCard } from "./SummaryCard"; + +function formatDateLabel(date: string) { + return new Date(`${date}T00:00:00`).toLocaleDateString(undefined, { + month: "short", + day: "numeric", + }); +} + +function formatEtb(amount: number) { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "ETB", + maximumFractionDigits: 0, + }).format(amount); +} + +const compact = new Intl.NumberFormat("en-US", { notation: "compact" }); + +/** Dot-and-label legend row rendered in the card header instead of recharts' default. */ +function LegendDot({ color, dashed, label }: { color: string; dashed?: boolean; label: string }) { + return ( + + {dashed ? ( + + + + ) : ( + + )} + + {label} + + + ); +} + +interface OverviewRevenueVolumeChartProps { + bookingTrend: IOverviewTrendPoint[]; + paymentTrend: IOverviewPaymentTrendPoint[]; + previousPaymentTrend: IOverviewPaymentTrendPoint[]; + rangeDays: number; +} + +/** + * Volume and money in one read: bars are bookings created per day, the gold + * area is ETB revenue per day, and the dashed ghost line is the preceding + * period's revenue shifted onto the same axis — "are we pacing ahead of last + * period" at a glance. + */ +export function OverviewRevenueVolumeChart({ + bookingTrend, + paymentTrend, + previousPaymentTrend, + rangeDays, +}: OverviewRevenueVolumeChartProps) { + const data = mergeTrend(bookingTrend, paymentTrend, previousPaymentTrend, rangeDays); + const hasData = data.some((point) => point.bookings > 0 || point.revenueEtb > 0); + + return ( + + + + + + } + > + {!hasData ? ( + + No activity in this period + + ) : ( + + + + + + + + + + + + compact.format(Number(value))} + tick={chartAxisTick} + axisLine={false} + tickLine={false} + width={44} + /> + formatDateLabel(String(value))} + formatter={(value, name) => + name === "Bookings" ? [value, name] : [formatEtb(Number(value)), name] + } + contentStyle={chartTooltipStyle} + /> + + + + + + )} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewSankeyFlow.tsx b/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewSankeyFlow.tsx new file mode 100644 index 000000000..1e4597ee9 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewSankeyFlow.tsx @@ -0,0 +1,170 @@ +import { Waypoints } from "lucide-react"; +import { ResponsiveContainer, Sankey, Tooltip, type SankeyLinkProps } from "recharts"; +import { Text } from "@mantine/core"; + +import type { IOverviewRevenueFlow } from "@/types/overview"; +import { chartTooltipStyle } from "./chart-style"; +import { SummaryCard } from "./SummaryCard"; +import { + DIRECTION_COLORS, + FLOW_FALLBACK_COLOR, + FLOW_LABELS, + FREIGHT_TYPE_COLORS, +} from "./flow-colors"; + +function formatEtb(amount: number) { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "ETB", + maximumFractionDigits: 0, + }).format(amount); +} + +interface SankeyNodeDatum { + name: string; + key: string; + color: string; +} + +/** Build recharts Sankey data: direction nodes on the left, freight types on the right. */ +function toSankeyData(flows: IOverviewRevenueFlow[]) { + const active = flows.filter((f) => f.amountEtb > 0); + const nodes: SankeyNodeDatum[] = []; + const indexByKey = new Map(); + const nodeIndex = (key: string, color: string) => { + const existing = indexByKey.get(key); + if (existing != null) return existing; + nodes.push({ name: FLOW_LABELS[key] ?? key, key, color }); + indexByKey.set(key, nodes.length - 1); + return nodes.length - 1; + }; + + // Register directions first so they all land on the left column. + for (const flow of active) { + nodeIndex(flow.direction, DIRECTION_COLORS[flow.direction] ?? FLOW_FALLBACK_COLOR); + } + const links = active.map((flow) => ({ + source: indexByKey.get(flow.direction)!, + target: nodeIndex( + flow.freightType, + FREIGHT_TYPE_COLORS[flow.freightType] ?? FLOW_FALLBACK_COLOR, + ), + value: flow.amountEtb, + })); + + return { nodes, links }; +} + +function FlowNode({ + x, + y, + width, + height, + index, + payload, +}: { + x: number; + y: number; + width: number; + height: number; + index: number; + payload: { name?: string; value?: number; color?: string }; +}) { + // Labels sit to the right of every bar: the right margin reserves room for + // the last column, and the pale ribbons stay readable under the left one. + return ( + + + + {payload.name} + + + {formatEtb(payload.value ?? 0)} + + + ); +} + +/** Ribbon tinted by its source direction — the corridor keeps its color across the chart. */ +function FlowLink({ + sourceX, + targetX, + sourceY, + targetY, + sourceControlX, + targetControlX, + linkWidth, + index, + payload, +}: SankeyLinkProps) { + // Custom node fields (color) ride along on the layout node recharts hands back. + const source = payload.source as { color?: string }; + return ( + + ); +} + +interface OverviewSankeyFlowProps { + flows: IOverviewRevenueFlow[]; +} + +/** + * Where the money runs: ETB revenue as ribbons from trade direction to + * freight type. Ribbon thickness is proportional to revenue, so the biggest + * corridor is unmissable. + */ +export function OverviewSankeyFlow({ flows }: OverviewSankeyFlowProps) { + const data = toSankeyData(flows); + + return ( + + {data.links.length === 0 ? ( + + No revenue in this period + + ) : ( + + + formatEtb(Number(value))} + contentStyle={chartTooltipStyle} + /> + + + )} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/summary/SummaryCard.tsx b/apps/edr-freight-web/backoffice/src/components/overview/summary/SummaryCard.tsx new file mode 100644 index 000000000..cfd022fe9 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/summary/SummaryCard.tsx @@ -0,0 +1,65 @@ +import type { LucideIcon } from "lucide-react"; +import { Group, Stack, Text, ThemeIcon } from "@mantine/core"; +import type { ElementType, ReactNode } from "react"; +import { Link } from "react-router-dom"; + +import "./overview-summary.css"; + +interface SummaryCardProps { + icon: LucideIcon; + /** Mantine color for the icon chip. */ + accent?: string; + title: string; + subtitle?: string; + /** Right side of the header — a legend, badge, or link. */ + action?: ReactNode; + /** Makes the whole card a link (adds the hover lift). */ + to?: string; + minHeight?: number; + children: ReactNode; +} + +/** + * Shared chrome for every overview card: soft gradient surface, layered + * shadow, icon-chip header with title/subtitle, optional action slot. + * One look for the whole page instead of eight flat white boxes. + */ +export function SummaryCard({ + icon: Icon, + accent = "edr-green", + title, + subtitle, + action, + to, + minHeight = 340, + children, +}: SummaryCardProps) { + const Root: ElementType = to ? Link : "div"; + return ( + + + + + + + + + {title} + + {subtitle ? ( + + {subtitle} + + ) : null} + + + {action} + + {children} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/summary/chart-style.ts b/apps/edr-freight-web/backoffice/src/components/overview/summary/chart-style.ts new file mode 100644 index 000000000..9f7dbae8d --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/summary/chart-style.ts @@ -0,0 +1,14 @@ +import type { CSSProperties } from "react"; + +/** Shared recharts styling for the overview summary charts. */ +export const chartGridStroke = "#EEF1F5"; + +export const chartAxisTick = { fontSize: 11, fill: "#8fa0b2" } as const; + +export const chartTooltipStyle: CSSProperties = { + borderRadius: 12, + border: "1px solid #EEF1F5", + boxShadow: "0 8px 24px rgba(16, 32, 47, 0.1)", + fontSize: 12, + padding: "8px 12px", +}; diff --git a/apps/edr-freight-web/backoffice/src/components/overview/summary/flow-colors.ts b/apps/edr-freight-web/backoffice/src/components/overview/summary/flow-colors.ts new file mode 100644 index 000000000..f3c7a2ace --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/summary/flow-colors.ts @@ -0,0 +1,25 @@ +/** + * Fixed colors + labels for trade directions and freight types, shared by the + * revenue mix and Sankey so the same entity is always the same color (and + * matches the operations departure chart's direction hexes). + */ +export const DIRECTION_COLORS: Record = { + EXPORT: "#D98A0B", + IMPORT: "#0369a1", + DOMESTIC: "#7c3aed", +}; + +export const FREIGHT_TYPE_COLORS: Record = { + CONTAINER: "#1B9E7A", + BULK: "#34D9AE", +}; + +export const FLOW_LABELS: Record = { + IMPORT: "Import", + EXPORT: "Export", + DOMESTIC: "Domestic", + CONTAINER: "Container", + BULK: "Bulk", +}; + +export const FLOW_FALLBACK_COLOR = "#94a3b8"; diff --git a/apps/edr-freight-web/backoffice/src/components/overview/summary/mergeTrend.test.ts b/apps/edr-freight-web/backoffice/src/components/overview/summary/mergeTrend.test.ts new file mode 100644 index 000000000..369b19237 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/summary/mergeTrend.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; + +import { mergeTrend } from "./mergeTrend"; + +describe("mergeTrend", () => { + it("unions dates from both trends, zero-filling the side that has no row", () => { + const result = mergeTrend( + [ + { date: "2026-06-01", count: 3 }, + { date: "2026-06-02", count: 5 }, + ], + [{ date: "2026-06-02", amountEtb: 1000, amountUsd: 0 }], + ); + + expect(result).toEqual([ + { date: "2026-06-01", bookings: 3, revenueEtb: 0 }, + { date: "2026-06-02", bookings: 5, revenueEtb: 1000 }, + ]); + }); + + it("sorts chronologically regardless of input order", () => { + const result = mergeTrend( + [{ date: "2026-06-03", count: 1 }], + [{ date: "2026-06-01", amountEtb: 500, amountUsd: 0 }], + ); + + expect(result.map((p) => p.date)).toEqual(["2026-06-01", "2026-06-03"]); + }); + + it("returns an empty series when both trends are empty", () => { + expect(mergeTrend([], [])).toEqual([]); + }); + + it("shifts the previous period forward so day N lands on day N of the current window", () => { + const result = mergeTrend( + [{ date: "2026-06-08", count: 2 }], + [], + [ + // 7d range: 2026-06-01 + 7 = 2026-06-08 (existing row), 06-02 + 7 = 06-09 (new row) + { date: "2026-06-01", amountEtb: 400, amountUsd: 0 }, + { date: "2026-06-02", amountEtb: 250, amountUsd: 0 }, + ], + 7, + ); + + expect(result).toEqual([ + { date: "2026-06-08", bookings: 2, revenueEtb: 0, prevRevenueEtb: 400 }, + { date: "2026-06-09", bookings: 0, revenueEtb: 0, prevRevenueEtb: 250 }, + ]); + }); + + it("shifts across a month boundary without timezone drift", () => { + const result = mergeTrend([], [], [{ date: "2026-05-28", amountEtb: 100, amountUsd: 0 }], 7); + expect(result[0].date).toBe("2026-06-04"); + }); +}); diff --git a/apps/edr-freight-web/backoffice/src/components/overview/summary/mergeTrend.ts b/apps/edr-freight-web/backoffice/src/components/overview/summary/mergeTrend.ts new file mode 100644 index 000000000..0ddc0d705 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/summary/mergeTrend.ts @@ -0,0 +1,63 @@ +import type { IOverviewPaymentTrendPoint, IOverviewTrendPoint } from "@/types/overview"; + +export interface RevenueVolumePoint { + date: string; + bookings: number; + revenueEtb: number; + /** Same-offset day of the preceding period — the ghost comparison line. */ + prevRevenueEtb?: number; +} + +/** `date` (YYYY-MM-DD) plus `days` days, in UTC so no DST/timezone drift. */ +export function shiftDate(date: string, days: number): string { + const parsed = new Date(`${date}T00:00:00Z`); + parsed.setUTCDate(parsed.getUTCDate() + days); + return parsed.toISOString().slice(0, 10); +} + +/** + * Merge the booking-count trend and the payment trend into one date-keyed + * series for the combined volume/revenue chart. Both trends only carry rows + * for days with activity (no zero-filled gaps), so this unions the dates + * rather than assuming they line up. + * + * When the previous period's payment trend is provided, each of its days is + * shifted forward by `shiftDays` (the range length) so day N of the prior + * window lands on day N of the current one, and lands in `prevRevenueEtb`. + */ +export function mergeTrend( + bookingTrend: IOverviewTrendPoint[], + paymentTrend: IOverviewPaymentTrendPoint[], + previousPaymentTrend: IOverviewPaymentTrendPoint[] = [], + shiftDays = 0, +): RevenueVolumePoint[] { + const byDate = new Map(); + + for (const point of bookingTrend) { + byDate.set(point.date, { date: point.date, bookings: point.count, revenueEtb: 0 }); + } + for (const point of paymentTrend) { + const existing = byDate.get(point.date); + if (existing) { + existing.revenueEtb = point.amountEtb; + } else { + byDate.set(point.date, { date: point.date, bookings: 0, revenueEtb: point.amountEtb }); + } + } + for (const point of previousPaymentTrend) { + const date = shiftDate(point.date, shiftDays); + const existing = byDate.get(date); + if (existing) { + existing.prevRevenueEtb = point.amountEtb; + } else { + byDate.set(date, { + date, + bookings: 0, + revenueEtb: 0, + prevRevenueEtb: point.amountEtb, + }); + } + } + + return [...byDate.values()].sort((a, b) => a.date.localeCompare(b.date)); +} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/summary/overview-summary.css b/apps/edr-freight-web/backoffice/src/components/overview/summary/overview-summary.css new file mode 100644 index 000000000..0391010b8 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/summary/overview-summary.css @@ -0,0 +1,65 @@ +/* Staggered fade-up entrance for the overview bands. Delay is set inline per + band; disabled entirely for reduced-motion users. */ +.ov-band { + animation: ov-rise 420ms ease-out both; +} + +@keyframes ov-rise { + from { + opacity: 0; + transform: translateY(14px); + } + to { + opacity: 1; + transform: none; + } +} + +@media (prefers-reduced-motion: reduce) { + .ov-band { + animation: none; + } +} + +/* ---- Shared card chrome (SummaryCard) ---- */ +.ov-card { + display: block; + height: 100%; + background: linear-gradient(180deg, #ffffff 0%, #fbfdfc 100%); + border: 1px solid var(--mantine-color-edr-border-0); + border-radius: 20px; + padding: 20px; + color: inherit; + text-decoration: none; + box-shadow: + 0 1px 2px rgba(16, 32, 47, 0.04), + 0 12px 32px -18px rgba(16, 32, 47, 0.14); + transition: + box-shadow 180ms ease, + transform 180ms ease, + border-color 180ms ease; +} +/* The lift is a click affordance — only linked cards get it. */ +.ov-card--link:hover { + box-shadow: + 0 2px 4px rgba(16, 32, 47, 0.05), + 0 20px 44px -18px rgba(16, 32, 47, 0.2); + border-color: var(--mantine-color-edr-green-2); + transform: translateY(-2px); +} + +/* Soft inset panel for grouping content inside a card. */ +.ov-inset { + background: var(--mantine-color-gray-0); + border: 1px solid var(--mantine-color-gray-1); + border-radius: 14px; +} + +/* Interactive list row inside a card. */ +.ov-row { + border-radius: 12px; + transition: background 140ms ease; +} +.ov-row:hover { + background: var(--mantine-color-gray-0); +} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewBookingsTabPanel.tsx b/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewBookingsTabPanel.tsx index cacba63e1..696741873 100644 --- a/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewBookingsTabPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewBookingsTabPanel.tsx @@ -59,12 +59,6 @@ export function OverviewBookingsTabPanel({ data }: OverviewBookingsTabPanelProps accent: "sky", hint: "Pending sign-off", }, - { - label: "Submitted today", - value: data.kpis.submittedToday, - icon: FileText, - hint: "New since midnight", - }, ]} /> diff --git a/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewContractsTabPanel.tsx b/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewContractsTabPanel.tsx index e185f40d2..9265da37d 100644 --- a/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewContractsTabPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewContractsTabPanel.tsx @@ -76,12 +76,6 @@ export function OverviewContractsTabPanel({ accent: "rose", hint: "Customs / documents", }, - { - label: "Created today", - value: data.kpis.createdToday, - icon: FileSignature, - hint: "New since midnight", - }, ]} /> diff --git a/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewOperationsTabPanel.tsx b/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewOperationsTabPanel.tsx index 675a81b32..b335ccf9c 100644 --- a/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewOperationsTabPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewOperationsTabPanel.tsx @@ -1,5 +1,4 @@ import { - Box, CalendarClock, Container as ContainerIcon, Send, @@ -78,11 +77,6 @@ export function OverviewOperationsTabPanel({ data }: OverviewOperationsTabPanelP value: data.kpis.containersInTransit, icon: ContainerIcon, }, - { - label: "Cargoes loaded", - value: data.kpis.cargoesLoaded, - icon: Box, - }, ]} /> diff --git a/apps/edr-freight-web/backoffice/src/components/page/KpiStrip.tsx b/apps/edr-freight-web/backoffice/src/components/page/KpiStrip.tsx index 6b90d6c8a..01a80db61 100644 --- a/apps/edr-freight-web/backoffice/src/components/page/KpiStrip.tsx +++ b/apps/edr-freight-web/backoffice/src/components/page/KpiStrip.tsx @@ -103,8 +103,16 @@ export function KpiStrip({ items, loading = false }: KpiStripProps) { component="span" fz="xs" fw={700} - c={item.delta > 0 ? "edr-green" : "red"} - style={{ whiteSpace: "nowrap" }} + c={item.delta > 0 ? "edr-green.7" : "red.7"} + style={{ + whiteSpace: "nowrap", + background: + item.delta > 0 + ? "var(--mantine-color-edr-green-0)" + : "var(--mantine-color-red-0)", + borderRadius: 999, + padding: "1px 7px", + }} > {item.delta > 0 ? "▲" : "▼"} {Math.abs(item.delta)} diff --git a/apps/edr-freight-web/backoffice/src/pages/dashboard/OverviewDomainPage.tsx b/apps/edr-freight-web/backoffice/src/pages/dashboard/OverviewDomainPage.tsx new file mode 100644 index 000000000..25ede8b4e --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/dashboard/OverviewDomainPage.tsx @@ -0,0 +1,145 @@ +import { useState } from "react"; +import { useParams } from "react-router-dom"; +import { AlertCircle, RefreshCw } from "lucide-react"; +import { ActionIcon, Alert, Button, Center, Loader, Paper, SegmentedControl, Skeleton, Stack } from "@mantine/core"; + +import { PageContainer, PageHeader } from "@/components/page"; +import { OVERVIEW_DOMAINS } from "@/components/overview/overview-domains.config"; +import { OverviewBillingTabPanel } from "@/components/overview/tabs/OverviewBillingTabPanel"; +import { OverviewBookingsTabPanel } from "@/components/overview/tabs/OverviewBookingsTabPanel"; +import { OverviewContractsTabPanel } from "@/components/overview/tabs/OverviewContractsTabPanel"; +import { OverviewCustomersTabPanel } from "@/components/overview/tabs/OverviewCustomersTabPanel"; +import { OverviewFleetTabPanel } from "@/components/overview/tabs/OverviewFleetTabPanel"; +import { OverviewOperationsTabPanel } from "@/components/overview/tabs/OverviewOperationsTabPanel"; +import { OverviewStaffTabPanel } from "@/components/overview/tabs/OverviewStaffTabPanel"; +import { + useOverviewBillingTab, + useOverviewBookingsTab, + useOverviewContractsTab, + useOverviewCustomersTab, + useOverviewOperationsTab, + useOverviewStaffTab, +} from "@/hooks/useOverview"; +import type { OverviewRange, OverviewTabKey } from "@/types/overview"; + +const RANGE_OPTIONS = [ + { label: "7 days", value: "7d" }, + { label: "30 days", value: "30d" }, + { label: "90 days", value: "90d" }, +]; + +function DomainSkeleton() { + return ( + + + + + + ); +} + +/** + * One domain's full depth — what used to be a tab panel on the overview page + * is now its own page, reached from that domain's "View all →" link. Same + * per-domain hooks and panel components as before; only the tab-switch + * wrapper (OverviewTabContent) is gone, replaced by a route param. + */ +export default function OverviewDomainPage() { + const { domain } = useParams<{ domain: OverviewTabKey }>(); + const [range, setRange] = useState("30d"); + + const meta = OVERVIEW_DOMAINS.find((d) => d.key === domain); + + const bookings = useOverviewBookingsTab(range, domain === "bookings"); + const contracts = useOverviewContractsTab(range, domain === "contracts"); + const billing = useOverviewBillingTab(range, domain === "billing"); + // Fleet reuses the operations dataset — same query key, so switching between + // the two pages costs no extra fetch. + const operations = useOverviewOperationsTab( + range, + domain === "operations" || domain === "fleet", + ); + const customers = useOverviewCustomersTab(range, domain === "customers"); + const staff = useOverviewStaffTab(range, domain === "staff"); + + const query = + domain === "bookings" + ? bookings + : domain === "contracts" + ? contracts + : domain === "billing" + ? billing + : domain === "operations" || domain === "fleet" + ? operations + : domain === "customers" + ? customers + : staff; + + const { isLoading, isError, refetch, isFetching } = query; + + return ( + + + setRange(value as OverviewRange)} + data={RANGE_OPTIONS} + size="sm" + radius="lg" + color="edr-green" + /> + void refetch()} + loading={isFetching && !isLoading} + > + + + + } + /> + + {isLoading ? ( + + ) : isError || !query.data ? ( + + } color="red" title="Failed to load" variant="light"> + + Could not load {meta?.label ?? "this"} metrics. Please try again. + + + + + ) : ( + + {isFetching && ( +
+ +
+ )} + + {domain === "bookings" && bookings.data && } + {domain === "contracts" && contracts.data && } + {domain === "billing" && billing.data && } + {domain === "operations" && operations.data && ( + + )} + {domain === "fleet" && operations.data && } + {domain === "customers" && customers.data && } + {domain === "staff" && staff.data && } +
+ )} +
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/dashboard/OverviewPage.tsx b/apps/edr-freight-web/backoffice/src/pages/dashboard/OverviewPage.tsx index 008149adb..aa29fa0cf 100644 --- a/apps/edr-freight-web/backoffice/src/pages/dashboard/OverviewPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/dashboard/OverviewPage.tsx @@ -1,141 +1,61 @@ import { useState } from "react"; -import { - AlertCircle, - Banknote, - FileSignature, - FileText, - Train, - TrainFront, - UserCheck, - Users, -} from "lucide-react"; -import { - Alert, - Badge, - Button, - Container, - Skeleton, - Stack, - Tabs, -} from "@mantine/core"; +import { AlertCircle } from "lucide-react"; +import { Alert, Button, Grid, Skeleton, Stack, Text } from "@mantine/core"; import { useQueryClient } from "@tanstack/react-query"; -import { useAuth } from "@/auth/useAuth"; -import { OverviewPageHeader } from "@/components/overview/OverviewPageHeader"; -import { OverviewTabContent } from "@/components/overview/OverviewTabContent"; -import "@/components/overview/overview.css"; +import { PageContainer } from "@/components/page"; +import { OverviewActivityHeatmap } from "@/components/overview/summary/OverviewActivityHeatmap"; +import { OverviewAttentionCard } from "@/components/overview/summary/OverviewAttentionCard"; +import { OverviewHero } from "@/components/overview/summary/OverviewHero"; +import { OverviewHeroKpis } from "@/components/overview/summary/OverviewHeroKpis"; +import { OverviewNetworkCard } from "@/components/overview/summary/OverviewNetworkCard"; +import { OverviewPipelineFunnel } from "@/components/overview/summary/OverviewPipelineFunnel"; +import { OverviewRevenueMix } from "@/components/overview/summary/OverviewRevenueMix"; +import { OverviewRevenueVolumeChart } from "@/components/overview/summary/OverviewRevenueVolumeChart"; +import { OverviewSankeyFlow } from "@/components/overview/summary/OverviewSankeyFlow"; import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; import { useOverview } from "@/hooks/useOverview"; -import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; -import type { OverviewRange, OverviewTabKey } from "@/types/overview"; +import type { OverviewRange } from "@/types/overview"; +import "@/components/overview/summary/overview-summary.css"; -const TAB_ITEMS: Array<{ - value: OverviewTabKey; - label: string; - icon: typeof FileText; - kpiKey: - | "bookings" - | "contracts" - | "billing" - | "operations" - | "customers" - | "staff"; - metricKey: string; - /** Any of these keys grants the tab. */ - permission: string[]; -}> = [ - { - value: "bookings", - label: "Bookings", - icon: FileText, - kpiKey: "bookings", - metricKey: "totalActive", - permission: [FREIGHT_PERMS.bookings.view], - }, - { - value: "contracts", - label: "Contracts", - icon: FileSignature, - kpiKey: "contracts", - metricKey: "totalActive", - permission: [FREIGHT_PERMS.contracts.view], - }, - { - value: "billing", - label: "Billing", - icon: Banknote, - kpiKey: "billing", - metricKey: "successfulPaymentsMtd", - permission: [FREIGHT_PERMS.bookings.view, FREIGHT_PERMS.payments.view], - }, - { - value: "operations", - label: "Operations", - icon: Train, - kpiKey: "operations", - metricKey: "trainsActive", - permission: [ - FREIGHT_PERMS.trainScheduling.view, - FREIGHT_PERMS.warehouseInventory.view, - FREIGHT_PERMS.firstMile.view, - FREIGHT_PERMS.lastMile.view, - ], - }, - { - value: "fleet", - label: "Fleet", - icon: TrainFront, - kpiKey: "operations", - metricKey: "wagonsAvailable", - permission: [FREIGHT_PERMS.wagons.view, FREIGHT_PERMS.trainScheduling.view], - }, - { - value: "customers", - label: "Customers", - icon: Users, - kpiKey: "customers", - metricKey: "totalCustomers", - permission: [FREIGHT_PERMS.customers.view], - }, - { - value: "staff", - label: "Staff", - icon: UserCheck, - kpiKey: "staff", - metricKey: "activeEmployees", - permission: [ - FREIGHT_PERMS.admin, - FREIGHT_PERMS.staff.roles.view, - FREIGHT_PERMS.staff.employeeRegistration.view, - FREIGHT_PERMS.staff.roleAssignment.view, - ], - }, -]; +const RANGE_LABEL: Record = { "7d": "7d", "30d": "30d", "90d": "90d" }; +const RANGE_DAYS: Record = { "7d": 7, "30d": 30, "90d": 90 }; -function HeaderSkeleton() { +/** Uppercase section eyebrow — matches the WarehouseDashboardPage convention. */ +function SectionTitle({ children }: { children: string }) { return ( - - - + + {children} + + ); +} + +/** One page band: eyebrow + content, with a staggered entrance by index. */ +function Band({ index, title, children }: { index: number; title: string; children: React.ReactNode }) { + return ( + + {title} + {children} + + ); +} + +function OverviewSkeleton() { + return ( + + + + + ); } const OverviewPage = () => { const [range, setRange] = useState("30d"); - const [activeTab, setActiveTab] = useState("bookings"); const queryClient = useQueryClient(); - const { user } = useAuth(); - const { data: summary, isLoading, isError, error, refetch, isFetching } = useOverview(range); + const { data, isLoading, isError, error, refetch, isFetching } = useOverview(range); - // Permission-scoped view: only tabs the user may see; a restricted role - // (e.g. operations) gets a summary 403 — that is not a connection problem. - const visibleTabs = TAB_ITEMS.filter((tab) => - tab.permission.some((key) => hasPermission(user, key)), - ); - const currentTab = visibleTabs.some((t) => t.value === activeTab) - ? activeTab - : visibleTabs[0]?.value; const accessDenied = (error as { response?: { status?: number } } | null)?.response?.status === 403; @@ -144,97 +64,113 @@ const OverviewPage = () => { void queryClient.invalidateQueries({ queryKey: QUERY_KEYS.OVERVIEW.ROOT }); }; - const getTabBadge = (tab: (typeof TAB_ITEMS)[number]) => { - if (!summary?.kpis) return 0; - const group = summary.kpis[tab.kpiKey] as unknown as Record; - return group[tab.metricKey] ?? 0; - }; - return ( - - - {isLoading && !summary ? ( - - ) : ( - - )} + + {/* Gradient greeting hero; the KPI strip overlaps its bottom edge. */} +
+ + {data ? ( +
+ +
+ ) : null} +
- {isError && !accessDenied && ( - } - color="red" - title="Unable to load dashboard summary" - variant="light" - > - - Check your connection and try again. - - - - )} + {isError && !accessDenied && ( + } + color="red" + title="Unable to load dashboard summary" + variant="light" + mt="lg" + > + + Check your connection and try again. + + + + )} - {visibleTabs.length === 0 && !isLoading && !isError && ( - - Your role has no access to any overview section. - - )} + {accessDenied && ( + + Your role has no access to the overview. + + )} - {visibleTabs.length > 0 && ( - - setActiveTab((value as OverviewTabKey) ?? visibleTabs[0].value) - } - variant="pills" - color="edr-green" - keepMounted={false} - classNames={{ list: "ov-tablist", tab: "ov-tab" }} - > - - {visibleTabs.map((tab) => { - const Icon = tab.icon; - const isActive = currentTab === tab.value; - return ( - } - rightSection={ - summary ? ( - - {getTabBadge(tab)} - - ) : undefined - } - > - {tab.label} - - ); - })} - + {isLoading && !data ? ( + + + + ) : data ? ( + + {/* Band 1 — revenue & volume: growing, making money, pacing vs last period. */} + + + + + + + + + + - {visibleTabs.map((tab) => ( - - - - ))} - - )} -
-
+ {/* Band 2 — where the money runs, and what's waiting on someone. */} + + + + + + + + + + + + {/* Band 3 — the network now, and when demand arrives. */} + + + + + + + + + + + + {/* Band 4 — the booking pipeline, full width so every stage bar has room. */} + + + +
+ ) : null} + ); }; diff --git a/packages/types/src/freight/overview.ts b/packages/types/src/freight/overview.ts index e3d98bc00..4972db10d 100644 --- a/packages/types/src/freight/overview.ts +++ b/packages/types/src/freight/overview.ts @@ -13,6 +13,7 @@ export interface IOverviewBookingKpis { export interface IOverviewOperationsKpis { trainsActive: number; wagonsAvailable: number; + wagonsTotal: number; containersInTransit: number; cargoesLoaded: number; schedulesUpcoming: number; @@ -99,13 +100,60 @@ export interface IOverviewRecentContract { createdAt: string; } +/** Bookings/revenue/tonnage totals for one range-wide window. */ +export interface IOverviewPeriodTotals { + bookingsCreated: number; + revenueEtb: number; + revenueUsd: number; + tons: number; +} + +/** One label's revenue split, e.g. a trade direction or freight type. */ +export interface IOverviewRevenueSlice { + label: string; + amountEtb: number; + amountUsd: number; +} + +export interface IOverviewTonsTrendPoint { + date: string; + tons: number; +} + +/** One direction → freight-type revenue flow (Sankey link). */ +export interface IOverviewRevenueFlow { + direction: string; + freightType: string; + amountEtb: number; + amountUsd: number; +} + +/** Booking arrivals for one weekday × 3-hour block. */ +export interface IOverviewHeatmapCell { + /** ISO weekday, 1 = Monday … 7 = Sunday. */ + dow: number; + /** 3-hour block, 0 = 00–03 … 7 = 21–24. */ + block: number; + count: number; +} + export interface IOverviewDashboard { kpis: IOverviewKpis; bookingTrend: IOverviewTrendPoint[]; bookingsByStatus: IOverviewStatusCount[]; bookingsByPipeline: IOverviewPipelineCount[]; paymentTrend: IOverviewPaymentTrendPoint[]; - recentBookings: IOverviewRecentBooking[]; + /** Totals for the selected range, ending today. */ + current: IOverviewPeriodTotals; + /** Totals for the immediately preceding range of the same length — the delta baseline. */ + previous: IOverviewPeriodTotals; + revenueByDirection: IOverviewRevenueSlice[]; + revenueByFreightType: IOverviewRevenueSlice[]; + /** The preceding same-length window's daily revenue — ghost-line comparison. */ + previousPaymentTrend: IOverviewPaymentTrendPoint[]; + tonsTrend: IOverviewTonsTrendPoint[]; + revenueFlows: IOverviewRevenueFlow[]; + bookingHeatmap: IOverviewHeatmapCell[]; generatedAt: string; } From 9fff469ffa8733bd674c952de28ca198fa5dab70 Mon Sep 17 00:00:00 2001 From: marshalyordanos Date: Thu, 13 Aug 2026 15:54:40 +0300 Subject: [PATCH 3/8] feat: implement shipping line bookings management - Add ShippingLineBookingsPage for listing and managing shipping line bookings. - Create ShippingLineDocumentsModal for document uploads related to bookings. - Introduce ShippingLineInitiateModal for initiating new shipping line bookings. - Implement booking document state management with booking-doc-state utility. - Add shipping line bookings service for API interactions. - Update index to export new components and services. - Enhance types for freight to include shipping line credits. --- .../3450000000000-BookingShippingLine.ts | 117 +++++ .../3460000000000-ShippingLineCredits.ts | 201 +++++++++ .../3470000000000-ShippingLineRates.ts | 127 ++++++ .../src/modules/billing/billing.module.ts | 4 +- .../src/modules/billing/billing.service.ts | 41 +- .../billing/entities/invoice.entity.ts | 25 +- .../bookings/booking-pricing.service.ts | 25 +- .../src/modules/bookings/clearance.util.ts | 24 + .../bookings/entities/booking.entity.ts | 37 +- .../src/modules/companies/companies.module.ts | 5 +- .../rule-engine/dto/create-rate.dto.ts | 8 + .../dto/list-rule-engine-query.dto.ts | 16 + .../rule-engine/entities/rate.entity.ts | 19 + .../interfaces/rates.repository.interface.ts | 2 + .../repositories/rates.repository.ts | 27 +- .../modules/rule-engine/rule-engine.module.ts | 4 + .../rule-engine/rule-engine.service.spec.ts | 142 ++++++ .../rule-engine/rule-engine.service.ts | 37 +- .../services/rates.duplicate-pattern.spec.ts | 2 + .../rule-engine/services/rates.service.ts | 52 +++ .../dto/cancel-shipping-line-booking.dto.ts | 15 + .../dto/initiate-shipping-line-booking.dto.ts | 38 ++ .../dto/shipping-line-credit.dto.ts | 51 +++ .../entities/shipping-line-credit.entity.ts | 110 +++++ .../shipping-line-bookings.controller.ts | 96 ++++ .../shipping-line-bookings.service.ts | 363 +++++++++++++++ .../shipping-line-companies.module.ts | 41 +- .../shipping-line-credits.controller.ts | 127 ++++++ .../shipping-line-credits.repository.ts | 138 ++++++ .../shipping-line-credits.service.spec.ts | 296 ++++++++++++ .../shipping-line-credits.service.ts | 420 ++++++++++++++++++ .../warehouses/warehouse-invoice.service.ts | 4 +- .../src/seed/file-upload-settings.seeder.ts | 20 + .../src/seed/freight-permissions.registry.ts | 26 ++ .../ruleEngine/RuleEngineFormDialog.tsx | 64 ++- .../src/hooks/rule-engine/useRuleEngine.ts | 24 + .../bookings/DocumentClearanceDetailPage.tsx | 17 +- .../ruleEngine/RuleEngineResourcePage.tsx | 64 ++- .../src/pages/ruleEngine/config/resources.ts | 244 +++++++++- .../services/ruleEngine/ruleEngine.service.ts | 6 + apps/edr-freight-web/portal/src/App.tsx | 8 + .../portal/src/components/AppLayout.tsx | 13 +- .../ShippingLineBookingDetailPage.tsx | 402 +++++++++++++++++ .../ShippingLineBookingsPage.tsx | 323 +++++++++++++- .../ShippingLineDocumentsModal.tsx | 248 +++++++++++ .../ShippingLineInitiateModal.tsx | 267 +++++++++++ .../pages/shipping-line/booking-doc-state.ts | 85 ++++ .../portal/src/pages/shipping-line/index.ts | 1 + .../shipping-line-bookings.service.ts | 129 ++++++ packages/types/src/freight/index.ts | 7 + 50 files changed, 4485 insertions(+), 77 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/3450000000000-BookingShippingLine.ts create mode 100644 apps/edr-freight-api/src/migrations/3460000000000-ShippingLineCredits.ts create mode 100644 apps/edr-freight-api/src/migrations/3470000000000-ShippingLineRates.ts create mode 100644 apps/edr-freight-api/src/modules/shipping-lines/dto/cancel-shipping-line-booking.dto.ts create mode 100644 apps/edr-freight-api/src/modules/shipping-lines/dto/initiate-shipping-line-booking.dto.ts create mode 100644 apps/edr-freight-api/src/modules/shipping-lines/dto/shipping-line-credit.dto.ts create mode 100644 apps/edr-freight-api/src/modules/shipping-lines/entities/shipping-line-credit.entity.ts create mode 100644 apps/edr-freight-api/src/modules/shipping-lines/shipping-line-bookings.controller.ts create mode 100644 apps/edr-freight-api/src/modules/shipping-lines/shipping-line-bookings.service.ts create mode 100644 apps/edr-freight-api/src/modules/shipping-lines/shipping-line-credits.controller.ts create mode 100644 apps/edr-freight-api/src/modules/shipping-lines/shipping-line-credits.repository.ts create mode 100644 apps/edr-freight-api/src/modules/shipping-lines/shipping-line-credits.service.spec.ts create mode 100644 apps/edr-freight-api/src/modules/shipping-lines/shipping-line-credits.service.ts create mode 100644 apps/edr-freight-web/portal/src/pages/shipping-line/ShippingLineBookingDetailPage.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/shipping-line/ShippingLineDocumentsModal.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/shipping-line/ShippingLineInitiateModal.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/shipping-line/booking-doc-state.ts create mode 100644 apps/edr-freight-web/portal/src/services/shipping-line-bookings.service.ts diff --git a/apps/edr-freight-api/src/migrations/3450000000000-BookingShippingLine.ts b/apps/edr-freight-api/src/migrations/3450000000000-BookingShippingLine.ts new file mode 100644 index 000000000..d53914ef3 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3450000000000-BookingShippingLine.ts @@ -0,0 +1,117 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Shipping lines book rail capacity directly, without a contract. + * + * A booking has always been owned by `company_id` (a customer `companies` row), + * but a shipping line is a `shipping_line_companies` row and deliberately NOT a + * company — it carries no TIN, licence or operational profiles. So it gets its + * own nullable owner column rather than a synthetic company row. + * + * Exactly one of the two is set: `company_id` for a customer booking, + * `shipping_line_company_id` for a shipping-line one. Existing rows keep + * `company_id` and a NULL `shipping_line_company_id`, so nothing needs + * backfilling and every customer query filtering on `company_id` behaves + * exactly as before. Government bookings already bill to a seeded government + * company, so they satisfy the CHECK unchanged. + * + * NOTE: not to be confused with the existing `bookings.shipping_line_id`, which + * is cargo metadata naming the carrier line that moves the goods + * (`freight.shipping_lines`, reference data). This column points at + * `freight.shipping_line_companies` — the portal account — and is unrelated. + */ +export class BookingShippingLine3450000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings + ADD COLUMN IF NOT EXISTS shipping_line_company_id uuid + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_bookings_shipping_line_company_id + ON freight.bookings (shipping_line_company_id) + `); + + // `company_id` / `company_profile_id` are NOT NULL and point at the customer + // tables, so a shipping-line booking could not be inserted at all. Relax + // them to nullable; their foreign keys are left in place and keep validating + // every non-NULL value, so a customer booking is constrained exactly as + // before. The CHECK below is what now guarantees an owner is present. + await queryRunner.query(` + ALTER TABLE freight.bookings ALTER COLUMN company_id DROP NOT NULL + `); + await queryRunner.query(` + ALTER TABLE freight.bookings ALTER COLUMN company_profile_id DROP NOT NULL + `); + + // Route and service are inherited from the contract on a customer booking. + // A shipping line initiates before any of that is known — the bare booking + // exists only to hang documents off — so these are relaxed too and filled + // in when the booking is completed. Existing rows all have values, and the + // customer paths still always set them. + await queryRunner.query(` + ALTER TABLE freight.bookings ALTER COLUMN origin_yard_id DROP NOT NULL + `); + await queryRunner.query(` + ALTER TABLE freight.bookings ALTER COLUMN destination_yard_id DROP NOT NULL + `); + await queryRunner.query(` + ALTER TABLE freight.bookings ALTER COLUMN service_type_id DROP NOT NULL + `); + await queryRunner.query(` + ALTER TABLE freight.bookings ALTER COLUMN freight_type DROP NOT NULL + `); + + // No FK: kept consistent with how the column is populated at the service + // layer, and avoids a lock on shipping_line_companies during deploy. + await queryRunner.query(` + ALTER TABLE freight.bookings + DROP CONSTRAINT IF EXISTS chk_bookings_single_owner + `); + await queryRunner.query(` + ALTER TABLE freight.bookings + ADD CONSTRAINT chk_bookings_single_owner + CHECK ( + (company_id IS NOT NULL AND shipping_line_company_id IS NULL) + OR (company_id IS NULL AND shipping_line_company_id IS NOT NULL) + ) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings + DROP CONSTRAINT IF EXISTS chk_bookings_single_owner + `); + + // Only reinstate NOT NULL if no shipping-line booking exists; those rows + // have a NULL company_id by design and would make the ALTER fail. Leaving + // the columns nullable is the safe outcome — the constraint is additive. + const [{ count }] = (await queryRunner.query(` + SELECT COUNT(*)::int AS count FROM freight.bookings + WHERE shipping_line_company_id IS NOT NULL + `)) as Array<{ count: number }>; + + if (count === 0) { + for (const column of [ + "company_id", + "company_profile_id", + "origin_yard_id", + "destination_yard_id", + "service_type_id", + "freight_type", + ]) { + await queryRunner.query(` + ALTER TABLE freight.bookings ALTER COLUMN ${column} SET NOT NULL + `); + } + } + + await queryRunner.query(` + DROP INDEX IF EXISTS freight.idx_bookings_shipping_line_company_id + `); + await queryRunner.query(` + ALTER TABLE freight.bookings DROP COLUMN IF EXISTS shipping_line_company_id + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3460000000000-ShippingLineCredits.ts b/apps/edr-freight-api/src/migrations/3460000000000-ShippingLineCredits.ts new file mode 100644 index 000000000..5a59d9d80 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3460000000000-ShippingLineCredits.ts @@ -0,0 +1,201 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Shipping lines consume services before paying for them. + * + * A shipping line books rail capacity and the booking proceeds with no payment + * gate at all — unlike a customer booking, which cannot advance until its + * PREPAID invoice settles. What the line owes is instead recorded here as a + * credit: one row per booking, priced once and never recalculated. Finance + * later selects a batch of unbilled credits, generates a single invoice for + * them, and the line pays that invoice through the normal CBE flow. When the + * invoice settles, its credits are marked paid and stop counting as debt. + * + * This is deliberately NOT a wallet or a stored balance. There is no money in + * the system to draw down: a credit is a debt the line already incurred, so + * the outstanding figure is always derived (`SUM(amount) WHERE status <> + * 'PAID'`) rather than kept in a column that UPDATEs can drift out of sync. + * + * `invoices.company_id` / `company_profile_id` are relaxed to nullable for the + * same reason `bookings` was in {@link BookingShippingLine3450000000000}: a + * shipping line is not a `companies` row and never will be, so an invoice + * billed to one has no customer to point at. Both FKs stay in place and keep + * validating every non-NULL value, so a customer invoice is constrained + * exactly as before; the CHECK below is what now guarantees a payer exists. + */ +export class ShippingLineCredits3460000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // ── Invoices: allow a shipping-line payer ──────────────────────────────── + await queryRunner.query(` + ALTER TABLE freight.invoices + ADD COLUMN IF NOT EXISTS shipping_line_company_id uuid + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_invoices_shipping_line_company_id + ON freight.invoices (shipping_line_company_id) + `); + + await queryRunner.query(` + ALTER TABLE freight.invoices ALTER COLUMN company_id DROP NOT NULL + `); + await queryRunner.query(` + ALTER TABLE freight.invoices ALTER COLUMN company_profile_id DROP NOT NULL + `); + + // Exactly one payer. Mirrors chk_bookings_single_owner so the two tables + // answer "who owes this?" the same way. Existing rows all have company_id + // and a NULL shipping_line_company_id, so nothing needs backfilling. + await queryRunner.query(` + ALTER TABLE freight.invoices + DROP CONSTRAINT IF EXISTS chk_invoices_single_payer + `); + await queryRunner.query(` + ALTER TABLE freight.invoices + ADD CONSTRAINT chk_invoices_single_payer + CHECK ( + (company_id IS NOT NULL AND shipping_line_company_id IS NULL) + OR (company_id IS NULL AND shipping_line_company_id IS NOT NULL) + ) + `); + + // ── The credit ledger ──────────────────────────────────────────────────── + await queryRunner.query(` + DO $$ BEGIN + CREATE TYPE freight.shipping_line_credits_status_enum AS ENUM ( + 'UNBILLED', 'BILLED', 'PAID', 'CANCELLED' + ); + EXCEPTION WHEN duplicate_object THEN NULL; END $$ + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.shipping_line_credits ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + shipping_line_company_id uuid NOT NULL, + booking_id uuid NOT NULL, + amount numeric(14,2) NOT NULL, + currency character varying(8) DEFAULT 'ETB'::character varying NOT NULL, + status freight.shipping_line_credits_status_enum + DEFAULT 'UNBILLED'::freight.shipping_line_credits_status_enum NOT NULL, + description character varying(255), + invoice_id uuid, + billed_at timestamp with time zone, + paid_at timestamp with time zone, + cancelled_at timestamp with time zone, + cancellation_reason character varying(255), + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + deleted_at timestamp with time zone, + CONSTRAINT pk_shipping_line_credits PRIMARY KEY (id), + CONSTRAINT chk_shipping_line_credits_amount CHECK (amount >= 0), + -- The state machine, enforced in the DB rather than trusted to the + -- service: an UNBILLED credit has no invoice, and anything past + -- UNBILLED must name the invoice it was billed on. Without this a + -- half-applied batch could leave BILLED rows with a NULL invoice_id + -- and silently vanish from both the unbilled list and the invoice. + CONSTRAINT chk_shipping_line_credits_invoice_link CHECK ( + (status = 'UNBILLED' AND invoice_id IS NULL) + OR (status IN ('BILLED', 'PAID') AND invoice_id IS NOT NULL) + OR status = 'CANCELLED' + ) + ) + `); + + await queryRunner.query(` + ALTER TABLE freight.shipping_line_credits + DROP CONSTRAINT IF EXISTS fk_shipping_line_credits_shipping_line + `); + await queryRunner.query(` + ALTER TABLE freight.shipping_line_credits + ADD CONSTRAINT fk_shipping_line_credits_shipping_line + FOREIGN KEY (shipping_line_company_id) + REFERENCES freight.shipping_line_companies(id) ON DELETE RESTRICT + `); + + await queryRunner.query(` + ALTER TABLE freight.shipping_line_credits + DROP CONSTRAINT IF EXISTS fk_shipping_line_credits_booking + `); + await queryRunner.query(` + ALTER TABLE freight.shipping_line_credits + ADD CONSTRAINT fk_shipping_line_credits_booking + FOREIGN KEY (booking_id) + REFERENCES freight.bookings(id) ON DELETE RESTRICT + `); + + // SET NULL rather than CASCADE: deleting an invoice must never delete the + // record of what was owed. The row would then violate the link CHECK, so a + // credit whose invoice is removed has to be walked back to UNBILLED + // explicitly — which is the correct, visible outcome. + await queryRunner.query(` + ALTER TABLE freight.shipping_line_credits + DROP CONSTRAINT IF EXISTS fk_shipping_line_credits_invoice + `); + await queryRunner.query(` + ALTER TABLE freight.shipping_line_credits + ADD CONSTRAINT fk_shipping_line_credits_invoice + FOREIGN KEY (invoice_id) + REFERENCES freight.invoices(id) ON DELETE SET NULL + `); + + // One live credit per booking. Partial so a soft-deleted or cancelled row + // does not block re-pricing a booking that was voided and rebooked. + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS uq_shipping_line_credits_booking + ON freight.shipping_line_credits (booking_id) + WHERE deleted_at IS NULL AND status <> 'CANCELLED' + `); + + // Drives the two hot reads: finance's unbilled worklist per line, and the + // outstanding total on the shipping-line detail page. + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_shipping_line_credits_line_status + ON freight.shipping_line_credits (shipping_line_company_id, status) + WHERE deleted_at IS NULL + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_shipping_line_credits_invoice_id + ON freight.shipping_line_credits (invoice_id) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DROP TABLE IF EXISTS freight.shipping_line_credits + `); + await queryRunner.query(` + DROP TYPE IF EXISTS freight.shipping_line_credits_status_enum + `); + + await queryRunner.query(` + ALTER TABLE freight.invoices + DROP CONSTRAINT IF EXISTS chk_invoices_single_payer + `); + + // Only reinstate NOT NULL if no shipping-line invoice exists; those rows + // have a NULL company_id by design and would make the ALTER fail. Leaving + // the columns nullable is the safe outcome — the constraint is additive. + const [{ count }] = (await queryRunner.query(` + SELECT COUNT(*)::int AS count FROM freight.invoices + WHERE shipping_line_company_id IS NOT NULL + `)) as Array<{ count: number }>; + + if (count === 0) { + await queryRunner.query(` + ALTER TABLE freight.invoices ALTER COLUMN company_id SET NOT NULL + `); + await queryRunner.query(` + ALTER TABLE freight.invoices ALTER COLUMN company_profile_id SET NOT NULL + `); + } + + await queryRunner.query(` + DROP INDEX IF EXISTS freight.idx_invoices_shipping_line_company_id + `); + await queryRunner.query(` + ALTER TABLE freight.invoices + DROP COLUMN IF EXISTS shipping_line_company_id + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3470000000000-ShippingLineRates.ts b/apps/edr-freight-api/src/migrations/3470000000000-ShippingLineRates.ts new file mode 100644 index 000000000..05e0bb799 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3470000000000-ShippingLineRates.ts @@ -0,0 +1,127 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Per-shipping-line rates. + * + * A shipping line books rail capacity directly (see BookingShippingLine3450000000000) + * and negotiates its own prices, so the rate table gains an owner column: + * `shipping_line_company_id` NULL = the standard rate every customer pays, + * NOT NULL = a rate that only that line's bookings resolve. + * + * Points at `freight.shipping_line_companies` (the portal account that owns the + * booking), NOT `freight.shipping_lines` — the latter is carrier reference data + * naming who physically moves the goods, and the existing SHIPPING_LINE trigger + * already keys off it. Both stay independent. + * + * Line rates OVERRIDE rather than stack: a booking owned by a line prices off + * that line's rate for the lane, and is hard-blocked when none exists (the + * standard rate is deliberately not a fallback — see RuleEngineService). + * + * Every existing row keeps a NULL owner, so nothing needs backfilling and the + * standard-rate lookups behave exactly as before. + */ +export class ShippingLineRates3470000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.rates + ADD COLUMN IF NOT EXISTS shipping_line_company_id uuid + `); + + await queryRunner.query(` + ALTER TABLE freight.rates + DROP CONSTRAINT IF EXISTS "FK_rates_shipping_line_company" + `); + await queryRunner.query(` + ALTER TABLE freight.rates + ADD CONSTRAINT "FK_rates_shipping_line_company" + FOREIGN KEY (shipping_line_company_id) + REFERENCES freight.shipping_line_companies (id) + ON DELETE RESTRICT + `); + + // Rate resolution always filters by owner, so the lookups this column + // participates in are (owner, lane) — indexed together with rate_type, + // which every lookup also pins. + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_rates_shipping_line_company_id + ON freight.rates (shipping_line_company_id) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_rates_shipping_line_lane + ON freight.rates (shipping_line_company_id, rate_type, origin_yard_id, destination_yard_id) + WHERE shipping_line_company_id IS NOT NULL + `); + + // A shipping line sells import freight only — the export leg is contracted + // through the customer, not the carrier. Enforced here so a line rate can + // never be filed against an export lane regardless of which API path wrote + // it. Surcharges carry no direction and are unaffected. + await queryRunner.query(` + ALTER TABLE freight.rates + DROP CONSTRAINT IF EXISTS "CK_rates_shipping_line_import_only" + `); + await queryRunner.query(` + ALTER TABLE freight.rates + ADD CONSTRAINT "CK_rates_shipping_line_import_only" CHECK ( + deleted_at IS NOT NULL OR status = 'SUPERSEDED' OR + shipping_line_company_id IS NULL OR + trade_direction IS NULL OR trade_direction = 'IMPORT' + ) + `); + + // The owner joins the rate's identity. Without it MSC's 20ft Djibouti→Modjo + // rate collides with the standard rate for the same lane — same rate_type, + // same scope, same unit — and the insert fails on UQ_rates_pattern. NULL + // (the standard rate) collapses to the zero uuid like every other nullable + // scope column, so existing rows keep their current uniqueness exactly. + await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_rates_pattern"`); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_rates_pattern" ON freight.rates USING btree ( + rate_type, + COALESCE(shipping_line_company_id, '00000000-0000-0000-0000-000000000000'::uuid), + COALESCE(container_type_id, '00000000-0000-0000-0000-000000000000'::uuid), + COALESCE(cargo_type_id, '00000000-0000-0000-0000-000000000000'::uuid), + COALESCE(trade_direction, ''::character varying), + COALESCE(origin_yard_id, '00000000-0000-0000-0000-000000000000'::uuid), + COALESCE(destination_yard_id, '00000000-0000-0000-0000-000000000000'::uuid), + rate_unit, + COALESCE(min_km, '-1'::numeric) + ) WHERE ((deleted_at IS NULL) AND ((status)::text <> 'SUPERSEDED'::text)) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + // Restore the pre-owner pattern index (as left by LastMileRateBands). + await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_rates_pattern"`); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_rates_pattern" ON freight.rates USING btree ( + rate_type, + COALESCE(container_type_id, '00000000-0000-0000-0000-000000000000'::uuid), + COALESCE(cargo_type_id, '00000000-0000-0000-0000-000000000000'::uuid), + COALESCE(trade_direction, ''::character varying), + COALESCE(origin_yard_id, '00000000-0000-0000-0000-000000000000'::uuid), + COALESCE(destination_yard_id, '00000000-0000-0000-0000-000000000000'::uuid), + rate_unit, + COALESCE(min_km, '-1'::numeric) + ) WHERE ((deleted_at IS NULL) AND ((status)::text <> 'SUPERSEDED'::text)) + `); + + await queryRunner.query(` + ALTER TABLE freight.rates + DROP CONSTRAINT IF EXISTS "CK_rates_shipping_line_import_only" + `); + await queryRunner.query( + `DROP INDEX IF EXISTS freight.idx_rates_shipping_line_lane`, + ); + await queryRunner.query( + `DROP INDEX IF EXISTS freight.idx_rates_shipping_line_company_id`, + ); + await queryRunner.query(` + ALTER TABLE freight.rates + DROP CONSTRAINT IF EXISTS "FK_rates_shipping_line_company" + `); + await queryRunner.query(` + ALTER TABLE freight.rates DROP COLUMN IF EXISTS shipping_line_company_id + `); + } +} diff --git a/apps/edr-freight-api/src/modules/billing/billing.module.ts b/apps/edr-freight-api/src/modules/billing/billing.module.ts index 7ec5c333b..06849d560 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.module.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.module.ts @@ -19,7 +19,8 @@ import { FilesModule } from "../files/files.module"; imports: [ TypeOrmModule.forFeature([Invoice, InvoiceLine]), forwardRef(() => PaymentModule), - CompaniesModule, + // Cycles back via ShippingLineCompaniesModule, which imports this module. + forwardRef(() => CompaniesModule), DocumentsModule, UserTradeAccessModule, FilesModule, @@ -29,3 +30,4 @@ import { FilesModule } from "../files/files.module"; exports: [BillingService], }) export class BillingModule {} + \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index 6c7e8455d..7d0f1e962 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -112,8 +112,16 @@ export interface GenerateInvoiceInput { sourceId: string; /** What the invoice is for (e.g. "prepaid", "credit"). */ type: string; - companyId: string; - companyProfileId: string; + /** The customer billed. Omit only when billing a shipping line instead. */ + companyId?: string | null; + companyProfileId?: string | null; + /** + * The shipping line billed, for an invoice covering batched shipping-line + * credits. Mutually exclusive with `companyId` — the DB enforces this via + * `chk_invoices_single_payer`, and {@link createInvoice} rejects a payload + * setting both or neither before it ever reaches the constraint. + */ + shippingLineCompanyId?: string | null; lines: InvoiceLineInput[]; currency?: string; /** Explicit pre-tax subtotal; defaults to the sum of line amounts. */ @@ -139,8 +147,11 @@ export interface InvoiceEventPayload { source: Freight.InvoiceSource; sourceId: string; type: string; - companyId: string; - companyProfileId: string; + /** Null when the payer is a shipping line rather than a customer company. */ + companyId: string | null; + companyProfileId: string | null; + /** Set only on shipping-line invoices; mutually exclusive with `companyId`. */ + shippingLineCompanyId?: string | null; totalAmount: number; currency: string; status: Freight.InvoiceStatus; @@ -609,7 +620,6 @@ export class BillingService { input: GenerateInvoiceInput, manager?: EntityManager, ): Promise { - console.log("oooooooooo", input); const run = (mg: EntityManager) => this.createInvoice(input, mg); return manager ? run(manager) : this.dataSource.transaction(run); } @@ -622,6 +632,21 @@ export class BillingService { const status = input.status ?? Freight.InvoiceStatus.Pending; const issued = status !== Freight.InvoiceStatus.Draft; + // Exactly one payer, checked here so a bad payload fails with a clear + // message instead of a raw `chk_invoices_single_payer` violation. + const billsCompany = Boolean(input.companyId); + const billsShippingLine = Boolean(input.shippingLineCompanyId); + if (billsCompany === billsShippingLine) { + throw new BadRequestException( + "An invoice must be billed to exactly one payer: either companyId or shippingLineCompanyId.", + ); + } + if (billsCompany && !input.companyProfileId) { + throw new BadRequestException( + "companyProfileId is required when billing a company.", + ); + } + const lines = input.lines.map((l) => { const quantity = l.quantity ?? 1; const unitRate = l.unitRate ?? 0; @@ -657,8 +682,9 @@ export class BillingService { source: input.source, sourceId: input.sourceId, type: input.type, - companyId: input.companyId, - companyProfileId: input.companyProfileId, + companyId: input.companyId ?? null, + companyProfileId: input.companyProfileId ?? null, + shippingLineCompanyId: input.shippingLineCompanyId ?? null, subtotalAmount: round2(subtotalAmount), taxAmount: round2(taxAmount), totalAmount: round2(totalAmount), @@ -988,6 +1014,7 @@ export class BillingService { type: invoice.type, companyId: invoice.companyId, companyProfileId: invoice.companyProfileId, + shippingLineCompanyId: invoice.shippingLineCompanyId ?? null, totalAmount: invoice.totalAmount, currency: invoice.currency, status: invoice.status, diff --git a/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts b/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts index 411d15ebe..3690e946d 100644 --- a/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts +++ b/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts @@ -23,22 +23,37 @@ export class Invoice extends BaseEntity { @Column({ name: "invoice_number", type: "varchar", length: 64, unique: true }) invoiceNumber!: string; - /** The customer (company) this invoice is billed to. */ - @Column({ name: "company_id", type: "uuid" }) - companyId!: string; + /** + * The customer (company) this invoice is billed to. Null on a shipping-line + * invoice, which is billed to `shippingLineCompanyId` instead — a shipping + * line is deliberately not a `companies` row. A DB CHECK + * (`chk_invoices_single_payer`) guarantees exactly one of the two is set. + */ + @Column({ name: "company_id", type: "uuid", nullable: true }) + companyId!: string | null; @ManyToOne(() => Company) @JoinColumn({ name: "company_id" }) company?: Company; /** The specific company profile (importer/exporter/forwarder/...) billed. */ - @Column({ name: "company_profile_id", type: "uuid" }) - companyProfileId!: string; + @Column({ name: "company_profile_id", type: "uuid", nullable: true }) + companyProfileId!: string | null; @ManyToOne(() => CompanyProfile) @JoinColumn({ name: "company_profile_id" }) companyProfile?: CompanyProfile; + /** + * The shipping line billed, when this invoice bills batched shipping-line + * credits rather than a customer booking. Mutually exclusive with + * `companyId`. No relation is declared: `ShippingLineCredit` already owns + * that edge, and importing the shipping-lines module here would close an + * import cycle (shipping-lines already depends on billing). + */ + @Column({ name: "shipping_line_company_id", type: "uuid", nullable: true }) + shippingLineCompanyId?: string | null; + /** Sum of line amounts before tax; defaults to `totalAmount` for tax-free invoices. */ @Column({ name: "subtotal_amount", type: "numeric", precision: 14, scale: 2, default: 0 }) subtotalAmount!: number; diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts index 4ebebe879..f7dc40a82 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -165,7 +165,7 @@ export class BookingPricingService { total += line.amount; } - const liveRates = await this.ratesService.findLiveRates(); + const liveRates = await this.liveRatesForBooking(booking); const rateById = new Map(liveRates.map((r) => [r.id, r])); const usedRatesMap = new Map([...baseRates, ...mileRates].map((r) => [r.id, r])); @@ -414,6 +414,9 @@ export class BookingPricingService { isGovernment: booking.isGovernment, allowConsolidation, shippingLineId: booking.shippingLineId, + // A shipping line's own booking prices off that line's negotiated rates + // instead of the standard customer ones (see RuleEngineService.ratesForOwner). + shippingLineCompanyId: booking.shippingLineCompanyId, originYardId: booking.originYardId, destinationYardId: booking.destinationYardId, totalWagons, @@ -428,6 +431,22 @@ export class BookingPricingService { }; } + /** + * LIVE rates this booking may price off. + * + * A shipping-line booking sees only its own line's rates; a customer booking + * only the standard ones. Line rates override rather than stack, and the + * standard rate is not a fallback — a lane the line has no rate for falls + * through to the existing "no rate configured" hard block, which is the + * intended outcome rather than silently billing the customer price. + */ + private async liveRatesForBooking(booking: Booking): Promise { + const rates = await this.ratesService.findLiveRates(); + return booking.shippingLineCompanyId + ? rates.filter((r) => r.shippingLineCompanyId === booking.shippingLineCompanyId) + : rates.filter((r) => !r.shippingLineCompanyId); + } + private async requireBooking(id: string): Promise { const booking = await this.bookingsRepository.findByIdWithFiles(id); if (!booking) throw new NotFoundException(`Booking ${id} not found`); @@ -512,7 +531,7 @@ export class BookingPricingService { warnings: string[]; blocked: string[]; }> { - const liveRates = await this.ratesService.findLiveRates(); + const liveRates = await this.liveRatesForBooking(booking); const paymentCurrency = booking.paymentCurrency; const isEtbBooking = paymentCurrency === 'ETB'; const usdToEtb = isEtbBooking ? await this.exchangeService.getRate('USD', 'ETB') : 1; @@ -713,7 +732,7 @@ export class BookingPricingService { return { lineItems: [], usedRates: [] }; } - const liveRates = await this.ratesService.findLiveRates(); + const liveRates = await this.liveRatesForBooking(booking); const paymentCurrency = booking.paymentCurrency; const isEtbBooking = paymentCurrency === 'ETB'; const usdToEtb = isEtbBooking ? await this.exchangeService.getRate('USD', 'ETB') : 1; diff --git a/apps/edr-freight-api/src/modules/bookings/clearance.util.ts b/apps/edr-freight-api/src/modules/bookings/clearance.util.ts index e28917715..c7fc71449 100644 --- a/apps/edr-freight-api/src/modules/bookings/clearance.util.ts +++ b/apps/edr-freight-api/src/modules/bookings/clearance.util.ts @@ -16,6 +16,16 @@ type Freight = 'container' | 'bulk'; */ export const INTERCITY_DOCUMENTS_SETTING_CODE = 'intercity_documents'; +/** + * The document set a shipping line uploads on a booking it initiated. + * + * Shipping lines book without a contract, so none of the trade-direction / + * freight / customs matrix below applies to them — this one admin-configured + * set is what Operations reviews before the booking may be completed. + */ +export const SHIPPING_LINE_DOCUMENTS_SETTING_CODE = + 'shipping_line_booking_documents'; + /** Trade direction → clearance operation. DOMESTIC has no customs clearance. */ function operationFor(tradeDirection: string): Op | null { if (tradeDirection === 'IMPORT') return 'import'; @@ -67,6 +77,20 @@ export function clearanceCodesForBooking(booking: Booking): { outputCode: string | null; includesCustoms: boolean; } { + // Shipping-line bookings resolve to their own single set and never reach the + // matrix below: they have no contract, and their trade direction / freight + // type are placeholders until the booking is completed, so the customer codes + // would resolve to a set that was never meant for them. Keyed off the owner + // column, which is NULL on every customer booking — so no customer booking + // can take this branch. + if (booking.shippingLineCompanyId) { + return { + inputCode: SHIPPING_LINE_DOCUMENTS_SETTING_CODE, + outputCode: null, + includesCustoms: false, + }; + } + // Customs applies when EITHER the service type bundles it OR the booking was // created with customsClearingEnabled (copied from the contract). Contract // bookings carry customsClearingEnabled even when the serviceType relation diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index dbc306e3b..cdcfbc3eb 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -108,15 +108,34 @@ export class Booking extends BaseEntity { // @JoinColumn({ name: 'customer_id' }) // customer?: Customer; - // Every booking is billed to a company — government bookings bill to a seeded - // government company (companies.kind = 'government'). Enforced NOT NULL. - @Column({ name: 'company_id', type: 'uuid' }) + // Every CUSTOMER booking is billed to a company — government bookings bill to + // a seeded government company (companies.kind = 'government'). NULL only on a + // shipping-line booking, owned by `shippingLineCompanyId` instead; a DB CHECK + // enforces that exactly one of the two is set. + @Column({ name: 'company_id', type: 'uuid', nullable: true }) companyId!: string; @ManyToOne(() => Company, { nullable: true }) @JoinColumn({ name: 'company_id' }) company?: Company | null; + /** + * The shipping-line ACCOUNT that owns this booking, when it is not a + * customer's. Shipping lines book without a contract and are not `companies` + * rows (no TIN, licence or operational profiles), so they get their own owner + * column rather than a synthetic company. NULL on every customer booking. + * + * Deliberately NOT `shippingLineId` above: that is cargo metadata naming the + * carrier line that moves the goods (`freight.shipping_lines`, reference data + * set on customer bookings too). This points at `shipping_line_companies` — + * the portal account — and the two are unrelated. + * + * No relation is declared: `ShippingLineCompany` lives in its own module and + * the column is read by id, matching how the migration leaves it FK-free. + */ + @Column({ name: 'shipping_line_company_id', type: 'uuid', nullable: true }) + shippingLineCompanyId?: string | null; + /** * The operational profile (importer/exporter/forwarder) this booking belongs * to. Stamped at creation from the booking's trade direction (IMPORT→importer, @@ -125,7 +144,9 @@ export class Booking extends BaseEntity { * commercial bookings resolve it from trade direction / active mode; * government bookings carry the explicitly-picked government profile. */ - @Column({ name: 'company_profile_id', type: 'uuid' }) + // NULL only on a shipping-line booking — shipping lines have no operational + // profiles. Always set on a customer booking, as before. + @Column({ name: 'company_profile_id', type: 'uuid', nullable: true }) companyProfileId!: string; @ManyToOne(() => CompanyProfile, { nullable: true }) @@ -259,7 +280,7 @@ export class Booking extends BaseEntity { @Column({ name: 'contract_type', type: 'varchar', length: 20 }) contractType!: string; - @Column({ name: 'service_type_id', type: 'uuid' }) + @Column({ name: 'service_type_id', type: 'uuid', nullable: true }) serviceTypeId!: string; @ManyToOne(() => ServiceType) @@ -337,14 +358,14 @@ export class Booking extends BaseEntity { @Column({ name: 'equipment_return', type: 'varchar', length: 20 }) equipmentReturn!: string; - @Column({ name: 'origin_yard_id', type: 'uuid' }) + @Column({ name: 'origin_yard_id', type: 'uuid', nullable: true }) originYardId!: string; @ManyToOne(() => Yard) @JoinColumn({ name: 'origin_yard_id' }) originYard?: Yard; - @Column({ name: 'destination_yard_id', type: 'uuid' }) + @Column({ name: 'destination_yard_id', type: 'uuid', nullable: true }) destinationYardId!: string; @ManyToOne(() => Yard) @@ -354,7 +375,7 @@ export class Booking extends BaseEntity { @Column({ name: 'trade_direction', type: 'varchar', length: 10 }) tradeDirection!: string; - @Column({ name: 'freight_type', type: 'varchar', length: 20 }) + @Column({ name: 'freight_type', type: 'varchar', length: 20, nullable: true }) freightType!: string; @Column({ name: 'cargo_type_id', type: 'uuid', nullable: true }) 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 aa0a00dff..b990aa8f0 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.module.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.module.ts @@ -46,8 +46,9 @@ import { VerifaydaModule } from "../verifayda/verifayda.module"; // 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, + // shipping-line session, which has no company row to look up. forwardRef + // because that module imports BillingModule, which imports this one. + forwardRef(() => ShippingLineCompaniesModule), ], controllers: [CompaniesController], providers: [ diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts index eccb14017..73ae42a0d 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts @@ -75,6 +75,14 @@ export class CreateRateDto { @IsUUID() destinationYardId?: string; + @ApiPropertyOptional({ + description: + 'FK to shipping_line_companies.id — set to price this rate for one shipping line only. Omitted/null = the standard rate every customer pays. A line rate overrides the standard one for that line\'s bookings.', + }) + @IsOptional() + @IsUUID() + shippingLineCompanyId?: string; + @ApiPropertyOptional({ enum: CURRENCIES }) @IsOptional() @IsIn([...CURRENCIES]) diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/list-rule-engine-query.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/list-rule-engine-query.dto.ts index d22bd84a7..774037e36 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/list-rule-engine-query.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/list-rule-engine-query.dto.ts @@ -141,6 +141,22 @@ export class ListRatesQueryDto extends PaginationQueryDto { @IsString() @MaxLength(200) trigger?: string; + + @ApiPropertyOptional({ + description: 'Filter to one shipping line\'s rates.', + }) + @IsOptional() + @IsUUID() + shippingLineCompanyId?: string; + + @ApiPropertyOptional({ + description: + 'true = only shipping-line rates (any line), false = only standard customer rates. Omitted = both. Powers the Shipping line tab.', + }) + @IsOptional() + @Transform(toOptionalBoolean) + @IsBoolean() + isShippingLineRate?: boolean; } export class ListWeightLimitRulesQueryDto extends PaginationQueryDto { diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts index 925a3555a..c60d03d31 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts @@ -1,5 +1,6 @@ import { BaseEntity } from '@edr/api-common'; import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; +import { ShippingLineCompany } from '../../shipping-lines/entities/shipping-line-company.entity'; import { CargoType } from './cargo-type.entity'; import { ContainerType } from './container-type.entity'; import { Yard } from './yard.entity'; @@ -108,6 +109,7 @@ export type RateTrigger = typeof RATE_TRIGGERS[number]; @Index(['trigger']) @Index(['originYardId']) @Index(['destinationYardId']) +@Index(['shippingLineCompanyId']) export class Rate extends BaseEntity { @Column({ name: 'rate_type', type: 'varchar', length: 50 }) rateType!: RateType; @@ -155,6 +157,23 @@ export class Rate extends BaseEntity { @JoinColumn({ name: 'destination_yard_id' }) destinationYard?: Yard | null; + /** + * The shipping line this rate belongs to, or NULL for the standard rate every + * customer pays. A booking owned by a shipping line prices exclusively off + * that line's rates — the standard rate is NOT a fallback, so a missing line + * rate hard-blocks the booking rather than quietly billing the customer price. + * + * Points at `shipping_line_companies` (the portal account that books capacity), + * not `shipping_lines` (carrier reference data behind the SHIPPING_LINE + * trigger). The two are unrelated despite the similar names. + */ + @Column({ name: 'shipping_line_company_id', type: 'uuid', nullable: true }) + shippingLineCompanyId?: string | null; + + @ManyToOne(() => ShippingLineCompany, { nullable: true, eager: false }) + @JoinColumn({ name: 'shipping_line_company_id' }) + shippingLineCompany?: ShippingLineCompany | null; + @Column({ name: 'currency', type: 'varchar', length: 5 }) currency!: string; diff --git a/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts b/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts index 9797ff715..ba2509068 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts @@ -16,6 +16,8 @@ export interface IRatesRepository { rateType: string; /** Omitted for singly-resolved rates — see the repository implementation. */ rateUnit?: string; + /** Owning shipping line; null/omitted = the standard customer rate. */ + shippingLineCompanyId?: string | null; containerTypeId?: string | null; cargoTypeId?: string | null; tradeDirection?: string | null; diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts index 7417987dc..8a739095e 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts @@ -69,6 +69,7 @@ export class RatesRepository implements IRatesRepository { findByPattern(pattern: { rateType: string; rateUnit?: string; + shippingLineCompanyId?: string | null; containerTypeId?: string | null; cargoTypeId?: string | null; tradeDirection?: string | null; @@ -85,6 +86,16 @@ export class RatesRepository implements IRatesRepository { qb.andWhere('rate.rate_unit = :rateUnit', { rateUnit: pattern.rateUnit }); } + // The owner is part of the identity: a line's rate for a lane is a + // different rate from the standard one, not a duplicate of it. + if (pattern.shippingLineCompanyId) { + qb.andWhere('rate.shipping_line_company_id = :shippingLineCompanyId', { + shippingLineCompanyId: pattern.shippingLineCompanyId, + }); + } else { + qb.andWhere('rate.shipping_line_company_id IS NULL'); + } + if (pattern.containerTypeId) { qb.andWhere('rate.container_type_id = :containerTypeId', { containerTypeId: pattern.containerTypeId }); } else { @@ -139,8 +150,22 @@ export class RatesRepository implements IRatesRepository { // yards joined the route columns have only ids to render. .leftJoinAndSelect('rate.originYard', 'originYard') .leftJoinAndSelect('rate.destinationYard', 'destinationYard') + // The shipping-line tab renders the owning line's name, not its id. + .leftJoinAndSelect('rate.shippingLineCompany', 'shippingLineCompany') .orderBy('rate.createdAt', query.sortOrder ?? 'DESC'); + if (query.shippingLineCompanyId) { + qb.andWhere('rate.shippingLineCompanyId = :shippingLineCompanyId', { + shippingLineCompanyId: query.shippingLineCompanyId, + }); + } else if (query.isShippingLineRate !== undefined) { + // Tab filter: shipping-line rates (any line) vs standard customer rates. + qb.andWhere( + query.isShippingLineRate + ? 'rate.shippingLineCompanyId IS NOT NULL' + : 'rate.shippingLineCompanyId IS NULL', + ); + } if (query.status) { qb.andWhere('rate.status = :status', { status: query.status }); } @@ -164,7 +189,7 @@ export class RatesRepository implements IRatesRepository { } if (query.search) { qb.andWhere( - '(rate.rateType ILIKE :search OR rate.status ILIKE :search OR rate.rateUnit ILIKE :search OR rate.currency ILIKE :search)', + '(rate.rateType ILIKE :search OR rate.status ILIKE :search OR rate.rateUnit ILIKE :search OR rate.currency ILIKE :search OR shippingLineCompany.name ILIKE :search)', { search: `%${query.search}%` }, ); } diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts index d487387b8..549a35fa2 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts @@ -69,6 +69,7 @@ import { YardFacilitiesService } from './services/yard-facilities.service'; import { RuleEngineService } from './rule-engine.service'; import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module'; +import { ShippingLineCompaniesModule } from '../shipping-lines/shipping-line-companies.module'; import { WagonTypesModule } from '../wagon-types/wagon-types.module'; import { BookingCargoModifier } from '../bookings/entities/booking-cargo-modifier.entity'; @@ -102,6 +103,9 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. // Rated wagon capacities — cargo types validate their per-wagon tonnage cap // against them (a cap above the rating is a typo, not a policy). WagonTypesModule, + // Rates may be scoped to one shipping line; creating such a rate validates + // the line exists and is active. + ShippingLineCompaniesModule, ], controllers: [ CargoTypesController, diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.spec.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.spec.ts index 281f2d1b2..c530e5314 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.spec.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.spec.ts @@ -527,3 +527,145 @@ describe('RuleEngineService — fuel surcharge (per lane + cargo type)', () => { expect(fuelMods(result)).toHaveLength(0); }); }); + +describe('RuleEngineService — shipping-line rates override the standard ones', () => { + const LINE = 'slc-msc'; + + /** Standard customer container-import rate on the lane. */ + const standardBase: Rate = { + id: 'rate-standard-20', + rateType: 'CONTAINER_IMPORT', + trigger: 'ALWAYS', + rateValue: 1000, + rateUnit: 'PER_CONTAINER', + currency: 'USD', + status: 'LIVE', + containerTypeId: 'ct-20', + cargoTypeId: null, + shippingLineCompanyId: null, + originYardId: 'yard-dj', + destinationYardId: 'yard-adama', + } as Rate; + + /** The same lane, priced for one shipping line. */ + const lineBase: Rate = { + ...standardBase, + id: 'rate-line-20', + rateValue: 1200, + shippingLineCompanyId: LINE, + } as Rate; + + const standardHazard: Rate = { + id: 'rate-hazard-standard', + rateType: 'HAZARD_SURCHARGE', + trigger: 'HAZARDOUS', + rateValue: 50, + rateUnit: 'PER_CONTAINER', + currency: 'USD', + status: 'LIVE', + containerTypeId: null, + cargoTypeId: null, + shippingLineCompanyId: null, + } as Rate; + + const lineHazard: Rate = { + ...standardHazard, + id: 'rate-hazard-line', + rateValue: 80, + shippingLineCompanyId: LINE, + } as Rate; + + const buildService = (rates: Rate[]) => + new RuleEngineService( + { findById: jest.fn().mockResolvedValue(null) } as never, + { findById: jest.fn().mockResolvedValue(null) } as never, + { + findActiveByContainerTypeId: jest + .fn() + .mockResolvedValue([{ id: 'wlr-20', maxVgmTons: 20, maxCapacityTons: null }]), + } as never, + { findAllActive: jest.fn().mockResolvedValue([]) } as never, + { findLiveRates: jest.fn().mockResolvedValue(rates) } as never, + { findById: jest.fn().mockResolvedValue(null) } as never, + {} as never, + ); + + // One 20ft at 25 t against a 20 t limit → 5 t excess. + const bookingInput = ( + overrides: Partial = {}, + ): BookingEvaluationInput => ({ + serviceTypeId: 'svc-1', + paymentCurrency: 'USD', + tradeDirection: 'IMPORT', + isHazardous: false, + totalWagons: 1, + originYardId: 'yard-dj', + destinationYardId: 'yard-adama', + containers: [ + { containerTypeId: 'ct-20', quantity: 1, vgmPerUnitTons: 25, totalVgmTons: 25 }, + ], + ...overrides, + }); + + const overweightOf = (result: { appliedModifiers: Array<{ surchargeCode: string }> }) => + result.appliedModifiers.filter((m) => m.surchargeCode === 'OVERWEIGHT_PER_TON'); + + it('derives a line booking\'s overweight from the LINE\'s base rate, not the standard one', async () => { + const result = await buildService([standardBase, lineBase]).evaluate( + bookingInput({ shippingLineCompanyId: LINE }), + ); + const ow = overweightOf(result); + expect(ow).toHaveLength(1); + // The line's 1200 / (2 × 20) = 30 USD/t, not the standard 1000 → 25 USD/t. + expect(ow[0]).toMatchObject({ + rateId: lineBase.id, + unitPriceUsd: 30, + calculatedAmount: 150, + }); + }); + + it('keeps a customer booking on the standard rate even when a line rate exists', async () => { + const result = await buildService([standardBase, lineBase]).evaluate(bookingInput()); + const ow = overweightOf(result); + expect(ow).toHaveLength(1); + expect(ow[0]).toMatchObject({ + rateId: standardBase.id, + unitPriceUsd: 25, + calculatedAmount: 125, + }); + }); + + it('does not fall back to the standard rate when the line has none for the lane', async () => { + const result = await buildService([standardBase]).evaluate( + bookingInput({ shippingLineCompanyId: LINE }), + ); + // No line rate on the lane → nothing to derive from. Base freight is what + // hard-blocks the booking; the standard 1000 must never be borrowed here. + expect(overweightOf(result)).toHaveLength(0); + }); + + it('bills the line\'s own surcharge and never the standard one alongside it', async () => { + const result = await buildService([ + standardBase, + lineBase, + standardHazard, + lineHazard, + ]).evaluate(bookingInput({ shippingLineCompanyId: LINE, isHazardous: true })); + + const hazard = result.appliedModifiers.filter( + (m) => m.surchargeCode === 'HAZARD_SURCHARGE', + ); + expect(hazard).toHaveLength(1); + expect(hazard[0]).toMatchObject({ rateId: lineHazard.id, calculatedAmount: 80 }); + }); + + it('hard-blocks a requested service the line has no surcharge rate for', async () => { + const result = await buildService([standardBase, lineBase, standardHazard]).evaluate( + bookingInput({ shippingLineCompanyId: LINE, isHazardous: true }), + ); + // The standard hazard rate exists but belongs to customers, so the line's + // hazardous booking must block rather than borrow it. + expect(result.hardBlocked).toHaveLength(1); + expect(result.hardBlocked[0]).toContain('hazardous'); + }); +}); diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts index 908d00ecc..5c9055415 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts @@ -74,6 +74,16 @@ export interface BookingEvaluationInput { isGovernment?: boolean; allowConsolidation?: boolean; shippingLineId?: string | null; + /** + * The shipping line that OWNS this booking (`bookings.shipping_line_company_id`), + * when it is a shipping-line booking rather than a customer one. Such a booking + * prices exclusively off that line's own rates — see {@link ratesForOwner}. + * + * Not to be confused with `shippingLineId` above, which is cargo metadata + * naming the carrier that physically moves the goods and only feeds the + * SHIPPING_LINE double-handling trigger. + */ + shippingLineCompanyId?: string | null; /** * The booking's rail leg. Import overweight derives its per-ton price from * this route's own container freight rate, so the engine needs the yards. @@ -282,7 +292,10 @@ export class RuleEngineService { // scope) must contribute exactly ONE line. Duplicate LIVE rate rows — e.g. // from a non-idempotent seeder — would otherwise repeat the same surcharge // many times and inflate the total, so we collapse them to one row each. - const liveRates = await this.ratesRepo.findLiveRates(); + const liveRates = this.ratesForOwner( + await this.ratesRepo.findLiveRates(), + input.shippingLineCompanyId, + ); const surchargeRates = this.dedupeRatesBySignature( liveRates.filter((r) => r.trigger && r.trigger !== 'ALWAYS'), ); @@ -812,6 +825,28 @@ export class RuleEngineService { return rate.rateType ?? rate.trigger; } + /** + * Narrow the LIVE rate pool to the ones this booking's owner may price off. + * + * A customer booking sees only standard rates (no owner) — a shipping line's + * negotiated price must never leak into a customer quote. A shipping-line + * booking sees only that line's own rates: line rates OVERRIDE the standard + * ones rather than stacking on them, and the standard rate is deliberately + * NOT a fallback, so a lane the line has no rate for hard-blocks downstream + * (base freight already blocks on "no rate for this route") instead of + * quietly billing the line at the customer price. + * + * Filtering once, here, is what makes the override apply uniformly: every + * downstream lookup (base freight, derived overweight, empty return, lashing, + * fuel, and the additive surcharges) reads from this same pool, so none of + * them needs its own owner check. + */ + private ratesForOwner(rates: Rate[], shippingLineCompanyId?: string | null): Rate[] { + return shippingLineCompanyId + ? rates.filter((r) => r.shippingLineCompanyId === shippingLineCompanyId) + : rates.filter((r) => !r.shippingLineCompanyId); + } + /** * Collapse rates that describe the same charge to a single representative. * diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rates.duplicate-pattern.spec.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rates.duplicate-pattern.spec.ts index 23d96da89..0a4108a4e 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/rates.duplicate-pattern.spec.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rates.duplicate-pattern.spec.ts @@ -61,6 +61,8 @@ describe('RatesService — one rate per pattern', () => { })), } as never, { findById: jest.fn().mockResolvedValue(null) } as never, + // Shipping line companies — these rates carry no owner, so it is never hit. + { findById: jest.fn() } as never, ); }); diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts index 232cd9299..1902a757a 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts @@ -8,6 +8,8 @@ import { } from '@nestjs/common'; import { PaginatedResponse, YardCountry } from '@edr/types'; import { IsNull, Not } from 'typeorm'; +import { ShippingLineStatus } from '../../shipping-lines/entities/shipping-line-company.entity'; +import { ShippingLineCompaniesService } from '../../shipping-lines/shipping-line-companies.service'; import { CreateRateDto } from '../dto/create-rate.dto'; import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto'; import { UpdateRateDto } from '../dto/update-rate.dto'; @@ -39,6 +41,7 @@ export class RatesService { private readonly yardsRepository: IYardsRepository, @Inject(CARGO_TYPES_REPOSITORY) private readonly cargoTypesRepository: ICargoTypesRepository, + private readonly shippingLineCompaniesService: ShippingLineCompaniesService, ) {} /** List rates — standard paginated envelope with server-side search. */ @@ -491,6 +494,7 @@ export class RatesService { rateType: string; /** Passed only for additive surcharges — see {@link resolvesSingleRate}. */ rateUnit?: string; + shippingLineCompanyId: string | null; containerTypeId: string | null; cargoTypeId: string | null; tradeDirection: string | null; @@ -508,6 +512,35 @@ export class RatesService { } } + /** + * Validate the shipping line a rate is scoped to, when any. + * + * A shipping line only ever ships import — the export leg is sold through the + * customer's contract — so a line rate carrying an EXPORT direction is + * rejected here as well as by `CK_rates_shipping_line_import_only`. + * Returns the owner id to store (null = the standard customer rate). + */ + private async resolveShippingLineScope( + shippingLineCompanyId: string | null | undefined, + tradeDirection: string | null, + ): Promise { + if (!shippingLineCompanyId) return null; + + // Throws NotFoundException when the line does not exist. + const line = await this.shippingLineCompaniesService.findById(shippingLineCompanyId); + if (line.status !== ShippingLineStatus.Active) { + throw new BadRequestException( + `${line.name} is ${line.status} — rates can only be configured for an active shipping line.`, + ); + } + if (tradeDirection && tradeDirection !== 'IMPORT') { + throw new BadRequestException( + 'Shipping line rates are import-only — the export leg is priced through the customer contract.', + ); + } + return shippingLineCompanyId; + } + /** Create a rate in DRAFT status. */ async create(dto: CreateRateDto, proposedByStaffId: string): Promise { const appliesTo = dto.appliesTo as Rate['appliesTo']; @@ -568,6 +601,11 @@ export class RatesService { destinationYardId: dto.destinationYardId, }); + const shippingLineCompanyId = await this.resolveShippingLineScope( + dto.shippingLineCompanyId, + tradeDirection, + ); + const rateType = deriveRateType({ appliesTo, trigger, @@ -602,6 +640,7 @@ export class RatesService { await this.assertNoDuplicatePattern({ rateType, ...(this.resolvesSingleRate(appliesTo, trigger) ? {} : { rateUnit }), + shippingLineCompanyId, containerTypeId, cargoTypeId, tradeDirection, @@ -614,6 +653,7 @@ export class RatesService { appliesTo, trigger, rateType, + shippingLineCompanyId, containerTypeId, cargoTypeId, tradeDirection, @@ -791,6 +831,17 @@ export class RatesService { updates.originYardId = yardScope.originYardId; updates.destinationYardId = yardScope.destinationYardId; + // The owning line is re-validated on every edit: a patch that flips the + // direction to EXPORT has to be refused for a line rate, and a patch that + // moves the rate to a suspended line too. + const shippingLineCompanyId = await this.resolveShippingLineScope( + dto.shippingLineCompanyId !== undefined + ? dto.shippingLineCompanyId + : existing.shippingLineCompanyId, + updates.tradeDirection, + ); + updates.shippingLineCompanyId = shippingLineCompanyId; + // Keep the derived rateType in sync with whatever changed. const rateType = deriveRateType({ appliesTo, @@ -839,6 +890,7 @@ export class RatesService { await this.assertNoDuplicatePattern({ rateType, ...(this.resolvesSingleRate(appliesTo, trigger) ? {} : { rateUnit }), + shippingLineCompanyId, containerTypeId: updates.containerTypeId, cargoTypeId: updates.cargoTypeId, tradeDirection: updates.tradeDirection, diff --git a/apps/edr-freight-api/src/modules/shipping-lines/dto/cancel-shipping-line-booking.dto.ts b/apps/edr-freight-api/src/modules/shipping-lines/dto/cancel-shipping-line-booking.dto.ts new file mode 100644 index 000000000..1064cdeb4 --- /dev/null +++ b/apps/edr-freight-api/src/modules/shipping-lines/dto/cancel-shipping-line-booking.dto.ts @@ -0,0 +1,15 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsOptional, IsString, MaxLength } from "class-validator"; + +/** Payload for a shipping line cancelling its own booking. */ +export class CancelShippingLineBookingDto { + @ApiProperty({ + required: false, + description: + "Why the booking is being cancelled. Recorded on the booking's review-note log.", + }) + @IsOptional() + @IsString() + @MaxLength(500) + reason?: string; +} diff --git a/apps/edr-freight-api/src/modules/shipping-lines/dto/initiate-shipping-line-booking.dto.ts b/apps/edr-freight-api/src/modules/shipping-lines/dto/initiate-shipping-line-booking.dto.ts new file mode 100644 index 000000000..6cdcdd1cf --- /dev/null +++ b/apps/edr-freight-api/src/modules/shipping-lines/dto/initiate-shipping-line-booking.dto.ts @@ -0,0 +1,38 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsIn, IsOptional, IsUUID } from "class-validator"; + +import { FREIGHT_TYPES } from "../../bookings/entities/booking.entity"; + +/** + * Payload for initiating a bare shipping-line booking. + * + * A customer's bare booking inherits its lane from the contract it is initiated + * under. Shipping lines have no contract, so the lane comes from a route the + * caller picks — one choice that yields origin, destination and trade direction + * together, rather than three fields that can contradict each other. + */ +export class InitiateShippingLineBookingDto { + @ApiProperty({ + description: + "The lane being booked. Supplies the booking's origin yard, destination yard and trade direction.", + }) + @IsUUID() + routeId!: string; + + @ApiProperty({ + required: false, + description: "Service type being booked.", + }) + @IsOptional() + @IsUUID() + serviceTypeId?: string; + + @ApiProperty({ + required: false, + enum: FREIGHT_TYPES, + description: "Freight type. Defaults to CONTAINER.", + }) + @IsOptional() + @IsIn(FREIGHT_TYPES) + freightType?: string; +} diff --git a/apps/edr-freight-api/src/modules/shipping-lines/dto/shipping-line-credit.dto.ts b/apps/edr-freight-api/src/modules/shipping-lines/dto/shipping-line-credit.dto.ts new file mode 100644 index 000000000..4e659e317 --- /dev/null +++ b/apps/edr-freight-api/src/modules/shipping-lines/dto/shipping-line-credit.dto.ts @@ -0,0 +1,51 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { + ArrayNotEmpty, + IsArray, + IsInt, + IsOptional, + IsString, + IsUUID, + MaxLength, + Min, + MinLength, +} from "class-validator"; + +/** Finance's request to bill a batch of unbilled credits as one invoice. */ +export class GenerateCreditInvoiceDto { + @ApiProperty({ + description: + "The unbilled credits to bill. All must belong to the same shipping line and share one currency.", + type: [String], + format: "uuid", + }) + @IsArray() + @ArrayNotEmpty() + @IsUUID("4", { each: true }) + creditIds!: string[]; + + @ApiPropertyOptional({ + description: + "Pay window in days from issue. Defaults to the standard invoice term.", + minimum: 1, + example: 14, + }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + dueInDays?: number; +} + +/** Write-off of a single unbilled credit. */ +export class CancelCreditDto { + @ApiProperty({ + description: "Why the credit is being written off. Recorded on the row.", + example: "Booking voided before departure", + }) + @IsString() + @MinLength(3) + @MaxLength(255) + reason!: string; +} diff --git a/apps/edr-freight-api/src/modules/shipping-lines/entities/shipping-line-credit.entity.ts b/apps/edr-freight-api/src/modules/shipping-lines/entities/shipping-line-credit.entity.ts new file mode 100644 index 000000000..3c81968f6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/shipping-lines/entities/shipping-line-credit.entity.ts @@ -0,0 +1,110 @@ +import { BaseEntity } from "@edr/api-common"; +import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm"; + +import { Booking } from "../../bookings/entities/booking.entity"; +import { Invoice } from "../../billing/entities/invoice.entity"; +import { ShippingLineCompany } from "./shipping-line-company.entity"; + +/** Where a credit sits between "service used" and "money received". */ +export enum ShippingLineCreditStatus { + /** Service used, priced, not yet on any invoice. Counts as debt. */ + Unbilled = "UNBILLED", + /** Finance put it on an invoice; awaiting payment. Still counts as debt. */ + Billed = "BILLED", + /** The invoice settled. Terminal — no longer debt, and never re-billed. */ + Paid = "PAID", + /** Written off / booking voided. Terminal, excluded from every total. */ + Cancelled = "CANCELLED", +} + +/** Statuses a shipping line still owes money for. */ +export const OUTSTANDING_CREDIT_STATUSES = [ + ShippingLineCreditStatus.Unbilled, + ShippingLineCreditStatus.Billed, +] as const; + +/** + * What a shipping line owes for one booking. + * + * Shipping lines get the service first and pay later, so a booking of theirs + * raises no invoice and passes no payment gate — it raises one of these. The + * amount is frozen when the booking is priced and is never recalculated, so a + * later rate change cannot silently alter a debt already incurred. + * + * Finance batches unbilled credits into one invoice (see + * `ShippingLineCreditsService.generateInvoice`); the line pays that invoice + * through the ordinary CBE flow; settlement flips the batch to PAID and the + * debt disappears. The outstanding figure is always derived by summing + * {@link OUTSTANDING_CREDIT_STATUSES} rows — there is no balance column, + * because a stored balance is one missed UPDATE away from being a lie. + */ +@Entity({ schema: "freight", name: "shipping_line_credits" }) +@Index(["shippingLineCompanyId", "status"]) +@Index(["invoiceId"]) +export class ShippingLineCredit extends BaseEntity { + /** The line that owes this. */ + @Column({ name: "shipping_line_company_id", type: "uuid" }) + shippingLineCompanyId!: string; + + @ManyToOne(() => ShippingLineCompany) + @JoinColumn({ name: "shipping_line_company_id" }) + shippingLineCompany?: ShippingLineCompany; + + /** + * The booking that incurred the charge. Unique among live rows (partial + * index excludes soft-deleted and CANCELLED), so one booking can never be + * billed twice. + */ + @Column({ name: "booking_id", type: "uuid" }) + bookingId!: string; + + @ManyToOne(() => Booking) + @JoinColumn({ name: "booking_id" }) + booking?: Booking; + + /** Frozen at pricing time. Never recalculated. */ + @Column({ name: "amount", type: "numeric", precision: 14, scale: 2 }) + amount!: number; + + @Column({ name: "currency", type: "varchar", length: 8, default: "ETB" }) + currency!: string; + + @Column({ + name: "status", + type: "enum", + enum: ShippingLineCreditStatus, + default: ShippingLineCreditStatus.Unbilled, + }) + status!: ShippingLineCreditStatus; + + /** What the charge is for; becomes the invoice line description. */ + @Column({ name: "description", type: "varchar", length: 255, nullable: true }) + description?: string | null; + + /** The invoice this credit was billed on; null while UNBILLED. */ + @Column({ name: "invoice_id", type: "uuid", nullable: true }) + invoiceId?: string | null; + + @ManyToOne(() => Invoice) + @JoinColumn({ name: "invoice_id" }) + invoice?: Invoice; + + /** When finance put it on an invoice. */ + @Column({ name: "billed_at", type: "timestamptz", nullable: true }) + billedAt?: Date | null; + + /** When that invoice settled. */ + @Column({ name: "paid_at", type: "timestamptz", nullable: true }) + paidAt?: Date | null; + + @Column({ name: "cancelled_at", type: "timestamptz", nullable: true }) + cancelledAt?: Date | null; + + @Column({ + name: "cancellation_reason", + type: "varchar", + length: 255, + nullable: true, + }) + cancellationReason?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-bookings.controller.ts b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-bookings.controller.ts new file mode 100644 index 000000000..6c3fb60a0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-bookings.controller.ts @@ -0,0 +1,96 @@ +import { CurrentUser } from "@edr/api-common"; +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Post, +} from "@nestjs/common"; +import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; + +import { PortalCustomer } from "../../common/booking-guards"; +import { CancelShippingLineBookingDto } from "./dto/cancel-shipping-line-booking.dto"; +import { InitiateShippingLineBookingDto } from "./dto/initiate-shipping-line-booking.dto"; +import { ShippingLineBookingsService } from "./shipping-line-bookings.service"; + +interface CurrentIamUser { + id: string; +} + +/** + * Bookings a shipping line makes for itself, from the portal. + * + * Separate from `/bookings` (customers) on purpose — see + * {@link ShippingLineBookingsService} for why the two flows are not merged. + * `PortalCustomer` only proves a valid portal session; the service resolves the + * shipping-line account from that session and rejects anyone else, so the owner + * is never taken from the request body. + */ +@ApiTags("shipping-line-bookings") +@Controller("shipping-line-bookings") +@ApiBearerAuth() +export class ShippingLineBookingsController { + constructor( + private readonly shippingLineBookingsService: ShippingLineBookingsService, + ) {} + + @Post("initiate") + @PortalCustomer() + @ApiOperation({ + summary: + "Initiate a bare booking (no contract). Starts at AWAITING_DOCUMENTS so the shipping line can upload its documents for Operations to approve.", + }) + async initiate( + @CurrentUser() user: CurrentIamUser, + @Body() dto: InitiateShippingLineBookingDto, + ) { + return this.shippingLineBookingsService.initiate(user.id, dto); + } + + // Declared before @Get(":id") so the path isn't captured as a booking id. + @Get("reference-data") + @PortalCustomer() + @ApiOperation({ + summary: + "Catalog for the initiate form: bookable routes (each carrying its trade direction) and service types.", + }) + async referenceData(@CurrentUser() user: CurrentIamUser) { + return this.shippingLineBookingsService.referenceData(user.id); + } + + @Get("my") + @PortalCustomer() + @ApiOperation({ summary: "List the signed-in shipping line's bookings." }) + async listMine(@CurrentUser() user: CurrentIamUser) { + return this.shippingLineBookingsService.listMine(user.id); + } + + @Get(":id") + @PortalCustomer() + @ApiOperation({ summary: "Get one of the signed-in shipping line's bookings." }) + async findMine( + @CurrentUser() user: CurrentIamUser, + @Param("id", ParseUUIDPipe) id: string, + ) { + return this.shippingLineBookingsService.findMine(user.id, id); + } + + @Post(":id/cancel") + @PortalCustomer() + @ApiOperation({ + summary: + "Cancel one of the signed-in shipping line's own bookings. Allowed only before the booking is priced.", + }) + async cancelMine( + @CurrentUser() user: CurrentIamUser, + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: CancelShippingLineBookingDto, + ) { + return this.shippingLineBookingsService.cancelMine( + user.id, + id, + dto.reason, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-bookings.service.ts b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-bookings.service.ts new file mode 100644 index 000000000..7a7ca457b --- /dev/null +++ b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-bookings.service.ts @@ -0,0 +1,363 @@ +import { insertWithGeneratedReference } from "@edr/api-common"; +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { In, Repository } from "typeorm"; + +import { BookingDocumentReview } from "../bookings/entities/booking-document-review.entity"; +import { BookingReviewNote } from "../bookings/entities/booking-review-note.entity"; +import { Booking } from "../bookings/entities/booking.entity"; +import { formatRouteLabel, Route } from "../routes/entities/route.entity"; +import { ServiceType } from "../rule-engine/entities/service-type.entity"; +import { InitiateShippingLineBookingDto } from "./dto/initiate-shipping-line-booking.dto"; +import { ShippingLineCompaniesService } from "./shipping-line-companies.service"; + +/** + * The only trade direction a shipping line books. + * + * Their cargo arrives by sea at Djibouti and moves inland to Ethiopia, which is + * IMPORT by the rule routes are stamped with (DJ→ET = IMPORT, ET→DJ = EXPORT, + * same country = DOMESTIC). Export and intercity lanes are therefore neither + * offered nor accepted. + */ +const SHIPPING_LINE_DIRECTION = "IMPORT"; + +/** + * Statuses a shipping line may cancel its own booking from — everything before + * the booking is priced. Past this point cancelling has billing consequences + * (fees, credit notes) and belongs with Operations. + */ +const SHIPPING_LINE_CANCELLABLE_STATUSES: string[] = [ + "AWAITING_DOCUMENTS", + "DOCUMENTS_UNDER_REVIEW", + "CLEARANCE_READY", + "CHANGES_REQUESTED", +]; + +/** + * Booking creation for shipping lines. + * + * Deliberately separate from `BookingsService` / `ContractBookingService` + * rather than a branch inside them. Those are built end to end around a + * customer: a `companies` row, an approved operational `company_profile`, a + * contract supplying route/quantities, and contract-capacity accounting. A + * shipping line has none of that — it books directly, without a contract — so + * branching there would mean threading "no company, no profile, no contract" + * through every method a customer booking passes through. Keeping it here means + * the customer paths are not touched at all. + * + * What IS shared is the table and the downstream lifecycle: the row lands in + * `freight.bookings` at `AWAITING_DOCUMENTS`, the shipping line uploads its + * documents against the `shipping_line_booking_documents` file-upload setting, + * and Operations reviews and finalizes them through the same clearance flow + * customers already use. + */ +@Injectable() +export class ShippingLineBookingsService { + constructor( + @InjectRepository(Booking) + private readonly bookingsRepository: Repository, + private readonly shippingLineCompaniesService: ShippingLineCompaniesService, + ) {} + + /** + * Resolve the shipping-line account for a signed-in user, or reject. Every + * entry point goes through this: the owner is taken from the session, never + * from the request body, so one shipping line cannot book as another. + */ + private async requireShippingLine(userId: string) { + const shippingLine = + await this.shippingLineCompaniesService.findByUserId(userId); + if (!shippingLine) { + throw new ForbiddenException("This account is not a shipping line."); + } + if (shippingLine.status !== "active") { + throw new ForbiddenException( + "This shipping-line account is suspended and cannot create bookings.", + ); + } + return shippingLine; + } + + /** + * The catalog the initiate form needs: the lanes EDR actually runs, and the + * services that can be booked on their own. + * + * Routes are offered instead of two loose yard pickers so a shipping line + * cannot invent a lane that does not exist — and because the route already + * carries its trade direction, which is otherwise guesswork. + * + * Read-only and scoped to bookable rows, which is why it lives here rather + * than reusing the staff `/routes` controller (gated behind fleet + * permissions a shipping line does not and should not hold). + */ + async referenceData(userId: string) { + await this.requireShippingLine(userId); + + const [routes, serviceTypes] = await Promise.all([ + this.bookingsRepository.manager.getRepository(Route).find({ + // Shipping lines only move inbound cargo: it lands at the Djibouti port + // and runs inland to Ethiopia. Filtering here rather than in the portal + // means an export or intercity lane is never offered AND never + // accepted — `initiate` re-checks the same rule below. + where: { status: "AVAILABLE", direction: SHIPPING_LINE_DIRECTION }, + relations: { originYard: true, destinationYard: true }, + }), + // Customs-bundled services are excluded: those run the phased ET/DJ + // customs workflow, which is a contract-backed flow a shipping line has + // no part in. Their clearance is the single document set Operations + // reviews on the booking itself. + this.bookingsRepository.manager.getRepository(ServiceType).find({ + where: { + canBeBookedAlone: true, + includesCustoms: false, + isActive: true, + }, + order: { displayOrder: "ASC" }, + }), + ]); + + return { + routes: routes.map((route) => ({ + id: route.id, + label: formatRouteLabel(route), + direction: route.direction, + originYardId: route.originYardId, + // Per-yard labels so the portal can offer origin and destination as two + // separate pickers (the shape the customer form uses) while still + // resolving the pair back to one of these routes. + originLabel: + route.originYard?.label ?? route.originYard?.code ?? "Origin", + destinationYardId: route.destinationYardId, + destinationLabel: + route.destinationYard?.label ?? + route.destinationYard?.code ?? + "Destination", + })), + serviceTypes: serviceTypes.map((service) => ({ + id: service.id, + name: service.serviceName, + })), + }; + } + + /** + * Create a BARE booking for a shipping line — no contract, no cargo, no date + * and no price. It exists so documents have something to hang off: the + * shipping line uploads them next, Operations approves, and only then is the + * booking completed with its cargo and shipment day. + */ + async initiate(userId: string, dto: InitiateShippingLineBookingDto) { + const shippingLine = await this.requireShippingLine(userId); + + // The route is the single source of origin, destination AND direction — + // resolved server-side so the three can never disagree, and so a caller + // cannot post a lane EDR does not run. + const route = await this.bookingsRepository.manager + .getRepository(Route) + .findOne({ where: { id: dto.routeId } }); + if (!route) { + throw new NotFoundException(`Route ${dto.routeId} not found`); + } + if (route.status !== "AVAILABLE") { + throw new BadRequestException( + "This route is not currently available for booking.", + ); + } + // Enforced here too, not just by filtering the picker: the route id comes + // from the request, so an export or intercity lane could otherwise be + // posted directly. + if (route.direction !== SHIPPING_LINE_DIRECTION) { + throw new BadRequestException( + "Shipping lines can only book inbound (Djibouti to Ethiopia) routes.", + ); + } + + // Same reasoning as the picker filter: a customs-bundled service would put + // the booking into the phased customs workflow, which has no contract to + // hang off here. Checked server-side because the id comes from the request. + if (dto.serviceTypeId) { + const serviceType = await this.bookingsRepository.manager + .getRepository(ServiceType) + .findOne({ where: { id: dto.serviceTypeId } }); + if (!serviceType) { + throw new NotFoundException( + `Service type ${dto.serviceTypeId} not found`, + ); + } + if (serviceType.includesCustoms) { + throw new BadRequestException( + "Shipping lines cannot book a service that bundles customs clearance.", + ); + } + } + + return insertWithGeneratedReference( + () => this.generateReference(), + (reference) => + this.bookingsRepository.save({ + reference, + // The owner columns: a shipping-line booking has no company and no + // operational profile, which is exactly what the `chk_bookings_ + // single_owner` CHECK expects alongside a set shippingLineCompanyId. + companyId: null, + companyProfileId: null, + shippingLineCompanyId: shippingLine.id, + status: "AWAITING_DOCUMENTS", + bookingType: "ONE_TIME", + contractId: null, + contractType: "NEW", + createdByRole: "SHIPPING_LINE", + createdByUserId: userId, + // Taken from the chosen route, never from the request body: the + // direction is frozen on the route from its yard countries, so + // deriving it here keeps it consistent with scheduling and booking + // windows, which read the same field. + originYardId: route.originYardId, + destinationYardId: route.destinationYardId, + tradeDirection: route.direction, + serviceTypeId: dto.serviceTypeId ?? null, + freightType: dto.freightType ?? "CONTAINER", + // Bare instance — filled in when the booking is completed. + scheduledDate: null, + cargoTypeId: null, + cargoTotalWeightVgm: 0, + } as never), + ); + } + + /** + * List the bookings belonging to the signed-in shipping line, newest first. + * + * Each row carries `hasQueriedDocuments`: a reviewer querying a document sets + * that document's review status but leaves the BOOKING on + * DOCUMENTS_UNDER_REVIEW, so status alone cannot tell the list which bookings + * need the shipping line to act. Resolved in one grouped query rather than a + * clearance call per row. + */ + async listMine(userId: string) { + const shippingLine = await this.requireShippingLine(userId); + + const bookings = await this.bookingsRepository.find({ + where: { shippingLineCompanyId: shippingLine.id }, + relations: { originYard: true, destinationYard: true }, + order: { createdAt: "DESC" }, + }); + if (bookings.length === 0) return []; + + const queried = await this.bookingsRepository.manager + .getRepository(BookingDocumentReview) + .find({ + where: { + bookingId: In(bookings.map((b) => b.id)), + status: "QUERIED", + }, + select: { bookingId: true }, + }); + + const queriedIds = new Set(queried.map((row) => row.bookingId)); + + return bookings.map((booking) => ({ + ...booking, + hasQueriedDocuments: queriedIds.has(booking.id), + })); + } + + /** + * Fetch one of the signed-in shipping line's own bookings. Scoped by owner so + * an id belonging to a customer (or another shipping line) reads as missing. + */ + async findMine(userId: string, bookingId: string) { + const shippingLine = await this.requireShippingLine(userId); + + const booking = await this.bookingsRepository.findOne({ + where: { id: bookingId, shippingLineCompanyId: shippingLine.id }, + // Yards are loaded so the portal can render the lane without a second + // lookup — they are set at initiate time from the chosen route. + relations: { originYard: true, destinationYard: true, serviceType: true }, + }); + if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); + + // Same flag as the list — see listMine for why booking status alone is not + // enough to tell whether the shipping line has something to fix. + const queriedCount = await this.bookingsRepository.manager + .getRepository(BookingDocumentReview) + .count({ where: { bookingId, status: "QUERIED" } }); + + return { ...booking, hasQueriedDocuments: queriedCount > 0 }; + } + + /** + * Cancel one of the signed-in shipping line's own bookings. + * + * Its own method rather than the customer `customerCancel`: that path routes + * into `BookingTransitionService.cancel`, whose status whitelist covers the + * contract-backed lifecycle (DRAFT, SUBMITTED, PENDING_APPROVAL…) and does + * not include the document-clearance statuses a shipping-line booking lives + * in — so it would reject every one of them. + * + * Only allowed before the booking is priced and paid. Once it carries a + * charge, cancelling is a billing decision (fees, credit notes) that belongs + * with Operations, not a self-service button. + */ + async cancelMine(userId: string, bookingId: string, reason?: string) { + const shippingLine = await this.requireShippingLine(userId); + + const booking = await this.bookingsRepository.findOne({ + where: { id: bookingId, shippingLineCompanyId: shippingLine.id }, + }); + if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); + + if (booking.status === "CANCELLED") { + throw new BadRequestException("This booking is already cancelled."); + } + if (!SHIPPING_LINE_CANCELLABLE_STATUSES.includes(booking.status)) { + throw new BadRequestException( + "This booking can no longer be cancelled — please contact Operations.", + ); + } + // Belt and braces: the statuses above are all pre-pricing, so a charge here + // would mean the booking moved on in a way this guard did not anticipate. + if (Number(booking.totalAmount ?? 0) > 0) { + throw new BadRequestException( + "This booking has already been priced — please contact Operations to cancel it.", + ); + } + + // The reason lives on the booking's review-note log, the same place the + // customer cancel path records it — there is no column for it. + await this.bookingsRepository.manager + .getRepository(BookingReviewNote) + .save({ + bookingId, + note: reason?.trim() || "Cancelled by the shipping line", + type: "REJECTION", + authorId: userId, + } as never); + + await this.bookingsRepository.update(bookingId, { + status: "CANCELLED", + } as never); + + return this.findMine(userId, bookingId); + } + + /** Mirrors the customer reference format — one booking sequence per year. */ + private async generateReference(): Promise { + const year = new Date().getFullYear(); + const { max } = (await this.bookingsRepository + .createQueryBuilder("b") + .select( + `COALESCE(MAX(NULLIF(regexp_replace(b.reference, '^BK-${year}-', ''), b.reference)::int), 0)`, + "max", + ) + .where("b.reference LIKE :prefix", { prefix: `BK-${year}-%` }) + .getRawOne<{ max: number }>()) ?? { max: 0 }; + + return `BK-${year}-${String(Number(max) + 1).padStart(6, "0")}`; + } +} diff --git a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.module.ts b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.module.ts index 81d5b9cdf..698dfb384 100644 --- a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.module.ts +++ b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.module.ts @@ -1,24 +1,55 @@ -import { Module } from "@nestjs/common"; +import { Module, forwardRef } from "@nestjs/common"; import { TypeOrmModule } from "@nestjs/typeorm"; import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity"; import { FreightAuthModule } from "../auth/freight-auth.module"; +import { BillingModule } from "../billing/billing.module"; +import { Booking } from "../bookings/entities/booking.entity"; import { OtpModule } from "../otp/otp.module"; import { ShippingLineCompany } from "./entities/shipping-line-company.entity"; +import { ShippingLineCredit } from "./entities/shipping-line-credit.entity"; +import { ShippingLineBookingsController } from "./shipping-line-bookings.controller"; +import { ShippingLineBookingsService } from "./shipping-line-bookings.service"; import { ShippingLineCompaniesController } from "./shipping-line-companies.controller"; import { ShippingLineCompaniesRepository } from "./shipping-line-companies.repository"; import { ShippingLineCompaniesService } from "./shipping-line-companies.service"; +import { ShippingLineCreditsController } from "./shipping-line-credits.controller"; +import { ShippingLineCreditsRepository } from "./shipping-line-credits.repository"; +import { ShippingLineCreditsService } from "./shipping-line-credits.service"; @Module({ imports: [ - TypeOrmModule.forFeature([ShippingLineCompany, User]), + // Booking is registered here only so this module can create shipping-line + // rows in `freight.bookings`; the customer BookingsModule is untouched. + TypeOrmModule.forFeature([ + ShippingLineCompany, + ShippingLineCredit, + User, + Booking, + ]), // CustomerResetService — activation links reuse the staff-triggered reset path. FreightAuthModule, OtpModule, + // Credits are billed by generating an ordinary invoice. Billing still knows + // nothing about credits and hears about settlement only by emitting its own + // `shipping_line_credit.invoice.paid` event, but the module graph now cycles + // (billing -> companies -> here -> billing), so this edge needs forwardRef. + forwardRef(() => BillingModule), ], - controllers: [ShippingLineCompaniesController], - providers: [ShippingLineCompaniesService, ShippingLineCompaniesRepository], - exports: [ShippingLineCompaniesService], + controllers: [ + ShippingLineCompaniesController, + ShippingLineBookingsController, + ShippingLineCreditsController, + ], + providers: [ + ShippingLineCompaniesService, + ShippingLineCompaniesRepository, + ShippingLineBookingsService, + ShippingLineCreditsService, + ShippingLineCreditsRepository, + ], + // Exported so whatever prices a shipping-line booking can record the charge. + exports: [ShippingLineCompaniesService, ShippingLineCreditsService], }) export class ShippingLineCompaniesModule {} diff --git a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-credits.controller.ts b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-credits.controller.ts new file mode 100644 index 000000000..2ac618afe --- /dev/null +++ b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-credits.controller.ts @@ -0,0 +1,127 @@ +import { CurrentUser } from "@edr/api-common"; +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Post, + Query, +} from "@nestjs/common"; +import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; + +import { BookingStaff, PortalCustomer } from "../../common/booking-guards"; +import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; +import { + CancelCreditDto, + GenerateCreditInvoiceDto, +} from "./dto/shipping-line-credit.dto"; +import { ShippingLineCreditStatus } from "./entities/shipping-line-credit.entity"; +import { ShippingLineCreditsService } from "./shipping-line-credits.service"; + +interface CurrentIamUser { + id: string; +} + +/** + * Finance's view of what shipping lines owe. + * + * A shipping line books and ships without paying — the charge is recorded as a + * credit instead. Finance reads the unbilled list here, batches it into an + * invoice, and the line then pays that invoice through the ordinary + * `/billing` + CBE routes; nothing in this controller touches money directly. + */ +@ApiTags("shipping-line-credits") +@Controller("shipping-line-credits") +@ApiBearerAuth() +export class ShippingLineCreditsController { + constructor(private readonly credits: ShippingLineCreditsService) {} + + // Declared before the parameterised staff routes so "me" is never captured + // as a shipping-line id. + @Get("me") + @PortalCustomer() + @ApiOperation({ + summary: + "The signed-in shipping line's own statement: outstanding balance plus its credit ledger.", + }) + async myStatement( + @CurrentUser() user: CurrentIamUser, + @Query("page") page?: string, + @Query("pageSize") pageSize?: string, + ) { + return this.credits.myStatement( + user.id, + page ? Number(page) : 1, + pageSize ? Number(pageSize) : 20, + ); + } + + @Get(":shippingLineId/outstanding") + @BookingStaff(FREIGHT_PERMS.shippingLineCredits.view) + @ApiOperation({ + summary: + "What one shipping line owes: unbilled + billed totals, derived from the ledger.", + }) + async outstanding( + @Param("shippingLineId", ParseUUIDPipe) shippingLineId: string, + ) { + return this.credits.outstanding(shippingLineId); + } + + @Get(":shippingLineId/unbilled") + @BookingStaff(FREIGHT_PERMS.shippingLineCredits.view) + @ApiOperation({ + summary: + "Credits that can go on an invoice for this line, oldest first. This is the selection list.", + }) + async listUnbilled( + @Param("shippingLineId", ParseUUIDPipe) shippingLineId: string, + ) { + return this.credits.listUnbilled(shippingLineId); + } + + @Get(":shippingLineId") + @BookingStaff(FREIGHT_PERMS.shippingLineCredits.view) + @ApiOperation({ + summary: "Full credit ledger for one shipping line (paginated).", + }) + async listCredits( + @Param("shippingLineId", ParseUUIDPipe) shippingLineId: string, + @Query("page") page?: string, + @Query("pageSize") pageSize?: string, + @Query("status") status?: ShippingLineCreditStatus, + ) { + return this.credits.listCredits( + shippingLineId, + page ? Number(page) : 1, + pageSize ? Number(pageSize) : 20, + status, + ); + } + + @Post("invoice") + @BookingStaff(FREIGHT_PERMS.shippingLineCredits.invoice) + @ApiOperation({ + summary: + "Bill a batch of unbilled credits as one invoice. All credits must belong to the same shipping line.", + }) + async generateInvoice(@Body() dto: GenerateCreditInvoiceDto) { + return this.credits.generateInvoice(dto.creditIds, { + dueInDays: dto.dueInDays, + }); + } + + @Post(":creditId/cancel") + @BookingStaff(FREIGHT_PERMS.shippingLineCredits.cancel) + @ApiOperation({ + summary: + "Write off an unbilled credit. Once billed, cancel the invoice instead.", + }) + async cancel( + @Param("creditId", ParseUUIDPipe) creditId: string, + @Body() dto: CancelCreditDto, + ) { + return this.credits.cancelCredit(creditId, dto.reason); + } +} diff --git a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-credits.repository.ts b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-credits.repository.ts new file mode 100644 index 000000000..fac89e0df --- /dev/null +++ b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-credits.repository.ts @@ -0,0 +1,138 @@ +import { BaseRepository } from "@edr/api-common"; +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { EntityManager, In, Repository } from "typeorm"; + +import { + OUTSTANDING_CREDIT_STATUSES, + ShippingLineCredit, + ShippingLineCreditStatus, +} from "./entities/shipping-line-credit.entity"; + +/** What one shipping line currently owes, split by billing stage. */ +export interface OutstandingTotals { + /** Priced but not yet on an invoice. */ + unbilledAmount: number; + /** On an issued invoice, awaiting payment. */ + billedAmount: number; + /** `unbilledAmount + billedAmount` — the full debt. */ + totalOutstanding: number; + unbilledCount: number; + billedCount: number; + currency: string; +} + +@Injectable() +export class ShippingLineCreditsRepository extends BaseRepository { + constructor( + @InjectRepository(ShippingLineCredit) + private readonly credits: Repository, + ) { + super(credits); + } + + findByBookingId(bookingId: string): Promise { + return this.credits.findOne({ where: { bookingId } }); + } + + /** + * Finance's worklist: everything for one line that can go on an invoice, + * oldest first so the longest-standing debt is billed before newer charges. + */ + findUnbilled(shippingLineCompanyId: string): Promise { + return this.credits.find({ + where: { + shippingLineCompanyId, + status: ShippingLineCreditStatus.Unbilled, + }, + relations: { booking: true }, + order: { createdAt: "ASC" }, + }); + } + + findByInvoiceId( + invoiceId: string, + manager?: EntityManager, + ): Promise { + const repo = manager + ? manager.getRepository(ShippingLineCredit) + : this.credits; + return repo.find({ where: { invoiceId } }); + } + + /** + * Load a specific batch inside the caller's transaction and lock it, so two + * concurrent invoice generations cannot both claim the same credits. + */ + findByIdsForUpdate( + manager: EntityManager, + ids: string[], + ): Promise { + return manager.getRepository(ShippingLineCredit).find({ + where: { id: In(ids) }, + lock: { mode: "pessimistic_write" }, + }); + } + + /** + * Derived debt — never a stored column. Grouped in one query so the detail + * page does not fan out per status. + */ + async outstandingFor( + shippingLineCompanyId: string, + ): Promise { + const rows = await this.credits + .createQueryBuilder("credit") + .select("credit.status", "status") + .addSelect("COALESCE(SUM(credit.amount), 0)", "amount") + .addSelect("COUNT(*)", "count") + .where("credit.shippingLineCompanyId = :shippingLineCompanyId", { + shippingLineCompanyId, + }) + .andWhere("credit.status IN (:...statuses)", { + statuses: [...OUTSTANDING_CREDIT_STATUSES], + }) + .andWhere("credit.deletedAt IS NULL") + .groupBy("credit.status") + .getRawMany<{ status: string; amount: string; count: string }>(); + + const totals = (status: ShippingLineCreditStatus) => { + const row = rows.find((r) => r.status === status); + return { + amount: row ? Number(row.amount) : 0, + count: row ? Number(row.count) : 0, + }; + }; + + const unbilled = totals(ShippingLineCreditStatus.Unbilled); + const billed = totals(ShippingLineCreditStatus.Billed); + + return { + unbilledAmount: unbilled.amount, + billedAmount: billed.amount, + totalOutstanding: unbilled.amount + billed.amount, + unbilledCount: unbilled.count, + billedCount: billed.count, + currency: "ETB", + }; + } + + /** Paginated ledger for one line — every credit, whatever its status. */ + findAllPaginated( + shippingLineCompanyId: string, + skip: number, + take: number, + status?: ShippingLineCreditStatus, + ): Promise<[ShippingLineCredit[], number]> { + return this.credits.findAndCount({ + where: { + shippingLineCompanyId, + ...(status ? { status } : {}), + }, + relations: { booking: true, invoice: true }, + order: { createdAt: "DESC" }, + skip, + take, + }); + } +} diff --git a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-credits.service.spec.ts b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-credits.service.spec.ts new file mode 100644 index 000000000..0348d08bd --- /dev/null +++ b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-credits.service.spec.ts @@ -0,0 +1,296 @@ +import { BadRequestException, NotFoundException } from "@nestjs/common"; +import { Freight } from "@edr/types"; + +import { Booking } from "../bookings/entities/booking.entity"; +import { + ShippingLineCredit, + ShippingLineCreditStatus, +} from "./entities/shipping-line-credit.entity"; +import { ShippingLineCreditsService } from "./shipping-line-credits.service"; + +/** + * The money path: a shipping line ships without paying, so the debt lives + * entirely in these three transitions. Each test below locks one way the debt + * could be lost or double-counted. + */ +describe("ShippingLineCreditsService", () => { + let creditsRepo: { + findByIdsForUpdate: jest.Mock; + findUnbilled: jest.Mock; + outstandingFor: jest.Mock; + findAllPaginated: jest.Mock; + }; + let billing: { generateInvoice: jest.Mock }; + let shippingLines: { findById: jest.Mock; findByUserId: jest.Mock }; + let dataSource: { transaction: jest.Mock; getRepository: jest.Mock }; + let mg: { + findOne: jest.Mock; + getRepository: jest.Mock; + update: jest.Mock; + }; + let txRepo: { findOne: jest.Mock; save: jest.Mock; create: jest.Mock }; + let updateResult: { affected: number }; + let service: ShippingLineCreditsService; + + const booking = { + id: "booking-1", + reference: "BK-2026-000001", + shippingLineCompanyId: "sl-1", + } as Booking; + + beforeEach(() => { + txRepo = { + findOne: jest.fn().mockResolvedValue(null), + create: jest.fn((v) => v), + save: jest.fn(async (v) => ({ id: "credit-1", ...v })), + }; + mg = { + findOne: jest.fn().mockResolvedValue(booking), + getRepository: jest.fn(() => txRepo), + update: jest.fn().mockResolvedValue({ affected: 1 }), + }; + updateResult = { affected: 2 }; + dataSource = { + transaction: jest.fn(async (cb) => cb(mg)), + getRepository: jest.fn(() => ({ + update: jest.fn().mockResolvedValue(updateResult), + })), + }; + creditsRepo = { + findByIdsForUpdate: jest.fn(), + findUnbilled: jest.fn(), + outstandingFor: jest.fn(), + findAllPaginated: jest.fn(), + }; + billing = { + generateInvoice: jest.fn().mockResolvedValue({ + id: "inv-1", + invoiceNumber: "INV-20260813-00001", + totalAmount: 50000, + }), + }; + shippingLines = { + findById: jest.fn().mockResolvedValue({ id: "sl-1", name: "ESL" }), + findByUserId: jest.fn(), + }; + + service = new ShippingLineCreditsService( + dataSource as never, + creditsRepo as never, + billing as never, + shippingLines as never, + ); + }); + + describe("recordCredit", () => { + it("records the charge against the booking's own shipping line", async () => { + const credit = await service.recordCredit({ + bookingId: "booking-1", + amount: 20000, + }); + + expect(txRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ + // Taken from the booking, never from the caller. + shippingLineCompanyId: "sl-1", + bookingId: "booking-1", + amount: 20000, + status: ShippingLineCreditStatus.Unbilled, + }), + ); + expect(credit.id).toBe("credit-1"); + }); + + it("is idempotent per booking — a retried pricing step cannot double the debt", async () => { + const existing = { + id: "credit-existing", + status: ShippingLineCreditStatus.Unbilled, + amount: 20000, + currency: "ETB", + }; + txRepo.findOne.mockResolvedValue(existing); + + const credit = await service.recordCredit({ + bookingId: "booking-1", + amount: 20000, + }); + + expect(credit).toBe(existing); + expect(txRepo.save).not.toHaveBeenCalled(); + }); + + it("refuses a customer booking — those are paid up front, not on credit", async () => { + mg.findOne.mockResolvedValue({ + ...booking, + shippingLineCompanyId: null, + }); + + await expect( + service.recordCredit({ bookingId: "booking-1", amount: 100 }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("rejects a negative amount", async () => { + await expect( + service.recordCredit({ bookingId: "booking-1", amount: -1 }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + }); + + describe("generateInvoice", () => { + const unbilled = (id: string, amount: number) => ({ + id, + shippingLineCompanyId: "sl-1", + bookingId: `booking-${id}`, + amount, + currency: "ETB", + status: ShippingLineCreditStatus.Unbilled, + description: `Freight service — ${id}`, + }); + + it("bills the batch as one invoice and flips the credits to BILLED", async () => { + creditsRepo.findByIdsForUpdate.mockResolvedValue([ + unbilled("c1", 20000), + unbilled("c2", 30000), + ]); + + const invoice = await service.generateInvoice(["c1", "c2"]); + + expect(billing.generateInvoice).toHaveBeenCalledWith( + expect.objectContaining({ + source: Freight.InvoiceSource.ShippingLineCredit, + // The payer, not a customer — invoices.company_id stays null. + shippingLineCompanyId: "sl-1", + sourceId: "sl-1", + status: Freight.InvoiceStatus.Issued, + lines: [ + expect.objectContaining({ amount: 20000 }), + expect.objectContaining({ amount: 30000 }), + ], + }), + mg, + ); + expect(mg.update).toHaveBeenCalledWith( + ShippingLineCredit, + expect.anything(), + expect.objectContaining({ + status: ShippingLineCreditStatus.Billed, + invoiceId: "inv-1", + }), + ); + expect(invoice.id).toBe("inv-1"); + }); + + it("refuses to bill a credit that is already on an invoice", async () => { + creditsRepo.findByIdsForUpdate.mockResolvedValue([ + { ...unbilled("c1", 20000), status: ShippingLineCreditStatus.Billed }, + ]); + + await expect(service.generateInvoice(["c1"])).rejects.toBeInstanceOf( + BadRequestException, + ); + expect(billing.generateInvoice).not.toHaveBeenCalled(); + }); + + it("refuses to mix two shipping lines on one invoice", async () => { + creditsRepo.findByIdsForUpdate.mockResolvedValue([ + unbilled("c1", 20000), + { ...unbilled("c2", 30000), shippingLineCompanyId: "sl-2" }, + ]); + + await expect( + service.generateInvoice(["c1", "c2"]), + ).rejects.toBeInstanceOf(BadRequestException); + expect(billing.generateInvoice).not.toHaveBeenCalled(); + }); + + it("refuses to mix currencies", async () => { + creditsRepo.findByIdsForUpdate.mockResolvedValue([ + unbilled("c1", 20000), + { ...unbilled("c2", 300), currency: "USD" }, + ]); + + await expect( + service.generateInvoice(["c1", "c2"]), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("reports ids that do not exist rather than silently billing the rest", async () => { + creditsRepo.findByIdsForUpdate.mockResolvedValue([unbilled("c1", 20000)]); + + await expect( + service.generateInvoice(["c1", "missing"]), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it("rejects an empty selection", async () => { + await expect(service.generateInvoice([])).rejects.toBeInstanceOf( + BadRequestException, + ); + }); + }); + + describe("onInvoicePaid", () => { + it("clears every billed credit on the settled invoice", async () => { + const update = jest.fn().mockResolvedValue({ affected: 2 }); + dataSource.getRepository = jest.fn(() => ({ update })); + + await service.onInvoicePaid({ + invoiceId: "inv-1", + invoiceNumber: "INV-20260813-00001", + } as never); + + expect(update).toHaveBeenCalledWith( + // Scoped to BILLED so a redelivered webhook cannot re-stamp paidAt. + { invoiceId: "inv-1", status: ShippingLineCreditStatus.Billed }, + expect.objectContaining({ status: ShippingLineCreditStatus.Paid }), + ); + }); + + it("is a no-op on webhook redelivery", async () => { + const update = jest.fn().mockResolvedValue({ affected: 0 }); + dataSource.getRepository = jest.fn(() => ({ update })); + + await expect( + service.onInvoicePaid({ + invoiceId: "inv-1", + invoiceNumber: "INV-1", + } as never), + ).resolves.toBeUndefined(); + }); + }); + + describe("cancelCredit", () => { + it("writes off an unbilled credit", async () => { + creditsRepo.findByIdsForUpdate.mockResolvedValue([ + { id: "c1", status: ShippingLineCreditStatus.Unbilled }, + ]); + + const result = await service.cancelCredit("c1", "Booking voided"); + + expect(result.status).toBe(ShippingLineCreditStatus.Cancelled); + expect(mg.update).toHaveBeenCalledWith( + ShippingLineCredit, + { id: "c1" }, + expect.objectContaining({ + status: ShippingLineCreditStatus.Cancelled, + cancellationReason: "Booking voided", + }), + ); + }); + + it("refuses to write off a credit already on an invoice", async () => { + creditsRepo.findByIdsForUpdate.mockResolvedValue([ + { + id: "c1", + status: ShippingLineCreditStatus.Billed, + invoiceId: "inv-1", + }, + ]); + + await expect( + service.cancelCredit("c1", "oops"), + ).rejects.toBeInstanceOf(BadRequestException); + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-credits.service.ts b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-credits.service.ts new file mode 100644 index 000000000..4241a796f --- /dev/null +++ b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-credits.service.ts @@ -0,0 +1,420 @@ +import { logCtx } from "@edr/api-common"; +import { Freight } from "@edr/types"; +import { + BadRequestException, + ForbiddenException, + Injectable, + Logger, + NotFoundException, +} from "@nestjs/common"; +import { OnEvent } from "@nestjs/event-emitter"; +import { DataSource, EntityManager, In } from "typeorm"; + +import { + BillingService, + InvoiceEventPayload, + InvoiceLineInput, +} from "../billing/billing.service"; +import { Invoice } from "../billing/entities/invoice.entity"; +import { Booking } from "../bookings/entities/booking.entity"; +import { + ShippingLineCredit, + ShippingLineCreditStatus, +} from "./entities/shipping-line-credit.entity"; +import { ShippingLineCreditsRepository } from "./shipping-line-credits.repository"; +import { ShippingLineCompaniesService } from "./shipping-line-companies.service"; + +/** A charge to record against a shipping line's booking. */ +export interface RecordCreditInput { + bookingId: string; + /** Frozen at this value; never recalculated afterwards. */ + amount: number; + currency?: string; + description?: string; +} + +/** Payment terms for a generated shipping-line invoice. */ +export interface GenerateCreditInvoiceOptions { + /** Pay window in days; defaults to the billing module's own default. */ + dueInDays?: number; +} + +/** + * The credit ledger for shipping lines — "use the service now, pay later". + * + * Three moments, in order: + * + * 1. **Charge.** A shipping line's booking is priced, and + * {@link recordCredit} writes an UNBILLED credit. No invoice, no payment + * intent, no gate on the booking — it proceeds regardless. + * 2. **Bill.** Finance picks a batch of unbilled credits for ONE line and + * {@link generateInvoice} turns them into a single invoice, one line per + * credit. The credits become BILLED. + * 3. **Settle.** The line pays that invoice through the ordinary CBE flow. + * Billing emits `shipping_line_credit.invoice.paid`, {@link onInvoicePaid} + * marks the batch PAID, and the debt disappears. + * + * Nothing here decrements a balance: the amount owed is always + * `SUM(amount)` over non-terminal credits. Payment is settled by the gateway + * webhook alone — no manual approval step — so a credit only ever leaves debt + * because real money arrived. + */ +@Injectable() +export class ShippingLineCreditsService { + private readonly logger = new Logger(ShippingLineCreditsService.name); + + constructor( + private readonly dataSource: DataSource, + private readonly credits: ShippingLineCreditsRepository, + private readonly billing: BillingService, + private readonly shippingLines: ShippingLineCompaniesService, + ) {} + + // ── 1. Charge ────────────────────────────────────────────────────────────── + + /** + * Record what a shipping line owes for one booking. + * + * Called when the booking is priced. The owner is read off the booking + * itself rather than passed in, so a credit can never be filed against the + * wrong line. Idempotent per booking: a second call returns the existing + * credit untouched rather than doubling the debt — safe against a retried + * pricing step, and the partial unique index backs it at the DB level. + * + * Pass `manager` to enlist in the caller's transaction, so the credit and + * whatever priced the booking commit together. + */ + async recordCredit( + input: RecordCreditInput, + manager?: EntityManager, + ): Promise { + if (!(input.amount >= 0)) { + throw new BadRequestException("Credit amount cannot be negative."); + } + + const run = async (mg: EntityManager): Promise => { + const booking = await mg.findOne(Booking, { + where: { id: input.bookingId }, + }); + if (!booking) { + throw new NotFoundException(`Booking ${input.bookingId} not found`); + } + if (!booking.shippingLineCompanyId) { + throw new BadRequestException( + `Booking ${booking.reference} is not a shipping-line booking — customer bookings are billed up front, not on credit.`, + ); + } + + const repo = mg.getRepository(ShippingLineCredit); + const existing = await repo.findOne({ + where: { bookingId: input.bookingId }, + }); + if (existing && existing.status !== ShippingLineCreditStatus.Cancelled) { + this.logger.warn( + `Credit already exists for booking ${booking.reference} (${existing.status}, ${existing.amount} ${existing.currency}) — leaving it unchanged.`, + ); + return existing; + } + + const credit = await repo.save( + repo.create({ + shippingLineCompanyId: booking.shippingLineCompanyId, + bookingId: input.bookingId, + amount: input.amount, + currency: input.currency ?? "ETB", + description: + input.description ?? `Freight service — booking ${booking.reference}`, + status: ShippingLineCreditStatus.Unbilled, + }), + ); + + logCtx( + { + creditId: credit.id, + bookingId: credit.bookingId, + shippingLineCompanyId: credit.shippingLineCompanyId, + amount: credit.amount, + }, + { path: "shippingLineCredit.recorded" }, + ); + + return credit; + }; + + return manager ? run(manager) : this.dataSource.transaction(run); + } + + // ── 2. Bill ──────────────────────────────────────────────────────────────── + + /** + * Turn a batch of unbilled credits into one invoice. + * + * Every credit must belong to the SAME shipping line — one invoice has one + * payer, so a mixed batch is rejected rather than silently split. The whole + * thing runs in one transaction with the credits locked FOR UPDATE, so two + * finance users clicking at once cannot bill the same credit twice: the + * second transaction blocks, then finds the rows already BILLED and fails. + */ + async generateInvoice( + creditIds: string[], + options: GenerateCreditInvoiceOptions = {}, + ): Promise { + if (creditIds.length === 0) { + throw new BadRequestException( + "Select at least one credit to invoice.", + ); + } + const uniqueIds = [...new Set(creditIds)]; + + return this.dataSource.transaction(async (mg) => { + const credits = await this.credits.findByIdsForUpdate(mg, uniqueIds); + + const missing = uniqueIds.filter( + (id) => !credits.some((c) => c.id === id), + ); + if (missing.length > 0) { + throw new NotFoundException( + `Credit(s) not found: ${missing.join(", ")}`, + ); + } + + const alreadyBilled = credits.filter( + (c) => c.status !== ShippingLineCreditStatus.Unbilled, + ); + if (alreadyBilled.length > 0) { + throw new BadRequestException( + `These credits are no longer unbilled and cannot be invoiced: ${alreadyBilled + .map((c) => `${c.id} (${c.status})`) + .join(", ")}`, + ); + } + + const lineIds = new Set(credits.map((c) => c.shippingLineCompanyId)); + if (lineIds.size > 1) { + throw new BadRequestException( + "All selected credits must belong to the same shipping line — one invoice has one payer.", + ); + } + const shippingLineCompanyId = credits[0].shippingLineCompanyId; + + const currencies = new Set(credits.map((c) => c.currency)); + if (currencies.size > 1) { + throw new BadRequestException( + `Cannot mix currencies on one invoice: ${[...currencies].join(", ")}.`, + ); + } + const currency = credits[0].currency; + + const shippingLine = await this.shippingLines.findById( + shippingLineCompanyId, + ); + if (!shippingLine) { + throw new NotFoundException( + `Shipping line ${shippingLineCompanyId} not found`, + ); + } + + const lines: InvoiceLineInput[] = credits.map((credit) => ({ + chargeType: "SHIPPING_LINE_SERVICE", + description: credit.description ?? undefined, + quantity: 1, + unitRate: Number(credit.amount), + amount: Number(credit.amount), + currency: credit.currency, + metadata: { creditId: credit.id, bookingId: credit.bookingId }, + })); + + const invoice = await this.billing.generateInvoice( + { + source: Freight.InvoiceSource.ShippingLineCredit, + // Unlike other sources this is the payer, not a single billed + // record: the invoice spans many bookings, and each credit keeps its + // own booking link. + sourceId: shippingLineCompanyId, + type: "SHIPPING_LINE_CREDIT", + shippingLineCompanyId, + currency, + lines, + dueInDays: options.dueInDays, + status: Freight.InvoiceStatus.Issued, + }, + mg, + ); + + const billedAt = new Date(); + await mg.update( + ShippingLineCredit, + { id: In(credits.map((c) => c.id)) }, + { + status: ShippingLineCreditStatus.Billed, + invoiceId: invoice.id, + billedAt, + }, + ); + + logCtx( + { + invoiceId: invoice.id, + invoiceNumber: invoice.invoiceNumber, + shippingLineCompanyId, + creditCount: credits.length, + totalAmount: invoice.totalAmount, + }, + { path: "shippingLineCredit.invoiced" }, + ); + + return invoice; + }); + } + + // ── 3. Settle ────────────────────────────────────────────────────────────── + + /** + * Clear the batch once its invoice is paid. + * + * Driven by the billing event rather than a call inside the payment path, so + * the CBE webhook flow needs no knowledge of credits: whatever settles the + * invoice — gateway webhook, or a finance-recorded offline payment — this + * fires. Idempotent, because a redelivered webhook re-emits the event. + */ + @OnEvent("shipping_line_credit.invoice.paid") + async onInvoicePaid(payload: InvoiceEventPayload): Promise { + const result = await this.dataSource + .getRepository(ShippingLineCredit) + .update( + { + invoiceId: payload.invoiceId, + status: ShippingLineCreditStatus.Billed, + }, + { status: ShippingLineCreditStatus.Paid, paidAt: new Date() }, + ); + + logCtx( + { + invoiceId: payload.invoiceId, + invoiceNumber: payload.invoiceNumber, + creditsCleared: result.affected ?? 0, + }, + { path: "shippingLineCredit.settled" }, + ); + + // Zero is the ordinary idempotent no-op on webhook redelivery. It is only + // worth a line in the log, not an error: the invoice is paid either way. + if (!result.affected) { + this.logger.log( + `Invoice ${payload.invoiceNumber} paid — no BILLED credits left to clear (already settled).`, + ); + } + } + + // ── Reads ────────────────────────────────────────────────────────────────── + + /** Finance's worklist: what can go on an invoice for this line right now. */ + async listUnbilled(shippingLineCompanyId: string) { + await this.requireShippingLine(shippingLineCompanyId); + const credits = await this.credits.findUnbilled(shippingLineCompanyId); + return { + items: credits, + totalAmount: credits.reduce((sum, c) => sum + Number(c.amount), 0), + currency: credits[0]?.currency ?? "ETB", + }; + } + + /** The debt figure shown on the shipping-line detail page. */ + async outstanding(shippingLineCompanyId: string) { + await this.requireShippingLine(shippingLineCompanyId); + return this.credits.outstandingFor(shippingLineCompanyId); + } + + /** Full ledger for one line, newest first. */ + async listCredits( + shippingLineCompanyId: string, + page = 1, + pageSize = 20, + status?: ShippingLineCreditStatus, + ) { + await this.requireShippingLine(shippingLineCompanyId); + const [items, total] = await this.credits.findAllPaginated( + shippingLineCompanyId, + (page - 1) * pageSize, + pageSize, + status, + ); + return { items, total, page, pageSize }; + } + + /** + * The signed-in shipping line's own statement: what it owes and why. + * Resolves the line from the session, so one line can never read another's. + */ + async myStatement(userId: string, page = 1, pageSize = 20) { + const shippingLine = await this.shippingLines.findByUserId(userId); + if (!shippingLine) { + throw new ForbiddenException("This account is not a shipping line."); + } + const [outstanding, ledger] = await Promise.all([ + this.credits.outstandingFor(shippingLine.id), + this.credits.findAllPaginated( + shippingLine.id, + (page - 1) * pageSize, + pageSize, + ), + ]); + return { + outstanding, + items: ledger[0], + total: ledger[1], + page, + pageSize, + }; + } + + // ── Cancellation ─────────────────────────────────────────────────────────── + + /** + * Write off an unbilled credit (booking voided, charge raised in error). + * Only UNBILLED credits can be cancelled — once a credit is on an issued + * invoice, the invoice is what has to be cancelled or credited, otherwise + * the invoice total would stop matching the sum of its lines. + */ + async cancelCredit( + creditId: string, + reason: string, + ): Promise { + return this.dataSource.transaction(async (mg) => { + const [credit] = await this.credits.findByIdsForUpdate(mg, [creditId]); + if (!credit) { + throw new NotFoundException(`Credit ${creditId} not found`); + } + if (credit.status !== ShippingLineCreditStatus.Unbilled) { + throw new BadRequestException( + `Only an unbilled credit can be cancelled; this one is ${credit.status}. Cancel or credit invoice ${credit.invoiceId} instead.`, + ); + } + + await mg.update( + ShippingLineCredit, + { id: creditId }, + { + status: ShippingLineCreditStatus.Cancelled, + cancelledAt: new Date(), + cancellationReason: reason, + }, + ); + + return { ...credit, status: ShippingLineCreditStatus.Cancelled }; + }); + } + + private async requireShippingLine(shippingLineCompanyId: string) { + const shippingLine = await this.shippingLines.findById( + shippingLineCompanyId, + ); + if (!shippingLine) { + throw new NotFoundException( + `Shipping line ${shippingLineCompanyId} not found`, + ); + } + return shippingLine; + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts index 6bf03c93d..558ffc1a9 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts @@ -99,7 +99,9 @@ interface InventoryContext { interface ViewSource { id: string; invoiceNumber: string; - companyId: string; + /** Nullable on the entity (shipping-line invoices have no company); every + * warehouse invoice is customer-billed, so in practice this is always set. */ + companyId: string | null; sourceId: string; type: string; status: Freight.InvoiceStatus | string; diff --git a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts index dd8f9bd20..53f7ad2d2 100644 --- a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts +++ b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts @@ -643,6 +643,21 @@ const INTERCITY_DOCUMENT_SETTINGS: OnboardingDocumentSetting[] = [ }, ]; +// ── Shipping line booking documents ───────────────────────────────────────── +// Collected on a shipping line's booking right after it is initiated. Shipping +// lines book without a contract, so this set — not a contract — is what +// Operations reviews before the booking may be completed. Fields start empty +// and are configured in the backoffice file-settings editor, like the sets +// above. `entity: "booking"` puts it alongside the other per-booking sets. +const SHIPPING_LINE_DOCUMENT_SETTINGS: OnboardingDocumentSetting[] = [ + { + code: "shipping_line_booking_documents", + label: "Shipping line booking documents", + entity: "booking", + fields: [], + }, +]; + // ── Hazardous cargo documents ─────────────────────────────────────────────── // Asked for in the contract wizard the moment the customer flags the cargo as // hazardous (ONE_TIME contracts only). Fields start empty and are configured in @@ -701,6 +716,11 @@ export class FileUploadSettingsSeeder { description: "Intercity shipment documents — contract-level for ONE_TIME (after both signatures), per booking for GENERAL; reviewed by Operations.", })), + ...SHIPPING_LINE_DOCUMENT_SETTINGS.map((s) => ({ + ...s, + description: + "Documents a shipping line uploads on a booking it initiated. Reviewed by Operations; the booking can only be completed once they are approved.", + })), ]; const missing = allSettings.filter((s) => !existingCodes.has(s.code)); diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 6c891d830..dbd996c63 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -513,6 +513,25 @@ export const FINANCE_PERMISSIONS: FreightPermissionSeed[] = [ "edr_freight_app:invoices:confirm_offline", "Confirm offline (bank transfer) invoice payment", ), + // Shipping lines consume services on credit and are invoiced after the fact, + // so what they owe is its own Finance surface, separate from invoices:view — + // an unbilled credit is not an invoice yet. + perm( + "d2c00001-0001-4000-8000-000000000001", + "edr_freight_app:shipping_line_credits:view", + "View shipping-line credits and outstanding balance", + ), + perm( + "d2c00001-0001-4000-8000-000000000002", + "edr_freight_app:shipping_line_credits:invoice", + "Generate an invoice from shipping-line credits", + ), + // Erases a debt outright, which is why it is not folded into :invoice. + perm( + "d2c00001-0001-4000-8000-000000000003", + "edr_freight_app:shipping_line_credits:cancel", + "Cancel (write off) an unbilled shipping-line credit", + ), ]; // E. First / last mile operations @@ -1730,6 +1749,13 @@ export const FREIGHT_PERMS = { update: "edr_freight_app:shipping_lines:update", resetPassword: "edr_freight_app:shipping_lines:reset-password", }, + shippingLineCredits: { + view: "edr_freight_app:shipping_line_credits:view", + /** Turn a batch of unbilled credits into an invoice. */ + invoice: "edr_freight_app:shipping_line_credits:invoice", + /** Write off an unbilled credit — separate grant: it erases a debt. */ + cancel: "edr_freight_app:shipping_line_credits:cancel", + }, payments: { view: "edr_freight_app:payments:view", }, diff --git a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx index 397496fd2..8682844d8 100644 --- a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx @@ -257,6 +257,34 @@ const RuleEngineFormDialog = ({ next.cargoTypeId = ""; next.rateUnit = ""; } + // Turning the shipping-line toggle on or off swaps the entire form, so + // nothing answered under the other shape may survive into the payload. + if (name === "isShippingLineRate") { + next.shippingLineCompanyId = ""; + next.shippingLineRateKind = ""; + next.shippingLineCargoKind = ""; + next.appliesTo = ""; + next.trigger = ""; + next.containerTypeId = ""; + next.cargoTypeId = ""; + next.originYardId = ""; + next.destinationYardId = ""; + next.rateUnit = ""; + } + // Base-vs-surcharge and container-vs-bulk each decide the scope field and + // the legal units for a shipping-line rate, exactly as appliesTo and + // cargoKind do on the customer form. + if (name === "shippingLineRateKind" || name === "shippingLineCargoKind") { + next.containerTypeId = ""; + next.cargoTypeId = ""; + next.rateUnit = ""; + if (name === "shippingLineRateKind") { + next.shippingLineCargoKind = ""; + next.trigger = ""; + next.originYardId = ""; + next.destinationYardId = ""; + } + } return next; }); }; @@ -347,12 +375,23 @@ const RuleEngineFormDialog = ({ borderRadius: "var(--mantine-radius-md)", }} > - - {field.label} - + + + {field.label} + + {field.description ? ( + + {field.description} + + ) : null} + setField(field.name, e.currentTarget.checked)} + // A toggle that re-targets what an existing record means (e.g. who + // a rate is priced for) is create-only — flipping it on a saved row + // would silently change every booking that prices off it. + disabled={field.disabled || (field.disabledOnEdit && !!initialRecord)} size="md" color="edr-green" /> @@ -493,6 +532,12 @@ const RuleEngineFormDialog = ({ // Dynamic options (e.g. rate unit) resolve from the live form values so // the choices track the other fields the admin has picked. const options = field.optionsFromValues ? field.optionsFromValues(values) : (field.options ?? []); + // A derived select shows (and submits) its computed value and is locked, + // matching the text-input branch — used by fields the shape decides on the + // admin's behalf, e.g. a shipping-line rate's import-only direction. + const computedSelect = field.computeValue + ? String(field.computeValue(values) ?? "") + : undefined; return (