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 247a1dad5..7270c92b0 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -31,6 +31,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"; @@ -98,6 +99,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. */ @@ -244,12 +270,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", { @@ -259,6 +280,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). @@ -300,14 +372,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; @@ -318,7 +387,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); @@ -459,12 +535,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/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' }) 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-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 }, ]; 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..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,91 +1,101 @@ 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' }, - ], + 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' }, + 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: '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/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. 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 2347a446c..1ae1093f2 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( 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 1a6170336..5fde44f2d 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,100 @@ 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)}`, + ), + ); + +/** + * 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( @@ -464,12 +544,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 +652,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 @@ -1746,6 +1834,7 @@ export const NOTIFICATION_PERMISSIONS: FreightPermissionSeed[] = [ export const ADVANCED_BACKOFFICE_PERMISSIONS: FreightPermissionSeed[] = [ ...REPORT_PERMISSIONS, + ...OVERVIEW_LAYOUT_PERMISSIONS, ...CUSTOMER_PERMISSIONS, ...SHIPPING_LINE_PERMISSIONS, ...CHAT_PERMISSIONS, @@ -1978,8 +2067,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). */ @@ -1988,8 +2076,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", @@ -2292,6 +2380,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", @@ -2444,7 +2533,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/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/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/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/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/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/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, 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(); 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). */