mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
chore: reporting and filtering
This commit is contained in:
@@ -30,6 +30,7 @@ import {
|
||||
InvoiceDocumentService,
|
||||
pngDataUrl,
|
||||
} from "./documents/invoice-document.service";
|
||||
import { INVOICE_SORT_COLUMNS } from "./dto/filter-invoice.dto";
|
||||
import { InvoiceLine } from "./entities/invoice-line.entity";
|
||||
import { Invoice, InvoicePayment } from "./entities/invoice.entity";
|
||||
import { InvoiceLineRepository } from "./invoice-line.repository";
|
||||
@@ -97,6 +98,31 @@ export interface RecordPaymentInput {
|
||||
}
|
||||
|
||||
/** Default invoice payment-term window, in days, used to compute `dueAt`. */
|
||||
/**
|
||||
* Every dimension the backoffice invoice list narrows by. `findAllPaginated`
|
||||
* and `collectedSummary` share it so the summary card can never total a
|
||||
* different set of invoices than the table below it shows.
|
||||
*/
|
||||
export interface InvoiceListFilters {
|
||||
companyId?: string;
|
||||
status?: Freight.InvoiceStatus;
|
||||
statuses?: Freight.InvoiceStatus[];
|
||||
sources?: string[];
|
||||
eimsStatuses?: string[];
|
||||
currency?: string;
|
||||
search?: string;
|
||||
issuedFrom?: string;
|
||||
issuedTo?: string;
|
||||
dueFrom?: string;
|
||||
dueTo?: string;
|
||||
minAmount?: number;
|
||||
maxAmount?: number;
|
||||
hasBalance?: boolean;
|
||||
overdue?: boolean;
|
||||
/** Per-user trade-direction scope, applied via the source booking. */
|
||||
tradeDirections?: string[];
|
||||
}
|
||||
|
||||
const DEFAULT_DUE_DAYS = 14;
|
||||
|
||||
/** Statuses an invoice can still be settled (paid/refunded/cancelled) from. */
|
||||
@@ -243,12 +269,7 @@ export class BillingService {
|
||||
/** Same list filters `findAllPaginated` and `collectedSummary` both narrow by. */
|
||||
private applyInvoiceFilters(
|
||||
qb: SelectQueryBuilder<Invoice>,
|
||||
filter: {
|
||||
companyId?: string;
|
||||
status?: Freight.InvoiceStatus;
|
||||
search?: string;
|
||||
tradeDirections?: string[];
|
||||
},
|
||||
filter: InvoiceListFilters,
|
||||
) {
|
||||
if (filter.companyId) {
|
||||
qb.andWhere("invoice.companyId = :companyId", {
|
||||
@@ -258,6 +279,57 @@ export class BillingService {
|
||||
if (filter.status) {
|
||||
qb.andWhere("invoice.status = :status", { status: filter.status });
|
||||
}
|
||||
if (filter.statuses?.length) {
|
||||
qb.andWhere("invoice.status IN (:...statuses)", {
|
||||
statuses: filter.statuses,
|
||||
});
|
||||
}
|
||||
if (filter.sources?.length) {
|
||||
qb.andWhere("invoice.source IN (:...sources)", { sources: filter.sources });
|
||||
}
|
||||
if (filter.eimsStatuses?.length) {
|
||||
qb.andWhere("invoice.eimsStatus IN (:...eimsStatuses)", {
|
||||
eimsStatuses: filter.eimsStatuses,
|
||||
});
|
||||
}
|
||||
if (filter.currency) {
|
||||
// Stored casing has drifted ("usd" rows exist) — compare normalised.
|
||||
qb.andWhere("UPPER(invoice.currency) = :currency", {
|
||||
currency: filter.currency.toUpperCase(),
|
||||
});
|
||||
}
|
||||
if (filter.issuedFrom) {
|
||||
qb.andWhere("invoice.issuedAt >= :issuedFrom", {
|
||||
issuedFrom: filter.issuedFrom,
|
||||
});
|
||||
}
|
||||
if (filter.issuedTo) {
|
||||
qb.andWhere("invoice.issuedAt <= :issuedTo", { issuedTo: filter.issuedTo });
|
||||
}
|
||||
if (filter.dueFrom) {
|
||||
qb.andWhere("invoice.dueAt >= :dueFrom", { dueFrom: filter.dueFrom });
|
||||
}
|
||||
if (filter.dueTo) {
|
||||
qb.andWhere("invoice.dueAt <= :dueTo", { dueTo: filter.dueTo });
|
||||
}
|
||||
if (filter.minAmount !== undefined) {
|
||||
qb.andWhere("invoice.totalAmount >= :minAmount", {
|
||||
minAmount: filter.minAmount,
|
||||
});
|
||||
}
|
||||
if (filter.maxAmount !== undefined) {
|
||||
qb.andWhere("invoice.totalAmount <= :maxAmount", {
|
||||
maxAmount: filter.maxAmount,
|
||||
});
|
||||
}
|
||||
if (filter.hasBalance) {
|
||||
qb.andWhere("invoice.balanceAmount > 0");
|
||||
}
|
||||
if (filter.overdue) {
|
||||
// Computed, not `status = OVERDUE`: nothing sweeps PENDING rows into
|
||||
// that status, so reading the column alone under-reports the arrears.
|
||||
qb.andWhere("invoice.balanceAmount > 0 AND invoice.dueAt < now()");
|
||||
}
|
||||
if (filter.search) {
|
||||
// Searches what the row actually shows: its number, who it bills, and
|
||||
// the source record behind it (booking reference, GRN, shipping line).
|
||||
@@ -299,14 +371,11 @@ export class BillingService {
|
||||
}
|
||||
|
||||
async findAllPaginated(
|
||||
filter: {
|
||||
companyId?: string;
|
||||
status?: Freight.InvoiceStatus;
|
||||
search?: string;
|
||||
filter: InvoiceListFilters & {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
/** Per-user trade-direction scope, applied via the source booking. */
|
||||
tradeDirections?: string[];
|
||||
sortBy?: string;
|
||||
sortOrder?: "ASC" | "DESC";
|
||||
} = {},
|
||||
): Promise<{ items: InvoiceListRow[]; total: number }> {
|
||||
const page = filter.page && filter.page > 0 ? filter.page : 1;
|
||||
@@ -317,7 +386,14 @@ export class BillingService {
|
||||
.getRepository(Invoice)
|
||||
.createQueryBuilder("invoice")
|
||||
.leftJoinAndSelect("invoice.company", "company")
|
||||
.orderBy("invoice.issuedAt", "DESC")
|
||||
// sortBy is whitelisted through INVOICE_SORT_COLUMNS, never interpolated
|
||||
// raw. The id tiebreaker keeps paging stable when the sort column ties
|
||||
// (issuedAt is null on every DRAFT row).
|
||||
.orderBy(
|
||||
INVOICE_SORT_COLUMNS[filter.sortBy ?? ""] ?? "invoice.issuedAt",
|
||||
filter.sortOrder ?? "DESC",
|
||||
)
|
||||
.addOrderBy("invoice.id", "ASC")
|
||||
.skip((page - 1) * pageSize)
|
||||
.take(pageSize);
|
||||
|
||||
@@ -458,12 +534,7 @@ export class BillingService {
|
||||
* visible page.
|
||||
*/
|
||||
async collectedSummary(
|
||||
filter: {
|
||||
companyId?: string;
|
||||
status?: Freight.InvoiceStatus;
|
||||
search?: string;
|
||||
tradeDirections?: string[];
|
||||
} = {},
|
||||
filter: InvoiceListFilters = {},
|
||||
): Promise<Record<string, number>> {
|
||||
const qb = this.dataSource
|
||||
.getRepository(Invoice)
|
||||
|
||||
@@ -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<string, string>) => {
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
@@ -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<string, string> = {
|
||||
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";
|
||||
}
|
||||
|
||||
@@ -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<Company> {
|
||||
* 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<Company> {
|
||||
type,
|
||||
kind,
|
||||
status,
|
||||
nationality,
|
||||
createdFrom,
|
||||
createdTo,
|
||||
onboardingCompleted,
|
||||
hasPendingChangeRequest,
|
||||
sortBy = 'review',
|
||||
@@ -122,6 +108,18 @@ export class CompaniesRepository extends BaseRepository<Company> {
|
||||
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
|
||||
|
||||
@@ -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
|
||||
)`;
|
||||
@@ -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; " +
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}%`,
|
||||
|
||||
@@ -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}%` });
|
||||
|
||||
@@ -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<ObjectLiteral>,
|
||||
): SelectQueryBuilder<ObjectLiteral> {
|
||||
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' },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -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<ObjectLiteral> {
|
||||
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) },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -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<ObjectLiteral> {
|
||||
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) },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -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<ObjectLiteral> {
|
||||
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' },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -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<ObjectLiteral> {
|
||||
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' },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -1,91 +1,106 @@
|
||||
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||
import { ObjectLiteral, SelectQueryBuilder } from "typeorm";
|
||||
|
||||
import { Booking } from '../../bookings/entities/booking.entity';
|
||||
import { Company } from '../../companies/entities/company.entity';
|
||||
import { ReportContext, ReportDefinition } from '../report.types';
|
||||
import { ReportContext, ReportColumn, ReportDefinition } from "../report.types";
|
||||
import {
|
||||
PAID_SHARE,
|
||||
PAYER_EXPR,
|
||||
PAYMENT_CLASSES,
|
||||
PAYMENT_CLASS_EXPR,
|
||||
REVENUE_FILTERS,
|
||||
REVENUE_SUM,
|
||||
currencyOf,
|
||||
revenueLedgerQb,
|
||||
} from "../revenue-classification";
|
||||
|
||||
const TONS = 'COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)';
|
||||
const REVENUE = 'COALESCE(b.adjusted_total_amount, b.total_amount)';
|
||||
const NOT_UMBRELLA = "(b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')";
|
||||
const DEAD_STATUSES = ['DRAFT', 'CANCELLED', 'REJECTED', 'EXPIRED'];
|
||||
/**
|
||||
* One column per payment class, pivoted with FILTER. The class values are the
|
||||
* compile-time constants in PAYMENT_CLASSES, never user input, so they are
|
||||
* safe to interpolate.
|
||||
*/
|
||||
const CLASS_COLUMNS = PAYMENT_CLASSES.map((c) => ({
|
||||
value: c.value,
|
||||
key: c.value
|
||||
.toLowerCase()
|
||||
.replace(/_(.)/g, (_, ch: string) => ch.toUpperCase()),
|
||||
label: c.label,
|
||||
}));
|
||||
|
||||
const classMoneyColumns: ReportColumn[] = CLASS_COLUMNS.map((c) => ({
|
||||
key: c.key,
|
||||
label: c.label,
|
||||
type: "money",
|
||||
sortable: true,
|
||||
}));
|
||||
|
||||
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||
const { params, directions } = ctx;
|
||||
const qb = ctx.ds
|
||||
.createQueryBuilder()
|
||||
.from(Booking, 'b')
|
||||
.innerJoin(Company, 'c', 'c.id = b.company_id')
|
||||
.where(`b.deleted_at IS NULL AND ${NOT_UMBRELLA}`);
|
||||
|
||||
if (params.dateFrom) qb.andWhere('b.created_at >= :dateFrom', { dateFrom: params.dateFrom });
|
||||
if (params.dateTo) qb.andWhere('b.created_at < :dateTo', { dateTo: params.dateTo });
|
||||
if (params.direction) qb.andWhere('b.trade_direction = :direction', { direction: params.direction });
|
||||
if (params.freightType) qb.andWhere('b.freight_type = :freightType', { freightType: params.freightType });
|
||||
const statuses = params.statuses as string[] | null;
|
||||
if (statuses) {
|
||||
qb.andWhere('b.status IN (:...statuses)', { statuses });
|
||||
} else {
|
||||
qb.andWhere('b.status NOT IN (:...deadStatuses)', { deadStatuses: DEAD_STATUSES });
|
||||
}
|
||||
if (directions !== null) {
|
||||
qb.andWhere(directions.length ? 'b.trade_direction IN (:...directions)' : '1 = 0', {
|
||||
directions,
|
||||
});
|
||||
}
|
||||
return qb;
|
||||
return revenueLedgerQb(ctx);
|
||||
}
|
||||
|
||||
export const revenueByCustomerReport: ReportDefinition = {
|
||||
key: 'revenue-by-customer',
|
||||
title: 'Revenue by Customer',
|
||||
description: 'Ranked customers by booking revenue',
|
||||
group: 'Commercial',
|
||||
filters: [
|
||||
{ key: 'date', label: 'Created', type: 'daterange' },
|
||||
{
|
||||
key: 'direction',
|
||||
label: 'Direction',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'IMPORT', label: 'Import' },
|
||||
{ value: 'EXPORT', label: 'Export' },
|
||||
{ value: 'DOMESTIC', label: 'Domestic' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'freightType',
|
||||
label: 'Freight type',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'CONTAINER', label: 'Container' },
|
||||
{ value: 'BULK', label: 'Bulk' },
|
||||
],
|
||||
},
|
||||
{ key: 'statuses', label: 'Status', type: 'multiselect' },
|
||||
],
|
||||
key: "revenue-by-customer",
|
||||
title: "Revenue by Customer",
|
||||
description:
|
||||
"Every paying customer on one row: total billed revenue, what they have settled, " +
|
||||
"what is still open, and a column per charge type — rail transport, customs " +
|
||||
"clearance, first/last mile, overweight, cancellation, demurrage, storage, loading " +
|
||||
"and unloading, and additional charges. Built on invoice lines, so the charge-type " +
|
||||
"split is the billed one; a booking total is a lump sum and cannot be split. The " +
|
||||
"payer is the company or, for shipping-line credit invoices, the shipping line. " +
|
||||
"There is no dedicated loading/unloading charge type in the system — handling, " +
|
||||
"double-handling and lashing stand in for it.",
|
||||
group: "Finance",
|
||||
filters: REVENUE_FILTERS,
|
||||
columns: [
|
||||
{ key: 'customer', label: 'Customer', type: 'string', sortable: true, sortExpr: 'c.name' },
|
||||
{ key: 'bookings', label: 'Bookings', type: 'number', sortable: true },
|
||||
{ key: 'tons', label: 'Tonnage', type: 'tons', sortable: true },
|
||||
{ key: 'revenue', label: 'Revenue', type: 'money', sortable: true },
|
||||
{
|
||||
key: "customer",
|
||||
label: "Customer",
|
||||
type: "string",
|
||||
sortable: true,
|
||||
sortExpr: PAYER_EXPR,
|
||||
},
|
||||
{ key: "revenue", label: "Total revenue", type: "money", sortable: true },
|
||||
{ key: "paid", label: "Paid", type: "money", sortable: true },
|
||||
{ key: "outstanding", label: "Outstanding", type: "money", sortable: true },
|
||||
...classMoneyColumns,
|
||||
{ key: "invoices", label: "Invoices", type: "number", sortable: true },
|
||||
],
|
||||
defaultSort: { key: 'revenue', dir: 'DESC' },
|
||||
defaultSort: { key: "revenue", dir: "DESC" },
|
||||
chart: { type: "bar", x: "customer", y: ["revenue"] },
|
||||
drill: { to: "revenue-transactions", carry: { customer: "customer" } },
|
||||
query(ctx) {
|
||||
return baseQuery(ctx)
|
||||
.select('c.name', 'customer')
|
||||
.addSelect('COUNT(*)::int', 'bookings')
|
||||
.addSelect(`ROUND(COALESCE(SUM(${TONS}), 0))::float8`, 'tons')
|
||||
.addSelect(`ROUND(COALESCE(SUM(${REVENUE}), 0))::float8`, 'revenue')
|
||||
.groupBy('c.name');
|
||||
const qb = baseQuery(ctx)
|
||||
.select(PAYER_EXPR, "customer")
|
||||
.addSelect(REVENUE_SUM, "revenue")
|
||||
.addSelect(`ROUND(COALESCE(SUM(${PAID_SHARE}), 0))::float8`, "paid")
|
||||
.addSelect(
|
||||
`ROUND(COALESCE(SUM(il.amount - (${PAID_SHARE})), 0))::float8`,
|
||||
"outstanding",
|
||||
)
|
||||
.addSelect("COUNT(DISTINCT i.id)::int", "invoices")
|
||||
.groupBy(PAYER_EXPR);
|
||||
|
||||
for (const c of CLASS_COLUMNS) {
|
||||
qb.addSelect(
|
||||
`ROUND(COALESCE(SUM(il.amount) FILTER (WHERE ${PAYMENT_CLASS_EXPR} = '${c.value}'), 0))::float8`,
|
||||
c.key,
|
||||
);
|
||||
}
|
||||
return qb;
|
||||
},
|
||||
async summary(ctx) {
|
||||
const row = await baseQuery(ctx)
|
||||
.select('COUNT(DISTINCT c.name)::int', 'customers')
|
||||
.addSelect(`ROUND(COALESCE(SUM(${REVENUE}), 0))::float8`, 'revenue')
|
||||
.getRawOne();
|
||||
.select(`COUNT(DISTINCT ${PAYER_EXPR})::int`, "customers")
|
||||
.addSelect(REVENUE_SUM, "revenue")
|
||||
.addSelect(`ROUND(COALESCE(SUM(${PAID_SHARE}), 0))::float8`, "paid")
|
||||
.getRawOne<{ customers: number; revenue: number; paid: number }>();
|
||||
const revenue = Number(row?.revenue ?? 0);
|
||||
const paid = Number(row?.paid ?? 0);
|
||||
const unit = currencyOf(ctx.params);
|
||||
return [
|
||||
{ label: 'Customers', value: Number(row?.customers ?? 0) },
|
||||
{ label: 'Revenue', value: Number(row?.revenue ?? 0), unit: 'ETB' },
|
||||
{ label: "Customers", value: Number(row?.customers ?? 0) },
|
||||
{ label: "Total revenue", value: revenue, unit },
|
||||
{ label: "Paid", value: paid, unit },
|
||||
{ label: "Outstanding", value: Math.round(revenue - paid), unit },
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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<ObjectLiteral> {
|
||||
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' },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -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<ObjectLiteral> {
|
||||
}
|
||||
|
||||
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) },
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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<ReportKey, ReportDefinition>(REPORTS.map((r) => [r.key, r]));
|
||||
const BY_KEY = new Map<ReportKey, ReportDefinition>(
|
||||
REPORTS.map((r) => [r.key, r]),
|
||||
);
|
||||
|
||||
export function getReport(key: string): ReportDefinition | undefined {
|
||||
return BY_KEY.get(key as ReportKey);
|
||||
|
||||
@@ -49,13 +49,14 @@ const perm = (id: string, key: string, en: string): FreightPermissionSeed => ({
|
||||
});
|
||||
|
||||
/**
|
||||
* One entry per report definition (see modules/reports/definitions). Each
|
||||
* gets its own permission, gated behind the `reports:view` master key that
|
||||
* opens the Reports section itself.
|
||||
* Keep new keys at the END: reportPermId derives ids from list index, so a
|
||||
* mid-list insert would shift ids already seeded for later keys.
|
||||
* Every report key ever seeded, in seed order.
|
||||
*
|
||||
* NEVER reorder or delete an entry: reportPermId derives a permission's uuid
|
||||
* from its index here, so a shift would re-map ids already granted to roles.
|
||||
* Retiring a report means adding it to RETIRED_REPORT_KEYS, not removing it.
|
||||
* New keys go at the END.
|
||||
*/
|
||||
export const REPORT_KEYS = [
|
||||
const SEEDED_REPORT_KEYS = [
|
||||
"bookings-list",
|
||||
"revenue-by-customer",
|
||||
"aging-receivables",
|
||||
@@ -98,21 +99,54 @@ export const REPORT_KEYS = [
|
||||
"cargo-volume-by-station",
|
||||
] as const;
|
||||
|
||||
export type ReportKey = (typeof REPORT_KEYS)[number];
|
||||
/**
|
||||
* Reports whose definition was deleted (see modules/reports/definitions) — a
|
||||
* flat list the Exports module and its backoffice table already serve, or a
|
||||
* narrower view of a report that supersedes it. Their permissions stay seeded
|
||||
* so no live report's uuid moves; nothing resolves them to a definition.
|
||||
*/
|
||||
const RETIRED_REPORT_KEYS = [
|
||||
"bookings-list",
|
||||
"customer-status",
|
||||
"contract-lifecycle",
|
||||
"invoices-by-status",
|
||||
"payments-by-status",
|
||||
"revenue-summary",
|
||||
] as const;
|
||||
|
||||
export const reportPermissionKey = (key: ReportKey): string =>
|
||||
export type ReportKey = Exclude<
|
||||
(typeof SEEDED_REPORT_KEYS)[number],
|
||||
(typeof RETIRED_REPORT_KEYS)[number]
|
||||
>;
|
||||
|
||||
/** One entry per live report definition — what the catalog and presets use. */
|
||||
export const REPORT_KEYS: readonly ReportKey[] = SEEDED_REPORT_KEYS.filter(
|
||||
(k): k is ReportKey =>
|
||||
!(RETIRED_REPORT_KEYS as readonly string[]).includes(k),
|
||||
);
|
||||
|
||||
export const reportPermissionKey = (key: string): string =>
|
||||
`edr_freight_app:reports:${key.replace(/-/g, "_")}:view`;
|
||||
|
||||
const reportPermId = (index: number): string =>
|
||||
`a4f00002-0001-4000-8000-${(index + 1).toString(16).padStart(12, "0")}`;
|
||||
|
||||
const titleCase = (slug: string): string =>
|
||||
slug.split("-").map((w) => w[0].toUpperCase() + w.slice(1)).join(" ");
|
||||
slug
|
||||
.split("-")
|
||||
.map((w) => w[0].toUpperCase() + w.slice(1))
|
||||
.join(" ");
|
||||
|
||||
export const REPORT_PERMISSIONS: FreightPermissionSeed[] = REPORT_KEYS.map(
|
||||
(key, index) =>
|
||||
perm(reportPermId(index), reportPermissionKey(key), `Report: ${titleCase(key)}`),
|
||||
);
|
||||
// Seeded from SEEDED_REPORT_KEYS, not REPORT_KEYS: a retired report keeps its
|
||||
// index and its permission row, which is what stops the live ids from moving.
|
||||
export const REPORT_PERMISSIONS: FreightPermissionSeed[] =
|
||||
SEEDED_REPORT_KEYS.map((key, index) =>
|
||||
perm(
|
||||
reportPermId(index),
|
||||
reportPermissionKey(key),
|
||||
`Report: ${titleCase(key)}`,
|
||||
),
|
||||
);
|
||||
|
||||
export const BOOKING_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
perm(
|
||||
@@ -464,12 +498,12 @@ export const RULE_ENGINE_PERMISSIONS: FreightPermissionSeed[] =
|
||||
),
|
||||
...(approveId
|
||||
? [
|
||||
perm(
|
||||
approveId,
|
||||
`edr_freight_app:rule_engine:${resource}:approve`,
|
||||
`Approve ${slug} changes`,
|
||||
),
|
||||
]
|
||||
perm(
|
||||
approveId,
|
||||
`edr_freight_app:rule_engine:${resource}:approve`,
|
||||
`Approve ${slug} changes`,
|
||||
),
|
||||
]
|
||||
: []),
|
||||
];
|
||||
});
|
||||
@@ -572,8 +606,16 @@ export const SHIPPING_LINE_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
|
||||
// Internal chat (Matrix/Element) — sidebar visibility + manual reconcile trigger.
|
||||
export const CHAT_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
perm('c9a00001-0001-4000-8000-000000000001', 'edr_freight_app:chat:view', 'Open internal chat'),
|
||||
perm('c9a00001-0001-4000-8000-000000000002', 'edr_freight_app:chat:sync', 'Re-run chat room/membership sync'),
|
||||
perm(
|
||||
"c9a00001-0001-4000-8000-000000000001",
|
||||
"edr_freight_app:chat:view",
|
||||
"Open internal chat",
|
||||
),
|
||||
perm(
|
||||
"c9a00001-0001-4000-8000-000000000002",
|
||||
"edr_freight_app:chat:sync",
|
||||
"Re-run chat room/membership sync",
|
||||
),
|
||||
];
|
||||
|
||||
// D. Finance — payments + invoices
|
||||
@@ -1948,8 +1990,7 @@ export const FREIGHT_PERMS = {
|
||||
// finance-level REQUEST grants (per action) and decision grants that apply
|
||||
// to ANY pending request — including the holder's own.
|
||||
/** Request recording an offline payment against a credit invoice. */
|
||||
invoiceMarkPaid:
|
||||
"edr_freight_app:shipping_line_credits:invoice_mark_paid",
|
||||
invoiceMarkPaid: "edr_freight_app:shipping_line_credits:invoice_mark_paid",
|
||||
/** Request voiding a credit invoice (credits return to unbilled). */
|
||||
invoiceCancel: "edr_freight_app:shipping_line_credits:invoice_cancel",
|
||||
/** Approve any pending invoice request (mark-paid or cancel). */
|
||||
@@ -1958,8 +1999,8 @@ export const FREIGHT_PERMS = {
|
||||
invoiceReject: "edr_freight_app:shipping_line_credits:invoice_reject",
|
||||
},
|
||||
chat: {
|
||||
view: 'edr_freight_app:chat:view',
|
||||
sync: 'edr_freight_app:chat:sync',
|
||||
view: "edr_freight_app:chat:view",
|
||||
sync: "edr_freight_app:chat:sync",
|
||||
},
|
||||
payments: {
|
||||
view: "edr_freight_app:payments:view",
|
||||
@@ -2403,7 +2444,8 @@ const FLEET_GRANULAR_KEYS: string[] = [
|
||||
FREIGHT_PERMS.consignments.create,
|
||||
];
|
||||
|
||||
const allReportKeys = (): string[] => REPORT_KEYS.map((k) => reportPermissionKey(k));
|
||||
const allReportKeys = (): string[] =>
|
||||
REPORT_KEYS.map((k) => reportPermissionKey(k));
|
||||
|
||||
// Everyone who works the booking desk also opens the overview dashboard and
|
||||
// the canned reports — granted alongside bookings:view in every preset below.
|
||||
|
||||
@@ -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<string, string>;
|
||||
}[] = [
|
||||
{ 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<CustomerView>("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<string, unknown> = { ...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() {
|
||||
<Stack gap={0}>
|
||||
<Box px="md" pt="md" pb="sm" w="100%">
|
||||
<FilterBar
|
||||
defs={NO_FILTER_DEFS}
|
||||
defs={CUSTOMER_FILTER_DEFS}
|
||||
controls={controls}
|
||||
searchPlaceholder="Search by company, TIN, email or profile reference…"
|
||||
sortOptions={SORT_OPTIONS.map((o) => ({ ...o }))}
|
||||
viewId="customers"
|
||||
>
|
||||
<SegmentedControl
|
||||
size="sm"
|
||||
radius="md"
|
||||
value={view}
|
||||
onChange={(v) => {
|
||||
// `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" },
|
||||
]}
|
||||
/>
|
||||
<ExportButton datasetKey="customers" params={controls.params} />
|
||||
<ExportButton datasetKey="customers" params={exportParams} />
|
||||
</FilterBar>
|
||||
</Box>
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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<string, unknown> = { ...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() {
|
||||
<Card p={0}>
|
||||
<Stack gap={0}>
|
||||
<Box px="md" pt="md" pb="sm" w="100%">
|
||||
<Group justify="space-between" gap="md" wrap="wrap">
|
||||
<TextInput
|
||||
placeholder="Search invoice, customer, booking ref, GRN or shipping line…"
|
||||
leftSection={<Search size={18} />}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
rightSection={
|
||||
query ? (
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
color="gray"
|
||||
radius="md"
|
||||
variant="transparent"
|
||||
onClick={() => setQuery("")}
|
||||
>
|
||||
<X size={16} />
|
||||
</ActionIcon>
|
||||
) : null
|
||||
}
|
||||
style={{ flex: 1, minWidth: "240px" }}
|
||||
radius="lg"
|
||||
/>
|
||||
<ExportButton datasetKey="invoices" params={filter} size="sm" />
|
||||
<SegmentedControl
|
||||
size="sm"
|
||||
radius="md"
|
||||
value={statusFilter || "all"}
|
||||
onChange={(v) => {
|
||||
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" },
|
||||
]}
|
||||
/>
|
||||
<FilterBar
|
||||
defs={INVOICE_FILTER_DEFS}
|
||||
controls={controls}
|
||||
searchPlaceholder="Search invoice, customer, booking ref, GRN or shipping line…"
|
||||
sortOptions={SORT_OPTIONS}
|
||||
viewId="invoices"
|
||||
>
|
||||
<ExportButton datasetKey="invoices" params={exportParams} size="sm" />
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="lg"
|
||||
@@ -285,7 +357,7 @@ export default function InvoicesPanel() {
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</FilterBar>
|
||||
</Box>
|
||||
|
||||
<Box style={{ overflowX: "auto" }} w="100%">
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
/**
|
||||
|
||||
@@ -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). */
|
||||
|
||||
Reference in New Issue
Block a user