Merge pull request #1273 from Tria-plc/freight/nati-2

Freight/nati 2
This commit is contained in:
Nathnael Wondisha
2026-08-13 14:41:41 +03:00
committed by GitHub
114 changed files with 7014 additions and 5278 deletions

View File

@@ -69,6 +69,7 @@
"cross-env": "^10.1.0",
"dotenv": "^17.4.2",
"dotenv-cli": "^11.0.0",
"exceljs": "^4.4.0",
"handlebars": "^4.7.9",
"jose": "^5.10.0",
"libphonenumber-js": "^1.13.6",

View File

@@ -413,6 +413,32 @@ export class BillingService {
return `data:image/png;base64,${signedQr}`;
}
/** Route + wagon count summary rows for a booking-sourced invoice; empty for every other source. */
private async bookingSummaryRows(
invoice: Invoice,
): Promise<InvoiceDocumentModel["summary"]> {
if (invoice.source !== Freight.InvoiceSource.Booking) return [];
const booking = await this.dataSource.getRepository(Booking).findOne({
where: { id: invoice.sourceId },
relations: { originYard: true, destinationYard: true },
});
if (!booking) return [];
return [
{
label: "Route",
value:
booking.originYard && booking.destinationYard
? `${booking.originYard.label}${booking.destinationYard.label}`
: null,
},
{
label: "Wagons",
value:
booking.wagonsRequired != null ? String(booking.wagonsRequired) : null,
},
];
}
/** Map a global invoice (+ lines) onto the source-agnostic document model. */
private async toDocumentModel(
invoice: Invoice & { lines: InvoiceLine[] },
@@ -446,6 +472,7 @@ export class BillingService {
{ label: "Status", value: invoice.status },
{ label: "Type", value: invoice.type },
{ label: "Reference", value: invoice.sourceId },
...(await this.bookingSummaryRows(invoice)),
{ label: "Currency", value: invoice.currency },
{
label: "Issued",

View File

@@ -18,6 +18,8 @@ const PDF_PRINT_STYLES = `
export interface PdfRenderOptions {
/** Label used in logs to identify the document kind. */
label?: string;
/** Landscape A4 instead of the default portrait — wide tables need it. */
landscape?: boolean;
/**
* Degraded renderer used when Chromium is unavailable. Receives the
* print-prepared HTML and must return a valid PDF buffer (≥ 2KB, `%PDF-`
@@ -59,6 +61,7 @@ export class PdfRenderService {
const pdf = await page.pdf({
format: "A4",
landscape: opts.landscape ?? false,
printBackground: true,
margin: { top: "16mm", bottom: "18mm", left: "14mm", right: "14mm" },
});

View File

@@ -59,6 +59,7 @@ export interface BookingListFilterOptions {
assignedToSchedule?: 'true' | 'false';
companyId?: string;
companyProfileId?: string;
contractId?: string;
contractType?: string;
serviceTypeId?: string;
cargoTypeId?: string;
@@ -936,6 +937,11 @@ export class BookingsRepository extends BaseRepository<Booking> {
companyProfileId: options.companyProfileId,
});
}
if (options.contractId) {
qb.andWhere('booking.contract_id = :contractId', {
contractId: options.contractId,
});
}
if (options.contractType) {
qb.andWhere('booking.contract_type = :contractType', {
contractType: options.contractType,

View File

@@ -1805,6 +1805,7 @@ export class BookingsService {
// ANDs both, so cross-company access is impossible.
companyId: forceCompanyId ?? filter.companyId,
companyProfileId: forceCompanyProfileId ?? filter.companyProfileId,
contractId: filter.contractId,
tradeDirections,
contractType: filter.contractType,
serviceTypeId: filter.serviceTypeId,

View File

@@ -47,6 +47,11 @@ export class FilterBookingDto {
@IsUUID()
companyProfileId?: string;
@ApiPropertyOptional({ format: 'uuid', description: 'Filter bookings drawn down under this contract' })
@IsOptional()
@IsUUID()
contractId?: string;
@ApiPropertyOptional()
@IsOptional()
contractType?: string;

View File

@@ -0,0 +1,87 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { Company } from '../../companies/entities/company.entity';
import { Invoice } from '../../billing/entities/invoice.entity';
import { applyBookingRefDirectionScope } from '../../user-trade-access/trade-scope.util';
import { ReportContext, ReportDefinition } from '../report.types';
const OPEN_STATUSES = ['ISSUED', 'PENDING', 'PARTIALLY_PAID', 'OVERDUE'];
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const { params, directions } = ctx;
// "As of" — invoices due after this instant aren't overdue yet. Defaults
// to now() in SQL when the filter is unset (see the COALESCE below).
const asOf = (params.asOf as string | null) ?? null;
const qb = ctx.ds
.createQueryBuilder()
.from(Invoice, 'i')
.innerJoin(Company, 'c', 'c.id = i.company_id')
.where('i.deleted_at IS NULL')
.andWhere('i.status IN (:...openStatuses)', { openStatuses: OPEN_STATUSES })
.andWhere('i.balance_amount > 0')
.setParameter('asOf', asOf);
// ACL: invoices.source_id is a varchar pointer at the originating booking.
// Rows not pointing at a booking (e.g. warehouse fee invoices) stay visible.
return applyBookingRefDirectionScope(qb, 'i.source_id', directions);
}
export const agingReceivablesReport: ReportDefinition = {
key: 'aging-receivables',
title: 'Aging Receivables',
description: 'Outstanding customer balances bucketed by days overdue',
group: 'Finance',
filters: [{ key: 'asOf', label: 'As of', type: 'date' }],
columns: [
{ key: 'customer', label: 'Customer', type: 'string', sortable: true, sortExpr: 'c.name' },
{ key: 'invoices', label: 'Invoices', type: 'number' },
{ key: 'outstanding', label: 'Outstanding', type: 'money', sortable: true },
{ key: 'current', label: 'Current', type: 'money' },
{ key: 'overdue0to30', label: '0-30d', type: 'money' },
{ key: 'overdue31to60', label: '31-60d', type: 'money' },
{ key: 'overdue61to90', label: '61-90d', type: 'money' },
{ key: 'overdue90plus', label: '90d+', type: 'money' },
],
defaultSort: { key: 'outstanding', dir: 'DESC' },
query(ctx) {
return baseQuery(ctx)
.select('c.name', 'customer')
.addSelect('COUNT(*)::int', 'invoices')
.addSelect('ROUND(SUM(i.balance_amount))::float8', 'outstanding')
.addSelect(
`ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at >= COALESCE(:asOf::timestamptz, now())), 0))::float8`,
'current',
)
.addSelect(
`ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < COALESCE(:asOf::timestamptz, now())
AND i.due_at >= COALESCE(:asOf::timestamptz, now()) - interval '30 days'), 0))::float8`,
'overdue0to30',
)
.addSelect(
`ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < COALESCE(:asOf::timestamptz, now()) - interval '30 days'
AND i.due_at >= COALESCE(:asOf::timestamptz, now()) - interval '60 days'), 0))::float8`,
'overdue31to60',
)
.addSelect(
`ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < COALESCE(:asOf::timestamptz, now()) - interval '60 days'
AND i.due_at >= COALESCE(:asOf::timestamptz, now()) - interval '90 days'), 0))::float8`,
'overdue61to90',
)
.addSelect(
`ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < COALESCE(:asOf::timestamptz, now()) - interval '90 days'), 0))::float8`,
'overdue90plus',
)
.groupBy('c.name');
},
async summary(ctx) {
const row = await baseQuery(ctx)
.select('ROUND(COALESCE(SUM(i.balance_amount), 0))::float8', 'outstanding')
.addSelect('COUNT(DISTINCT c.id)::int', 'customers')
.getRawOne();
return [
{ label: 'Outstanding', value: Number(row?.outstanding ?? 0), unit: 'ETB' },
{ label: 'Customers with balance', value: Number(row?.customers ?? 0) },
];
},
};

View File

@@ -0,0 +1,109 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { BookingStatus } from '@edr/types';
import { Booking } from '../../bookings/entities/booking.entity';
import { Yard } from '../../rule-engine/entities/yard.entity';
import { CargoType } from '../../rule-engine/entities/cargo-type.entity';
import { ReportContext, ReportDefinition } from '../report.types';
// One resolver behind "Booking per status, per port/train/date/cargo/contract
// type" — the same breakdown Operation, Marketing, Global Logistics and the
// Operation Report each ask for verbatim. Embed once, reuse everywhere.
const TONS = 'COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)';
const REVENUE = 'COALESCE(b.adjusted_total_amount, b.total_amount)';
const STATUS_OPTIONS = [...new Set(Object.values(BookingStatus))].map((v) => ({
value: v,
label: v.replace(/_/g, ' '),
}));
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const { params, directions } = ctx;
const qb = ctx.ds
.createQueryBuilder()
.from(Booking, 'b')
.leftJoin(Yard, 'o', 'o.id = b.origin_yard_id')
.leftJoin(CargoType, 'cty', 'cty.id = b.cargo_type_id')
.where('b.deleted_at IS NULL');
if (params.dateFrom) qb.andWhere('b.created_at >= :dateFrom', { dateFrom: params.dateFrom });
if (params.dateTo) qb.andWhere('b.created_at < :dateTo', { dateTo: params.dateTo });
if (params.direction) qb.andWhere('b.trade_direction = :direction', { direction: params.direction });
if (params.freightType) qb.andWhere('b.freight_type = :freightType', { freightType: params.freightType });
const statuses = params.statuses as string[] | null;
if (statuses) qb.andWhere('b.status IN (:...statuses)', { statuses });
if (directions !== null) {
qb.andWhere(directions.length ? 'b.trade_direction IN (:...directions)' : '1 = 0', { directions });
}
return qb;
}
export const bookingStatusBreakdownReport: ReportDefinition = {
key: 'booking-status-breakdown',
title: 'Bookings by Status',
description: 'Booking counts by status, direction, origin station, cargo and contract type',
group: 'Commercial',
filters: [
{ key: 'date', label: 'Created', type: 'daterange' },
{
key: 'direction',
label: 'Direction',
type: 'select',
options: [
{ value: 'IMPORT', label: 'Import' },
{ value: 'EXPORT', label: 'Export' },
{ value: 'DOMESTIC', label: 'Domestic' },
],
},
{
key: 'freightType',
label: 'Freight type',
type: 'select',
options: [
{ value: 'CONTAINER', label: 'Container' },
{ value: 'BULK', label: 'Bulk' },
],
},
{ key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS },
],
columns: [
{ key: 'status', label: 'Status', type: 'string', sortable: true, sortExpr: 'b.status' },
{ key: 'direction', label: 'Direction', type: 'string', sortable: true, sortExpr: 'b.trade_direction' },
{ key: 'originStation', label: 'Origin', type: 'string', sortable: true },
{ key: 'cargoType', label: 'Cargo type', type: 'string', sortable: true },
{ key: 'contractKind', label: 'Contract type', type: 'string', sortable: true },
{ key: 'bookings', label: 'Bookings', type: 'number', sortable: true },
{ key: 'tons', label: 'Tonnage', type: 'tons', sortable: true },
{ key: 'amount', label: 'Amount', type: 'money', sortable: true },
],
defaultSort: { key: 'bookings', dir: 'DESC' },
chart: { type: 'bar', x: 'status', y: ['bookings'] },
query(ctx) {
return baseQuery(ctx)
.select('b.status', 'status')
.addSelect('b.trade_direction', 'direction')
.addSelect("COALESCE(o.label, 'Unknown')", 'originStation')
.addSelect("COALESCE(cty.cargo_type_name, 'Other')", 'cargoType')
.addSelect("COALESCE(b.contract_kind, 'SPOT')", 'contractKind')
.addSelect('COUNT(*)::int', 'bookings')
.addSelect(`ROUND(COALESCE(SUM(${TONS}), 0))::float8`, 'tons')
.addSelect(`ROUND(COALESCE(SUM(${REVENUE}), 0))::float8`, 'amount')
.groupBy('b.status')
.addGroupBy('b.trade_direction')
.addGroupBy('o.label')
.addGroupBy('cty.cargo_type_name')
.addGroupBy('b.contract_kind');
},
async summary(ctx) {
const row = await baseQuery(ctx)
.select('COUNT(*)::int', 'bookings')
.addSelect(`ROUND(COALESCE(SUM(${TONS}), 0))::float8`, 'tons')
.addSelect(`ROUND(COALESCE(SUM(${REVENUE}), 0))::float8`, 'amount')
.getRawOne();
return [
{ label: 'Bookings', value: Number(row?.bookings ?? 0) },
{ label: 'Tonnage', value: Number(row?.tons ?? 0), unit: 't' },
{ label: 'Amount', value: Number(row?.amount ?? 0), unit: 'ETB' },
];
},
};

View File

@@ -0,0 +1,129 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { Booking } from '../../bookings/entities/booking.entity';
import { CargoType } from '../../rule-engine/entities/cargo-type.entity';
import { Yard } from '../../rule-engine/entities/yard.entity';
import { Company } from '../../companies/entities/company.entity';
import { ReportContext, ReportDefinition } from '../report.types';
// For PER_ITEM bulk bookings cargo_total_weight_vgm holds an item COUNT, and
// the real tonnage lives in bulk_total_weight_tons — hence the COALESCE order
// (same guard as the retired report-queries.ts).
const TONS = 'COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)';
// adjusted_total_amount silently overrides total_amount when set.
const REVENUE = 'COALESCE(b.adjusted_total_amount, b.total_amount)';
// GENERAL contract_kind rows are umbrella contracts, not shipments; counting
// them double-counts every child booking.
const NOT_UMBRELLA = "(b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')";
const DEAD_STATUSES = ['DRAFT', 'CANCELLED', 'REJECTED', 'EXPIRED'];
function applyFilters(
ctx: ReportContext,
qb: SelectQueryBuilder<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' },
];
},
};

View File

@@ -0,0 +1,58 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { Booking } from '../../bookings/entities/booking.entity';
import { ReportContext, ReportDefinition } from '../report.types';
const TONS = 'COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)';
const NOT_UMBRELLA = "(b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')";
const DEAD_STATUSES = ['DRAFT', 'CANCELLED', 'REJECTED', 'EXPIRED'];
function baseQuery(ctx: ReportContext): SelectQueryBuilder<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 cargoSummaryReport: ReportDefinition = {
key: 'cargo-summary',
title: 'Cargo Summary',
description: 'Cargo tonnage by direction and cargo type',
group: 'Operations',
filters: [{ key: 'date', label: 'Created', type: 'daterange' }],
columns: [
{ key: 'direction', label: 'Direction', type: 'string', sortable: true },
{ key: 'freightType', label: 'Cargo type', type: 'string', sortable: true },
{ key: 'bookings', label: 'Bookings', type: 'number', sortable: true },
{ key: 'tons', label: 'Tonnage', type: 'tons', sortable: true },
],
defaultSort: { key: 'tons', dir: 'DESC' },
query(ctx) {
return baseQuery(ctx)
.select('b.trade_direction', 'direction')
.addSelect('b.freight_type', 'freightType')
.addSelect('COUNT(*)::int', 'bookings')
.addSelect(`ROUND(COALESCE(SUM(${TONS}), 0))::float8`, 'tons')
.groupBy('b.trade_direction')
.addGroupBy('b.freight_type');
},
async summary(ctx) {
const row = await baseQuery(ctx)
.select(`ROUND(COALESCE(SUM(${TONS}), 0))::float8`, 'tons')
.addSelect('COUNT(*)::int', 'bookings')
.getRawOne();
return [
{ label: 'Bookings', value: Number(row?.bookings ?? 0) },
{ label: 'Total tonnage', value: Number(row?.tons ?? 0), unit: 't' },
];
},
};

View File

@@ -0,0 +1,83 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { Contract, CONTRACT_KINDS, CONTRACT_STATUSES } from '../../contracts/entities/contract.entity';
import { Company } from '../../companies/entities/company.entity';
import { ReportContext, ReportDefinition } from '../report.types';
function baseQuery(ctx: ReportContext): SelectQueryBuilder<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) },
];
},
};

View File

@@ -0,0 +1,121 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { Company } from '../../companies/entities/company.entity';
import { Contract } from '../../contracts/entities/contract.entity';
import { ReportContext, ReportDefinition } from '../report.types';
const TONS = 'COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)';
const DEAD_STATUSES = ['DRAFT', 'CANCELLED', 'REJECTED', 'EXPIRED'];
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const { params, directions } = ctx;
const qb = ctx.ds
.createQueryBuilder()
.from(Contract, 'ct')
.leftJoin(Company, 'c', 'c.id = ct.company_id')
.leftJoin(
(sub) =>
sub
.select('s.contract_id', 'contract_id')
.addSelect('COALESCE(SUM(s.quantity_cap), 0)', 'committed')
.from('freight.contract_cargo_scope', 's')
.where('s.deleted_at IS NULL')
.groupBy('s.contract_id'),
'cap',
'cap.contract_id = ct.id',
)
.leftJoin(
(sub) =>
sub
.select('b.contract_id', 'contract_id')
.addSelect(`COALESCE(SUM(${TONS}), 0)`, 'tons')
.addSelect('COUNT(*)::int', 'cnt')
.from('freight.bookings', 'b')
.where('b.deleted_at IS NULL')
.andWhere('b.status NOT IN (:...deadStatuses)', { deadStatuses: DEAD_STATUSES })
.groupBy('b.contract_id'),
'booked',
'booked.contract_id = ct.id',
)
.where('ct.deleted_at IS NULL')
.andWhere("ct.status <> 'DRAFT'");
if (params.dateFrom) {
qb.andWhere(
"(ct.contract_valid_until IS NULL OR ct.contract_valid_until >= :dateFrom::timestamptz)",
{ dateFrom: params.dateFrom },
);
}
if (params.dateTo) {
qb.andWhere('ct.contract_valid_from < :dateTo::timestamptz', { dateTo: params.dateTo });
}
const statuses = params.statuses as string[] | null;
if (statuses) qb.andWhere('ct.status IN (:...statuses)', { statuses });
if (params.contractId) {
qb.andWhere('ct.id = :contractId', { contractId: params.contractId });
}
if (directions !== null) {
qb.andWhere(directions.length ? 'ct.trade_direction IN (:...directions)' : '1 = 0', {
directions,
});
}
return qb;
}
export const contractUtilizationReport: ReportDefinition = {
key: 'contract-utilization',
title: 'Contract Utilization',
description: 'Committed volume vs. booked tonnage per contract',
group: 'Commercial',
idKey: { key: 'contractId', label: 'Contract' },
filters: [
{ key: 'date', label: 'Active during', type: 'daterange' },
{ key: 'statuses', label: 'Status', type: 'multiselect' },
],
columns: [
{ key: 'reference', label: 'Reference', type: 'string', sortable: true, sortExpr: 'ct.reference' },
{ key: 'customer', label: 'Customer', type: 'string', sortable: true, sortExpr: 'c.name' },
{ key: 'status', label: 'Status', type: 'string' },
{ key: 'kind', label: 'Kind', type: 'string' },
{ key: 'validFrom', label: 'Valid from', type: 'date' },
{ key: 'validUntil', label: 'Valid until', type: 'date' },
{ key: 'committed', label: 'Committed', type: 'tons' },
{ key: 'bookedTons', label: 'Booked', type: 'tons', sortable: true },
{ key: 'bookings', label: 'Bookings', type: 'number' },
{ key: 'utilizationPct', label: 'Utilization', type: 'percent', sortable: true },
],
defaultSort: { key: 'utilizationPct', dir: 'DESC' },
query(ctx) {
return baseQuery(ctx)
.select('ct.reference', 'reference')
.addSelect('c.name', 'customer')
.addSelect('ct.status', 'status')
.addSelect('ct.contract_kind', 'kind')
.addSelect(`to_char(ct.contract_valid_from, 'YYYY-MM-DD')`, 'validFrom')
.addSelect(`to_char(ct.contract_valid_until, 'YYYY-MM-DD')`, 'validUntil')
.addSelect('COALESCE(cap.committed, 0)::float8', 'committed')
.addSelect('COALESCE(booked.tons, 0)::float8', 'bookedTons')
.addSelect('COALESCE(booked.cnt, 0)', 'bookings')
.addSelect(
`CASE WHEN COALESCE(cap.committed, 0) > 0
THEN ROUND(COALESCE(booked.tons, 0) / cap.committed * 100)::float8 END`,
'utilizationPct',
);
},
async summary(ctx) {
const row = await baseQuery(ctx)
.select('COUNT(*)::int', 'contracts')
.addSelect('COALESCE(SUM(booked.tons), 0)::float8', 'bookedTons')
.addSelect(
`AVG(CASE WHEN COALESCE(cap.committed, 0) > 0
THEN booked.tons / cap.committed * 100 END)::float8`,
'avgUtilization',
)
.getRawOne();
return [
{ label: 'Contracts', value: Number(row?.contracts ?? 0) },
{ label: 'Booked tonnage', value: Number(row?.bookedTons ?? 0), unit: 't' },
{ label: 'Avg utilization', value: Math.round(Number(row?.avgUtilization ?? 0)), unit: '%' },
];
},
};

View File

@@ -0,0 +1,67 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { CompanyProfile, ProfileStatus, ProfileType } from '../../companies/entities/company-profile.entity';
import { Company } from '../../companies/entities/company.entity';
import { ReportContext, ReportDefinition } from '../report.types';
// "Type (Importer, Exporter, Freight Forwarding)" and "Active/Suspended" are
// CompanyProfile fields, not Company's — a company can hold several profiles
// (e.g. importer AND exporter), each independently approved/suspended.
const TYPE_OPTIONS = Object.values(ProfileType).map((v) => ({ value: v, label: v.replace(/_/g, ' ') }));
const STATUS_OPTIONS = Object.values(ProfileStatus).map((v) => ({ value: v, label: v }));
function baseQuery(ctx: ReportContext): SelectQueryBuilder<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) },
];
},
};

View File

@@ -0,0 +1,66 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import {
ClearanceMilestone,
MILESTONE_OWNER_REGIONS,
MILESTONE_STATUSES,
} from '../../contracts/entities/clearance-milestone.entity';
import { ReportContext, ReportDefinition } from '../report.types';
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const { params } = ctx;
const qb = ctx.ds.createQueryBuilder().from(ClearanceMilestone, 'cm').where('cm.deleted_at IS NULL');
if (params.dateFrom) qb.andWhere('cm.created_at >= :dateFrom', { dateFrom: params.dateFrom });
if (params.dateTo) qb.andWhere('cm.created_at < :dateTo', { dateTo: params.dateTo });
if (params.ownerRegion) qb.andWhere('cm.owner_region = :ownerRegion', { ownerRegion: params.ownerRegion });
const statuses = params.statuses as string[] | null;
if (statuses) qb.andWhere('cm.status IN (:...statuses)', { statuses });
return qb;
}
export const customsDocumentsReport: ReportDefinition = {
key: 'customs-documents',
title: 'Customs Clearance Milestones',
description: 'Clearance milestone volume by label, owner and status',
group: 'Operations',
filters: [
{ key: 'date', label: 'Created', type: 'daterange' },
{
key: 'ownerRegion',
label: 'Owner',
type: 'select',
options: MILESTONE_OWNER_REGIONS.map((v) => ({ value: v, label: v })),
},
{ key: 'statuses', label: 'Status', type: 'multiselect', options: MILESTONE_STATUSES.map((v) => ({ value: v, label: v })) },
],
columns: [
{ key: 'milestone', label: 'Milestone', type: 'string', sortable: true },
{ key: 'ownerRegion', label: 'Owner', type: 'string', sortable: true },
{ key: 'status', label: 'Status', type: 'string', sortable: true },
{ key: 'count', label: 'Count', type: 'number', sortable: true },
],
defaultSort: { key: 'count', dir: 'DESC' },
query(ctx) {
return baseQuery(ctx)
.select('cm.milestone_label', 'milestone')
.addSelect("COALESCE(cm.owner_region, 'Unassigned')", 'ownerRegion')
.addSelect('cm.status', 'status')
.addSelect('COUNT(*)::int', 'count')
.groupBy('cm.milestone_label')
.addGroupBy('cm.owner_region')
.addGroupBy('cm.status');
},
async summary(ctx) {
const row = await baseQuery(ctx)
.select('COUNT(*)::int', 'total')
.addSelect("COUNT(*) FILTER (WHERE cm.status = 'COMPLETED')::int", 'completed')
.addSelect("COUNT(*) FILTER (WHERE cm.status = 'PENDING')::int", 'pending')
.getRawOne();
return [
{ label: 'Milestones', value: Number(row?.total ?? 0) },
{ label: 'Completed', value: Number(row?.completed ?? 0) },
{ label: 'Pending', value: Number(row?.pending ?? 0) },
];
},
};

View File

@@ -0,0 +1,90 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { Booking } from '../../bookings/entities/booking.entity';
import { Company } from '../../companies/entities/company.entity';
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
import { ReportContext, ReportDefinition } from '../report.types';
// FirstMile and LastMile are separate tables with an identical shape (status,
// booking, optional vehicle). One resolver, unioned, with a `leg` column —
// beats shipping two near-duplicate reports for the two halves of the trip.
const LEG_UNION = `(
SELECT 'FIRST' AS leg, fm.id AS id, fm.booking_id AS booking_id, fm.status AS status,
fm.vehicle_id AS vehicle_id, fm.created_at AS created_at
FROM freight.first_mile fm WHERE fm.deleted_at IS NULL
UNION ALL
SELECT 'LAST' AS leg, lm.id AS id, lm.booking_id AS booking_id, lm.status AS status,
lm.vehicle_id AS vehicle_id, lm.created_at AS created_at
FROM freight.last_mile lm WHERE lm.deleted_at IS NULL
)`;
const STATUS_OPTIONS = [
{ value: 'PAYMENT_PENDING', label: 'Payment pending' },
{ value: 'READY_TO_TRANSIT', label: 'Ready to transit' },
{ value: 'IN_TRANSIT', label: 'In transit' },
{ value: 'RECEIVED_TO_PORT', label: 'Received to port' },
{ value: 'DELIVERED', label: 'Delivered' },
];
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const { params } = ctx;
const qb = ctx.ds
.createQueryBuilder()
.from(LEG_UNION, 'fl')
.innerJoin(Booking, 'b', 'b.id = fl.booking_id')
.leftJoin(Company, 'c', 'c.id = b.company_id')
.leftJoin(Vehicle, 'v', 'v.id = fl.vehicle_id')
.where('1 = 1');
if (params.leg) qb.andWhere('fl.leg = :leg', { leg: params.leg });
if (params.dateFrom) qb.andWhere('fl.created_at >= :dateFrom', { dateFrom: params.dateFrom });
if (params.dateTo) qb.andWhere('fl.created_at < :dateTo', { dateTo: params.dateTo });
const statuses = params.statuses as string[] | null;
if (statuses) qb.andWhere('fl.status IN (:...statuses)', { statuses });
return qb;
}
export const firstLastMileBookingsReport: ReportDefinition = {
key: 'first-last-mile-bookings',
title: 'First/Last Mile Trucking',
description: 'First- and last-mile bookings by status and truck assignment',
group: 'Operations',
filters: [
{ key: 'date', label: 'Created', type: 'daterange' },
{ key: 'leg', label: 'Leg', type: 'select', options: [{ value: 'FIRST', label: 'First mile' }, { value: 'LAST', label: 'Last mile' }] },
{ key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS },
],
columns: [
{ key: 'leg', label: 'Leg', type: 'string', sortable: true },
{ key: 'booking', label: 'Booking', type: 'string', sortable: true, sortExpr: 'b.reference' },
{ key: 'customer', label: 'Customer', type: 'string', sortable: true, sortExpr: 'c.name' },
{ key: 'status', label: 'Status', type: 'string', sortable: true, sortExpr: 'fl.status' },
{ key: 'truck', label: 'Truck', type: 'string' },
{ key: 'assigned', label: 'Assigned', type: 'string', sortable: true },
{ key: 'createdAt', label: 'Created', type: 'date', sortable: true, sortExpr: 'fl.created_at' },
],
defaultSort: { key: 'createdAt', dir: 'DESC' },
query(ctx) {
return baseQuery(ctx)
.select('fl.leg', 'leg')
.addSelect('b.reference', 'booking')
.addSelect("COALESCE(c.name, 'Unknown')", 'customer')
.addSelect('fl.status', 'status')
.addSelect("COALESCE(v.plate_number, '—')", 'truck')
.addSelect("CASE WHEN fl.vehicle_id IS NOT NULL THEN 'Assigned' ELSE 'Unassigned' END", 'assigned')
.addSelect(`to_char(fl.created_at, 'YYYY-MM-DD')`, 'createdAt');
},
async summary(ctx) {
const row = await baseQuery(ctx)
.select('COUNT(*)::int', 'total')
.addSelect('COUNT(*) FILTER (WHERE fl.vehicle_id IS NOT NULL)::int', 'assigned')
.getRawOne();
const total = Number(row?.total ?? 0);
const assigned = Number(row?.assigned ?? 0);
return [
{ label: 'Trips', value: total },
{ label: 'Assigned', value: assigned },
{ label: 'Unassigned', value: total - assigned },
];
},
};

View File

@@ -0,0 +1,69 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { ScheduleWagonAdjustmentLog } from '../../train-schedules/entities/schedule-wagon-adjustment-log.entity';
import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
import { ReportContext, ReportDefinition } from '../report.types';
// ADD = allocated, REMOVE = cancelled. SWITCH (a physical wagon swap, net
// count unchanged) is excluded — it's neither an allocation nor a cancellation.
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const { params } = ctx;
const qb = ctx.ds
.createQueryBuilder()
.from(ScheduleWagonAdjustmentLog, 'l')
.leftJoin(TrainSchedule, 'ts', 'ts.id = l.train_schedule_id')
.where('l.deleted_at IS NULL')
.andWhere("l.action IN ('ADD', 'REMOVE')");
if (params.dateFrom) qb.andWhere('l.occurred_at >= :dateFrom', { dateFrom: params.dateFrom });
if (params.dateTo) qb.andWhere('l.occurred_at < :dateTo', { dateTo: params.dateTo });
if (params.direction) qb.andWhere('ts.direction = :direction', { direction: params.direction });
return qb;
}
export const globalLogisticsWagonsReport: ReportDefinition = {
key: 'global-logistics-wagons',
title: 'Wagon Allocations by Day',
description: 'Wagons allocated vs. cancelled per day, by direction',
group: 'Operations',
filters: [
{ key: 'date', label: 'Date', type: 'daterange' },
{
key: 'direction',
label: 'Direction',
type: 'select',
options: [
{ value: 'IMPORT', label: 'Import' },
{ value: 'EXPORT', label: 'Export' },
{ value: 'DOMESTIC', label: 'Domestic' },
],
},
],
columns: [
{ key: 'date', label: 'Date', type: 'date', sortable: true, sortExpr: `date_trunc('day', l.occurred_at)` },
{ key: 'direction', label: 'Direction', type: 'string', sortable: true },
{ key: 'allocated', label: 'Allocated', type: 'number', sortable: true },
{ key: 'cancelled', label: 'Cancelled', type: 'number', sortable: true },
],
defaultSort: { key: 'date', dir: 'DESC' },
chart: { type: 'line', x: 'date', y: ['allocated', 'cancelled'] },
query(ctx) {
return baseQuery(ctx)
.select(`to_char(date_trunc('day', l.occurred_at), 'YYYY-MM-DD')`, 'date')
.addSelect("COALESCE(ts.direction, 'Unknown')", 'direction')
.addSelect("COUNT(*) FILTER (WHERE l.action = 'ADD')::int", 'allocated')
.addSelect("COUNT(*) FILTER (WHERE l.action = 'REMOVE')::int", 'cancelled')
.groupBy(`date_trunc('day', l.occurred_at)`)
.addGroupBy('ts.direction');
},
async summary(ctx) {
const row = await baseQuery(ctx)
.select("COUNT(*) FILTER (WHERE l.action = 'ADD')::int", 'allocated')
.addSelect("COUNT(*) FILTER (WHERE l.action = 'REMOVE')::int", 'cancelled')
.getRawOne();
return [
{ label: 'Allocated', value: Number(row?.allocated ?? 0) },
{ label: 'Cancelled', value: Number(row?.cancelled ?? 0) },
];
},
};

View File

@@ -0,0 +1,72 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { Freight } from '@edr/types';
import { Invoice } from '../../billing/entities/invoice.entity';
import { Company } from '../../companies/entities/company.entity';
import { CompanyProfile } from '../../companies/entities/company-profile.entity';
import { ReportContext, ReportDefinition } from '../report.types';
const STATUS_OPTIONS = Object.values(Freight.InvoiceStatus).map((v) => ({ value: v, label: v }));
function baseQuery(ctx: ReportContext): SelectQueryBuilder<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' },
];
},
};

View File

@@ -0,0 +1,59 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { Freight } from '@edr/types';
import { Invoice } from '../../billing/entities/invoice.entity';
import { ReportContext, ReportDefinition } from '../report.types';
const STATUS_OPTIONS = Object.values(Freight.InvoiceStatus).map((v) => ({ value: v, label: v }));
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const { params } = ctx;
const qb = ctx.ds.createQueryBuilder().from(Invoice, 'i').where('i.deleted_at IS NULL');
if (params.dateFrom) qb.andWhere('i.created_at >= :dateFrom', { dateFrom: params.dateFrom });
if (params.dateTo) qb.andWhere('i.created_at < :dateTo', { dateTo: params.dateTo });
const statuses = params.statuses as string[] | null;
if (statuses) qb.andWhere('i.status IN (:...statuses)', { statuses });
return qb;
}
export const invoicingPipelineReport: ReportDefinition = {
key: 'invoicing-pipeline',
title: 'Invoicing Pipeline',
description: 'Invoice volume and value by type and status',
group: 'Finance',
filters: [
{ key: 'date', label: 'Created', type: 'daterange' },
{ key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS },
],
columns: [
{ key: 'type', label: 'Type', type: 'string', sortable: true },
{ key: 'status', label: 'Status', type: 'string', sortable: true },
{ key: 'invoices', label: 'Invoices', type: 'number', sortable: true },
{ key: 'totalAmount', label: 'Total', type: 'money', sortable: true },
{ key: 'balance', label: 'Outstanding', type: 'money', sortable: true },
],
defaultSort: { key: 'invoices', dir: 'DESC' },
query(ctx) {
return baseQuery(ctx)
.select('i.type', 'type')
.addSelect('i.status', 'status')
.addSelect('COUNT(*)::int', 'invoices')
.addSelect('ROUND(COALESCE(SUM(i.total_amount), 0))::float8', 'totalAmount')
.addSelect('ROUND(COALESCE(SUM(i.balance_amount), 0))::float8', 'balance')
.groupBy('i.type')
.addGroupBy('i.status');
},
async summary(ctx) {
const row = await baseQuery(ctx)
.select('COUNT(*)::int', 'invoices')
.addSelect('ROUND(COALESCE(SUM(i.total_amount), 0))::float8', 'totalAmount')
.addSelect('ROUND(COALESCE(SUM(i.balance_amount), 0))::float8', 'balance')
.getRawOne();
return [
{ label: 'Invoices', value: Number(row?.invoices ?? 0) },
{ label: 'Total value', value: Number(row?.totalAmount ?? 0), unit: 'ETB' },
{ label: 'Outstanding', value: Number(row?.balance ?? 0), unit: 'ETB' },
];
},
};

View File

@@ -0,0 +1,78 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.entity';
import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
import { ReportContext, ReportDefinition } from '../report.types';
// train_set_wagons.assigned_weight_tons is the planned load per slot, already
// maintained by the wagon-allocation flow — no need to re-derive it from
// bulk/container line items.
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const { params } = ctx;
const qb = ctx.ds
.createQueryBuilder()
.from(TrainSetWagon, 'tsw')
.innerJoin(TrainSchedule, 'ts', 'ts.train_set_id = tsw.train_set_id')
.leftJoin(WagonType, 'wt', 'wt.id = tsw.wagon_type_id')
.where('tsw.deleted_at IS NULL AND ts.deleted_at IS NULL');
if (params.trainNumber) {
qb.andWhere('ts.train_number ILIKE :trainNumber', { trainNumber: `%${params.trainNumber}%` });
}
if (params.dateFrom) {
qb.andWhere('ts.scheduled_departure_date >= :dateFrom', { dateFrom: params.dateFrom });
}
if (params.dateTo) qb.andWhere('ts.scheduled_departure_date < :dateTo', { dateTo: params.dateTo });
return qb;
}
export const loadedCapacityReport: ReportDefinition = {
key: 'loaded-capacity',
title: 'Loaded Capacity',
description: 'Nameplate vs. loaded capacity per train, by wagon type',
group: 'Operations',
filters: [
{ key: 'trainNumber', label: 'Train No.', type: 'text' },
{ key: 'date', label: 'Departure', type: 'daterange' },
],
columns: [
{ key: 'trainNumber', label: 'Train No.', type: 'string', sortable: true, sortExpr: 'ts.train_number' },
{ key: 'departureDate', label: 'Departure', type: 'date' },
{ key: 'wagonType', label: 'Wagon type', type: 'string', sortable: true },
{ key: 'wagons', label: 'Wagons', type: 'number', sortable: true },
{ key: 'capacityTons', label: 'Capacity', type: 'tons', sortable: true },
{ key: 'loadedTons', label: 'Loaded', type: 'tons', sortable: true },
{ key: 'utilizationPct', label: 'Utilization', type: 'percent', sortable: true },
],
defaultSort: { key: 'loadedTons', dir: 'DESC' },
query(ctx) {
return baseQuery(ctx)
.select('ts.train_number', 'trainNumber')
.addSelect(`to_char(ts.scheduled_departure_date, 'YYYY-MM-DD')`, 'departureDate')
.addSelect("COALESCE(wt.name, 'Unknown')", 'wagonType')
.addSelect('COUNT(*)::int', 'wagons')
.addSelect('COALESCE(SUM(tsw.capacity_tons), 0)::float8', 'capacityTons')
.addSelect('COALESCE(SUM(tsw.assigned_weight_tons), 0)::float8', 'loadedTons')
.addSelect(
`CASE WHEN COALESCE(SUM(tsw.capacity_tons), 0) > 0
THEN ROUND(SUM(tsw.assigned_weight_tons) / SUM(tsw.capacity_tons) * 100)::float8 END`,
'utilizationPct',
)
.groupBy('ts.train_number')
.addGroupBy('ts.scheduled_departure_date')
.addGroupBy('wt.name');
},
async summary(ctx) {
const row = await baseQuery(ctx)
.select('COUNT(*)::int', 'wagons')
.addSelect('COALESCE(SUM(tsw.capacity_tons), 0)::float8', 'capacityTons')
.addSelect('COALESCE(SUM(tsw.assigned_weight_tons), 0)::float8', 'loadedTons')
.getRawOne();
return [
{ label: 'Wagons', value: Number(row?.wagons ?? 0) },
{ label: 'Capacity', value: Number(row?.capacityTons ?? 0), unit: 't' },
{ label: 'Loaded', value: Number(row?.loadedTons ?? 0), unit: 't' },
];
},
};

View File

@@ -0,0 +1,68 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { Locomotive, LOCOMOTIVE_STATUSES } from '../../locomotives/entities/locomotive.entity';
import { Yard } from '../../rule-engine/entities/yard.entity';
import { ReportContext, ReportDefinition } from '../report.types';
const STATUS_OPTIONS = LOCOMOTIVE_STATUSES.map((v) => ({ value: v, label: v.replace(/_/g, ' ') }));
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const { params } = ctx;
const qb = ctx.ds
.createQueryBuilder()
.from(Locomotive, 'l')
.leftJoin(Yard, 'y', 'y.id = l.current_yard_id')
.where('l.deleted_at IS NULL');
const statuses = params.statuses as string[] | null;
if (statuses) qb.andWhere('l.status IN (:...statuses)', { statuses });
return qb;
}
export const locomotiveFleetStatusReport: ReportDefinition = {
key: 'locomotive-fleet-status',
title: 'Locomotive Fleet Status',
description: 'Locomotive counts by type, station and status',
group: 'Operations',
filters: [{ key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS }],
columns: [
{ key: 'locomotiveType', label: 'Type', type: 'string', sortable: true },
{ key: 'station', label: 'Station', type: 'string', sortable: true },
{ key: 'status', label: 'Status', type: 'string', sortable: true },
{ key: 'count', label: 'Count', type: 'number', sortable: true },
],
defaultSort: { key: 'count', dir: 'DESC' },
chart: { type: 'bar', x: 'status', y: ['count'] },
query(ctx) {
return baseQuery(ctx)
.select('l.locomotive_type', 'locomotiveType')
.addSelect("COALESCE(y.label, 'Unassigned')", 'station')
.addSelect('l.status', 'status')
.addSelect('COUNT(*)::int', 'count')
.groupBy('l.locomotive_type')
.addGroupBy('y.label')
.addGroupBy('l.status');
},
async summary(ctx) {
const row = await baseQuery(ctx)
.select('COUNT(*)::int', 'total')
.addSelect('COUNT(*) FILTER (WHERE l.status = :available)::int', 'available')
.addSelect('COUNT(*) FILTER (WHERE l.status = :assigned)::int', 'assigned')
.addSelect('COUNT(*) FILTER (WHERE l.status = :maintenance)::int', 'maintenance')
.addSelect('COUNT(*) FILTER (WHERE l.status = :outOfService)::int', 'outOfService')
.setParameters({
available: 'AVAILABLE',
assigned: 'ASSIGNED',
maintenance: 'MAINTENANCE',
outOfService: 'OUT_OF_SERVICE',
})
.getRawOne();
return [
{ label: 'Total locomotives', value: Number(row?.total ?? 0) },
{ label: 'Available', value: Number(row?.available ?? 0) },
{ label: 'Assigned', value: Number(row?.assigned ?? 0) },
{ label: 'Under maintenance', value: Number(row?.maintenance ?? 0) },
{ label: 'Out of service', value: Number(row?.outOfService ?? 0) },
];
},
};

View File

@@ -0,0 +1,73 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { PaymentEntity } from '../../payment/entities/payment.entity';
import { ReportContext, ReportDefinition } from '../report.types';
// No direct company link on payments (refId points at whatever the intent was
// for — booking, demurrage, ...); breakdown stops at status/method/currency.
const STATUS_OPTIONS = [
{ value: 'action-required', label: 'Action required' },
{ value: 'processing', label: 'Processing' },
{ value: 'success', label: 'Success' },
{ value: 'failed', label: 'Failed' },
{ value: 'canceled', label: 'Canceled' },
{ value: 'refunded', label: 'Refunded' },
];
const METHOD_OPTIONS = ['telebirr', 'cbe-birr', 'ebirr', 'waafi', 'card', 'dmoney', 'cac-bank', 'cbe-bill'].map(
(v) => ({ value: v, label: v }),
);
function baseQuery(ctx: ReportContext): SelectQueryBuilder<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' },
];
},
};

View File

@@ -0,0 +1,91 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { Booking } from '../../bookings/entities/booking.entity';
import { Company } from '../../companies/entities/company.entity';
import { ReportContext, ReportDefinition } from '../report.types';
const TONS = 'COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)';
const REVENUE = 'COALESCE(b.adjusted_total_amount, b.total_amount)';
const NOT_UMBRELLA = "(b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')";
const DEAD_STATUSES = ['DRAFT', 'CANCELLED', 'REJECTED', 'EXPIRED'];
function baseQuery(ctx: ReportContext): SelectQueryBuilder<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;
}
export const revenueByCustomerReport: ReportDefinition = {
key: 'revenue-by-customer',
title: 'Revenue by Customer',
description: 'Ranked customers by booking revenue',
group: 'Commercial',
filters: [
{ key: 'date', label: 'Created', type: 'daterange' },
{
key: 'direction',
label: 'Direction',
type: 'select',
options: [
{ value: 'IMPORT', label: 'Import' },
{ value: 'EXPORT', label: 'Export' },
{ value: 'DOMESTIC', label: 'Domestic' },
],
},
{
key: 'freightType',
label: 'Freight type',
type: 'select',
options: [
{ value: 'CONTAINER', label: 'Container' },
{ value: 'BULK', label: 'Bulk' },
],
},
{ key: 'statuses', label: 'Status', type: 'multiselect' },
],
columns: [
{ key: 'customer', label: 'Customer', type: 'string', sortable: true, sortExpr: 'c.name' },
{ key: 'bookings', label: 'Bookings', type: 'number', sortable: true },
{ key: 'tons', label: 'Tonnage', type: 'tons', sortable: true },
{ key: 'revenue', label: 'Revenue', type: 'money', sortable: true },
],
defaultSort: { key: 'revenue', dir: 'DESC' },
query(ctx) {
return baseQuery(ctx)
.select('c.name', 'customer')
.addSelect('COUNT(*)::int', 'bookings')
.addSelect(`ROUND(COALESCE(SUM(${TONS}), 0))::float8`, 'tons')
.addSelect(`ROUND(COALESCE(SUM(${REVENUE}), 0))::float8`, 'revenue')
.groupBy('c.name');
},
async summary(ctx) {
const row = await baseQuery(ctx)
.select('COUNT(DISTINCT c.name)::int', 'customers')
.addSelect(`ROUND(COALESCE(SUM(${REVENUE}), 0))::float8`, 'revenue')
.getRawOne();
return [
{ label: 'Customers', value: Number(row?.customers ?? 0) },
{ label: 'Revenue', value: Number(row?.revenue ?? 0), unit: 'ETB' },
];
},
};

View File

@@ -0,0 +1,62 @@
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' },
];
},
};

View File

@@ -0,0 +1,100 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { TrainSchedule, TRAIN_SCHEDULE_STATUSES } from '../../train-schedules/entities/train-schedule.entity';
import { Yard } from '../../rule-engine/entities/yard.entity';
import { ReportContext, ReportDefinition } from '../report.types';
// ITLMS's spec lists Scheduled/Dispatched/In Transit/Arrived/Cancelled as the
// train lifecycle. The platform tracks DRAFT/SCHEDULED/DISPATCHED/ARRIVED/
// CANCELLED — no separate "in transit" status exists (a dispatched schedule
// with no actual_arrival_at yet *is* in transit; reported as DISPATCHED).
const STATUS_OPTIONS = TRAIN_SCHEDULE_STATUSES.map((v) => ({ value: v, label: v }));
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const { params } = ctx;
const qb = ctx.ds
.createQueryBuilder()
.from(TrainSchedule, 'ts')
.leftJoin(Yard, 'o', 'o.id = ts.origin_station_id')
.leftJoin(Yard, 'd', 'd.id = ts.destination_station_id')
.where('ts.deleted_at IS NULL');
if (params.dateFrom) {
qb.andWhere('ts.scheduled_departure_date >= :dateFrom', { dateFrom: params.dateFrom });
}
if (params.dateTo) {
qb.andWhere('ts.scheduled_departure_date < :dateTo', { dateTo: params.dateTo });
}
if (params.direction) qb.andWhere('ts.direction = :direction', { direction: params.direction });
const statuses = params.statuses as string[] | null;
if (statuses) qb.andWhere('ts.status IN (:...statuses)', { statuses });
return qb;
}
export const trainScheduleStatusReport: ReportDefinition = {
key: 'train-schedule-status',
title: 'Train Schedules',
description: 'Scheduled, dispatched, arrived and cancelled train departures',
group: 'Operations',
filters: [
{ key: 'date', label: 'Departure', type: 'daterange' },
{
key: 'direction',
label: 'Direction',
type: 'select',
options: [
{ value: 'IMPORT', label: 'Import' },
{ value: 'EXPORT', label: 'Export' },
{ value: 'DOMESTIC', label: 'Domestic' },
],
},
{ key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS },
],
columns: [
{ key: 'trainNumber', label: 'Train No.', type: 'string', sortable: true, sortExpr: 'ts.train_number' },
{ key: 'reference', label: 'Reference', type: 'string' },
{ key: 'status', label: 'Status', type: 'string', sortable: true, sortExpr: 'ts.status' },
{ key: 'direction', label: 'Direction', type: 'string' },
{ key: 'origin', label: 'Origin', type: 'string' },
{ key: 'destination', label: 'Destination', type: 'string' },
{
key: 'scheduledDeparture',
label: 'Scheduled dep.',
type: 'date',
sortable: true,
sortExpr: 'ts.scheduled_departure_date',
},
{ key: 'actualDeparture', label: 'Actual dep.', type: 'date' },
{ key: 'actualArrival', label: 'Actual arr.', type: 'date' },
],
defaultSort: { key: 'scheduledDeparture', dir: 'DESC' },
query(ctx) {
return baseQuery(ctx)
.select('ts.train_number', 'trainNumber')
.addSelect('ts.reference', 'reference')
.addSelect('ts.status', 'status')
.addSelect('ts.direction', 'direction')
.addSelect("COALESCE(o.label, 'Unknown')", 'origin')
.addSelect("COALESCE(d.label, 'Unknown')", 'destination')
.addSelect(`to_char(ts.scheduled_departure_date, 'YYYY-MM-DD')`, 'scheduledDeparture')
.addSelect(`to_char(ts.actual_departure_at, 'YYYY-MM-DD HH24:MI')`, 'actualDeparture')
.addSelect(`to_char(ts.actual_arrival_at, 'YYYY-MM-DD HH24:MI')`, 'actualArrival');
},
async summary(ctx) {
const row = await baseQuery(ctx)
.select('COUNT(*)::int', 'total')
.addSelect('COUNT(*) FILTER (WHERE ts.status = :scheduled)::int', 'scheduled')
.addSelect('COUNT(*) FILTER (WHERE ts.status = :dispatched)::int', 'dispatched')
.addSelect('COUNT(*) FILTER (WHERE ts.status = :arrived)::int', 'arrived')
.addSelect('COUNT(*) FILTER (WHERE ts.status = :cancelled)::int', 'cancelled')
.setParameters({ scheduled: 'SCHEDULED', dispatched: 'DISPATCHED', arrived: 'ARRIVED', cancelled: 'CANCELLED' })
.getRawOne();
return [
{ label: 'Total', value: Number(row?.total ?? 0) },
{ label: 'Scheduled', value: Number(row?.scheduled ?? 0) },
{ label: 'Dispatched', value: Number(row?.dispatched ?? 0) },
{ label: 'Arrived', value: Number(row?.arrived ?? 0) },
{ label: 'Cancelled', value: Number(row?.cancelled ?? 0) },
];
},
};

View File

@@ -0,0 +1,86 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
import { Yard } from '../../rule-engine/entities/yard.entity';
import { ReportContext, ReportDefinition } from '../report.types';
// "Turnaround" here is departure-to-arrival transit time on the actual (not
// scheduled) timestamps. Station dwell time (arrival -> the SAME train's next
// departure) would need pairing consecutive schedules by physical train,
// which isn't tracked directly — deferred, not modeled as a shortcut.
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const { params } = ctx;
const qb = ctx.ds
.createQueryBuilder()
.from(TrainSchedule, 'ts')
.leftJoin(Yard, 'o', 'o.id = ts.origin_station_id')
.leftJoin(Yard, 'd', 'd.id = ts.destination_station_id')
.where('ts.deleted_at IS NULL')
.andWhere('ts.actual_departure_at IS NOT NULL')
.andWhere('ts.actual_arrival_at IS NOT NULL');
if (params.dateFrom) qb.andWhere('ts.actual_departure_at >= :dateFrom', { dateFrom: params.dateFrom });
if (params.dateTo) qb.andWhere('ts.actual_departure_at < :dateTo', { dateTo: params.dateTo });
if (params.direction) qb.andWhere('ts.direction = :direction', { direction: params.direction });
return qb;
}
export const trainTurnaroundReport: ReportDefinition = {
key: 'train-turnaround',
title: 'Train Turnaround',
description: 'Actual departure-to-arrival transit time per schedule',
group: 'Operations',
filters: [
{ key: 'date', label: 'Departed', type: 'daterange' },
{
key: 'direction',
label: 'Direction',
type: 'select',
options: [
{ value: 'IMPORT', label: 'Import' },
{ value: 'EXPORT', label: 'Export' },
{ value: 'DOMESTIC', label: 'Domestic' },
],
},
],
columns: [
{ key: 'trainNumber', label: 'Train No.', type: 'string', sortable: true, sortExpr: 'ts.train_number' },
{ key: 'origin', label: 'Origin', type: 'string' },
{ key: 'destination', label: 'Destination', type: 'string' },
{
key: 'actualDeparture',
label: 'Departed',
type: 'date',
sortable: true,
sortExpr: 'ts.actual_departure_at',
},
{ key: 'actualArrival', label: 'Arrived', type: 'date' },
{ key: 'transitHours', label: 'Transit (hrs)', type: 'number', sortable: true },
],
defaultSort: { key: 'actualDeparture', dir: 'DESC' },
query(ctx) {
return baseQuery(ctx)
.select('ts.train_number', 'trainNumber')
.addSelect("COALESCE(o.label, 'Unknown')", 'origin')
.addSelect("COALESCE(d.label, 'Unknown')", 'destination')
.addSelect(`to_char(ts.actual_departure_at, 'YYYY-MM-DD HH24:MI')`, 'actualDeparture')
.addSelect(`to_char(ts.actual_arrival_at, 'YYYY-MM-DD HH24:MI')`, 'actualArrival')
.addSelect(
`ROUND(EXTRACT(EPOCH FROM (ts.actual_arrival_at - ts.actual_departure_at))::numeric / 3600, 1)::float8`,
'transitHours',
);
},
async summary(ctx) {
const row = await baseQuery(ctx)
.select('COUNT(*)::int', 'trips')
.addSelect(
`ROUND(AVG(EXTRACT(EPOCH FROM (ts.actual_arrival_at - ts.actual_departure_at)))::numeric / 3600, 1)::float8`,
'avgHours',
)
.getRawOne();
return [
{ label: 'Trips', value: Number(row?.trips ?? 0) },
{ label: 'Avg transit', value: Number(row?.avgHours ?? 0), unit: 'h' },
];
},
};

View File

@@ -0,0 +1,77 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { WagonStatus } from '@edr/types';
import { Wagon } from '../../wagons/entities/wagon.entity';
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
import { Yard } from '../../rule-engine/entities/yard.entity';
import { ReportContext, ReportDefinition } from '../report.types';
const STATUS_OPTIONS = Object.values(WagonStatus).map((v) => ({
value: v,
label: v.replace(/_/g, ' '),
}));
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const { params } = ctx;
const qb = ctx.ds
.createQueryBuilder()
.from(Wagon, 'w')
.leftJoin(WagonType, 'wt', 'wt.id = w.wagon_type_id')
.leftJoin(Yard, 'y', 'y.id = w.current_yard_id')
.where('w.deleted_at IS NULL');
const statuses = params.statuses as string[] | null;
if (statuses) qb.andWhere('w.status IN (:...statuses)', { statuses });
return qb;
}
export const wagonFleetStatusReport: ReportDefinition = {
key: 'wagon-fleet-status',
title: 'Wagon Fleet Status',
description: 'Wagon counts by type, station and status',
group: 'Operations',
filters: [{ key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS }],
columns: [
{ key: 'wagonType', label: 'Wagon type', type: 'string', sortable: true },
{ key: 'station', label: 'Station', type: 'string', sortable: true },
{ key: 'status', label: 'Status', type: 'string', sortable: true },
{ key: 'count', label: 'Count', type: 'number', sortable: true },
],
defaultSort: { key: 'count', dir: 'DESC' },
chart: { type: 'bar', x: 'status', y: ['count'] },
query(ctx) {
return baseQuery(ctx)
.select('COALESCE(wt.name, \'Unknown\')', 'wagonType')
.addSelect("COALESCE(y.label, 'Unassigned')", 'station')
.addSelect('w.status', 'status')
.addSelect('COUNT(*)::int', 'count')
.groupBy('wt.name')
.addGroupBy('y.label')
.addGroupBy('w.status');
},
async summary(ctx) {
const row = await baseQuery(ctx)
.select('COUNT(*)::int', 'total')
.addSelect('COUNT(*) FILTER (WHERE w.status = :available)::int', 'available')
.addSelect('COUNT(*) FILTER (WHERE w.status = :assigned)::int', 'assigned')
.addSelect('COUNT(*) FILTER (WHERE w.status = :maintenance)::int', 'maintenance')
.addSelect('COUNT(*) FILTER (WHERE w.status = :detained)::int', 'detained')
.addSelect('COUNT(*) FILTER (WHERE w.status = :outOfService)::int', 'outOfService')
.setParameters({
available: WagonStatus.Available,
assigned: WagonStatus.Assigned,
maintenance: WagonStatus.Maintenance,
detained: WagonStatus.Detained,
outOfService: WagonStatus.OutOfService,
})
.getRawOne();
return [
{ label: 'Total wagons', value: Number(row?.total ?? 0) },
{ label: 'Available', value: Number(row?.available ?? 0) },
{ label: 'Assigned', value: Number(row?.assigned ?? 0) },
{ label: 'Under maintenance', value: Number(row?.maintenance ?? 0) },
{ label: 'Detained', value: Number(row?.detained ?? 0) },
{ label: 'Out of service', value: Number(row?.outOfService ?? 0) },
];
},
};

View File

@@ -0,0 +1,85 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { WagonTransferRequestStatus } from '@edr/types';
import { WagonTransferRequest } from '../../wagons/entities/wagon-transfer-request.entity';
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
import { Yard } from '../../rule-engine/entities/yard.entity';
import { ReportContext, ReportDefinition } from '../report.types';
const STATUS_OPTIONS = Object.values(WagonTransferRequestStatus).map((v) => ({
value: v,
label: v.replace(/_/g, ' '),
}));
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const { params } = ctx;
const qb = ctx.ds
.createQueryBuilder()
.from(WagonTransferRequest, 'r')
.leftJoin(Yard, 'fy', 'fy.id = r.from_yard_id')
.leftJoin(Yard, 'ty', 'ty.id = r.to_yard_id')
.leftJoin(WagonType, 'wt', 'wt.id = r.wagon_type_id')
.where('r.deleted_at IS NULL');
if (params.dateFrom) qb.andWhere('r.created_at >= :dateFrom', { dateFrom: params.dateFrom });
if (params.dateTo) qb.andWhere('r.created_at < :dateTo', { dateTo: params.dateTo });
const statuses = params.statuses as string[] | null;
if (statuses) qb.andWhere('r.status IN (:...statuses)', { statuses });
return qb;
}
export const wagonRequestsReport: ReportDefinition = {
key: 'wagon-requests',
title: 'Wagon Requests',
description: 'Inter-yard wagon transfer requests and fulfilment delay',
group: 'Operations',
filters: [
{ key: 'date', label: 'Requested', type: 'daterange' },
{ key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS },
],
columns: [
{ key: 'fromYard', label: 'From', type: 'string', sortable: true, sortExpr: 'fy.label' },
{ key: 'toYard', label: 'To', type: 'string', sortable: true, sortExpr: 'ty.label' },
{ key: 'wagonType', label: 'Wagon type', type: 'string' },
{ key: 'quantity', label: 'Requested', type: 'number' },
{ key: 'fulfilledQuantity', label: 'Fulfilled', type: 'number' },
{ key: 'status', label: 'Status', type: 'string', sortable: true, sortExpr: 'r.status' },
{ key: 'requestedAt', label: 'Requested at', type: 'date', sortable: true, sortExpr: 'r.created_at' },
{ key: 'fulfilledAt', label: 'Fulfilled at', type: 'date' },
{ key: 'delayDays', label: 'Delay (days)', type: 'number', sortable: true },
],
defaultSort: { key: 'requestedAt', dir: 'DESC' },
query(ctx) {
return baseQuery(ctx)
.select('fy.label', 'fromYard')
.addSelect('ty.label', 'toYard')
.addSelect("COALESCE(wt.name, 'Unknown')", 'wagonType')
.addSelect('r.quantity', 'quantity')
.addSelect('r.fulfilled_quantity', 'fulfilledQuantity')
.addSelect('r.status', 'status')
.addSelect(`to_char(r.created_at, 'YYYY-MM-DD')`, 'requestedAt')
.addSelect(`to_char(r.fulfilled_at, 'YYYY-MM-DD')`, 'fulfilledAt')
.addSelect(
`ROUND(EXTRACT(EPOCH FROM (COALESCE(r.fulfilled_at, now()) - r.created_at))::numeric / 86400, 1)::float8`,
'delayDays',
);
},
async summary(ctx) {
const row = await baseQuery(ctx)
.select('COUNT(*)::int', 'requests')
.addSelect('COUNT(*) FILTER (WHERE r.status IN (:...openStatuses))::int', 'open')
.addSelect(
`ROUND(AVG(EXTRACT(EPOCH FROM (COALESCE(r.fulfilled_at, now()) - r.created_at))::numeric / 86400), 1)::float8`,
'avgDelayDays',
)
.setParameters({
openStatuses: [WagonTransferRequestStatus.Pending, WagonTransferRequestStatus.PartiallyFulfilled],
})
.getRawOne();
return [
{ label: 'Requests', value: Number(row?.requests ?? 0) },
{ label: 'Still open', value: Number(row?.open ?? 0) },
{ label: 'Avg delay', value: Number(row?.avgDelayDays ?? 0), unit: 'd' },
];
},
};

View File

@@ -0,0 +1,94 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { WagonStatus } from '@edr/types';
import { Wagon } from '../../wagons/entities/wagon.entity';
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
import { Yard } from '../../rule-engine/entities/yard.entity';
import { ReportContext, ReportDefinition } from '../report.types';
// Only these two statuses have an operational "how long has it been stuck
// here" question — everything else (Available, Assigned, ...) turns over too
// fast for a days-in-status view to matter.
const TRACKED_STATUSES = [WagonStatus.Maintenance, WagonStatus.Detained];
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const { params } = ctx;
const qb = ctx.ds
.createQueryBuilder()
.from(Wagon, 'w')
.leftJoin(WagonType, 'wt', 'wt.id = w.wagon_type_id')
.leftJoin(Yard, 'y', 'y.id = w.current_yard_id')
// Latest time each wagon flipped INTO its current status, per (wagon, status)
// pair — a plain (non-correlated) derived table, joined on both columns, so
// it stays a normal JOIN rather than needing a LATERAL correlated subquery.
.leftJoin(
(sub) =>
sub
.select('l.wagon_id', 'wagon_id')
.addSelect('l.to_status', 'to_status')
.addSelect('MAX(l.created_at)', 'since')
.from('freight.wagon_status_logs', 'l')
.groupBy('l.wagon_id')
.addGroupBy('l.to_status'),
'log',
'log.wagon_id = w.id AND log.to_status = w.status',
)
.where('w.deleted_at IS NULL')
.andWhere('w.status IN (:...trackedStatuses)', { trackedStatuses: TRACKED_STATUSES });
const status = params.status as string | null;
if (status) qb.andWhere('w.status = :status', { status });
return qb;
}
export const wagonStatusDurationReport: ReportDefinition = {
key: 'wagon-status-duration',
title: 'Wagon Status Duration',
description: 'How long each wagon has sat in Maintenance or Detained',
group: 'Operations',
filters: [
{
key: 'status',
label: 'Status',
type: 'select',
options: TRACKED_STATUSES.map((v) => ({ value: v, label: v.replace(/_/g, ' ') })),
},
],
columns: [
{ key: 'wagonNumber', label: 'Wagon', type: 'string', sortable: true, sortExpr: 'w.wagon_number' },
{ key: 'wagonType', label: 'Wagon type', type: 'string' },
{ key: 'station', label: 'Station', type: 'string' },
{ key: 'status', label: 'Status', type: 'string', sortable: true, sortExpr: 'w.status' },
{ key: 'since', label: 'Since', type: 'date', sortable: true },
{ key: 'daysInStatus', label: 'Days in status', type: 'number', sortable: true },
],
defaultSort: { key: 'daysInStatus', dir: 'DESC' },
query(ctx) {
return baseQuery(ctx)
.select('w.wagon_number', 'wagonNumber')
.addSelect("COALESCE(wt.name, 'Unknown')", 'wagonType')
.addSelect("COALESCE(y.label, 'Unassigned')", 'station')
.addSelect('w.status', 'status')
.addSelect(`to_char(COALESCE(log.since, w.updated_at), 'YYYY-MM-DD')`, 'since')
.addSelect(
`FLOOR(EXTRACT(EPOCH FROM (now() - COALESCE(log.since, w.updated_at))) / 86400)::int`,
'daysInStatus',
);
},
async summary(ctx) {
const row = await baseQuery(ctx)
.select('COUNT(*) FILTER (WHERE w.status = :maintenance)::int', 'maintenance')
.addSelect('COUNT(*) FILTER (WHERE w.status = :detained)::int', 'detained')
.addSelect(
`MAX(FLOOR(EXTRACT(EPOCH FROM (now() - COALESCE(log.since, w.updated_at))) / 86400))::int`,
'longest',
)
.setParameters({ maintenance: WagonStatus.Maintenance, detained: WagonStatus.Detained })
.getRawOne();
return [
{ label: 'Under maintenance', value: Number(row?.maintenance ?? 0) },
{ label: 'Detained', value: Number(row?.detained ?? 0) },
{ label: 'Longest days in status', value: Number(row?.longest ?? 0), unit: 'd' },
];
},
};

View File

@@ -0,0 +1,76 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { Wagon } from '../../wagons/entities/wagon.entity';
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
import { Container } from '../../container-management/entities/container.entity';
import { ContainerType } from '../../rule-engine/entities/container-type.entity';
import { ReportContext, ReportDefinition } from '../report.types';
// TEU = container size in feet / 20 (20ft -> 1 TEU, 40ft -> 2 TEU). Scoped to
// each wagon's CURRENT schedule pin — a live-state view, not a historical one.
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const { params } = ctx;
const qb = ctx.ds
.createQueryBuilder()
.from(Wagon, 'w')
.innerJoin(TrainSchedule, 'ts', 'ts.id = w.current_train_schedule_id')
.leftJoin(WagonType, 'wt', 'wt.id = w.wagon_type_id')
.leftJoin(Container, 'c', 'c.wagon_id = w.id AND c.deleted_at IS NULL')
.leftJoin(ContainerType, 'ct', 'ct.id = c.container_type_id')
.where('w.deleted_at IS NULL');
if (params.trainNumber) {
qb.andWhere('ts.train_number ILIKE :trainNumber', { trainNumber: `%${params.trainNumber}%` });
}
if (params.dateFrom) {
qb.andWhere('ts.scheduled_departure_date >= :dateFrom', { dateFrom: params.dateFrom });
}
if (params.dateTo) qb.andWhere('ts.scheduled_departure_date < :dateTo', { dateTo: params.dateTo });
return qb;
}
export const wagonTeuUtilizationReport: ReportDefinition = {
key: 'wagon-teu-utilization',
title: 'Wagon TEU Utilization',
description: 'TEU loaded per wagon on its currently assigned train',
group: 'Operations',
filters: [
{ key: 'trainNumber', label: 'Train No.', type: 'text' },
{ key: 'date', label: 'Departure', type: 'daterange' },
],
columns: [
{ key: 'wagonNumber', label: 'Wagon', type: 'string', sortable: true, sortExpr: 'w.wagon_number' },
{ key: 'wagonType', label: 'Wagon type', type: 'string' },
{ key: 'trainNumber', label: 'Train No.', type: 'string', sortable: true, sortExpr: 'ts.train_number' },
{ key: 'departureDate', label: 'Departure', type: 'date' },
{ key: 'containers', label: 'Containers', type: 'number', sortable: true },
{ key: 'teu', label: 'TEU', type: 'number', sortable: true },
],
defaultSort: { key: 'teu', dir: 'DESC' },
query(ctx) {
return baseQuery(ctx)
.select('w.wagon_number', 'wagonNumber')
.addSelect("COALESCE(wt.name, 'Unknown')", 'wagonType')
.addSelect('ts.train_number', 'trainNumber')
.addSelect(`to_char(ts.scheduled_departure_date, 'YYYY-MM-DD')`, 'departureDate')
.addSelect('COUNT(c.id)::int', 'containers')
.addSelect('(COALESCE(SUM(ct.size_ft), 0) / 20.0)::float8', 'teu')
.groupBy('w.wagon_number')
.addGroupBy('wt.name')
.addGroupBy('ts.train_number')
.addGroupBy('ts.scheduled_departure_date');
},
async summary(ctx) {
const row = await baseQuery(ctx)
.select('COUNT(DISTINCT w.id)::int', 'wagons')
.addSelect('COUNT(c.id)::int', 'containers')
.addSelect('(COALESCE(SUM(ct.size_ft), 0) / 20.0)::float8', 'teu')
.getRawOne();
return [
{ label: 'Wagons', value: Number(row?.wagons ?? 0) },
{ label: 'Containers', value: Number(row?.containers ?? 0) },
{ label: 'Total TEU', value: Number(row?.teu ?? 0) },
];
},
};

View File

@@ -1,54 +0,0 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsIn, IsOptional, IsString } from 'class-validator';
export class ReportQueryDto {
@ApiPropertyOptional({ description: 'Inclusive start date (YYYY-MM-DD). Default: 30 days ago.' })
@IsOptional()
@IsString()
dateFrom?: string;
@ApiPropertyOptional({ description: 'Inclusive end date (YYYY-MM-DD). Default: today.' })
@IsOptional()
@IsString()
dateTo?: string;
@ApiPropertyOptional({ enum: ['day', 'week', 'month'], default: 'day' })
@IsOptional()
@IsIn(['day', 'week', 'month'])
granularity?: 'day' | 'week' | 'month';
@ApiPropertyOptional({ description: 'Comma-separated company UUIDs' })
@IsOptional()
@IsString()
companyIds?: string;
@ApiPropertyOptional({ description: 'Comma-separated route UUIDs' })
@IsOptional()
@IsString()
routeIds?: string;
@ApiPropertyOptional({ description: 'Comma-separated yard UUIDs (matches origin or destination)' })
@IsOptional()
@IsString()
yardIds?: string;
@ApiPropertyOptional({ description: 'Comma-separated cargo type UUIDs' })
@IsOptional()
@IsString()
cargoTypeIds?: string;
@ApiPropertyOptional({ description: 'Comma-separated status values (report-specific)' })
@IsOptional()
@IsString()
statuses?: string;
@ApiPropertyOptional({ description: 'Trade direction filter' })
@IsOptional()
@IsString()
direction?: string;
@ApiPropertyOptional({ enum: ['CONTAINER', 'BULK'] })
@IsOptional()
@IsIn(['CONTAINER', 'BULK'])
freightType?: string;
}

View File

@@ -1,24 +0,0 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class ReportKpiDto {
@ApiProperty()
label!: string;
@ApiProperty()
value!: number;
@ApiPropertyOptional()
unit?: string;
}
export class ReportResultDto {
@ApiProperty({ type: [ReportKpiDto] })
kpis!: ReportKpiDto[];
@ApiProperty({
type: 'array',
items: { type: 'object', additionalProperties: true },
description: 'Report rows; columns vary per report key',
})
rows!: Record<string, unknown>[];
}

View File

@@ -0,0 +1,62 @@
import { PDF_ROW_CAP, XLSX_ROW_CAP } from './report-export.service';
import { resolveExportCap, resolveExportColumns, resolveExportFormat } from './report-export-request.util';
import { ReportColumn } from './report.types';
describe('resolveExportFormat', () => {
it('only \'pdf\' exports as pdf', () => {
expect(resolveExportFormat('pdf')).toBe('pdf');
});
it.each([undefined, 'xlsx', 'csv', ''])('%p falls back to xlsx', (raw) => {
expect(resolveExportFormat(raw)).toBe('xlsx');
});
});
describe('resolveExportCap', () => {
it('missing limit uses the full format cap', () => {
expect(resolveExportCap('xlsx', undefined)).toBe(XLSX_ROW_CAP);
expect(resolveExportCap('pdf', undefined)).toBe(PDF_ROW_CAP);
});
it('a limit under the cap is used as-is', () => {
expect(resolveExportCap('pdf', '100')).toBe(100);
});
it('a limit over the cap is clamped down', () => {
expect(resolveExportCap('pdf', String(PDF_ROW_CAP + 1000))).toBe(PDF_ROW_CAP);
expect(resolveExportCap('xlsx', String(XLSX_ROW_CAP + 1))).toBe(XLSX_ROW_CAP);
});
it.each(['0', '-5', 'not-a-number', ''])('non-positive/invalid limit %p falls back to the cap', (raw) => {
expect(resolveExportCap('xlsx', raw)).toBe(XLSX_ROW_CAP);
});
});
describe('resolveExportColumns', () => {
const columns: ReportColumn[] = [
{ key: 'a', label: 'A', type: 'string' },
{ key: 'b', label: 'B', type: 'number' },
{ key: 'c', label: 'C', type: 'money' },
];
const def = { columns };
it('missing fields returns every column', () => {
expect(resolveExportColumns(def, undefined)).toEqual(columns);
});
it('empty fields string returns every column', () => {
expect(resolveExportColumns(def, '')).toEqual(columns);
});
it('a known subset filters to just those columns, in the report\'s own order', () => {
expect(resolveExportColumns(def, 'c,a')).toEqual([columns[0], columns[2]]);
});
it('unknown keys are dropped, not passed through', () => {
expect(resolveExportColumns(def, 'a,ghost')).toEqual([columns[0]]);
});
it('all-unknown keys falls back to every column instead of a blank sheet', () => {
expect(resolveExportColumns(def, 'ghost,also-ghost')).toEqual(columns);
});
});

View File

@@ -0,0 +1,29 @@
import { PDF_ROW_CAP, XLSX_ROW_CAP } from './report-export.service';
import { ReportColumn, ReportDefinition } from './report.types';
export type ExportFormat = 'xlsx' | 'pdf';
/** Anything but the literal string 'pdf' exports as xlsx. */
export function resolveExportFormat(raw: string | undefined): ExportFormat {
return raw === 'pdf' ? 'pdf' : 'xlsx';
}
/** Caller's requested row limit, clamped to the format's hard cap. A
* missing/non-positive/non-numeric limit means "as many as the format allows". */
export function resolveExportCap(format: ExportFormat, rawLimit: string | undefined): number {
const formatCap = format === 'pdf' ? PDF_ROW_CAP : XLSX_ROW_CAP;
const requested = Number(rawLimit);
return requested > 0 ? Math.min(requested, formatCap) : formatCap;
}
/** Caller's requested column subset, whitelisted against the report's own
* columns. Missing, empty, or all-unknown `rawFields` falls back to every
* column rather than shipping a blank sheet. */
export function resolveExportColumns(
def: Pick<ReportDefinition, 'columns'>,
rawFields: string | undefined,
): ReportColumn[] {
const requested = rawFields?.split(',').filter(Boolean);
const filtered = requested?.length ? def.columns.filter((c) => requested.includes(c.key)) : def.columns;
return filtered.length ? filtered : def.columns;
}

View File

@@ -0,0 +1,117 @@
import { Injectable } from '@nestjs/common';
import ExcelJS from 'exceljs';
import { PdfRenderService } from '../billing/documents/pdf-render.service';
import { ReportColumn, ReportDefinition, ReportKpi } from './report.types';
// ponytail: in-memory Workbook, cap below. Switch to ExcelJS's streaming
// WorkbookWriter if a report ever needs to outgrow XLSX_ROW_CAP.
export const XLSX_ROW_CAP = 50_000;
// ponytail: HTML→PDF render cost grows with row count; larger exports must
// use XLSX instead.
export const PDF_ROW_CAP = 5_000;
const NUMBER_FORMAT: Partial<Record<ReportColumn['type'], string>> = {
money: '#,##0.00',
tons: '#,##0.0',
percent: '0"%"',
number: '#,##0',
};
function formatCell(value: unknown, type: ReportColumn['type']): string {
if (value === null || value === undefined) return '';
if (type === 'money' || type === 'number') {
return Number(value).toLocaleString('en-US', { maximumFractionDigits: 2 });
}
if (type === 'tons') return `${Number(value).toLocaleString('en-US')} t`;
if (type === 'percent') return `${value}%`;
return String(value);
}
@Injectable()
export class ReportExportService {
constructor(private readonly pdfRender: PdfRenderService) {}
async toXlsx(
def: ReportDefinition,
rows: Record<string, unknown>[],
kpis: ReportKpi[],
columns: ReportColumn[] = def.columns,
): Promise<Buffer> {
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet(def.title.slice(0, 31));
if (kpis.length) {
sheet.addRow(kpis.map((k) => `${k.label}: ${k.value.toLocaleString()}${k.unit ? ` ${k.unit}` : ''}`));
sheet.addRow([]);
}
const headerRow = sheet.addRow(columns.map((c) => c.label));
headerRow.font = { bold: true };
for (const row of rows) {
sheet.addRow(columns.map((c) => row[c.key] ?? null));
}
columns.forEach((col, i) => {
const format = NUMBER_FORMAT[col.type];
const excelCol = sheet.getColumn(i + 1);
excelCol.width = Math.max(col.label.length + 2, 12);
if (format) excelCol.numFmt = format;
});
const buffer = await workbook.xlsx.writeBuffer();
return Buffer.from(buffer);
}
async toPdf(
def: ReportDefinition,
rows: Record<string, unknown>[],
kpis: ReportKpi[],
columns: ReportColumn[] = def.columns,
): Promise<Buffer> {
const html = this.buildHtml(def, rows, kpis, columns);
return this.pdfRender.htmlToPdfBuffer(html, { label: `report:${def.key}`, landscape: true });
}
private buildHtml(
def: ReportDefinition,
rows: Record<string, unknown>[],
kpis: ReportKpi[],
columns: ReportColumn[],
): string {
const esc = (v: unknown) =>
String(v ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const kpiHtml = kpis.length
? `<div style="display:flex;gap:24px;margin-bottom:16px">${kpis
.map(
(k) =>
`<div><div style="font-size:11px;color:#666">${esc(k.label)}</div><div style="font-size:16px;font-weight:600">${k.value.toLocaleString()}${k.unit ? ` ${esc(k.unit)}` : ''}</div></div>`,
)
.join('')}</div>`
: '';
const head = columns.map((c) => `<th>${esc(c.label)}</th>`).join('');
const body = rows
.map(
(row) =>
`<tr>${columns.map((c) => `<td>${esc(formatCell(row[c.key], c.type))}</td>`).join('')}</tr>`,
)
.join('');
return `<!doctype html><html><head><meta charset="utf-8"><style>
body { font-family: Arial, sans-serif; font-size: 10px; color: #111; }
h1 { font-size: 16px; margin-bottom: 4px; }
p.desc { color: #666; margin-top: 0 0 12px; }
table { width: 100%; border-collapse: collapse; }
th, td { border: 1px solid #ddd; padding: 4px 6px; text-align: left; }
th { background: #f3f3f3; }
</style></head><body>
<h1>${esc(def.title)}</h1>
<p class="desc">${esc(def.description)}</p>
${kpiHtml}
<table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>
</body></html>`;
}
}

View File

@@ -1,669 +0,0 @@
import { DataSource } from 'typeorm';
export interface ReportFilters {
/** ISO timestamp, inclusive lower bound. null = no lower bound (all time). */
dateFrom: string | null;
/** ISO timestamp, exclusive upper bound. null = no upper bound. */
dateTo: string | null;
granularity: 'day' | 'week' | 'month';
companyIds: string[] | null;
routeIds: string[] | null;
yardIds: string[] | null;
cargoTypeIds: string[] | null;
statuses: string[] | null;
/** Trade-scope-resolved directions. null = unrestricted, [] = show nothing. */
directions: string[] | null;
freightType: string | null;
}
export interface ReportKpi {
label: string;
value: number;
unit?: string;
}
export interface ReportResult {
kpis: ReportKpi[];
rows: Record<string, unknown>[];
}
type ReportQuery = (ds: DataSource, f: ReportFilters) => Promise<ReportResult>;
// For PER_ITEM bulk bookings cargo_total_weight_vgm holds an item COUNT, and
// the real tonnage lives in bulk_total_weight_tons — hence the COALESCE order.
const TONS = 'COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)';
// adjusted_total_amount silently overrides total_amount when set.
const REVENUE = 'COALESCE(b.adjusted_total_amount, b.total_amount)';
// GENERAL contract_kind rows are umbrella contracts, not shipments; counting
// them double-counts every child booking (same guard as overview.repository).
const NOT_UMBRELLA = "(b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')";
const DEAD_STATUSES = "'DRAFT','CANCELLED','REJECTED','EXPIRED'";
const num = (v: unknown): number => (v === null || v === undefined ? 0 : Number(v));
const sum = (rows: Record<string, unknown>[], col: string): number =>
rows.reduce((acc, r) => acc + num(r[col]), 0);
/**
* Shared WHERE for booking-based reports (alias `b`).
* Params occupy $1..$8 in this fixed order; report SQL continues at $9.
*/
function bookingWhere(f: ReportFilters): { where: string; params: unknown[] } {
return {
where: `
b.deleted_at IS NULL
AND ${NOT_UMBRELLA}
AND ($1::timestamptz IS NULL OR b.created_at >= $1)
AND ($2::timestamptz IS NULL OR b.created_at < $2)
AND ($3::uuid[] IS NULL OR b.company_id = ANY($3))
AND ($4::uuid[] IS NULL OR b.cargo_type_id = ANY($4))
AND ($5::text[] IS NULL OR b.trade_direction = ANY($5))
AND ($6::text IS NULL OR b.freight_type = $6)
AND (CASE WHEN $7::text[] IS NULL
THEN b.status NOT IN (${DEAD_STATUSES})
ELSE b.status = ANY($7) END)
AND ($8::uuid[] IS NULL OR b.origin_yard_id = ANY($8) OR b.destination_yard_id = ANY($8))`,
params: [
f.dateFrom,
f.dateTo,
f.companyIds,
f.cargoTypeIds,
f.directions,
f.freightType,
f.statuses,
f.yardIds,
],
};
}
/**
* Direction scope for rows that reference a booking through a varchar id
* column (invoices.source_id, payments.ref_id). Rows not pointing at a
* booking stay visible — they carry no direction to scope by.
* (Positional-param port of trade-scope.util's bookingRefScopeSql.)
*/
const refDirScope = (refColumn: string, param: string): string => `
(${param}::text[] IS NULL OR NOT EXISTS (
SELECT 1 FROM freight.bookings sb
WHERE sb.id::text = ${refColumn} AND NOT (sb.trade_direction = ANY(${param}))))`;
const bookingsTrend: ReportQuery = async (ds, f) => {
const { where, params } = bookingWhere(f);
const rows = await ds.query(
`SELECT to_char(date_trunc($9, b.created_at), 'YYYY-MM-DD') AS period,
COUNT(*)::int AS bookings,
ROUND(COALESCE(SUM(${TONS}), 0))::float8 AS tons,
ROUND(COALESCE(SUM(${REVENUE}), 0))::float8 AS revenue
FROM freight.bookings b
WHERE ${where}
GROUP BY 1 ORDER BY 1`,
[...params, f.granularity],
);
return {
kpis: [
{ label: 'Bookings', value: sum(rows, 'bookings') },
{ label: 'Tonnage', value: sum(rows, 'tons'), unit: 't' },
{ label: 'Revenue', value: sum(rows, 'revenue'), unit: 'ETB' },
],
rows,
};
};
const revenueByCustomer: ReportQuery = async (ds, f) => {
const { where, params } = bookingWhere(f);
const rows = await ds.query(
`SELECT c.name AS customer,
COUNT(*)::int AS bookings,
ROUND(COALESCE(SUM(${TONS}), 0))::float8 AS tons,
ROUND(COALESCE(SUM(${REVENUE}), 0))::float8 AS revenue
FROM freight.bookings b
JOIN freight.companies c ON c.id = b.company_id
WHERE ${where}
GROUP BY c.name ORDER BY revenue DESC LIMIT 100`,
params,
);
const total = sum(rows, 'revenue');
return {
kpis: [
{ label: 'Customers', value: rows.length },
{ label: 'Revenue', value: total, unit: 'ETB' },
{
label: 'Top customer share',
value: total > 0 ? Math.round((num(rows[0]?.revenue) / total) * 100) : 0,
unit: '%',
},
],
rows,
};
};
const revenueByLane: ReportQuery = async (ds, f) => {
const { where, params } = bookingWhere(f);
const rows = await ds.query(
`SELECT o.label AS origin, d.label AS destination,
COUNT(*)::int AS bookings,
ROUND(COALESCE(SUM(${TONS}), 0))::float8 AS tons,
ROUND(COALESCE(SUM(${REVENUE}), 0))::float8 AS revenue
FROM freight.bookings b
JOIN freight.yards o ON o.id = b.origin_yard_id
JOIN freight.yards d ON d.id = b.destination_yard_id
WHERE ${where}
GROUP BY 1, 2 ORDER BY revenue DESC LIMIT 100`,
params,
);
return {
kpis: [
{ label: 'Lanes', value: rows.length },
{ label: 'Tonnage', value: sum(rows, 'tons'), unit: 't' },
{ label: 'Revenue', value: sum(rows, 'revenue'), unit: 'ETB' },
],
rows,
};
};
const contractUtilization: ReportQuery = async (ds, f) => {
const rows = await ds.query(
`SELECT ct.reference, c.name AS customer, ct.status, ct.contract_kind AS kind,
to_char(ct.contract_valid_from, 'YYYY-MM-DD') AS valid_from,
to_char(ct.contract_valid_until, 'YYYY-MM-DD') AS valid_until,
cap.committed::float8 AS committed,
booked.tons::float8 AS booked_tons,
booked.cnt AS bookings,
CASE WHEN cap.committed > 0
THEN ROUND(booked.tons / cap.committed * 100)::float8 END AS utilization_pct
FROM freight.contracts ct
LEFT JOIN freight.companies c ON c.id = ct.company_id
LEFT JOIN LATERAL (
SELECT COALESCE(SUM(s.quantity_cap), 0) AS committed
FROM freight.contract_cargo_scope s
WHERE s.contract_id = ct.id AND s.deleted_at IS NULL) cap ON true
LEFT JOIN LATERAL (
SELECT COALESCE(SUM(${TONS}), 0) AS tons, COUNT(*)::int AS cnt
FROM freight.bookings b
WHERE b.contract_id = ct.id AND b.deleted_at IS NULL
AND b.status NOT IN (${DEAD_STATUSES})) booked ON true
WHERE ct.deleted_at IS NULL
AND ct.status NOT IN ('DRAFT')
AND ct.contract_valid_from < COALESCE($2::timestamptz, 'infinity')
AND (ct.contract_valid_until IS NULL
OR ct.contract_valid_until >= COALESCE($1::timestamptz, '-infinity'))
AND ($3::uuid[] IS NULL OR ct.company_id = ANY($3))
AND ($4::text[] IS NULL OR ct.trade_direction = ANY($4))
AND ($5::text[] IS NULL OR ct.status = ANY($5))
ORDER BY utilization_pct DESC NULLS LAST LIMIT 200`,
[f.dateFrom, f.dateTo, f.companyIds, f.directions, f.statuses],
);
const capped = rows.filter((r: Record<string, unknown>) => num(r.committed) > 0);
return {
kpis: [
{ label: 'Contracts', value: rows.length },
{
label: 'Avg utilization',
value: capped.length
? Math.round(sum(capped, 'utilization_pct') / capped.length)
: 0,
unit: '%',
},
{ label: 'Booked tonnage', value: sum(rows, 'booked_tons'), unit: 't' },
],
rows,
};
};
// ponytail: 60-min departure grace is a constant; make it a query param if ops
// ever wants a configurable threshold.
const trainOnTime: ReportQuery = async (ds, f) => {
const rows = await ds.query(
`SELECT o.label AS origin, d.label AS destination,
COUNT(*)::int AS trips,
COUNT(*) FILTER (WHERE ts.actual_departure_at IS NOT NULL)::int AS departed,
ROUND(AVG(EXTRACT(EPOCH FROM (ts.actual_departure_at - ts.scheduled_departure_date)) / 60)
FILTER (WHERE ts.actual_departure_at IS NOT NULL))::float8 AS avg_dep_delay_min,
ROUND(AVG(EXTRACT(EPOCH FROM (ts.actual_arrival_at - ts.scheduled_arrival_date)) / 60)
FILTER (WHERE ts.actual_arrival_at IS NOT NULL
AND ts.scheduled_arrival_date IS NOT NULL))::float8 AS avg_arr_delay_min,
ROUND(100.0 * COUNT(*) FILTER (WHERE ts.actual_departure_at
<= ts.scheduled_departure_date + interval '60 minutes')
/ NULLIF(COUNT(*) FILTER (WHERE ts.actual_departure_at IS NOT NULL), 0))::float8 AS on_time_pct
FROM freight.train_schedules ts
JOIN freight.yards o ON o.id = ts.origin_station_id
JOIN freight.yards d ON d.id = ts.destination_station_id
WHERE ts.deleted_at IS NULL
AND ts.status IN ('DISPATCHED', 'ARRIVED')
AND ($1::timestamptz IS NULL OR ts.scheduled_departure_date >= $1)
AND ($2::timestamptz IS NULL OR ts.scheduled_departure_date < $2)
AND ($3::uuid[] IS NULL OR ts.route_id = ANY($3))
AND ($4::text[] IS NULL OR ts.direction = ANY($4))
AND ($5::uuid[] IS NULL OR ts.origin_station_id = ANY($5) OR ts.destination_station_id = ANY($5))
GROUP BY 1, 2 ORDER BY trips DESC`,
[f.dateFrom, f.dateTo, f.routeIds, f.directions, f.yardIds],
);
const departed = sum(rows, 'departed');
const weighted = rows.reduce(
(acc: number, r: Record<string, unknown>) =>
acc + (num(r.on_time_pct) * num(r.departed)) / 100,
0,
);
return {
kpis: [
{ label: 'Trips', value: sum(rows, 'trips') },
{
label: 'On-time departures',
value: departed > 0 ? Math.round((weighted / departed) * 100) : 0,
unit: '%',
},
{
label: 'Avg departure delay',
value: rows.length ? Math.round(sum(rows, 'avg_dep_delay_min') / rows.length) : 0,
unit: 'min',
},
],
rows,
};
};
const scheduleFillRate: ReportQuery = async (ds, f) => {
const rows = await ds.query(
`SELECT ts.train_number, ts.reference,
to_char(ts.scheduled_departure_date, 'YYYY-MM-DD') AS departure,
o.label AS origin, d.label AS destination, ts.direction, ts.status,
ts.max_wagons, tset.wagon_count,
ROUND(w.cap_tons)::float8 AS capacity_tons,
ROUND(w.booked_tons)::float8 AS booked_tons,
CASE WHEN w.cap_tons > 0
THEN ROUND(w.booked_tons / w.cap_tons * 100)::float8 END AS fill_pct
FROM freight.train_schedules ts
JOIN freight.yards o ON o.id = ts.origin_station_id
JOIN freight.yards d ON d.id = ts.destination_station_id
LEFT JOIN freight.train_sets tset ON tset.id = ts.train_set_id
LEFT JOIN LATERAL (
SELECT COALESCE(SUM(tw.capacity_tons), 0) AS cap_tons,
COALESCE(SUM(tw.assigned_weight_tons), 0) AS booked_tons
FROM freight.train_set_wagons tw
WHERE tw.train_set_id = ts.train_set_id AND tw.deleted_at IS NULL) w ON true
WHERE ts.deleted_at IS NULL
AND ts.status <> 'CANCELLED'
AND ($1::timestamptz IS NULL OR ts.scheduled_departure_date >= $1)
AND ($2::timestamptz IS NULL OR ts.scheduled_departure_date < $2)
AND ($3::uuid[] IS NULL OR ts.route_id = ANY($3))
AND ($4::text[] IS NULL OR ts.direction = ANY($4))
AND ($5::uuid[] IS NULL OR ts.origin_station_id = ANY($5) OR ts.destination_station_id = ANY($5))
ORDER BY ts.scheduled_departure_date DESC LIMIT 200`,
[f.dateFrom, f.dateTo, f.routeIds, f.directions, f.yardIds],
);
const withCap = rows.filter((r: Record<string, unknown>) => num(r.capacity_tons) > 0);
const capTons = sum(withCap, 'capacity_tons');
return {
kpis: [
{ label: 'Schedules', value: rows.length },
{
label: 'Avg fill rate',
value: capTons > 0 ? Math.round((sum(withCap, 'booked_tons') / capTons) * 100) : 0,
unit: '%',
},
{ label: 'Booked tonnage', value: sum(rows, 'booked_tons'), unit: 't' },
],
rows,
};
};
const tripsPerRoute: ReportQuery = async (ds, f) => {
const rows = await ds.query(
`SELECT o.label AS origin, d.label AS destination, ts.direction,
COUNT(*)::int AS trips,
ROUND(COALESCE(SUM(w.booked_tons), 0))::float8 AS tons_hauled,
ROUND(COALESCE(AVG(w.booked_tons), 0))::float8 AS avg_tons_per_trip
FROM freight.train_schedules ts
JOIN freight.yards o ON o.id = ts.origin_station_id
JOIN freight.yards d ON d.id = ts.destination_station_id
LEFT JOIN LATERAL (
SELECT COALESCE(SUM(tw.assigned_weight_tons), 0) AS booked_tons
FROM freight.train_set_wagons tw
WHERE tw.train_set_id = ts.train_set_id AND tw.deleted_at IS NULL) w ON true
WHERE ts.deleted_at IS NULL
AND ts.status IN ('DISPATCHED', 'ARRIVED')
AND ($1::timestamptz IS NULL OR ts.scheduled_departure_date >= $1)
AND ($2::timestamptz IS NULL OR ts.scheduled_departure_date < $2)
AND ($3::uuid[] IS NULL OR ts.route_id = ANY($3))
AND ($4::text[] IS NULL OR ts.direction = ANY($4))
AND ($5::uuid[] IS NULL OR ts.origin_station_id = ANY($5) OR ts.destination_station_id = ANY($5))
GROUP BY 1, 2, 3 ORDER BY trips DESC`,
[f.dateFrom, f.dateTo, f.routeIds, f.directions, f.yardIds],
);
return {
kpis: [
{ label: 'Trips', value: sum(rows, 'trips') },
{ label: 'Routes served', value: rows.length },
{ label: 'Tonnage hauled', value: sum(rows, 'tons_hauled'), unit: 't' },
],
rows,
};
};
const invoicedVsCollected: ReportQuery = async (ds, f) => {
const rows = await ds.query(
`SELECT to_char(date_trunc($5, COALESCE(i.issued_at, i.created_at)), 'YYYY-MM-DD') AS period,
COUNT(*)::int AS invoices,
ROUND(SUM(i.total_amount))::float8 AS invoiced,
ROUND(SUM(i.paid_amount))::float8 AS collected,
ROUND(SUM(i.balance_amount))::float8 AS outstanding
FROM freight.invoices i
WHERE i.deleted_at IS NULL
AND i.status NOT IN ('DRAFT', 'CANCELLED')
AND ($1::timestamptz IS NULL OR COALESCE(i.issued_at, i.created_at) >= $1)
AND ($2::timestamptz IS NULL OR COALESCE(i.issued_at, i.created_at) < $2)
AND ($3::uuid[] IS NULL OR i.company_id = ANY($3))
AND ${refDirScope('i.source_id', '$4')}
GROUP BY 1 ORDER BY 1`,
[f.dateFrom, f.dateTo, f.companyIds, f.directions, f.granularity],
);
const invoiced = sum(rows, 'invoiced');
const collected = sum(rows, 'collected');
return {
kpis: [
{ label: 'Invoiced', value: invoiced, unit: 'ETB' },
{ label: 'Collected', value: collected, unit: 'ETB' },
{
label: 'Collection rate',
value: invoiced > 0 ? Math.round((collected / invoiced) * 100) : 0,
unit: '%',
},
{ label: 'Outstanding', value: sum(rows, 'outstanding'), unit: 'ETB' },
],
rows,
};
};
// Aging is an as-of snapshot: dateTo is the as-of moment (default now),
// dateFrom is ignored.
const agingReceivables: ReportQuery = async (ds, f) => {
const rows = await ds.query(
`SELECT c.name AS customer,
COUNT(*)::int AS invoices,
ROUND(SUM(i.balance_amount))::float8 AS outstanding,
ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at >= COALESCE($1::timestamptz, now())), 0))::float8 AS current,
ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < COALESCE($1::timestamptz, now())
AND i.due_at >= COALESCE($1::timestamptz, now()) - interval '30 days'), 0))::float8 AS overdue_0_30,
ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < COALESCE($1::timestamptz, now()) - interval '30 days'
AND i.due_at >= COALESCE($1::timestamptz, now()) - interval '60 days'), 0))::float8 AS overdue_31_60,
ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < COALESCE($1::timestamptz, now()) - interval '60 days'
AND i.due_at >= COALESCE($1::timestamptz, now()) - interval '90 days'), 0))::float8 AS overdue_61_90,
ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < COALESCE($1::timestamptz, now()) - interval '90 days'), 0))::float8 AS overdue_90_plus
FROM freight.invoices i
JOIN freight.companies c ON c.id = i.company_id
WHERE i.deleted_at IS NULL
AND i.status IN ('ISSUED', 'PENDING', 'PARTIALLY_PAID', 'OVERDUE')
AND i.balance_amount > 0
AND ($1::timestamptz IS NULL OR i.created_at < $1)
AND ($2::uuid[] IS NULL OR i.company_id = ANY($2))
AND ${refDirScope('i.source_id', '$3')}
GROUP BY 1 ORDER BY outstanding DESC LIMIT 200`,
[f.dateTo, f.companyIds, f.directions],
);
const outstanding = sum(rows, 'outstanding');
return {
kpis: [
{ label: 'Outstanding', value: outstanding, unit: 'ETB' },
{ label: 'Overdue', value: outstanding - sum(rows, 'current'), unit: 'ETB' },
{ label: 'Customers with balance', value: rows.length },
],
rows,
};
};
const revenueByPaymentMethod: ReportQuery = async (ds, f) => {
// payments.status values are lowercase-hyphenated ('success'), unlike every
// other status enum in the schema. No deleted_at on this table.
const rows = await ds.query(
`SELECT p.method::text AS method,
COUNT(*)::int AS payments,
ROUND(SUM(p.amount))::float8 AS amount
FROM freight.payments p
WHERE p.status = 'success'
AND ($1::timestamptz IS NULL OR p.created_at >= $1)
AND ($2::timestamptz IS NULL OR p.created_at < $2)
AND ${refDirScope('p.ref_id', '$3')}
GROUP BY 1 ORDER BY amount DESC`,
[f.dateFrom, f.dateTo, f.directions],
);
const total = sum(rows, 'amount');
return {
kpis: [
{ label: 'Collected', value: total, unit: 'ETB' },
{ label: 'Payments', value: sum(rows, 'payments') },
{
label: 'Top method share',
value: total > 0 ? Math.round((num(rows[0]?.amount) / total) * 100) : 0,
unit: '%',
},
],
rows,
};
};
// ---------------------------------------------------------------------------
// Record-level list exports. Same engine, raw rows instead of aggregates.
// ponytail: flat LIMIT 5000 per list — stream/paginate the export if a table
// ever outgrows that.
const LIST_LIMIT = 5000;
const bookingsList: ReportQuery = async (ds, f) => {
const { where, params } = bookingWhere(f);
const rows = await ds.query(
`SELECT b.reference,
to_char(b.created_at, 'YYYY-MM-DD') AS created,
c.name AS customer, b.status, b.freight_type,
b.trade_direction AS direction,
o.label AS origin, d.label AS destination,
COALESCE(cty.cargo_type_name, b.cargo_free_text) AS cargo,
ROUND(${TONS})::float8 AS tons,
ROUND(${REVENUE})::float8 AS amount,
b.payment_status, b.scheduling_status
FROM freight.bookings b
JOIN freight.companies c ON c.id = b.company_id
JOIN freight.yards o ON o.id = b.origin_yard_id
JOIN freight.yards d ON d.id = b.destination_yard_id
LEFT JOIN freight.cargo_types cty ON cty.id = b.cargo_type_id
WHERE ${where}
ORDER BY b.created_at DESC LIMIT ${LIST_LIMIT}`,
params,
);
return {
kpis: [
{ label: 'Bookings', value: rows.length },
{ label: 'Tonnage', value: sum(rows, 'tons'), unit: 't' },
{ label: 'Amount', value: sum(rows, 'amount'), unit: 'ETB' },
],
rows,
};
};
const contractsList: ReportQuery = async (ds, f) => {
const rows = await ds.query(
`SELECT ct.reference, c.name AS customer, ct.contract_kind AS kind,
ct.status, ct.trade_direction AS direction, ct.freight_type,
to_char(ct.contract_valid_from, 'YYYY-MM-DD') AS valid_from,
to_char(ct.contract_valid_until, 'YYYY-MM-DD') AS valid_until,
to_char(ct.created_at, 'YYYY-MM-DD') AS created
FROM freight.contracts ct
LEFT JOIN freight.companies c ON c.id = ct.company_id
WHERE ct.deleted_at IS NULL
AND ($1::timestamptz IS NULL OR ct.created_at >= $1)
AND ($2::timestamptz IS NULL OR ct.created_at < $2)
AND ($3::uuid[] IS NULL OR ct.company_id = ANY($3))
AND ($4::text[] IS NULL OR ct.trade_direction = ANY($4))
AND ($5::text[] IS NULL OR ct.status = ANY($5))
ORDER BY ct.created_at DESC LIMIT ${LIST_LIMIT}`,
[f.dateFrom, f.dateTo, f.companyIds, f.directions, f.statuses],
);
const active = rows.filter((r: Record<string, unknown>) =>
['CONTRACT_ACTIVE', 'ACTIVE_SHIPMENT_IN_PROGRESS'].includes(String(r.status)),
).length;
return {
kpis: [
{ label: 'Contracts', value: rows.length },
{ label: 'Active', value: active },
],
rows,
};
};
const schedulesList: ReportQuery = async (ds, f) => {
const rows = await ds.query(
`SELECT ts.train_number, ts.reference, ts.direction, ts.status,
o.label AS origin, d.label AS destination,
to_char(ts.scheduled_departure_date, 'YYYY-MM-DD HH24:MI') AS scheduled_departure,
to_char(ts.actual_departure_at, 'YYYY-MM-DD HH24:MI') AS actual_departure,
to_char(ts.scheduled_arrival_date, 'YYYY-MM-DD HH24:MI') AS scheduled_arrival,
to_char(ts.actual_arrival_at, 'YYYY-MM-DD HH24:MI') AS actual_arrival,
ts.max_wagons, tset.wagon_count
FROM freight.train_schedules ts
JOIN freight.yards o ON o.id = ts.origin_station_id
JOIN freight.yards d ON d.id = ts.destination_station_id
LEFT JOIN freight.train_sets tset ON tset.id = ts.train_set_id
WHERE ts.deleted_at IS NULL
AND ($1::timestamptz IS NULL OR ts.scheduled_departure_date >= $1)
AND ($2::timestamptz IS NULL OR ts.scheduled_departure_date < $2)
AND ($3::text[] IS NULL OR ts.direction = ANY($3))
AND ($4::text[] IS NULL OR ts.status = ANY($4))
AND ($5::uuid[] IS NULL OR ts.origin_station_id = ANY($5) OR ts.destination_station_id = ANY($5))
ORDER BY ts.scheduled_departure_date DESC LIMIT ${LIST_LIMIT}`,
[f.dateFrom, f.dateTo, f.directions, f.statuses, f.yardIds],
);
const count = (s: string) =>
rows.filter((r: Record<string, unknown>) => r.status === s).length;
return {
kpis: [
{ label: 'Schedules', value: rows.length },
{ label: 'Dispatched', value: count('DISPATCHED') },
{ label: 'Arrived', value: count('ARRIVED') },
],
rows,
};
};
const fleetWagons: ReportQuery = async (ds, f) => {
const rows = await ds.query(
`SELECT w.wagon_number, wt.name AS type,
wt.capacity_tons::float8 AS capacity_tons,
w.status, y.label AS current_yard
FROM freight.wagons w
JOIN freight.wagon_types wt ON wt.id = w.wagon_type_id
LEFT JOIN freight.yards y ON y.id = w.current_yard_id
WHERE w.deleted_at IS NULL
AND ($1::text[] IS NULL OR w.status = ANY($1))
AND ($2::uuid[] IS NULL OR w.current_yard_id = ANY($2))
ORDER BY w.wagon_number LIMIT ${LIST_LIMIT}`,
[f.statuses, f.yardIds],
);
const count = (s: string) =>
rows.filter((r: Record<string, unknown>) => r.status === s).length;
return {
kpis: [
{ label: 'Wagons', value: rows.length },
{ label: 'Available', value: count('AVAILABLE') },
{ label: 'Assigned', value: count('ASSIGNED') },
{ label: 'Maintenance', value: count('MAINTENANCE') },
],
rows,
};
};
const fleetLocomotives: ReportQuery = async (ds, f) => {
const rows = await ds.query(
`SELECT l.code, l.name, l.locomotive_type,
l.max_pull_weight_tons::float8 AS max_pull_tons,
l.status, y.label AS current_yard
FROM freight.locomotives l
LEFT JOIN freight.yards y ON y.id = l.current_yard_id
WHERE l.deleted_at IS NULL
AND ($1::text[] IS NULL OR l.status = ANY($1))
AND ($2::uuid[] IS NULL OR l.current_yard_id = ANY($2))
ORDER BY l.code LIMIT ${LIST_LIMIT}`,
[f.statuses, f.yardIds],
);
const available = rows.filter(
(r: Record<string, unknown>) => r.status === 'AVAILABLE',
).length;
return {
kpis: [
{ label: 'Locomotives', value: rows.length },
{ label: 'Available', value: available },
],
rows,
};
};
const customersList: ReportQuery = async (ds, f) => {
const rows = await ds.query(
`SELECT c.name, c.type, c.kind, c.status, c.tin,
to_char(c.approved_at, 'YYYY-MM-DD') AS approved,
to_char(c.created_at, 'YYYY-MM-DD') AS created
FROM freight.companies c
WHERE c.deleted_at IS NULL
AND ($1::timestamptz IS NULL OR c.created_at >= $1)
AND ($2::timestamptz IS NULL OR c.created_at < $2)
AND ($3::text[] IS NULL OR c.status = ANY($3))
ORDER BY c.created_at DESC LIMIT ${LIST_LIMIT}`,
[f.dateFrom, f.dateTo, f.statuses],
);
const active = rows.filter(
(r: Record<string, unknown>) => r.status === 'active',
).length;
return {
kpis: [
{ label: 'Customers', value: rows.length },
{ label: 'Active', value: active },
],
rows,
};
};
const paymentsList: ReportQuery = async (ds, f) => {
// No deleted_at on freight.payments; statuses are lowercase-hyphenated.
const rows = await ds.query(
`SELECT to_char(p.created_at, 'YYYY-MM-DD HH24:MI') AS created,
p.method::text AS method, p.status::text AS status,
p.currency::text AS currency,
ROUND(p.amount)::float8 AS amount,
p.transaction_id, p.merchant_order_id,
to_char(p.paid_at, 'YYYY-MM-DD') AS paid
FROM freight.payments p
WHERE ($1::timestamptz IS NULL OR p.created_at >= $1)
AND ($2::timestamptz IS NULL OR p.created_at < $2)
AND ($3::text[] IS NULL OR p.status::text = ANY($3))
AND ${refDirScope('p.ref_id', '$4')}
ORDER BY p.created_at DESC LIMIT ${LIST_LIMIT}`,
[f.dateFrom, f.dateTo, f.statuses, f.directions],
);
const success = rows.filter(
(r: Record<string, unknown>) => r.status === 'success',
);
return {
kpis: [
{ label: 'Payments', value: rows.length },
{ label: 'Successful', value: success.length },
{ label: 'Collected', value: sum(success, 'amount'), unit: 'ETB' },
],
rows,
};
};
export const REPORT_QUERIES: Record<string, ReportQuery> = {
'bookings-list': bookingsList,
'contracts-list': contractsList,
'schedules-list': schedulesList,
'fleet-wagons': fleetWagons,
'fleet-locomotives': fleetLocomotives,
'customers-list': customersList,
'payments-list': paymentsList,
'bookings-trend': bookingsTrend,
'revenue-by-customer': revenueByCustomer,
'revenue-by-lane': revenueByLane,
'contract-utilization': contractUtilization,
'train-on-time': trainOnTime,
'schedule-fill-rate': scheduleFillRate,
'trips-per-route': tripsPerRoute,
'invoiced-vs-collected': invoicedVsCollected,
'aging-receivables': agingReceivables,
'revenue-by-payment-method': revenueByPaymentMethod,
};

View File

@@ -0,0 +1,152 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import {
buildPaginationMeta,
normalizePagination,
} from '../../common/utils/pagination.util';
import { applyBookingRefDirectionScope } from '../user-trade-access/trade-scope.util';
import { ReportDefinition, ReportRunResult } from './report.types';
const DAY_MS = 24 * 60 * 60 * 1000;
/** Raw query params, minus the pagination/sort keys the runner owns. */
export type RawReportQuery = Record<string, string | undefined>;
/**
* Coerce raw query strings into typed filter params per the report's own
* filter declarations. Unknown filter keys are ignored — `forbidNonWhitelisted`
* can't police a per-report bag, so extras are just dropped, not rejected.
*/
function coerceParams(
def: ReportDefinition,
raw: RawReportQuery,
): Record<string, unknown> {
const params: Record<string, unknown> = {};
for (const filter of def.filters) {
if (filter.type === 'daterange') {
const from = raw[`${filter.key}From`];
const to = raw[`${filter.key}To`];
params[`${filter.key}From`] = from ? new Date(from).toISOString() : null;
// Inclusive end date, exclusive bound in SQL.
params[`${filter.key}To`] = to
? new Date(new Date(to).getTime() + DAY_MS).toISOString()
: null;
} else if (filter.type === 'multiselect') {
const csv = raw[filter.key];
const items = csv?.split(',').map((s) => s.trim()).filter(Boolean) ?? [];
params[filter.key] = items.length ? items : null;
} else {
params[filter.key] = raw[filter.key]?.trim() || null;
}
}
// idKey, when the report declares one, is a plain string param.
if (def.idKey) {
params[def.idKey.key] = raw[def.idKey.key]?.trim() || null;
}
return params;
}
/**
* Sort expression for a column with no explicit `sortExpr`: the SELECT alias
* TypeORM emitted for it, quoted. TypeORM always double-quotes `addSelect`
* aliases in the generated SQL (preserving case) — ordering by the bare,
* unquoted key instead lets Postgres fold it to lowercase and 42703 on any
* camelCase alias (e.g. "utilizationPct" -> unquoted "utilizationpct").
*/
const aliasSortExpr = (key: string): string => `"${key.replace(/"/g, '""')}"`;
/** Resolve a client-requested sort column against the report's own whitelist. */
function resolveSort(
def: ReportDefinition,
sortBy?: string,
sortOrder?: string,
): { key: string; expr: string; dir: 'ASC' | 'DESC' } | null {
const dir = sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
const requested = sortBy && def.columns.find((c) => c.key === sortBy && c.sortable);
if (requested) {
return { key: requested.key, expr: requested.sortExpr ?? aliasSortExpr(requested.key), dir };
}
if (!def.defaultSort) return null;
const fallback = def.columns.find((c) => c.key === def.defaultSort!.key);
if (!fallback) return null;
return {
key: fallback.key,
expr: fallback.sortExpr ?? aliasSortExpr(fallback.key),
dir: def.defaultSort.dir,
};
}
@Injectable()
export class ReportRunnerService {
constructor(@InjectDataSource() private readonly ds: DataSource) {}
async run(
def: ReportDefinition,
raw: RawReportQuery,
directions: string[] | null,
): Promise<ReportRunResult> {
const params = coerceParams(def, raw);
const ctx = { ds: this.ds, params, directions };
const qb = def.query(ctx);
const sort = resolveSort(def, raw.sortBy, raw.sortOrder);
if (sort) qb.orderBy(sort.expr, sort.dir);
const { page: pageNum, pageSize, skip, take } = normalizePagination({
page: raw.page ? Number(raw.page) : undefined,
pageSize: raw.pageSize ? Number(raw.pageSize) : undefined,
});
const [sql, sqlParams] = qb.getQueryAndParameters();
// getCount() re-derives its own (wrong) select list for GROUP BY queries —
// wrapping the real query as a subquery counts exactly what will be paged.
const countRow = await this.ds.query(
`SELECT COUNT(*)::int AS c FROM (${sql}) report_count`,
sqlParams,
);
const total = Number(countRow[0]?.c ?? 0);
// .offset()/.limit(), not .skip()/.take() — skip/take route raw & grouped
// selects through TypeORM's DISTINCT-id subquery path, which is wrong here.
const items = await qb.offset(skip).limit(take).getRawMany();
const kpis = def.summary ? await def.summary(ctx) : [];
return {
columns: def.columns,
items,
meta: buildPaginationMeta(total, pageNum, pageSize),
kpis,
};
}
/** Same query, no paging — used by the export path. */
async runAll(
def: ReportDefinition,
raw: RawReportQuery,
directions: string[] | null,
limit: number,
): Promise<{ columns: typeof def.columns; items: Record<string, unknown>[]; kpis: ReportRunResult['kpis'] }> {
const params = coerceParams(def, raw);
const ctx = { ds: this.ds, params, directions };
const qb = def.query(ctx);
// Same sort the on-screen table is using, not always the default — an
// export is supposed to match what the user is looking at.
const sort = resolveSort(def, raw.sortBy, raw.sortOrder);
if (sort) qb.orderBy(sort.expr, sort.dir);
const items = await qb.limit(limit).getRawMany();
if (items.length >= limit) {
throw new BadRequestException(
`Export exceeds the ${limit}-row cap for this format. Narrow the filters.`,
);
}
const kpis = def.summary ? await def.summary(ctx) : [];
return { columns: def.columns, items, kpis };
}
}
// Re-exported so definitions can scope ACL columns without importing the
// trade-scope module directly.
export { applyBookingRefDirectionScope };

View File

@@ -0,0 +1,52 @@
import { REPORT_KEYS } from '../../seed/freight-permissions.registry';
import { REPORTS, getReport } from './report.registry';
describe('REPORTS', () => {
it('has exactly one definition per seeded REPORT_KEYS entry', () => {
const defKeys = REPORTS.map((r) => r.key).sort();
expect(defKeys).toEqual([...REPORT_KEYS].sort());
});
it('has no duplicate keys', () => {
const keys = REPORTS.map((r) => r.key);
expect(new Set(keys).size).toBe(keys.length);
});
it('resolves every key via getReport', () => {
for (const key of REPORT_KEYS) {
expect(getReport(key)?.key).toBe(key);
}
});
it('every sortable column and defaultSort point at a real column key', () => {
for (const def of REPORTS) {
const columnKeys = new Set(def.columns.map((c) => c.key));
if (def.defaultSort) {
expect(columnKeys.has(def.defaultSort.key)).toBe(true);
}
// Every column marked sortable must have a resolvable key (itself, since
// the runner falls back to `key` when `sortExpr` is absent).
for (const col of def.columns.filter((c) => c.sortable)) {
expect(col.key.length).toBeGreaterThan(0);
}
}
});
it('idKey, when declared, is not also listed as a user-facing filter', () => {
for (const def of REPORTS) {
if (!def.idKey) continue;
expect(def.filters.some((f) => f.key === def.idKey!.key)).toBe(false);
}
});
it('chart.x and chart.y, when declared, point at real column keys', () => {
for (const def of REPORTS) {
if (!def.chart) continue;
const columnKeys = new Set(def.columns.map((c) => c.key));
expect(columnKeys.has(def.chart.x)).toBe(true);
for (const y of def.chart.y) {
expect(columnKeys.has(y)).toBe(true);
}
}
});
});

View File

@@ -0,0 +1,62 @@
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 { ReportDefinition } from './report.types';
/**
* Every report the platform knows about. Adding one = a new file under
* definitions/ + a key in REPORT_KEYS (freight-permissions.registry.ts) +
* an entry here. Nothing else — no frontend edit, no route, no sidebar edit.
*/
export const REPORTS: ReportDefinition[] = [
bookingsListReport,
revenueByCustomerReport,
agingReceivablesReport,
contractUtilizationReport,
wagonFleetStatusReport,
wagonStatusDurationReport,
wagonRequestsReport,
locomotiveFleetStatusReport,
bookingStatusBreakdownReport,
trainScheduleStatusReport,
trainTurnaroundReport,
wagonTeuUtilizationReport,
loadedCapacityReport,
globalLogisticsWagonsReport,
customerStatusReport,
contractLifecycleReport,
customsDocumentsReport,
invoicingPipelineReport,
firstLastMileBookingsReport,
invoicesByStatusReport,
paymentsByStatusReport,
revenueSummaryReport,
cargoSummaryReport,
];
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);
}

View File

@@ -0,0 +1,112 @@
import { DataSource, ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { ReportKey } from '../../seed/freight-permissions.registry';
export type { ReportKey };
export type ReportColumnType =
| 'string'
| 'number'
| 'money'
| 'tons'
| 'percent'
| 'date';
export interface ReportColumn {
key: string;
label: string;
type: ReportColumnType;
sortable?: boolean;
/** SQL to ORDER BY when this column is sorted, if different from `key`. */
sortExpr?: string;
}
export type ReportFilterType = 'daterange' | 'date' | 'select' | 'multiselect' | 'text';
export interface ReportFilterOption {
value: string;
label: string;
}
export interface ReportFilterDef {
key: string;
label: string;
type: ReportFilterType;
/** Static option list for select/multiselect. */
options?: ReportFilterOption[];
}
export interface ReportKpi {
label: string;
value: number;
unit?: string;
}
export type ReportChartType = 'line' | 'bar';
/**
* Plots the SAME rows the table gets — no separate query. `x` and `y` are
* column keys from `columns`. A report whose group-by has dimensions beyond
* `x` will render one mark per row (e.g. two rows sharing a date because they
* differ by direction), which is a busier chart, not a wrong one. Pivoting
* rows into one-per-x series is a later add if a report actually needs it.
*/
export interface ReportChartDef {
type: ReportChartType;
x: string;
y: string[];
}
/**
* Optional entity scope a report can be embedded against — e.g. a
* contract-utilization report shown on a single contract's detail page.
* Purely descriptive; `query()` reads the resolved value off `ctx.params`
* like any other filter.
*/
export interface ReportIdKey {
key: string;
label: string;
}
export interface ReportContext {
ds: DataSource;
/** Filter values, already coerced against `def.filters` (CSV → array, etc). */
params: Record<string, unknown>;
/** Trade-scope-resolved directions. null = unrestricted, [] = show nothing. */
directions: string[] | null;
}
export interface ReportDefinition {
key: ReportKey;
title: string;
description: string;
group: 'Commercial' | 'Operations' | 'Finance';
idKey?: ReportIdKey;
filters: ReportFilterDef[];
columns: ReportColumn[];
defaultSort?: { key: string; dir: 'ASC' | 'DESC' };
query(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral>;
/** KPIs over the same filtered set; shown above the table and in exports. */
summary?(ctx: ReportContext): Promise<ReportKpi[]>;
/** Optional chart view of the same rows. Table remains the default view. */
chart?: ReportChartDef;
}
/** Catalog shape served by GET /reports — metadata only, no rows. */
export type ReportCatalogEntry = Omit<ReportDefinition, 'query' | 'summary'> & {
hasSummary: boolean;
};
export interface ReportRunResult {
columns: ReportColumn[];
items: Record<string, unknown>[];
meta: {
page: number;
pageSize: number;
total: number;
totalPages: number;
hasNextPage: boolean;
hasPreviousPage: boolean;
};
kpis: ReportKpi[];
}

View File

@@ -1,34 +1,92 @@
import { Controller, Get, Param, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Controller, Get, NotFoundException, Param, Query, Res } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '@edr/api-common';
import type { Response } from 'express';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { assertFreightPermission, hasFreightPermission } from '../../common/freight-permission.util';
import { FREIGHT_PERMS, reportPermissionKey } from '../../seed/freight-permissions.registry';
import { UserTradeAccessService } from '../user-trade-access/user-trade-access.service';
import { ReportQueryDto } from './dto/report-query.dto';
import { ReportResultDto } from './dto/report-result.dto';
import { ReportsService } from './reports.service';
import { ReportExportService } from './report-export.service';
import { resolveExportCap, resolveExportColumns, resolveExportFormat } from './report-export-request.util';
import { RawReportQuery, ReportRunnerService } from './report-runner.service';
import { REPORTS, getReport } from './report.registry';
import { ReportCatalogEntry, ReportDefinition } from './report.types';
const toCatalogEntry = (def: ReportDefinition): ReportCatalogEntry => {
const { query: _query, summary, ...meta } = def;
return { ...meta, hasSummary: Boolean(summary) };
};
@ApiTags('Reports')
@ApiBearerAuth()
@Controller('reports')
@BookingStaff(FREIGHT_PERMS.reports.view)
export class ReportsController {
constructor(
private readonly reportsService: ReportsService,
private readonly runner: ReportRunnerService,
private readonly exportService: ReportExportService,
private readonly userTradeAccessService: UserTradeAccessService,
) {}
@Get()
@ApiOperation({ summary: 'List reports the caller has permission to run' })
async catalog(@CurrentUser() user: TCurrentUser): Promise<ReportCatalogEntry[]> {
return REPORTS.filter((def) => hasFreightPermission(user, reportPermissionKey(def.key))).map(
toCatalogEntry,
);
}
@Get(':key')
@BookingStaff(FREIGHT_PERMS.reports.view)
@ApiOperation({ summary: 'Run a canned report by key with optional filters' })
@ApiOkResponse({ type: ReportResultDto })
@ApiOperation({ summary: 'Run a report by key, paginated/sorted/filtered' })
async run(
@Param('key') key: string,
@Query() query: ReportQueryDto,
@Query() query: RawReportQuery,
@CurrentUser() user: TCurrentUser,
): Promise<ReportResultDto> {
const allowed = await this.userTradeAccessService.resolveAllowedDirections(user);
return this.reportsService.run(key, query, allowed);
) {
const def = this.resolve(key, user);
const directions = await this.userTradeAccessService.resolveAllowedDirections(user);
return this.runner.run(def, query, directions);
}
@Get(':key/export')
@ApiOperation({ summary: 'Export a report to xlsx or pdf' })
async export(
@Param('key') key: string,
@Query() query: RawReportQuery & { format?: string; fields?: string; limit?: string },
@CurrentUser() user: TCurrentUser,
@Res() res: Response,
): Promise<void> {
const def = this.resolve(key, user);
const directions = await this.userTradeAccessService.resolveAllowedDirections(user);
const format = resolveExportFormat(query.format);
const cap = resolveExportCap(format, query.limit);
const exportColumns = resolveExportColumns(def, query.fields);
const { items, kpis } = await this.runner.runAll(def, query, directions, cap);
const buffer =
format === 'pdf'
? await this.exportService.toPdf(def, items, kpis, exportColumns)
: await this.exportService.toXlsx(def, items, kpis, exportColumns);
const filename = `${def.key}.${format === 'pdf' ? 'pdf' : 'xlsx'}`;
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.setHeader(
'Content-Type',
format === 'pdf'
? 'application/pdf'
: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
);
res.send(buffer);
}
private resolve(key: string, user: TCurrentUser): ReportDefinition {
const def = getReport(key);
if (!def) throw new NotFoundException(`Unknown report: ${key}`);
// Exact-match on purpose — unlike FreightPermissionGuard's :view/:read
// fallback, a report's own key is the only thing that opens it.
assertFreightPermission(user, reportPermissionKey(def.key));
return def;
}
}

View File

@@ -1,13 +1,14 @@
import { Module } from '@nestjs/common';
import { DocumentsModule } from '../billing/documents/documents.module';
import { UserTradeAccessModule } from '../user-trade-access/user-trade-access.module';
import { ReportExportService } from './report-export.service';
import { ReportRunnerService } from './report-runner.service';
import { ReportsController } from './reports.controller';
import { ReportsRepository } from './reports.repository';
import { ReportsService } from './reports.service';
@Module({
imports: [UserTradeAccessModule],
imports: [UserTradeAccessModule, DocumentsModule],
controllers: [ReportsController],
providers: [ReportsService, ReportsRepository],
providers: [ReportRunnerService, ReportExportService],
})
export class ReportsModule {}

View File

@@ -1,14 +0,0 @@
import { Injectable } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { REPORT_QUERIES, ReportFilters, ReportResult } from './report-queries';
@Injectable()
export class ReportsRepository {
constructor(@InjectDataSource() private readonly dataSource: DataSource) {}
run(key: keyof typeof REPORT_QUERIES, filters: ReportFilters): Promise<ReportResult> {
return REPORT_QUERIES[key](this.dataSource, filters);
}
}

View File

@@ -1,46 +0,0 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { scopedDirections } from '../user-trade-access/trade-scope.util';
import { ReportQueryDto } from './dto/report-query.dto';
import { REPORT_QUERIES, ReportFilters, ReportResult } from './report-queries';
import { ReportsRepository } from './reports.repository';
import type { Freight } from '@edr/types';
const DAY_MS = 24 * 60 * 60 * 1000;
const list = (csv?: string): string[] | null => {
const items = csv?.split(',').map((s) => s.trim()).filter(Boolean) ?? [];
return items.length ? items : null;
};
@Injectable()
export class ReportsService {
constructor(private readonly repository: ReportsRepository) {}
run(
key: string,
dto: ReportQueryDto,
allowedDirections: Freight.ScheduleTradeDirection[] | null,
): Promise<ReportResult> {
if (!(key in REPORT_QUERIES)) {
throw new NotFoundException(`Unknown report: ${key}`);
}
// No default range: absent dates mean all time, so exports cover everything.
const to = dto.dateTo ? new Date(dto.dateTo) : null;
const from = dto.dateFrom ? new Date(dto.dateFrom) : null;
const filters: ReportFilters = {
dateFrom: from ? from.toISOString() : null,
// dateTo is inclusive in the API; queries treat the bound as exclusive.
dateTo: to ? new Date(to.getTime() + DAY_MS).toISOString() : null,
granularity: dto.granularity ?? 'day',
companyIds: list(dto.companyIds),
routeIds: list(dto.routeIds),
yardIds: list(dto.yardIds),
cargoTypeIds: list(dto.cargoTypeIds),
statuses: list(dto.statuses),
directions: scopedDirections(allowedDirections, dto.direction),
freightType: dto.freightType ?? null,
};
return this.repository.run(key, filters);
}
}

View File

@@ -47,6 +47,55 @@ const perm = (id: string, key: string, en: string): FreightPermissionSeed => ({
applicationKey: EDR_FREIGHT_APP_KEY,
});
/**
* One entry per report definition (see modules/reports/definitions). Each
* gets its own permission, gated behind the `reports:view` master key that
* opens the Reports section itself.
* Keep new keys at the END: reportPermId derives ids from list index, so a
* mid-list insert would shift ids already seeded for later keys.
*/
export const REPORT_KEYS = [
"bookings-list",
"revenue-by-customer",
"aging-receivables",
"contract-utilization",
"wagon-fleet-status",
"wagon-status-duration",
"wagon-requests",
"locomotive-fleet-status",
"booking-status-breakdown",
"train-schedule-status",
"train-turnaround",
"wagon-teu-utilization",
"loaded-capacity",
"global-logistics-wagons",
"customer-status",
"contract-lifecycle",
"customs-documents",
"invoicing-pipeline",
"first-last-mile-bookings",
"invoices-by-status",
"payments-by-status",
"revenue-summary",
"cargo-summary",
] as const;
export type ReportKey = (typeof REPORT_KEYS)[number];
export const reportPermissionKey = (key: ReportKey): string =>
`edr_freight_app:reports:${key.replace(/-/g, "_")}:view`;
const reportPermId = (index: number): string =>
`a4f00002-0001-4000-8000-${(index + 1).toString(16).padStart(12, "0")}`;
const titleCase = (slug: string): string =>
slug.split("-").map((w) => w[0].toUpperCase() + w.slice(1)).join(" ");
export const REPORT_PERMISSIONS: FreightPermissionSeed[] = REPORT_KEYS.map(
(key, index) =>
perm(reportPermId(index), reportPermissionKey(key), `Report: ${titleCase(key)}`),
);
export const BOOKING_PERMISSIONS: FreightPermissionSeed[] = [
perm(
"a1000001-0001-4000-8000-000000000001",
@@ -1516,6 +1565,7 @@ export const NOTIFICATION_PERMISSIONS: FreightPermissionSeed[] = [
];
export const ADVANCED_BACKOFFICE_PERMISSIONS: FreightPermissionSeed[] = [
...REPORT_PERMISSIONS,
...CUSTOMER_PERMISSIONS,
...FINANCE_PERMISSIONS,
...MILE_PERMISSIONS,
@@ -2003,6 +2053,7 @@ export const FREIGHT_PERMS = {
},
reports: {
view: "edr_freight_app:reports:view",
report: (key: ReportKey): string => reportPermissionKey(key),
},
staff: {
users: {
@@ -2147,11 +2198,17 @@ const FLEET_GRANULAR_KEYS: string[] = [
FREIGHT_PERMS.consignments.create,
];
const allReportKeys = (): string[] => REPORT_KEYS.map((k) => reportPermissionKey(k));
// Everyone who works the booking desk also opens the overview dashboard and
// the canned reports — granted alongside bookings:view in every preset below.
// Each report also carries its own key (see REPORT_PERMISSIONS); spreading
// allReportKeys() here keeps every existing preset seeing every report, same
// as when reports:view alone gated the whole section.
const STAFF_DASHBOARD_KEYS: string[] = [
FREIGHT_PERMS.overview.view,
FREIGHT_PERMS.reports.view,
...allReportKeys(),
];
// Notification desks — recipient selectors, not access. A preset gets a desk

View File

@@ -1,4 +1,5 @@
import { useEffect } from "react";
import { useEffect, useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import {
Navigate,
Outlet,
@@ -10,6 +11,7 @@ import {
} from "react-router-dom";
import { FreightDashboardLayout, type SidebarItem } from "@/components/layout";
import { api } from "@/services/api";
import { useAuth } from "./auth/useAuth";
import LoadingScreen from "./components/LoadingScreen";
import LoginPage from "./pages/auth/LoginPage";
@@ -35,15 +37,13 @@ import DocumentClearanceListPage from "./pages/bookings/DocumentClearanceListPag
import CustomerDetailPage from "./pages/customers/CustomerDetailPage";
import CustomersPage from "./pages/customers/CustomersPage";
import InvoiceDetailPage from "./pages/invoices/InvoiceDetailPage";
import InvoicesPage from "./pages/invoices/InvoicesPage";
import UsdPaymentsPage from "./pages/invoices/UsdPaymentsPage";
import FinanceHubPage from "./pages/invoices/FinanceHubPage";
import MyProfilePage from "./pages/dashboard/MyProfilePage";
import OverviewPage from "./pages/dashboard/OverviewPage";
import ReportsHubPage from "./pages/reports/ReportsHubPage";
import ReportsIndexRedirect from "./pages/reports/ReportsIndexRedirect";
import ReportPage from "./pages/reports/ReportPage";
import AuditLogsPage from "./pages/AuditLogsPage";
import AiBookingMockTestPage from "./pages/ai/AiBookingMockTestPage";
import PaymentsPage from "./pages/payments/PaymentsPage";
//import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage";
import { RequirePermission } from "./components/auth/RequirePermission";
import { FREIGHT_PERMS } from "./lib/permissions";
@@ -82,7 +82,6 @@ import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPa
import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage";
import TradeAccessPage from "./pages/configuration/TradeAccessPage";
import ExchangeRateSettingsCard from "./pages/settings/ExchangeRateSettingsCard";
import ContractValidityPeriodsPage from "./pages/configuration/ContractValidityPeriodsPage";
import FirstMilePage from "./pages/operations/FirstMilePage";
import LastMilePage from "./pages/operations/LastMilePage";
import TrainDetailPage from "./pages/trains/TrainDetailPage";
@@ -140,8 +139,18 @@ const DashboardShell = () => {
const demoItems: SidebarItem[] = [];
const { data: reportCatalog } = useQuery(api.reports.catalog.queryOptions());
const reportItems: SidebarItem[] = useMemo(
() =>
(reportCatalog ?? []).map((report) => ({
label: report.title,
href: `/dashboard/reports/${report.key}`,
})),
[reportCatalog],
);
const sidebarSections = filterSidebarByPermission(
buildSidebarSections(demoItems),
buildSidebarSections(demoItems, reportItems),
user,
);
const displayName = user?.name?.en || user?.username || user?.email || "User";
@@ -200,12 +209,43 @@ const App = () => {
{/* Landing is per-user: /dashboard/overview is gated on overview:view, so
a fixed target strands anyone without that key on a blank page. */}
<Route path="/" element={<Navigate to={landingPath} replace />} />
<Route path="/dashboard" element={<Navigate to={landingPath} replace />} />
<Route
path="/dashboard"
element={<Navigate to={landingPath} replace />}
/>
<Route path="/dashboard" element={<DashboardShell />}>
<Route path="overview" element={<RequirePermission permission={FREIGHT_PERMS.overview.view}><OverviewPage /></RequirePermission>} />
<Route path="reports" element={<RequirePermission permission={FREIGHT_PERMS.reports.view}><ReportsHubPage /></RequirePermission>} />
<Route path="reports/:reportKey" element={<RequirePermission permission={FREIGHT_PERMS.reports.view}><ReportPage /></RequirePermission>} />
<Route path="audit-logs" element={<RequirePermission permission={FREIGHT_PERMS.auditLog.view}><AuditLogsPage /></RequirePermission>} />
<Route
path="overview"
element={
<RequirePermission permission={FREIGHT_PERMS.overview.view}>
<OverviewPage />
</RequirePermission>
}
/>
<Route
path="reports"
element={
<RequirePermission permission={FREIGHT_PERMS.reports.view}>
<ReportsIndexRedirect />
</RequirePermission>
}
/>
<Route
path="reports/:reportKey"
element={
<RequirePermission permission={FREIGHT_PERMS.reports.view}>
<ReportPage />
</RequirePermission>
}
/>
<Route
path="audit-logs"
element={
<RequirePermission permission={FREIGHT_PERMS.auditLog.view}>
<AuditLogsPage />
</RequirePermission>
}
/>
{/* Dev/testing page for the mock AI booking assistant. */}
<Route
path="ai-booking-mock-test"
@@ -217,16 +257,28 @@ const App = () => {
/>
<Route path="profile" element={<MyProfilePage />} />
<Route path="booking-requests" element={<RequirePermission permission={FREIGHT_PERMS.bookings.view}><BookingRequestsPage /></RequirePermission>} />
<Route
path="payments"
path="booking-requests"
element={
<RequirePermission permission={FREIGHT_PERMS.payments.view}>
<PaymentsPage />
<RequirePermission permission={FREIGHT_PERMS.bookings.view}>
<BookingRequestsPage />
</RequirePermission>
}
/>
{/* Payments used to be its own page; it's now the "payments" tab on
the merged Invoices hub. Old bookmarks/links still land there. */}
<Route
path="payments"
element={<Navigate to="/dashboard/invoices?tab=payments" replace />}
/>
<Route
path="support"
element={
<RequirePermission permission={FREIGHT_PERMS.support.agentView}>
<SupportInboxPage />
</RequirePermission>
}
/>
<Route path="support" element={<RequirePermission permission={FREIGHT_PERMS.support.agentView}><SupportInboxPage /></RequirePermission>} />
<Route
path="customers"
element={
@@ -243,21 +295,27 @@ const App = () => {
</RequirePermission>
}
/>
{/* Merged Invoices / Payments / USD Payments hub — tabs switch via
?tab=invoices|payments|usd-payments (default invoices). Access is
OR'd across both keys so a user with just one still gets in; each
tab hides itself if the user lacks the permission it used to be
routed on. */}
<Route
path="invoices"
element={
<RequirePermission permission={FREIGHT_PERMS.invoices.view}>
<InvoicesPage />
<RequirePermission
permission={[
FREIGHT_PERMS.invoices.view,
FREIGHT_PERMS.payments.view,
]}
>
<FinanceHubPage />
</RequirePermission>
}
/>
<Route
path="usd-payments"
element={
<RequirePermission permission={FREIGHT_PERMS.invoices.view}>
<UsdPaymentsPage />
</RequirePermission>
}
element={<Navigate to="/dashboard/invoices?tab=usd-payments" replace />}
/>
<Route
path="invoices/:id"
@@ -267,7 +325,14 @@ const App = () => {
</RequirePermission>
}
/>
<Route path="booking-requests/new" element={<RequirePermission permission={FREIGHT_PERMS.bookings.view}><NewBookingPage /></RequirePermission>} />
<Route
path="booking-requests/new"
element={
<RequirePermission permission={FREIGHT_PERMS.bookings.view}>
<NewBookingPage />
</RequirePermission>
}
/>
<Route
path="wagon-cancellations"
element={
@@ -476,25 +541,195 @@ const App = () => {
path="bookings/:id/milestones"
element={<BookingMilestonesRedirect />}
/>
<Route path="warehouses" element={<RequirePermission permission={FREIGHT_PERMS.warehouses.view}><WarehouseListPage /></RequirePermission>} />
<Route path="warehouses/:id" element={<RequirePermission permission={FREIGHT_PERMS.warehouses.view}><WarehouseDetailPage /></RequirePermission>} />
<Route path="warehouse-inventory" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><WarehouseInventoryPage /></RequirePermission>} />
<Route path="import-warehouse" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><ImportWarehouseFlowPage /></RequirePermission>} />
<Route path="export-warehouse" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><ExportWarehouseFlowPage /></RequirePermission>} />
<Route path="arrival-queue" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><ArrivalQueuePage /></RequirePermission>} />
<Route path="loading-queue" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><LoadingQueuePage /></RequirePermission>} />
<Route path="intercity" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><IntercityPage /></RequirePermission>} />
<Route path="trucks-on-site" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><TrucksOnSitePage /></RequirePermission>} />
<Route path="import-trucks" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><ImportTrucksPage /></RequirePermission>} />
<Route path="container-returns" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><ContainerReturnsPage /></RequirePermission>} />
<Route path="loaded-inventory" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><LoadedInventoryPage /></RequirePermission>} />
<Route path="dispatch-queue" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><DispatchQueuePage /></RequirePermission>} />
<Route path="export-djibouti-unloading" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><ExportDjiboutiUnloadingQueuePage /></RequirePermission>} />
<Route path="interchange-documents" element={<RequirePermission permission={FREIGHT_PERMS.interchangeDocuments.view}><InterchangeDocumentsPage /></RequirePermission>} />
<Route path="inventory-inquiry" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><InventoryInquiryPage /></RequirePermission>} />
<Route path="warehouse-rules" element={<RequirePermission permission={[FREIGHT_PERMS.warehouseAllocationRules.view, FREIGHT_PERMS.warehouseFeeRules.view]}><WarehouseRulesPage /></RequirePermission>} />
<Route path="warehouse-fee-invoices" element={<RequirePermission permission={FREIGHT_PERMS.warehouseFeeInvoices.view}><WarehouseInvoicesPage /></RequirePermission>} />
<Route path="warehouse-dashboard" element={<RequirePermission permission={FREIGHT_PERMS.warehouseDashboard.view}><WarehouseDashboardPage /></RequirePermission>} />
<Route
path="warehouses"
element={
<RequirePermission permission={FREIGHT_PERMS.warehouses.view}>
<WarehouseListPage />
</RequirePermission>
}
/>
<Route
path="warehouses/:id"
element={
<RequirePermission permission={FREIGHT_PERMS.warehouses.view}>
<WarehouseDetailPage />
</RequirePermission>
}
/>
<Route
path="warehouse-inventory"
element={
<RequirePermission
permission={FREIGHT_PERMS.warehouseInventory.view}
>
<WarehouseInventoryPage />
</RequirePermission>
}
/>
<Route
path="import-warehouse"
element={
<RequirePermission
permission={FREIGHT_PERMS.warehouseInventory.view}
>
<ImportWarehouseFlowPage />
</RequirePermission>
}
/>
<Route
path="export-warehouse"
element={
<RequirePermission
permission={FREIGHT_PERMS.warehouseInventory.view}
>
<ExportWarehouseFlowPage />
</RequirePermission>
}
/>
<Route
path="arrival-queue"
element={
<RequirePermission
permission={FREIGHT_PERMS.warehouseInventory.view}
>
<ArrivalQueuePage />
</RequirePermission>
}
/>
<Route
path="loading-queue"
element={
<RequirePermission
permission={FREIGHT_PERMS.warehouseInventory.view}
>
<LoadingQueuePage />
</RequirePermission>
}
/>
<Route
path="intercity"
element={
<RequirePermission
permission={FREIGHT_PERMS.warehouseInventory.view}
>
<IntercityPage />
</RequirePermission>
}
/>
<Route
path="trucks-on-site"
element={
<RequirePermission
permission={FREIGHT_PERMS.warehouseInventory.view}
>
<TrucksOnSitePage />
</RequirePermission>
}
/>
<Route
path="import-trucks"
element={
<RequirePermission
permission={FREIGHT_PERMS.warehouseInventory.view}
>
<ImportTrucksPage />
</RequirePermission>
}
/>
<Route
path="container-returns"
element={
<RequirePermission
permission={FREIGHT_PERMS.warehouseInventory.view}
>
<ContainerReturnsPage />
</RequirePermission>
}
/>
<Route
path="loaded-inventory"
element={
<RequirePermission
permission={FREIGHT_PERMS.warehouseInventory.view}
>
<LoadedInventoryPage />
</RequirePermission>
}
/>
<Route
path="dispatch-queue"
element={
<RequirePermission
permission={FREIGHT_PERMS.warehouseInventory.view}
>
<DispatchQueuePage />
</RequirePermission>
}
/>
<Route
path="export-djibouti-unloading"
element={
<RequirePermission
permission={FREIGHT_PERMS.warehouseInventory.view}
>
<ExportDjiboutiUnloadingQueuePage />
</RequirePermission>
}
/>
<Route
path="interchange-documents"
element={
<RequirePermission
permission={FREIGHT_PERMS.interchangeDocuments.view}
>
<InterchangeDocumentsPage />
</RequirePermission>
}
/>
<Route
path="inventory-inquiry"
element={
<RequirePermission
permission={FREIGHT_PERMS.warehouseInventory.view}
>
<InventoryInquiryPage />
</RequirePermission>
}
/>
<Route
path="warehouse-rules"
element={
<RequirePermission
permission={[
FREIGHT_PERMS.warehouseAllocationRules.view,
FREIGHT_PERMS.warehouseFeeRules.view,
]}
>
<WarehouseRulesPage />
</RequirePermission>
}
/>
<Route
path="warehouse-fee-invoices"
element={
<RequirePermission
permission={FREIGHT_PERMS.warehouseFeeInvoices.view}
>
<WarehouseInvoicesPage />
</RequirePermission>
}
/>
<Route
path="warehouse-dashboard"
element={
<RequirePermission
permission={FREIGHT_PERMS.warehouseDashboard.view}
>
<WarehouseDashboardPage />
</RequirePermission>
}
/>
<Route
path="operations/train-scheduling"
@@ -773,7 +1008,9 @@ const App = () => {
<Route
path="file-settings"
element={
<RequirePermission permission={FREIGHT_PERMS.settings.fileUpload.view}>
<RequirePermission
permission={FREIGHT_PERMS.settings.fileUpload.view}
>
<FileUploadSettingsPage />
</RequirePermission>
}
@@ -781,7 +1018,9 @@ const App = () => {
<Route
path="dropdown-settings"
element={
<RequirePermission permission={FREIGHT_PERMS.settings.dropdown.view}>
<RequirePermission
permission={FREIGHT_PERMS.settings.dropdown.view}
>
<DropdownSettingsPage />
</RequirePermission>
}
@@ -796,9 +1035,7 @@ const App = () => {
<Route
path="stamp-settings"
element={
<RequirePermission
permission={FREIGHT_PERMS.settings.stamp.view}
>
<RequirePermission permission={FREIGHT_PERMS.settings.stamp.view}>
<CompanyStampSettingsPage />
</RequirePermission>
}
@@ -876,7 +1113,9 @@ const App = () => {
<Route
path="configuration/exchange-rate"
element={
<RequirePermission permission={FREIGHT_PERMS.settings.exchangeRate.view}>
<RequirePermission
permission={FREIGHT_PERMS.settings.exchangeRate.view}
>
<div className="p-4">
<ExchangeRateSettingsCard />
</div>
@@ -944,4 +1183,3 @@ function LegacyGlEthiopiaClearanceRedirect() {
}
export default App;

View File

@@ -1,44 +1,16 @@
import type { LucideIcon } from "lucide-react";
import {
Building2,
FileCheck,
Mail,
MapPin,
Phone,
User,
} from "lucide-react";
import { Group, Stack, Text, Divider } from "@mantine/core";
import { Building2, FileCheck, Mail, MapPin, Phone, User } from "lucide-react";
import { Text } from "@mantine/core";
import type { BookingDetail } from "@/types/booking";
import { LinkedEntityCard } from "@/components/detail";
import type { FieldRowProps } from "@/components/detail";
import { SectionCard } from "./SectionCard";
interface InfoRowProps {
icon: LucideIcon;
label: string;
value?: string | null;
}
function InfoRow({ icon: Icon, label, value }: InfoRowProps) {
return (
<Group justify="space-between" wrap="nowrap" py={6} gap="md">
<Group gap="xs" wrap="nowrap">
<Icon size={15} color="var(--mantine-color-gray-5)" />
<Text size="sm" c="dimmed">
{label}
</Text>
</Group>
<Text size="sm" fw={600} ta="right" style={{ minWidth: 0 }} truncate>
{value || "—"}
</Text>
</Group>
);
}
export interface BookingCompanyCardProps {
booking: BookingDetail;
}
/** Customer (company) information for the booking. */
/** Customer (company) quick info for the booking, linking to its detail page. */
export function BookingCompanyCard({ booking }: BookingCompanyCardProps) {
const company = booking.company;
@@ -46,11 +18,9 @@ export function BookingCompanyCard({ booking }: BookingCompanyCardProps) {
if (!company && booking.isGovernment) {
return (
<SectionCard icon={Building2} title="Customer" accent="blue">
<InfoRow
icon={Building2}
label="Government"
value={booking.governmentInstitution}
/>
<Text size="sm" fw={600}>
{booking.governmentInstitution ?? "Government"}
</Text>
</SectionCard>
);
}
@@ -67,36 +37,24 @@ export function BookingCompanyCard({ booking }: BookingCompanyCardProps) {
const companyName = company.companyName ?? company.name ?? company.label;
const rows: InfoRowProps[] = [
const rows: FieldRowProps[] = [
{ icon: FileCheck, label: "TIN", value: company.tin },
{ icon: Mail, label: "Email", value: company.email },
{ icon: Phone, label: "Phone", value: company.phone },
{ icon: MapPin, label: "Address", value: company.address },
{ icon: User, label: "Contact person", value: company.contactPersonName },
{ icon: Phone, label: "Contact phone", value: company.contactPersonPhone },
].filter((r) => r.value);
];
return (
<SectionCard
<LinkedEntityCard
icon={Building2}
title="Customer"
subtitle={companyName}
name={companyName ?? "Unnamed company"}
to={company.id ? `/dashboard/customers/${company.id}` : null}
accent="blue"
>
<Stack gap={0}>
{rows.length === 0 ? (
<Text size="sm" c="dimmed">
No additional company details available.
</Text>
) : (
rows.map((row, index) => (
<div key={row.label}>
{index > 0 && <Divider color="var(--mantine-color-gray-2)" />}
<InfoRow {...row} />
</div>
))
)}
</Stack>
</SectionCard>
rows={rows}
emptyMessage="No additional company details available."
/>
);
}

View File

@@ -0,0 +1,49 @@
import { Anchor as AnchorIcon } from "lucide-react";
import { Code } from "@mantine/core";
import type { BookingDetail } from "@/types/booking";
import { LinkedEntityCard } from "@/components/detail";
import type { FieldRowProps } from "@/components/detail";
export interface BookingContractCardProps {
booking: BookingDetail;
}
/** Parent contract quick info for the booking, linking to its detail page. */
export function BookingContractCard({ booking }: BookingContractCardProps) {
if (!booking.contractId || !booking.contractReference) return null;
const rows: FieldRowProps[] = [
{
label: "Kind",
value: booking.contractKind === "GENERAL" ? "General" : "One-time",
},
];
return (
<LinkedEntityCard
icon={AnchorIcon}
title="Contract"
name={booking.contractReference}
to={`/dashboard/contract-requests/${booking.contractId}`}
accent="teal"
rows={rows}
footer={
booking.contractSummary ? (
<Code
block
mt={4}
style={{
maxHeight: 220,
overflow: "auto",
whiteSpace: "pre-wrap",
background: "var(--mantine-color-gray-0)",
}}
>
{booking.contractSummary}
</Code>
) : undefined
}
/>
);
}

View File

@@ -1,27 +0,0 @@
import { Anchor } from "lucide-react";
import { Code } from "@mantine/core";
import { SectionCard } from "./SectionCard";
export interface BookingContractSummaryCardProps {
summary: string;
}
/** Generated contract terms, shown verbatim. */
export function BookingContractSummaryCard({ summary }: BookingContractSummaryCardProps) {
return (
<SectionCard icon={Anchor} title="Contract summary" accent="teal">
<Code
block
style={{
maxHeight: 256,
overflow: "auto",
whiteSpace: "pre-wrap",
background: "var(--mantine-color-gray-0)",
}}
>
{summary}
</Code>
</SectionCard>
);
}

View File

@@ -1,3 +1,4 @@
import type { ReactNode } from "react";
import { Download, Truck } from "lucide-react";
import { Button, Group, SimpleGrid, Stack, Text } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
@@ -11,14 +12,26 @@ import { MetricTile } from "./MetricTile";
export interface BookingMileServicesCardProps {
booking: BookingDetail;
/** Export handover-mode control — how the cargo reaches the train. Lives
* here because it's the other "how does the cargo physically travel" fact;
* shown even when no mile address is set, since EXPORT bookings still need
* the choice made. */
handoverSection?: ReactNode;
}
/**
* First / last mile addresses, plus the stored last-mile contract reference
* (signed status + PDF download) for Truck & Machinery once a request on this
* booking is approved. Renders nothing when neither address is present.
* First / last mile addresses, plus the export handover control and the
* stored last-mile contract reference (signed status + PDF download) for
* Truck & Machinery once a request on this booking is approved. Renders
* nothing when none of the three are present.
*/
export function BookingMileServicesCard({ booking }: BookingMileServicesCardProps) {
export function BookingMileServicesCard({
booking,
handoverSection,
}: BookingMileServicesCardProps) {
const hasAddresses =
Boolean(booking.firstMilePickupAddress) || Boolean(booking.lastMileDeliveryAddress);
const { data: requestsResponse } = useQuery({
queryKey: QUERY_KEYS.LAST_MILE_REQUESTS.list({ bookingId: booking.id }),
queryFn: async () =>
@@ -29,7 +42,7 @@ export function BookingMileServicesCard({ booking }: BookingMileServicesCardProp
(r) => r.status === "APPROVED",
);
if (!booking.firstMilePickupAddress && !booking.lastMileDeliveryAddress) {
if (!hasAddresses && !handoverSection) {
return null;
}
@@ -46,40 +59,45 @@ export function BookingMileServicesCard({ booking }: BookingMileServicesCardProp
return (
<SectionCard icon={Truck} title="Mile services" accent="grape">
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="sm">
{booking.firstMilePickupAddress && (
<MetricTile label="First mile pickup" value={booking.firstMilePickupAddress} />
<Stack gap="md">
{hasAddresses && (
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="sm">
{booking.firstMilePickupAddress && (
<MetricTile label="First mile pickup" value={booking.firstMilePickupAddress} />
)}
{booking.lastMileDeliveryAddress && (
<MetricTile label="Last mile delivery" value={booking.lastMileDeliveryAddress} />
)}
</SimpleGrid>
)}
{booking.lastMileDeliveryAddress && (
<MetricTile label="Last mile delivery" value={booking.lastMileDeliveryAddress} />
{approvedRequest && (
<Group justify="space-between" align="center" wrap="wrap">
<Stack gap={0}>
<Text size="sm" fw={600}>
Last-mile contract
</Text>
<Text size="xs" c={approvedRequest.customerSignedAt ? "green.8" : "orange.8"}>
{approvedRequest.customerSignedAt
? `Signed ${new Date(approvedRequest.customerSignedAt).toLocaleDateString()}${
approvedRequest.signerDisplayName
? ` by ${approvedRequest.signerDisplayName}`
: ""
}`
: "Awaiting customer signature"}
</Text>
</Stack>
<Button
size="xs"
variant="light"
leftSection={<Download size={14} />}
onClick={() => void downloadContract()}
>
Download PDF
</Button>
</Group>
)}
</SimpleGrid>
{approvedRequest && (
<Group justify="space-between" align="center" wrap="wrap" mt="sm">
<Stack gap={0}>
<Text size="sm" fw={600}>
Last-mile contract
</Text>
<Text size="xs" c={approvedRequest.customerSignedAt ? "green.8" : "orange.8"}>
{approvedRequest.customerSignedAt
? `Signed ${new Date(approvedRequest.customerSignedAt).toLocaleDateString()}${
approvedRequest.signerDisplayName
? ` by ${approvedRequest.signerDisplayName}`
: ""
}`
: "Awaiting customer signature"}
</Text>
</Stack>
<Button
size="xs"
variant="light"
leftSection={<Download size={14} />}
onClick={() => void downloadContract()}
>
Download PDF
</Button>
</Group>
)}
{handoverSection}
</Stack>
</SectionCard>
);
}

View File

@@ -1,258 +0,0 @@
import type { ReactNode } from "react";
import {
ArrowLeft,
Building2,
Calendar,
Clock,
Container as ContainerIcon,
Flame,
RefreshCw,
Wallet,
Weight,
} from "lucide-react";
import {
Button,
Group,
Paper,
Stack,
Text,
ThemeIcon,
Title,
} from "@mantine/core";
import type { LucideIcon } from "lucide-react";
import type { BookingDetail } from "@/types/booking";
import { cargoTonsAndItems } from "@/utils/cargoWeight";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
import { ContractReferenceLink } from "@/components/bookings/ContractReferenceLink";
import { SchedulingStatusBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
import { NextStepBanner } from "@/components/bookings/NextStepBanner";
import { formatDate } from "./booking-detail.styles";
export interface BookingRequestHeroProps {
booking: BookingDetail;
customerLabel: string;
onBack: () => void;
onRefresh: () => void;
isFetching?: boolean;
}
/** Top hero for the request detail page: identity, status, next step, key figures. */
export function BookingRequestHero({
booking,
customerLabel,
onBack,
onRefresh,
isFetching,
}: BookingRequestHeroProps) {
const amount = Number(booking.totalAmount);
const containers = booking.bookingContainers ?? [];
const containerCount = containers.reduce(
(sum, c) => sum + Number(c.quantity ?? 0),
0,
);
const { tons: weight, items: itemCount } = cargoTonsAndItems(booking);
return (
<Paper
radius="xl"
p="xl"
style={{ position: "relative", overflow: "hidden" }}
>
<Stack gap="lg" style={{ position: "relative" }}>
<Group justify="space-between" align="flex-start" wrap="wrap">
<Button
variant="default"
size="compact-sm"
radius="lg"
leftSection={<ArrowLeft size={16} />}
onClick={onBack}
>
Back to list
</Button>
<Button
variant="light"
color="edr-green"
size="compact-sm"
radius="lg"
leftSection={<RefreshCw size={15} />}
loading={isFetching}
onClick={onRefresh}
>
Refresh
</Button>
</Group>
<Group justify="space-between" align="flex-start" wrap="wrap" gap="lg">
<Stack gap="sm" style={{ flex: 1, minWidth: 0 }}>
<Text
size="xs"
fw={700}
tt="uppercase"
style={{ letterSpacing: 1, color: "#B26C09" }}
>
Booking reference
</Text>
<Group gap="sm" align="center" wrap="wrap">
<Stack gap={2} miw={0}>
<Title order={2} fw={700} style={{ letterSpacing: "-0.4px" }}>
{booking.reference}
</Title>
<ContractReferenceLink
contractId={booking.contractId}
contractReference={booking.contractReference}
/>
</Stack>
<BookingStatusBadge status={booking.status} />
<BookingPriorityBadge score={booking.priorityScore} />
{booking.schedulingStatus ? (
<SchedulingStatusBadge status={booking.schedulingStatus} />
) : null}
</Group>
{booking.holdExpiresAt && booking.schedulingStatus === "HOLDING" ? (
<Text size="xs" c="orange.7">
Hold expires {new Date(booking.holdExpiresAt).toLocaleString()}
</Text>
) : null}
<Group gap="lg" mt={4}>
<MetaItem icon={Building2} text={customerLabel} strong />
<MetaItem
icon={Calendar}
text={`Scheduled ${booking.scheduledDate}`}
/>
<MetaItem
icon={Clock}
text={`Created ${formatDate(booking.createdAt)}`}
/>
</Group>
</Stack>
</Group>
{booking.nextStep ? (
<Paper
radius="lg"
p={4}
maw={640}
style={{
background: "var(--mantine-color-gray-0)",
border: "1px solid var(--mantine-color-gray-2)",
}}
>
<NextStepBanner nextStep={booking.nextStep} />
</Paper>
) : null}
<Group grow gap="md" align="stretch" wrap="wrap">
<HeroTile
icon={Wallet}
label="Total value"
value={`${booking.paymentCurrency} ${amount.toLocaleString(
undefined,
{
minimumFractionDigits: 2,
},
)}`}
hint={booking.paymentStatus}
accent="edr-green"
/>
<HeroTile
icon={Weight}
label="Cargo weight"
value={`${weight} T`}
hint={itemCount != null ? `${itemCount} items` : "VGM total"}
accent="blue"
/>
<HeroTile
icon={ContainerIcon}
label="Containers"
value={containerCount || "—"}
hint={`${containers.length} line${containers.length === 1 ? "" : "s"}`}
accent="teal"
/>
<HeroTile
icon={Flame}
label="Priority score"
value={booking.priorityScore ?? 0}
hint={booking.tradeDirection}
accent="orange"
/>
</Group>
</Stack>
</Paper>
);
}
function MetaItem({
icon: Icon,
text,
strong,
}: {
icon: LucideIcon;
text: ReactNode;
strong?: boolean;
}) {
return (
<Group gap={6} wrap="nowrap">
<Icon size={14} color="var(--mantine-color-gray-5)" />
<Text size="sm" fw={strong ? 600 : 400} c={strong ? "dark" : "dimmed"}>
{text}
</Text>
</Group>
);
}
function HeroTile({
icon: Icon,
label,
value,
hint,
accent = "edr-green",
}: {
icon: LucideIcon;
label: string;
value: ReactNode;
hint?: ReactNode;
accent?: string;
}) {
return (
<Paper
p="md"
radius="lg"
style={{
flex: "1 1 160px",
minWidth: 150,
background: "var(--mantine-color-gray-0)",
border: "1px solid var(--mantine-color-gray-2)",
}}
>
<Group gap="sm" wrap="nowrap" align="flex-start">
<ThemeIcon size={36} radius="md" variant="light" color={accent}>
<Icon size={18} />
</ThemeIcon>
<Stack gap={2} style={{ minWidth: 0 }}>
<Text
size="xs"
fw={600}
tt="uppercase"
c="dimmed"
style={{ letterSpacing: 0.4 }}
>
{label}
</Text>
<Text fw={700} size="lg" lh={1.1} style={{ whiteSpace: "nowrap" }}>
{value}
</Text>
{hint ? (
<Text size="xs" c="dimmed" truncate>
{hint}
</Text>
) : null}
</Stack>
</Group>
</Paper>
);
}

View File

@@ -16,10 +16,9 @@ export * from "./BookingPaymentCard";
export * from "./BookingPaymentCountdownCard";
export * from "./BookingFactsCard";
export * from "./BookingDocumentsCard";
export * from "./BookingRequestHero";
export * from "./BookingRouteServiceCard";
export * from "./BookingMileServicesCard";
export * from "./BookingCargoCard";
export * from "./BookingContractSummaryCard";
export * from "./BookingContractCard";
export * from "./BookingCompanyCard";
export * from "./BookingSchedulingWindowCard";

View File

@@ -2,6 +2,7 @@ import { Button, Group, TextInput } from "@mantine/core";
import { DatePickerInput } from "@mantine/dates";
import { Search, X } from "lucide-react";
import type { ReactNode } from "react";
import { getDateRangePresets } from "./dateRangePresets";
export interface ListControlsProps {
search: string;
@@ -54,28 +55,19 @@ const ListControls = ({
)}
{showDateRange && (
<>
<DatePickerInput
label={dateLabel ? `${dateLabel} from` : "From"}
placeholder="Any"
value={dateFrom}
onChange={onDateFromChange}
// Cannot start after it ends — the picker refuses the invalid range
// instead of silently returning nothing.
maxDate={dateTo ?? undefined}
clearable
w={150}
/>
<DatePickerInput
label={dateLabel ? `${dateLabel} to` : "To"}
placeholder="Any"
value={dateTo}
onChange={onDateToChange}
minDate={dateFrom ?? undefined}
clearable
w={150}
/>
</>
<DatePickerInput
type="range"
label={dateLabel ?? "Date range"}
placeholder="Any"
value={[dateFrom, dateTo]}
onChange={([from, to]) => {
onDateFromChange(from);
onDateToChange(to);
}}
presets={getDateRangePresets()}
clearable
w={230}
/>
)}
{children}

View File

@@ -0,0 +1,41 @@
import {
format,
startOfDay,
endOfDay,
startOfMonth,
endOfMonth,
startOfYear,
subDays,
subMonths,
} from "date-fns";
import type { DatePickerPreset } from "@mantine/dates";
const iso = (date: Date) => format(date, "yyyy-MM-dd");
/**
* Shared "Today / Last 7 days / …" presets for every Mantine
* `<DatePickerInput type="range" presets={getDateRangePresets()} />` in the app,
* so every from/to filter offers the same shortcuts. Computed fresh per call
* (not a module-level constant) so "Today" stays today.
*/
export function getDateRangePresets(): DatePickerPreset<"range">[] {
const today = new Date();
return [
{ label: "Today", value: [iso(startOfDay(today)), iso(endOfDay(today))] },
{
label: "Yesterday",
value: [iso(startOfDay(subDays(today, 1))), iso(endOfDay(subDays(today, 1)))],
},
{ label: "Last 7 days", value: [iso(startOfDay(subDays(today, 6))), iso(endOfDay(today))] },
{ label: "Last 30 days", value: [iso(startOfDay(subDays(today, 29))), iso(endOfDay(today))] },
{ label: "This month", value: [iso(startOfMonth(today)), iso(endOfDay(today))] },
{
label: "Last month",
value: [
iso(startOfMonth(subMonths(today, 1))),
iso(endOfMonth(subMonths(today, 1))),
],
},
{ label: "Year to date", value: [iso(startOfYear(today)), iso(endOfDay(today))] },
];
}

View File

@@ -0,0 +1,16 @@
import { Badge } from "@mantine/core";
const STATUS_COLOR: Record<string, string> = {
PENDING: "edr-green",
ACCEPTED: "blue",
REJECTED: "red",
};
/** Status of a customer-submitted shipment (booking) request against a contract. */
export function BookingRequestStatusBadge({ status }: { status: string }) {
return (
<Badge variant="light" radius="sm" color={STATUS_COLOR[status] ?? "gray"}>
{status}
</Badge>
);
}

View File

@@ -30,6 +30,7 @@ import { clearanceWorkflowFileLabel } from "@edr/types";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { detailStyles } from "@/components/bookings/detail/booking-detail.styles";
import { LinkedEntityCard } from "@/components/detail";
import { customersService } from "@/services/customers.service";
type ContractFile = NonNullable<Freight.IContract["files"]>[number];
@@ -141,25 +142,23 @@ export function ContractCustomerCard({
return (
<Stack gap="lg">
<SectionCard
<LinkedEntityCard
icon={Building2}
title="Customer"
subtitle={company.name}
name={company.name ?? "Unnamed company"}
to={`/dashboard/customers/${company.id}`}
accent="blue"
>
<InfoRows
rows={[
{ icon: FileCheck, label: "TIN", value: company.tin },
{ icon: Hash, label: "VAT number", value: company.vatNumber },
{ icon: ShieldCheck, label: "FAN number", value: company.fanNumber },
{ icon: Globe, label: "Country", value: company.country },
{ icon: Mail, label: "Email", value: company.email },
{ icon: Phone, label: "Phone", value: company.phone },
{ icon: MapPin, label: "Address", value: company.address },
{ icon: Globe, label: "Website", value: company.website },
]}
/>
</SectionCard>
rows={[
{ icon: FileCheck, label: "TIN", value: company.tin },
{ icon: Hash, label: "VAT number", value: company.vatNumber },
{ icon: ShieldCheck, label: "FAN number", value: company.fanNumber },
{ icon: Globe, label: "Country", value: company.country },
{ icon: Mail, label: "Email", value: company.email },
{ icon: Phone, label: "Phone", value: company.phone },
{ icon: MapPin, label: "Address", value: company.address },
{ icon: Globe, label: "Website", value: company.website },
]}
/>
<SectionCard icon={User} title="Contact person" accent="teal">
<InfoRows

View File

@@ -12,56 +12,14 @@ import {
User,
Warehouse,
} from "lucide-react";
import { Badge, Box, Divider, Group, Stack, Text } from "@mantine/core";
import { Badge, Box, Group, Stack, Text } from "@mantine/core";
import type { Freight } from "@edr/types";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { LinkedEntityCard } from "@/components/detail";
type ReqContract = NonNullable<Freight.IBookingRequest["contract"]>;
interface InfoRowProps {
icon: LucideIcon;
label: string;
value?: string | null;
}
function InfoRow({ icon: Icon, label, value }: InfoRowProps) {
return (
<Group justify="space-between" wrap="nowrap" py={6} gap="md">
<Group gap="xs" wrap="nowrap">
<Icon size={15} color="var(--mantine-color-gray-5)" />
<Text size="sm" c="dimmed">
{label}
</Text>
</Group>
<Text size="sm" fw={600} ta="right" style={{ minWidth: 0 }} truncate>
{value || "—"}
</Text>
</Group>
);
}
function InfoRows({ rows }: { rows: InfoRowProps[] }) {
const visible = rows.filter((r) => r.value);
if (visible.length === 0) {
return (
<Text size="sm" c="dimmed">
No details available.
</Text>
);
}
return (
<Stack gap={0}>
{visible.map((row, i) => (
<div key={row.label}>
{i > 0 && <Divider color="var(--mantine-color-gray-2)" />}
<InfoRow {...row} />
</div>
))}
</Stack>
);
}
/** Customer (company) on the request's contract. */
export function RequestCustomerCard({ contract }: { contract?: ReqContract | null }) {
const company = contract?.company;
@@ -75,23 +33,21 @@ export function RequestCustomerCard({ contract }: { contract?: ReqContract | nul
);
}
return (
<SectionCard
<LinkedEntityCard
icon={Building2}
title="Customer"
subtitle={company.name ?? undefined}
name={company.name ?? "Unnamed company"}
to={company.id ? `/dashboard/customers/${company.id}` : null}
accent="blue"
>
<InfoRows
rows={[
{ icon: FileCheck, label: "TIN", value: company.tin },
{ icon: Mail, label: "Email", value: company.email },
{ icon: Phone, label: "Phone", value: company.phone },
{ icon: MapPin, label: "Address", value: company.address },
{ icon: User, label: "Contact", value: company.contactPersonName },
{ icon: Phone, label: "Contact phone", value: company.contactPersonPhone },
]}
/>
</SectionCard>
rows={[
{ icon: FileCheck, label: "TIN", value: company.tin },
{ icon: Mail, label: "Email", value: company.email },
{ icon: Phone, label: "Phone", value: company.phone },
{ icon: MapPin, label: "Address", value: company.address },
{ icon: User, label: "Contact", value: company.contactPersonName },
{ icon: Phone, label: "Contact phone", value: company.contactPersonPhone },
]}
/>
);
}
@@ -119,43 +75,41 @@ export function RequestContractSummaryCard({
}) {
if (!contract) return null;
return (
<SectionCard
<LinkedEntityCard
icon={FileText}
title="Contract"
subtitle={contract.reference}
name={contract.reference}
to={`/dashboard/contract-requests/${contract.id}`}
accent="grape"
>
<InfoRows
rows={[
{
icon: FileText,
label: "Kind",
value: contract.contractKind === "GENERAL" ? "General" : "One-time",
},
{
icon: Package,
label: "Cargo",
value: contract.freightType === "CONTAINER" ? "Container" : "Bulk",
},
{ icon: Ship, label: "Trade", value: titleCase(contract.tradeDirection) },
{ icon: FileCheck, label: "Currency", value: contract.paymentCurrency },
{
icon: FileCheck,
label: "Customs",
value: contract.customsClearingEnabled
? "Included (Global Logistics)"
: "Not included",
},
{
icon: FileText,
label: "Valid until",
value: contract.contractValidUntil
? fmtDate(contract.contractValidUntil)
: "Not active yet",
},
]}
/>
</SectionCard>
rows={[
{
icon: FileText,
label: "Kind",
value: contract.contractKind === "GENERAL" ? "General" : "One-time",
},
{
icon: Package,
label: "Cargo",
value: contract.freightType === "CONTAINER" ? "Container" : "Bulk",
},
{ icon: Ship, label: "Trade", value: titleCase(contract.tradeDirection) },
{ icon: FileCheck, label: "Currency", value: contract.paymentCurrency },
{
icon: FileCheck,
label: "Customs",
value: contract.customsClearingEnabled
? "Included (Global Logistics)"
: "Not included",
},
{
icon: FileText,
label: "Valid until",
value: contract.contractValidUntil
? fmtDate(contract.contractValidUntil)
: "Not active yet",
},
]}
/>
);
}

View File

@@ -1,151 +0,0 @@
import { Alert, Button, Loader, Modal, Radio, Stack, Text } from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import { KeyRound } from "lucide-react";
import { useState } from "react";
import { useAuth } from "@/auth/useAuth";
import { useToast } from "@/hooks/use-toast";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { api } from "@/services/api";
import type { Company, ResetChannel } from "@/types/customer";
export interface ResetPasswordActionProps {
company: Pick<Company, "id">;
}
/**
* Staff-triggered password reset. Sends a single-use link to the customer's
* primary contact; the customer opens it and picks their own new password. No
* credential is ever shown to or handled by staff.
*/
export default function ResetPasswordAction({
company,
}: ResetPasswordActionProps) {
const { user } = useAuth();
const { toast } = useToast();
const [opened, setOpened] = useState(false);
const [channel, setChannel] = useState<ResetChannel>("phone");
const allowed = hasPermission(user, FREIGHT_PERMS.customers.resetPassword);
// The destination is the primary contact's IAM account, not the company
// record — those are different fields and routinely hold different values, so
// showing `company.phone` here would tell staff the wrong number. Only fetched
// once the modal is open.
const targetQuery = useQuery(
api.customers.resetTarget.queryOptions({
input: { companyId: company.id },
enabled: allowed && opened,
}),
);
const target = targetQuery.data;
const { mutate, isPending } = useMutation(
api.customers.resetPassword.mutationOptions({
onSuccess: (result) => {
setOpened(false);
toast({
title: "Reset link sent",
description: `The customer can set a new password using the link sent to ${result.maskedTarget}. It expires in 24 hours.`,
});
},
onError: (error) => {
toast({
title: "Could not send reset link",
description: error.message,
variant: "destructive",
});
},
}),
);
if (!allowed) return null;
// SMS is domestic-only: a foreign number counts as unavailable, same as a
// missing one, so staff can't send a link that will never arrive.
const phoneUsable = !!target?.phone && target.phoneIsDomestic !== false;
const channelMissing =
!!target && (channel === "email" ? !target.email : !phoneUsable);
return (
<>
<Button
variant="default"
leftSection={<KeyRound size={16} />}
onClick={() => setOpened(true)}
>
Reset password
</Button>
<Modal
opened={opened}
onClose={() => setOpened(false)}
title="Send a password-reset link"
centered
>
<Stack gap="md">
<Text size="sm" c="dimmed">
We&apos;ll send a single-use link to this customer&apos;s primary
contact. They choose their own new password you will not see it.
The link expires in 24 hours.
</Text>
{targetQuery.isLoading ? (
<Stack align="center" py="md">
<Loader size="sm" />
</Stack>
) : targetQuery.isError ? (
<Alert color="red" variant="light">
{targetQuery.error.message}
</Alert>
) : target ? (
<>
<Radio.Group
value={channel}
onChange={(v) => setChannel(v as ResetChannel)}
label={`Send the link to ${target.name || "the primary contact"} via`}
>
<Stack gap="xs" mt="xs">
<Radio
value="phone"
label="SMS"
disabled={!phoneUsable}
description={
!target.phone
? "No phone number on this account"
: target.phoneIsDomestic === false
? `${target.phone} — foreign number, SMS unavailable; use email`
: target.phone
}
/>
<Radio
value="email"
label="Email"
disabled={!target.email}
description={
target.email ?? "No email address on this account"
}
/>
</Stack>
</Radio.Group>
<Text size="xs" c="dimmed">
These are the primary contact&apos;s own login details, which may
differ from the company contact details on the profile.
</Text>
<Button
color="edr-green"
loading={isPending}
disabled={channelMissing}
onClick={() => mutate({ companyId: company.id, channel })}
>
Send reset link
</Button>
</>
) : null}
</Stack>
</Modal>
</>
);
}

View File

@@ -19,10 +19,6 @@ export {
RequestDocumentChangeModal,
type RequestDocumentChangeModalProps,
} from "./RequestDocumentChangeModal";
export {
default as ResetPasswordAction,
type ResetPasswordActionProps,
} from "./ResetPasswordAction";
export { formatBytes, formatDate, formatMoney, humanize } from "./format";
export {
PersonCard,

View File

@@ -0,0 +1,63 @@
import type { ReactNode } from "react";
import type { LucideIcon } from "lucide-react";
import { ArrowUpRight } from "lucide-react";
import { Anchor, Group, Text } from "@mantine/core";
import { Link } from "react-router-dom";
export interface EntityLinkProps {
/** Route to the related record's detail page. Renders nothing if falsy — a
* link with no id would be a dead one (e.g. a government booking with no
* company). */
to?: string | null;
label: ReactNode;
icon?: LucideIcon;
/** Monospace label — for references/codes (e.g. "CT-2024-0117"). */
mono?: boolean;
size?: "xs" | "sm" | "md";
fw?: number;
className?: string;
}
/**
* Inline link to another record's detail page, with a small "go to" glyph so
* it reads as navigation rather than plain emphasis. `stopPropagation` matters
* wherever this sits inside a clickable table row (booking/invoice rows
* navigate on click) — without it a nested link races the row handler.
*/
export function EntityLink({
to,
label,
icon: Icon,
mono,
size = "sm",
fw = 600,
className,
}: EntityLinkProps) {
if (!to) {
return (
<Text size={size} fw={fw} c="dimmed" ff={mono ? "monospace" : undefined}>
{label}
</Text>
);
}
return (
<Anchor
component={Link}
to={to}
onClick={(e) => e.stopPropagation()}
underline="hover"
c="edr-green"
fw={fw}
fz={size}
ff={mono ? "monospace" : undefined}
className={className}
>
<Group gap={4} wrap="nowrap" component="span" style={{ display: "inline-flex" }}>
{Icon ? <Icon size={14} /> : null}
<span>{label}</span>
<ArrowUpRight size={13} style={{ flexShrink: 0 }} />
</Group>
</Anchor>
);
}

View File

@@ -0,0 +1,59 @@
import type { ReactNode } from "react";
import type { LucideIcon } from "lucide-react";
import { Group, Stack, Text } from "@mantine/core";
export interface FieldProps {
label: string;
value?: ReactNode;
}
/**
* Stacked label-over-value pair — uppercase dimmed label, value below. Used in
* grids of facts (e.g. an invoice summary, a contract's key figures).
*/
export function Field({ label, value }: FieldProps) {
const isEmpty = value === undefined || value === null || value === "";
return (
<Stack gap={2}>
<Text
size="xs"
fw={600}
c="edr-muted"
tt="uppercase"
style={{ letterSpacing: "0.04em" }}
>
{label}
</Text>
<Text size="sm" c="edr-text">
{isEmpty ? "—" : value}
</Text>
</Stack>
);
}
export interface FieldRowProps {
icon?: LucideIcon;
label: string;
value?: ReactNode;
}
/**
* Left icon+label / right bold value row, divider-separated when stacked in a
* list. Used inside quick-info cards (see `LinkedEntityCard`).
*/
export function FieldRow({ icon: Icon, label, value }: FieldRowProps) {
const isEmpty = value === undefined || value === null || value === "";
return (
<Group justify="space-between" wrap="nowrap" py={6} gap="md">
<Group gap="xs" wrap="nowrap">
{Icon ? <Icon size={15} color="var(--mantine-color-gray-5)" /> : null}
<Text size="sm" c="dimmed">
{label}
</Text>
</Group>
<Text size="sm" fw={600} ta="right" style={{ minWidth: 0 }} truncate>
{isEmpty ? "—" : value}
</Text>
</Group>
);
}

View File

@@ -0,0 +1,67 @@
import type { ReactNode } from "react";
import type { LucideIcon } from "lucide-react";
import { Divider, Stack, Text } from "@mantine/core";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { FieldRow, type FieldRowProps } from "./Field";
import { EntityLink } from "./EntityLink";
export interface LinkedEntityCardProps {
icon: LucideIcon;
/** Card title, e.g. "Customer" or "Contract". */
title: string;
/** The entity's own name/reference, rendered as the linked subtitle. */
name: ReactNode;
/** Route to the entity's detail page. Omit when there's nothing to link to
* (e.g. a government booking with no company) — the name renders as plain
* dimmed text instead of a dead link. */
to?: string | null;
accent?: string;
/** Quick-info rows shown below the linked name — empty ones are dropped. */
rows?: FieldRowProps[];
/** Extra content under the rows (e.g. a summary paragraph, an action). */
footer?: ReactNode;
/** Shown instead of rows/footer when there's nothing to display at all. */
emptyMessage?: string;
}
/**
* "Customer at a glance" / "Contract at a glance" card for a detail page's
* sticky rail: a linked title plus a handful of quick-info rows, so the
* related record's essentials are visible without navigating away.
*/
export function LinkedEntityCard({
icon,
title,
name,
to,
accent = "blue",
rows = [],
footer,
emptyMessage,
}: LinkedEntityCardProps) {
const visibleRows = rows.filter((r) => r.value !== undefined && r.value !== null && r.value !== "");
return (
<SectionCard icon={icon} title={title} accent={accent}>
<Stack gap={4}>
<EntityLink to={to} label={name} size="sm" fw={700} />
{visibleRows.length > 0 ? (
<Stack gap={0} mt={4}>
{visibleRows.map((row, index) => (
<div key={row.label}>
{index > 0 && <Divider color="var(--mantine-color-gray-2)" />}
<FieldRow {...row} />
</div>
))}
</Stack>
) : emptyMessage ? (
<Text size="sm" c="dimmed" mt={4}>
{emptyMessage}
</Text>
) : null}
{footer}
</Stack>
</SectionCard>
);
}

View File

@@ -0,0 +1,14 @@
export { Field, FieldRow } from "./Field";
export type { FieldProps, FieldRowProps } from "./Field";
export { EntityLink } from "./EntityLink";
export type { EntityLinkProps } from "./EntityLink";
export { LinkedEntityCard } from "./LinkedEntityCard";
export type { LinkedEntityCardProps } from "./LinkedEntityCard";
// Re-exported so pages under this restructure have one import path for both
// the new quick-info primitives and the existing section-card shell. Imported
// from the file directly (not the bookings/detail barrel) — that barrel also
// re-exports cards that import from this module, and going through it would
// create a circular import.
export { SectionCard } from "@/components/bookings/detail/SectionCard";
export type { SectionCardProps } from "@/components/bookings/detail/SectionCard";

View File

@@ -44,10 +44,12 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [
},
},
{
prefix: "/dashboard/payments",
// Invoices, Payments, and USD Payments are tabs on one page now
// (FinanceHubPage); the header title itself is set per-tab there.
prefix: "/dashboard/invoices",
meta: {
title: "Payments",
subtitle: "View booking payment transactions",
title: "Invoices",
subtitle: "Invoices, payments, and USD bank transfers",
},
},
{

View File

@@ -7,7 +7,7 @@ import Breadcrumbs, { type BreadcrumbItem } from "@/components/ui/Breadcrumbs";
export interface PageHeaderProps {
title: string;
subtitle?: string;
subtitle?: ReactNode;
/** Breadcrumb trail — pass only on nested pages (details, sub-resources). */
breadcrumbs?: BreadcrumbItem[];
/** Route to return to; renders a back arrow before the title. */

View File

@@ -0,0 +1,83 @@
import { Box, Text } from "@mantine/core";
import {
Bar,
BarChart,
CartesianGrid,
Legend,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { overviewChartColors } from "@/components/overview/overview.styles";
import type { ReportChartDef, ReportColumn } from "@/types/reports";
import { formatReportCell } from "./report-format";
interface ReportChartProps {
chart: ReportChartDef;
items: Record<string, unknown>[];
columns: ReportColumn[];
/** Filtered row count on the server. Chart is capped at 100 rows (the API's
* page-size ceiling) — surface it plainly rather than silently truncate. */
total?: number;
}
const COLORS = overviewChartColors.pipeline;
/** Plots the same rows the table gets — chart.x/chart.y are just column keys. */
export function ReportChart({ chart, items, columns, total }: ReportChartProps) {
const columnByKey = new Map(columns.map((c) => [c.key, c]));
const yLabel = (key: string) => columnByKey.get(key)?.label ?? key;
const yType = (key: string) => columnByKey.get(key)?.type ?? "number";
if (!items.length) {
return (
<Text size="sm" c="dimmed" ta="center" py="xl">
No data for the selected filters.
</Text>
);
}
const Chart = chart.type === "line" ? LineChart : BarChart;
const truncated = typeof total === "number" && total > items.length;
return (
<Box px="md" pb="md">
{truncated ? (
<Text size="xs" c="dimmed" mb="xs">
Showing first {items.length} of {total} rows. Narrow the filters to see the rest charted.
</Text>
) : null}
<ResponsiveContainer width="100%" height={320}>
<Chart data={items} margin={{ top: 8, right: 16, left: 0, bottom: 24 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis
dataKey={chart.x}
tick={{ fontSize: 11 }}
angle={-20}
textAnchor="end"
height={50}
stroke="#94a3b8"
/>
<YAxis tick={{ fontSize: 12 }} stroke="#94a3b8" />
<Tooltip formatter={(value, name) => [formatReportCell(value, yType(String(name))), yLabel(String(name))]} />
{chart.y.length > 1 ? <Legend formatter={(name) => yLabel(String(name))} /> : null}
{chart.y.map((key, i) =>
chart.type === "line" ? (
<Line key={key} type="monotone" dataKey={key} stroke={COLORS[i % COLORS.length]} strokeWidth={2} dot={false} />
) : (
<Bar key={key} dataKey={key} fill={COLORS[i % COLORS.length]} radius={[4, 4, 0, 0]} />
),
)}
</Chart>
</ResponsiveContainer>
</Box>
);
}
export default ReportChart;

View File

@@ -0,0 +1,158 @@
import { Button, Checkbox, Group, Modal, Radio, Select, SimpleGrid, Stack, Text } from "@mantine/core";
import { Download, FileSpreadsheet, FileText } from "lucide-react";
import { useState } from "react";
import { reportsService } from "@/services/reports.service";
import type { ReportCatalogEntry, ReportRunParams } from "@/types/reports";
interface ReportExportButtonProps {
def: ReportCatalogEntry;
/** Filters + sort currently applied on screen — no key/page/pageSize. */
params: Omit<ReportRunParams, "key" | "page" | "pageSize">;
}
const RECORD_OPTIONS = [
{ value: "all", label: "All (up to format limit)" },
{ value: "100", label: "First 100" },
{ value: "500", label: "First 500" },
{ value: "1000", label: "First 1,000" },
];
/** Triggers a browser save for a blob without leaving the SPA. */
function saveBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
/** One export button: format, which fields, how many records — applies the
* filters/sort already on screen. Record count defaults to all (capped
* server-side per format). */
export function ReportExportButton({ def, params }: ReportExportButtonProps) {
const [opened, setOpened] = useState(false);
const [format, setFormat] = useState<"xlsx" | "pdf">("xlsx");
const [fields, setFields] = useState<string[]>(def.columns.map((c) => c.key));
const [records, setRecords] = useState("all");
const [exporting, setExporting] = useState(false);
const allSelected = fields.length === def.columns.length;
const toggleField = (key: string) =>
setFields((prev) => (prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key]));
const toggleAll = () => setFields(allSelected ? [] : def.columns.map((c) => c.key));
const handleDownload = async () => {
setExporting(true);
try {
const blob = await reportsService.download(def.key, format, {
...params,
fields: allSelected ? undefined : fields.join(","),
limit: records === "all" ? undefined : records,
});
saveBlob(blob, `${def.key}.${format}`);
setOpened(false);
} finally {
setExporting(false);
}
};
return (
<>
<Button
variant="default"
radius="md"
size="sm"
leftSection={<Download size={16} />}
onClick={() => setOpened(true)}
>
Export
</Button>
<Modal opened={opened} onClose={() => setOpened(false)} title="Export report" radius="md" size="md">
<Stack gap="lg">
<div>
<Text size="sm" fw={600} mb="xs">
Format
</Text>
<Radio.Group value={format} onChange={(v) => setFormat(v as "xlsx" | "pdf")}>
<SimpleGrid cols={2}>
<Radio.Card value="xlsx" radius="md" p="md">
<Group wrap="nowrap" gap="sm">
<Radio.Indicator />
<FileSpreadsheet size={22} />
<Text size="sm" fw={500}>
Excel (.xlsx)
</Text>
</Group>
</Radio.Card>
<Radio.Card value="pdf" radius="md" p="md">
<Group wrap="nowrap" gap="sm">
<Radio.Indicator />
<FileText size={22} />
<Text size="sm" fw={500}>
PDF
</Text>
</Group>
</Radio.Card>
</SimpleGrid>
</Radio.Group>
</div>
<div>
<Group justify="space-between" mb="xs">
<Text size="sm" fw={600}>
Fields
</Text>
<Button variant="subtle" size="compact-sm" onClick={toggleAll}>
{allSelected ? "Clear all" : "Select all"}
</Button>
</Group>
<SimpleGrid cols={2} spacing="xs">
{def.columns.map((col) => (
<Checkbox
key={col.key}
label={col.label}
checked={fields.includes(col.key)}
onChange={() => toggleField(col.key)}
/>
))}
</SimpleGrid>
</div>
<Select
label="Records"
value={records}
onChange={(v) => setRecords(v ?? "all")}
data={RECORD_OPTIONS}
allowDeselect={false}
radius="md"
size="sm"
/>
<Text size="xs" c="dimmed">
Uses the filters and sorting currently applied to the report.
</Text>
<Group justify="flex-end">
<Button variant="default" radius="md" onClick={() => setOpened(false)}>
Cancel
</Button>
<Button
radius="md"
loading={exporting}
disabled={!fields.length}
leftSection={<Download size={16} />}
onClick={() => void handleDownload()}
>
Download
</Button>
</Group>
</Stack>
</Modal>
</>
);
}
export default ReportExportButton;

View File

@@ -0,0 +1,110 @@
import { Group, MultiSelect, Select, TextInput } from "@mantine/core";
import { DateInput, DatePickerInput } from "@mantine/dates";
import { Search } from "lucide-react";
import { getDateRangePresets } from "@/components/common/dateRangePresets";
import type { ReportFilterDef } from "@/types/reports";
export interface ReportFilterValues {
[param: string]: string | undefined;
}
interface ReportFiltersProps {
filters: ReportFilterDef[];
values: ReportFilterValues;
onChange: (values: ReportFilterValues) => void;
}
const toDate = (value: string | undefined): Date | null => (value ? new Date(value) : null);
const fromDate = (value: string | null): string | undefined => value ?? undefined;
/** Renders one widget per report-declared filter and reports raw param values back up. */
export function ReportFilters({ filters, values, onChange }: ReportFiltersProps) {
if (!filters.length) return null;
const set = (patch: ReportFilterValues) => onChange({ ...values, ...patch });
return (
<Group gap="sm" wrap="wrap">
{filters.map((filter) => {
switch (filter.type) {
case "daterange":
return (
<DatePickerInput
key={filter.key}
type="range"
placeholder={filter.label}
value={[values[`${filter.key}From`] ?? null, values[`${filter.key}To`] ?? null]}
onChange={([from, to]) =>
set({ [`${filter.key}From`]: fromDate(from), [`${filter.key}To`]: fromDate(to) })
}
presets={getDateRangePresets()}
radius="md"
size="sm"
clearable
w={230}
/>
);
case "date":
return (
<DateInput
key={filter.key}
placeholder={filter.label}
value={toDate(values[filter.key])}
onChange={(d) => set({ [filter.key]: fromDate(d) })}
radius="md"
size="sm"
clearable
w={150}
/>
);
case "select":
return (
<Select
key={filter.key}
placeholder={filter.label}
data={filter.options ?? []}
value={values[filter.key] ?? null}
onChange={(v) => set({ [filter.key]: v ?? undefined })}
radius="md"
size="sm"
clearable
w={170}
/>
);
case "multiselect":
return (
<MultiSelect
key={filter.key}
placeholder={filter.label}
data={filter.options ?? []}
value={values[filter.key]?.split(",").filter(Boolean) ?? []}
onChange={(v) => set({ [filter.key]: v.length ? v.join(",") : undefined })}
radius="md"
size="sm"
clearable
w={200}
/>
);
case "text":
return (
<TextInput
key={filter.key}
placeholder={filter.label}
leftSection={<Search size={16} />}
value={values[filter.key] ?? ""}
onChange={(e) => set({ [filter.key]: e.target.value || undefined })}
radius="md"
size="sm"
w={220}
/>
);
default:
return null;
}
})}
</Group>
);
}
export default ReportFilters;

View File

@@ -0,0 +1,39 @@
import { Stack, Text, Title } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/services/api";
import { ReportView } from "./ReportView";
interface ReportSectionProps {
reportKey: string;
/** Scopes the report to one entity, e.g. the contract this page is showing. */
idKeyValue?: string;
}
/**
* Drops a report inline on any page — a contract detail page embedding
* `contract-utilization`, for instance. Renders nothing while the catalog is
* loading or if the caller lacks the report's permission, so pages can embed
* it unconditionally without their own permission check.
*/
export function ReportSection({ reportKey, idKeyValue }: ReportSectionProps) {
const { data: catalog } = useQuery(api.reports.catalog.queryOptions());
const def = catalog?.find((r) => r.key === reportKey);
if (!def) return null;
return (
<Stack gap="xs">
<div>
<Title order={4}>{def.title}</Title>
<Text size="sm" c="dimmed">
{def.description}
</Text>
</div>
<ReportView reportKey={reportKey} idKeyValue={idKeyValue} />
</Stack>
);
}
export default ReportSection;

View File

@@ -0,0 +1,226 @@
import { ActionIcon, Alert, Box, Card, Group, SegmentedControl, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { useQuery } from "@tanstack/react-query";
import type { Column, SortingState } from "@tanstack/react-table";
import { ArrowDown, ArrowUp, ArrowUpDown, LayoutGrid, LineChart, RefreshCw } from "lucide-react";
import { useMemo, useState } from "react";
import { PageHeader } from "@/components/page";
import { KpiStrip } from "@/components/page/KpiStrip";
import { api } from "@/services/api";
import type { ReportRunParams } from "@/types/reports";
import { DataTable, DataTableFooter, usePagination, type ColumnDef } from "@edr/ui-common";
import { ReportChart } from "./ReportChart";
import { ReportExportButton } from "./ReportExportButton";
import { ReportFilters, type ReportFilterValues } from "./ReportFilters";
import { formatKpiValue, formatReportCell } from "./report-format";
function SortableHeader({ label, column }: { label: string; column: Column<Record<string, unknown>, unknown> }) {
const sorted = column.getIsSorted();
const Icon = sorted === "asc" ? ArrowUp : sorted === "desc" ? ArrowDown : ArrowUpDown;
return (
<UnstyledButton
onClick={column.getToggleSortingHandler()}
style={{ display: "flex", alignItems: "center", gap: 4 }}
>
<Text size="sm" fw={600} c="edr-text">
{label}
</Text>
<Icon size={13} opacity={sorted ? 1 : 0.4} />
</UnstyledButton>
);
}
interface ReportViewProps {
reportKey: string;
/** Scopes the report to one entity when embedded (e.g. a contract detail page). */
idKeyValue?: string;
/** Full-page usage: renders the title/description as a PageHeader (no back
* arrow) with export/refresh as its actions, instead of inline above the
* table. Off by default for embedded sections. */
pageHeader?: boolean;
}
/**
* The report engine: one component renders any report the catalog describes —
* filters, KPI strip, sortable/paginated table or chart, xlsx/pdf export.
* Adding a report never touches this file.
*/
export function ReportView({ reportKey, idKeyValue, pageHeader }: ReportViewProps) {
const { data: catalog } = useQuery(api.reports.catalog.queryOptions());
const def = catalog?.find((r) => r.key === reportKey);
const { pagination, setPagination } = usePagination({ pageSize: 20 });
const [sorting, setSorting] = useState<SortingState>([]);
const [filterValues, setFilterValues] = useState<ReportFilterValues>({});
const [debouncedFilters] = useDebouncedValue(filterValues, 300);
const [view, setView] = useState<"table" | "chart">("table");
// Filters + sort as the user currently has them — independent of the view
// toggle's paging, so export always matches what's on screen either way.
const appliedParams = useMemo(() => {
const sort = sorting[0];
return {
sortBy: sort?.id,
sortOrder: sort ? (sort.desc ? "DESC" as const : "ASC" as const) : undefined,
...debouncedFilters,
...(def?.idKey && idKeyValue ? { [def.idKey.key]: idKeyValue } : {}),
};
}, [def, sorting, debouncedFilters, idKeyValue]);
const runParams: ReportRunParams | undefined = useMemo(() => {
if (!def) return undefined;
return {
key: def.key,
// Chart view isn't paginated on screen — pull the server's max page (100)
// in one shot instead of just whatever page the table happens to be on,
// so the chart doesn't silently plot a fraction of the filtered rows.
page: view === "chart" ? 1 : pagination.pageIndex + 1,
pageSize: view === "chart" ? 100 : pagination.pageSize,
...appliedParams,
};
}, [def, view, pagination, appliedParams]);
const { data, isLoading, isError, isFetching, refetch } = useQuery({
...api.reports.run.queryOptions({ input: runParams as ReportRunParams }),
enabled: Boolean(runParams),
});
const total = data?.meta.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
const columns: ColumnDef<Record<string, unknown>>[] = useMemo(
() =>
(def?.columns ?? []).map((col) => ({
id: col.key,
accessorKey: col.key,
header: col.sortable
? ({ column }) => <SortableHeader label={col.label} column={column} />
: col.label,
enableSorting: col.sortable,
cell: ({ row }) => (
<Text size="sm" c="edr-text">
{formatReportCell(row.original[col.key], col.type)}
</Text>
),
})),
[def?.columns],
);
if (!def) {
return catalog ? (
<Alert color="red">You don't have access to this report.</Alert>
) : null;
}
const chartToggle = def.chart ? (
<SegmentedControl
size="xs"
value={view}
onChange={(v) => setView(v as "table" | "chart")}
data={[
{ label: <LayoutGrid size={14} />, value: "table" },
{ label: <LineChart size={14} />, value: "chart" },
]}
/>
) : null;
const refreshButton = (
<Tooltip label="Refresh">
<ActionIcon
variant="default"
radius="md"
loading={isFetching}
onClick={() => void refetch()}
aria-label="Refresh"
>
<RefreshCw size={16} />
</ActionIcon>
</Tooltip>
);
const exportButton = <ReportExportButton def={def} params={appliedParams} />;
return (
<Stack gap="md">
{pageHeader ? (
<PageHeader
title={def.title}
subtitle={def.description}
action={
<Group gap="xs">
{exportButton}
{refreshButton}
</Group>
}
/>
) : null}
{data?.kpis.length ? (
<KpiStrip
loading={isLoading}
items={data.kpis.map((k) => ({ label: k.label, value: formatKpiValue(k.value, k.unit) }))}
/>
) : null}
<Card p={0}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm">
<Group justify="space-between" gap="md" wrap="wrap">
<ReportFilters
filters={def.filters}
values={filterValues}
onChange={(v) => {
setFilterValues(v);
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
}}
/>
<Group gap="xs">
{chartToggle}
{pageHeader ? null : (
<>
{exportButton}
{refreshButton}
</>
)}
</Group>
</Group>
</Box>
{view === "chart" && def.chart ? (
<ReportChart chart={def.chart} items={data?.items ?? []} columns={def.columns} total={total} />
) : (
<Box style={{ overflowX: "auto" }} w="100%">
<DataTable
columns={columns}
data={data?.items ?? []}
status={isLoading ? "loading" : isError ? "error" : "success"}
emptyMessage="No data for the selected filters."
error={isError ? { message: "Failed to load report.", onRetry: () => void refetch() } : undefined}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { sorting },
onSortingChange: setSorting,
onPaginationChange: setPagination,
manualPagination: true,
manualSorting: true,
pageCount,
}}
containerClassName="border-0 shadow-none bg-transparent"
footer={DataTableFooter}
/>
</Box>
)}
</Stack>
</Card>
</Stack>
);
}
export default ReportView;

View File

@@ -0,0 +1,42 @@
import type { ReportColumnType } from "@/types/reports";
/** Cell formatting shared by the on-screen table and (indirectly) exports. */
export function formatReportCell(value: unknown, type: ReportColumnType): string {
if (value === null || value === undefined || value === "") return "—";
switch (type) {
case "money":
return new Intl.NumberFormat(undefined, {
style: "currency",
currency: "ETB",
maximumFractionDigits: 2,
}).format(Number(value));
case "tons":
return `${Number(value).toLocaleString()} t`;
case "percent":
return `${value}%`;
case "number":
return Number(value).toLocaleString();
case "date": {
const d = new Date(String(value));
return Number.isNaN(d.getTime())
? String(value)
: d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
}
default:
return String(value);
}
}
export function formatKpiValue(value: number, unit?: string): string {
const formatted = value.toLocaleString(undefined, { maximumFractionDigits: 1 });
if (unit === "ETB") {
return new Intl.NumberFormat(undefined, {
style: "currency",
currency: "ETB",
maximumFractionDigits: 0,
}).format(value);
}
if (unit === "%") return `${formatted}%`;
if (unit === "t") return `${formatted} t`;
return formatted;
}

View File

@@ -14,6 +14,7 @@ import {
} from "@mantine/core";
import { ChevronDown, ChevronRight, Info, Train, Weight } from "lucide-react";
import { EntityLink } from "@/components/detail";
import type { TrainScheduleDetail } from "@/types/trainScheduling";
/**
@@ -254,15 +255,19 @@ export function LegCapacityPanel({ schedule }: { schedule: TrainScheduleDetail }
{e.bookings.map((b) => (
<Table.Tr key={b.bookingId}>
<Table.Td w="50%">
<Text size="sm" fw={500} style={{ whiteSpace: "nowrap" }}>
{b.reference}
<Group gap={4} wrap="nowrap" style={{ whiteSpace: "nowrap" }}>
<EntityLink
to={`/dashboard/booking-requests/${b.bookingId}`}
label={b.reference}
size="sm"
fw={500}
/>
{b.route ? (
<Text span size="xs" c="dimmed">
{" "}
({b.route})
</Text>
) : null}
</Text>
</Group>
</Table.Td>
<Table.Td w="25%">
<Group gap={4} wrap="nowrap">

View File

@@ -37,6 +37,7 @@ import {
import { CountdownTimer } from "@edr/ui-common";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
import { EntityLink } from "@/components/detail";
import { api } from "@/services/api";
import { bookingsService } from "@/services/bookings.service";
import { useToast } from "@/hooks/use-toast";
@@ -628,6 +629,7 @@ export function ScheduleWorkspacePanel({
{pool.map((b) => (
<BookingCard
key={b.id}
bookingId={b.id}
reference={b.reference}
customer={b.customer}
weightTons={b.weightTons}
@@ -744,6 +746,7 @@ export function ScheduleWorkspacePanel({
return (
<BookingCard
key={b.id}
bookingId={b.id}
reference={ref}
customer={b.customer}
weightTons={b.weightTons}
@@ -1036,6 +1039,7 @@ function PanelColumn({
}
function BookingCard({
bookingId,
reference,
customer,
weightTons,
@@ -1047,6 +1051,8 @@ function BookingCard({
leg,
right,
}: {
/** When set, the reference links to the booking's detail page. */
bookingId?: string;
reference: string;
customer?: string | null;
weightTons?: number | null;
@@ -1081,9 +1087,18 @@ function BookingCard({
<Group justify="space-between" align="center" wrap="nowrap" gap="sm">
<Stack gap={3} style={{ minWidth: 0 }}>
<Group gap={8} align="center" wrap="nowrap">
<Text size="sm" fw={700} truncate>
{reference}
</Text>
{bookingId ? (
<EntityLink
to={`/dashboard/booking-requests/${bookingId}`}
label={reference}
size="sm"
fw={700}
/>
) : (
<Text size="sm" fw={700} truncate>
{reference}
</Text>
)}
{status ? <BookingStatusBadge status={status} /> : null}
{intercity ? (
<Tooltip

View File

@@ -1,5 +1,6 @@
import { Badge, Card, Group, Progress, SimpleGrid, Stack, Text, ThemeIcon } from "@mantine/core";
import { Box, Package } from "lucide-react";
import { EntityLink } from "@/components/detail";
import type { TrainScheduleWagonAllocation, WagonPlanRow } from "@/types/trainScheduling";
type WagonSlot = (WagonPlanRow & {
@@ -159,9 +160,12 @@ export function WagonPlanGrid({
<Card key={`${alloc.bookingId}-${index}`} padding="xs" radius="md" bg="gray.0">
<Stack gap={2}>
<Group justify="space-between" gap="xs">
<Text size="xs" fw={500} lineClamp={1}>
{alloc.bookingReference ?? alloc.bookingId}
</Text>
<EntityLink
to={`/dashboard/booking-requests/${alloc.bookingId}`}
label={alloc.bookingReference ?? alloc.bookingId}
size="xs"
fw={500}
/>
{label === "BULK" ? (
<Text size="xs" c="dimmed">
{alloc.allocatedWeightTons}T cargo

View File

@@ -81,6 +81,8 @@ export const QUERY_KEYS = {
["contracts", "clearance-history", region ?? "ET"] as const,
milestones: (id: string) => ["contracts", "milestones", id] as const,
capacity: (id: string) => ["contracts", "capacity", id] as const,
bookingRequests: (id: string) =>
["contracts", "booking-requests", id] as const,
bookingMilestones: (bookingId: string) =>
["contracts", "booking-milestones", bookingId] as const,
bookingIncidents: (bookingId: string) =>

View File

@@ -128,7 +128,9 @@ export const URL_CONSTANTS = {
},
REPORTS: {
CATALOG: "/reports",
RUN: (key: string) => `/reports/${key}`,
EXPORT: (key: string) => `/reports/${key}/export`,
},
OVERVIEW: {

View File

@@ -1,5 +1,6 @@
import React, { useEffect, useState } from "react";
import { Input } from "@/shared/common/ui/input";
import { DateRangePicker, parseDay, formatDay } from "@/shared/common/ui/date-range-picker";
import {
Select,
SelectTrigger,
@@ -134,21 +135,12 @@ const buildQuery = (): CollectionQueryDTO => {
className="w-64"
/>
<div className="flex gap-2 items-center">
<Input
type="date"
onChange={(e) =>
setDateRange({ ...dateRange, start: e.target.value })
}
/>
<span className="text-muted-foreground text-sm">to</span>
<Input
type="date"
onChange={(e) =>
setDateRange({ ...dateRange, end: e.target.value })
}
/>
</div>
<DateRangePicker
value={{ from: parseDay(dateRange.start), to: parseDay(dateRange.end) }}
onChange={(range) =>
setDateRange({ start: formatDay(range.from), end: formatDay(range.to) })
}
/>
<Select value={sort} onValueChange={setSort}>
<SelectTrigger className="w-[180px]">

View File

@@ -2,43 +2,56 @@ import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import toast from "react-hot-toast";
import {
ArrowLeft,
Container as ContainerIcon,
FileSignature,
FileText,
Flame,
FolderOpen,
Layers,
LayoutGrid,
Milestone,
MoreHorizontal,
Package,
RefreshCw,
Truck,
Wallet,
Weight,
} from "lucide-react";
import {
Container,
Stack,
Grid,
ActionIcon,
Box,
Button,
Center,
Container,
Grid,
Group,
Loader,
Menu,
Paper,
SegmentedControl,
Stack,
Tabs,
Text,
Paper,
Button,
Box,
SegmentedControl,
} from "@mantine/core";
import { PageContainer } from "@/components/page";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { PageContainer, PageHeader, KpiStrip } from "@/components/page";
import type { KpiItem } from "@/components/page";
import { EntityLink } from "@/components/detail";
import { BookingActionsToolbar } from "@/components/bookings/BookingActionsToolbar";
import { BookingPricingSummary } from "@/components/bookings/BookingPricingSummary";
import { BookingWorkflowStepper } from "@/components/bookings/BookingWorkflowStepper";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
import { NextStepBanner } from "@/components/bookings/NextStepBanner";
import { SchedulingStatusBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
import { ConsolidationWaitingBanner } from "@/components/bookings/detail/ConsolidationWaitingBanner";
import {
detailStyles,
BookingRequestHero,
BookingRouteServiceCard,
BookingMileServicesCard,
BookingCargoCard,
BookingCompanyCard,
BookingContractSummaryCard,
BookingContractCard,
BookingContainerUnitsCard,
BookingSchedulingWindowCard,
BookingDocumentsPanel,
@@ -48,6 +61,7 @@ import {
import { WarehouseInfoCard } from "@/components/warehouses";
import { getStatusMeta } from "@/features/bookings/booking-status.config";
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
import { cargoTonsAndItems } from "@/utils/cargoWeight";
import type { BookingDetail } from "@/types/booking";
import {
useBookingDetail,
@@ -133,7 +147,6 @@ export default function BookingRequestDetailPage() {
);
}
const row = toBookingListRow(booking);
const statusMeta = getStatusMeta(booking.status);
// Clearance review + finalize now lives solely on the Operations "Clearance
// Documents" hub (/dashboard/contracts/clearance-documents → detail page), so
@@ -159,23 +172,176 @@ export default function BookingRequestDetailPage() {
setSearchParams(next, { replace: true });
};
const company = booking.company;
const customerName = toBookingListRow(booking).customerLabel;
const amount = Number(booking.totalAmount);
const containers = booking.bookingContainers ?? [];
const containerCount = containers.reduce(
(sum, c) => sum + Number(c.quantity ?? 0),
0,
);
const { tons: weight, items: itemCount } = cargoTonsAndItems(booking);
const kpis: KpiItem[] = [
{
label: "Total value",
value: `${booking.paymentCurrency} ${amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}`,
hint: booking.paymentStatus,
icon: Wallet,
color: "edr-green",
},
{
label: "Cargo weight",
value: `${weight} T`,
hint: itemCount != null ? `${itemCount} items` : "VGM total",
icon: Weight,
color: "blue",
},
{
label: "Containers",
value: containerCount || "—",
hint: `${containers.length} line${containers.length === 1 ? "" : "s"}`,
icon: ContainerIcon,
color: "teal",
},
{
label: "Priority score",
value: booking.priorityScore ?? 0,
hint: booking.tradeDirection,
icon: Flame,
color: "orange",
},
];
const hasSignableContract = booking.isGovernment && booking.contractSummary;
return (
<PageContainer>
<Breadcrumbs
items={[
<PageHeader
breadcrumbs={[
{ label: "Booking requests", href: "/dashboard/booking-requests" },
{ label: booking.reference },
]}
backTo="/dashboard/booking-requests"
title={booking.reference}
meta={
<Group gap={6} wrap="wrap">
<BookingStatusBadge status={booking.status} />
<BookingPriorityBadge score={booking.priorityScore} />
{booking.schedulingStatus ? (
<SchedulingStatusBadge status={booking.schedulingStatus} />
) : null}
</Group>
}
subtitle={
<Group gap={6} wrap="wrap">
<EntityLink
to={company?.id ? `/dashboard/customers/${company.id}` : null}
label={customerName ?? "—"}
/>
<Text size="sm" c="dimmed">
· Scheduled {booking.scheduledDate}
</Text>
</Group>
}
action={
<Group gap="sm" wrap="nowrap">
<ActionIcon
variant="light"
color="edr-green"
size="lg"
radius="md"
loading={isFetching}
aria-label="Refresh"
onClick={() => refetch()}
>
<RefreshCw size={16} />
</ActionIcon>
<Menu position="bottom-end" width={260} withinPortal>
<Menu.Target>
<ActionIcon
variant="default"
size="lg"
radius="md"
aria-label="More actions"
>
<MoreHorizontal size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
{hasSignableContract && (
<Menu.Item
leftSection={<FileSignature size={15} />}
onClick={() =>
navigate(`/dashboard/booking-requests/${booking.id}/contract`)
}
>
View / sign contract
</Menu.Item>
)}
<Menu.Item
leftSection={<FileText size={15} />}
onClick={async () => {
try {
const blob =
await bookingsService.downloadCarriageAcceptanceSheet(
booking.id,
);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `carriage-acceptance-${booking.reference}.pdf`;
a.click();
URL.revokeObjectURL(url);
} catch (error) {
toast.error(
error instanceof Error
? error.message
: "Carriage acceptance sheet is not available yet",
);
}
}}
>
Carriage acceptance sheet
</Menu.Item>
{booking.customsClearingEnabled && (
<Menu.Item
leftSection={<Milestone size={15} />}
onClick={() =>
navigate(`/dashboard/bookings/${booking.id}/clearance`)
}
>
View document clearance
</Menu.Item>
)}
</Menu.Dropdown>
</Menu>
</Group>
}
/>
<Stack gap="lg">
<BookingRequestHero
booking={booking}
customerLabel={row.customerLabel}
onBack={() => navigate("/dashboard/booking-requests")}
onRefresh={() => refetch()}
isFetching={isFetching}
/>
<KpiStrip items={kpis} />
{booking.holdExpiresAt && booking.schedulingStatus === "HOLDING" ? (
<Text size="xs" c="orange.7">
Hold expires {new Date(booking.holdExpiresAt).toLocaleString()}
</Text>
) : null}
{booking.nextStep ? (
<Paper
radius="lg"
p={4}
style={{
background: "var(--mantine-color-gray-0)",
border: "1px solid var(--mantine-color-gray-2)",
}}
>
<NextStepBanner nextStep={booking.nextStep} />
</Paper>
) : null}
<BookingWorkflowStepper
status={booking.status}
@@ -223,7 +389,7 @@ export default function BookingRequestDetailPage() {
</Tabs.List>
<Tabs.Panel value="overview">
<OverviewPanel booking={booking} row={row} />
<OverviewPanel booking={booking} onRefetch={refetch} />
</Tabs.Panel>
{isGeneralContract && (
<Tabs.Panel value="orders">
@@ -247,6 +413,7 @@ export default function BookingRequestDetailPage() {
<Box style={{ position: "sticky", top: 24 }}>
<Stack gap="lg">
<BookingCompanyCard booking={booking} />
<BookingContractCard booking={booking} />
<BookingPricingSummary booking={booking} />
<Box id="warehouse-payments">
<WarehouseInfoCard
@@ -265,97 +432,6 @@ export default function BookingRequestDetailPage() {
booking={booking}
mutations={mutations}
/>
{booking.tradeDirection === "EXPORT" && (
<Paper withBorder radius="md" p="sm">
<Stack gap={6}>
<Text size="sm" fw={600}>
How the cargo reaches the train
</Text>
<SegmentedControl
fullWidth
size="xs"
value={booking.exportHandoverMode ?? "WAREHOUSE"}
data={[
{ value: "WAREHOUSE", label: "Warehouse then train" },
{ value: "DIRECT_TO_TRAIN", label: "Direct truck to train" },
]}
onChange={async (value) => {
try {
await bookingsService.setExportHandoverMode(
booking.id,
value as "DIRECT_TO_TRAIN" | "WAREHOUSE",
);
await refetch();
} catch (error) {
toast.error(
error instanceof Error
? error.message
: "Could not change the handover mode",
);
}
}}
/>
<Text size="xs" c="dimmed">
{booking.exportHandoverMode === "DIRECT_TO_TRAIN"
? "No warehouse receipt and no GRN — the carriage acceptance sheet is the handover document."
: "Cargo is received at the warehouse and issued a GRN before loading."}
</Text>
</Stack>
</Paper>
)}
{booking.isGovernment && booking.contractSummary && (
<Button
fullWidth
variant="default"
leftSection={<FileSignature size={16} />}
onClick={() =>
navigate(
`/dashboard/booking-requests/${booking.id}/contract`,
)
}
>
View / sign contract
</Button>
)}
<Button
fullWidth
variant="default"
leftSection={<FileText size={16} />}
onClick={async () => {
try {
const blob =
await bookingsService.downloadCarriageAcceptanceSheet(
booking.id,
);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `carriage-acceptance-${booking.reference}.pdf`;
a.click();
URL.revokeObjectURL(url);
} catch (error) {
toast.error(
error instanceof Error
? error.message
: "Carriage acceptance sheet is not available yet",
);
}
}}
>
Carriage acceptance sheet
</Button>
{booking.customsClearingEnabled && (
<Button
fullWidth
variant="default"
leftSection={<Milestone size={16} />}
onClick={() =>
navigate(`/dashboard/bookings/${booking.id}/clearance`)
}
>
View document clearance
</Button>
)}
</Stack>
</Box>
</Grid.Col>
@@ -368,11 +444,13 @@ export default function BookingRequestDetailPage() {
/** The booking's primary detail cards — route, services, cargo, containers. */
function OverviewPanel({
booking,
row,
onRefetch,
}: {
booking: BookingDetail;
row: ReturnType<typeof toBookingListRow>;
onRefetch: () => void;
}) {
const row = toBookingListRow(booking);
return (
<Stack gap="lg">
<BookingRouteServiceCard
@@ -380,12 +458,49 @@ function OverviewPanel({
originLabel={row.originLabel}
destinationLabel={row.destinationLabel}
/>
<BookingMileServicesCard booking={booking} />
<BookingMileServicesCard
booking={booking}
handoverSection={
booking.tradeDirection === "EXPORT" ? (
<Stack gap={6}>
<Text size="sm" fw={600}>
How the cargo reaches the train
</Text>
<SegmentedControl
fullWidth
size="xs"
value={booking.exportHandoverMode ?? "WAREHOUSE"}
data={[
{ value: "WAREHOUSE", label: "Warehouse then train" },
{ value: "DIRECT_TO_TRAIN", label: "Direct truck to train" },
]}
onChange={async (value) => {
try {
await bookingsService.setExportHandoverMode(
booking.id,
value as "DIRECT_TO_TRAIN" | "WAREHOUSE",
);
onRefetch();
} catch (error) {
toast.error(
error instanceof Error
? error.message
: "Could not change the handover mode",
);
}
}}
/>
<Text size="xs" c="dimmed">
{booking.exportHandoverMode === "DIRECT_TO_TRAIN"
? "No warehouse receipt and no GRN — the carriage acceptance sheet is the handover document."
: "Cargo is received at the warehouse and issued a GRN before loading."}
</Text>
</Stack>
) : null
}
/>
<BookingCargoCard booking={booking} />
<BookingContainerUnitsCard booking={booking} />
{booking.contractSummary && (
<BookingContractSummaryCard summary={booking.contractSummary} />
)}
</Stack>
);
}

View File

@@ -13,7 +13,8 @@ import {
Text,
TextInput,
} from "@mantine/core";
import { DateInput } from "@mantine/dates";
import { DatePickerInput } from "@mantine/dates";
import { getDateRangePresets } from "@/components/common/dateRangePresets";
import { useDebouncedValue } from "@mantine/hooks";
import {
AlertTriangle,
@@ -764,53 +765,33 @@ export default function BookingRequestsPage() {
radius="lg"
style={{ minWidth: 140 }}
/>
<DateInput
placeholder="Created from"
value={createdFrom}
onChange={(v) => {
setCreatedFrom(v ? new Date(v) : null);
<DatePickerInput
type="range"
placeholder="Created date range"
value={[createdFrom, createdTo]}
onChange={([from, to]) => {
setCreatedFrom(from ? new Date(from) : null);
setCreatedTo(to ? new Date(to) : null);
resetPage();
}}
maxDate={createdTo ?? undefined}
presets={getDateRangePresets()}
clearable
radius="lg"
style={{ minWidth: 140 }}
style={{ minWidth: 220 }}
/>
<DateInput
placeholder="Created to"
value={createdTo}
onChange={(v) => {
setCreatedTo(v ? new Date(v) : null);
<DatePickerInput
type="range"
placeholder="Scheduled date range"
value={[scheduledFrom, scheduledTo]}
onChange={([from, to]) => {
setScheduledFrom(from ? new Date(from) : null);
setScheduledTo(to ? new Date(to) : null);
resetPage();
}}
minDate={createdFrom ?? undefined}
presets={getDateRangePresets()}
clearable
radius="lg"
style={{ minWidth: 140 }}
/>
<DateInput
placeholder="Scheduled from"
value={scheduledFrom}
onChange={(v) => {
setScheduledFrom(v ? new Date(v) : null);
resetPage();
}}
maxDate={scheduledTo ?? undefined}
clearable
radius="lg"
style={{ minWidth: 150 }}
/>
<DateInput
placeholder="Scheduled to"
value={scheduledTo}
onChange={(v) => {
setScheduledTo(v ? new Date(v) : null);
resetPage();
}}
minDate={scheduledFrom ?? undefined}
clearable
radius="lg"
style={{ minWidth: 150 }}
style={{ minWidth: 230 }}
/>
{activeFilterCount > 0 ? (
<Button

View File

@@ -10,11 +10,9 @@ import {
Group,
Loader,
Paper,
Progress,
RingProgress,
Stack,
Text,
ThemeIcon,
} from "@mantine/core";
import {
AlertCircle,
@@ -29,9 +27,13 @@ import {
import type { Freight } from "@edr/types";
import { ClearanceOpsTabs } from "@/components/contracts/ClearanceOpsTabs";
import { PageContainer } from "@/components/page/PageContainer";
import { PageHeader } from "@/components/page/PageHeader";
import { SectionCard } from "@/components/bookings/detail";
import { PageContainer, PageHeader, KpiStrip } from "@/components/page";
import type { KpiItem } from "@/components/page";
import {
SectionCard,
BookingCompanyCard,
BookingContractCard,
} from "@/components/bookings/detail";
import { ClearanceReviewSection } from "@/components/bookings/detail/ClearanceReviewSection";
import { ClearancePhaseStepper } from "@/components/contracts/ClearancePhaseStepper";
import { PhasedClearanceActionPanel } from "@/components/contracts/PhasedClearanceActionPanel";
@@ -171,6 +173,18 @@ export default function DocumentClearanceDetailPage() {
);
}
const direction = booking?.tradeDirection ?? "—";
const origin = booking?.originYard?.label ?? booking?.originYard?.code ?? "Origin";
const destination =
booking?.destinationYard?.label ?? booking?.destinationYard?.code ?? "Destination";
const kpis: KpiItem[] = [
{ label: "Approved", value: stats.approved, icon: CheckCircle2, color: "edr-green" },
{ label: "Queried", value: stats.queried, icon: AlertCircle, color: "red" },
{ label: "Pending", value: stats.pending, icon: Clock, color: "gray" },
{ label: "Review progress", value: `${stats.pct}%`, icon: PackageCheck, color: "blue" },
];
return (
<PageContainer>
<Stack gap="lg">
@@ -182,25 +196,46 @@ export default function DocumentClearanceDetailPage() {
{ label: reference },
]}
meta={
clearance.allApproved ? (
<Badge
variant="light"
color="edr-green"
radius="sm"
leftSection={<CheckCircle2 size={13} />}
>
All approved
<Group gap={6} wrap="wrap">
<Badge variant="light" color={direction === "IMPORT" ? "edr-green" : "gray"} radius="sm">
{direction}
</Badge>
) : (
<Badge
variant="light"
color="gray"
radius="sm"
leftSection={<Clock size={13} />}
>
Review pending
</Badge>
)
{clearance.includesCustoms ? (
<Badge variant="light" color="edr-green" radius="sm" leftSection={<ShieldCheck size={12} />}>
Customs
</Badge>
) : null}
{clearance.allApproved ? (
<Badge
variant="light"
color="edr-green"
radius="sm"
leftSection={<CheckCircle2 size={13} />}
>
All approved
</Badge>
) : (
<Badge
variant="light"
color="gray"
radius="sm"
leftSection={<Clock size={13} />}
>
Review pending
</Badge>
)}
</Group>
}
subtitle={
<Group gap={8} wrap="nowrap">
<Text size="sm" c="dimmed" fw={600}>
{origin}
</Text>
<ArrowRight size={14} className="shrink-0 text-muted-foreground" />
<Text size="sm" c="dimmed" fw={600}>
{destination}
</Text>
</Group>
}
action={
canCompleteBooking ? (
@@ -233,12 +268,16 @@ export default function DocumentClearanceDetailPage() {
}
/>
<ClearanceHero
booking={booking}
clearance={clearance}
stats={stats}
requestedLines={requestedLines}
/>
<KpiStrip items={kpis} />
{requestedLines ? (
<Group gap={10} align="center" wrap="wrap">
<Text size="xs" fw={700} tt="uppercase" c="dimmed" lts="0.05em">
Requested cargo
</Text>
<RequestedCargoChips lines={requestedLines} size="sm" />
</Group>
) : null}
{isPhasedGeneral ? (
<Paper withBorder radius="md" p="lg">
@@ -273,67 +312,54 @@ export default function DocumentClearanceDetailPage() {
</Grid.Col>
<Grid.Col span={{ base: 12, lg: isPhasedGeneral ? 5 : 4 }}>
{isPhasedGeneral ? (
<PhasedClearanceActionPanel
bookingId={id!}
clearance={clearance}
tradeDirection={booking?.tradeDirection ?? "IMPORT"}
workflowFiles={workflowFiles}
roleMode="ET"
// A bare initiated instance still has no cargo/price — the
// stepper's "Create booking" step must read as NOT-yet-created
// so it never claims the booking is done before GL completes it.
bookingCreated={Number(booking?.totalAmount ?? 0) > 0}
bookingMilestones={bookingMilestones ?? []}
onChanged={() => void refetch()}
onViewFile={view}
onDownloadFile={(f) => void downloadBookingFile(f.id, f.name)}
/>
) : (
<Box style={{ position: "sticky", top: 24 }}>
<SectionCard
icon={PackageCheck}
title="Review progress"
accent="edr-green"
>
<Stack align="center" gap="sm">
<RingProgress
size={140}
thickness={12}
roundCaps
sections={[{ value: stats.pct, color: "edr-green" }]}
label={
<Stack gap={0} align="center">
<Text fw={800} fz={26} lh={1}>
{stats.pct}%
</Text>
<Text size="xs" c="dimmed">
approved
</Text>
</Stack>
}
/>
<Group gap="lg" justify="center">
<ProgressStat
color="edr-green"
label="Approved"
value={stats.approved}
<Box style={{ position: "sticky", top: 24 }}>
<Stack gap="lg">
{booking ? <BookingCompanyCard booking={booking} /> : null}
{booking ? <BookingContractCard booking={booking} /> : null}
{isPhasedGeneral ? (
<PhasedClearanceActionPanel
bookingId={id!}
clearance={clearance}
tradeDirection={booking?.tradeDirection ?? "IMPORT"}
workflowFiles={workflowFiles}
roleMode="ET"
// A bare initiated instance still has no cargo/price — the
// stepper's "Create booking" step must read as NOT-yet-created
// so it never claims the booking is done before GL completes it.
bookingCreated={Number(booking?.totalAmount ?? 0) > 0}
bookingMilestones={bookingMilestones ?? []}
onChanged={() => void refetch()}
onViewFile={view}
onDownloadFile={(f) => void downloadBookingFile(f.id, f.name)}
/>
) : (
<SectionCard
icon={PackageCheck}
title="Review progress"
accent="edr-green"
>
<Stack align="center" gap="sm">
<RingProgress
size={140}
thickness={12}
roundCaps
sections={[{ value: stats.pct, color: "edr-green" }]}
label={
<Stack gap={0} align="center">
<Text fw={800} fz={26} lh={1}>
{stats.pct}%
</Text>
<Text size="xs" c="dimmed">
approved
</Text>
</Stack>
}
/>
<ProgressStat
color="red"
label="Queried"
value={stats.queried}
/>
<ProgressStat
color="gray"
label="Pending"
value={stats.pending}
/>
</Group>
</Stack>
</SectionCard>
</Box>
)}
</Stack>
</SectionCard>
)}
</Stack>
</Box>
</Grid.Col>
</Grid>
}
@@ -347,125 +373,3 @@ export default function DocumentClearanceDetailPage() {
</PageContainer>
);
}
function ClearanceHero({
booking,
clearance,
stats,
requestedLines,
}: {
booking: ReturnType<typeof useBookingDetail>["data"];
clearance: Freight.ClearanceView;
stats: { pct: number; approved: number; total: number };
requestedLines?: Freight.RequestedShipmentLines | null;
}) {
const direction = booking?.tradeDirection ?? "—";
const origin =
booking?.originYard?.label ?? booking?.originYard?.code ?? "Origin";
const destination =
booking?.destinationYard?.label ??
booking?.destinationYard?.code ??
"Destination";
return (
<Paper withBorder radius="md" p="lg">
<Group justify="space-between" align="flex-start" wrap="wrap" gap="lg">
<Group gap="md" wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon variant="light" color="edr-green" radius="md" size={52}>
<ShieldCheck size={26} />
</ThemeIcon>
<Box style={{ minWidth: 0 }}>
<Group gap={8} wrap="nowrap">
<Text fw={800} fz={20} c="edr-text" truncate>
{booking?.reference ?? "Clearance"}
</Text>
<Badge
size="sm"
variant="light"
color={direction === "IMPORT" ? "edr-green" : "gray"}
radius="sm"
>
{direction}
</Badge>
{clearance.includesCustoms ? (
<Badge
size="sm"
variant="light"
color="edr-green"
radius="sm"
leftSection={<ShieldCheck size={12} />}
>
Customs
</Badge>
) : null}
</Group>
<Group gap={8} mt={6} wrap="nowrap">
<Text size="sm" fw={600} truncate maw={160}>
{origin}
</Text>
<ArrowRight size={15} className="shrink-0 text-muted-foreground" />
<Text size="sm" fw={600} truncate maw={160}>
{destination}
</Text>
</Group>
</Box>
</Group>
<Box style={{ minWidth: 200, flex: 1, maxWidth: 320 }}>
<Group justify="space-between" mb={6}>
<Text size="xs" c="dimmed" fw={600}>
Document review
</Text>
<Text size="xs" c="dimmed">
{stats.approved}/{stats.total}
</Text>
</Group>
<Progress value={stats.pct} color="edr-green" radius="xl" size="md" />
</Box>
</Group>
{requestedLines ? (
<>
<Box my="md" h={1} bg="var(--mantine-color-default-border)" />
<Group gap={10} align="center" wrap="wrap">
<Text size="xs" fw={700} tt="uppercase" c="dimmed" lts="0.05em">
Requested cargo
</Text>
<RequestedCargoChips lines={requestedLines} size="sm" />
</Group>
</>
) : null}
</Paper>
);
}
function ProgressStat({
color,
label,
value,
}: {
color: string;
label: string;
value: number;
}) {
return (
<Stack gap={2} align="center">
<Text fw={700} fz={18} c="edr-text">
{value}
</Text>
<Group gap={4} wrap="nowrap">
<Box
style={{
width: 7,
height: 7,
borderRadius: 999,
background: `var(--mantine-color-${color}-6)`,
}}
/>
<Text fz="11px" c="dimmed">
{label}
</Text>
</Group>
</Stack>
);
}

View File

@@ -11,7 +11,8 @@ import {
Text,
TextInput,
} from "@mantine/core";
import { DateInput } from "@mantine/dates";
import { DatePickerInput } from "@mantine/dates";
import { getDateRangePresets } from "@/components/common/dateRangePresets";
import { useDebouncedValue } from "@mantine/hooks";
import { useMutation, useQuery } from "@tanstack/react-query";
import { Search, XCircle } from "lucide-react";
@@ -304,29 +305,19 @@ export default function WagonCancellationsPage() {
w={190}
radius="md"
/>
<DateInput
placeholder="From"
value={from}
onChange={(v) => {
setFrom(v ? new Date(v) : null);
<DatePickerInput
type="range"
placeholder="Date range"
value={[from, to]}
onChange={([newFrom, newTo]) => {
setFrom(newFrom ? new Date(newFrom) : null);
setTo(newTo ? new Date(newTo) : null);
resetPage();
}}
maxDate={to ?? undefined}
presets={getDateRangePresets()}
clearable
radius="md"
style={{ minWidth: 140 }}
/>
<DateInput
placeholder="To"
value={to}
onChange={(v) => {
setTo(v ? new Date(v) : null);
resetPage();
}}
minDate={from ?? undefined}
clearable
radius="md"
style={{ minWidth: 140 }}
style={{ minWidth: 220 }}
/>
<Button
variant="subtle"

View File

@@ -10,7 +10,8 @@ import {
TextInput,
ThemeIcon,
} from "@mantine/core";
import { DateInput } from "@mantine/dates";
import { DatePickerInput } from "@mantine/dates";
import { getDateRangePresets } from "@/components/common/dateRangePresets";
import { useDebouncedValue } from "@mantine/hooks";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { FileText, Inbox, RefreshCw, Search, User, X } from "lucide-react";
@@ -348,31 +349,20 @@ export default function ClearanceDocumentsPage() {
style={{ minWidth: 140 }}
aria-label="Filter by ownership"
/>
<DateInput
placeholder="Created from"
value={createdFrom}
onChange={(v) => {
setCreatedFrom(v ? new Date(v) : null);
<DatePickerInput
type="range"
placeholder="Created date range"
value={[createdFrom, createdTo]}
onChange={([from, to]) => {
setCreatedFrom(from ? new Date(from) : null);
setCreatedTo(to ? new Date(to) : null);
resetPage();
}}
maxDate={createdTo ?? undefined}
presets={getDateRangePresets()}
clearable
radius="lg"
style={{ minWidth: 140 }}
aria-label="Created from"
/>
<DateInput
placeholder="Created to"
value={createdTo}
onChange={(v) => {
setCreatedTo(v ? new Date(v) : null);
resetPage();
}}
minDate={createdFrom ?? undefined}
clearable
radius="lg"
style={{ minWidth: 140 }}
aria-label="Created to"
style={{ minWidth: 220 }}
aria-label="Created date range"
/>
</Group>
</Box>

View File

@@ -10,12 +10,9 @@ import {
Grid,
Group,
Loader,
Paper,
Progress,
RingProgress,
Stack,
Text,
ThemeIcon,
} from "@mantine/core";
import {
AlertCircle,
@@ -37,9 +34,11 @@ import {
import { BookingChangesRequestedAlert } from "@/components/contracts/BookingChangesRequestedAlert";
import { ClearanceOpsTabs } from "@/components/contracts/ClearanceOpsTabs";
import { PageContainer } from "@/components/page/PageContainer";
import { PageHeader } from "@/components/page/PageHeader";
import { PageContainer, PageHeader, KpiStrip } from "@/components/page";
import type { KpiItem } from "@/components/page";
import { EntityLink } from "@/components/detail";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { RequestCustomerCard } from "@/components/contracts/detail/RequestDetailCards";
import { ContractClearanceReviewSection } from "@/components/contracts/ContractClearanceReviewSection";
import { GlUpcomingWindowsSection } from "@/components/contracts/GlUpcomingWindowsSection";
import { PhasedClearanceActionPanel } from "@/components/contracts/PhasedClearanceActionPanel";
@@ -196,6 +195,29 @@ export default function ContractClearanceDetailPage() {
const workflowFiles = clearance.workflowFiles ?? [];
const direction = contract?.tradeDirection ?? "—";
const customs =
contract?.serviceType?.includesCustoms ??
contract?.customsClearingEnabled ??
false;
const routes = [...(contract?.routes ?? [])].sort(
(a, b) => a.sortOrder - b.sortOrder,
);
const origin =
routes[0]?.originYard?.label ?? routes[0]?.originYard?.code ?? "Origin";
const lastRoute = routes[routes.length - 1] ?? routes[0];
const destination =
lastRoute?.destinationYard?.label ??
lastRoute?.destinationYard?.code ??
"Destination";
const kpis: KpiItem[] = [
{ label: "Approved", value: stats.approved, icon: CheckCircle2, color: "edr-green" },
{ label: "Queried", value: stats.queried, icon: AlertCircle, color: "red" },
{ label: "Pending", value: stats.pending, icon: Clock, color: "gray" },
{ label: "Review progress", value: `${stats.pct}%`, icon: PackageCheck, color: "blue" },
];
return (
<PageContainer>
<Stack gap="lg">
@@ -206,57 +228,81 @@ export default function ContractClearanceDetailPage() {
{ label: hubLabel, href: hubHref },
{ label: reference },
]}
subtitle={
<Group gap={8} wrap="nowrap">
{id ? (
<EntityLink to={`/dashboard/contract-requests/${id}`} label="Contract details" />
) : null}
<Text size="sm" c="dimmed">
· {origin}
</Text>
<ArrowRight size={14} className="shrink-0 text-muted-foreground" />
<Text size="sm" c="dimmed">
{destination}
</Text>
</Group>
}
meta={
bookingExpired ? (
<Badge
variant="light"
color="orange"
radius="sm"
leftSection={<RefreshCw size={13} />}
>
Payment expired rebook
<Group gap={6} wrap="wrap">
<Badge variant="light" color={direction === "IMPORT" ? "edr-green" : "gray"} radius="sm">
{directionLabel(direction)}
</Badge>
) : bookingAlreadyCreated ? (
<Badge
variant="light"
color="blue"
radius="sm"
leftSection={<PackageCheck size={13} />}
>
Booking created
</Badge>
) : ready ? (
<Badge
variant="light"
color="edr-green"
radius="sm"
leftSection={<PackageCheck size={13} />}
>
Ready create booking
</Badge>
) : clearance.allApproved ? (
<Badge
variant="light"
color="edr-green"
radius="sm"
leftSection={<CheckCircle2 size={13} />}
>
All approved
</Badge>
) : (
<Badge
variant="light"
color="gray"
radius="sm"
leftSection={<Clock size={13} />}
>
Review pending
</Badge>
)
{customs ? (
<Badge variant="light" color="edr-green" radius="sm" leftSection={<ShieldCheck size={12} />}>
Customs
</Badge>
) : null}
{bookingExpired ? (
<Badge
variant="light"
color="orange"
radius="sm"
leftSection={<RefreshCw size={13} />}
>
Payment expired rebook
</Badge>
) : bookingAlreadyCreated ? (
<Badge
variant="light"
color="blue"
radius="sm"
leftSection={<PackageCheck size={13} />}
>
Booking created
</Badge>
) : ready ? (
<Badge
variant="light"
color="edr-green"
radius="sm"
leftSection={<PackageCheck size={13} />}
>
Ready create booking
</Badge>
) : clearance.allApproved ? (
<Badge
variant="light"
color="edr-green"
radius="sm"
leftSection={<CheckCircle2 size={13} />}
>
All approved
</Badge>
) : (
<Badge
variant="light"
color="gray"
radius="sm"
leftSection={<Clock size={13} />}
>
Review pending
</Badge>
)}
</Group>
}
/>
<ClearanceHero contract={contract} stats={stats} />
<KpiStrip items={kpis} />
{/* Windows on this contract's routes/direction only — tells GL ET when
it can actually create the booking without checking the schedule board. */}
@@ -383,6 +429,7 @@ export default function ContractClearanceDetailPage() {
<Grid.Col span={{ base: 12, lg: 5 }}>
<Stack gap="md">
<RequestCustomerCard contract={contract} />
{phasedCustoms ? (
<PhasedClearanceActionPanel
contractId={id!}
@@ -425,23 +472,6 @@ export default function ContractClearanceDetailPage() {
</Stack>
}
/>
<Group gap="lg" justify="center">
<ProgressStat
color="edr-green"
label="Approved"
value={stats.approved}
/>
<ProgressStat
color="red"
label="Queried"
value={stats.queried}
/>
<ProgressStat
color="gray"
label="Pending"
value={stats.pending}
/>
</Group>
</Stack>
</SectionCard>
</Box>
@@ -457,126 +487,3 @@ export default function ContractClearanceDetailPage() {
);
}
function ClearanceHero({
contract,
stats,
}: {
contract: ReturnType<typeof useContractDetail>["data"];
stats: { pct: number; approved: number; total: number };
}) {
const direction = contract?.tradeDirection ?? "—";
const serviceName = contract?.serviceType?.serviceName ?? null;
const customs =
contract?.serviceType?.includesCustoms ??
contract?.customsClearingEnabled ??
false;
const routes = [...(contract?.routes ?? [])].sort(
(a, b) => a.sortOrder - b.sortOrder,
);
const origin =
routes[0]?.originYard?.label ?? routes[0]?.originYard?.code ?? "Origin";
const last = routes[routes.length - 1] ?? routes[0];
const destination =
last?.destinationYard?.label ??
last?.destinationYard?.code ??
"Destination";
return (
<Paper withBorder radius="md" p="lg">
<Group justify="space-between" align="flex-start" wrap="wrap" gap="lg">
<Group gap="md" wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon variant="light" color="edr-green" radius="md" size={52}>
<ShieldCheck size={26} />
</ThemeIcon>
<Box style={{ minWidth: 0 }}>
<Group gap={8} wrap="nowrap">
<Text fw={800} fz={20} c="edr-text" truncate>
{contract?.reference ?? "Clearance"}
</Text>
<Badge
size="sm"
variant="light"
color={direction === "IMPORT" ? "edr-green" : "gray"}
radius="sm"
>
{directionLabel(direction)}
</Badge>
{customs ? (
<Badge
size="sm"
variant="light"
color="edr-green"
radius="sm"
leftSection={<ShieldCheck size={12} />}
>
Customs
</Badge>
) : (
<Badge size="sm" variant="light" color="gray" radius="sm">
No customs
</Badge>
)}
</Group>
{serviceName && (
<Text size="sm" fw={600} c="edr-text" mt={6} truncate maw={280}>
{serviceName}
</Text>
)}
<Group gap={8} mt={6} wrap="nowrap">
<Text size="sm" fw={600} truncate maw={160}>
{origin}
</Text>
<ArrowRight size={15} className="shrink-0 text-muted-foreground" />
<Text size="sm" fw={600} truncate maw={160}>
{destination}
</Text>
</Group>
</Box>
</Group>
<Box style={{ minWidth: 200, flex: 1, maxWidth: 320 }}>
<Group justify="space-between" mb={6}>
<Text size="xs" c="dimmed" fw={600}>
Document review
</Text>
<Text size="xs" c="dimmed">
{stats.approved}/{stats.total}
</Text>
</Group>
<Progress value={stats.pct} color="edr-green" radius="xl" size="md" />
</Box>
</Group>
</Paper>
);
}
function ProgressStat({
color,
label,
value,
}: {
color: string;
label: string;
value: number;
}) {
return (
<Stack gap={2} align="center">
<Text fw={700} fz={18} c="edr-text">
{value}
</Text>
<Group gap={4} wrap="nowrap">
<Box
style={{
width: 7,
height: 7,
borderRadius: 999,
background: `var(--mantine-color-${color}-6)`,
}}
/>
<Text fz="11px" c="dimmed">
{label}
</Text>
</Group>
</Stack>
);
}

View File

@@ -13,7 +13,8 @@ import {
TextInput,
ThemeIcon,
} from "@mantine/core";
import { DateInput } from "@mantine/dates";
import { DatePickerInput } from "@mantine/dates";
import { getDateRangePresets } from "@/components/common/dateRangePresets";
import { useDebouncedValue } from "@mantine/hooks";
import {
AlertTriangle,
@@ -613,31 +614,20 @@ export default function ContractRequestsPage() {
style={{ minWidth: 140 }}
aria-label="Filter by payment currency"
/>
<DateInput
placeholder="Created from"
value={createdFrom}
onChange={(v) => {
setCreatedFrom(v ? new Date(v) : null);
<DatePickerInput
type="range"
placeholder="Created date range"
value={[createdFrom, createdTo]}
onChange={([from, to]) => {
setCreatedFrom(from ? new Date(from) : null);
setCreatedTo(to ? new Date(to) : null);
resetPage();
}}
maxDate={createdTo ?? undefined}
presets={getDateRangePresets()}
clearable
radius="lg"
style={{ minWidth: 140 }}
aria-label="Created from"
/>
<DateInput
placeholder="Created to"
value={createdTo}
onChange={(v) => {
setCreatedTo(v ? new Date(v) : null);
resetPage();
}}
minDate={createdFrom ?? undefined}
clearable
radius="lg"
style={{ minWidth: 140 }}
aria-label="Created to"
style={{ minWidth: 220 }}
aria-label="Created date range"
/>
{activeFilterCount > 0 ? (
<Button

View File

@@ -29,9 +29,10 @@ import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import type { BookingDetail } from "@/types/booking";
import { PageContainer } from "@/components/page/PageContainer";
import { PageHeader } from "@/components/page/PageHeader";
import { PageContainer, PageHeader } from "@/components/page";
import { EntityLink } from "@/components/detail";
import { ClearanceReviewSection } from "@/components/bookings/detail/ClearanceReviewSection";
import { BookingCompanyCard } from "@/components/bookings/detail/BookingCompanyCard";
import { ContractClearanceReviewSection } from "@/components/contracts/ContractClearanceReviewSection";
import { ClearanceWorkflowFilesPanel } from "@/components/contracts/ClearanceWorkflowFilesPanel";
import { GlExchangePanel } from "@/components/contracts/GlExchangePanel";
@@ -42,6 +43,7 @@ import {
import { PhasedClearanceActionPanel } from "@/components/contracts/PhasedClearanceActionPanel";
import { TransitAssigneePanel } from "@/components/contracts/TransitAssigneePanel";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { RequestCustomerCard } from "@/components/contracts/detail/RequestDetailCards";
import { IncidentReportCard } from "@/components/contracts/gl-actions/IncidentReportCard";
import { findWorkflowFile } from "@/components/contracts/PhasedUploadedFileRow";
import { useFileViewer } from "@/hooks/useFileViewer";
@@ -56,6 +58,7 @@ type GlClearanceDetail =
reference: string;
tradeDirection: string;
clearance: Freight.ContractClearanceView;
contract: Freight.IContract;
}
| {
kind: "booking";
@@ -79,6 +82,7 @@ async function loadGlClearanceDetail(id: string): Promise<GlClearanceDetail> {
reference: contract.reference,
tradeDirection: contract.tradeDirection,
clearance,
contract,
};
} catch {
const [clearance, booking] = await Promise.all([
@@ -181,6 +185,16 @@ export default function GlClearanceDetailPage() {
{ label: "GL Djibouti Clearance", href: backTo },
{ label: data.reference },
]}
subtitle={
<EntityLink
to={
data.kind === "contract"
? `/dashboard/contract-requests/${id}`
: `/dashboard/booking-requests/${id}`
}
label={data.kind === "contract" ? "Contract details" : "Booking details"}
/>
}
meta={
<Badge variant="light" color={isImport ? "edr-green" : "gray"} radius="sm">
{directionLabel(data.tradeDirection)}
@@ -278,37 +292,44 @@ export default function GlClearanceDetailPage() {
</Grid.Col>
<Grid.Col span={{ base: 12, lg: 5 }}>
<PhasedClearanceActionPanel
contractId={data.kind === "contract" ? id : undefined}
bookingId={data.kind === "booking" ? id : linkedBookingId}
// For a per-booking instance, "created" means COMPLETED (has
// cargo/price), not merely that a booking row exists — a bare
// instance is not yet a real booking. Contract-level clearance
// keeps its linked-booking signal.
bookingCreated={
data.kind === "booking"
? bookingCompleted
: Boolean(linkedBookingId)
}
bookingMilestones={
data.kind === "booking"
? (data.clearance.milestones ?? [])
: (bookingMilestones ?? [])
}
clearance={data.clearance}
tradeDirection={data.tradeDirection}
workflowFiles={workflowFiles}
roleMode="DJ"
useUploadModals
onUploadDoRequest={() => setUploadKind("do")}
onUploadRoRequest={() => setUploadKind("ro")}
onChanged={() => {
void refetch();
refetchBookingMilestonesIfLinked();
}}
onViewFile={view}
onDownloadFile={(f) => void downloadBookingFile(f.id, f.name)}
/>
<Stack gap="md">
{data.kind === "contract" ? (
<RequestCustomerCard contract={data.contract} />
) : (
<BookingCompanyCard booking={data.booking} />
)}
<PhasedClearanceActionPanel
contractId={data.kind === "contract" ? id : undefined}
bookingId={data.kind === "booking" ? id : linkedBookingId}
// For a per-booking instance, "created" means COMPLETED (has
// cargo/price), not merely that a booking row exists — a bare
// instance is not yet a real booking. Contract-level clearance
// keeps its linked-booking signal.
bookingCreated={
data.kind === "booking"
? bookingCompleted
: Boolean(linkedBookingId)
}
bookingMilestones={
data.kind === "booking"
? (data.clearance.milestones ?? [])
: (bookingMilestones ?? [])
}
clearance={data.clearance}
tradeDirection={data.tradeDirection}
workflowFiles={workflowFiles}
roleMode="DJ"
useUploadModals
onUploadDoRequest={() => setUploadKind("do")}
onUploadRoRequest={() => setUploadKind("ro")}
onChanged={() => {
void refetch();
refetchBookingMilestonesIfLinked();
}}
onViewFile={view}
onDownloadFile={(f) => void downloadBookingFile(f.id, f.name)}
/>
</Stack>
</Grid.Col>
</Grid>
</Tabs.Panel>

View File

@@ -25,6 +25,7 @@ import type { Freight } from "@edr/types";
import { PageContainer } from "@/components/page/PageContainer";
import { PageHeader } from "@/components/page/PageHeader";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { EntityLink } from "@/components/detail";
import {
RequestCustomerCard,
RequestContractSummaryCard,
@@ -113,7 +114,17 @@ export default function ShipmentRequestDetailPage() {
<Stack gap="lg">
<PageHeader
title={`Shipment request ${request.reference}`}
subtitle={`On contract ${contractRef}`}
subtitle={
<Group gap={6} wrap="wrap">
<Text size="sm" c="dimmed">
On contract
</Text>
<EntityLink
to={`/dashboard/contract-requests/${request.contractId}`}
label={contractRef}
/>
</Group>
}
backTo="/dashboard/shipment-requests"
breadcrumbs={[
{ label: "Shipment Requests", href: "/dashboard/shipment-requests" },

View File

@@ -17,7 +17,8 @@ import {
Textarea,
TextInput,
} from "@mantine/core";
import { DateInput } from "@mantine/dates";
import { DatePickerInput } from "@mantine/dates";
import { getDateRangePresets } from "@/components/common/dateRangePresets";
import {
ArrowUpDown,
FilterX,
@@ -460,25 +461,19 @@ export default function ShipmentRequestsPage() {
allowDeselect={false}
aria-label="Cargo type"
/>
<DateInput
<DatePickerInput
type="range"
radius="md"
w={150}
placeholder="Preferred from"
value={preferredFrom}
onChange={(v) => setPreferredFrom(v ? new Date(v) : null)}
maxDate={preferredTo ?? undefined}
w={230}
placeholder="Preferred date range"
value={[preferredFrom, preferredTo]}
onChange={([from, to]) => {
setPreferredFrom(from ? new Date(from) : null);
setPreferredTo(to ? new Date(to) : null);
}}
presets={getDateRangePresets()}
clearable
aria-label="Preferred date from"
/>
<DateInput
radius="md"
w={150}
placeholder="Preferred to"
value={preferredTo}
onChange={(v) => setPreferredTo(v ? new Date(v) : null)}
minDate={preferredFrom ?? undefined}
clearable
aria-label="Preferred date to"
aria-label="Preferred date range"
/>
<Select
radius="md"

View File

@@ -24,6 +24,7 @@ import {
Contact,
Download,
Eye,
FileSignature,
FileText,
History,
Hourglass,
@@ -55,7 +56,6 @@ import {
ProfileStatusBadge,
ProfileTypeBadge,
RequestDocumentChangeModal,
ResetPasswordAction,
TableCard,
formatBytes,
formatDate,
@@ -63,6 +63,8 @@ import {
humanize,
} from "@/components/customers";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import { ContractStatusBadge } from "@/components/contracts/ContractStatusBadge";
import { useContractList } from "@/hooks/contracts/useContracts";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import {
@@ -85,6 +87,7 @@ import {
usePagination,
type ColumnDef,
} from "@edr/ui-common";
import type { Freight } from "@edr/types";
/** Plain-text summary of the company's eTrade-sourced record, downloaded client-side (eTrade returns data, not a document). */
function downloadTinRecord(company: Company) {
@@ -170,6 +173,7 @@ export default function CustomerDetailPage() {
enabled: Boolean(id),
}),
);
const contractsQuery = useContractList({ companyId: id, pageSize: 100 }, Boolean(id));
const { pagination: invoicePagination, setPagination: setInvoicePagination } =
usePagination({
@@ -191,6 +195,7 @@ export default function CustomerDetailPage() {
);
const bookings = Array.isArray(bookingsQuery.data) ? bookingsQuery.data : [];
const contracts = contractsQuery.data?.items ?? [];
const documents = Array.isArray(documentsQuery.data)
? documentsQuery.data
: [];
@@ -402,6 +407,59 @@ export default function CustomerDetailPage() {
[],
);
const contractColumns: ColumnDef<Freight.IContract>[] = useMemo(
() => [
{
id: "reference",
header: "Contract",
cell: ({ row }) => (
<Text size="sm" fw={600} c="edr-text">
{row.original.reference}
</Text>
),
},
{
id: "kind",
header: "Kind",
cell: ({ row }) => (
<Text size="sm" c="dimmed">
{row.original.contractKind === "GENERAL" ? "General" : "One-time"}
</Text>
),
},
{
id: "status",
header: "Status",
cell: ({ row }) => (
<ContractStatusBadge
status={row.original.status}
isRenewal={Boolean(row.original.renewalOfId)}
/>
),
},
{
id: "validUntil",
header: "Valid until",
cell: ({ row }) => (
<Text size="sm" c="dimmed">
{formatDate(row.original.contractValidUntil)}
</Text>
),
},
{
id: "createdAt",
header: "Created",
meta: { headerClassName: "text-right", cellClassName: "text-right" },
cell: ({ row }) => (
<Text size="sm" c="dimmed">
{formatDate(row.original.createdAt)}
</Text>
),
},
],
[],
);
const documentColumns: ColumnDef<CustomerDocument>[] = useMemo(
() => [
{
@@ -709,7 +767,6 @@ export default function CustomerDetailPage() {
<ChangeRequestPendingBadge companyId={company.id} />
</Group>
}
action={<ResetPasswordAction company={company} />}
/>
<Tabs defaultValue="overview">
@@ -720,6 +777,9 @@ export default function CustomerDetailPage() {
<Tabs.Tab value="bookings" leftSection={<Package size={16} />}>
Bookings
</Tabs.Tab>
<Tabs.Tab value="contracts" leftSection={<FileSignature size={16} />}>
Contracts
</Tabs.Tab>
<Tabs.Tab value="documents" leftSection={<FileText size={16} />}>
Documents
</Tabs.Tab>
@@ -1187,6 +1247,7 @@ export default function CustomerDetailPage() {
status={tableStatus(bookingsQuery)}
emptyMessage="No bookings for this customer."
containerClassName="border-0 shadow-none bg-transparent"
onRowClick={(row) => navigate(`/dashboard/booking-requests/${row.id}`)}
error={
bookingsQuery.isError
? {
@@ -1199,6 +1260,28 @@ export default function CustomerDetailPage() {
</TableCard>
</Tabs.Panel>
{/* CONTRACTS */}
<Tabs.Panel value="contracts" pt="lg">
<TableCard minWidth={860}>
<DataTable
columns={contractColumns}
data={contracts}
status={tableStatus(contractsQuery)}
emptyMessage="No contracts for this customer."
containerClassName="border-0 shadow-none bg-transparent"
onRowClick={(row) => navigate(`/dashboard/contract-requests/${row.id}`)}
error={
contractsQuery.isError
? {
message: "Failed to load contracts.",
onRetry: () => void contractsQuery.refetch(),
}
: undefined
}
/>
</TableCard>
</Tabs.Panel>
{/* DOCUMENTS */}
<Tabs.Panel value="documents" pt="lg">
<Stack gap="lg">

View File

@@ -1,6 +1,7 @@
import type { ColumnDef } from "@edr/ui-common";
import { Box, Button, Card, Container, Group, Modal, Select, Stack, Text, TextInput, Title } from "@mantine/core";
import { DatePickerInput } from "@mantine/dates";
import { getDateRangePresets } from "@/components/common/dateRangePresets";
import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query";
import { api } from "@/services/api";
@@ -613,26 +614,19 @@ const FleetResourcePage = () => {
filters={
<Group gap="sm" wrap="wrap" align="center">
<DatePickerInput
aria-label="Created from"
placeholder="Created from"
value={dateFrom}
onChange={setDateFrom}
maxDate={dateTo ?? undefined}
type="range"
aria-label="Created date range"
placeholder="Created date range"
value={[dateFrom, dateTo]}
onChange={([from, to]) => {
setDateFrom(from);
setDateTo(to);
}}
presets={getDateRangePresets()}
clearable
size="sm"
radius="lg"
w={160}
/>
<DatePickerInput
aria-label="Created to"
placeholder="Created to"
value={dateTo}
onChange={setDateTo}
minDate={dateFrom ?? undefined}
clearable
size="sm"
radius="lg"
w={160}
w={240}
/>
{listFilterSelects ? (
<Group gap="sm" wrap="wrap" align="center">

View File

@@ -0,0 +1,100 @@
import { Tabs } from "@mantine/core";
import { Landmark, Receipt, Wallet } from "lucide-react";
import { useSearchParams } from "react-router-dom";
import { useAuth } from "@/auth/useAuth";
import { PageContainer, PageHeader } from "@/components/page";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import InvoicesPanel from "./InvoicesPage";
import UsdPaymentsPanel from "./UsdPaymentsPage";
import PaymentsPanel from "../payments/PaymentsPage";
/**
* Invoices, Payments, and USD Payments used to be three separate routes/pages
* with near-identical chrome. They're merged here as URL-linkable tabs
* (`?tab=`) on one page — each tab keeps the permission it was individually
* gated on before, and just doesn't render if the user lacks it.
*/
const TABS = [
{
key: "invoices",
label: "Invoices",
icon: Receipt,
permission: FREIGHT_PERMS.invoices.view,
subtitle:
"Every invoice issued across bookings, warehouse fees and clearance charges.",
Panel: InvoicesPanel,
},
{
key: "payments",
label: "Payments",
icon: Wallet,
permission: FREIGHT_PERMS.payments.view,
subtitle: "View and reconcile booking payment transactions.",
Panel: PaymentsPanel,
},
{
key: "usd-payments",
label: "USD Payments",
icon: Landmark,
// Same gate as Invoices, not a dedicated key — mirrors the old route.
permission: FREIGHT_PERMS.invoices.view,
subtitle:
"USD invoices are paid by bank transfer. Upload the customer's slip and confirm the payment before the pay window closes.",
Panel: UsdPaymentsPanel,
},
] as const;
type TabKey = (typeof TABS)[number]["key"];
export default function FinanceHubPage() {
const { user } = useAuth();
const [searchParams, setSearchParams] = useSearchParams();
const visibleTabs = TABS.filter((tab) => hasPermission(user, tab.permission));
const requested = searchParams.get("tab");
const active: TabKey =
visibleTabs.find((tab) => tab.key === requested)?.key ??
visibleTabs[0]?.key ??
"invoices";
const activeTab = visibleTabs.find((tab) => tab.key === active);
const handleChange = (value: string | null) => {
if (!value) return;
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
next.set("tab", value);
return next;
},
{ replace: true },
);
};
return (
<PageContainer>
<PageHeader title={activeTab?.label ?? "Invoices"} subtitle={activeTab?.subtitle} />
<Tabs value={active} onChange={handleChange} keepMounted={false}>
<Tabs.List>
{visibleTabs.map((tab) => (
<Tabs.Tab
key={tab.key}
value={tab.key}
leftSection={<tab.icon size={16} />}
>
{tab.label}
</Tabs.Tab>
))}
</Tabs.List>
{visibleTabs.map((tab) => (
<Tabs.Panel key={tab.key} value={tab.key} pt="lg">
<tab.Panel />
</Tabs.Panel>
))}
</Tabs>
</PageContainer>
);
}

View File

@@ -1,9 +1,11 @@
import type { ReactNode } from "react";
import {
ActionIcon,
Button,
Card,
Center,
Container,
Grid,
Group,
Loader,
SimpleGrid,
@@ -12,7 +14,7 @@ import {
Text,
} from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { ArrowLeft, Download } from "lucide-react";
import { ArrowLeft, Building2, Download, FileText } from "lucide-react";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { EimsFilingCard } from "@/components/invoices/EimsFilingCard";
@@ -26,8 +28,11 @@ import {
humanize,
} from "@/components/customers";
import { PageContainer, PageHeader } from "@/components/page";
import { LinkedEntityCard, type FieldRowProps } from "@/components/detail";
import { useBookingDetail } from "@/hooks/bookings/useBookings";
import { api } from "@/services/api";
import { invoicesService } from "@/services/invoices.service";
import type { Invoice } from "@/types/invoice";
function openPdfBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
@@ -43,7 +48,17 @@ function openPdfBlob(blob: Blob, filename: string) {
setTimeout(() => URL.revokeObjectURL(url), 60_000);
}
function InfoField({ label, value }: { label: string; value?: string | null }) {
function InfoField({
label,
value,
}: {
label: string;
value?: ReactNode;
}) {
const isEmpty =
value === undefined ||
value === null ||
(typeof value === "string" && !value.trim());
return (
<Stack gap={2}>
<Text
@@ -56,12 +71,75 @@ function InfoField({ label, value }: { label: string; value?: string | null }) {
{label}
</Text>
<Text size="sm" c="edr-text">
{value && value.trim() ? value : "—"}
{isEmpty ? "—" : value}
</Text>
</Stack>
);
}
/** Billed-to company, with its contact/registration details as quick-info rows. */
function RecipientCard({ invoice }: { invoice: Invoice }) {
const company = invoice.company;
const rows: FieldRowProps[] = [
{ label: "Profile", value: invoice.companyProfile?.reference },
{ label: "TIN", value: company?.tin },
{ label: "VAT No.", value: company?.vatNumber },
{ label: "Phone", value: company?.phone },
{ label: "Email", value: company?.email },
{ label: "Address", value: company?.address },
];
return (
<LinkedEntityCard
icon={Building2}
title="Recipient"
name={company?.name ?? "Unnamed company"}
to={invoice.companyId ? `/dashboard/customers/${invoice.companyId}` : null}
rows={rows}
emptyMessage="No additional recipient details available."
/>
);
}
/** What the invoice was raised for — a booking's route/wagons when the
* source is a booking; otherwise just the source type and its raw id
* (warehouse/demurrage/first-mile/last-mile ids don't link anywhere). */
function SourceCard({ invoice }: { invoice: Invoice }) {
const isBooking = invoice.source === "booking";
const { data: booking } = useBookingDetail(
isBooking ? invoice.sourceId : undefined,
);
if (!isBooking) {
return (
<LinkedEntityCard
icon={FileText}
title="Source"
name={humanize(invoice.source)}
rows={[{ label: "Reference", value: invoice.sourceId }]}
/>
);
}
const route =
booking?.originYard && booking?.destinationYard
? `${booking.originYard.label}${booking.destinationYard.label}`
: undefined;
return (
<LinkedEntityCard
icon={FileText}
title="Source"
name={booking?.reference ?? invoice.sourceId}
to={`/dashboard/booking-requests/${invoice.sourceId}`}
rows={[
{ label: "Type", value: humanize(invoice.type) },
{ label: "Route", value: route },
{ label: "Wagons", value: booking?.wagonsRequired ?? undefined },
]}
/>
);
}
export default function InvoiceDetailPage() {
const { user } = useAuth();
const canExport = hasPermission(user, FREIGHT_PERMS.invoices.export);
@@ -121,7 +199,7 @@ export default function InvoiceDetailPage() {
]}
backTo="/dashboard/invoices"
title={invoice.invoiceNumber}
subtitle={`${humanize(invoice.source)} · ${invoice.sourceId}`}
subtitle={humanize(invoice.source)}
meta={<InvoiceStatusBadge status={invoice.status} />}
action={
<ActionIcon
@@ -138,125 +216,130 @@ export default function InvoiceDetailPage() {
}
/>
<Stack gap="lg">
<Card>
<Grid gap="lg">
<Grid.Col span={{ base: 12, lg: 8 }}>
<Stack gap="lg">
<Text fw={600} c="edr-text">
Summary
</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="lg">
<InfoField label="Billed to" value={invoice.company?.name} />
<InfoField
label="Profile"
value={invoice.companyProfile?.reference}
/>
<InfoField label="Type" value={humanize(invoice.type)} />
<InfoField label="Currency" value={invoice.currency} />
<InfoField label="Issued" value={formatDate(invoice.issuedAt)} />
<InfoField label="Due" value={formatDate(invoice.dueAt)} />
<InfoField
label="Total"
value={formatMoney(invoice.totalAmount, invoice.currency)}
/>
<InfoField
label="Balance"
value={formatMoney(invoice.balanceAmount, invoice.currency)}
/>
</SimpleGrid>
<Card>
<Stack gap="lg">
<Text fw={600} c="edr-text">
Amounts
</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="lg">
<InfoField label="Currency" value={invoice.currency} />
<InfoField label="Issued" value={formatDate(invoice.issuedAt)} />
<InfoField label="Due" value={formatDate(invoice.dueAt)} />
<InfoField
label="Total"
value={formatMoney(invoice.totalAmount, invoice.currency)}
/>
<InfoField
label="Balance"
value={formatMoney(invoice.balanceAmount, invoice.currency)}
/>
</SimpleGrid>
</Stack>
</Card>
<EimsFilingCard invoiceId={invoice.id} />
<Card>
<Stack gap="md">
<Text fw={600} c="edr-text">
Line items
</Text>
<Table striped withRowBorders={false} verticalSpacing="xs">
<Table.Thead>
<Table.Tr>
<Table.Th>Description</Table.Th>
<Table.Th>Charge type</Table.Th>
<Table.Th ta="right">Quantity</Table.Th>
<Table.Th ta="right">Unit rate</Table.Th>
<Table.Th ta="right">Amount</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{(invoice.lines ?? []).map((line) => (
<Table.Tr key={line.id}>
<Table.Td>{line.description ?? line.chargeType}</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">
{humanize(line.chargeType)}
</Text>
</Table.Td>
<Table.Td ta="right">{line.quantity}</Table.Td>
<Table.Td ta="right">
{formatMoney(line.unitRate, line.currency)}
</Table.Td>
<Table.Td ta="right">
{formatMoney(line.amount, line.currency)}
</Table.Td>
</Table.Tr>
))}
{(invoice.lines ?? []).length === 0 && (
<Table.Tr>
<Table.Td colSpan={5}>
<Text size="sm" c="dimmed" ta="center" py="md">
No line items.
</Text>
</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
<Group
justify="flex-end"
gap="xl"
pt="sm"
style={{
borderTop: "1px solid var(--mantine-color-edr-border-0)",
}}
>
<Stack gap={2} align="flex-end">
<Text size="xs" c="edr-muted">
Subtotal
</Text>
<Text size="sm">
{formatMoney(invoice.subtotalAmount, invoice.currency)}
</Text>
</Stack>
<Stack gap={2} align="flex-end">
<Text size="xs" c="edr-muted">
Tax
</Text>
<Text size="sm">
{formatMoney(invoice.taxAmount, invoice.currency)}
</Text>
</Stack>
<Stack gap={2} align="flex-end">
<Text size="xs" c="edr-muted">
Paid
</Text>
<Text size="sm">
{formatMoney(invoice.paidAmount, invoice.currency)}
</Text>
</Stack>
<Stack gap={2} align="flex-end">
<Text size="xs" fw={700}>
Total
</Text>
<Text size="sm" fw={700}>
{formatMoney(invoice.totalAmount, invoice.currency)}
</Text>
</Stack>
</Group>
</Stack>
</Card>
</Stack>
</Card>
</Grid.Col>
<EimsFilingCard invoiceId={invoice.id} />
<Card>
<Stack gap="md">
<Text fw={600} c="edr-text">
Line items
</Text>
<Table striped withRowBorders={false} verticalSpacing="xs">
<Table.Thead>
<Table.Tr>
<Table.Th>Description</Table.Th>
<Table.Th>Charge type</Table.Th>
<Table.Th ta="right">Quantity</Table.Th>
<Table.Th ta="right">Unit rate</Table.Th>
<Table.Th ta="right">Amount</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{(invoice.lines ?? []).map((line) => (
<Table.Tr key={line.id}>
<Table.Td>{line.description ?? line.chargeType}</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">
{humanize(line.chargeType)}
</Text>
</Table.Td>
<Table.Td ta="right">{line.quantity}</Table.Td>
<Table.Td ta="right">
{formatMoney(line.unitRate, line.currency)}
</Table.Td>
<Table.Td ta="right">
{formatMoney(line.amount, line.currency)}
</Table.Td>
</Table.Tr>
))}
{(invoice.lines ?? []).length === 0 && (
<Table.Tr>
<Table.Td colSpan={5}>
<Text size="sm" c="dimmed" ta="center" py="md">
No line items.
</Text>
</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
<Group
justify="flex-end"
gap="xl"
pt="sm"
style={{
borderTop: "1px solid var(--mantine-color-edr-border-0)",
}}
>
<Stack gap={2} align="flex-end">
<Text size="xs" c="edr-muted">
Subtotal
</Text>
<Text size="sm">
{formatMoney(invoice.subtotalAmount, invoice.currency)}
</Text>
</Stack>
<Stack gap={2} align="flex-end">
<Text size="xs" c="edr-muted">
Tax
</Text>
<Text size="sm">
{formatMoney(invoice.taxAmount, invoice.currency)}
</Text>
</Stack>
<Stack gap={2} align="flex-end">
<Text size="xs" c="edr-muted">
Paid
</Text>
<Text size="sm">
{formatMoney(invoice.paidAmount, invoice.currency)}
</Text>
</Stack>
<Stack gap={2} align="flex-end">
<Text size="xs" fw={700}>
Total
</Text>
<Text size="sm" fw={700}>
{formatMoney(invoice.totalAmount, invoice.currency)}
</Text>
</Stack>
</Group>
<Grid.Col span={{ base: 12, lg: 4 }}>
<Stack gap="lg">
<RecipientCard invoice={invoice} />
<SourceCard invoice={invoice} />
</Stack>
</Card>
</Stack>
</Grid.Col>
</Grid>
</PageContainer>
);
}

View File

@@ -21,7 +21,6 @@ import {
formatMoney,
humanize,
} from "@/components/customers";
import { PageContainer, PageHeader } from "@/components/page";
import { api } from "@/services/api";
import type { Invoice } from "@/types/invoice";
import {
@@ -31,7 +30,8 @@ import {
type ColumnDef,
} from "@edr/ui-common";
export default function InvoicesPage() {
/** 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("");
@@ -127,112 +127,103 @@ export default function InvoicesPage() {
);
return (
<PageContainer>
<PageHeader
title="Invoices"
subtitle="Every invoice issued across bookings, warehouse fees and clearance charges."
action={
<ActionIcon
variant="default"
size="lg"
radius="md"
aria-label="Refresh"
loading={isFetching}
onClick={() => void refetch()}
>
<RefreshCw size={16} />
</ActionIcon>
}
/>
<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 by invoice number…"
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"
/>
<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" },
]}
/>
<Text size="sm" c="dimmed">
{total} record{total !== 1 ? "s" : ""}
</Text>
<ActionIcon
variant="default"
size="lg"
radius="md"
aria-label="Refresh"
loading={isFetching}
onClick={() => void refetch()}
>
<RefreshCw size={16} />
</ActionIcon>
</Group>
</Box>
<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 by invoice number…"
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"
/>
<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" },
]}
/>
<Text size="sm" c="dimmed">
{total} record{total !== 1 ? "s" : ""}
</Text>
</Group>
<Box style={{ overflowX: "auto" }} w="100%">
<Box miw={920}>
<DataTable
columns={columns}
data={rows}
status={isLoading ? "loading" : isError ? "error" : "success"}
onRowClick={(row) => navigate(`/dashboard/invoices/${row.id}`)}
emptyMessage={
debouncedQuery
? "No invoices match your search."
: "No invoices yet."
}
error={
isError
? {
message: "Failed to load invoices.",
onRetry: () => void refetch(),
}
: undefined
}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
pageCount,
}}
containerClassName="border-0 shadow-none bg-transparent"
footer={DataTableFooter}
/>
</Box>
<Box style={{ overflowX: "auto" }} w="100%">
<Box miw={920}>
<DataTable
columns={columns}
data={rows}
status={isLoading ? "loading" : isError ? "error" : "success"}
onRowClick={(row) => navigate(`/dashboard/invoices/${row.id}`)}
emptyMessage={
debouncedQuery
? "No invoices match your search."
: "No invoices yet."
}
error={
isError
? {
message: "Failed to load invoices.",
onRetry: () => void refetch(),
}
: undefined
}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
pageCount,
}}
containerClassName="border-0 shadow-none bg-transparent"
footer={DataTableFooter}
/>
</Box>
</Box>
</Stack>
</Card>
</PageContainer>
</Box>
</Stack>
</Card>
);
}

View File

@@ -25,7 +25,6 @@ import {
humanize,
} from "@/components/customers";
import { PhasedFileDropzone } from "@/components/contracts/PhasedFileDropzone";
import { PageContainer, PageHeader } from "@/components/page";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { api } from "@/services/api";
@@ -95,7 +94,8 @@ function windowClosed(row: OfflineUsdInvoice): boolean {
return Boolean(deadline && new Date(deadline).getTime() <= Date.now());
}
export default function UsdPaymentsPage() {
/** USD Payments tab body of `FinanceHubPage` — page chrome lives in the parent. */
export default function UsdPaymentsPanel() {
const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState("");
@@ -262,24 +262,7 @@ export default function UsdPaymentsPage() {
);
return (
<PageContainer>
<PageHeader
title="USD Payments"
subtitle="USD invoices are paid by bank transfer. Upload the customer's slip and confirm the payment before the pay window closes."
action={
<ActionIcon
variant="default"
size="lg"
radius="md"
aria-label="Refresh"
loading={isFetching}
onClick={() => void refetch()}
>
<RefreshCw size={16} />
</ActionIcon>
}
/>
<>
<Card p={0}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
@@ -324,6 +307,16 @@ export default function UsdPaymentsPage() {
<Text size="sm" c="dimmed">
{total} record{total !== 1 ? "s" : ""}
</Text>
<ActionIcon
variant="default"
size="lg"
radius="md"
aria-label="Refresh"
loading={isFetching}
onClick={() => void refetch()}
>
<RefreshCw size={16} />
</ActionIcon>
</Group>
</Box>
@@ -421,6 +414,6 @@ export default function UsdPaymentsPage() {
</Stack>
)}
</Modal>
</PageContainer>
</>
);
}

View File

@@ -25,7 +25,7 @@ import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import { KpiStrip } from "@/components/page";
import { api } from "@/services/api";
import type { PaymentMethod, PaymentRow } from "@/services/payments.service";
import {
@@ -101,7 +101,8 @@ function formatDate(iso: string | null): string {
const tableHeader =
"text-xs font-semibold uppercase tracking-wide text-muted-foreground";
export default function PaymentsPage() {
/** Payments tab body of `FinanceHubPage` — page chrome lives in the parent. */
export default function PaymentsPanel() {
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState("");
const [statusTab, setStatusTab] = useState<StatusTabKey>("all");
@@ -205,12 +206,7 @@ export default function PaymentsPage() {
];
return (
<PageContainer>
<PageHeader
title="Payments"
subtitle="View and reconcile booking payment transactions."
/>
<Stack gap="lg">
<KpiStrip
loading={summaryLoading}
items={[
@@ -360,6 +356,6 @@ export default function PaymentsPage() {
</Box>
</Stack>
</Card>
</PageContainer>
</Stack>
);
}

View File

@@ -1,445 +1,14 @@
import {
Button,
Card,
Group,
MultiSelect,
Select,
Text,
} from "@mantine/core";
import { DateInput } from "@mantine/dates";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import {
DataTable,
DataTableFooter,
usePagination,
type ColumnDef,
} from "@edr/ui-common";
import { Download, FileSpreadsheet, Printer, RotateCcw } from "lucide-react";
import { useMemo } from "react";
import { useParams, useSearchParams, Link } from "react-router-dom";
import {
Area,
AreaChart,
Bar,
BarChart,
CartesianGrid,
Legend,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import * as XLSX from "xlsx";
import { ALL_TRADE_DIRECTIONS, TRADE_DIRECTION_LABELS } from "@edr/types";
import { useParams } from "react-router-dom";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import { overviewChartColors } from "@/components/overview/overview.styles";
import { api } from "@/services/api";
import type { ReportQueryInput, ReportRow } from "@/types/reports";
import {
REPORT_CONFIG_BY_KEY,
type ReportColumn,
type ReportConfig,
} from "./reportConfigs";
const compact = new Intl.NumberFormat("en", { notation: "compact" });
const UNIT_SUFFIX = { ETB: " ETB", t: " t", "%": "%", min: " min" } as const;
function formatCell(value: unknown, col: ReportColumn): string {
if (value === null || value === undefined || value === "") return "—";
if (col.unit || col.numeric) {
const n = Number(value);
if (!Number.isNaN(n)) {
return `${n.toLocaleString()}${col.unit ? UNIT_SUFFIX[col.unit] : ""}`;
}
}
return String(value);
}
const toDate = (s: string | null): Date | null => (s ? new Date(s) : null);
// Mantine DateInput onChange emits a date string (or null).
const toParam = (d: Date | string | null): string | null => {
if (!d) return null;
return typeof d === "string" ? d.slice(0, 10) : d.toISOString().slice(0, 10);
};
function downloadBlob(content: BlobPart, type: string, filename: string) {
const url = URL.createObjectURL(new Blob([content], { type }));
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
function exportCsv(config: ReportConfig, rows: ReportRow[]) {
const esc = (v: unknown) => `"${String(v ?? "").replace(/"/g, '""')}"`;
const lines = [
config.columns.map((c) => esc(c.label)).join(","),
...rows.map((r) => config.columns.map((c) => esc(r[c.key])).join(",")),
];
downloadBlob(lines.join("\n"), "text/csv;charset=utf-8", `${config.key}.csv`);
}
function exportXlsx(config: ReportConfig, rows: ReportRow[]) {
const sheetRows = rows.map((r) =>
Object.fromEntries(config.columns.map((c) => [c.label, r[c.key] ?? ""])),
);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(
wb,
XLSX.utils.json_to_sheet(sheetRows),
config.title.slice(0, 31),
);
XLSX.writeFile(wb, `${config.key}.xlsx`);
}
function ReportChartView({
config,
rows,
}: {
config: ReportConfig;
rows: ReportRow[];
}) {
const chart = config.chart;
const data = useMemo(() => {
if (!chart) return [];
const sliced = chart.topN ? rows.slice(0, chart.topN) : rows;
// xKey "a+b" concatenates columns (e.g. origin+destination → "A → B").
const keys = chart.xKey.split("+");
return sliced.map((r) => ({
...r,
__x:
keys.length > 1
? keys.map((k) => String(r[k] ?? "")).join(" → ")
: String(r[chart.xKey] ?? ""),
}));
}, [chart, rows]);
if (!chart) return null;
if (data.length === 0) {
return (
<Card withBorder shadow="sm">
<Text c="dimmed" ta="center" py="xl">
No data for the selected filters
</Text>
</Card>
);
}
const ChartComponent =
chart.type === "bar" ? BarChart : chart.type === "line" ? LineChart : AreaChart;
return (
<Card withBorder shadow="sm">
<ResponsiveContainer width="100%" height={280}>
<ChartComponent data={data} margin={{ top: 8, right: 8, left: 8, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis dataKey="__x" tick={{ fontSize: 12 }} interval="preserveStartEnd" />
<YAxis
tick={{ fontSize: 12 }}
tickFormatter={(v: number) => compact.format(v)}
width={56}
/>
<Tooltip formatter={(value) => Number(value ?? 0).toLocaleString()} />
{chart.series.length > 1 ? <Legend /> : null}
{chart.series.map((s, i) => {
const color =
overviewChartColors.pipeline[i % overviewChartColors.pipeline.length];
if (chart.type === "bar") {
return (
<Bar key={s.key} dataKey={s.key} name={s.label} fill={color} radius={[4, 4, 0, 0]} />
);
}
if (chart.type === "line") {
return (
<Line
key={s.key}
type="monotone"
dataKey={s.key}
name={s.label}
stroke={color}
strokeWidth={2}
dot={false}
/>
);
}
return (
<Area
key={s.key}
type="monotone"
dataKey={s.key}
name={s.label}
stroke={color}
fill={color}
fillOpacity={0.15}
strokeWidth={2}
/>
);
})}
</ChartComponent>
</ResponsiveContainer>
</Card>
);
}
import { ReportView } from "@/components/reports/ReportView";
import { PageContainer } from "@/components/page";
export default function ReportPage() {
const { reportKey = "" } = useParams<{ reportKey: string }>();
const config = REPORT_CONFIG_BY_KEY.get(reportKey);
const [params, setParams] = useSearchParams();
const { pagination, setPagination } = usePagination({ pageSize: 20 });
const setParam = (name: string, value: string | null) => {
setParams(
(prev) => {
if (value) prev.set(name, value);
else prev.delete(name);
return prev;
},
{ replace: true },
);
setPagination((p) => ({ ...p, pageIndex: 0 }));
};
const input: ReportQueryInput = {
key: reportKey,
dateFrom: params.get("dateFrom") ?? undefined,
dateTo: params.get("dateTo") ?? undefined,
granularity:
(params.get("granularity") as ReportQueryInput["granularity"]) ?? undefined,
yardIds: params.get("yardIds") ?? undefined,
statuses: params.get("statuses") ?? undefined,
direction: params.get("direction") ?? undefined,
freightType: params.get("freightType") ?? undefined,
};
const reportQuery = useQuery(
api.reports.run.queryOptions({
input,
placeholderData: keepPreviousData,
staleTime: 30_000,
enabled: Boolean(config),
}),
);
const yardsQuery = useQuery(
api.routes.yards.queryOptions({
staleTime: 5 * 60_000,
enabled: Boolean(config?.filters.includes("yards")),
}),
);
if (!config) {
return (
<PageContainer>
<PageHeader title="Unknown report" backTo="/dashboard/reports" />
<Text>
This report does not exist. <Link to="/dashboard/reports">Back to reports</Link>
</Text>
</PageContainer>
);
}
const rows = reportQuery.data?.rows ?? [];
const kpis = reportQuery.data?.kpis ?? [];
const pageCount = Math.max(1, Math.ceil(rows.length / pagination.pageSize));
const columns: ColumnDef<ReportRow, unknown>[] = config.columns.map((col) => ({
accessorKey: col.key,
header: col.label,
cell: (info) => formatCell(info.getValue(), col),
}));
const tableStatus = reportQuery.isLoading
? "loading"
: reportQuery.isError
? "error"
: "success";
return (
<PageContainer>
<PageHeader
title={config.title}
subtitle={config.description}
backTo="/dashboard/reports"
action={
<Group gap="xs">
<Button
variant="default"
size="xs"
leftSection={<Download size={14} />}
onClick={() => exportCsv(config, rows)}
disabled={rows.length === 0}
>
CSV
</Button>
<Button
variant="default"
size="xs"
leftSection={<FileSpreadsheet size={14} />}
onClick={() => exportXlsx(config, rows)}
disabled={rows.length === 0}
>
Excel
</Button>
<Button
variant="default"
size="xs"
leftSection={<Printer size={14} />}
onClick={() => window.print()}
>
Print
</Button>
</Group>
}
/>
<Card withBorder shadow="sm">
<Group gap="sm" align="flex-end" wrap="wrap">
<DateInput
label="From"
size="xs"
clearable
value={toDate(params.get("dateFrom"))}
maxDate={toDate(params.get("dateTo")) ?? undefined}
onChange={(d) => setParam("dateFrom", toParam(d))}
placeholder="All time"
/>
<DateInput
label="To"
size="xs"
clearable
value={toDate(params.get("dateTo"))}
minDate={toDate(params.get("dateFrom")) ?? undefined}
onChange={(d) => setParam("dateTo", toParam(d))}
placeholder="All time"
/>
{config.filters.includes("granularity") ? (
<Select
label="Group by"
size="xs"
data={[
{ value: "day", label: "Day" },
{ value: "week", label: "Week" },
{ value: "month", label: "Month" },
]}
value={params.get("granularity") ?? "day"}
onChange={(v) => setParam("granularity", v)}
allowDeselect={false}
/>
) : null}
{config.filters.includes("yards") ? (
<MultiSelect
label="Yards"
size="xs"
searchable
clearable
w={220}
data={(yardsQuery.data ?? []).map((y) => ({
value: y.id,
label: y.label,
}))}
value={params.get("yardIds")?.split(",").filter(Boolean) ?? []}
onChange={(v) => setParam("yardIds", v.length ? v.join(",") : null)}
placeholder="All yards"
/>
) : null}
{config.filters.includes("direction") ? (
<Select
label="Direction"
size="xs"
clearable
data={ALL_TRADE_DIRECTIONS.map((d) => ({
value: d,
label: TRADE_DIRECTION_LABELS[d],
}))}
value={params.get("direction")}
onChange={(v) => setParam("direction", v)}
placeholder="All"
/>
) : null}
{config.filters.includes("freightType") ? (
<Select
label="Freight type"
size="xs"
clearable
data={["CONTAINER", "BULK"]}
value={params.get("freightType")}
onChange={(v) => setParam("freightType", v)}
placeholder="All"
/>
) : null}
{config.filters.includes("statuses") && config.statusOptions ? (
<MultiSelect
label="Status"
size="xs"
searchable
clearable
w={220}
data={config.statusOptions}
value={params.get("statuses")?.split(",").filter(Boolean) ?? []}
onChange={(v) => setParam("statuses", v.length ? v.join(",") : null)}
placeholder="Default (active)"
/>
) : null}
<Button
variant="subtle"
size="xs"
leftSection={<RotateCcw size={14} />}
onClick={() => setParams({}, { replace: true })}
>
Reset
</Button>
</Group>
</Card>
<KpiStrip
loading={reportQuery.isLoading}
items={kpis.map((k) => ({
label: k.label,
value: k.value.toLocaleString(),
hint: k.unit,
}))}
/>
<ReportChartView config={config} rows={rows} />
<DataTable
columns={columns}
data={rows}
status={tableStatus}
emptyMessage="No data for the selected filters"
error={
reportQuery.isError
? {
message: "Failed to load report",
onRetry: () => void reportQuery.refetch(),
}
: undefined
}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: rows.length,
}}
tableOptions={{
manualPagination: false,
state: { pagination },
onPaginationChange: setPagination,
autoResetPageIndex: false,
}}
footer={({ table, pagination: p }) => (
<DataTableFooter
table={table}
pagination={p}
options={{ labels: { items: "rows" } }}
/>
)}
/>
<ReportView reportKey={reportKey} pageHeader />
</PageContainer>
);
}

View File

@@ -1,159 +0,0 @@
import {
ActionIcon,
Badge,
Card,
Group,
SimpleGrid,
Stack,
Text,
TextInput,
Title,
} from "@mantine/core";
import { Search, Star } from "lucide-react";
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { PageContainer, PageHeader } from "@/components/page";
import {
REPORT_CONFIGS,
REPORT_DOMAINS,
type ReportConfig,
} from "./reportConfigs";
const FAVORITES_KEY = "reports.favorites";
const loadFavorites = (): string[] => {
try {
return JSON.parse(localStorage.getItem(FAVORITES_KEY) ?? "[]");
} catch {
return [];
}
};
function ReportCard({
config,
favorite,
onToggleFavorite,
}: {
config: ReportConfig;
favorite: boolean;
onToggleFavorite: () => void;
}) {
const navigate = useNavigate();
return (
<Card
withBorder
shadow="sm"
className="cursor-pointer transition-colors hover:bg-gray-50"
onClick={() => navigate(`/dashboard/reports/${config.key}`)}
>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<div style={{ minWidth: 0 }}>
<Text fw={600} truncate>
{config.title}
</Text>
<Text size="sm" c="dimmed" lineClamp={2}>
{config.description}
</Text>
</div>
<ActionIcon
variant="subtle"
color={favorite ? "yellow" : "gray"}
aria-label={favorite ? "Remove from favorites" : "Add to favorites"}
onClick={(e) => {
e.stopPropagation();
onToggleFavorite();
}}
>
<Star size={16} fill={favorite ? "currentColor" : "none"} />
</ActionIcon>
</Group>
<Badge mt="sm" size="sm" variant="light">
{config.domain}
</Badge>
</Card>
);
}
export default function ReportsHubPage() {
const [search, setSearch] = useState("");
const [favorites, setFavorites] = useState<string[]>(loadFavorites);
const toggleFavorite = (key: string) => {
setFavorites((prev) => {
const next = prev.includes(key)
? prev.filter((k) => k !== key)
: [...prev, key];
localStorage.setItem(FAVORITES_KEY, JSON.stringify(next));
return next;
});
};
const visible = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return REPORT_CONFIGS;
return REPORT_CONFIGS.filter(
(c) =>
c.title.toLowerCase().includes(q) ||
c.description.toLowerCase().includes(q),
);
}, [search]);
const pinned = visible.filter((c) => favorites.includes(c.key));
const renderGrid = (configs: ReportConfig[]) => (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{configs.map((c) => (
<ReportCard
key={c.key}
config={c}
favorite={favorites.includes(c.key)}
onToggleFavorite={() => toggleFavorite(c.key)}
/>
))}
</SimpleGrid>
);
return (
<PageContainer>
<PageHeader
title="Reports"
subtitle="Operational, commercial and financial reporting"
action={
<TextInput
size="xs"
w={240}
leftSection={<Search size={14} />}
placeholder="Search reports…"
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
/>
}
/>
{pinned.length ? (
<Stack gap="sm">
<Title order={4}>Favorites</Title>
{renderGrid(pinned)}
</Stack>
) : null}
{REPORT_DOMAINS.map((domain) => {
const configs = visible.filter((c) => c.domain === domain);
if (!configs.length) return null;
return (
<Stack key={domain} gap="sm">
<Title order={4}>{domain}</Title>
{renderGrid(configs)}
</Stack>
);
})}
{visible.length === 0 ? (
<Text c="dimmed" ta="center" py="xl">
No reports match {search}
</Text>
) : null}
</PageContainer>
);
}

View File

@@ -0,0 +1,17 @@
import { useQuery } from "@tanstack/react-query";
import { Navigate } from "react-router-dom";
import { api } from "@/services/api";
/**
* `/dashboard/reports` has no page of its own — it forwards to the first
* report the caller has access to (catalog order = registration order,
* already permission-filtered server-side), or home if they have none.
*/
export default function ReportsIndexRedirect() {
const { data: catalog, isLoading } = useQuery(api.reports.catalog.queryOptions());
if (isLoading) return null;
const first = catalog?.[0];
return <Navigate to={first ? `/dashboard/reports/${first.key}` : "/dashboard"} replace />;
}

View File

@@ -1,445 +0,0 @@
import { BookingStatus } from "@edr/types";
export type ReportDomain = "Commercial" | "Operations" | "Finance" | "Data";
export type ReportColumnUnit = "ETB" | "t" | "%" | "min";
export interface ReportColumn {
key: string;
label: string;
/** Numeric unit — formats the cell (thousands separators, suffix). */
unit?: ReportColumnUnit;
numeric?: boolean;
}
export interface ReportChart {
type: "area" | "line" | "bar";
xKey: string;
series: { key: string; label: string }[];
/** Chart only the first N rows (rows arrive sorted by the backend). */
topN?: number;
}
export type ReportFilterKey =
| "granularity"
| "yards"
| "direction"
| "freightType"
| "statuses";
export interface ReportConfig {
key: string;
title: string;
description: string;
domain: ReportDomain;
filters: ReportFilterKey[];
/** Options for the `statuses` filter, when enabled. */
statusOptions?: string[];
chart?: ReportChart;
columns: ReportColumn[];
}
// Full enum from @edr/types; Set dedupes the deprecated AwaitingPayment alias.
const BOOKING_STATUSES = [...new Set(Object.values(BookingStatus))];
// Full list mirroring CONTRACT_STATUSES in contract.entity.ts (no shared enum
// in @edr/types yet).
const CONTRACT_STATUSES = [
"DRAFT",
"SUBMITTED",
"PRICE_CHANGED_PENDING_CONFIRM",
"CHANGES_REQUESTED",
"PENDING_APPROVAL",
"APPROVED",
"APPROVED_PENDING_SIGNATURE",
"CONTRACT_READY",
"SIGNED_CUSTOMER",
"FULLY_EXECUTED",
"CONTRACT_ACTIVE",
"AWAITING_CLEARANCE_DOCUMENTS",
"CLEARANCE_UNDER_REVIEW",
"CLEARANCE_READY_FOR_BOOKING",
"ACTIVE_SHIPMENT_IN_PROGRESS",
"SUSPENDED",
"CONTRACT_CLOSED",
"EXPIRED",
"REJECTED",
"CANCELLED",
"RENEWAL_DRAFT",
"RENEWAL_SUBMITTED",
"RENEWAL_PENDING_APPROVAL",
"AMENDMENTS_PROPOSED",
"ARCHIVED",
];
const INVOICE_STATUSES = [
"ISSUED",
"PENDING",
"PAYMENT_PROCESSING",
"PARTIALLY_PAID",
"PAID",
"OVERDUE",
"REFUNDED",
];
export const REPORT_CONFIGS: ReportConfig[] = [
{
key: "bookings-trend",
title: "Bookings Trend",
description: "Booking volume, tonnage and revenue over time",
domain: "Commercial",
filters: ["granularity", "yards", "direction", "freightType", "statuses"],
statusOptions: BOOKING_STATUSES,
chart: {
type: "area",
xKey: "period",
series: [{ key: "revenue", label: "Revenue (ETB)" }],
},
columns: [
{ key: "period", label: "Period" },
{ key: "bookings", label: "Bookings", numeric: true },
{ key: "tons", label: "Tonnage", unit: "t" },
{ key: "revenue", label: "Revenue", unit: "ETB" },
],
},
{
key: "revenue-by-customer",
title: "Revenue by Customer",
description: "Ranked customers by booking revenue",
domain: "Commercial",
filters: ["yards", "direction", "freightType", "statuses"],
statusOptions: BOOKING_STATUSES,
chart: {
type: "bar",
xKey: "customer",
series: [{ key: "revenue", label: "Revenue (ETB)" }],
topN: 10,
},
columns: [
{ key: "customer", label: "Customer" },
{ key: "bookings", label: "Bookings", numeric: true },
{ key: "tons", label: "Tonnage", unit: "t" },
{ key: "revenue", label: "Revenue", unit: "ETB" },
],
},
{
key: "revenue-by-lane",
title: "Revenue by Lane",
description: "Origin → destination lanes by tonnage and revenue",
domain: "Commercial",
filters: ["direction", "freightType", "statuses"],
statusOptions: BOOKING_STATUSES,
chart: {
type: "bar",
xKey: "origin+destination",
series: [{ key: "revenue", label: "Revenue (ETB)" }],
topN: 10,
},
columns: [
{ key: "origin", label: "Origin" },
{ key: "destination", label: "Destination" },
{ key: "bookings", label: "Bookings", numeric: true },
{ key: "tons", label: "Tonnage", unit: "t" },
{ key: "revenue", label: "Revenue", unit: "ETB" },
],
},
{
key: "contract-utilization",
title: "Contract Utilization",
description: "Committed scope caps vs booked tonnage per contract",
domain: "Commercial",
filters: ["direction", "statuses"],
statusOptions: CONTRACT_STATUSES,
columns: [
{ key: "reference", label: "Contract" },
{ key: "customer", label: "Customer" },
{ key: "status", label: "Status" },
{ key: "kind", label: "Kind" },
{ key: "valid_from", label: "Valid from" },
{ key: "valid_until", label: "Valid until" },
{ key: "committed", label: "Committed", unit: "t" },
{ key: "booked_tons", label: "Booked", unit: "t" },
{ key: "bookings", label: "Bookings", numeric: true },
{ key: "utilization_pct", label: "Utilization", unit: "%" },
],
},
{
key: "train-on-time",
title: "Train On-Time Performance",
description: "Departure punctuality and delays by lane (60-min grace)",
domain: "Operations",
filters: ["yards", "direction"],
chart: {
type: "bar",
xKey: "origin+destination",
series: [{ key: "on_time_pct", label: "On-time %" }],
topN: 15,
},
columns: [
{ key: "origin", label: "Origin" },
{ key: "destination", label: "Destination" },
{ key: "trips", label: "Trips", numeric: true },
{ key: "departed", label: "Departed", numeric: true },
{ key: "avg_dep_delay_min", label: "Avg dep. delay", unit: "min" },
{ key: "avg_arr_delay_min", label: "Avg arr. delay", unit: "min" },
{ key: "on_time_pct", label: "On-time", unit: "%" },
],
},
{
key: "schedule-fill-rate",
title: "Schedule Fill Rate",
description: "Booked tonnage vs wagon capacity per train schedule",
domain: "Operations",
filters: ["yards", "direction"],
chart: {
type: "line",
xKey: "departure",
series: [{ key: "fill_pct", label: "Fill %" }],
},
columns: [
{ key: "train_number", label: "Train" },
{ key: "departure", label: "Departure" },
{ key: "origin", label: "Origin" },
{ key: "destination", label: "Destination" },
{ key: "direction", label: "Direction" },
{ key: "status", label: "Status" },
{ key: "wagon_count", label: "Wagons", numeric: true },
{ key: "capacity_tons", label: "Capacity", unit: "t" },
{ key: "booked_tons", label: "Booked", unit: "t" },
{ key: "fill_pct", label: "Fill", unit: "%" },
],
},
{
key: "trips-per-route",
title: "Trips per Route",
description: "Completed trips and tonnage hauled per lane",
domain: "Operations",
filters: ["yards", "direction"],
chart: {
type: "bar",
xKey: "origin+destination",
series: [{ key: "trips", label: "Trips" }],
topN: 15,
},
columns: [
{ key: "origin", label: "Origin" },
{ key: "destination", label: "Destination" },
{ key: "direction", label: "Direction" },
{ key: "trips", label: "Trips", numeric: true },
{ key: "tons_hauled", label: "Tonnage hauled", unit: "t" },
{ key: "avg_tons_per_trip", label: "Avg per trip", unit: "t" },
],
},
{
key: "invoiced-vs-collected",
title: "Invoiced vs Collected",
description: "Billing issued vs payments received over time",
domain: "Finance",
filters: ["granularity", "direction"],
chart: {
type: "line",
xKey: "period",
series: [
{ key: "invoiced", label: "Invoiced (ETB)" },
{ key: "collected", label: "Collected (ETB)" },
],
},
columns: [
{ key: "period", label: "Period" },
{ key: "invoices", label: "Invoices", numeric: true },
{ key: "invoiced", label: "Invoiced", unit: "ETB" },
{ key: "collected", label: "Collected", unit: "ETB" },
{ key: "outstanding", label: "Outstanding", unit: "ETB" },
],
},
{
key: "aging-receivables",
title: "Aging Receivables",
description: "Outstanding invoice balances by age bucket per customer",
domain: "Finance",
filters: ["direction", "statuses"],
statusOptions: INVOICE_STATUSES,
chart: {
type: "bar",
xKey: "customer",
series: [{ key: "outstanding", label: "Outstanding (ETB)" }],
topN: 10,
},
columns: [
{ key: "customer", label: "Customer" },
{ key: "invoices", label: "Invoices", numeric: true },
{ key: "outstanding", label: "Outstanding", unit: "ETB" },
{ key: "current", label: "Current", unit: "ETB" },
{ key: "overdue_0_30", label: "030d", unit: "ETB" },
{ key: "overdue_31_60", label: "3160d", unit: "ETB" },
{ key: "overdue_61_90", label: "6190d", unit: "ETB" },
{ key: "overdue_90_plus", label: "90d+", unit: "ETB" },
],
},
{
key: "revenue-by-payment-method",
title: "Revenue by Payment Method",
description: "Successful payments broken down by method",
domain: "Finance",
filters: ["direction"],
chart: {
type: "bar",
xKey: "method",
series: [{ key: "amount", label: "Amount (ETB)" }],
},
columns: [
{ key: "method", label: "Method" },
{ key: "payments", label: "Payments", numeric: true },
{ key: "amount", label: "Amount", unit: "ETB" },
],
},
// --- Record-level list exports (Data domain) — filtered or full dumps ---
{
key: "bookings-list",
title: "Bookings Export",
description: "Booking records with customer, lane, cargo, amounts",
domain: "Data",
filters: ["yards", "direction", "freightType", "statuses"],
statusOptions: BOOKING_STATUSES,
columns: [
{ key: "reference", label: "Reference" },
{ key: "created", label: "Created" },
{ key: "customer", label: "Customer" },
{ key: "status", label: "Status" },
{ key: "freight_type", label: "Freight" },
{ key: "direction", label: "Direction" },
{ key: "origin", label: "Origin" },
{ key: "destination", label: "Destination" },
{ key: "cargo", label: "Cargo" },
{ key: "tons", label: "Tonnage", unit: "t" },
{ key: "amount", label: "Amount", unit: "ETB" },
{ key: "payment_status", label: "Payment" },
{ key: "scheduling_status", label: "Scheduling" },
],
},
{
key: "contracts-list",
title: "Contracts Export",
description: "Contract records with validity, status, customer",
domain: "Data",
filters: ["direction", "statuses"],
statusOptions: CONTRACT_STATUSES,
columns: [
{ key: "reference", label: "Reference" },
{ key: "customer", label: "Customer" },
{ key: "kind", label: "Kind" },
{ key: "status", label: "Status" },
{ key: "direction", label: "Direction" },
{ key: "freight_type", label: "Freight" },
{ key: "valid_from", label: "Valid from" },
{ key: "valid_until", label: "Valid until" },
{ key: "created", label: "Created" },
],
},
{
key: "schedules-list",
title: "Train Schedules Export",
description: "Schedule records with planned vs actual times",
domain: "Data",
filters: ["yards", "direction", "statuses"],
statusOptions: ["DRAFT", "SCHEDULED", "DISPATCHED", "ARRIVED", "CANCELLED"],
columns: [
{ key: "train_number", label: "Train" },
{ key: "reference", label: "Reference" },
{ key: "direction", label: "Direction" },
{ key: "status", label: "Status" },
{ key: "origin", label: "Origin" },
{ key: "destination", label: "Destination" },
{ key: "scheduled_departure", label: "Sched. departure" },
{ key: "actual_departure", label: "Actual departure" },
{ key: "scheduled_arrival", label: "Sched. arrival" },
{ key: "actual_arrival", label: "Actual arrival" },
{ key: "max_wagons", label: "Max wagons", numeric: true },
{ key: "wagon_count", label: "Wagons", numeric: true },
],
},
{
key: "fleet-wagons",
title: "Wagons Export",
description: "Wagon fleet with type, capacity, status, location",
domain: "Data",
filters: ["yards", "statuses"],
statusOptions: ["AVAILABLE", "ASSIGNED", "MAINTENANCE"],
columns: [
{ key: "wagon_number", label: "Wagon" },
{ key: "type", label: "Type" },
{ key: "capacity_tons", label: "Capacity", unit: "t" },
{ key: "status", label: "Status" },
{ key: "current_yard", label: "Current yard" },
],
},
{
key: "fleet-locomotives",
title: "Locomotives Export",
description: "Locomotive fleet with type, pull capacity, status",
domain: "Data",
filters: ["yards", "statuses"],
statusOptions: ["AVAILABLE", "OUT_OF_SERVICE"],
columns: [
{ key: "code", label: "Code" },
{ key: "name", label: "Name" },
{ key: "locomotive_type", label: "Type" },
{ key: "max_pull_tons", label: "Max pull", unit: "t" },
{ key: "status", label: "Status" },
{ key: "current_yard", label: "Current yard" },
],
},
{
key: "customers-list",
title: "Customers Export",
description: "Company records with type, status, TIN",
domain: "Data",
filters: ["statuses"],
statusOptions: ["pending", "active"],
columns: [
{ key: "name", label: "Name" },
{ key: "type", label: "Type" },
{ key: "kind", label: "Kind" },
{ key: "status", label: "Status" },
{ key: "tin", label: "TIN" },
{ key: "approved", label: "Approved" },
{ key: "created", label: "Created" },
],
},
{
key: "payments-list",
title: "Payments Export",
description: "Payment transactions with method, status, references",
domain: "Data",
filters: ["direction", "statuses"],
statusOptions: [
"action-required",
"processing",
"success",
"failed",
"canceled",
"refunded",
],
columns: [
{ key: "created", label: "Created" },
{ key: "method", label: "Method" },
{ key: "status", label: "Status" },
{ key: "currency", label: "Currency" },
{ key: "amount", label: "Amount", unit: "ETB" },
{ key: "transaction_id", label: "Transaction" },
{ key: "merchant_order_id", label: "Merchant order" },
{ key: "paid", label: "Paid" },
],
},
];
export const REPORT_CONFIG_BY_KEY = new Map(
REPORT_CONFIGS.map((c) => [c.key, c]),
);
export const REPORT_DOMAINS: ReportDomain[] = [
"Commercial",
"Operations",
"Finance",
"Data",
];

View File

@@ -19,7 +19,8 @@ import {
ThemeIcon,
Tooltip,
} from "@mantine/core";
import { DateInput } from "@mantine/dates";
import { DatePickerInput } from "@mantine/dates";
import { getDateRangePresets } from "@/components/common/dateRangePresets";
import { useDebouncedValue } from "@mantine/hooks";
import {
AlertTriangle,
@@ -792,24 +793,19 @@ export default function BatchBoardPage() {
w={140}
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
/>
<DateInput
<DatePickerInput
type="range"
size="sm"
radius="lg"
placeholder="Departs from"
value={departureFrom}
onChange={(v) => setDepartureFrom(v ? new Date(v) : null)}
placeholder="Departure date range"
value={[departureFrom, departureTo]}
onChange={([from, to]) => {
setDepartureFrom(from ? new Date(from) : null);
setDepartureTo(to ? new Date(to) : null);
}}
presets={getDateRangePresets()}
clearable
w={140}
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
/>
<DateInput
size="sm"
radius="lg"
placeholder="Departs to"
value={departureTo}
onChange={(v) => setDepartureTo(v ? new Date(v) : null)}
clearable
w={140}
w={230}
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
/>
<Select

Some files were not shown because too many files have changed in this diff Show More