From 25d13baa6f3b088c4daf53802cf04c2ae1938e92 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 21 Aug 2026 09:25:21 +0000 Subject: [PATCH 1/9] chore: reporting and filtering --- .../src/modules/billing/billing.service.ts | 109 ++++++-- .../billing/dto/filter-invoice.dto.spec.ts | 53 ++++ .../modules/billing/dto/filter-invoice.dto.ts | 118 ++++++++- .../modules/companies/companies.repository.ts | 46 ++-- .../modules/companies/company-scope.sql.ts | 40 +++ .../companies/dto/list-companies-query.dto.ts | 32 ++- .../exports/datasets/bookings.dataset.ts | 2 +- .../exports/datasets/customers.dataset.ts | 28 ++ .../exports/datasets/invoices.dataset.ts | 26 +- .../definitions/bookings-list.report.ts | 129 ---------- .../definitions/contract-lifecycle.report.ts | 83 ------ .../definitions/customer-status.report.ts | 67 ----- .../definitions/invoices-by-status.report.ts | 72 ------ .../definitions/payments-by-status.report.ts | 73 ------ .../definitions/revenue-by-customer.report.ts | 163 ++++++------ .../definitions/revenue-summary.report.ts | 62 ----- .../definitions/teu-performance.report.ts | 88 +++---- .../src/modules/reports/report.registry.ts | 88 +++---- .../src/seed/freight-permissions.registry.ts | 94 +++++-- .../src/pages/customers/CustomersPage.tsx | 195 ++++++++------ .../src/pages/invoices/InvoicesPage.tsx | 243 +++++++++++------- .../backoffice/src/types/customer.ts | 4 + .../backoffice/src/types/invoice.ts | 28 +- 23 files changed, 941 insertions(+), 902 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.spec.ts create mode 100644 apps/edr-freight-api/src/modules/companies/company-scope.sql.ts delete mode 100644 apps/edr-freight-api/src/modules/reports/definitions/bookings-list.report.ts delete mode 100644 apps/edr-freight-api/src/modules/reports/definitions/contract-lifecycle.report.ts delete mode 100644 apps/edr-freight-api/src/modules/reports/definitions/customer-status.report.ts delete mode 100644 apps/edr-freight-api/src/modules/reports/definitions/invoices-by-status.report.ts delete mode 100644 apps/edr-freight-api/src/modules/reports/definitions/payments-by-status.report.ts delete mode 100644 apps/edr-freight-api/src/modules/reports/definitions/revenue-summary.report.ts diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index ac1c2ef24..fbadc77c0 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -30,6 +30,7 @@ import { InvoiceDocumentService, pngDataUrl, } from "./documents/invoice-document.service"; +import { INVOICE_SORT_COLUMNS } from "./dto/filter-invoice.dto"; import { InvoiceLine } from "./entities/invoice-line.entity"; import { Invoice, InvoicePayment } from "./entities/invoice.entity"; import { InvoiceLineRepository } from "./invoice-line.repository"; @@ -97,6 +98,31 @@ export interface RecordPaymentInput { } /** Default invoice payment-term window, in days, used to compute `dueAt`. */ +/** + * Every dimension the backoffice invoice list narrows by. `findAllPaginated` + * and `collectedSummary` share it so the summary card can never total a + * different set of invoices than the table below it shows. + */ +export interface InvoiceListFilters { + companyId?: string; + status?: Freight.InvoiceStatus; + statuses?: Freight.InvoiceStatus[]; + sources?: string[]; + eimsStatuses?: string[]; + currency?: string; + search?: string; + issuedFrom?: string; + issuedTo?: string; + dueFrom?: string; + dueTo?: string; + minAmount?: number; + maxAmount?: number; + hasBalance?: boolean; + overdue?: boolean; + /** Per-user trade-direction scope, applied via the source booking. */ + tradeDirections?: string[]; +} + const DEFAULT_DUE_DAYS = 14; /** Statuses an invoice can still be settled (paid/refunded/cancelled) from. */ @@ -243,12 +269,7 @@ export class BillingService { /** Same list filters `findAllPaginated` and `collectedSummary` both narrow by. */ private applyInvoiceFilters( qb: SelectQueryBuilder, - filter: { - companyId?: string; - status?: Freight.InvoiceStatus; - search?: string; - tradeDirections?: string[]; - }, + filter: InvoiceListFilters, ) { if (filter.companyId) { qb.andWhere("invoice.companyId = :companyId", { @@ -258,6 +279,57 @@ export class BillingService { if (filter.status) { qb.andWhere("invoice.status = :status", { status: filter.status }); } + if (filter.statuses?.length) { + qb.andWhere("invoice.status IN (:...statuses)", { + statuses: filter.statuses, + }); + } + if (filter.sources?.length) { + qb.andWhere("invoice.source IN (:...sources)", { sources: filter.sources }); + } + if (filter.eimsStatuses?.length) { + qb.andWhere("invoice.eimsStatus IN (:...eimsStatuses)", { + eimsStatuses: filter.eimsStatuses, + }); + } + if (filter.currency) { + // Stored casing has drifted ("usd" rows exist) — compare normalised. + qb.andWhere("UPPER(invoice.currency) = :currency", { + currency: filter.currency.toUpperCase(), + }); + } + if (filter.issuedFrom) { + qb.andWhere("invoice.issuedAt >= :issuedFrom", { + issuedFrom: filter.issuedFrom, + }); + } + if (filter.issuedTo) { + qb.andWhere("invoice.issuedAt <= :issuedTo", { issuedTo: filter.issuedTo }); + } + if (filter.dueFrom) { + qb.andWhere("invoice.dueAt >= :dueFrom", { dueFrom: filter.dueFrom }); + } + if (filter.dueTo) { + qb.andWhere("invoice.dueAt <= :dueTo", { dueTo: filter.dueTo }); + } + if (filter.minAmount !== undefined) { + qb.andWhere("invoice.totalAmount >= :minAmount", { + minAmount: filter.minAmount, + }); + } + if (filter.maxAmount !== undefined) { + qb.andWhere("invoice.totalAmount <= :maxAmount", { + maxAmount: filter.maxAmount, + }); + } + if (filter.hasBalance) { + qb.andWhere("invoice.balanceAmount > 0"); + } + if (filter.overdue) { + // Computed, not `status = OVERDUE`: nothing sweeps PENDING rows into + // that status, so reading the column alone under-reports the arrears. + qb.andWhere("invoice.balanceAmount > 0 AND invoice.dueAt < now()"); + } if (filter.search) { // Searches what the row actually shows: its number, who it bills, and // the source record behind it (booking reference, GRN, shipping line). @@ -299,14 +371,11 @@ export class BillingService { } async findAllPaginated( - filter: { - companyId?: string; - status?: Freight.InvoiceStatus; - search?: string; + filter: InvoiceListFilters & { page?: number; pageSize?: number; - /** Per-user trade-direction scope, applied via the source booking. */ - tradeDirections?: string[]; + sortBy?: string; + sortOrder?: "ASC" | "DESC"; } = {}, ): Promise<{ items: InvoiceListRow[]; total: number }> { const page = filter.page && filter.page > 0 ? filter.page : 1; @@ -317,7 +386,14 @@ export class BillingService { .getRepository(Invoice) .createQueryBuilder("invoice") .leftJoinAndSelect("invoice.company", "company") - .orderBy("invoice.issuedAt", "DESC") + // sortBy is whitelisted through INVOICE_SORT_COLUMNS, never interpolated + // raw. The id tiebreaker keeps paging stable when the sort column ties + // (issuedAt is null on every DRAFT row). + .orderBy( + INVOICE_SORT_COLUMNS[filter.sortBy ?? ""] ?? "invoice.issuedAt", + filter.sortOrder ?? "DESC", + ) + .addOrderBy("invoice.id", "ASC") .skip((page - 1) * pageSize) .take(pageSize); @@ -458,12 +534,7 @@ export class BillingService { * visible page. */ async collectedSummary( - filter: { - companyId?: string; - status?: Freight.InvoiceStatus; - search?: string; - tradeDirections?: string[]; - } = {}, + filter: InvoiceListFilters = {}, ): Promise> { const qb = this.dataSource .getRepository(Invoice) diff --git a/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.spec.ts b/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.spec.ts new file mode 100644 index 000000000..55e6b19d2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.spec.ts @@ -0,0 +1,53 @@ +import { plainToInstance } from "class-transformer"; +import { validateSync } from "class-validator"; + +import { FilterInvoiceDto } from "./filter-invoice.dto"; + +/** + * The list endpoint runs under `forbidNonWhitelisted`, so every param the + * backoffice filter bar sends has to survive transform + validation here or + * the whole request 400s. The CSV filters are the fragile part: they arrive as + * one string and must come out as a validated array. + */ +const parse = (query: Record) => { + const dto = plainToInstance(FilterInvoiceDto, query); + return { dto, errors: validateSync(dto).map((e) => e.property) }; +}; + +describe("FilterInvoiceDto", () => { + it("accepts the full filter-bar query and splits the CSV filters", () => { + const { dto, errors } = parse({ + page: "2", + pageSize: "10", + search: "INV-2026", + statuses: "PENDING,OVERDUE", + sources: "booking,warehouse", + eimsStatuses: "NOT_SUBMITTED", + currency: "etb", + issuedFrom: "2026-08-01T00:00:00.000Z", + issuedTo: "2026-08-20T20:59:59.999Z", + dueFrom: "2026-08-01T00:00:00.000Z", + dueTo: "2026-09-01T20:59:59.999Z", + minAmount: "100", + maxAmount: "5000", + hasBalance: "true", + overdue: "false", + sortBy: "balanceAmount", + sortOrder: "asc", + }); + + expect(errors).toEqual([]); + expect(dto.statuses).toEqual(["PENDING", "OVERDUE"]); + expect(dto.sources).toEqual(["booking", "warehouse"]); + expect(dto.currency).toBe("ETB"); + expect(dto.minAmount).toBe(100); + expect(dto.hasBalance).toBe(true); + expect(dto.overdue).toBe(false); + expect(dto.sortOrder).toBe("ASC"); + }); + + it("rejects a value outside the enum and an unsortable column", () => { + expect(parse({ statuses: "PENDING,NOT_A_STATUS" }).errors).toEqual(["statuses"]); + expect(parse({ sortBy: "eimsIrn" }).errors).toEqual(["sortBy"]); + }); +}); diff --git a/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts b/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts index 91327946c..aec6e4ac0 100644 --- a/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts +++ b/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts @@ -2,14 +2,43 @@ import { Freight } from "@edr/types"; import { ApiPropertyOptional } from "@nestjs/swagger"; import { Transform } from "class-transformer"; import { + IsArray, + IsBoolean, + IsDateString, IsIn, IsInt, + IsNumber, IsOptional, IsString, IsUUID, Min, } from "class-validator"; +import { EimsInvoiceStatus } from "../../eims/eims-registration.types"; + +/** Columns the invoice list may be ordered by -> their query-builder expression. */ +export const INVOICE_SORT_COLUMNS: Record = { + issuedAt: "invoice.issuedAt", + dueAt: "invoice.dueAt", + createdAt: "invoice.createdAt", + totalAmount: "invoice.totalAmount", + balanceAmount: "invoice.balanceAmount", + invoiceNumber: "invoice.invoiceNumber", +}; + +/** `?statuses=A,B` -> `["A","B"]`. A bare value stays a one-element list. */ +const csv = ({ value }: { value: unknown }) => + typeof value === "string" + ? value + .split(",") + .map((v) => v.trim()) + .filter(Boolean) + : value; + +const bool = ({ value }: { value: unknown }) => value === "true" || value === true; + +const num = ({ value }: { value: unknown }) => Number(value); + export class FilterInvoiceDto { @ApiPropertyOptional({ default: 1 }) @IsOptional() @@ -40,10 +69,97 @@ export class FilterInvoiceDto { @IsIn(Object.values(Freight.InvoiceStatus)) status?: Freight.InvoiceStatus; - /** Manual-payments worklist only: restrict to one currency. */ + /** + * Multi-select status (`?statuses=PENDING,OVERDUE`). ANDed with `status` + * when both are sent, so the single-status worklists keep their meaning. + */ + @ApiPropertyOptional({ isArray: true, enum: Freight.InvoiceStatus }) + @IsOptional() + @Transform(csv) + @IsArray() + @IsIn(Object.values(Freight.InvoiceStatus), { each: true }) + statuses?: Freight.InvoiceStatus[]; + + /** Originating subsystem (`booking`, `warehouse`, `shipping_line_credit`, …). */ + @ApiPropertyOptional({ isArray: true, enum: Freight.InvoiceSource }) + @IsOptional() + @Transform(csv) + @IsArray() + @IsIn(Object.values(Freight.InvoiceSource), { each: true }) + sources?: Freight.InvoiceSource[]; + + /** MoR filing state — Finance's "what still needs registering" cut. */ + @ApiPropertyOptional({ isArray: true, enum: EimsInvoiceStatus }) + @IsOptional() + @Transform(csv) + @IsArray() + @IsIn(Object.values(EimsInvoiceStatus), { each: true }) + eimsStatuses?: EimsInvoiceStatus[]; + + /** Manual-payments worklist and the invoice list: restrict to one currency. */ @ApiPropertyOptional({ enum: ["USD", "ETB"] }) @IsOptional() @Transform(({ value }: { value: unknown }) => String(value).toUpperCase()) @IsIn(["USD", "ETB"]) currency?: "USD" | "ETB"; + + @ApiPropertyOptional({ description: "Issued at or after this instant (ISO)." }) + @IsOptional() + @IsDateString() + issuedFrom?: string; + + @ApiPropertyOptional({ description: "Issued at or before this instant (ISO)." }) + @IsOptional() + @IsDateString() + issuedTo?: string; + + @ApiPropertyOptional({ description: "Due at or after this instant (ISO)." }) + @IsOptional() + @IsDateString() + dueFrom?: string; + + @ApiPropertyOptional({ description: "Due at or before this instant (ISO)." }) + @IsOptional() + @IsDateString() + dueTo?: string; + + /** Total amount bounds, in the invoice's own currency — pair with `currency`. */ + @ApiPropertyOptional() + @IsOptional() + @Transform(num) + @IsNumber() + minAmount?: number; + + @ApiPropertyOptional() + @IsOptional() + @Transform(num) + @IsNumber() + maxAmount?: number; + + @ApiPropertyOptional({ description: "Only invoices with an outstanding balance." }) + @IsOptional() + @Transform(bool) + @IsBoolean() + hasBalance?: boolean; + + /** + * Outstanding AND past its due date, computed rather than read off `status`: + * nothing sweeps PENDING rows into OVERDUE, so the status alone under-reports. + */ + @ApiPropertyOptional({ description: "Only invoices outstanding past their due date." }) + @IsOptional() + @Transform(bool) + @IsBoolean() + overdue?: boolean; + + @ApiPropertyOptional({ enum: Object.keys(INVOICE_SORT_COLUMNS), default: "issuedAt" }) + @IsOptional() + @IsIn(Object.keys(INVOICE_SORT_COLUMNS)) + sortBy?: string; + + @ApiPropertyOptional({ enum: ["ASC", "DESC"], default: "DESC" }) + @IsOptional() + @Transform(({ value }: { value: unknown }) => String(value).toUpperCase()) + @IsIn(["ASC", "DESC"]) + sortOrder?: "ASC" | "DESC"; } diff --git a/apps/edr-freight-api/src/modules/companies/companies.repository.ts b/apps/edr-freight-api/src/modules/companies/companies.repository.ts index 092ebc2c4..17e3631e0 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.repository.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.repository.ts @@ -3,6 +3,10 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { BaseRepository } from '@edr/api-common'; import { Company } from './entities/company.entity'; +import { + companyDraftSql, + companyPendingChangeRequestSql, +} from './company-scope.sql'; import { ListCompaniesQueryDto } from './dto/list-companies-query.dto'; import { CompanyStatsResponseDto } from './dto/company-stats-response.dto'; @@ -15,31 +19,10 @@ export class CompaniesRepository extends BaseRepository { * placeholder name + TIN, so it must not be offered up for review. * Staff-created companies have no external profiles and are never drafts. */ - private static readonly DRAFT_SQL = `( - EXISTS ( - SELECT 1 FROM freight.external_profiles ep - WHERE ep.company_id = company.id - AND ep.deleted_at IS NULL - ) - AND NOT EXISTS ( - SELECT 1 FROM freight.external_profiles ep - WHERE ep.company_id = company.id - AND ep.deleted_at IS NULL - AND ep.onboarding_completed = true - ) - )`; + private static readonly DRAFT_SQL = companyDraftSql('company'); - /** - * A company waiting on a reviewer to decide an edit it submitted after being - * approved. These rows are `status = active`, so the pending-application filter - * can never surface them — the review queue needs its own predicate. - */ - private static readonly PENDING_CHANGE_REQUEST_SQL = `EXISTS ( - SELECT 1 FROM freight.company_change_request ccr - WHERE ccr.company_id = company.id - AND ccr.status = 'pending' - AND ccr.deleted_at IS NULL - )`; + private static readonly PENDING_CHANGE_REQUEST_SQL = + companyPendingChangeRequestSql('company'); /** * The `sortBy = 'review'` queue ordering: whatever marketing must act on @@ -96,6 +79,9 @@ export class CompaniesRepository extends BaseRepository { type, kind, status, + nationality, + createdFrom, + createdTo, onboardingCompleted, hasPendingChangeRequest, sortBy = 'review', @@ -122,6 +108,18 @@ export class CompaniesRepository extends BaseRepository { qb.andWhere('company.status = :status', { status }); } + if (nationality) { + qb.andWhere('company.nationality = :nationality', { nationality }); + } + + if (createdFrom) { + qb.andWhere('company.createdAt >= :createdFrom', { createdFrom }); + } + + if (createdTo) { + qb.andWhere('company.createdAt <= :createdTo', { createdTo }); + } + if (onboardingCompleted !== undefined) { qb.andWhere( onboardingCompleted diff --git a/apps/edr-freight-api/src/modules/companies/company-scope.sql.ts b/apps/edr-freight-api/src/modules/companies/company-scope.sql.ts new file mode 100644 index 000000000..df7a0bc7e --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/company-scope.sql.ts @@ -0,0 +1,40 @@ +/** + * Two predicates that define a customer's review state but are NOT columns on + * `companies`. Shared verbatim by the list repository and the export dataset — + * the backoffice offers both as one Status filter, so an export that computed + * "onboarding draft" differently from the list would quietly disagree with the + * screen it was launched from. + * + * Each takes the query's table alias because the two callers use different + * ones (`company` in the repository, `c` in the dataset). + */ + +/** + * Still in the portal onboarding wizard: has at least one external profile, + * none of them submitted. Such a row exists from the wizard's first click, so + * it must be excluded from the awaiting-approval queue. + */ +export const companyDraftSql = (alias: string): string => `( + EXISTS ( + SELECT 1 FROM freight.external_profiles ep + WHERE ep.company_id = ${alias}.id + AND ep.deleted_at IS NULL + ) + AND NOT EXISTS ( + SELECT 1 FROM freight.external_profiles ep + WHERE ep.company_id = ${alias}.id + AND ep.deleted_at IS NULL + AND ep.onboarding_completed = true + ) + )`; + +/** + * An already-approved customer who edited their profile: they stay + * `status = active`, so no status filter can ever surface them. + */ +export const companyPendingChangeRequestSql = (alias: string): string => `EXISTS ( + SELECT 1 FROM freight.company_change_request ccr + WHERE ccr.company_id = ${alias}.id + AND ccr.status = 'pending' + AND ccr.deleted_at IS NULL + )`; diff --git a/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts index ffb600e36..18b816a2a 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts @@ -1,7 +1,20 @@ import { ApiPropertyOptional } from "@nestjs/swagger"; -import { IsBoolean, IsIn, IsInt, IsOptional, IsString, Min } from "class-validator"; +import { + IsBoolean, + IsDateString, + IsIn, + IsInt, + IsOptional, + IsString, + Min, +} from "class-validator"; import { Transform } from "class-transformer"; -import { CompanyKind, CompanyStatus, CompanyType } from "../entities/company.entity"; +import { + CompanyKind, + CompanyNationality, + CompanyStatus, + CompanyType, +} from "../entities/company.entity"; export class ListCompaniesQueryDto { @ApiPropertyOptional({ default: 1 }) @@ -38,6 +51,21 @@ export class ListCompaniesQueryDto { @IsIn(Object.values(CompanyStatus)) status?: CompanyStatus; + @ApiPropertyOptional({ enum: CompanyNationality }) + @IsOptional() + @IsIn(Object.values(CompanyNationality)) + nationality?: CompanyNationality; + + @ApiPropertyOptional({ description: "Registered on or after this instant (ISO)." }) + @IsOptional() + @IsDateString() + createdFrom?: string; + + @ApiPropertyOptional({ description: "Registered on or before this instant (ISO)." }) + @IsOptional() + @IsDateString() + createdTo?: string; + @ApiPropertyOptional({ description: "Filter by onboarding submission. `true` = reviewable applications; " + diff --git a/apps/edr-freight-api/src/modules/exports/datasets/bookings.dataset.ts b/apps/edr-freight-api/src/modules/exports/datasets/bookings.dataset.ts index 1e2cc9098..c9f2a7d21 100644 --- a/apps/edr-freight-api/src/modules/exports/datasets/bookings.dataset.ts +++ b/apps/edr-freight-api/src/modules/exports/datasets/bookings.dataset.ts @@ -13,7 +13,7 @@ import { applyDirectionScope } from '../../user-trade-access/trade-scope.util'; import { ExportDataset } from '../export.types'; /** - * Domain semantics shared with `reports/definitions/bookings-list.report.ts`. + * Domain semantics that the retired `bookings-list` report used to share. * Kept identical on purpose — for PER_ITEM bulk bookings `cargo_total_weight_vgm` * holds an item COUNT, not tonnage, and `adjusted_total_amount` silently * overrides `total_amount`. Getting either wrong misreports money or weight. diff --git a/apps/edr-freight-api/src/modules/exports/datasets/customers.dataset.ts b/apps/edr-freight-api/src/modules/exports/datasets/customers.dataset.ts index 7a01ae048..a840f54a0 100644 --- a/apps/edr-freight-api/src/modules/exports/datasets/customers.dataset.ts +++ b/apps/edr-freight-api/src/modules/exports/datasets/customers.dataset.ts @@ -1,5 +1,9 @@ import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry'; import { Company } from '../../companies/entities/company.entity'; +import { + companyDraftSql, + companyPendingChangeRequestSql, +} from '../../companies/company-scope.sql'; import { ExportDataset } from '../export.types'; /** @@ -114,6 +118,21 @@ export const customersDataset: ExportDataset = { { value: 'government', label: 'Government' }, ] }, { key: 'status', label: 'Status', type: 'text' }, + { key: 'nationality', label: 'Nationality', type: 'select', options: [ + { value: 'ethiopian', label: 'Ethiopian' }, + { value: 'foreign', label: 'Foreign' }, + ] }, + // The list's Status filter folds the review queues in, and sends these two + // alongside `status`. They are predicates, not columns — see + // `company-scope.sql.ts`, shared with the list so both agree exactly. + { key: 'onboardingCompleted', label: 'Onboarding submitted', type: 'select', options: [ + { value: 'true', label: 'Submitted' }, + { value: 'false', label: 'Still a draft' }, + ] }, + { key: 'hasPendingChangeRequest', label: 'Pending profile changes', type: 'select', options: [ + { value: 'true', label: 'Awaiting review' }, + { value: 'false', label: 'None open' }, + ] }, { key: 'search', label: 'Search name, TIN or email', type: 'text' }, ], @@ -127,6 +146,15 @@ export const customersDataset: ExportDataset = { if (params.type) qb.andWhere('c.type = :type', { type: params.type }); if (params.kind) qb.andWhere('c.kind = :kind', { kind: params.kind }); if (params.status) qb.andWhere('c.status = :status', { status: params.status }); + if (params.nationality) qb.andWhere('c.nationality = :nationality', { nationality: params.nationality }); + if (params.onboardingCompleted) { + const draft = companyDraftSql('c'); + qb.andWhere(params.onboardingCompleted === 'true' ? `NOT ${draft}` : draft); + } + if (params.hasPendingChangeRequest) { + const pending = companyPendingChangeRequestSql('c'); + qb.andWhere(params.hasPendingChangeRequest === 'true' ? pending : `NOT ${pending}`); + } if (params.search) { qb.andWhere('(c.name ILIKE :search OR c.tin ILIKE :search OR c.email ILIKE :search)', { search: `%${params.search as string}%`, diff --git a/apps/edr-freight-api/src/modules/exports/datasets/invoices.dataset.ts b/apps/edr-freight-api/src/modules/exports/datasets/invoices.dataset.ts index 5eb0986b5..67f2207e9 100644 --- a/apps/edr-freight-api/src/modules/exports/datasets/invoices.dataset.ts +++ b/apps/edr-freight-api/src/modules/exports/datasets/invoices.dataset.ts @@ -97,14 +97,21 @@ export const invoicesDataset: ExportDataset = { filters: [ { key: 'issued', label: 'Issued', type: 'daterange' }, + { key: 'due', label: 'Due', type: 'daterange' }, { key: 'statuses', label: 'Status', type: 'multiselect' }, // The invoices list page sends a single `status`; accept both so its // on-screen filter actually carries into the export. { key: 'status', label: 'Status (single)', type: 'text' }, + { key: 'sources', label: 'Source', type: 'multiselect' }, + { key: 'eimsStatuses', label: 'EIMS status', type: 'multiselect' }, { key: 'currency', label: 'Currency', type: 'select', options: [ { value: 'ETB', label: 'ETB' }, { value: 'USD', label: 'USD' }, ] }, + { key: 'minAmount', label: 'Min total', type: 'text' }, + { key: 'maxAmount', label: 'Max total', type: 'text' }, + { key: 'hasBalance', label: 'Outstanding only', type: 'text' }, + { key: 'overdue', label: 'Overdue only', type: 'text' }, { key: 'companyId', label: 'Customer', type: 'text' }, { key: 'search', label: 'Search invoice no. or customer', type: 'text' }, ], @@ -116,10 +123,27 @@ export const invoicesDataset: ExportDataset = { qb.andWhere('i.deleted_at IS NULL'); if (params.issuedFrom) qb.andWhere('i.issued_at >= :issuedFrom', { issuedFrom: params.issuedFrom }); if (params.issuedTo) qb.andWhere('i.issued_at < :issuedTo', { issuedTo: params.issuedTo }); + if (params.dueFrom) qb.andWhere('i.due_at >= :dueFrom', { dueFrom: params.dueFrom }); + if (params.dueTo) qb.andWhere('i.due_at < :dueTo', { dueTo: params.dueTo }); const statuses = params.statuses as string[] | null; if (statuses?.length) qb.andWhere('i.status IN (:...statuses)', { statuses }); if (params.status) qb.andWhere('i.status = :status', { status: params.status }); - if (params.currency) qb.andWhere('i.currency = :currency', { currency: params.currency }); + const sources = params.sources as string[] | null; + if (sources?.length) qb.andWhere('i.source IN (:...sources)', { sources }); + const eimsStatuses = params.eimsStatuses as string[] | null; + if (eimsStatuses?.length) qb.andWhere('i.eims_status IN (:...eimsStatuses)', { eimsStatuses }); + // Casing has drifted in the data ("usd" rows exist) — normalise both sides, + // same as the list endpoint does. + if (params.currency) { + qb.andWhere('UPPER(i.currency) = :currency', { + currency: String(params.currency).toUpperCase(), + }); + } + if (params.minAmount) qb.andWhere('i.total_amount >= :minAmount', { minAmount: Number(params.minAmount) }); + if (params.maxAmount) qb.andWhere('i.total_amount <= :maxAmount', { maxAmount: Number(params.maxAmount) }); + if (params.hasBalance === 'true') qb.andWhere('i.balance_amount > 0'); + // Computed, not `status = OVERDUE` — nothing sweeps PENDING rows into it. + if (params.overdue === 'true') qb.andWhere('i.balance_amount > 0 AND i.due_at < now()'); if (params.companyId) qb.andWhere('i.company_id = :companyId', { companyId: params.companyId }); if (params.search) { qb.andWhere('(i.invoice_number ILIKE :search OR c.name ILIKE :search)', { search: `%${params.search as string}%` }); diff --git a/apps/edr-freight-api/src/modules/reports/definitions/bookings-list.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/bookings-list.report.ts deleted file mode 100644 index ca476cea3..000000000 --- a/apps/edr-freight-api/src/modules/reports/definitions/bookings-list.report.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; - -import { Booking } from '../../bookings/entities/booking.entity'; -import { CargoType } from '../../rule-engine/entities/cargo-type.entity'; -import { Yard } from '../../rule-engine/entities/yard.entity'; -import { Company } from '../../companies/entities/company.entity'; -import { ReportContext, ReportDefinition } from '../report.types'; - -// For PER_ITEM bulk bookings cargo_total_weight_vgm holds an item COUNT, and -// the real tonnage lives in bulk_total_weight_tons — hence the COALESCE order -// (same guard as the retired report-queries.ts). -const TONS = 'COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)'; -// adjusted_total_amount silently overrides total_amount when set. -const REVENUE = 'COALESCE(b.adjusted_total_amount, b.total_amount)'; -// GENERAL contract_kind rows are umbrella contracts, not shipments; counting -// them double-counts every child booking. -const NOT_UMBRELLA = "(b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')"; -const DEAD_STATUSES = ['DRAFT', 'CANCELLED', 'REJECTED', 'EXPIRED']; - -function applyFilters( - ctx: ReportContext, - qb: SelectQueryBuilder, -): SelectQueryBuilder { - const { params, directions } = ctx; - qb.where(`b.deleted_at IS NULL AND ${NOT_UMBRELLA}`); - if (params.dateFrom) qb.andWhere('b.created_at >= :dateFrom', { dateFrom: params.dateFrom }); - if (params.dateTo) qb.andWhere('b.created_at < :dateTo', { dateTo: params.dateTo }); - if (params.direction) qb.andWhere('b.trade_direction = :direction', { direction: params.direction }); - if (params.freightType) qb.andWhere('b.freight_type = :freightType', { freightType: params.freightType }); - const statuses = params.statuses as string[] | null; - if (statuses) { - qb.andWhere('b.status IN (:...statuses)', { statuses }); - } else { - qb.andWhere('b.status NOT IN (:...deadStatuses)', { deadStatuses: DEAD_STATUSES }); - } - if (params.search) { - qb.andWhere('(b.reference ILIKE :search OR c.name ILIKE :search)', { - search: `%${params.search}%`, - }); - } - if (directions !== null) { - qb.andWhere(directions.length ? 'b.trade_direction IN (:...directions)' : '1 = 0', { - directions, - }); - } - return qb; -} - -export const bookingsListReport: ReportDefinition = { - key: 'bookings-list', - title: 'Bookings', - description: 'Every booking with customer, route, cargo and revenue', - group: 'Commercial', - filters: [ - { key: 'date', label: 'Created', type: 'daterange' }, - { - key: 'direction', - label: 'Direction', - type: 'select', - options: [ - { value: 'IMPORT', label: 'Import' }, - { value: 'EXPORT', label: 'Export' }, - { value: 'DOMESTIC', label: 'Domestic' }, - ], - }, - { - key: 'freightType', - label: 'Freight type', - type: 'select', - options: [ - { value: 'CONTAINER', label: 'Container' }, - { value: 'BULK', label: 'Bulk' }, - ], - }, - { key: 'statuses', label: 'Status', type: 'multiselect' }, - { key: 'search', label: 'Search reference or customer', type: 'text' }, - ], - columns: [ - { key: 'reference', label: 'Reference', type: 'string', sortable: true, sortExpr: 'b.reference' }, - { key: 'created', label: 'Created', type: 'date', sortable: true, sortExpr: 'b.created_at' }, - { key: 'customer', label: 'Customer', type: 'string', sortable: true, sortExpr: 'c.name' }, - { key: 'status', label: 'Status', type: 'string', sortable: true, sortExpr: 'b.status' }, - { key: 'direction', label: 'Direction', type: 'string' }, - { key: 'origin', label: 'Origin', type: 'string' }, - { key: 'destination', label: 'Destination', type: 'string' }, - { key: 'cargo', label: 'Cargo', type: 'string' }, - { key: 'tons', label: 'Tonnage', type: 'tons', sortable: true }, - { key: 'amount', label: 'Amount', type: 'money', sortable: true }, - ], - defaultSort: { key: 'created', dir: 'DESC' }, - query(ctx) { - const qb = ctx.ds - .createQueryBuilder() - .select('b.reference', 'reference') - .addSelect(`to_char(b.created_at, 'YYYY-MM-DD')`, 'created') - .addSelect('c.name', 'customer') - .addSelect('b.status', 'status') - .addSelect('b.trade_direction', 'direction') - .addSelect('o.label', 'origin') - .addSelect('d.label', 'destination') - .addSelect('COALESCE(cty.cargo_type_name, b.cargo_free_text)', 'cargo') - .addSelect(`ROUND(${TONS})::float8`, 'tons') - .addSelect(`ROUND(${REVENUE})::float8`, 'amount') - .from(Booking, 'b') - .innerJoin(Company, 'c', 'c.id = b.company_id') - .innerJoin(Yard, 'o', 'o.id = b.origin_yard_id') - .innerJoin(Yard, 'd', 'd.id = b.destination_yard_id') - .leftJoin(CargoType, 'cty', 'cty.id = b.cargo_type_id'); - return applyFilters(ctx, qb); - }, - async summary(ctx) { - const qb = applyFilters( - ctx, - ctx.ds - .createQueryBuilder() - .select('COUNT(*)::int', 'bookings') - .addSelect(`ROUND(COALESCE(SUM(${TONS}), 0))::float8`, 'tons') - .addSelect(`ROUND(COALESCE(SUM(${REVENUE}), 0))::float8`, 'revenue') - .from(Booking, 'b') - .innerJoin(Company, 'c', 'c.id = b.company_id'), - ); - const row = await qb.getRawOne(); - return [ - { label: 'Bookings', value: Number(row?.bookings ?? 0) }, - { label: 'Tonnage', value: Number(row?.tons ?? 0), unit: 't' }, - { label: 'Revenue', value: Number(row?.revenue ?? 0), unit: 'ETB' }, - ]; - }, -}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/contract-lifecycle.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/contract-lifecycle.report.ts deleted file mode 100644 index 513abeda4..000000000 --- a/apps/edr-freight-api/src/modules/reports/definitions/contract-lifecycle.report.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; - -import { Contract, CONTRACT_KINDS, CONTRACT_STATUSES } from '../../contracts/entities/contract.entity'; -import { Company } from '../../companies/entities/company.entity'; -import { ReportContext, ReportDefinition } from '../report.types'; - -function baseQuery(ctx: ReportContext): SelectQueryBuilder { - const { params, directions } = ctx; - const qb = ctx.ds - .createQueryBuilder() - .from(Contract, 'ct') - .leftJoin(Company, 'c', 'c.id = ct.company_id') - .where('ct.deleted_at IS NULL'); - - if (params.dateFrom) qb.andWhere('ct.contract_valid_from >= :dateFrom', { dateFrom: params.dateFrom }); - if (params.dateTo) qb.andWhere('ct.contract_valid_from < :dateTo', { dateTo: params.dateTo }); - if (params.kind) qb.andWhere('ct.contract_kind = :kind', { kind: params.kind }); - if (params.direction) qb.andWhere('ct.trade_direction = :direction', { direction: params.direction }); - const statuses = params.statuses as string[] | null; - if (statuses) qb.andWhere('ct.status IN (:...statuses)', { statuses }); - if (directions !== null) { - qb.andWhere(directions.length ? 'ct.trade_direction IN (:...directions)' : '1 = 0', { directions }); - } - return qb; -} - -export const contractLifecycleReport: ReportDefinition = { - key: 'contract-lifecycle', - title: 'Contracts', - description: 'Signed, active and cancelled contracts', - group: 'Commercial', - filters: [ - { key: 'date', label: 'Valid from', type: 'daterange' }, - { key: 'kind', label: 'Kind', type: 'select', options: CONTRACT_KINDS.map((v) => ({ value: v, label: v })) }, - { - key: 'direction', - label: 'Direction', - type: 'select', - options: [ - { value: 'IMPORT', label: 'Import' }, - { value: 'EXPORT', label: 'Export' }, - { value: 'DOMESTIC', label: 'Domestic' }, - ], - }, - { key: 'statuses', label: 'Status', type: 'multiselect', options: CONTRACT_STATUSES.map((v) => ({ value: v, label: v.replace(/_/g, ' ') })) }, - ], - columns: [ - { key: 'reference', label: 'Reference', type: 'string', sortable: true, sortExpr: 'ct.reference' }, - { key: 'customer', label: 'Customer', type: 'string', sortable: true, sortExpr: 'c.name' }, - { key: 'kind', label: 'Kind', type: 'string' }, - { key: 'direction', label: 'Direction', type: 'string' }, - { key: 'freightType', label: 'Freight type', type: 'string' }, - { key: 'status', label: 'Status', type: 'string', sortable: true, sortExpr: 'ct.status' }, - { key: 'validFrom', label: 'Valid from', type: 'date', sortable: true, sortExpr: 'ct.contract_valid_from' }, - { key: 'validUntil', label: 'Valid until', type: 'date' }, - { key: 'signedAt', label: 'Signed', type: 'date' }, - ], - defaultSort: { key: 'validFrom', dir: 'DESC' }, - query(ctx) { - return baseQuery(ctx) - .select('ct.reference', 'reference') - .addSelect("COALESCE(c.name, ct.government_institution, 'Unknown')", 'customer') - .addSelect('ct.contract_kind', 'kind') - .addSelect('ct.trade_direction', 'direction') - .addSelect('ct.freight_type', 'freightType') - .addSelect('ct.status', 'status') - .addSelect(`to_char(ct.contract_valid_from, 'YYYY-MM-DD')`, 'validFrom') - .addSelect(`to_char(ct.contract_valid_until, 'YYYY-MM-DD')`, 'validUntil') - .addSelect(`to_char(ct.fully_executed_at, 'YYYY-MM-DD')`, 'signedAt'); - }, - async summary(ctx) { - const row = await baseQuery(ctx) - .select('COUNT(*)::int', 'total') - .addSelect('COUNT(*) FILTER (WHERE ct.fully_executed_at IS NOT NULL)::int', 'signed') - .addSelect("COUNT(*) FILTER (WHERE ct.status = 'CANCELLED')::int", 'cancelled') - .getRawOne(); - return [ - { label: 'Contracts', value: Number(row?.total ?? 0) }, - { label: 'Signed', value: Number(row?.signed ?? 0) }, - { label: 'Cancelled', value: Number(row?.cancelled ?? 0) }, - ]; - }, -}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/customer-status.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/customer-status.report.ts deleted file mode 100644 index 60b6ff31a..000000000 --- a/apps/edr-freight-api/src/modules/reports/definitions/customer-status.report.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; - -import { CompanyProfile, ProfileStatus, ProfileType } from '../../companies/entities/company-profile.entity'; -import { Company } from '../../companies/entities/company.entity'; -import { ReportContext, ReportDefinition } from '../report.types'; - -// "Type (Importer, Exporter, Freight Forwarding)" and "Active/Suspended" are -// CompanyProfile fields, not Company's — a company can hold several profiles -// (e.g. importer AND exporter), each independently approved/suspended. -const TYPE_OPTIONS = Object.values(ProfileType).map((v) => ({ value: v, label: v.replace(/_/g, ' ') })); -const STATUS_OPTIONS = Object.values(ProfileStatus).map((v) => ({ value: v, label: v })); - -function baseQuery(ctx: ReportContext): SelectQueryBuilder { - const { params } = ctx; - const qb = ctx.ds - .createQueryBuilder() - .from(CompanyProfile, 'cp') - .innerJoin(Company, 'c', 'c.id = cp.company_id') - .where('cp.deleted_at IS NULL'); - - if (params.type) qb.andWhere('cp.type = :type', { type: params.type }); - const statuses = params.statuses as string[] | null; - if (statuses) qb.andWhere('cp.status IN (:...statuses)', { statuses }); - return qb; -} - -export const customerStatusReport: ReportDefinition = { - key: 'customer-status', - title: 'Customer Profiles', - description: 'Company profiles by role type and approval status', - group: 'Commercial', - filters: [ - { key: 'type', label: 'Type', type: 'select', options: TYPE_OPTIONS }, - { key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS }, - ], - columns: [ - { key: 'company', label: 'Company', type: 'string', sortable: true, sortExpr: 'c.name' }, - { key: 'type', label: 'Type', type: 'string', sortable: true, sortExpr: 'cp.type' }, - { key: 'status', label: 'Status', type: 'string', sortable: true, sortExpr: 'cp.status' }, - { key: 'reference', label: 'Reference', type: 'string' }, - { key: 'note', label: 'Note', type: 'string' }, - { key: 'reviewedAt', label: 'Reviewed', type: 'date', sortable: true, sortExpr: 'cp.reviewed_at' }, - ], - defaultSort: { key: 'reviewedAt', dir: 'DESC' }, - query(ctx) { - return baseQuery(ctx) - .select('c.name', 'company') - .addSelect('cp.type', 'type') - .addSelect('cp.status', 'status') - .addSelect("COALESCE(cp.reference, '')", 'reference') - .addSelect("COALESCE(cp.review_note, '')", 'note') - .addSelect(`to_char(cp.reviewed_at, 'YYYY-MM-DD')`, 'reviewedAt'); - }, - async summary(ctx) { - const row = await baseQuery(ctx) - .select('COUNT(*)::int', 'total') - .addSelect('COUNT(*) FILTER (WHERE cp.status = :active)::int', 'active') - .addSelect('COUNT(*) FILTER (WHERE cp.status = :suspended)::int', 'suspended') - .setParameters({ active: ProfileStatus.Active, suspended: ProfileStatus.Suspended }) - .getRawOne(); - return [ - { label: 'Profiles', value: Number(row?.total ?? 0) }, - { label: 'Active', value: Number(row?.active ?? 0) }, - { label: 'Suspended', value: Number(row?.suspended ?? 0) }, - ]; - }, -}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/invoices-by-status.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/invoices-by-status.report.ts deleted file mode 100644 index 81b183f90..000000000 --- a/apps/edr-freight-api/src/modules/reports/definitions/invoices-by-status.report.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; - -import { Freight } from '@edr/types'; -import { Invoice } from '../../billing/entities/invoice.entity'; -import { Company } from '../../companies/entities/company.entity'; -import { CompanyProfile } from '../../companies/entities/company-profile.entity'; -import { ReportContext, ReportDefinition } from '../report.types'; - -const STATUS_OPTIONS = Object.values(Freight.InvoiceStatus).map((v) => ({ value: v, label: v })); - -function baseQuery(ctx: ReportContext): SelectQueryBuilder { - const { params } = ctx; - const qb = ctx.ds - .createQueryBuilder() - .from(Invoice, 'i') - .innerJoin(Company, 'c', 'c.id = i.company_id') - .leftJoin(CompanyProfile, 'cp', 'cp.id = i.company_profile_id') - .where('i.deleted_at IS NULL'); - - if (params.dateFrom) qb.andWhere('i.issued_at >= :dateFrom', { dateFrom: params.dateFrom }); - if (params.dateTo) qb.andWhere('i.issued_at < :dateTo', { dateTo: params.dateTo }); - const statuses = params.statuses as string[] | null; - if (statuses) qb.andWhere('i.status IN (:...statuses)', { statuses }); - return qb; -} - -export const invoicesByStatusReport: ReportDefinition = { - key: 'invoices-by-status', - title: 'Invoices', - description: 'Every invoice with customer, profile type and settlement status', - group: 'Finance', - filters: [ - { key: 'date', label: 'Issued', type: 'daterange' }, - { key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS }, - ], - columns: [ - { key: 'invoiceNumber', label: 'Invoice No.', type: 'string', sortable: true, sortExpr: 'i.invoice_number' }, - { key: 'customer', label: 'Customer', type: 'string', sortable: true, sortExpr: 'c.name' }, - { key: 'profileType', label: 'Profile', type: 'string' }, - { key: 'status', label: 'Status', type: 'string', sortable: true, sortExpr: 'i.status' }, - { key: 'totalAmount', label: 'Total', type: 'money', sortable: true }, - { key: 'paidAmount', label: 'Paid', type: 'money' }, - { key: 'balanceAmount', label: 'Balance', type: 'money', sortable: true }, - { key: 'issuedAt', label: 'Issued', type: 'date', sortable: true, sortExpr: 'i.issued_at' }, - { key: 'dueAt', label: 'Due', type: 'date' }, - ], - defaultSort: { key: 'issuedAt', dir: 'DESC' }, - query(ctx) { - return baseQuery(ctx) - .select('i.invoice_number', 'invoiceNumber') - .addSelect('c.name', 'customer') - .addSelect("COALESCE(cp.type, 'Unknown')", 'profileType') - .addSelect('i.status', 'status') - .addSelect('ROUND(i.total_amount)::float8', 'totalAmount') - .addSelect('ROUND(i.paid_amount)::float8', 'paidAmount') - .addSelect('ROUND(i.balance_amount)::float8', 'balanceAmount') - .addSelect(`to_char(i.issued_at, 'YYYY-MM-DD')`, 'issuedAt') - .addSelect(`to_char(i.due_at, 'YYYY-MM-DD')`, 'dueAt'); - }, - async summary(ctx) { - const row = await baseQuery(ctx) - .select('COUNT(*)::int', 'invoices') - .addSelect('ROUND(COALESCE(SUM(i.total_amount), 0))::float8', 'total') - .addSelect('ROUND(COALESCE(SUM(i.balance_amount), 0))::float8', 'balance') - .getRawOne(); - return [ - { label: 'Invoices', value: Number(row?.invoices ?? 0) }, - { label: 'Total value', value: Number(row?.total ?? 0), unit: 'ETB' }, - { label: 'Outstanding', value: Number(row?.balance ?? 0), unit: 'ETB' }, - ]; - }, -}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/payments-by-status.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/payments-by-status.report.ts deleted file mode 100644 index 15e99a4b1..000000000 --- a/apps/edr-freight-api/src/modules/reports/definitions/payments-by-status.report.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; - -import { PaymentEntity } from '../../payment/entities/payment.entity'; -import { ReportContext, ReportDefinition } from '../report.types'; - -// No direct company link on payments (refId points at whatever the intent was -// for — booking, demurrage, ...); breakdown stops at status/method/currency. -const STATUS_OPTIONS = [ - { value: 'action-required', label: 'Action required' }, - { value: 'processing', label: 'Processing' }, - { value: 'success', label: 'Success' }, - { value: 'failed', label: 'Failed' }, - { value: 'canceled', label: 'Canceled' }, - { value: 'refunded', label: 'Refunded' }, -]; -const METHOD_OPTIONS = ['telebirr', 'cbe-birr', 'ebirr', 'waafi', 'card', 'dmoney', 'cac-bank', 'cbe-bill'].map( - (v) => ({ value: v, label: v }), -); - -function baseQuery(ctx: ReportContext): SelectQueryBuilder { - const { params } = ctx; - // payments carries no deleted_at column (unlike the rest of the schema) — - // confirmed against the live DB, not assumed from BaseEntity. - const qb = ctx.ds.createQueryBuilder().from(PaymentEntity, 'p').where('1 = 1'); - - if (params.dateFrom) qb.andWhere('p.created_at >= :dateFrom', { dateFrom: params.dateFrom }); - if (params.dateTo) qb.andWhere('p.created_at < :dateTo', { dateTo: params.dateTo }); - if (params.method) qb.andWhere('p.method = :method', { method: params.method }); - const statuses = params.statuses as string[] | null; - if (statuses) qb.andWhere('p.status IN (:...statuses)', { statuses }); - return qb; -} - -export const paymentsByStatusReport: ReportDefinition = { - key: 'payments-by-status', - title: 'Payments by Status', - description: 'Payment volume and value by status, method and currency', - group: 'Finance', - filters: [ - { key: 'date', label: 'Created', type: 'daterange' }, - { key: 'method', label: 'Method', type: 'select', options: METHOD_OPTIONS }, - { key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS }, - ], - columns: [ - { key: 'status', label: 'Status', type: 'string', sortable: true }, - { key: 'method', label: 'Method', type: 'string', sortable: true }, - { key: 'currency', label: 'Currency', type: 'string' }, - { key: 'payments', label: 'Payments', type: 'number', sortable: true }, - { key: 'amount', label: 'Amount', type: 'money', sortable: true }, - ], - defaultSort: { key: 'amount', dir: 'DESC' }, - query(ctx) { - return baseQuery(ctx) - .select('p.status', 'status') - .addSelect('p.method', 'method') - .addSelect('p.currency', 'currency') - .addSelect('COUNT(*)::int', 'payments') - .addSelect('ROUND(COALESCE(SUM(p.amount), 0))::float8', 'amount') - .groupBy('p.status') - .addGroupBy('p.method') - .addGroupBy('p.currency'); - }, - async summary(ctx) { - const row = await baseQuery(ctx) - .select('COUNT(*)::int', 'payments') - .addSelect("ROUND(COALESCE(SUM(p.amount) FILTER (WHERE p.status = 'success'), 0))::float8", 'paid') - .getRawOne(); - return [ - { label: 'Payments', value: Number(row?.payments ?? 0) }, - { label: 'Total paid', value: Number(row?.paid ?? 0), unit: 'ETB' }, - ]; - }, -}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/revenue-by-customer.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/revenue-by-customer.report.ts index 31b9951b8..eb04c9924 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/revenue-by-customer.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/revenue-by-customer.report.ts @@ -1,91 +1,106 @@ -import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; +import { ObjectLiteral, SelectQueryBuilder } from "typeorm"; -import { Booking } from '../../bookings/entities/booking.entity'; -import { Company } from '../../companies/entities/company.entity'; -import { ReportContext, ReportDefinition } from '../report.types'; +import { ReportContext, ReportColumn, ReportDefinition } from "../report.types"; +import { + PAID_SHARE, + PAYER_EXPR, + PAYMENT_CLASSES, + PAYMENT_CLASS_EXPR, + REVENUE_FILTERS, + REVENUE_SUM, + currencyOf, + revenueLedgerQb, +} from "../revenue-classification"; -const TONS = 'COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)'; -const REVENUE = 'COALESCE(b.adjusted_total_amount, b.total_amount)'; -const NOT_UMBRELLA = "(b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')"; -const DEAD_STATUSES = ['DRAFT', 'CANCELLED', 'REJECTED', 'EXPIRED']; +/** + * One column per payment class, pivoted with FILTER. The class values are the + * compile-time constants in PAYMENT_CLASSES, never user input, so they are + * safe to interpolate. + */ +const CLASS_COLUMNS = PAYMENT_CLASSES.map((c) => ({ + value: c.value, + key: c.value + .toLowerCase() + .replace(/_(.)/g, (_, ch: string) => ch.toUpperCase()), + label: c.label, +})); + +const classMoneyColumns: ReportColumn[] = CLASS_COLUMNS.map((c) => ({ + key: c.key, + label: c.label, + type: "money", + sortable: true, +})); function baseQuery(ctx: ReportContext): SelectQueryBuilder { - const { params, directions } = ctx; - const qb = ctx.ds - .createQueryBuilder() - .from(Booking, 'b') - .innerJoin(Company, 'c', 'c.id = b.company_id') - .where(`b.deleted_at IS NULL AND ${NOT_UMBRELLA}`); - - if (params.dateFrom) qb.andWhere('b.created_at >= :dateFrom', { dateFrom: params.dateFrom }); - if (params.dateTo) qb.andWhere('b.created_at < :dateTo', { dateTo: params.dateTo }); - if (params.direction) qb.andWhere('b.trade_direction = :direction', { direction: params.direction }); - if (params.freightType) qb.andWhere('b.freight_type = :freightType', { freightType: params.freightType }); - const statuses = params.statuses as string[] | null; - if (statuses) { - qb.andWhere('b.status IN (:...statuses)', { statuses }); - } else { - qb.andWhere('b.status NOT IN (:...deadStatuses)', { deadStatuses: DEAD_STATUSES }); - } - if (directions !== null) { - qb.andWhere(directions.length ? 'b.trade_direction IN (:...directions)' : '1 = 0', { - directions, - }); - } - return qb; + return revenueLedgerQb(ctx); } export const revenueByCustomerReport: ReportDefinition = { - key: 'revenue-by-customer', - title: 'Revenue by Customer', - description: 'Ranked customers by booking revenue', - group: 'Commercial', - filters: [ - { key: 'date', label: 'Created', type: 'daterange' }, - { - key: 'direction', - label: 'Direction', - type: 'select', - options: [ - { value: 'IMPORT', label: 'Import' }, - { value: 'EXPORT', label: 'Export' }, - { value: 'DOMESTIC', label: 'Domestic' }, - ], - }, - { - key: 'freightType', - label: 'Freight type', - type: 'select', - options: [ - { value: 'CONTAINER', label: 'Container' }, - { value: 'BULK', label: 'Bulk' }, - ], - }, - { key: 'statuses', label: 'Status', type: 'multiselect' }, - ], + key: "revenue-by-customer", + title: "Revenue by Customer", + description: + "Every paying customer on one row: total billed revenue, what they have settled, " + + "what is still open, and a column per charge type — rail transport, customs " + + "clearance, first/last mile, overweight, cancellation, demurrage, storage, loading " + + "and unloading, and additional charges. Built on invoice lines, so the charge-type " + + "split is the billed one; a booking total is a lump sum and cannot be split. The " + + "payer is the company or, for shipping-line credit invoices, the shipping line. " + + "There is no dedicated loading/unloading charge type in the system — handling, " + + "double-handling and lashing stand in for it.", + group: "Finance", + filters: REVENUE_FILTERS, columns: [ - { key: 'customer', label: 'Customer', type: 'string', sortable: true, sortExpr: 'c.name' }, - { key: 'bookings', label: 'Bookings', type: 'number', sortable: true }, - { key: 'tons', label: 'Tonnage', type: 'tons', sortable: true }, - { key: 'revenue', label: 'Revenue', type: 'money', sortable: true }, + { + key: "customer", + label: "Customer", + type: "string", + sortable: true, + sortExpr: PAYER_EXPR, + }, + { key: "revenue", label: "Total revenue", type: "money", sortable: true }, + { key: "paid", label: "Paid", type: "money", sortable: true }, + { key: "outstanding", label: "Outstanding", type: "money", sortable: true }, + ...classMoneyColumns, + { key: "invoices", label: "Invoices", type: "number", sortable: true }, ], - defaultSort: { key: 'revenue', dir: 'DESC' }, + defaultSort: { key: "revenue", dir: "DESC" }, + chart: { type: "bar", x: "customer", y: ["revenue"] }, + drill: { to: "revenue-transactions", carry: { customer: "customer" } }, query(ctx) { - return baseQuery(ctx) - .select('c.name', 'customer') - .addSelect('COUNT(*)::int', 'bookings') - .addSelect(`ROUND(COALESCE(SUM(${TONS}), 0))::float8`, 'tons') - .addSelect(`ROUND(COALESCE(SUM(${REVENUE}), 0))::float8`, 'revenue') - .groupBy('c.name'); + const qb = baseQuery(ctx) + .select(PAYER_EXPR, "customer") + .addSelect(REVENUE_SUM, "revenue") + .addSelect(`ROUND(COALESCE(SUM(${PAID_SHARE}), 0))::float8`, "paid") + .addSelect( + `ROUND(COALESCE(SUM(il.amount - (${PAID_SHARE})), 0))::float8`, + "outstanding", + ) + .addSelect("COUNT(DISTINCT i.id)::int", "invoices") + .groupBy(PAYER_EXPR); + + for (const c of CLASS_COLUMNS) { + qb.addSelect( + `ROUND(COALESCE(SUM(il.amount) FILTER (WHERE ${PAYMENT_CLASS_EXPR} = '${c.value}'), 0))::float8`, + c.key, + ); + } + return qb; }, async summary(ctx) { const row = await baseQuery(ctx) - .select('COUNT(DISTINCT c.name)::int', 'customers') - .addSelect(`ROUND(COALESCE(SUM(${REVENUE}), 0))::float8`, 'revenue') - .getRawOne(); + .select(`COUNT(DISTINCT ${PAYER_EXPR})::int`, "customers") + .addSelect(REVENUE_SUM, "revenue") + .addSelect(`ROUND(COALESCE(SUM(${PAID_SHARE}), 0))::float8`, "paid") + .getRawOne<{ customers: number; revenue: number; paid: number }>(); + const revenue = Number(row?.revenue ?? 0); + const paid = Number(row?.paid ?? 0); + const unit = currencyOf(ctx.params); return [ - { label: 'Customers', value: Number(row?.customers ?? 0) }, - { label: 'Revenue', value: Number(row?.revenue ?? 0), unit: 'ETB' }, + { label: "Customers", value: Number(row?.customers ?? 0) }, + { label: "Total revenue", value: revenue, unit }, + { label: "Paid", value: paid, unit }, + { label: "Outstanding", value: Math.round(revenue - paid), unit }, ]; }, }; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/revenue-summary.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/revenue-summary.report.ts deleted file mode 100644 index 512e05be8..000000000 --- a/apps/edr-freight-api/src/modules/reports/definitions/revenue-summary.report.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; - -import { Booking } from '../../bookings/entities/booking.entity'; -import { ReportContext, ReportDefinition } from '../report.types'; - -const REVENUE = 'COALESCE(b.adjusted_total_amount, b.total_amount)'; -const NOT_UMBRELLA = "(b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')"; -const DEAD_STATUSES = ['DRAFT', 'CANCELLED', 'REJECTED', 'EXPIRED']; - -function baseQuery(ctx: ReportContext): SelectQueryBuilder { - const { params, directions } = ctx; - const qb = ctx.ds - .createQueryBuilder() - .from(Booking, 'b') - .where(`b.deleted_at IS NULL AND ${NOT_UMBRELLA}`) - .andWhere('b.status NOT IN (:...deadStatuses)', { deadStatuses: DEAD_STATUSES }); - - if (params.dateFrom) qb.andWhere('b.created_at >= :dateFrom', { dateFrom: params.dateFrom }); - if (params.dateTo) qb.andWhere('b.created_at < :dateTo', { dateTo: params.dateTo }); - if (directions !== null) { - qb.andWhere(directions.length ? 'b.trade_direction IN (:...directions)' : '1 = 0', { directions }); - } - return qb; -} - -export const revenueSummaryReport: ReportDefinition = { - key: 'revenue-summary', - title: 'Revenue Summary', - description: 'Booking revenue by direction, cargo type and currency', - group: 'Finance', - filters: [{ key: 'date', label: 'Created', type: 'daterange' }], - columns: [ - { key: 'direction', label: 'Direction', type: 'string', sortable: true }, - { key: 'freightType', label: 'Cargo type', type: 'string', sortable: true }, - { key: 'currency', label: 'Currency', type: 'string' }, - { key: 'bookings', label: 'Bookings', type: 'number', sortable: true }, - { key: 'revenue', label: 'Revenue', type: 'money', sortable: true }, - ], - defaultSort: { key: 'revenue', dir: 'DESC' }, - chart: { type: 'bar', x: 'direction', y: ['revenue'] }, - query(ctx) { - return baseQuery(ctx) - .select('b.trade_direction', 'direction') - .addSelect('b.freight_type', 'freightType') - .addSelect('b.payment_currency', 'currency') - .addSelect('COUNT(*)::int', 'bookings') - .addSelect(`ROUND(COALESCE(SUM(${REVENUE}), 0))::float8`, 'revenue') - .groupBy('b.trade_direction') - .addGroupBy('b.freight_type') - .addGroupBy('b.payment_currency'); - }, - async summary(ctx) { - const row = await baseQuery(ctx) - .select(`ROUND(COALESCE(SUM(${REVENUE}), 0))::float8`, 'revenue') - .addSelect('COUNT(*)::int', 'bookings') - .getRawOne(); - return [ - { label: 'Bookings', value: Number(row?.bookings ?? 0) }, - { label: 'Total revenue', value: Number(row?.revenue ?? 0), unit: 'ETB' }, - ]; - }, -}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/teu-performance.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/teu-performance.report.ts index 38a8ef04f..6bec4b29a 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/teu-performance.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/teu-performance.report.ts @@ -1,6 +1,6 @@ -import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; +import { ObjectLiteral, SelectQueryBuilder } from "typeorm"; -import { ReportContext, ReportDefinition } from '../report.types'; +import { ReportContext, ReportDefinition } from "../report.types"; import { CONTAINER_CLASSES, CONTAINER_CLASS_EXPR, @@ -14,8 +14,8 @@ import { implementRateExpr, plannedRowsParams, plannedRowsSql, -} from '../operations-classification'; -import { PERIOD_FILTER, periodExprOn, periodTruncExprOn } from '../revenue-classification'; +} from "../operations-classification"; +import { PERIOD_FILTER, periodExprOn, periodTruncExprOn } from "../revenue-classification"; const CONTAINERS_20 = `COALESCE(SUM(( SELECT COUNT(*) FROM freight.wagon_allocation_container_items ci @@ -39,41 +39,39 @@ function baseQuery(ctx: ReportContext): SelectQueryBuilder { } export const teuPerformanceReport: ReportDefinition = { - key: 'teu-performance', - title: 'TEU Performance', + key: "teu-performance", + title: "TEU Performance", description: - 'Twenty-foot equivalent units moved per container class against plan. Every 40ft box ' + - 'counts as two TEU, so ten 40ft and thirty 20ft is 50 TEU. Counted from the ' + - 'marshalling record — the containers actually allocated to wagons — not from the ' + - 'billing lines. Plan comes from Operational targets.' + + "Twenty-foot equivalent units moved per container class against plan. Every 40ft box " + + "counts as two TEU, so ten 40ft and thirty 20ft is 50 TEU. Counted from the " + + "marshalling record — the containers actually allocated to wagons — not from the " + + "billing lines. Plan comes from Operational targets." + PLAN_GRANULARITY_NOTE, - group: 'Operations', + group: "Operations", filters: [ PERIOD_FILTER, ...OPERATIONS_FILTERS, - { key: 'classes', label: 'Container class', type: 'multiselect', options: CONTAINER_CLASSES }, + { key: "classes", label: "Container class", type: "multiselect", options: CONTAINER_CLASSES }, ], columns: [ - { key: 'period', label: 'Period', type: 'string', sortable: true }, - { key: 'containerClass', label: 'Container type', type: 'string', sortable: true }, - { key: 'containers20', label: '20ft', type: 'number', sortable: true }, - { key: 'containers40', label: '40ft', type: 'number', sortable: true }, - { key: 'containers', label: 'Containers', type: 'number', sortable: true }, - { key: 'operated', label: 'Operated (TEU)', type: 'number', sortable: true }, - { key: 'plan', label: 'Plan', type: 'number' }, - { key: 'implementRate', label: 'Implement rate', type: 'percent' }, + { key: "period", label: "Period", type: "string", sortable: true }, + { key: "containerClass", label: "Container type", type: "string", sortable: true }, + { key: "containers20", label: "20ft", type: "number", sortable: true }, + { key: "containers40", label: "40ft", type: "number", sortable: true }, + { key: "operated", label: "Operated (TEU)", type: "number", sortable: true }, + { key: "plan", label: "Plan", type: "number" }, + { key: "implementRate", label: "Implement rate", type: "percent" }, ], - defaultSort: { key: 'operated', dir: 'DESC' }, - chart: { type: 'bar', x: 'containerClass', y: ['operated'] }, + defaultSort: { key: "operated", dir: "DESC" }, + chart: { type: "bar", x: "containerClass", y: ["operated"] }, query(ctx) { const bucket = periodTruncExprOn(OPS_DATE, ctx.params); const operated = baseQuery(ctx) - .select(periodExprOn(OPS_DATE, ctx.params), 'period') - .addSelect(CONTAINER_CLASS_EXPR, 'class_key') - .addSelect(CONTAINERS_20, 'containers20') - .addSelect(CONTAINERS_40, 'containers40') - .addSelect(CONTAINERS_EXPR, 'containers') - .addSelect(TEU_EXPR, 'operated') + .select(periodExprOn(OPS_DATE, ctx.params), "period") + .addSelect(CONTAINER_CLASS_EXPR, "class_key") + .addSelect(CONTAINERS_20, "containers20") + .addSelect(CONTAINERS_40, "containers40") + .addSelect(TEU_EXPR, "operated") .groupBy(bucket) .addGroupBy(CONTAINER_CLASS_EXPR); @@ -84,38 +82,36 @@ export const teuPerformanceReport: ReportDefinition = { COALESCE(o.class_key, p.plan_key) AS class_key, COALESCE(o.containers20, 0) AS containers20, COALESCE(o.containers40, 0) AS containers40, - COALESCE(o.containers, 0) AS containers, COALESCE(o.operated, 0) AS operated, p.plan_value AS plan FROM (${operated.getQuery()}) o - FULL OUTER JOIN (${plannedRowsSql('TEU', 'container_class', ctx.params)}) p + FULL OUTER JOIN (${plannedRowsSql("TEU", "container_class", ctx.params)}) p ON p.period = o.period AND p.plan_key = o.class_key`; return ctx.ds .createQueryBuilder() - .from(`(${combined})`, 'r') + .from(`(${combined})`, "r") .setParameters({ ...operated.getParameters(), ...plannedRowsParams(ctx.params) }) - .select('r.period', 'period') - .addSelect(CONTAINER_CLASS_LABEL_OF('r.class_key'), 'containerClass') - .addSelect('r.class_key', 'containerClassKey') - .addSelect('r.containers20::int', 'containers20') - .addSelect('r.containers40::int', 'containers40') - .addSelect('r.containers::int', 'containers') - .addSelect('r.operated::int', 'operated') - .addSelect('r.plan::float8', 'plan') - .addSelect(implementRateExpr('r.operated', 'r.plan'), 'implementRate'); + .select("r.period", "period") + .addSelect(CONTAINER_CLASS_LABEL_OF("r.class_key"), "containerClass") + .addSelect("r.class_key", "containerClassKey") + .addSelect("r.containers20::int", "containers20") + .addSelect("r.containers40::int", "containers40") + .addSelect("r.operated::int", "operated") + .addSelect("r.plan::float8", "plan") + .addSelect(implementRateExpr("r.operated", "r.plan"), "implementRate"); }, async summary(ctx) { const row = await baseQuery(ctx) - .select(TEU_EXPR, 'teu') - .addSelect(CONTAINERS_EXPR, 'containers') - .addSelect('COUNT(DISTINCT ts.id)::int', 'trains') + .select(TEU_EXPR, "teu") + .addSelect(CONTAINERS_EXPR, "containers") + .addSelect("COUNT(DISTINCT ts.id)::int", "trains") .getRawOne<{ teu: number; containers: number; trains: number }>(); return [ - { label: 'TEU', value: Number(row?.teu ?? 0) }, - { label: 'Containers', value: Number(row?.containers ?? 0) }, - { label: 'Trains', value: Number(row?.trains ?? 0) }, + { label: "TEU", value: Number(row?.teu ?? 0) }, + { label: "Containers", value: Number(row?.containers ?? 0) }, + { label: "Trains", value: Number(row?.trains ?? 0) }, ]; }, }; diff --git a/apps/edr-freight-api/src/modules/reports/report.registry.ts b/apps/edr-freight-api/src/modules/reports/report.registry.ts index fedc8a6dd..fc404738d 100644 --- a/apps/edr-freight-api/src/modules/reports/report.registry.ts +++ b/apps/edr-freight-api/src/modules/reports/report.registry.ts @@ -1,45 +1,39 @@ -import { ReportKey } from '../../seed/freight-permissions.registry'; -import { bookingsListReport } from './definitions/bookings-list.report'; -import { revenueByCustomerReport } from './definitions/revenue-by-customer.report'; -import { agingReceivablesReport } from './definitions/aging-receivables.report'; -import { contractUtilizationReport } from './definitions/contract-utilization.report'; -import { wagonFleetStatusReport } from './definitions/wagon-fleet-status.report'; -import { wagonStatusDurationReport } from './definitions/wagon-status-duration.report'; -import { wagonRequestsReport } from './definitions/wagon-requests.report'; -import { locomotiveFleetStatusReport } from './definitions/locomotive-fleet-status.report'; -import { bookingStatusBreakdownReport } from './definitions/booking-status-breakdown.report'; -import { trainScheduleStatusReport } from './definitions/train-schedule-status.report'; -import { trainTurnaroundReport } from './definitions/train-turnaround.report'; -import { wagonTeuUtilizationReport } from './definitions/wagon-teu-utilization.report'; -import { loadedCapacityReport } from './definitions/loaded-capacity.report'; -import { globalLogisticsWagonsReport } from './definitions/global-logistics-wagons.report'; -import { customerStatusReport } from './definitions/customer-status.report'; -import { contractLifecycleReport } from './definitions/contract-lifecycle.report'; -import { customsDocumentsReport } from './definitions/customs-documents.report'; -import { invoicingPipelineReport } from './definitions/invoicing-pipeline.report'; -import { firstLastMileBookingsReport } from './definitions/first-last-mile-bookings.report'; -import { invoicesByStatusReport } from './definitions/invoices-by-status.report'; -import { paymentsByStatusReport } from './definitions/payments-by-status.report'; -import { revenueSummaryReport } from './definitions/revenue-summary.report'; -import { cargoSummaryReport } from './definitions/cargo-summary.report'; -import { revenueByCategoryReport } from './definitions/revenue-by-category.report'; -import { revenueTransactionsReport } from './definitions/revenue-transactions.report'; -import { revenueByPeriodReport } from './definitions/revenue-by-period.report'; -import { revenueByRouteReport } from './definitions/revenue-by-route.report'; -import { revenueTopCustomersReport } from './definitions/revenue-top-customers.report'; -import { paymentClassificationReport } from './definitions/payment-classification.report'; -import { revenueReconciliationReport } from './definitions/revenue-reconciliation.report'; -import { receivablesPayablesReport } from './definitions/receivables-payables.report'; -import { revenueAnomaliesReport } from './definitions/revenue-anomalies.report'; -import { stationStayingTimeReport } from './definitions/station-staying-time.report'; -import { turnaroundCycleReport } from './definitions/turnaround-cycle.report'; -import { trainDelaysReport } from './definitions/train-delays.report'; -import { trainsetPerformanceReport } from './definitions/trainset-performance.report'; -import { teuPerformanceReport } from './definitions/teu-performance.report'; -import { cargoVolumePerformanceReport } from './definitions/cargo-volume-performance.report'; -import { chargedVsActualVolumeReport } from './definitions/charged-vs-actual-volume.report'; -import { cargoVolumeByStationReport } from './definitions/cargo-volume-by-station.report'; -import { ReportDefinition } from './report.types'; +import { ReportKey } from "../../seed/freight-permissions.registry"; +import { revenueByCustomerReport } from "./definitions/revenue-by-customer.report"; +import { agingReceivablesReport } from "./definitions/aging-receivables.report"; +import { contractUtilizationReport } from "./definitions/contract-utilization.report"; +import { wagonFleetStatusReport } from "./definitions/wagon-fleet-status.report"; +import { wagonStatusDurationReport } from "./definitions/wagon-status-duration.report"; +import { wagonRequestsReport } from "./definitions/wagon-requests.report"; +import { locomotiveFleetStatusReport } from "./definitions/locomotive-fleet-status.report"; +import { bookingStatusBreakdownReport } from "./definitions/booking-status-breakdown.report"; +import { trainScheduleStatusReport } from "./definitions/train-schedule-status.report"; +import { trainTurnaroundReport } from "./definitions/train-turnaround.report"; +import { wagonTeuUtilizationReport } from "./definitions/wagon-teu-utilization.report"; +import { loadedCapacityReport } from "./definitions/loaded-capacity.report"; +import { globalLogisticsWagonsReport } from "./definitions/global-logistics-wagons.report"; +import { customsDocumentsReport } from "./definitions/customs-documents.report"; +import { invoicingPipelineReport } from "./definitions/invoicing-pipeline.report"; +import { firstLastMileBookingsReport } from "./definitions/first-last-mile-bookings.report"; +import { cargoSummaryReport } from "./definitions/cargo-summary.report"; +import { revenueByCategoryReport } from "./definitions/revenue-by-category.report"; +import { revenueTransactionsReport } from "./definitions/revenue-transactions.report"; +import { revenueByPeriodReport } from "./definitions/revenue-by-period.report"; +import { revenueByRouteReport } from "./definitions/revenue-by-route.report"; +import { revenueTopCustomersReport } from "./definitions/revenue-top-customers.report"; +import { paymentClassificationReport } from "./definitions/payment-classification.report"; +import { revenueReconciliationReport } from "./definitions/revenue-reconciliation.report"; +import { receivablesPayablesReport } from "./definitions/receivables-payables.report"; +import { revenueAnomaliesReport } from "./definitions/revenue-anomalies.report"; +import { stationStayingTimeReport } from "./definitions/station-staying-time.report"; +import { turnaroundCycleReport } from "./definitions/turnaround-cycle.report"; +import { trainDelaysReport } from "./definitions/train-delays.report"; +import { trainsetPerformanceReport } from "./definitions/trainset-performance.report"; +import { teuPerformanceReport } from "./definitions/teu-performance.report"; +import { cargoVolumePerformanceReport } from "./definitions/cargo-volume-performance.report"; +import { chargedVsActualVolumeReport } from "./definitions/charged-vs-actual-volume.report"; +import { cargoVolumeByStationReport } from "./definitions/cargo-volume-by-station.report"; +import { ReportDefinition } from "./report.types"; /** * Every report the platform knows about. Adding one = a new file under @@ -47,7 +41,6 @@ import { ReportDefinition } from './report.types'; * an entry here. Nothing else — no frontend edit, no route, no sidebar edit. */ export const REPORTS: ReportDefinition[] = [ - bookingsListReport, revenueByCustomerReport, agingReceivablesReport, contractUtilizationReport, @@ -61,14 +54,9 @@ export const REPORTS: ReportDefinition[] = [ wagonTeuUtilizationReport, loadedCapacityReport, globalLogisticsWagonsReport, - customerStatusReport, - contractLifecycleReport, customsDocumentsReport, invoicingPipelineReport, firstLastMileBookingsReport, - invoicesByStatusReport, - paymentsByStatusReport, - revenueSummaryReport, cargoSummaryReport, revenueByCategoryReport, revenueTransactionsReport, @@ -89,7 +77,9 @@ export const REPORTS: ReportDefinition[] = [ cargoVolumeByStationReport, ]; -const BY_KEY = new Map(REPORTS.map((r) => [r.key, r])); +const BY_KEY = new Map( + REPORTS.map((r) => [r.key, r]), +); export function getReport(key: string): ReportDefinition | undefined { return BY_KEY.get(key as ReportKey); diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 6e4a289c5..4e4144f7d 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -49,13 +49,14 @@ const perm = (id: string, key: string, en: string): FreightPermissionSeed => ({ }); /** - * One entry per report definition (see modules/reports/definitions). Each - * gets its own permission, gated behind the `reports:view` master key that - * opens the Reports section itself. - * Keep new keys at the END: reportPermId derives ids from list index, so a - * mid-list insert would shift ids already seeded for later keys. + * Every report key ever seeded, in seed order. + * + * NEVER reorder or delete an entry: reportPermId derives a permission's uuid + * from its index here, so a shift would re-map ids already granted to roles. + * Retiring a report means adding it to RETIRED_REPORT_KEYS, not removing it. + * New keys go at the END. */ -export const REPORT_KEYS = [ +const SEEDED_REPORT_KEYS = [ "bookings-list", "revenue-by-customer", "aging-receivables", @@ -98,21 +99,54 @@ export const REPORT_KEYS = [ "cargo-volume-by-station", ] as const; -export type ReportKey = (typeof REPORT_KEYS)[number]; +/** + * Reports whose definition was deleted (see modules/reports/definitions) — a + * flat list the Exports module and its backoffice table already serve, or a + * narrower view of a report that supersedes it. Their permissions stay seeded + * so no live report's uuid moves; nothing resolves them to a definition. + */ +const RETIRED_REPORT_KEYS = [ + "bookings-list", + "customer-status", + "contract-lifecycle", + "invoices-by-status", + "payments-by-status", + "revenue-summary", +] as const; -export const reportPermissionKey = (key: ReportKey): string => +export type ReportKey = Exclude< + (typeof SEEDED_REPORT_KEYS)[number], + (typeof RETIRED_REPORT_KEYS)[number] +>; + +/** One entry per live report definition — what the catalog and presets use. */ +export const REPORT_KEYS: readonly ReportKey[] = SEEDED_REPORT_KEYS.filter( + (k): k is ReportKey => + !(RETIRED_REPORT_KEYS as readonly string[]).includes(k), +); + +export const reportPermissionKey = (key: string): string => `edr_freight_app:reports:${key.replace(/-/g, "_")}:view`; const reportPermId = (index: number): string => `a4f00002-0001-4000-8000-${(index + 1).toString(16).padStart(12, "0")}`; const titleCase = (slug: string): string => - slug.split("-").map((w) => w[0].toUpperCase() + w.slice(1)).join(" "); + slug + .split("-") + .map((w) => w[0].toUpperCase() + w.slice(1)) + .join(" "); -export const REPORT_PERMISSIONS: FreightPermissionSeed[] = REPORT_KEYS.map( - (key, index) => - perm(reportPermId(index), reportPermissionKey(key), `Report: ${titleCase(key)}`), -); +// Seeded from SEEDED_REPORT_KEYS, not REPORT_KEYS: a retired report keeps its +// index and its permission row, which is what stops the live ids from moving. +export const REPORT_PERMISSIONS: FreightPermissionSeed[] = + SEEDED_REPORT_KEYS.map((key, index) => + perm( + reportPermId(index), + reportPermissionKey(key), + `Report: ${titleCase(key)}`, + ), + ); export const BOOKING_PERMISSIONS: FreightPermissionSeed[] = [ perm( @@ -464,12 +498,12 @@ export const RULE_ENGINE_PERMISSIONS: FreightPermissionSeed[] = ), ...(approveId ? [ - perm( - approveId, - `edr_freight_app:rule_engine:${resource}:approve`, - `Approve ${slug} changes`, - ), - ] + perm( + approveId, + `edr_freight_app:rule_engine:${resource}:approve`, + `Approve ${slug} changes`, + ), + ] : []), ]; }); @@ -572,8 +606,16 @@ export const SHIPPING_LINE_PERMISSIONS: FreightPermissionSeed[] = [ // Internal chat (Matrix/Element) — sidebar visibility + manual reconcile trigger. export const CHAT_PERMISSIONS: FreightPermissionSeed[] = [ - perm('c9a00001-0001-4000-8000-000000000001', 'edr_freight_app:chat:view', 'Open internal chat'), - perm('c9a00001-0001-4000-8000-000000000002', 'edr_freight_app:chat:sync', 'Re-run chat room/membership sync'), + perm( + "c9a00001-0001-4000-8000-000000000001", + "edr_freight_app:chat:view", + "Open internal chat", + ), + perm( + "c9a00001-0001-4000-8000-000000000002", + "edr_freight_app:chat:sync", + "Re-run chat room/membership sync", + ), ]; // D. Finance — payments + invoices @@ -1948,8 +1990,7 @@ export const FREIGHT_PERMS = { // finance-level REQUEST grants (per action) and decision grants that apply // to ANY pending request — including the holder's own. /** Request recording an offline payment against a credit invoice. */ - invoiceMarkPaid: - "edr_freight_app:shipping_line_credits:invoice_mark_paid", + invoiceMarkPaid: "edr_freight_app:shipping_line_credits:invoice_mark_paid", /** Request voiding a credit invoice (credits return to unbilled). */ invoiceCancel: "edr_freight_app:shipping_line_credits:invoice_cancel", /** Approve any pending invoice request (mark-paid or cancel). */ @@ -1958,8 +1999,8 @@ export const FREIGHT_PERMS = { invoiceReject: "edr_freight_app:shipping_line_credits:invoice_reject", }, chat: { - view: 'edr_freight_app:chat:view', - sync: 'edr_freight_app:chat:sync', + view: "edr_freight_app:chat:view", + sync: "edr_freight_app:chat:sync", }, payments: { view: "edr_freight_app:payments:view", @@ -2403,7 +2444,8 @@ const FLEET_GRANULAR_KEYS: string[] = [ FREIGHT_PERMS.consignments.create, ]; -const allReportKeys = (): string[] => REPORT_KEYS.map((k) => reportPermissionKey(k)); +const allReportKeys = (): string[] => + REPORT_KEYS.map((k) => reportPermissionKey(k)); // Everyone who works the booking desk also opens the overview dashboard and // the canned reports — granted alongside bookings:view in every preset below. diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx index 5b8155c63..72eb38fe6 100644 --- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx @@ -4,7 +4,6 @@ import { Box, Card, Group, - SegmentedControl, Stack, Text, Tooltip, @@ -22,7 +21,7 @@ import { ShieldOff, Users, } from "lucide-react"; -import { useMemo, useState } from "react"; +import { useMemo } from "react"; import { useNavigate } from "react-router-dom"; import { @@ -31,49 +30,22 @@ import { ManualRegistrationBadge, ProfileChips, formatDate, + humanize, } from "@/components/customers"; import { KpiStrip, PageContainer, PageHeader } from "@/components/page"; import { api } from "@/services/api"; -import type { Company, CompanyStatus } from "@/types/customer"; +import type { Company, CompanyListFilter } from "@/types/customer"; import { isOnboardingDraft } from "@/types/customer"; import { DataTable, DataTableFooter, type ColumnDef } from "@edr/ui-common"; -import { FilterBar, useFilters, type FilterDef } from "@/components/filters"; +import { + FilterBar, + dateRangeParams, + isoToLocalDateStr, + useFilters, + type FilterDef, +} from "@/components/filters"; import { ExportButton } from "@/components/export/ExportButton"; -/** - * The list's segmented views. "Pending approval" means submitted-and-awaiting- - * review, so it excludes drafts — a company row exists from the onboarding - * wizard's first click and would otherwise pad the review queue. Those drafts - * get their own view instead of disappearing, so staff can still chase them. - */ -type CustomerView = - | "all" - | "pending" - | "pendingChanges" - | "onboarding" - | "active"; - -/** - * "Pending changes" is deliberately not folded into "Pending approval". A - * customer who edits their profile after being approved stays `status = active`, - * so the pending filter can never match them — their resubmission would only - * ever be visible by opening their detail page. This view is that queue. - */ -const VIEW_FILTERS: Record< - CustomerView, - { - status?: CompanyStatus; - onboardingCompleted?: boolean; - hasPendingChangeRequest?: boolean; - } -> = { - all: {}, - pending: { status: "pending", onboardingCompleted: true }, - pendingChanges: { hasPendingChangeRequest: true }, - onboarding: { onboardingCompleted: false }, - active: { status: "active" }, -}; - const SORT_OPTIONS = [ // Queue ordering: awaiting first approval → pending profile changes → the // rest, newest first within each group. The default, so whatever marketing @@ -85,29 +57,110 @@ const SORT_OPTIONS = [ { value: "name:DESC", label: "Name (Z–A)" }, ] as const; -/** No filter pills — search/sort/page are the only real filter dimensions; - * `view` below is a tab (mutually exclusive, navigational), not a filter. */ -const NO_FILTER_DEFS: FilterDef[] = []; +/** + * Every state a customer can be in, as one single-select list. + * + * Three of these are not `companies.status` values at all, which is why each + * option maps its own params: + * - **Pending approval** is submitted-and-awaiting-review, so it excludes + * drafts — a company row exists from the onboarding wizard's first click and + * would otherwise pad the review queue. + * - **Onboarding** is that draft: still in the portal wizard, never submitted. + * - **Pending changes** is an already-approved (`active`) customer who edited + * their profile. `status` can never match them, so without this option their + * resubmission is only visible by opening their detail page. + */ +const STATUS_OPTIONS: { + value: string; + label: string; + params: Record; +}[] = [ + { value: "pending", label: "Pending approval", params: { status: "pending", onboardingCompleted: "true" } }, + { value: "pendingChanges", label: "Pending changes", params: { hasPendingChangeRequest: "true" } }, + { value: "onboarding", label: "Onboarding", params: { onboardingCompleted: "false" } }, + { value: "active", label: "Active", params: { status: "active" } }, + { value: "suspended", label: "Suspended", params: { status: "suspended" } }, + { value: "blacklisted", label: "Blacklisted", params: { status: "blacklisted" } }, +]; + +/** + * Filter pills. The review queues that used to sit beside them as segmented + * tabs are folded into the Status pill above — three of the five were never a + * plain `status` value, so as a separate tab strip they could contradict the + * status filter next to them. One list, mutually exclusive, no contradiction. + */ +const CUSTOMER_FILTER_DEFS: FilterDef[] = [ + { + key: "status", + label: "Status", + type: "enum", + multiple: false, + options: STATUS_OPTIONS.map(({ value, label }) => ({ value, label })), + toParams: (v) => + STATUS_OPTIONS.find((o) => o.value === v.v[0])?.params ?? {}, + }, + { + key: "type", + label: "Type", + type: "enum", + multiple: false, + options: ( + ["customer", "freight_forwarder", "dj_freight_forwarder", "transporter"] as const + ).map((value) => ({ value, label: humanize(value) })), + }, + { + key: "kind", + label: "Sector", + type: "enum", + multiple: false, + options: [ + { value: "commercial", label: "Commercial" }, + { value: "government", label: "Government" }, + ], + }, + { + key: "nationality", + label: "Nationality", + type: "enum", + multiple: false, + options: [ + { value: "ethiopian", label: "Ethiopian" }, + { value: "foreign", label: "Foreign" }, + ], + }, + { + key: "created", + label: "Registered", + type: "date", + secondary: true, + operators: ["between", "before", "after"], + toParams: dateRangeParams("createdFrom", "createdTo"), + }, +]; export default function CustomersPage() { const navigate = useNavigate(); - const [view, setView] = useState("all"); - const controls = useFilters(NO_FILTER_DEFS, { defaultSort: "review:DESC", pageSize: 10 }); + const controls = useFilters(CUSTOMER_FILTER_DEFS, { + defaultSort: "review:DESC", + pageSize: 10, + }); - const filter = useMemo(() => { - const [sortBy, sortOrder] = controls.sort.split(":") as [ - "review" | "name" | "createdAt" | "updatedAt", - "ASC" | "DESC", - ]; - return { - page: controls.page, - pageSize: controls.pageSize, - search: String(controls.params.search ?? ""), - sortBy, - sortOrder, - ...VIEW_FILTERS[view], - }; - }, [controls.page, controls.pageSize, controls.params.search, controls.sort, view]); + // `controls.params` is the whole query: page/pageSize/search, the split + // sortBy/sortOrder, and every pill's mapped params. + const filter = controls.params as unknown as CompanyListFilter; + + /** + * The export's `daterange` filters are coerced from calendar days while the + * list takes ISO instants — hand the dialog the local day each bound falls on + * so the file covers the same range the screen shows. + */ + const exportParams = useMemo(() => { + const out: Record = { ...controls.params }; + for (const key of ["createdFrom", "createdTo"]) { + if (typeof out[key] === "string") out[key] = isoToLocalDateStr(out[key] as string); + } + return out; + }, [controls.params]); const { data: stats } = useQuery( api.customers.stats.queryOptions({ input: {} }), @@ -293,33 +346,13 @@ export default function CustomersPage() { ({ ...o }))} viewId="customers" > - { - // `view` lives outside useFilters (it's a tab, not a - // filter pill), so switching it needs its own page reset — - // the same "stranded on page 5" hazard useFilters guards - // against for its own filters. - setView(v as CustomerView); - controls.setPage(1); - }} - data={[ - { label: "All", value: "all" }, - { label: "Pending approval", value: "pending" }, - { label: "Pending changes", value: "pendingChanges" }, - { label: "Onboarding", value: "onboarding" }, - { label: "Active", value: "active" }, - ]} - /> - + @@ -331,8 +364,8 @@ export default function CustomersPage() { status={isLoading ? "loading" : isError ? "error" : "success"} onRowClick={(row) => navigate(`/dashboard/customers/${row.id}`)} emptyMessage={ - controls.searchText - ? "No companies match your search." + controls.activeCount > 0 + ? "No companies match these filters." : "No companies yet." } error={ diff --git a/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx index 68cb7514d..8026f692b 100644 --- a/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx @@ -1,29 +1,127 @@ -import type { Freight } from "@edr/types"; -import { - ActionIcon, - Badge, - Box, - Card, - Group, - SegmentedControl, - Stack, - Text, - TextInput, -} from "@mantine/core"; -import { useDebouncedValue } from "@mantine/hooks"; +import { Freight } from "@edr/types"; +import { ActionIcon, Badge, Box, Card, Group, Stack, Text } from "@mantine/core"; import { useQuery } from "@tanstack/react-query"; -import { Banknote, CircleDollarSign, Landmark, RefreshCw, Search, X } from "lucide-react"; -import { useMemo, useState } from "react"; +import { Banknote, CircleDollarSign, Landmark, RefreshCw } from "lucide-react"; +import { useMemo } from "react"; import { useNavigate } from "react-router-dom"; import { InvoiceStatusBadge, formatDate, formatMoney, humanize } from "@/components/customers"; +import { + FilterBar, + dateRangeParams, + isoToLocalDateStr, + useFilters, + type FilterDef, +} from "@/components/filters"; import { KpiStrip } from "@/components/page"; import CreditInvoiceActions from "@/components/shipping-lines/CreditInvoiceActions"; import { ExportButton } from "@/components/export/ExportButton"; import { useExchangeSettingsQuery } from "@/hooks/useExchangeSettings"; import { api } from "@/services/api"; -import type { Invoice } from "@/types/invoice"; -import { DataTable, DataTableFooter, usePagination, type ColumnDef } from "@edr/ui-common"; +import type { Invoice, InvoiceListFilter } from "@/types/invoice"; +import { DataTable, DataTableFooter, type ColumnDef } from "@edr/ui-common"; + +const STATUS_OPTIONS = Object.values(Freight.InvoiceStatus).map((value) => ({ + value, + label: humanize(value), +})); + +const SOURCE_OPTIONS = Object.values(Freight.InvoiceSource).map((value) => ({ + value, + label: humanize(value), +})); + +/** Mirrors `EimsInvoiceStatus` in the API — Finance's "what still needs filing" cut. */ +const EIMS_STATUS_OPTIONS = [ + "NOT_SUBMITTED", + "SUBMITTING", + "REGISTERED", + "FAILED", + "UNKNOWN", + "CANCELLED", +].map((value) => ({ value, label: humanize(value) })); + +/** + * Every dimension the list narrows by. Keys are the URL keys; `toParams` maps + * them onto the API's `FilterInvoiceDto`. Secondary defs sit behind "More + * filters" until they hold a value, then pin themselves as a pill. + */ +const INVOICE_FILTER_DEFS: FilterDef[] = [ + { key: "statuses", label: "Status", type: "enum", options: STATUS_OPTIONS }, + { key: "sources", label: "Source", type: "enum", options: SOURCE_OPTIONS }, + { + key: "currency", + label: "Currency", + type: "enum", + multiple: false, + options: [ + { value: "ETB", label: "ETB" }, + { value: "USD", label: "USD" }, + ], + }, + { + // One pill for the two settlement cuts Finance actually chases. Both are + // computed from the balance and due date rather than read off `status` — + // nothing sweeps PENDING rows into OVERDUE, so the status under-reports. + key: "settlement", + label: "Settlement", + type: "enum", + multiple: false, + options: [ + { value: "outstanding", label: "Outstanding" }, + { value: "overdue", label: "Overdue" }, + ], + toParams: (v) => + v.v[0] === "overdue" ? { overdue: "true" } : { hasBalance: "true" }, + }, + { + key: "issued", + label: "Issued", + type: "date", + operators: ["between", "before", "after"], + toParams: dateRangeParams("issuedFrom", "issuedTo"), + }, + { + key: "due", + label: "Due", + type: "date", + secondary: true, + operators: ["between", "before", "after"], + toParams: dateRangeParams("dueFrom", "dueTo"), + }, + { + key: "amount", + label: "Amount", + type: "number", + secondary: true, + operators: ["between", "is"], + // Amounts are compared in each invoice's OWN currency — pair this with the + // currency pill when the mix matters. + toParams: (v) => + v.op === "between" + ? { minAmount: v.v[0], maxAmount: v.v[1] } + : { minAmount: v.v[0], maxAmount: v.v[0] }, + }, + { + key: "eimsStatuses", + label: "EIMS", + type: "enum", + secondary: true, + options: EIMS_STATUS_OPTIONS, + }, +]; + +const SORT_OPTIONS = [ + { value: "issuedAt:DESC", label: "Newest issued" }, + { value: "issuedAt:ASC", label: "Oldest issued" }, + { value: "dueAt:ASC", label: "Due soonest" }, + { value: "totalAmount:DESC", label: "Largest amount" }, + { value: "balanceAmount:DESC", label: "Largest balance" }, + { value: "invoiceNumber:ASC", label: "Invoice no. (A–Z)" }, +]; + +/** Date params the export's `daterange` coercion expects as calendar days. */ +const EXPORT_DAY_KEYS = ["issuedFrom", "issuedTo", "dueFrom", "dueTo"]; /** * Which record raised the invoice, not just which subsystem. The source label @@ -65,20 +163,12 @@ function InvoiceSourceCell({ invoice }: { invoice: Invoice }) { /** Invoices tab body of `FinanceHubPage` — page chrome lives in the parent. */ export default function InvoicesPanel() { const navigate = useNavigate(); - const { pagination, setPagination } = usePagination({ pageSize: 10 }); - const [query, setQuery] = useState(""); - const [debouncedQuery] = useDebouncedValue(query, 300); - const [statusFilter, setStatusFilter] = useState<"" | Freight.InvoiceStatus>(""); + const controls = useFilters(INVOICE_FILTER_DEFS, { + defaultSort: "issuedAt:DESC", + pageSize: 10, + }); - const filter = useMemo( - () => ({ - page: pagination.pageIndex + 1, - pageSize: pagination.pageSize, - search: debouncedQuery, - status: statusFilter || undefined, - }), - [pagination.pageIndex, pagination.pageSize, debouncedQuery, statusFilter], - ); + const filter = controls.params as unknown as InvoiceListFilter; const { data, isLoading, isError, refetch, isFetching } = useQuery( api.invoices.list.queryOptions({ input: { filter } }), @@ -86,7 +176,6 @@ export default function InvoicesPanel() { const rows = data?.items ?? []; const total = data?.total ?? 0; - const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize)); // Shipping-line credit invoices carry maker–checker actions (mark paid / // cancel). One batched lookup fetches the visible rows' pending requests. @@ -106,14 +195,28 @@ export default function InvoicesPanel() { ); // Summary card: total collected (paidAmount) across every invoice matching - // the current search/status filters, not just the visible page. + // the current filters, not just the visible page. Same params minus + // pagination, so the card can never total a different set than the table. + const summaryFilter = useMemo(() => { + const { page: _page, pageSize: _pageSize, ...rest } = filter; + return rest; + }, [filter]); const { data: summary, isLoading: summaryLoading } = useQuery( - api.invoices.collectedSummary.queryOptions({ - input: { - filter: { search: debouncedQuery, status: statusFilter || undefined }, - }, - }), + api.invoices.collectedSummary.queryOptions({ input: { filter: summaryFilter } }), ); + + /** + * The export's `daterange` filters are coerced from calendar days, while the + * list takes ISO instants — hand the dialog the local day each bound falls + * on so an exported file covers the same range the screen shows. + */ + const exportParams = useMemo(() => { + const out: Record = { ...controls.params }; + for (const key of EXPORT_DAY_KEYS) { + if (typeof out[key] === "string") out[key] = isoToLocalDateStr(out[key] as string); + } + return out; + }, [controls.params]); const { data: exchangeSettings } = useExchangeSettingsQuery(); const etbCollected = summary?.ETB ?? 0; const usdCollected = summary?.USD ?? 0; @@ -236,45 +339,14 @@ export default function InvoicesPanel() { - - } - value={query} - onChange={(e) => setQuery(e.target.value)} - rightSection={ - query ? ( - setQuery("")} - > - - - ) : null - } - style={{ flex: 1, minWidth: "240px" }} - radius="lg" - /> - - { - setStatusFilter(v === "all" ? "" : (v as Freight.InvoiceStatus)); - setPagination((prev) => ({ ...prev, pageIndex: 0 })); - }} - data={[ - { label: "All", value: "all" }, - { label: "Pending", value: "PENDING" }, - { label: "Payment processing", value: "PAYMENT_PROCESSING" }, - { label: "Paid", value: "PAID" }, - { label: "Overdue", value: "OVERDUE" }, - ]} - /> + + - + @@ -296,7 +368,9 @@ export default function InvoicesPanel() { status={isLoading ? "loading" : isError ? "error" : "success"} onRowClick={(row) => navigate(`/dashboard/invoices/${row.id}`)} emptyMessage={ - debouncedQuery ? "No invoices match your search." : "No invoices yet." + controls.activeCount > 0 + ? "No invoices match these filters." + : "No invoices yet." } error={ isError @@ -306,18 +380,7 @@ export default function InvoicesPanel() { } : undefined } - pagination={{ - pageIndex: pagination.pageIndex, - pageSize: pagination.pageSize, - pageCount, - totalCount: total, - }} - tableOptions={{ - state: { pagination }, - onPaginationChange: setPagination, - manualPagination: true, - pageCount, - }} + {...controls.tableProps(total)} containerClassName="border-0 shadow-none bg-transparent" footer={DataTableFooter} /> diff --git a/apps/edr-freight-web/backoffice/src/types/customer.ts b/apps/edr-freight-web/backoffice/src/types/customer.ts index 8c95e7342..3c2427a1b 100644 --- a/apps/edr-freight-web/backoffice/src/types/customer.ts +++ b/apps/edr-freight-web/backoffice/src/types/customer.ts @@ -313,6 +313,10 @@ export interface CompanyListFilter { type?: CompanyType; kind?: CompanyKind; status?: CompanyStatus; + nationality?: CompanyNationality; + /** ISO instants — inclusive bounds on the registration date. */ + createdFrom?: string; + createdTo?: string; /** `true` = submitted applications only; `false` = drafts only; omit for both. */ onboardingCompleted?: boolean; /** diff --git a/apps/edr-freight-web/backoffice/src/types/invoice.ts b/apps/edr-freight-web/backoffice/src/types/invoice.ts index 3f2f661e1..9bd0ff502 100644 --- a/apps/edr-freight-web/backoffice/src/types/invoice.ts +++ b/apps/edr-freight-web/backoffice/src/types/invoice.ts @@ -24,15 +24,39 @@ export interface Invoice extends Freight.IInvoice { sourceRef?: InvoiceSourceRef | null; } -/** Query parameters for the invoice list. */ +/** + * Query parameters for the invoice list. Every key maps 1:1 onto + * `FilterInvoiceDto` on the API — the list endpoint runs with + * `forbidNonWhitelisted`, so a param that isn't declared there is a 400, not a + * silently ignored extra. + */ export interface InvoiceListFilter { page: number; pageSize: number; companyId?: string; + /** Single status — kept for the worklists that pin one. */ status?: Freight.InvoiceStatus; + /** CSV multi-select status, as the filter bar sends it. */ + statuses?: string; + /** CSV of `Freight.InvoiceSource` values. */ + sources?: string; + /** CSV of EIMS filing states. */ + eimsStatuses?: string; search?: string; - /** Manual-payments worklist only. */ currency?: "USD" | "ETB"; + /** ISO instants — inclusive bounds on `issuedAt` / `dueAt`. */ + issuedFrom?: string; + issuedTo?: string; + dueFrom?: string; + dueTo?: string; + minAmount?: number; + maxAmount?: number; + /** Outstanding balance only. */ + hasBalance?: boolean; + /** Outstanding AND past due — computed, not read off `status`. */ + overdue?: boolean; + sortBy?: string; + sortOrder?: "ASC" | "DESC"; } /** Standard paginated list envelope (matches the customers/bookings service shape). */ From 7a2383f02caad2c343309c2151cc9dd38e548207 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 21 Aug 2026 10:57:42 +0000 Subject: [PATCH 2/9] fix: revenue by customer --- .../definitions/revenue-by-customer.report.ts | 81 +++++++++---------- 1 file changed, 38 insertions(+), 43 deletions(-) diff --git a/apps/edr-freight-api/src/modules/reports/definitions/revenue-by-customer.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/revenue-by-customer.report.ts index eb04c9924..4312bbef6 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/revenue-by-customer.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/revenue-by-customer.report.ts @@ -1,6 +1,6 @@ -import { ObjectLiteral, SelectQueryBuilder } from "typeorm"; +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; -import { ReportContext, ReportColumn, ReportDefinition } from "../report.types"; +import { ReportContext, ReportColumn, ReportDefinition } from '../report.types'; import { PAID_SHARE, PAYER_EXPR, @@ -10,7 +10,7 @@ import { REVENUE_SUM, currencyOf, revenueLedgerQb, -} from "../revenue-classification"; +} from '../revenue-classification'; /** * One column per payment class, pivoted with FILTER. The class values are the @@ -19,16 +19,14 @@ import { */ const CLASS_COLUMNS = PAYMENT_CLASSES.map((c) => ({ value: c.value, - key: c.value - .toLowerCase() - .replace(/_(.)/g, (_, ch: string) => ch.toUpperCase()), + key: c.value.toLowerCase().replace(/_(.)/g, (_, ch: string) => ch.toUpperCase()), label: c.label, })); const classMoneyColumns: ReportColumn[] = CLASS_COLUMNS.map((c) => ({ key: c.key, label: c.label, - type: "money", + type: 'money', sortable: true, })); @@ -37,46 +35,43 @@ function baseQuery(ctx: ReportContext): SelectQueryBuilder { } export const revenueByCustomerReport: ReportDefinition = { - key: "revenue-by-customer", - title: "Revenue by Customer", + key: 'revenue-by-customer', + title: 'Revenue by Customer', description: - "Every paying customer on one row: total billed revenue, what they have settled, " + - "what is still open, and a column per charge type — rail transport, customs " + - "clearance, first/last mile, overweight, cancellation, demurrage, storage, loading " + - "and unloading, and additional charges. Built on invoice lines, so the charge-type " + - "split is the billed one; a booking total is a lump sum and cannot be split. The " + - "payer is the company or, for shipping-line credit invoices, the shipping line. " + - "There is no dedicated loading/unloading charge type in the system — handling, " + - "double-handling and lashing stand in for it.", - group: "Finance", + 'Every paying customer on one row: total billed revenue, what they have settled, ' + + 'what is still open, and a column per charge type — rail transport, customs ' + + 'clearance, first/last mile, overweight, cancellation, demurrage, storage, loading ' + + 'and unloading, and additional charges. Built on invoice lines, so the charge-type ' + + 'split is the billed one; a booking total is a lump sum and cannot be split. The ' + + 'payer is the company or, for shipping-line credit invoices, the shipping line. ' + + 'There is no dedicated loading/unloading charge type in the system — handling, ' + + 'double-handling and lashing stand in for it.', + group: 'Finance', filters: REVENUE_FILTERS, columns: [ { - key: "customer", - label: "Customer", - type: "string", + key: 'customer', + label: 'Customer', + type: 'string', sortable: true, sortExpr: PAYER_EXPR, }, - { key: "revenue", label: "Total revenue", type: "money", sortable: true }, - { key: "paid", label: "Paid", type: "money", sortable: true }, - { key: "outstanding", label: "Outstanding", type: "money", sortable: true }, + { key: 'revenue', label: 'Total revenue', type: 'money', sortable: true }, + { key: 'paid', label: 'Paid', type: 'money', sortable: true }, + { key: 'outstanding', label: 'Outstanding', type: 'money', sortable: true }, ...classMoneyColumns, - { key: "invoices", label: "Invoices", type: "number", sortable: true }, + { key: 'invoices', label: 'Invoices', type: 'number', sortable: true }, ], - defaultSort: { key: "revenue", dir: "DESC" }, - chart: { type: "bar", x: "customer", y: ["revenue"] }, - drill: { to: "revenue-transactions", carry: { customer: "customer" } }, + defaultSort: { key: 'revenue', dir: 'DESC' }, + chart: { type: 'bar', x: 'customer', y: ['revenue'] }, + drill: { to: 'revenue-transactions', carry: { customer: 'customer' } }, query(ctx) { const qb = baseQuery(ctx) - .select(PAYER_EXPR, "customer") - .addSelect(REVENUE_SUM, "revenue") - .addSelect(`ROUND(COALESCE(SUM(${PAID_SHARE}), 0))::float8`, "paid") - .addSelect( - `ROUND(COALESCE(SUM(il.amount - (${PAID_SHARE})), 0))::float8`, - "outstanding", - ) - .addSelect("COUNT(DISTINCT i.id)::int", "invoices") + .select(PAYER_EXPR, 'customer') + .addSelect(REVENUE_SUM, 'revenue') + .addSelect(`ROUND(COALESCE(SUM(${PAID_SHARE}), 0))::float8`, 'paid') + .addSelect(`ROUND(COALESCE(SUM(il.amount - (${PAID_SHARE})), 0))::float8`, 'outstanding') + .addSelect('COUNT(DISTINCT i.id)::int', 'invoices') .groupBy(PAYER_EXPR); for (const c of CLASS_COLUMNS) { @@ -89,18 +84,18 @@ export const revenueByCustomerReport: ReportDefinition = { }, async summary(ctx) { const row = await baseQuery(ctx) - .select(`COUNT(DISTINCT ${PAYER_EXPR})::int`, "customers") - .addSelect(REVENUE_SUM, "revenue") - .addSelect(`ROUND(COALESCE(SUM(${PAID_SHARE}), 0))::float8`, "paid") + .select(`COUNT(DISTINCT ${PAYER_EXPR})::int`, 'customers') + .addSelect(REVENUE_SUM, 'revenue') + .addSelect(`ROUND(COALESCE(SUM(${PAID_SHARE}), 0))::float8`, 'paid') .getRawOne<{ customers: number; revenue: number; paid: number }>(); const revenue = Number(row?.revenue ?? 0); const paid = Number(row?.paid ?? 0); const unit = currencyOf(ctx.params); return [ - { label: "Customers", value: Number(row?.customers ?? 0) }, - { label: "Total revenue", value: revenue, unit }, - { label: "Paid", value: paid, unit }, - { label: "Outstanding", value: Math.round(revenue - paid), unit }, + { label: 'Customers', value: Number(row?.customers ?? 0) }, + { label: 'Total revenue', value: revenue, unit }, + { label: 'Paid', value: paid, unit }, + { label: 'Outstanding', value: Math.round(revenue - paid), unit }, ]; }, }; From b60e36148325c3c63e295a41e9cc3de376767ca5 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Fri, 21 Aug 2026 15:42:58 +0300 Subject: [PATCH 3/9] feat(iam): add per-layout overview permissions Adds edr_freight_app:overview::view for each of the 6 overview dashboard layouts (clearance, occ, operation, marketer, finance, executive), seeded via OVERVIEW_LAYOUT_PERMISSIONS alongside the existing report permissions. Granted 1:1 to match today's role-dashboards.config.ts ROLE_LAYOUTS key table, appended only at the terminal EDR_FREIGHT_ROLES / EDR_FREIGHT_POSITIONS assembly points (never inside the reusable ROLE_PERMISSION_PRESETS/POSITION_PERMISSION_PRESETS builders) so composite positions like chief don't leak multiple layout grants from the role arrays they spread. The ~20 ad-hoc edr_freight_app/xxx department sub-positions are not backfilled here and will fall back to the executive layout until granted manually via the IAM positions admin screen. Part of switching overview layout resolution from role/position-key matching to permission checks, mirroring how reports already work. --- .../src/seed/edr-freight.seed.ts | 50 ++++++++++++------- .../src/seed/freight-permissions.registry.ts | 48 ++++++++++++++++++ 2 files changed, 80 insertions(+), 18 deletions(-) diff --git a/apps/edr-freight-api/src/seed/edr-freight.seed.ts b/apps/edr-freight-api/src/seed/edr-freight.seed.ts index e4ab41954..e611011f8 100644 --- a/apps/edr-freight-api/src/seed/edr-freight.seed.ts +++ b/apps/edr-freight-api/src/seed/edr-freight.seed.ts @@ -2,10 +2,15 @@ import { BOOKING_RULE_ENGINE_PERMISSIONS, BOOKING_RULE_ENGINE_PERMISSION_KEYS, deriveReadPermissions, + FREIGHT_PERMS, POSITION_PERMISSION_PRESETS, ROLE_PERMISSION_PRESETS, } from './freight-permissions.registry'; +/** Shorthand for the one overview-layout permission a role/position preset gets. */ +const overviewLayout = (key: Parameters[0]): string => + FREIGHT_PERMS.overview.layout(key); + export type FreightSeedRole = { key: string; name: { en: string }; @@ -248,48 +253,55 @@ export const EDR_FREIGHT_ROLES: FreightSeedRole[] = [ { key: "edr_line_staff", name: { en: "EDR Line Staff" }, - permissionKeys: [...ROLE_PERMISSION_PRESETS.lineStaff], + // OCC: the legacy role form of the control-centre desk (no position preset + // grants this layout — see EDR_FREIGHT_POSITIONS). + permissionKeys: [...ROLE_PERMISSION_PRESETS.lineStaff, overviewLayout("occ")], }, { key: "edr_operations_officer", name: { en: "EDR Operations Officer" }, - permissionKeys: [...ROLE_PERMISSION_PRESETS.operationsOfficer], + permissionKeys: [ + ...ROLE_PERMISSION_PRESETS.operationsOfficer, + overviewLayout("operation"), + ], }, { key: "edr_director", name: { en: "EDR Director" }, - permissionKeys: [...ROLE_PERMISSION_PRESETS.director], + permissionKeys: [...ROLE_PERMISSION_PRESETS.director, overviewLayout("executive")], }, { key: "edr_ceo", name: { en: "EDR CEO" }, - permissionKeys: [...ROLE_PERMISSION_PRESETS.ceo], + permissionKeys: [...ROLE_PERMISSION_PRESETS.ceo, overviewLayout("executive")], }, { key: "edr_finance", name: { en: "EDR Finance" }, - permissionKeys: [...ROLE_PERMISSION_PRESETS.finance], + // No position preset grants this layout — Finance only exists as a Role. + permissionKeys: [...ROLE_PERMISSION_PRESETS.finance, overviewLayout("finance")], }, { key: "edr_marketing", name: { en: "EDR Marketing" }, - permissionKeys: [...ROLE_PERMISSION_PRESETS.marketing], + permissionKeys: [...ROLE_PERMISSION_PRESETS.marketing, overviewLayout("marketer")], }, { key: "edr_gl_ethiopia", name: { en: "EDR Global Logistics — Ethiopia" }, - permissionKeys: [...ROLE_PERMISSION_PRESETS.glEthiopia], + permissionKeys: [...ROLE_PERMISSION_PRESETS.glEthiopia, overviewLayout("clearance")], }, { key: "edr_gl_djibouti", name: { en: "EDR Global Logistics — Djibouti" }, - permissionKeys: [...ROLE_PERMISSION_PRESETS.glDjibouti], + permissionKeys: [...ROLE_PERMISSION_PRESETS.glDjibouti, overviewLayout("clearance")], }, { key: "edr_org_manager", name: { en: "EDR Org Manager" }, permissionKeys: [ ...BOOKING_RULE_ENGINE_PERMISSION_KEYS, + overviewLayout("executive"), ...EMPLOYEE_REGISTRATION_PERMISSIONS.map((p) => p.key), ...ROLE_ASSIGNMENT_PERMISSIONS.map((p) => p.key), ...HIERARCHY_UNIT_PERMISSIONS.map((p) => p.key), @@ -326,15 +338,17 @@ export const EDR_FREIGHT_ROLES: FreightSeedRole[] = [ * PositionPermission rows (NOT Role/RolePermission). Users get their access by * being assigned to a Position via EmployeePosition. */ +// No position preset grants the "occ" or "finance" overview layouts today — +// see the comments on edr_line_staff / edr_finance above. export const EDR_FREIGHT_POSITIONS: FreightSeedPosition[] = [ - { key: "chief", name: { en: "Chief" }, rank: 1, permissionKeys: [...POSITION_PERMISSION_PRESETS.chief] }, - { key: "director", name: { en: "Director" }, rank: 2, permissionKeys: [...POSITION_PERMISSION_PRESETS.director] }, - { key: "ceo", name: { en: "CEO" }, rank: 1, permissionKeys: [...POSITION_PERMISSION_PRESETS.ceo] }, - { key: "ethiopian_gl", name: { en: "Ethiopian GL" }, rank: 3, permissionKeys: [...POSITION_PERMISSION_PRESETS.ethiopianGl] }, - { key: "djibouti_gl", name: { en: "Djibouti GL" }, rank: 3, permissionKeys: [...POSITION_PERMISSION_PRESETS.djiboutiGl] }, - { key: "marketer", name: { en: "Marketer" }, rank: 4, permissionKeys: [...POSITION_PERMISSION_PRESETS.marketer] }, - { key: "operation", name: { en: "Operation" }, rank: 4, permissionKeys: [...POSITION_PERMISSION_PRESETS.operation] }, - { key: "operations_chief", name: { en: "Operations Chief" }, rank: 2, permissionKeys: [...POSITION_PERMISSION_PRESETS.operationsChief] }, - { key: "dispatcher", name: { en: "Dispatcher" }, rank: 4, permissionKeys: [...POSITION_PERMISSION_PRESETS.dispatcher] }, - { key: "truck_machinery_chief", name: { en: "Truck & Machinery Chief" }, rank: 2, permissionKeys: [...POSITION_PERMISSION_PRESETS.truckMachineryChief] }, + { key: "chief", name: { en: "Chief" }, rank: 1, permissionKeys: [...POSITION_PERMISSION_PRESETS.chief, overviewLayout("executive")] }, + { key: "director", name: { en: "Director" }, rank: 2, permissionKeys: [...POSITION_PERMISSION_PRESETS.director, overviewLayout("executive")] }, + { key: "ceo", name: { en: "CEO" }, rank: 1, permissionKeys: [...POSITION_PERMISSION_PRESETS.ceo, overviewLayout("executive")] }, + { key: "ethiopian_gl", name: { en: "Ethiopian GL" }, rank: 3, permissionKeys: [...POSITION_PERMISSION_PRESETS.ethiopianGl, overviewLayout("clearance")] }, + { key: "djibouti_gl", name: { en: "Djibouti GL" }, rank: 3, permissionKeys: [...POSITION_PERMISSION_PRESETS.djiboutiGl, overviewLayout("clearance")] }, + { key: "marketer", name: { en: "Marketer" }, rank: 4, permissionKeys: [...POSITION_PERMISSION_PRESETS.marketer, overviewLayout("marketer")] }, + { key: "operation", name: { en: "Operation" }, rank: 4, permissionKeys: [...POSITION_PERMISSION_PRESETS.operation, overviewLayout("operation")] }, + { key: "operations_chief", name: { en: "Operations Chief" }, rank: 2, permissionKeys: [...POSITION_PERMISSION_PRESETS.operationsChief, overviewLayout("operation")] }, + { key: "dispatcher", name: { en: "Dispatcher" }, rank: 4, permissionKeys: [...POSITION_PERMISSION_PRESETS.dispatcher, overviewLayout("operation")] }, + { key: "truck_machinery_chief", name: { en: "Truck & Machinery Chief" }, rank: 2, permissionKeys: [...POSITION_PERMISSION_PRESETS.truckMachineryChief, overviewLayout("operation")] }, ]; 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 4e4144f7d..5fee67dbc 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -148,6 +148,52 @@ export const REPORT_PERMISSIONS: FreightPermissionSeed[] = ), ); +/** + * Overview dashboard layouts (see the backoffice's role-dashboards.config.ts, + * where `LAYOUTS` renders one composition per key). Unlike reports, a caller + * lands on exactly ONE layout, so `OVERVIEW_LAYOUT_KEYS` is also the priority + * order: whoever resolves the permission set picks the FIRST key here the + * caller holds — the specific operational view wins over the broad executive + * one, same rule the old role/position-key table encoded. + * + * NEVER reorder — GET /overview/layouts and the frontend both walk this array + * to break ties, so reordering silently changes who gets which dashboard. + */ +export const OVERVIEW_LAYOUT_KEYS = [ + "clearance", + "occ", + "operation", + "marketer", + "finance", + "executive", +] as const; + +export type OverviewLayoutKey = (typeof OVERVIEW_LAYOUT_KEYS)[number]; + +export const OVERVIEW_LAYOUT_LABELS: Record = { + clearance: "Clearance & logistics dashboard", + occ: "Control centre dashboard", + operation: "Operations dashboard", + marketer: "Marketing dashboard", + finance: "Finance dashboard", + executive: "Executive dashboard", +}; + +export const overviewLayoutPermissionKey = (key: string): string => + `edr_freight_app:overview:${key}:view`; + +const overviewLayoutPermId = (index: number): string => + `a4f00003-0001-4000-8000-${(index + 1).toString(16).padStart(12, "0")}`; + +export const OVERVIEW_LAYOUT_PERMISSIONS: FreightPermissionSeed[] = + OVERVIEW_LAYOUT_KEYS.map((key, index) => + perm( + overviewLayoutPermId(index), + overviewLayoutPermissionKey(key), + `Overview layout: ${OVERVIEW_LAYOUT_LABELS[key]}`, + ), + ); + export const BOOKING_PERMISSIONS: FreightPermissionSeed[] = [ perm( "a1000001-0001-4000-8000-000000000001", @@ -1759,6 +1805,7 @@ export const NOTIFICATION_PERMISSIONS: FreightPermissionSeed[] = [ export const ADVANCED_BACKOFFICE_PERMISSIONS: FreightPermissionSeed[] = [ ...REPORT_PERMISSIONS, + ...OVERVIEW_LAYOUT_PERMISSIONS, ...CUSTOMER_PERMISSIONS, ...SHIPPING_LINE_PERMISSIONS, ...CHAT_PERMISSIONS, @@ -2295,6 +2342,7 @@ export const FREIGHT_PERMS = { }, overview: { view: "edr_freight_app:overview:view", + layout: (key: OverviewLayoutKey): string => overviewLayoutPermissionKey(key), }, reports: { view: "edr_freight_app:reports:view", From 17e41aa7677b1c0427cab27582ea3ef44e5eb1e4 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Fri, 21 Aug 2026 15:43:26 +0300 Subject: [PATCH 4/9] feat(overview): serve GET /overview/layouts filtered by permission MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New catalog endpoint, same shape as GET /reports: returns the overview layouts (key + label) the caller holds the matching edr_freight_app:overview::view permission for, in priority order. Backend enforcement to go with the permission-based frontend resolver (next commit) — a caller can no longer land on a layout their JWT doesn't actually carry the permission for. --- .../overview/dto/overview-layout.dto.ts | 19 +++++++++++++++ .../modules/overview/overview.controller.ts | 24 ++++++++++++++++++- 2 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 apps/edr-freight-api/src/modules/overview/dto/overview-layout.dto.ts diff --git a/apps/edr-freight-api/src/modules/overview/dto/overview-layout.dto.ts b/apps/edr-freight-api/src/modules/overview/dto/overview-layout.dto.ts new file mode 100644 index 000000000..8a125bf81 --- /dev/null +++ b/apps/edr-freight-api/src/modules/overview/dto/overview-layout.dto.ts @@ -0,0 +1,19 @@ +import { ApiProperty } from '@nestjs/swagger'; + +import type { OverviewLayoutKey } from '../../../seed/freight-permissions.registry'; + +/** + * One entry per `GET /overview/layouts` item: a layout the caller holds the + * `edr_freight_app:overview::view` permission for. Mirrors the reports + * module's catalog entry (`ReportCatalogEntry`) — same "server filters by + * permission, frontend just renders what comes back" shape. + */ +export class OverviewLayoutDto { + @ApiProperty({ + enum: ['clearance', 'occ', 'operation', 'marketer', 'finance', 'executive'], + }) + key!: OverviewLayoutKey; + + @ApiProperty() + label!: string; +} diff --git a/apps/edr-freight-api/src/modules/overview/overview.controller.ts b/apps/edr-freight-api/src/modules/overview/overview.controller.ts index fcec82d4d..37d0b77d2 100644 --- a/apps/edr-freight-api/src/modules/overview/overview.controller.ts +++ b/apps/edr-freight-api/src/modules/overview/overview.controller.ts @@ -9,7 +9,13 @@ import { CurrentUser } from '@edr/api-common'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; import { BookingStaff } from '../../common/booking-guards'; -import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; +import { hasFreightPermission } from '../../common/freight-permission.util'; +import { + FREIGHT_PERMS, + OVERVIEW_LAYOUT_KEYS, + OVERVIEW_LAYOUT_LABELS, +} from '../../seed/freight-permissions.registry'; +import { OverviewLayoutDto } from './dto/overview-layout.dto'; import { OverviewQueryDto } from './dto/overview-query.dto'; import { OverviewResponseDto } from './dto/overview-response.dto'; import { @@ -34,6 +40,22 @@ export class OverviewController { private readonly userTradeAccessService: UserTradeAccessService, ) {} + /** + * Layouts the caller has permission to render, in priority order — exactly + * the same "server filters by permission, frontend just renders what comes + * back" shape as GET /reports. A caller lands on exactly one layout, so the + * frontend picks the first entry here rather than rendering the whole list. + */ + @Get('layouts') + @BookingStaff(FREIGHT_PERMS.overview.view) + @ApiOperation({ summary: 'Overview dashboard layouts the caller has permission to render' }) + @ApiOkResponse({ type: OverviewLayoutDto, isArray: true }) + getLayouts(@CurrentUser() user: TCurrentUser): OverviewLayoutDto[] { + return OVERVIEW_LAYOUT_KEYS.filter((key) => + hasFreightPermission(user, FREIGHT_PERMS.overview.layout(key)), + ).map((key) => ({ key, label: OVERVIEW_LAYOUT_LABELS[key] })); + } + @Get() @BookingStaff(FREIGHT_PERMS.overview.view) @ApiOperation({ summary: 'Aggregated dashboard summary for backoffice overview' }) From 503ea30f58182e0ad75e84ec7d445995f8dd9a72 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Fri, 21 Aug 2026 15:43:44 +0300 Subject: [PATCH 5/9] feat(overview): resolve dashboard layout from permissions, not role keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit role-dashboards.config.ts's ROLE_LAYOUTS was a hand-typed table of ~40 raw role/position keys (including edr_freight_app/xxx ad-hoc sub-positions), matched against the user's held keys client-side. Replaced with resolveOverviewLayout(), which just picks the highest-priority key out of whatever GET /overview/layouts returns — the same 'server filters by permission, frontend renders what comes back' shape Reports already uses, so the frontend no longer needs to know any individual permission key. Hard cutover: no fallback to the old key table. Positions not covered by the previous commit's grants (the ad-hoc department sub-positions) will render the executive layout until granted a layout permission. --- .../overview/role-dashboards.config.ts | 100 +++++------------- .../backoffice/src/constants/QUERY_KEYS.ts | 1 + .../backoffice/src/constants/URLS.ts | 1 + .../backoffice/src/hooks/useOverview.ts | 9 ++ .../src/pages/dashboard/OverviewPage.tsx | 12 +-- .../src/services/overview.service.ts | 13 +++ 6 files changed, 56 insertions(+), 80 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/components/overview/role-dashboards.config.ts b/apps/edr-freight-web/backoffice/src/components/overview/role-dashboards.config.ts index b683a3fbe..f059eae72 100644 --- a/apps/edr-freight-web/backoffice/src/components/overview/role-dashboards.config.ts +++ b/apps/edr-freight-web/backoffice/src/components/overview/role-dashboards.config.ts @@ -1,6 +1,3 @@ -import type { AuthUser } from "@/auth/types"; -import { getPositionKeys } from "@/lib/permissions"; - /** One overview composition. Every backoffice user lands on exactly one of these. */ export type OverviewLayoutKey = | "executive" @@ -20,80 +17,35 @@ export const OVERVIEW_LAYOUT_LABEL: Record = { }; /** - * Position/role key → layout, in match priority order: a user holding several - * of these keys gets the first match, so the specific operational view wins - * over the broad executive one. Roles are matched alongside positions because - * the IAM payload models the GL desks as positions (`ethiopian_gl`) on some - * accounts and as roles (`edr_gl_ethiopia`) on others — see `getPositionKeys`. - * - * The `edr_freight_app/…` keys are the org's real position keys (root desks and - * their sub-positions) as configured under Unit → Departments. They are typed - * by hand in the Add/Edit Department form, so a new sub-position appears here - * only once someone adds it — unmapped keys fall through to `executive`. + * Priority order: a caller who holds more than one of the six + * `edr_freight_app:overview::view` permissions gets the FIRST match + * here — the specific operational view wins over the broad executive one. + * Mirrors `OVERVIEW_LAYOUT_KEYS` in the API's freight-permissions.registry.ts + * bit for bit; keep the two in sync if this ever changes. */ -const ROLE_LAYOUTS: Array<[key: string, layout: OverviewLayoutKey]> = [ - // ── Clearance & logistics: both GL desks, root and sub-positions ────────── - ["ethiopian_gl", "clearance"], - ["edr_freight_app/gl_003", "clearance"], // Ethiopian GL Chief - ["edr_freight_app/off_001", "clearance"], // Ethiopian GL Director - ["edr_freight_app/off_0056", "clearance"], // Ethiopian GL Officer - ["djibouti_gl", "clearance"], - ["edr_freight_app/dj_gl_001", "clearance"], // Djibouti GL Director - ["edr_freight_app/dj_gl_002", "clearance"], // Djibouti GL Chief - ["edr_freight_app/dj_gl_003", "clearance"], // Djibouti GL Officer - ["edr_gl_ethiopia", "clearance"], // legacy role form - ["edr_gl_djibouti", "clearance"], // legacy role form - - // ── Control centre ─────────────────────────────────────────────────────── - ["edr_freight_app/occ_001", "occ"], // OCC - ["edr_freight_app/occ_005", "occ"], // OCC Director - ["edr_line_staff", "occ"], // legacy role form - - // ── Operations: operations desk, track & machinery, rolling stock ───────── - ["edr_freight_app/opn", "operation"], // Operation - ["edr_freight_app/opcf", "operation"], // Operation Chief - ["edr_freight_app/opdr", "operation"], // Operation Director - ["edr_freight_app/opco", "operation"], // Operation Officer - ["edr_freight_app/opp_005", "operation"], // Operation Dispatcher - ["edr_freight_app/opp_0067", "operation"], // Gelan Operation Director - ["edr_freight_app/track_001", "operation"], // Track And Machinery - ["edr_freight_app/ttk_001", "operation"], // Track Director - ["edr_freight_app/tto_001", "operation"], // Track Operator - ["edr_freight_app/rool_001", "operation"], // Rolling Stock - ["edr_freight_app/rl_003", "operation"], // Rolling Stock Director - ["edr_freight_app/rl_009", "operation"], // Rolling Stock Team Lead - ["edr_freight_app/rl_0090", "operation"], // Rolling Stock Dispatcher - ["operation", "operation"], - ["operations_chief", "operation"], - ["dispatcher", "operation"], - ["truck_machinery_chief", "operation"], - ["edr_operations_officer", "operation"], // legacy role form - - // ── Marketing ──────────────────────────────────────────────────────────── - ["edr_freight_app/edr_test_org_0022", "marketer"], // Commercial Marketing - ["edr_freight_app/edr_test_org_00567", "marketer"], // Marketing Director - ["edr_freight_app/edr_test_org_0054", "marketer"], // Marketing Chief - ["edr_freight_app/edr_test_org_0013", "marketer"], // Marketing Officer - ["marketer", "marketer"], - ["edr_marketing", "marketer"], // legacy role form - - // ── Finance ────────────────────────────────────────────────────────────── - ["edr_freight_app/finance", "finance"], - ["edr_finance", "finance"], // legacy role form - - // ── Executive: org-wide desks with no operational queue of their own ────── - ["ceo", "executive"], - ["director", "executive"], - ["chief", "executive"], - ["edr_ceo", "executive"], // legacy role form - ["edr_director", "executive"], // legacy role form - ["edr_org_manager", "executive"], // legacy role form +const LAYOUT_PRIORITY: OverviewLayoutKey[] = [ + "clearance", + "occ", + "operation", + "marketer", + "finance", + "executive", ]; -/** Unmapped keys (superadmin, IAM admins, Safety, new positions) keep the executive layout. */ +/** + * Which layout to render, given the keys `GET /overview/layouts` said the + * caller may see — the endpoint already filtered those by permission, so + * this only breaks the tie when a caller holds more than one. Same shape as + * the Reports page trusting `GET /reports`'s catalog rather than re-deriving + * access from permission keys client-side. + * + * Empty/unmapped falls back to the executive layout — same default the old + * role/position-key table used for superadmin, IAM admins, and any position + * that hasn't been granted one of these permissions yet. + */ export function resolveOverviewLayout( - user: AuthUser | null | undefined, + allowed: OverviewLayoutKey[] | undefined, ): OverviewLayoutKey { - const held = new Set(getPositionKeys(user)); - return ROLE_LAYOUTS.find(([key]) => held.has(key))?.[1] ?? "executive"; + const held = new Set(allowed ?? []); + return LAYOUT_PRIORITY.find((key) => held.has(key)) ?? "executive"; } 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 7f6f4993c..3f1cce1b5 100644 --- a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts @@ -235,6 +235,7 @@ export const QUERY_KEYS = { OVERVIEW: { ROOT: ["overview"] as const, + layouts: () => ["overview", "layouts"] as const, dashboard: (range?: string) => ["overview", "dashboard", range ?? "30d"] as const, bookingsTab: (range?: string) => diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index 68fefa3c9..bb3d88d2a 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -184,6 +184,7 @@ export const URL_CONSTANTS = { OVERVIEW: { BASE: "/overview", + LAYOUTS: "/overview/layouts", BOOKINGS: "/overview/bookings", CONTRACTS: "/overview/contracts", BILLING: "/overview/billing", diff --git a/apps/edr-freight-web/backoffice/src/hooks/useOverview.ts b/apps/edr-freight-web/backoffice/src/hooks/useOverview.ts index 7225ee5bc..42abe59e1 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/useOverview.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/useOverview.ts @@ -11,6 +11,15 @@ export function useOverview(range: OverviewRange = "30d") { }); } +/** Layouts the caller may render — server-filtered by permission, same shape as useReports' catalog. */ +export function useOverviewLayouts() { + return useQuery({ + queryKey: QUERY_KEYS.OVERVIEW.layouts(), + queryFn: () => overviewService.getLayouts(), + staleTime: 5 * 60 * 1000, + }); +} + export function useOverviewBookingsTab(range: OverviewRange, enabled: boolean) { return useQuery({ queryKey: QUERY_KEYS.OVERVIEW.bookingsTab(range), 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 eadd2f1c6..4c316477f 100644 --- a/apps/edr-freight-web/backoffice/src/pages/dashboard/OverviewPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/dashboard/OverviewPage.tsx @@ -3,7 +3,6 @@ import { AlertCircle } from "lucide-react"; import { Alert, Button, Skeleton, Stack } from "@mantine/core"; import { useQueryClient } from "@tanstack/react-query"; -import { useAuth } from "@/auth/useAuth"; import { PageContainer } from "@/components/page"; import { ClearanceOverview } from "@/components/overview/layouts/ClearanceOverview"; import { ExecutiveOverview } from "@/components/overview/layouts/ExecutiveOverview"; @@ -20,7 +19,7 @@ import { import { OverviewHero } from "@/components/overview/summary/OverviewHero"; import { OverviewHeroKpis } from "@/components/overview/summary/OverviewHeroKpis"; import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; -import { useOverview } from "@/hooks/useOverview"; +import { useOverview, useOverviewLayouts } from "@/hooks/useOverview"; import type { OverviewRange } from "@/types/overview"; import "@/components/overview/summary/overview-summary.css"; @@ -57,13 +56,14 @@ function OverviewSkeleton() { const OverviewPage = () => { const [range, setRange] = useState("30d"); const queryClient = useQueryClient(); - const { user } = useAuth(); const { data, isLoading, isError, error, refetch, isFetching } = useOverview(range); + const { data: layouts, isLoading: layoutsLoading } = useOverviewLayouts(); // Hero, range control and headline KPIs are role-neutral; everything below - // them is chosen by role key. - const layoutKey = resolveOverviewLayout(user); + // them is chosen by which overview::view permissions the caller holds + // (GET /overview/layouts already filtered these server-side). + const layoutKey = resolveOverviewLayout(layouts?.map((l) => l.key)); const RoleLayout = layoutKey ? LAYOUTS[layoutKey] : null; const accessDenied = @@ -129,7 +129,7 @@ const OverviewPage = () => { )} - {isLoading && !data ? ( + {(isLoading || layoutsLoading) && !data ? ( diff --git a/apps/edr-freight-web/backoffice/src/services/overview.service.ts b/apps/edr-freight-web/backoffice/src/services/overview.service.ts index 9229b0b4f..471b3538d 100644 --- a/apps/edr-freight-web/backoffice/src/services/overview.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/overview.service.ts @@ -1,6 +1,7 @@ import { api as client } from "../auth/http"; import { unwrap } from "@/utils/endpoint"; import { URL_CONSTANTS } from "@/constants/URLS"; +import type { OverviewLayoutKey } from "@/components/overview/role-dashboards.config"; import type { IOverviewBillingTab, IOverviewBookingsTab, @@ -16,7 +17,19 @@ import type { const O = URL_CONSTANTS.OVERVIEW; +/** Mirrors the API's OverviewLayoutDto — one entry per GET /overview/layouts item. */ +export interface IOverviewLayoutOption { + key: OverviewLayoutKey; + label: string; +} + export const overviewService = { + /** Layouts the caller has permission to render, in server priority order. */ + getLayouts: async (): Promise => { + const response = await client.get(O.LAYOUTS); + return unwrap(response); + }, + getDashboard: async (range?: OverviewRange): Promise => { const response = await client.get(O.BASE, { params: range ? { range } : undefined, From 3ce58d4c571ff9ae2601d306900dac2655c9c62b Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Fri, 21 Aug 2026 15:51:05 +0300 Subject: [PATCH 6/9] refactor(reports): label revenue categories from a key column CATEGORY_LABEL_EXPR wraps the classifying CASE, so it only works where the classification happens in the same SELECT. A report that classifies in a subquery and labels in the wrapper has a plain key column to label instead. CATEGORY_LABEL_OF takes that key expression; CATEGORY_LABEL_EXPR is now defined through it, so its three existing callers are unchanged. Mirrors CATEGORY_LABEL_OF in operations-classification.ts. Claude-Session: https://claude.ai/code/session_01LoY3hNWqcaAC1pYmGPN7jr --- .../src/modules/reports/revenue-classification.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/edr-freight-api/src/modules/reports/revenue-classification.ts b/apps/edr-freight-api/src/modules/reports/revenue-classification.ts index 526e35add..7beb79334 100644 --- a/apps/edr-freight-api/src/modules/reports/revenue-classification.ts +++ b/apps/edr-freight-api/src/modules/reports/revenue-classification.ts @@ -139,8 +139,15 @@ const labelCase = (expr: string, options: ReportFilterOption[]): string => .map((o) => `WHEN '${o.value}' THEN '${o.label.replace(/'/g, "''")}'`) .join('\n ')}\nEND`; +/** + * The same labelling applied to a key that is already a column — for reports + * that classify in a subquery and label in the wrapper. + */ +export const CATEGORY_LABEL_OF = (keyExpr: string): string => + labelCase(keyExpr, REVENUE_CATEGORIES); + /** The category as a business label rather than its key, for display columns. */ -export const CATEGORY_LABEL_EXPR = labelCase(REVENUE_CATEGORY_EXPR, REVENUE_CATEGORIES); +export const CATEGORY_LABEL_EXPR = CATEGORY_LABEL_OF(REVENUE_CATEGORY_EXPR); /** * Period-over-period change, as a percentage. From 91c9c3e513dc3fda3a876ca64c2b55b14d45b927 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Fri, 21 Aug 2026 15:51:18 +0300 Subject: [PATCH 7/9] feat(reports): list unbilled categories at zero in revenue-by-category MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A category with no invoice lines in a period simply had no row, so a category going quiet was indistinguishable from one that never existed, and filtering to a category that was never billed returned an empty table. The query is now three levels. The aggregate groups as before. A grid crosses every period that saw revenue with every category the filter allows, and LEFT JOINs the aggregate onto it so a missing combination lands at zero. The wrapper does the display rounding and the labelling. Two things had to move for that to be correct: - The lag() window is now in the wrapper. A window function only sees the rows its own query level produces, so left on the aggregate it would skip a category's silent periods — billed in January and March, it would read March's prior as January and report flat growth. - The category filter is off the aggregate and enforced by the grid's category list. Filtering the aggregate too would make the period axis depend on the selection, which is what left the table empty when the selected category had never been billed. Periods come from the data, not generate_series over the date filter: a twelve-month range over one billed month would otherwise publish eleven months of pure zeros, and daily granularity would multiply that by thirty. The Categories KPI is now "Categories with revenue" — a bare count of live categories reads as a contradiction next to a table listing all fourteen. EXPLAIN-validated against the dev database across seven filter shapes, including the empty-array case (hence unnest(ARRAY[...]) over VALUES, which is a syntax error when empty). Claude-Session: https://claude.ai/code/session_01LoY3hNWqcaAC1pYmGPN7jr --- .../definitions/revenue-by-category.report.ts | 130 +++++++++++++++--- 1 file changed, 110 insertions(+), 20 deletions(-) diff --git a/apps/edr-freight-api/src/modules/reports/definitions/revenue-by-category.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/revenue-by-category.report.ts index 0a46f15ec..151cd4cc3 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/revenue-by-category.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/revenue-by-category.report.ts @@ -3,9 +3,10 @@ import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; import { ReportContext, ReportDefinition } from '../report.types'; import { AVG_PER_UNIT_EXPR, - CATEGORY_LABEL_EXPR, + CATEGORY_LABEL_OF, CONTAINERS_EXPR, PERIOD_FILTER, + REVENUE_CATEGORIES, REVENUE_CATEGORY_EXPR, REVENUE_FILTERS, REVENUE_SUM, @@ -21,19 +22,35 @@ import { const REVENUE = 'SUM(il.amount)'; /** - * Previous period's revenue for the same category. + * Previous period's revenue for the same category, over the zero-filled grid. * - * Postgres evaluates window functions after GROUP BY, so `lag(SUM(...))` is - * legal alongside the SUM — no self-join, no CTE. Both the PARTITION BY and the - * ORDER BY must repeat their grouping expressions verbatim: ordering by the - * inner `date_trunc` when the group key is the `to_char` wrapper fails, and - * ordinal shorthand (`ORDER BY 1`) is read as a constant inside a window - * clause, silently producing an unordered partition. + * The window runs in the OUTER query, not alongside the aggregate. `lag()` only + * ever sees the rows its own query level produces, so computing it inside the + * aggregate would skip straight over a category's silent periods — a category + * billed in January and March would read March's prior as January and report + * flat growth, hiding the month it earned nothing. Against the grid, February + * exists at zero and both comparisons are real. */ -const priorRevenue = (period: string): string => - `lag(${REVENUE}) OVER (PARTITION BY ${REVENUE_CATEGORY_EXPR} ORDER BY ${period})`; +const PRIOR_REVENUE = 'lag(r.revenue) OVER (PARTITION BY r.category_key ORDER BY r.period)'; -const growthPct = (period: string): string => growthPctExpr(REVENUE, priorRevenue(period)); +/** + * Every category the grid must carry, narrowed to the caller's selection. + * + * This is where the `categories` filter is enforced for the table — the grid + * lists only what the caller asked for, and the join back to the aggregate + * drops the rest. See {@link revenueByCategoryReport.query} for why the filter + * cannot also be left on the aggregate. + * + * Intersected in JS against the constant list rather than interpolating the + * request's own values: the grid spells its categories into the SQL text, and a + * user-supplied string must never land there. An unrecognised value simply + * drops out — the ledger would match nothing on it anyway. + */ +const gridCategoryKeys = (params: Record): string[] => { + const selected = params.categories as string[] | null; + const all = REVENUE_CATEGORIES.map((c) => c.value); + return selected?.length ? all.filter((key) => selected.includes(key)) : all; +}; function baseQuery(ctx: ReportContext): SelectQueryBuilder { return revenueLedgerQb(ctx); @@ -44,7 +61,9 @@ export const revenueByCategoryReport: ReportDefinition = { title: 'Revenue by Category', description: 'Billed revenue in the twelve rail revenue categories, per period, with volume and ' + - 'period-over-period growth. Growth compares against the previous period inside the ' + + 'period-over-period growth. Every category is listed in every period that has revenue, ' + + 'at zero when it was not billed, so a category going quiet reads as a drop rather than ' + + 'a missing row. Growth compares against the previous period inside the ' + 'selected date range, so the earliest period always reads zero. ' + 'Multimodal means a named sea carrier is on the booking.', group: 'Finance', @@ -81,21 +100,89 @@ export const revenueByCategoryReport: ReportDefinition = { }, query(ctx) { const period = periodExpr(ctx.params); - return baseQuery(ctx) + + /* + * One row per period/category that actually has lines. Revenue stays + * unrounded here so the growth window below divides the same numbers the + * old single-level query did; the display rounding happens in the wrapper. + * + * The category filter is deliberately dropped from this aggregate and + * applied by the grid instead. The period axis is built from whatever + * periods this aggregate produces, so filtering here would make the axis + * depend on the selection — pick a category that was never billed and + * there would be no periods left to hang its zero rows on, which is + * exactly the empty table the grid exists to prevent. Unselected + * categories still cost nothing: the grid never lists them, so the join + * drops them. + */ + const agg = revenueLedgerQb({ ...ctx, params: { ...ctx.params, categories: null } }) .select(period, 'period') - .addSelect(CATEGORY_LABEL_EXPR, 'category') - .addSelect(REVENUE_CATEGORY_EXPR, 'categoryKey') - .addSelect(`ROUND(${REVENUE})::float8`, 'revenue') - .addSelect(`ROUND(COALESCE(${priorRevenue(period)}, 0))::float8`, 'priorRevenue') - .addSelect(`COALESCE(${growthPct(period)}, 0)`, 'growthPct') + .addSelect(REVENUE_CATEGORY_EXPR, 'category_key') + .addSelect(REVENUE, 'revenue') .addSelect(`ROUND(COALESCE(${TONS_EXPR}, 0), 1)::float8`, 'tons') .addSelect(`ROUND(COALESCE(${TEU_EXPR}, 0))::int`, 'teu') .addSelect(`ROUND(COALESCE(${CONTAINERS_EXPR}, 0))::int`, 'containers') - .addSelect(`COALESCE(${AVG_PER_UNIT_EXPR}, 0)`, 'avgPerUnit') + .addSelect(`COALESCE(${AVG_PER_UNIT_EXPR}, 0)`, 'avg_per_unit') .addSelect(UNIT_LABEL_EXPR, 'unit') .addSelect('COUNT(*)::int', 'lines') .groupBy(period) .addGroupBy(REVENUE_CATEGORY_EXPR); + + const categoryKeys = gridCategoryKeys(ctx.params) + .map((key) => `'${key}'`) + .join(', '); + + /* + * The grid: every period that has revenue at all, crossed with every + * category the filter allows, then LEFT JOINed back to the aggregate so an + * unbilled category lands at zero instead of vanishing. + * + * Periods come from the data, NOT from generate_series over the date + * filter. A default twelve-month range over a database with one billed + * month would otherwise publish eleven months of pure zeros, and a daily + * granularity would multiply that by thirty. A period that saw no revenue + * in ANY category is still absent; a category that saw none in a live + * period is not — and because the aggregate above ignores the category + * filter, "live" means live for the business, not live for the selection. + * + * `unnest(ARRAY[...])` rather than `VALUES` because an empty array is legal + * and yields no rows — `VALUES` with nothing in it is a syntax error, and a + * filter naming only unrecognised categories produces exactly that list. + */ + const grid = ` + WITH agg AS (${agg.getQuery()}) + SELECT g.period, + g.category_key, + COALESCE(a.revenue, 0) AS revenue, + COALESCE(a.tons, 0) AS tons, + COALESCE(a.teu, 0) AS teu, + COALESCE(a.containers, 0) AS containers, + COALESCE(a.avg_per_unit, 0) AS avg_per_unit, + COALESCE(a.unit, '') AS unit, + COALESCE(a.lines, 0) AS lines + FROM ( + SELECT p.period, c.category_key + FROM (SELECT DISTINCT period FROM agg) p + CROSS JOIN unnest(ARRAY[${categoryKeys}]::text[]) AS c(category_key) + ) g + LEFT JOIN agg a ON a.period = g.period AND a.category_key = g.category_key`; + + return ctx.ds + .createQueryBuilder() + .from(`(${grid})`, 'r') + .setParameters(agg.getParameters()) + .select('r.period', 'period') + .addSelect(CATEGORY_LABEL_OF('r.category_key'), 'category') + .addSelect('r.category_key', 'categoryKey') + .addSelect('ROUND(r.revenue)::float8', 'revenue') + .addSelect(`ROUND(COALESCE(${PRIOR_REVENUE}, 0))::float8`, 'priorRevenue') + .addSelect(`COALESCE(${growthPctExpr('r.revenue', PRIOR_REVENUE)}, 0)`, 'growthPct') + .addSelect('r.tons::float8', 'tons') + .addSelect('r.teu::int', 'teu') + .addSelect('r.containers::int', 'containers') + .addSelect('r.avg_per_unit::float8', 'avgPerUnit') + .addSelect('r.unit', 'unit') + .addSelect('r.lines::int', 'lines'); }, async summary(ctx) { const row = await baseQuery(ctx) @@ -113,7 +200,10 @@ export const revenueByCategoryReport: ReportDefinition = { const currency = currencyOf(ctx.params); return [ { label: 'Total revenue', value: Number(row?.revenue ?? 0), unit: currency }, - { label: 'Categories', value: Number(row?.categories ?? 0) }, + // "with revenue" is not decoration: the table now lists every category in + // every live period, so a bare "Categories: 6" next to fourteen rows + // would read as a contradiction rather than as the count of live ones. + { label: 'Categories with revenue', value: Number(row?.categories ?? 0) }, // Always shown, even at zero: an audit report must never quietly drop money. { label: 'Unclassified', value: Number(row?.unclassified ?? 0), unit: currency }, ]; From ad5caddf6060eb70931a21fd0fd9f8fe9f9abb2d Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Fri, 21 Aug 2026 15:54:53 +0300 Subject: [PATCH 8/9] feat(wagons): filter list by last-maintenance date range Adds maintenanceFrom/maintenanceTo to ListWagonsQueryDto and applies them in WagonsService.buildListQuery as a correlated subquery against wagon_status_logs (last flip to MAINTENANCE), mirroring the existing createdFrom/createdTo range filter. Both ends inclusive, whole days. --- .../wagons/dto/list-wagons-query.dto.ts | 14 +++++++++++++ .../src/modules/wagons/wagons.service.ts | 21 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/apps/edr-freight-api/src/modules/wagons/dto/list-wagons-query.dto.ts b/apps/edr-freight-api/src/modules/wagons/dto/list-wagons-query.dto.ts index 4f48c2a8b..1e2db1631 100644 --- a/apps/edr-freight-api/src/modules/wagons/dto/list-wagons-query.dto.ts +++ b/apps/edr-freight-api/src/modules/wagons/dto/list-wagons-query.dto.ts @@ -91,4 +91,18 @@ export class ListWagonsQueryDto { @IsOptional() @IsDateString() createdTo?: string; + + @ApiPropertyOptional({ + description: 'Last maintenance flip on or after this day (YYYY-MM-DD)', + }) + @IsOptional() + @IsDateString() + maintenanceFrom?: string; + + @ApiPropertyOptional({ + description: 'Last maintenance flip on or before this day (YYYY-MM-DD)', + }) + @IsOptional() + @IsDateString() + maintenanceTo?: string; } diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts index f2c76e283..034418ef6 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts @@ -90,6 +90,27 @@ export class WagonsService { }); } + // Last-maintenance range, both ends inclusive. There's no column to + // compare directly — "last maintenance" is the latest status-log flip to + // MAINTENANCE (see attachStatusDates below), so this mirrors that same + // MAX(...) FILTER(...) as a correlated subquery against the same table. + if (query.maintenanceFrom) { + qb.andWhere( + `(SELECT MAX(l.created_at) FROM freight.wagon_status_logs l + WHERE l.wagon_id = w.id AND l.to_status = '${WagonStatus.Maintenance}') + >= CAST(:maintenanceFrom AS date)`, + { maintenanceFrom: query.maintenanceFrom }, + ); + } + if (query.maintenanceTo) { + qb.andWhere( + `(SELECT MAX(l.created_at) FROM freight.wagon_status_logs l + WHERE l.wagon_id = w.id AND l.to_status = '${WagonStatus.Maintenance}') + < CAST(:maintenanceTo AS date) + INTERVAL '1 day'`, + { maintenanceTo: query.maintenanceTo }, + ); + } + // Search matches the wagon number or either run number. if (search) { qb.andWhere( From 2317684db9a0f2592b29ddd8365fdfb93309e536 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Fri, 21 Aug 2026 15:55:02 +0300 Subject: [PATCH 9/9] feat(fleet): add last-maintenance date filter to wagons list Adds maintenanceFrom/maintenanceTo to WagonListFilters and wires a wagons-only "Last maintenance" date-range filter into FleetResourcePage, alongside the existing Registered filter. Server-paged, so the range is resolved by the API (see wagons.service.ts). --- .../src/pages/fleet/FleetResourcePage.tsx | 21 ++++++++++++++++--- .../backoffice/src/services/wagon.service.ts | 6 ++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx index 50f3fe26c..746d0118a 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx @@ -224,6 +224,21 @@ const FleetResourcePage = () => { secondary: true, toParams: dateRangeParams("createdFrom", "createdTo"), }; + // Wagons-only: "last maintenance" is a derived value (latest status-log + // flip to MAINTENANCE), not a column other fleet resources have. + const dateDefs: FilterDef[] = + slug === "wagons" + ? [ + dateDef, + { + key: "lastMaintenance", + label: "Last maintenance", + type: "date", + secondary: true, + toParams: dateRangeParams("maintenanceFrom", "maintenanceTo"), + }, + ] + : [dateDef]; if (config?.listFilters?.length) { return [ ...config.listFilters.map((filter): FilterDef => ({ @@ -235,13 +250,13 @@ const FleetResourcePage = () => { ? (dynamicOptions[filter.dynamicOptions] ?? []) : (filter.options ?? []), })), - dateDef, + ...dateDefs, ]; } const fallback = FALLBACK_STATUS_OPTIONS[slug]; return fallback - ? [{ key: "status", label: "Status", type: "enum", multiple: false, options: fallback }, dateDef] - : [dateDef]; + ? [{ key: "status", label: "Status", type: "enum", multiple: false, options: fallback }, ...dateDefs] + : dateDefs; }, [config, dynamicOptions, slug]); const controls = useFilters(filterDefs, { pageSize: 10 }); diff --git a/apps/edr-freight-web/backoffice/src/services/wagon.service.ts b/apps/edr-freight-web/backoffice/src/services/wagon.service.ts index f2c4ece8e..e484632e6 100644 --- a/apps/edr-freight-web/backoffice/src/services/wagon.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/wagon.service.ts @@ -51,6 +51,10 @@ export interface WagonListFilters { /** Registration day range (YYYY-MM-DD), both ends inclusive. */ createdFrom?: string; createdTo?: string; + /** Last-maintenance day range (YYYY-MM-DD), both ends inclusive — matches + * the latest status-log flip to MAINTENANCE, not a stored column. */ + maintenanceFrom?: string; + maintenanceTo?: string; /** Only read by `getPaged`. */ page?: number; pageSize?: number; @@ -67,6 +71,8 @@ const wagonListQuery = (filters: WagonListFilters): string => { if (filters.trainNumber) params.set('trainNumber', filters.trainNumber); if (filters.createdFrom) params.set('createdFrom', filters.createdFrom); if (filters.createdTo) params.set('createdTo', filters.createdTo); + if (filters.maintenanceFrom) params.set('maintenanceFrom', filters.maintenanceFrom); + if (filters.maintenanceTo) params.set('maintenanceTo', filters.maintenanceTo); if (filters.page) params.set('page', String(filters.page)); if (filters.pageSize) params.set('pageSize', String(filters.pageSize)); const qs = params.toString();