diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index f3ba897a0..e10e5dcb7 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -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", diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index caa25fbaa..513d8f9e5 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -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 { + 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", diff --git a/apps/edr-freight-api/src/modules/billing/documents/pdf-render.service.ts b/apps/edr-freight-api/src/modules/billing/documents/pdf-render.service.ts index 447bc2516..e8c3de792 100644 --- a/apps/edr-freight-api/src/modules/billing/documents/pdf-render.service.ts +++ b/apps/edr-freight-api/src/modules/billing/documents/pdf-render.service.ts @@ -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" }, }); diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index 2b1463a95..ea1523882 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -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 { 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, diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 6c3f14ecb..4f26c4415 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts index 2d3297af8..43d489bac 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/aging-receivables.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/aging-receivables.report.ts new file mode 100644 index 000000000..d5709508c --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/aging-receivables.report.ts @@ -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 { + 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) }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/booking-status-breakdown.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/booking-status-breakdown.report.ts new file mode 100644 index 000000000..449c47181 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/booking-status-breakdown.report.ts @@ -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 { + 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' }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/bookings-list.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/bookings-list.report.ts new file mode 100644 index 000000000..ca476cea3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/bookings-list.report.ts @@ -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, +): SelectQueryBuilder { + const { params, directions } = ctx; + qb.where(`b.deleted_at IS NULL AND ${NOT_UMBRELLA}`); + if (params.dateFrom) qb.andWhere('b.created_at >= :dateFrom', { dateFrom: params.dateFrom }); + if (params.dateTo) qb.andWhere('b.created_at < :dateTo', { dateTo: params.dateTo }); + if (params.direction) qb.andWhere('b.trade_direction = :direction', { direction: params.direction }); + if (params.freightType) qb.andWhere('b.freight_type = :freightType', { freightType: params.freightType }); + const statuses = params.statuses as string[] | null; + if (statuses) { + qb.andWhere('b.status IN (:...statuses)', { statuses }); + } else { + qb.andWhere('b.status NOT IN (:...deadStatuses)', { deadStatuses: DEAD_STATUSES }); + } + if (params.search) { + qb.andWhere('(b.reference ILIKE :search OR c.name ILIKE :search)', { + search: `%${params.search}%`, + }); + } + if (directions !== null) { + qb.andWhere(directions.length ? 'b.trade_direction IN (:...directions)' : '1 = 0', { + directions, + }); + } + return qb; +} + +export const bookingsListReport: ReportDefinition = { + key: 'bookings-list', + title: 'Bookings', + description: 'Every booking with customer, route, cargo and revenue', + group: 'Commercial', + filters: [ + { key: 'date', label: 'Created', type: 'daterange' }, + { + key: 'direction', + label: 'Direction', + type: 'select', + options: [ + { value: 'IMPORT', label: 'Import' }, + { value: 'EXPORT', label: 'Export' }, + { value: 'DOMESTIC', label: 'Domestic' }, + ], + }, + { + key: 'freightType', + label: 'Freight type', + type: 'select', + options: [ + { value: 'CONTAINER', label: 'Container' }, + { value: 'BULK', label: 'Bulk' }, + ], + }, + { key: 'statuses', label: 'Status', type: 'multiselect' }, + { key: 'search', label: 'Search reference or customer', type: 'text' }, + ], + columns: [ + { key: 'reference', label: 'Reference', type: 'string', sortable: true, sortExpr: 'b.reference' }, + { key: 'created', label: 'Created', type: 'date', sortable: true, sortExpr: 'b.created_at' }, + { key: 'customer', label: 'Customer', type: 'string', sortable: true, sortExpr: 'c.name' }, + { key: 'status', label: 'Status', type: 'string', sortable: true, sortExpr: 'b.status' }, + { key: 'direction', label: 'Direction', type: 'string' }, + { key: 'origin', label: 'Origin', type: 'string' }, + { key: 'destination', label: 'Destination', type: 'string' }, + { key: 'cargo', label: 'Cargo', type: 'string' }, + { key: 'tons', label: 'Tonnage', type: 'tons', sortable: true }, + { key: 'amount', label: 'Amount', type: 'money', sortable: true }, + ], + defaultSort: { key: 'created', dir: 'DESC' }, + query(ctx) { + const qb = ctx.ds + .createQueryBuilder() + .select('b.reference', 'reference') + .addSelect(`to_char(b.created_at, 'YYYY-MM-DD')`, 'created') + .addSelect('c.name', 'customer') + .addSelect('b.status', 'status') + .addSelect('b.trade_direction', 'direction') + .addSelect('o.label', 'origin') + .addSelect('d.label', 'destination') + .addSelect('COALESCE(cty.cargo_type_name, b.cargo_free_text)', 'cargo') + .addSelect(`ROUND(${TONS})::float8`, 'tons') + .addSelect(`ROUND(${REVENUE})::float8`, 'amount') + .from(Booking, 'b') + .innerJoin(Company, 'c', 'c.id = b.company_id') + .innerJoin(Yard, 'o', 'o.id = b.origin_yard_id') + .innerJoin(Yard, 'd', 'd.id = b.destination_yard_id') + .leftJoin(CargoType, 'cty', 'cty.id = b.cargo_type_id'); + return applyFilters(ctx, qb); + }, + async summary(ctx) { + const qb = applyFilters( + ctx, + ctx.ds + .createQueryBuilder() + .select('COUNT(*)::int', 'bookings') + .addSelect(`ROUND(COALESCE(SUM(${TONS}), 0))::float8`, 'tons') + .addSelect(`ROUND(COALESCE(SUM(${REVENUE}), 0))::float8`, 'revenue') + .from(Booking, 'b') + .innerJoin(Company, 'c', 'c.id = b.company_id'), + ); + const row = await qb.getRawOne(); + return [ + { label: 'Bookings', value: Number(row?.bookings ?? 0) }, + { label: 'Tonnage', value: Number(row?.tons ?? 0), unit: 't' }, + { label: 'Revenue', value: Number(row?.revenue ?? 0), unit: 'ETB' }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/cargo-summary.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/cargo-summary.report.ts new file mode 100644 index 000000000..ee3063ee1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/cargo-summary.report.ts @@ -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 { + 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' }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/contract-lifecycle.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/contract-lifecycle.report.ts new file mode 100644 index 000000000..513abeda4 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/contract-lifecycle.report.ts @@ -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 { + const { params, directions } = ctx; + const qb = ctx.ds + .createQueryBuilder() + .from(Contract, 'ct') + .leftJoin(Company, 'c', 'c.id = ct.company_id') + .where('ct.deleted_at IS NULL'); + + if (params.dateFrom) qb.andWhere('ct.contract_valid_from >= :dateFrom', { dateFrom: params.dateFrom }); + if (params.dateTo) qb.andWhere('ct.contract_valid_from < :dateTo', { dateTo: params.dateTo }); + if (params.kind) qb.andWhere('ct.contract_kind = :kind', { kind: params.kind }); + if (params.direction) qb.andWhere('ct.trade_direction = :direction', { direction: params.direction }); + const statuses = params.statuses as string[] | null; + if (statuses) qb.andWhere('ct.status IN (:...statuses)', { statuses }); + if (directions !== null) { + qb.andWhere(directions.length ? 'ct.trade_direction IN (:...directions)' : '1 = 0', { directions }); + } + return qb; +} + +export const contractLifecycleReport: ReportDefinition = { + key: 'contract-lifecycle', + title: 'Contracts', + description: 'Signed, active and cancelled contracts', + group: 'Commercial', + filters: [ + { key: 'date', label: 'Valid from', type: 'daterange' }, + { key: 'kind', label: 'Kind', type: 'select', options: CONTRACT_KINDS.map((v) => ({ value: v, label: v })) }, + { + key: 'direction', + label: 'Direction', + type: 'select', + options: [ + { value: 'IMPORT', label: 'Import' }, + { value: 'EXPORT', label: 'Export' }, + { value: 'DOMESTIC', label: 'Domestic' }, + ], + }, + { key: 'statuses', label: 'Status', type: 'multiselect', options: CONTRACT_STATUSES.map((v) => ({ value: v, label: v.replace(/_/g, ' ') })) }, + ], + columns: [ + { key: 'reference', label: 'Reference', type: 'string', sortable: true, sortExpr: 'ct.reference' }, + { key: 'customer', label: 'Customer', type: 'string', sortable: true, sortExpr: 'c.name' }, + { key: 'kind', label: 'Kind', type: 'string' }, + { key: 'direction', label: 'Direction', type: 'string' }, + { key: 'freightType', label: 'Freight type', type: 'string' }, + { key: 'status', label: 'Status', type: 'string', sortable: true, sortExpr: 'ct.status' }, + { key: 'validFrom', label: 'Valid from', type: 'date', sortable: true, sortExpr: 'ct.contract_valid_from' }, + { key: 'validUntil', label: 'Valid until', type: 'date' }, + { key: 'signedAt', label: 'Signed', type: 'date' }, + ], + defaultSort: { key: 'validFrom', dir: 'DESC' }, + query(ctx) { + return baseQuery(ctx) + .select('ct.reference', 'reference') + .addSelect("COALESCE(c.name, ct.government_institution, 'Unknown')", 'customer') + .addSelect('ct.contract_kind', 'kind') + .addSelect('ct.trade_direction', 'direction') + .addSelect('ct.freight_type', 'freightType') + .addSelect('ct.status', 'status') + .addSelect(`to_char(ct.contract_valid_from, 'YYYY-MM-DD')`, 'validFrom') + .addSelect(`to_char(ct.contract_valid_until, 'YYYY-MM-DD')`, 'validUntil') + .addSelect(`to_char(ct.fully_executed_at, 'YYYY-MM-DD')`, 'signedAt'); + }, + async summary(ctx) { + const row = await baseQuery(ctx) + .select('COUNT(*)::int', 'total') + .addSelect('COUNT(*) FILTER (WHERE ct.fully_executed_at IS NOT NULL)::int', 'signed') + .addSelect("COUNT(*) FILTER (WHERE ct.status = 'CANCELLED')::int", 'cancelled') + .getRawOne(); + return [ + { label: 'Contracts', value: Number(row?.total ?? 0) }, + { label: 'Signed', value: Number(row?.signed ?? 0) }, + { label: 'Cancelled', value: Number(row?.cancelled ?? 0) }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/contract-utilization.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/contract-utilization.report.ts new file mode 100644 index 000000000..03f98d306 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/contract-utilization.report.ts @@ -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 { + 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: '%' }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/customer-status.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/customer-status.report.ts new file mode 100644 index 000000000..60b6ff31a --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/customer-status.report.ts @@ -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 { + const { params } = ctx; + const qb = ctx.ds + .createQueryBuilder() + .from(CompanyProfile, 'cp') + .innerJoin(Company, 'c', 'c.id = cp.company_id') + .where('cp.deleted_at IS NULL'); + + if (params.type) qb.andWhere('cp.type = :type', { type: params.type }); + const statuses = params.statuses as string[] | null; + if (statuses) qb.andWhere('cp.status IN (:...statuses)', { statuses }); + return qb; +} + +export const customerStatusReport: ReportDefinition = { + key: 'customer-status', + title: 'Customer Profiles', + description: 'Company profiles by role type and approval status', + group: 'Commercial', + filters: [ + { key: 'type', label: 'Type', type: 'select', options: TYPE_OPTIONS }, + { key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS }, + ], + columns: [ + { key: 'company', label: 'Company', type: 'string', sortable: true, sortExpr: 'c.name' }, + { key: 'type', label: 'Type', type: 'string', sortable: true, sortExpr: 'cp.type' }, + { key: 'status', label: 'Status', type: 'string', sortable: true, sortExpr: 'cp.status' }, + { key: 'reference', label: 'Reference', type: 'string' }, + { key: 'note', label: 'Note', type: 'string' }, + { key: 'reviewedAt', label: 'Reviewed', type: 'date', sortable: true, sortExpr: 'cp.reviewed_at' }, + ], + defaultSort: { key: 'reviewedAt', dir: 'DESC' }, + query(ctx) { + return baseQuery(ctx) + .select('c.name', 'company') + .addSelect('cp.type', 'type') + .addSelect('cp.status', 'status') + .addSelect("COALESCE(cp.reference, '')", 'reference') + .addSelect("COALESCE(cp.review_note, '')", 'note') + .addSelect(`to_char(cp.reviewed_at, 'YYYY-MM-DD')`, 'reviewedAt'); + }, + async summary(ctx) { + const row = await baseQuery(ctx) + .select('COUNT(*)::int', 'total') + .addSelect('COUNT(*) FILTER (WHERE cp.status = :active)::int', 'active') + .addSelect('COUNT(*) FILTER (WHERE cp.status = :suspended)::int', 'suspended') + .setParameters({ active: ProfileStatus.Active, suspended: ProfileStatus.Suspended }) + .getRawOne(); + return [ + { label: 'Profiles', value: Number(row?.total ?? 0) }, + { label: 'Active', value: Number(row?.active ?? 0) }, + { label: 'Suspended', value: Number(row?.suspended ?? 0) }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/customs-documents.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/customs-documents.report.ts new file mode 100644 index 000000000..a0bcb32fc --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/customs-documents.report.ts @@ -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 { + 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) }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/first-last-mile-bookings.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/first-last-mile-bookings.report.ts new file mode 100644 index 000000000..d3abe10dc --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/first-last-mile-bookings.report.ts @@ -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 { + 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 }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/global-logistics-wagons.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/global-logistics-wagons.report.ts new file mode 100644 index 000000000..d3fd759f7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/global-logistics-wagons.report.ts @@ -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 { + 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) }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/invoices-by-status.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/invoices-by-status.report.ts new file mode 100644 index 000000000..81b183f90 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/invoices-by-status.report.ts @@ -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 { + const { params } = ctx; + const qb = ctx.ds + .createQueryBuilder() + .from(Invoice, 'i') + .innerJoin(Company, 'c', 'c.id = i.company_id') + .leftJoin(CompanyProfile, 'cp', 'cp.id = i.company_profile_id') + .where('i.deleted_at IS NULL'); + + if (params.dateFrom) qb.andWhere('i.issued_at >= :dateFrom', { dateFrom: params.dateFrom }); + if (params.dateTo) qb.andWhere('i.issued_at < :dateTo', { dateTo: params.dateTo }); + const statuses = params.statuses as string[] | null; + if (statuses) qb.andWhere('i.status IN (:...statuses)', { statuses }); + return qb; +} + +export const invoicesByStatusReport: ReportDefinition = { + key: 'invoices-by-status', + title: 'Invoices', + description: 'Every invoice with customer, profile type and settlement status', + group: 'Finance', + filters: [ + { key: 'date', label: 'Issued', type: 'daterange' }, + { key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS }, + ], + columns: [ + { key: 'invoiceNumber', label: 'Invoice No.', type: 'string', sortable: true, sortExpr: 'i.invoice_number' }, + { key: 'customer', label: 'Customer', type: 'string', sortable: true, sortExpr: 'c.name' }, + { key: 'profileType', label: 'Profile', type: 'string' }, + { key: 'status', label: 'Status', type: 'string', sortable: true, sortExpr: 'i.status' }, + { key: 'totalAmount', label: 'Total', type: 'money', sortable: true }, + { key: 'paidAmount', label: 'Paid', type: 'money' }, + { key: 'balanceAmount', label: 'Balance', type: 'money', sortable: true }, + { key: 'issuedAt', label: 'Issued', type: 'date', sortable: true, sortExpr: 'i.issued_at' }, + { key: 'dueAt', label: 'Due', type: 'date' }, + ], + defaultSort: { key: 'issuedAt', dir: 'DESC' }, + query(ctx) { + return baseQuery(ctx) + .select('i.invoice_number', 'invoiceNumber') + .addSelect('c.name', 'customer') + .addSelect("COALESCE(cp.type, 'Unknown')", 'profileType') + .addSelect('i.status', 'status') + .addSelect('ROUND(i.total_amount)::float8', 'totalAmount') + .addSelect('ROUND(i.paid_amount)::float8', 'paidAmount') + .addSelect('ROUND(i.balance_amount)::float8', 'balanceAmount') + .addSelect(`to_char(i.issued_at, 'YYYY-MM-DD')`, 'issuedAt') + .addSelect(`to_char(i.due_at, 'YYYY-MM-DD')`, 'dueAt'); + }, + async summary(ctx) { + const row = await baseQuery(ctx) + .select('COUNT(*)::int', 'invoices') + .addSelect('ROUND(COALESCE(SUM(i.total_amount), 0))::float8', 'total') + .addSelect('ROUND(COALESCE(SUM(i.balance_amount), 0))::float8', 'balance') + .getRawOne(); + return [ + { label: 'Invoices', value: Number(row?.invoices ?? 0) }, + { label: 'Total value', value: Number(row?.total ?? 0), unit: 'ETB' }, + { label: 'Outstanding', value: Number(row?.balance ?? 0), unit: 'ETB' }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/invoicing-pipeline.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/invoicing-pipeline.report.ts new file mode 100644 index 000000000..8907f4f5a --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/invoicing-pipeline.report.ts @@ -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 { + 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' }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/loaded-capacity.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/loaded-capacity.report.ts new file mode 100644 index 000000000..8827b77b8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/loaded-capacity.report.ts @@ -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 { + 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' }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/locomotive-fleet-status.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/locomotive-fleet-status.report.ts new file mode 100644 index 000000000..cf971cf2c --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/locomotive-fleet-status.report.ts @@ -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 { + 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) }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/payments-by-status.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/payments-by-status.report.ts new file mode 100644 index 000000000..15e99a4b1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/payments-by-status.report.ts @@ -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 { + const { params } = ctx; + // payments carries no deleted_at column (unlike the rest of the schema) — + // confirmed against the live DB, not assumed from BaseEntity. + const qb = ctx.ds.createQueryBuilder().from(PaymentEntity, 'p').where('1 = 1'); + + if (params.dateFrom) qb.andWhere('p.created_at >= :dateFrom', { dateFrom: params.dateFrom }); + if (params.dateTo) qb.andWhere('p.created_at < :dateTo', { dateTo: params.dateTo }); + if (params.method) qb.andWhere('p.method = :method', { method: params.method }); + const statuses = params.statuses as string[] | null; + if (statuses) qb.andWhere('p.status IN (:...statuses)', { statuses }); + return qb; +} + +export const paymentsByStatusReport: ReportDefinition = { + key: 'payments-by-status', + title: 'Payments by Status', + description: 'Payment volume and value by status, method and currency', + group: 'Finance', + filters: [ + { key: 'date', label: 'Created', type: 'daterange' }, + { key: 'method', label: 'Method', type: 'select', options: METHOD_OPTIONS }, + { key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS }, + ], + columns: [ + { key: 'status', label: 'Status', type: 'string', sortable: true }, + { key: 'method', label: 'Method', type: 'string', sortable: true }, + { key: 'currency', label: 'Currency', type: 'string' }, + { key: 'payments', label: 'Payments', type: 'number', sortable: true }, + { key: 'amount', label: 'Amount', type: 'money', sortable: true }, + ], + defaultSort: { key: 'amount', dir: 'DESC' }, + query(ctx) { + return baseQuery(ctx) + .select('p.status', 'status') + .addSelect('p.method', 'method') + .addSelect('p.currency', 'currency') + .addSelect('COUNT(*)::int', 'payments') + .addSelect('ROUND(COALESCE(SUM(p.amount), 0))::float8', 'amount') + .groupBy('p.status') + .addGroupBy('p.method') + .addGroupBy('p.currency'); + }, + async summary(ctx) { + const row = await baseQuery(ctx) + .select('COUNT(*)::int', 'payments') + .addSelect("ROUND(COALESCE(SUM(p.amount) FILTER (WHERE p.status = 'success'), 0))::float8", 'paid') + .getRawOne(); + return [ + { label: 'Payments', value: Number(row?.payments ?? 0) }, + { label: 'Total paid', value: Number(row?.paid ?? 0), unit: 'ETB' }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/revenue-by-customer.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/revenue-by-customer.report.ts new file mode 100644 index 000000000..31b9951b8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/revenue-by-customer.report.ts @@ -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 { + 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' }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/revenue-summary.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/revenue-summary.report.ts new file mode 100644 index 000000000..512e05be8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/revenue-summary.report.ts @@ -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 { + const { params, directions } = ctx; + const qb = ctx.ds + .createQueryBuilder() + .from(Booking, 'b') + .where(`b.deleted_at IS NULL AND ${NOT_UMBRELLA}`) + .andWhere('b.status NOT IN (:...deadStatuses)', { deadStatuses: DEAD_STATUSES }); + + if (params.dateFrom) qb.andWhere('b.created_at >= :dateFrom', { dateFrom: params.dateFrom }); + if (params.dateTo) qb.andWhere('b.created_at < :dateTo', { dateTo: params.dateTo }); + if (directions !== null) { + qb.andWhere(directions.length ? 'b.trade_direction IN (:...directions)' : '1 = 0', { directions }); + } + return qb; +} + +export const revenueSummaryReport: ReportDefinition = { + key: 'revenue-summary', + title: 'Revenue Summary', + description: 'Booking revenue by direction, cargo type and currency', + group: 'Finance', + filters: [{ key: 'date', label: 'Created', type: 'daterange' }], + columns: [ + { key: 'direction', label: 'Direction', type: 'string', sortable: true }, + { key: 'freightType', label: 'Cargo type', type: 'string', sortable: true }, + { key: 'currency', label: 'Currency', type: 'string' }, + { key: 'bookings', label: 'Bookings', type: 'number', sortable: true }, + { key: 'revenue', label: 'Revenue', type: 'money', sortable: true }, + ], + defaultSort: { key: 'revenue', dir: 'DESC' }, + chart: { type: 'bar', x: 'direction', y: ['revenue'] }, + query(ctx) { + return baseQuery(ctx) + .select('b.trade_direction', 'direction') + .addSelect('b.freight_type', 'freightType') + .addSelect('b.payment_currency', 'currency') + .addSelect('COUNT(*)::int', 'bookings') + .addSelect(`ROUND(COALESCE(SUM(${REVENUE}), 0))::float8`, 'revenue') + .groupBy('b.trade_direction') + .addGroupBy('b.freight_type') + .addGroupBy('b.payment_currency'); + }, + async summary(ctx) { + const row = await baseQuery(ctx) + .select(`ROUND(COALESCE(SUM(${REVENUE}), 0))::float8`, 'revenue') + .addSelect('COUNT(*)::int', 'bookings') + .getRawOne(); + return [ + { label: 'Bookings', value: Number(row?.bookings ?? 0) }, + { label: 'Total revenue', value: Number(row?.revenue ?? 0), unit: 'ETB' }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/train-schedule-status.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/train-schedule-status.report.ts new file mode 100644 index 000000000..88a25d890 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/train-schedule-status.report.ts @@ -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 { + 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) }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/train-turnaround.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/train-turnaround.report.ts new file mode 100644 index 000000000..7dc7b8c3c --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/train-turnaround.report.ts @@ -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 { + 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' }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/wagon-fleet-status.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/wagon-fleet-status.report.ts new file mode 100644 index 000000000..8855c491b --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/wagon-fleet-status.report.ts @@ -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 { + 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) }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/wagon-requests.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/wagon-requests.report.ts new file mode 100644 index 000000000..df5ac364c --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/wagon-requests.report.ts @@ -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 { + 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' }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/wagon-status-duration.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/wagon-status-duration.report.ts new file mode 100644 index 000000000..348f681d1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/wagon-status-duration.report.ts @@ -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 { + 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' }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/wagon-teu-utilization.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/wagon-teu-utilization.report.ts new file mode 100644 index 000000000..7dabec37c --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/wagon-teu-utilization.report.ts @@ -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 { + 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) }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/dto/report-query.dto.ts b/apps/edr-freight-api/src/modules/reports/dto/report-query.dto.ts deleted file mode 100644 index 82b1a76ef..000000000 --- a/apps/edr-freight-api/src/modules/reports/dto/report-query.dto.ts +++ /dev/null @@ -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; -} diff --git a/apps/edr-freight-api/src/modules/reports/dto/report-result.dto.ts b/apps/edr-freight-api/src/modules/reports/dto/report-result.dto.ts deleted file mode 100644 index cc1c215af..000000000 --- a/apps/edr-freight-api/src/modules/reports/dto/report-result.dto.ts +++ /dev/null @@ -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[]; -} diff --git a/apps/edr-freight-api/src/modules/reports/report-export-request.util.spec.ts b/apps/edr-freight-api/src/modules/reports/report-export-request.util.spec.ts new file mode 100644 index 000000000..dc6fa6230 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/report-export-request.util.spec.ts @@ -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); + }); +}); diff --git a/apps/edr-freight-api/src/modules/reports/report-export-request.util.ts b/apps/edr-freight-api/src/modules/reports/report-export-request.util.ts new file mode 100644 index 000000000..18f2fa322 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/report-export-request.util.ts @@ -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, + 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; +} diff --git a/apps/edr-freight-api/src/modules/reports/report-export.service.ts b/apps/edr-freight-api/src/modules/reports/report-export.service.ts new file mode 100644 index 000000000..f0919c9c7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/report-export.service.ts @@ -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> = { + 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[], + kpis: ReportKpi[], + columns: ReportColumn[] = def.columns, + ): Promise { + 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[], + kpis: ReportKpi[], + columns: ReportColumn[] = def.columns, + ): Promise { + 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[], + kpis: ReportKpi[], + columns: ReportColumn[], + ): string { + const esc = (v: unknown) => + String(v ?? '').replace(/&/g, '&').replace(//g, '>'); + + const kpiHtml = kpis.length + ? `
${kpis + .map( + (k) => + `
${esc(k.label)}
${k.value.toLocaleString()}${k.unit ? ` ${esc(k.unit)}` : ''}
`, + ) + .join('')}
` + : ''; + + const head = columns.map((c) => `${esc(c.label)}`).join(''); + const body = rows + .map( + (row) => + `${columns.map((c) => `${esc(formatCell(row[c.key], c.type))}`).join('')}`, + ) + .join(''); + + return ` +

${esc(def.title)}

+

${esc(def.description)}

+ ${kpiHtml} + ${head}${body}
+ `; + } +} diff --git a/apps/edr-freight-api/src/modules/reports/report-queries.ts b/apps/edr-freight-api/src/modules/reports/report-queries.ts deleted file mode 100644 index 9e4a6f617..000000000 --- a/apps/edr-freight-api/src/modules/reports/report-queries.ts +++ /dev/null @@ -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[]; -} - -type ReportQuery = (ds: DataSource, f: ReportFilters) => Promise; - -// 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[], 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) => 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) => - 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) => 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) => - ['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) => 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) => 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) => 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) => 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) => 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 = { - '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, -}; diff --git a/apps/edr-freight-api/src/modules/reports/report-runner.service.ts b/apps/edr-freight-api/src/modules/reports/report-runner.service.ts new file mode 100644 index 000000000..a9b662077 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/report-runner.service.ts @@ -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; + +/** + * 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 { + const params: Record = {}; + 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 { + 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[]; 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 }; diff --git a/apps/edr-freight-api/src/modules/reports/report.registry.spec.ts b/apps/edr-freight-api/src/modules/reports/report.registry.spec.ts new file mode 100644 index 000000000..ccec53c3d --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/report.registry.spec.ts @@ -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); + } + } + }); +}); diff --git a/apps/edr-freight-api/src/modules/reports/report.registry.ts b/apps/edr-freight-api/src/modules/reports/report.registry.ts new file mode 100644 index 000000000..004b61e5b --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/report.registry.ts @@ -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(REPORTS.map((r) => [r.key, r])); + +export function getReport(key: string): ReportDefinition | undefined { + return BY_KEY.get(key as ReportKey); +} diff --git a/apps/edr-freight-api/src/modules/reports/report.types.ts b/apps/edr-freight-api/src/modules/reports/report.types.ts new file mode 100644 index 000000000..a709ac654 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/report.types.ts @@ -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; + /** 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; + /** KPIs over the same filtered set; shown above the table and in exports. */ + summary?(ctx: ReportContext): Promise; + /** 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 & { + hasSummary: boolean; +}; + +export interface ReportRunResult { + columns: ReportColumn[]; + items: Record[]; + meta: { + page: number; + pageSize: number; + total: number; + totalPages: number; + hasNextPage: boolean; + hasPreviousPage: boolean; + }; + kpis: ReportKpi[]; +} diff --git a/apps/edr-freight-api/src/modules/reports/reports.controller.ts b/apps/edr-freight-api/src/modules/reports/reports.controller.ts index dc64773d8..4d213bc2b 100644 --- a/apps/edr-freight-api/src/modules/reports/reports.controller.ts +++ b/apps/edr-freight-api/src/modules/reports/reports.controller.ts @@ -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 { + 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 { - 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 { + 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; } } diff --git a/apps/edr-freight-api/src/modules/reports/reports.module.ts b/apps/edr-freight-api/src/modules/reports/reports.module.ts index a7fe792a5..2f98e9e04 100644 --- a/apps/edr-freight-api/src/modules/reports/reports.module.ts +++ b/apps/edr-freight-api/src/modules/reports/reports.module.ts @@ -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 {} diff --git a/apps/edr-freight-api/src/modules/reports/reports.repository.ts b/apps/edr-freight-api/src/modules/reports/reports.repository.ts deleted file mode 100644 index 65f154b22..000000000 --- a/apps/edr-freight-api/src/modules/reports/reports.repository.ts +++ /dev/null @@ -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 { - return REPORT_QUERIES[key](this.dataSource, filters); - } -} diff --git a/apps/edr-freight-api/src/modules/reports/reports.service.ts b/apps/edr-freight-api/src/modules/reports/reports.service.ts deleted file mode 100644 index 04e6e9a60..000000000 --- a/apps/edr-freight-api/src/modules/reports/reports.service.ts +++ /dev/null @@ -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 { - 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); - } -} diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index f0b71a056..f3aa1ef2f 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -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 diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 37795b2f2..a9b9db226 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -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. */} } /> - } /> + } + /> }> - } /> - } /> - } /> - } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> {/* Dev/testing page for the mock AI booking assistant. */} { /> } /> - } /> - + + + + } + /> + {/* Payments used to be its own page; it's now the "payments" tab on + the merged Invoices hub. Old bookmarks/links still land there. */} + } + /> + + } /> - } /> { } /> + {/* 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. */} - + + } /> - - - } + element={} /> { } /> - } /> + + + + } + /> { path="bookings/:id/milestones" element={} /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> { + } @@ -781,7 +1018,9 @@ const App = () => { + } @@ -796,9 +1035,7 @@ const App = () => { + } @@ -876,7 +1113,9 @@ const App = () => { +
@@ -944,4 +1183,3 @@ function LegacyGlEthiopiaClearanceRedirect() { } export default App; - diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingCompanyCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingCompanyCard.tsx index 06fafc099..92df02ab6 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingCompanyCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingCompanyCard.tsx @@ -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 ( - - - - - {label} - - - - {value || "—"} - - - ); -} - 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 ( - + + {booking.governmentInstitution ?? "Government"} + ); } @@ -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 ( - - - {rows.length === 0 ? ( - - No additional company details available. - - ) : ( - rows.map((row, index) => ( -
- {index > 0 && } - -
- )) - )} -
-
+ rows={rows} + emptyMessage="No additional company details available." + /> ); } diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingContractCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingContractCard.tsx new file mode 100644 index 000000000..f5fc3bf2a --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingContractCard.tsx @@ -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 ( + + {booking.contractSummary} + + ) : undefined + } + /> + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingContractSummaryCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingContractSummaryCard.tsx deleted file mode 100644 index 1e86a7d2a..000000000 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingContractSummaryCard.tsx +++ /dev/null @@ -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 ( - - - {summary} - - - ); -} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingMileServicesCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingMileServicesCard.tsx index 0a079bb4d..d3bee348d 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingMileServicesCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingMileServicesCard.tsx @@ -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 ( - - {booking.firstMilePickupAddress && ( - + + {hasAddresses && ( + + {booking.firstMilePickupAddress && ( + + )} + {booking.lastMileDeliveryAddress && ( + + )} + )} - {booking.lastMileDeliveryAddress && ( - + {approvedRequest && ( + + + + Last-mile contract + + + {approvedRequest.customerSignedAt + ? `Signed ${new Date(approvedRequest.customerSignedAt).toLocaleDateString()}${ + approvedRequest.signerDisplayName + ? ` by ${approvedRequest.signerDisplayName}` + : "" + }` + : "Awaiting customer signature"} + + + + )} - - {approvedRequest && ( - - - - Last-mile contract - - - {approvedRequest.customerSignedAt - ? `Signed ${new Date(approvedRequest.customerSignedAt).toLocaleDateString()}${ - approvedRequest.signerDisplayName - ? ` by ${approvedRequest.signerDisplayName}` - : "" - }` - : "Awaiting customer signature"} - - - - - )} + {handoverSection} + ); } diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRequestHero.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRequestHero.tsx deleted file mode 100644 index ed9802150..000000000 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRequestHero.tsx +++ /dev/null @@ -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 ( - - - - - - - - - - - Booking reference - - - - - {booking.reference} - - - - - - {booking.schedulingStatus ? ( - - ) : null} - - - {booking.holdExpiresAt && booking.schedulingStatus === "HOLDING" ? ( - - Hold expires {new Date(booking.holdExpiresAt).toLocaleString()} - - ) : null} - - - - - - - - - - {booking.nextStep ? ( - - - - ) : null} - - - - - - - - - - ); -} - -function MetaItem({ - icon: Icon, - text, - strong, -}: { - icon: LucideIcon; - text: ReactNode; - strong?: boolean; -}) { - return ( - - - - {text} - - - ); -} - -function HeroTile({ - icon: Icon, - label, - value, - hint, - accent = "edr-green", -}: { - icon: LucideIcon; - label: string; - value: ReactNode; - hint?: ReactNode; - accent?: string; -}) { - return ( - - - - - - - - {label} - - - {value} - - {hint ? ( - - {hint} - - ) : null} - - - - ); -} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts b/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts index b0b024977..23f9b2bd8 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts @@ -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"; diff --git a/apps/edr-freight-web/backoffice/src/components/common/ListControls.tsx b/apps/edr-freight-web/backoffice/src/components/common/ListControls.tsx index 92ab514ad..8ca604ba4 100644 --- a/apps/edr-freight-web/backoffice/src/components/common/ListControls.tsx +++ b/apps/edr-freight-web/backoffice/src/components/common/ListControls.tsx @@ -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 && ( - <> - - - + { + onDateFromChange(from); + onDateToChange(to); + }} + presets={getDateRangePresets()} + clearable + w={230} + /> )} {children} diff --git a/apps/edr-freight-web/backoffice/src/components/common/dateRangePresets.ts b/apps/edr-freight-web/backoffice/src/components/common/dateRangePresets.ts new file mode 100644 index 000000000..e59e3bb84 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/common/dateRangePresets.ts @@ -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 + * `` 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))] }, + ]; +} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/BookingRequestStatusBadge.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/BookingRequestStatusBadge.tsx new file mode 100644 index 000000000..dc742df4f --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/contracts/BookingRequestStatusBadge.tsx @@ -0,0 +1,16 @@ +import { Badge } from "@mantine/core"; + +const STATUS_COLOR: Record = { + 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 ( + + {status} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/detail/ContractDetailTabCards.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/detail/ContractDetailTabCards.tsx index d29e045c4..5d875d98e 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/detail/ContractDetailTabCards.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/detail/ContractDetailTabCards.tsx @@ -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[number]; @@ -141,25 +142,23 @@ export function ContractCustomerCard({ return ( - - - + 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 }, + ]} + /> ; -interface InfoRowProps { - icon: LucideIcon; - label: string; - value?: string | null; -} - -function InfoRow({ icon: Icon, label, value }: InfoRowProps) { - return ( - - - - - {label} - - - - {value || "—"} - - - ); -} - -function InfoRows({ rows }: { rows: InfoRowProps[] }) { - const visible = rows.filter((r) => r.value); - if (visible.length === 0) { - return ( - - No details available. - - ); - } - return ( - - {visible.map((row, i) => ( -
- {i > 0 && } - -
- ))} -
- ); -} - /** 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 ( - - - + 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 ( - - - + 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", + }, + ]} + /> ); } diff --git a/apps/edr-freight-web/backoffice/src/components/customers/ResetPasswordAction.tsx b/apps/edr-freight-web/backoffice/src/components/customers/ResetPasswordAction.tsx deleted file mode 100644 index f0e9b266a..000000000 --- a/apps/edr-freight-web/backoffice/src/components/customers/ResetPasswordAction.tsx +++ /dev/null @@ -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; -} - -/** - * 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("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 ( - <> - - - setOpened(false)} - title="Send a password-reset link" - centered - > - - - We'll send a single-use link to this customer's primary - contact. They choose their own new password — you will not see it. - The link expires in 24 hours. - - - {targetQuery.isLoading ? ( - - - - ) : targetQuery.isError ? ( - - {targetQuery.error.message} - - ) : target ? ( - <> - setChannel(v as ResetChannel)} - label={`Send the link to ${target.name || "the primary contact"} via`} - > - - - - - - - - These are the primary contact's own login details, which may - differ from the company contact details on the profile. - - - - - ) : null} - - - - ); -} diff --git a/apps/edr-freight-web/backoffice/src/components/customers/index.ts b/apps/edr-freight-web/backoffice/src/components/customers/index.ts index daeb11311..6f869173c 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/index.ts +++ b/apps/edr-freight-web/backoffice/src/components/customers/index.ts @@ -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, diff --git a/apps/edr-freight-web/backoffice/src/components/detail/EntityLink.tsx b/apps/edr-freight-web/backoffice/src/components/detail/EntityLink.tsx new file mode 100644 index 000000000..497c59d6c --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/detail/EntityLink.tsx @@ -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 ( + + {label} + + ); + } + + return ( + e.stopPropagation()} + underline="hover" + c="edr-green" + fw={fw} + fz={size} + ff={mono ? "monospace" : undefined} + className={className} + > + + {Icon ? : null} + {label} + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/detail/Field.tsx b/apps/edr-freight-web/backoffice/src/components/detail/Field.tsx new file mode 100644 index 000000000..7300005b9 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/detail/Field.tsx @@ -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 ( + + + {label} + + + {isEmpty ? "—" : value} + + + ); +} + +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 ( + + + {Icon ? : null} + + {label} + + + + {isEmpty ? "—" : value} + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/detail/LinkedEntityCard.tsx b/apps/edr-freight-web/backoffice/src/components/detail/LinkedEntityCard.tsx new file mode 100644 index 000000000..931932fb5 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/detail/LinkedEntityCard.tsx @@ -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 ( + + + + {visibleRows.length > 0 ? ( + + {visibleRows.map((row, index) => ( +
+ {index > 0 && } + +
+ ))} +
+ ) : emptyMessage ? ( + + {emptyMessage} + + ) : null} + {footer} +
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/detail/index.ts b/apps/edr-freight-web/backoffice/src/components/detail/index.ts new file mode 100644 index 000000000..15e379099 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/detail/index.ts @@ -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"; diff --git a/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts b/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts index 9b75f35f5..20794f72b 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts +++ b/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts @@ -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", }, }, { diff --git a/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx b/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx index 08d81da5a..c032a3220 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx +++ b/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx @@ -10,7 +10,6 @@ import { Hammer, History, Image as ImageIcon, - Landmark, LayoutDashboard, LayoutGrid, MapPin, @@ -52,522 +51,520 @@ import { getCategorySidebarChildren } from "@/pages/ruleEngine/config/resources" * a user's first reachable route without importing the route tree (App.tsx * imports RequirePermission, which imports landing — that would cycle). */ -export const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ - { - title: "Main menu", - items: [ - { - label: "Overview", - href: "/dashboard/overview", - icon: , - permission: FREIGHT_PERMS.overview.view, - }, - { - label: "Reports", - href: "/dashboard/reports", - icon: , - permission: FREIGHT_PERMS.reports.view, - }, - { - label: "Customers", - href: "/dashboard/customers", - icon: , - permission: FREIGHT_PERMS.customers.view, - }, - { - label: "Contracts", - href: "/dashboard/contract-requests", - icon: , - permission: FREIGHT_PERMS.contracts.view, - }, - { - label: "Bookings", - href: "/dashboard/booking-requests", - icon: , - permission: FREIGHT_PERMS.bookings.view, - }, - { - label: "Wagon cancellations", - href: "/dashboard/wagon-cancellations", - icon: , - permission: FREIGHT_PERMS.bookings.wagonCancellationView, - }, - // Operations hub: per-shipment clearance-document review for services - // WITHOUT customs clearing (self-clearance) — bookings only. - { - label: "Clearance Documents", - href: "/dashboard/contracts/clearance-documents", - icon: , - permission: FREIGHT_PERMS.contracts.opsClearanceReview, - }, - { - label: "Payments", - href: "/dashboard/payments", - icon: , - permission: FREIGHT_PERMS.payments.view, - }, - { - label: "Invoices", - href: "/dashboard/invoices", - icon: , - permission: FREIGHT_PERMS.invoices.view, - }, - { - label: "USD Payments", - href: "/dashboard/usd-payments", - icon: , - permission: FREIGHT_PERMS.invoices.view, - }, - { - label: "Support", - href: "/dashboard/support", - icon: , - permission: FREIGHT_PERMS.support.agentView, - }, - ...demoItems, - ], - }, - { - // title: "Port & Terminal", - items: [ - { - label: "Operations", - icon: , - children: [ - { - label: "Clearance", - href: "/dashboard/contracts/clearance", - icon: , - permission: [ - FREIGHT_PERMS.contracts.clearanceReview, - FREIGHT_PERMS.contracts.clearanceEtActions, - ], - }, - // { - // label: "Shipment Requests", - // href: "/dashboard/shipment-requests", - // icon: , - // permission: FREIGHT_PERMS.contracts.createBooking, - // }, - // Operations Path A queue: per-booking self-clearance review for - // GENERAL non-customs booking instances (and legacy self-clear bookings). - // { - // label: "Self-Clearance Review", - // href: "/dashboard/contracts/ops-clearance", - // icon: , - // permission: FREIGHT_PERMS.contracts.opsClearanceReview, - // }, - { - label: "GL Djibouti Clearance", - href: "/dashboard/gl-djibouti/clearance", - icon: , - permission: FREIGHT_PERMS.contracts.clearanceDjActions, - }, - { - label: "Train Schedules", - href: "/dashboard/operations/train-scheduling-v2", - icon: , - permission: FREIGHT_PERMS.trainScheduling.view, - }, - { - label: "Batch Board", - href: "/dashboard/operations/batch-board", - icon: , - permission: FREIGHT_PERMS.trainScheduling.view, - }, - { - label: "First Mile", - href: "/dashboard/operations/first-mile", - icon: , - permission: FREIGHT_PERMS.firstMile.view, - }, - { - label: "Last Mile", - href: "/dashboard/operations/last-mile", - icon: , - permission: FREIGHT_PERMS.lastMile.view, - }, - ], - }, - { - label: "Fleet Management", - icon: , - children: [ - { - label: "Fleet Dashboard", - href: "/dashboard/fleet-dashboard", - icon: , - permission: FREIGHT_PERMS.fleetDashboard.view, - }, - { - label: "Routes", - href: "/dashboard/routes", - icon: , - permission: FREIGHT_PERMS.routes.view, - }, - { - label: "Locomotives", - href: "/dashboard/locomotives", - icon: , - permission: FREIGHT_PERMS.locomotives.view, - }, - { - label: "Train Builder", - href: "/dashboard/train-builder", - icon: , - permission: FREIGHT_PERMS.trains.view, - }, +export const buildSidebarSections = ( + demoItems: SidebarItem[], + reportItems: SidebarItem[] = [], +): SidebarSection[] => [ + { + title: "Main menu", + items: [ + { + label: "Overview", + href: "/dashboard/overview", + icon: , + permission: FREIGHT_PERMS.overview.view, + }, + { + label: "Customers", + href: "/dashboard/customers", + icon: , + permission: FREIGHT_PERMS.customers.view, + }, + { + label: "Contracts", + href: "/dashboard/contract-requests", + icon: , + permission: FREIGHT_PERMS.contracts.view, + }, + { + label: "Bookings", + href: "/dashboard/booking-requests", + icon: , + permission: FREIGHT_PERMS.bookings.view, + }, + { + label: "Wagon cancellations", + href: "/dashboard/wagon-cancellations", + icon: , + permission: FREIGHT_PERMS.bookings.wagonCancellationView, + }, + // Operations hub: per-shipment clearance-document review for services + // WITHOUT customs clearing (self-clearance) — bookings only. + { + label: "Clearance Documents", + href: "/dashboard/contracts/clearance-documents", + icon: , + permission: FREIGHT_PERMS.contracts.opsClearanceReview, + }, + { + // Invoices, Payments, and USD Payments live on one page as tabs + // (FinanceHubPage) — single nav entry, OR'd across both keys so + // either permission alone still gets a user in. + label: "Transactions", + href: "/dashboard/invoices", + icon: , + permission: [FREIGHT_PERMS.invoices.view, FREIGHT_PERMS.payments.view], + }, + { + label: "Support", + href: "/dashboard/support", + icon: , + permission: FREIGHT_PERMS.support.agentView, + }, + ...demoItems, + ], + }, + { + // title: "Port & Terminal", + items: [ + { + label: "Operations", + icon: , + children: [ + { + label: "Clearance", + href: "/dashboard/contracts/clearance", + icon: , + permission: [ + FREIGHT_PERMS.contracts.clearanceReview, + FREIGHT_PERMS.contracts.clearanceEtActions, + ], + }, + // { + // label: "Shipment Requests", + // href: "/dashboard/shipment-requests", + // icon: , + // permission: FREIGHT_PERMS.contracts.createBooking, + // }, + // Operations Path A queue: per-booking self-clearance review for + // GENERAL non-customs booking instances (and legacy self-clear bookings). + // { + // label: "Self-Clearance Review", + // href: "/dashboard/contracts/ops-clearance", + // icon: , + // permission: FREIGHT_PERMS.contracts.opsClearanceReview, + // }, + { + label: "GL Djibouti Clearance", + href: "/dashboard/gl-djibouti/clearance", + icon: , + permission: FREIGHT_PERMS.contracts.clearanceDjActions, + }, + { + label: "Train Schedules", + href: "/dashboard/operations/train-scheduling-v2", + icon: , + permission: FREIGHT_PERMS.trainScheduling.view, + }, + { + label: "Batch Board", + href: "/dashboard/operations/batch-board", + icon: , + permission: FREIGHT_PERMS.trainScheduling.view, + }, + { + label: "First Mile", + href: "/dashboard/operations/first-mile", + icon: , + permission: FREIGHT_PERMS.firstMile.view, + }, + { + label: "Last Mile", + href: "/dashboard/operations/last-mile", + icon: , + permission: FREIGHT_PERMS.lastMile.view, + }, + ], + }, + { + label: "Fleet Management", + icon: , + children: [ + { + label: "Fleet Dashboard", + href: "/dashboard/fleet-dashboard", + icon: , + permission: FREIGHT_PERMS.fleetDashboard.view, + }, + { + label: "Routes", + href: "/dashboard/routes", + icon: , + permission: FREIGHT_PERMS.routes.view, + }, + { + label: "Locomotives", + href: "/dashboard/locomotives", + icon: , + permission: FREIGHT_PERMS.locomotives.view, + }, + { + label: "Train Builder", + href: "/dashboard/train-builder", + icon: , + permission: FREIGHT_PERMS.trains.view, + }, - // { - // label: "Wagon types", - // href: "/dashboard/wagon-types", - // icon: , - // }, - { - label: "Wagons", - href: "/dashboard/wagons", - icon: , - permission: FREIGHT_PERMS.wagons.view, - }, - { - label: "Wagon Transfers", - href: "/dashboard/wagon-transfers", - icon: , - permission: [ - FREIGHT_PERMS.wagons.transferView, - FREIGHT_PERMS.wagons.view, - ], - }, - { - label: "Vehicles", - href: "/dashboard/vehicles", - icon: , - permission: FREIGHT_PERMS.vehicles.view, - }, - { - label: "Drivers", - href: "/dashboard/drivers", - icon: , - permission: FREIGHT_PERMS.drivers.view, - }, - { - label: "Track Vehicles", - href: "/dashboard/tracking", - icon: , - permission: FREIGHT_PERMS.tracking.view, - }, - { - label: "Fuel Purchases", - href: "/dashboard/fuel-purchases", - icon: , - permission: FREIGHT_PERMS.fuel.view, - }, - { - label: "Fuel Analytics", - href: "/dashboard/fuel-stats", - icon: , - permission: FREIGHT_PERMS.fuel.view, - }, - { - label: "Maintenance", - href: "/dashboard/maintenance", - icon: , - permission: FREIGHT_PERMS.maintenance.view, - }, - { - label: "Work Orders", - href: "/dashboard/work-orders", - icon: , - permission: FREIGHT_PERMS.maintenance.view, - }, - { - label: "Compliance & Alerts", - href: "/dashboard/compliance", - icon: , - permission: FREIGHT_PERMS.compliance.view, - }, - { - label: "Incidents", - href: "/dashboard/incidents", - icon: , - // No dedicated backend key exists for incidents yet. Not part of - // the fleet.view/admin fallback cleanup — removing fleet.view - // here with nothing to replace it would lock the page to - // super-admin only, so it stays as the sole (if coarse) gate. - permission: FREIGHT_PERMS.fleet.view, - }, - { - label: "Procurement", - href: "/dashboard/procurement", - icon: , - permission: FREIGHT_PERMS.procurement.view, - }, - { - label: "Financial Reports", - href: "/dashboard/financial-reports", - icon: , - permission: FREIGHT_PERMS.fleetReports.view, - }, - // { - // label: "Containers", - // href: "/dashboard/containers", - // icon: , - // }, - // { - // label: "Cargoes", - // href: "/dashboard/cargoes", - // icon: , - // }, - ], - }, - { - label: "Imports", - href: "/dashboard/import-warehouse", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - children: [ - { - label: "Import Overview", - href: "/dashboard/import-warehouse", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - { - label: "Arrival Queue", - href: "/dashboard/arrival-queue", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - { - label: "Import Trucks", - href: "/dashboard/import-trucks", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - { - label: "Container Returns", - href: "/dashboard/container-returns", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - { - label: "Terminal Inventory", - href: "/dashboard/warehouse-inventory?direction=IMPORT", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - { - label: "Inventory Inquiry", - href: "/dashboard/inventory-inquiry", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - ], - }, - { - label: "Exports", - href: "/dashboard/export-warehouse", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - children: [ - { - label: "Export Overview", - href: "/dashboard/export-warehouse", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - { - label: "Loading Queue", - href: "/dashboard/loading-queue", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - { - label: "Loaded Inventory", - href: "/dashboard/loaded-inventory", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - { - label: "Dispatch Queue", - href: "/dashboard/dispatch-queue", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - { - label: "Djibouti Unloading", - href: "/dashboard/export-djibouti-unloading", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - { - label: "Interchange Documents", - href: "/dashboard/interchange-documents", - icon: , - permission: FREIGHT_PERMS.interchangeDocuments.view, - }, - { - label: "Terminal Inventory", - href: "/dashboard/warehouse-inventory?direction=EXPORT", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - ], - }, - { - label: "Intercity", - href: "/dashboard/intercity", - icon: , - permission: FREIGHT_PERMS.trainScheduling.view, - children: [ - { - label: "Intercity Cargo", - href: "/dashboard/intercity", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - ], - }, - { - label: "Warehouse Management", - icon: , - children: [ - { - label: "Warehouse Dashboard", - href: "/dashboard/warehouse-dashboard", - icon: , - permission: FREIGHT_PERMS.warehouseDashboard.view, - }, - { - // Yard-wide, not per-direction: the gate sees import and export - // trucks at the same barrier. - label: "Trucks on Site", - href: "/dashboard/trucks-on-site", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, - { - label: "Warehouses", - href: "/dashboard/warehouses", - icon: , - permission: FREIGHT_PERMS.warehouses.view, - }, - { - label: "Allocation & Fees", - href: "/dashboard/warehouse-rules", - icon: , - permission: [ - FREIGHT_PERMS.warehouseAllocationRules.view, - FREIGHT_PERMS.warehouseFeeRules.view, - ], - }, - { - label: "Fee Invoices", - href: "/dashboard/warehouse-fee-invoices", - icon: , - permission: FREIGHT_PERMS.warehouseFeeInvoices.view, - }, - ], - }, - ], - }, - { - title: "Freight configuration", - mutedTitle: true, - items: [ - { - label: "File settings", - href: "/dashboard/file-settings", - icon: , - permission: FREIGHT_PERMS.settings.fileUpload.view, - }, - { - label: "Dropdown settings", - href: "/dashboard/dropdown-settings", - icon: , - permission: FREIGHT_PERMS.settings.dropdown.view, - }, - { - // One entry, one stamp. The former "Stamp settings" entry here pointed - // at the per-officer teeter (ማህተም), not a company seal — it moved to - // /user-management/teeter-and-signature. - label: "Company stamp", - href: "/dashboard/stamp-settings", - icon: , - permission: FREIGHT_PERMS.settings.stamp.view, - }, - { - label: "Company logo", - href: "/dashboard/logo-settings", - icon: , - permission: FREIGHT_PERMS.settings.logo.view, - }, - { - label: "Contract templates", - href: "/dashboard/contract-templates", - icon: , - // `view` opens the page; `read` alone is API-only and shows no menu. - permission: FREIGHT_PERMS.settings.contractTemplates.view, - }, - { - label: "Portal content", - href: "/dashboard/portal-content", - icon: , - permission: [ - FREIGHT_PERMS.settings.supportContent.view, - FREIGHT_PERMS.settings.supportContent.manage, - ], - }, - { - label: "Audit logs", - href: "/dashboard/audit-logs", - icon: , - permission: FREIGHT_PERMS.auditLog.view, - }, - { - label: "Configuration", - href: "/dashboard/configuration", - icon: , - children: [ - ...getCategorySidebarChildren("configuration"), - { - label: "Train scheduling rules", - href: "/dashboard/configuration/train-scheduling-rules", - permission: FREIGHT_PERMS.trainScheduling.rulesManage, - }, - { - label: "Trade access", - href: "/dashboard/configuration/trade-access", - permission: FREIGHT_PERMS.tradeAccess.view, - }, - { - label: "Exchange rate", - href: "/dashboard/configuration/exchange-rate", - permission: FREIGHT_PERMS.settings.exchangeRate.view, - }, - ], - }, - { - label: "Rules", - href: "/dashboard/rules", - icon: , - children: getCategorySidebarChildren("rules"), - }, + // { + // label: "Wagon types", + // href: "/dashboard/wagon-types", + // icon: , + // }, + { + label: "Wagons", + href: "/dashboard/wagons", + icon: , + permission: FREIGHT_PERMS.wagons.view, + }, + { + label: "Wagon Transfers", + href: "/dashboard/wagon-transfers", + icon: , + permission: [ + FREIGHT_PERMS.wagons.transferView, + FREIGHT_PERMS.wagons.view, + ], + }, + { + label: "Vehicles", + href: "/dashboard/vehicles", + icon: , + permission: FREIGHT_PERMS.vehicles.view, + }, + { + label: "Drivers", + href: "/dashboard/drivers", + icon: , + permission: FREIGHT_PERMS.drivers.view, + }, + { + label: "Track Vehicles", + href: "/dashboard/tracking", + icon: , + permission: FREIGHT_PERMS.tracking.view, + }, + { + label: "Fuel Purchases", + href: "/dashboard/fuel-purchases", + icon: , + permission: FREIGHT_PERMS.fuel.view, + }, + { + label: "Fuel Analytics", + href: "/dashboard/fuel-stats", + icon: , + permission: FREIGHT_PERMS.fuel.view, + }, + { + label: "Maintenance", + href: "/dashboard/maintenance", + icon: , + permission: FREIGHT_PERMS.maintenance.view, + }, + { + label: "Work Orders", + href: "/dashboard/work-orders", + icon: , + permission: FREIGHT_PERMS.maintenance.view, + }, + { + label: "Compliance & Alerts", + href: "/dashboard/compliance", + icon: , + permission: FREIGHT_PERMS.compliance.view, + }, + { + label: "Incidents", + href: "/dashboard/incidents", + icon: , + // No dedicated backend key exists for incidents yet. Not part of + // the fleet.view/admin fallback cleanup — removing fleet.view + // here with nothing to replace it would lock the page to + // super-admin only, so it stays as the sole (if coarse) gate. + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Procurement", + href: "/dashboard/procurement", + icon: , + permission: FREIGHT_PERMS.procurement.view, + }, + { + label: "Financial Reports", + href: "/dashboard/financial-reports", + icon: , + permission: FREIGHT_PERMS.fleetReports.view, + }, + // { + // label: "Containers", + // href: "/dashboard/containers", + // icon: , + // }, + // { + // label: "Cargoes", + // href: "/dashboard/cargoes", + // icon: , + // }, + ], + }, + { + label: "Imports", + href: "/dashboard/import-warehouse", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + children: [ + { + label: "Import Overview", + href: "/dashboard/import-warehouse", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + { + label: "Arrival Queue", + href: "/dashboard/arrival-queue", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + { + label: "Import Trucks", + href: "/dashboard/import-trucks", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + { + label: "Container Returns", + href: "/dashboard/container-returns", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + { + label: "Terminal Inventory", + href: "/dashboard/warehouse-inventory?direction=IMPORT", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + { + label: "Inventory Inquiry", + href: "/dashboard/inventory-inquiry", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + ], + }, + { + label: "Exports", + href: "/dashboard/export-warehouse", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + children: [ + { + label: "Export Overview", + href: "/dashboard/export-warehouse", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + { + label: "Loading Queue", + href: "/dashboard/loading-queue", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + { + label: "Loaded Inventory", + href: "/dashboard/loaded-inventory", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + { + label: "Dispatch Queue", + href: "/dashboard/dispatch-queue", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + { + label: "Djibouti Unloading", + href: "/dashboard/export-djibouti-unloading", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + { + label: "Interchange Documents", + href: "/dashboard/interchange-documents", + icon: , + permission: FREIGHT_PERMS.interchangeDocuments.view, + }, + { + label: "Terminal Inventory", + href: "/dashboard/warehouse-inventory?direction=EXPORT", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + ], + }, + { + label: "Intercity", + href: "/dashboard/intercity", + icon: , + permission: FREIGHT_PERMS.trainScheduling.view, + children: [ + { + label: "Intercity Cargo", + href: "/dashboard/intercity", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + ], + }, + { + label: "Warehouse Management", + icon: , + children: [ + { + label: "Warehouse Dashboard", + href: "/dashboard/warehouse-dashboard", + icon: , + permission: FREIGHT_PERMS.warehouseDashboard.view, + }, + { + // Yard-wide, not per-direction: the gate sees import and export + // trucks at the same barrier. + label: "Trucks on Site", + href: "/dashboard/trucks-on-site", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + { + label: "Warehouses", + href: "/dashboard/warehouses", + icon: , + permission: FREIGHT_PERMS.warehouses.view, + }, + { + label: "Allocation & Fees", + href: "/dashboard/warehouse-rules", + icon: , + permission: [ + FREIGHT_PERMS.warehouseAllocationRules.view, + FREIGHT_PERMS.warehouseFeeRules.view, + ], + }, + { + label: "Fee Invoices", + href: "/dashboard/warehouse-fee-invoices", + icon: , + permission: FREIGHT_PERMS.warehouseFeeInvoices.view, + }, + ], + }, - { - label: "Staff", - href: "/user-management", - icon: , - permission: [ - FREIGHT_PERMS.admin, - FREIGHT_PERMS.staff.roles.view, - FREIGHT_PERMS.staff.employeeRegistration.view, - FREIGHT_PERMS.staff.roleAssignment.view, - ], - }, - ], - }, -]; + { + label: "Reports", + href: "/dashboard/reports", + icon: , + permission: FREIGHT_PERMS.reports.view, + // Populated from the live GET /reports catalog (already permission- + // filtered server-side) — no report key is ever hand-listed here. + ...(reportItems.length ? { children: reportItems } : {}), + }, + ], + }, + { + title: "Freight configuration", + mutedTitle: true, + items: [ + { + label: "File settings", + href: "/dashboard/file-settings", + icon: , + permission: FREIGHT_PERMS.settings.fileUpload.view, + }, + { + label: "Dropdown settings", + href: "/dashboard/dropdown-settings", + icon: , + permission: FREIGHT_PERMS.settings.dropdown.view, + }, + { + // One entry, one stamp. The former "Stamp settings" entry here pointed + // at the per-officer teeter (ማህተም), not a company seal — it moved to + // /user-management/teeter-and-signature. + label: "Company stamp", + href: "/dashboard/stamp-settings", + icon: , + permission: FREIGHT_PERMS.settings.stamp.view, + }, + { + label: "Company logo", + href: "/dashboard/logo-settings", + icon: , + permission: FREIGHT_PERMS.settings.logo.view, + }, + { + label: "Contract templates", + href: "/dashboard/contract-templates", + icon: , + // `view` opens the page; `read` alone is API-only and shows no menu. + permission: FREIGHT_PERMS.settings.contractTemplates.view, + }, + { + label: "Portal content", + href: "/dashboard/portal-content", + icon: , + permission: [ + FREIGHT_PERMS.settings.supportContent.view, + FREIGHT_PERMS.settings.supportContent.manage, + ], + }, + { + label: "Audit logs", + href: "/dashboard/audit-logs", + icon: , + permission: FREIGHT_PERMS.auditLog.view, + }, + { + label: "Configuration", + href: "/dashboard/configuration", + icon: , + children: [ + ...getCategorySidebarChildren("configuration"), + { + label: "Train scheduling rules", + href: "/dashboard/configuration/train-scheduling-rules", + permission: FREIGHT_PERMS.trainScheduling.rulesManage, + }, + { + label: "Trade access", + href: "/dashboard/configuration/trade-access", + permission: FREIGHT_PERMS.tradeAccess.view, + }, + { + label: "Exchange rate", + href: "/dashboard/configuration/exchange-rate", + permission: FREIGHT_PERMS.settings.exchangeRate.view, + }, + ], + }, + { + label: "Rules", + href: "/dashboard/rules", + icon: , + children: getCategorySidebarChildren("rules"), + }, + + { + label: "Staff", + href: "/user-management", + icon: , + permission: [ + FREIGHT_PERMS.admin, + FREIGHT_PERMS.staff.roles.view, + FREIGHT_PERMS.staff.employeeRegistration.view, + FREIGHT_PERMS.staff.roleAssignment.view, + ], + }, + ], + }, + ]; /** * Keep only items the user is permitted to see; drop now-empty sections. diff --git a/apps/edr-freight-web/backoffice/src/components/page/PageHeader.tsx b/apps/edr-freight-web/backoffice/src/components/page/PageHeader.tsx index 087567585..803d3f5f7 100644 --- a/apps/edr-freight-web/backoffice/src/components/page/PageHeader.tsx +++ b/apps/edr-freight-web/backoffice/src/components/page/PageHeader.tsx @@ -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. */ diff --git a/apps/edr-freight-web/backoffice/src/components/reports/ReportChart.tsx b/apps/edr-freight-web/backoffice/src/components/reports/ReportChart.tsx new file mode 100644 index 000000000..0be5384be --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/reports/ReportChart.tsx @@ -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[]; + 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 ( + + No data for the selected filters. + + ); + } + + const Chart = chart.type === "line" ? LineChart : BarChart; + + const truncated = typeof total === "number" && total > items.length; + + return ( + + {truncated ? ( + + Showing first {items.length} of {total} rows. Narrow the filters to see the rest charted. + + ) : null} + + + + + + [formatReportCell(value, yType(String(name))), yLabel(String(name))]} /> + {chart.y.length > 1 ? yLabel(String(name))} /> : null} + {chart.y.map((key, i) => + chart.type === "line" ? ( + + ) : ( + + ), + )} + + + + ); +} + +export default ReportChart; diff --git a/apps/edr-freight-web/backoffice/src/components/reports/ReportExportButton.tsx b/apps/edr-freight-web/backoffice/src/components/reports/ReportExportButton.tsx new file mode 100644 index 000000000..76ff7d628 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/reports/ReportExportButton.tsx @@ -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; +} + +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(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 ( + <> + + + setOpened(false)} title="Export report" radius="md" size="md"> + +
+ + Format + + setFormat(v as "xlsx" | "pdf")}> + + + + + + + Excel (.xlsx) + + + + + + + + + PDF + + + + + +
+ +
+ + + Fields + + + + + {def.columns.map((col) => ( + toggleField(col.key)} + /> + ))} + +
+ + set({ [filter.key]: v ?? undefined })} + radius="md" + size="sm" + clearable + w={170} + /> + ); + case "multiselect": + return ( + set({ [filter.key]: v.length ? v.join(",") : undefined })} + radius="md" + size="sm" + clearable + w={200} + /> + ); + case "text": + return ( + } + value={values[filter.key] ?? ""} + onChange={(e) => set({ [filter.key]: e.target.value || undefined })} + radius="md" + size="sm" + w={220} + /> + ); + default: + return null; + } + })} + + ); +} + +export default ReportFilters; diff --git a/apps/edr-freight-web/backoffice/src/components/reports/ReportSection.tsx b/apps/edr-freight-web/backoffice/src/components/reports/ReportSection.tsx new file mode 100644 index 000000000..99c6d30c3 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/reports/ReportSection.tsx @@ -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 ( + +
+ {def.title} + + {def.description} + +
+ +
+ ); +} + +export default ReportSection; diff --git a/apps/edr-freight-web/backoffice/src/components/reports/ReportView.tsx b/apps/edr-freight-web/backoffice/src/components/reports/ReportView.tsx new file mode 100644 index 000000000..47f5a194a --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/reports/ReportView.tsx @@ -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, unknown> }) { + const sorted = column.getIsSorted(); + const Icon = sorted === "asc" ? ArrowUp : sorted === "desc" ? ArrowDown : ArrowUpDown; + return ( + + + {label} + + + + ); +} + +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([]); + const [filterValues, setFilterValues] = useState({}); + 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>[] = useMemo( + () => + (def?.columns ?? []).map((col) => ({ + id: col.key, + accessorKey: col.key, + header: col.sortable + ? ({ column }) => + : col.label, + enableSorting: col.sortable, + cell: ({ row }) => ( + + {formatReportCell(row.original[col.key], col.type)} + + ), + })), + [def?.columns], + ); + + if (!def) { + return catalog ? ( + You don't have access to this report. + ) : null; + } + + const chartToggle = def.chart ? ( + setView(v as "table" | "chart")} + data={[ + { label: , value: "table" }, + { label: , value: "chart" }, + ]} + /> + ) : null; + + const refreshButton = ( + + void refetch()} + aria-label="Refresh" + > + + + + ); + + const exportButton = ; + + return ( + + {pageHeader ? ( + + {exportButton} + {refreshButton} + + } + /> + ) : null} + + {data?.kpis.length ? ( + ({ label: k.label, value: formatKpiValue(k.value, k.unit) }))} + /> + ) : null} + + + + + + { + setFilterValues(v); + setPagination((prev) => ({ ...prev, pageIndex: 0 })); + }} + /> + + {chartToggle} + {pageHeader ? null : ( + <> + {exportButton} + {refreshButton} + + )} + + + + + {view === "chart" && def.chart ? ( + + ) : ( + + 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} + /> + + )} + + + + ); +} + +export default ReportView; diff --git a/apps/edr-freight-web/backoffice/src/components/reports/report-format.ts b/apps/edr-freight-web/backoffice/src/components/reports/report-format.ts new file mode 100644 index 000000000..d9b174ef1 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/reports/report-format.ts @@ -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; +} diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/LegCapacityPanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/LegCapacityPanel.tsx index c98a020a5..c006c9e03 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/LegCapacityPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/LegCapacityPanel.tsx @@ -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) => ( - - {b.reference} + + {b.route ? ( - {" "} ({b.route}) ) : null} - + diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx index 25c20c8db..a023aeb88 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx @@ -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) => ( - - {reference} - + {bookingId ? ( + + ) : ( + + {reference} + + )} {status ? : null} {intercity ? ( - - {alloc.bookingReference ?? alloc.bookingId} - + {label === "BULK" ? ( {alloc.allocatedWeightTons}T cargo diff --git a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts index b681a2909..1ee429bad 100644 --- a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts @@ -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) => diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index d3566645e..ba74f3206 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -128,7 +128,9 @@ export const URL_CONSTANTS = { }, REPORTS: { + CATALOG: "/reports", RUN: (key: string) => `/reports/${key}`, + EXPORT: (key: string) => `/reports/${key}/export`, }, OVERVIEW: { diff --git a/apps/edr-freight-web/backoffice/src/pages/AuditLog.tsx b/apps/edr-freight-web/backoffice/src/pages/AuditLog.tsx index 5374bcee7..1f2c9461e 100644 --- a/apps/edr-freight-web/backoffice/src/pages/AuditLog.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/AuditLog.tsx @@ -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" /> -
- - setDateRange({ ...dateRange, start: e.target.value }) - } - /> - to - - setDateRange({ ...dateRange, end: e.target.value }) - } - /> -
+ + setDateRange({ start: formatDay(range.from), end: formatDay(range.to) }) + } + /> [] = useMemo( + () => [ + { + id: "reference", + header: "Contract", + cell: ({ row }) => ( + + {row.original.reference} + + ), + }, + { + id: "kind", + header: "Kind", + cell: ({ row }) => ( + + {row.original.contractKind === "GENERAL" ? "General" : "One-time"} + + ), + }, + { + id: "status", + header: "Status", + cell: ({ row }) => ( + + ), + }, + { + id: "validUntil", + header: "Valid until", + cell: ({ row }) => ( + + {formatDate(row.original.contractValidUntil)} + + ), + }, + { + id: "createdAt", + header: "Created", + meta: { headerClassName: "text-right", cellClassName: "text-right" }, + cell: ({ row }) => ( + + {formatDate(row.original.createdAt)} + + ), + }, + ], + [], + ); + const documentColumns: ColumnDef[] = useMemo( () => [ { @@ -709,7 +767,6 @@ export default function CustomerDetailPage() {
} - action={} /> @@ -720,6 +777,9 @@ export default function CustomerDetailPage() { }> Bookings + }> + Contracts + }> Documents @@ -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() { + {/* CONTRACTS */} + + + navigate(`/dashboard/contract-requests/${row.id}`)} + error={ + contractsQuery.isError + ? { + message: "Failed to load contracts.", + onRetry: () => void contractsQuery.refetch(), + } + : undefined + } + /> + + + {/* DOCUMENTS */} diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx index 06d9a20ab..d837ee712 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx @@ -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={ { + setDateFrom(from); + setDateTo(to); + }} + presets={getDateRangePresets()} clearable size="sm" radius="lg" - w={160} - /> - {listFilterSelects ? ( diff --git a/apps/edr-freight-web/backoffice/src/pages/invoices/FinanceHubPage.tsx b/apps/edr-freight-web/backoffice/src/pages/invoices/FinanceHubPage.tsx new file mode 100644 index 000000000..bd5169262 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/invoices/FinanceHubPage.tsx @@ -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 ( + + + + + + {visibleTabs.map((tab) => ( + } + > + {tab.label} + + ))} + + + {visibleTabs.map((tab) => ( + + + + ))} + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/invoices/InvoiceDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/invoices/InvoiceDetailPage.tsx index 3188509c8..ef7f9d086 100644 --- a/apps/edr-freight-web/backoffice/src/pages/invoices/InvoiceDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/invoices/InvoiceDetailPage.tsx @@ -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 ( - {value && value.trim() ? value : "—"} + {isEmpty ? "—" : value} ); } +/** 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 ( + + ); +} + +/** 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 ( + + ); + } + + const route = + booking?.originYard && booking?.destinationYard + ? `${booking.originYard.label} → ${booking.destinationYard.label}` + : undefined; + + return ( + + ); +} + 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={} action={ - - + + - - Summary - - - - - - - - - - - + + + + Amounts + + + + + + + + + + + + + + + + + Line items + + + + + Description + Charge type + Quantity + Unit rate + Amount + + + + {(invoice.lines ?? []).map((line) => ( + + {line.description ?? line.chargeType} + + + {humanize(line.chargeType)} + + + {line.quantity} + + {formatMoney(line.unitRate, line.currency)} + + + {formatMoney(line.amount, line.currency)} + + + ))} + {(invoice.lines ?? []).length === 0 && ( + + + + No line items. + + + + )} + +
+ + + + + Subtotal + + + {formatMoney(invoice.subtotalAmount, invoice.currency)} + + + + + Tax + + + {formatMoney(invoice.taxAmount, invoice.currency)} + + + + + Paid + + + {formatMoney(invoice.paidAmount, invoice.currency)} + + + + + Total + + + {formatMoney(invoice.totalAmount, invoice.currency)} + + + +
+
-
+ - - - - - - Line items - - - - - Description - Charge type - Quantity - Unit rate - Amount - - - - {(invoice.lines ?? []).map((line) => ( - - {line.description ?? line.chargeType} - - - {humanize(line.chargeType)} - - - {line.quantity} - - {formatMoney(line.unitRate, line.currency)} - - - {formatMoney(line.amount, line.currency)} - - - ))} - {(invoice.lines ?? []).length === 0 && ( - - - - No line items. - - - - )} - -
- - - - - Subtotal - - - {formatMoney(invoice.subtotalAmount, invoice.currency)} - - - - - Tax - - - {formatMoney(invoice.taxAmount, invoice.currency)} - - - - - Paid - - - {formatMoney(invoice.paidAmount, invoice.currency)} - - - - - Total - - - {formatMoney(invoice.totalAmount, invoice.currency)} - - - + + + + -
-
+ + ); } diff --git a/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx index fd839de16..afa4fa603 100644 --- a/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx @@ -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 ( - - void refetch()} - > - -
- } - /> + + + + + } + value={query} + onChange={(e) => setQuery(e.target.value)} + rightSection={ + query ? ( + setQuery("")} + > + + + ) : null + } + style={{ flex: 1, minWidth: "240px" }} + radius="lg" + /> + { + setStatusFilter( + v === "all" ? "" : (v as Freight.InvoiceStatus), + ); + setPagination((prev) => ({ ...prev, pageIndex: 0 })); + }} + data={[ + { label: "All", value: "all" }, + { label: "Pending", value: "PENDING" }, + { label: "Payment processing", value: "PAYMENT_PROCESSING" }, + { label: "Paid", value: "PAID" }, + { label: "Overdue", value: "OVERDUE" }, + ]} + /> + + {total} record{total !== 1 ? "s" : ""} + + void refetch()} + > + + + + - - - - - } - value={query} - onChange={(e) => setQuery(e.target.value)} - rightSection={ - query ? ( - setQuery("")} - > - - - ) : null - } - style={{ flex: 1, minWidth: "240px" }} - radius="lg" - /> - { - setStatusFilter( - v === "all" ? "" : (v as Freight.InvoiceStatus), - ); - setPagination((prev) => ({ ...prev, pageIndex: 0 })); - }} - data={[ - { label: "All", value: "all" }, - { label: "Pending", value: "PENDING" }, - { label: "Payment processing", value: "PAYMENT_PROCESSING" }, - { label: "Paid", value: "PAID" }, - { label: "Overdue", value: "OVERDUE" }, - ]} - /> - - {total} record{total !== 1 ? "s" : ""} - - + + + 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} + /> - - - - 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} - /> - - - - - + + + ); } diff --git a/apps/edr-freight-web/backoffice/src/pages/invoices/UsdPaymentsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/invoices/UsdPaymentsPage.tsx index 585abea21..023b92e0d 100644 --- a/apps/edr-freight-web/backoffice/src/pages/invoices/UsdPaymentsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/invoices/UsdPaymentsPage.tsx @@ -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 ( - - void refetch()} - > - - - } - /> - + <> @@ -324,6 +307,16 @@ export default function UsdPaymentsPage() { {total} record{total !== 1 ? "s" : ""} + void refetch()} + > + +
@@ -421,6 +414,6 @@ export default function UsdPaymentsPage() {
)}
- + ); } diff --git a/apps/edr-freight-web/backoffice/src/pages/payments/PaymentsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/payments/PaymentsPage.tsx index 60044e550..2d98cc805 100644 --- a/apps/edr-freight-web/backoffice/src/pages/payments/PaymentsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/payments/PaymentsPage.tsx @@ -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("all"); @@ -205,12 +206,7 @@ export default function PaymentsPage() { ]; return ( - - - + - +
); } diff --git a/apps/edr-freight-web/backoffice/src/pages/reports/ReportPage.tsx b/apps/edr-freight-web/backoffice/src/pages/reports/ReportPage.tsx index 71a270a78..16651cdf2 100644 --- a/apps/edr-freight-web/backoffice/src/pages/reports/ReportPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/reports/ReportPage.tsx @@ -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 ( - - - No data for the selected filters - - - ); - } - - const ChartComponent = - chart.type === "bar" ? BarChart : chart.type === "line" ? LineChart : AreaChart; - - return ( - - - - - - compact.format(v)} - width={56} - /> - Number(value ?? 0).toLocaleString()} /> - {chart.series.length > 1 ? : null} - {chart.series.map((s, i) => { - const color = - overviewChartColors.pipeline[i % overviewChartColors.pipeline.length]; - if (chart.type === "bar") { - return ( - - ); - } - if (chart.type === "line") { - return ( - - ); - } - return ( - - ); - })} - - - - ); -} +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 ( - - - - This report does not exist. Back to reports - - - ); - } - - const rows = reportQuery.data?.rows ?? []; - const kpis = reportQuery.data?.kpis ?? []; - const pageCount = Math.max(1, Math.ceil(rows.length / pagination.pageSize)); - - const columns: ColumnDef[] = 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 ( - - - - - - } - /> - - - - setParam("dateFrom", toParam(d))} - placeholder="All time" - /> - setParam("dateTo", toParam(d))} - placeholder="All time" - /> - {config.filters.includes("granularity") ? ( - ({ - value: d, - label: TRADE_DIRECTION_LABELS[d], - }))} - value={params.get("direction")} - onChange={(v) => setParam("direction", v)} - placeholder="All" - /> - ) : null} - {config.filters.includes("freightType") ? ( - - - - - - - - - - - - - {schedule.reference ? ( - - {schedule.reference} - - ) : null} - - {schedule.route?.name ?? "Train schedule"} - - {schedule.train?.trainName ? ( - - {schedule.train.trainName} - - ) : null} - {schedule.train ? ( - - Train {schedule.train.code} - - ) : null} - - - {/* Voyage (train) number and trade direction — the two things - operations identify a run by, so they read at a glance - rather than as small badges among the rest. */} - - {schedule.trainNumber ? ( - - - Train No. - - - {schedule.trainNumber} - - - ) : null} - {schedule.voyageNumber ? ( - - - Voyage No. - - - {schedule.voyageNumber} - - - ) : null} - {/* Merging rewrites the consist, so it is offered only while - the departure can still be edited. */} - {canEditBookings ? ( - - ) : null} - {schedule.direction ? ( - - - Direction - - - {schedule.direction} - - - ) : null} - - {(schedule.stops?.length ?? 0) >= 3 || - (schedule.bookings ?? []).some( - (b) => b.tradeDirection === "DOMESTIC", - ) ? ( - + {schedule.train.trainName ?? `Train ${schedule.train.code}`} + {schedule.train.trainName ? ` · Train ${schedule.train.code}` : ""} + + ) : undefined + } + meta={ + + {schedule.reference ? ( + + {schedule.reference} + + ) : null} + + + {gatepassApplies && gatepassSecured ? ( + } + > + Gate pass secured + + ) : null} + {previewResult ? ( + - ) : ( - - - - )} - - - - - - - - {(schedule.trainSet?.wagons?.length ?? 0) > 0 ? ( - - ) : null} - {canPrintMarshalling ? ( - - ) : null} - {["DISPATCHED", "ARRIVED"].includes(schedule.status) ? ( - - ) : null} - {["DISPATCHED", "ARRIVED"].includes(schedule.status) ? ( - - ) : null} - {schedule.windowPhase === "PRE_WINDOW" ? ( - - ) : null} - {["DRAFT", "SCHEDULED"].includes(schedule.status) ? ( - - ) : null} - {gatepassApplies ? ( - gatepassSecured ? ( - + ) : null} + {(schedule.trainSet?.wagons?.length ?? 0) > 0 ? ( + + ) : null} + + + + + + + + {canPrintMarshalling ? ( + } + disabled={downloadMarshalling.isPending} + onClick={() => void openMarshallingDocument()} > - Gate pass secured - - ) : ( - - ) - ) : null} - + + ) : null} + + + } + /> - {previewResult ? ( - + + + {schedule.trainNumber ? ( + + + Train No. + + + {schedule.trainNumber} + + + ) : null} + {schedule.voyageNumber ? ( + + + Voyage No. + + + {schedule.voyageNumber} + + + ) : null} + {schedule.direction ? ( + + + Direction + + - } - > - Preview {previewResult.valid ? "valid" : "has issues"} - - ) : null} + > + {schedule.direction} + + + ) : null} + + {(schedule.stops?.length ?? 0) >= 3 || + (schedule.bookings ?? []).some( + (b) => b.tradeDirection === "DOMESTIC", + ) ? ( + + ) : ( + + + + )} diff --git a/apps/edr-freight-web/backoffice/src/record-management/components/sectorReports/SectorReportFilters.tsx b/apps/edr-freight-web/backoffice/src/record-management/components/sectorReports/SectorReportFilters.tsx index 1ad10b52b..1d148c594 100644 --- a/apps/edr-freight-web/backoffice/src/record-management/components/sectorReports/SectorReportFilters.tsx +++ b/apps/edr-freight-web/backoffice/src/record-management/components/sectorReports/SectorReportFilters.tsx @@ -14,6 +14,7 @@ import { SelectTrigger, SelectValue, } from "@/shared/common/ui/select"; +import { DateRangePicker, parseDay, formatDay } from "@/shared/common/ui/date-range-picker"; export interface ReportFilterOption { id: string; @@ -167,33 +168,18 @@ export function SectorReportFilters({ -