From c3979b08b6732c2d61abd7d71a3e480f56f90b07 Mon Sep 17 00:00:00 2001 From: Marshal Date: Mon, 3 Aug 2026 21:43:23 +0000 Subject: [PATCH] add reports module with controller, service, and repository --- apps/edr-freight-api/src/app.module.ts | 2 + .../overview/dto/overview-response.dto.ts | 2 + .../modules/overview/overview.repository.ts | 10 +- .../modules/reports/dto/report-query.dto.ts | 54 +++ .../modules/reports/dto/report-result.dto.ts | 24 + .../src/modules/reports/report-queries.ts | 450 ++++++++++++++++++ .../src/modules/reports/reports.controller.ts | 33 ++ .../src/modules/reports/reports.module.ts | 13 + .../src/modules/reports/reports.repository.ts | 14 + .../src/modules/reports/reports.service.ts | 45 ++ apps/edr-freight-web/backoffice/src/App.tsx | 11 + .../overview/OverviewQuickLinks.tsx | 90 ---- .../overview/OverviewTabContent.tsx | 13 +- .../tabs/OverviewBookingsTabPanel.tsx | 7 + .../tabs/OverviewContractsTabPanel.tsx | 7 + .../overview/tabs/OverviewFleetTabPanel.tsx | 115 +++++ .../tabs/OverviewOperationsTabPanel.tsx | 36 -- .../backoffice/src/constants/URLS.ts | 4 + .../src/pages/dashboard/OverviewPage.tsx | 25 +- .../src/pages/reports/ReportPage.tsx | 441 +++++++++++++++++ .../src/pages/reports/ReportsHubPage.tsx | 159 +++++++ .../src/pages/reports/reportConfigs.ts | 295 ++++++++++++ .../backoffice/src/services/api.ts | 11 + .../src/services/reports.service.ts | 14 + .../backoffice/src/types/reports.ts | 27 ++ packages/types/src/freight/overview.ts | 5 + 26 files changed, 1771 insertions(+), 136 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/reports/dto/report-query.dto.ts create mode 100644 apps/edr-freight-api/src/modules/reports/dto/report-result.dto.ts create mode 100644 apps/edr-freight-api/src/modules/reports/report-queries.ts create mode 100644 apps/edr-freight-api/src/modules/reports/reports.controller.ts create mode 100644 apps/edr-freight-api/src/modules/reports/reports.module.ts create mode 100644 apps/edr-freight-api/src/modules/reports/reports.repository.ts create mode 100644 apps/edr-freight-api/src/modules/reports/reports.service.ts delete mode 100644 apps/edr-freight-web/backoffice/src/components/overview/OverviewQuickLinks.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewFleetTabPanel.tsx create mode 100644 apps/edr-freight-web/backoffice/src/pages/reports/ReportPage.tsx create mode 100644 apps/edr-freight-web/backoffice/src/pages/reports/ReportsHubPage.tsx create mode 100644 apps/edr-freight-web/backoffice/src/pages/reports/reportConfigs.ts create mode 100644 apps/edr-freight-web/backoffice/src/services/reports.service.ts create mode 100644 apps/edr-freight-web/backoffice/src/types/reports.ts diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 66f64678a..337518b2e 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -89,6 +89,7 @@ import { CargoesModule } from "./modules/cargoes/cargoes.module"; import { RoutesModule } from "./modules/routes/routes.module"; import { WarehousesModule } from "./modules/warehouses/warehouses.module"; import { OverviewModule } from "./modules/overview/overview.module"; +import { ReportsModule } from "./modules/reports/reports.module"; import { UserTradeAccessModule } from "./modules/user-trade-access/user-trade-access.module"; import { VehiclesModule } from "./modules/vehicles/vehicles.module"; import { DriversModule } from "./modules/drivers/drivers.module"; @@ -207,6 +208,7 @@ import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middlewar RoutesModule, WarehousesModule, OverviewModule, + ReportsModule, UserTradeAccessModule, VehiclesModule, DriversModule, diff --git a/apps/edr-freight-api/src/modules/overview/dto/overview-response.dto.ts b/apps/edr-freight-api/src/modules/overview/dto/overview-response.dto.ts index 981ff35dd..005dad08a 100644 --- a/apps/edr-freight-api/src/modules/overview/dto/overview-response.dto.ts +++ b/apps/edr-freight-api/src/modules/overview/dto/overview-response.dto.ts @@ -1,6 +1,7 @@ import { ApiProperty } from '@nestjs/swagger'; export class OverviewBookingKpisDto { + @ApiProperty() total!: number; @ApiProperty() totalActive!: number; @ApiProperty() needsAction!: number; @ApiProperty() urgent!: number; @@ -9,6 +10,7 @@ export class OverviewBookingKpisDto { } export class OverviewContractKpisDto { + @ApiProperty() total!: number; @ApiProperty() totalActive!: number; @ApiProperty() needsAction!: number; @ApiProperty() inApproval!: number; diff --git a/apps/edr-freight-api/src/modules/overview/overview.repository.ts b/apps/edr-freight-api/src/modules/overview/overview.repository.ts index c75510c0e..c61320d8c 100644 --- a/apps/edr-freight-api/src/modules/overview/overview.repository.ts +++ b/apps/edr-freight-api/src/modules/overview/overview.repository.ts @@ -39,6 +39,7 @@ const EXCLUDE_GENERAL_CONTRACT_BOOKINGS = "(booking.contract_kind IS NULL OR booking.contract_kind <> 'GENERAL')"; export type OverviewBookingKpisRow = { + total: number; totalActive: number; needsAction: number; urgent: number; @@ -58,6 +59,7 @@ export type OverviewRecentBookingRow = { }; export type OverviewContractKpisRow = { + total: number; totalActive: number; needsAction: number; inApproval: number; @@ -108,7 +110,8 @@ export class OverviewRepository { const scope = directionScopeSql("booking.trade_direction", dirs); const row = await this.bookingRepository .createQueryBuilder("booking") - .select( + .select("COUNT(*)::int", "total") + .addSelect( `COUNT(*) FILTER (WHERE booking.status NOT IN (:...closedStatuses) AND booking.status != 'DRAFT')::int`, "totalActive", ) @@ -140,6 +143,7 @@ export class OverviewRepository { .getRawOne>(); return { + total: Number(row?.total ?? 0), totalActive: Number(row?.totalActive ?? 0), needsAction: Number(row?.needsAction ?? 0), urgent: Number(row?.urgent ?? 0), @@ -845,7 +849,8 @@ export class OverviewRepository { const scope = directionScopeSql("contract.trade_direction", dirs); const row = await this.contractRepository .createQueryBuilder("contract") - .select( + .select("COUNT(*)::int", "total") + .addSelect( `COUNT(*) FILTER (WHERE contract.status NOT IN (:...closedStatuses) AND contract.status != 'DRAFT')::int`, "totalActive", ) @@ -876,6 +881,7 @@ export class OverviewRepository { .getRawOne>(); return { + total: Number(row?.total ?? 0), totalActive: Number(row?.totalActive ?? 0), needsAction: Number(row?.needsAction ?? 0), inApproval: Number(row?.inApproval ?? 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 new file mode 100644 index 000000000..82b1a76ef --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/dto/report-query.dto.ts @@ -0,0 +1,54 @@ +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 new file mode 100644 index 000000000..cc1c215af --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/dto/report-result.dto.ts @@ -0,0 +1,24 @@ +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-queries.ts b/apps/edr-freight-api/src/modules/reports/report-queries.ts new file mode 100644 index 000000000..e329c8df6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/report-queries.ts @@ -0,0 +1,450 @@ +import { DataSource } from 'typeorm'; + +export interface ReportFilters { + /** ISO timestamp, inclusive lower bound. */ + dateFrom: string; + /** ISO timestamp, exclusive upper bound. */ + dateTo: string; + 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 b.created_at >= $1::timestamptz AND b.created_at < $2::timestamptz + 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 < $2::timestamptz + AND (ct.contract_valid_until IS NULL OR ct.contract_valid_until >= $1::timestamptz) + 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 ts.scheduled_departure_date >= $1::timestamptz + AND ts.scheduled_departure_date < $2::timestamptz + 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 ts.scheduled_departure_date >= $1::timestamptz + AND ts.scheduled_departure_date < $2::timestamptz + 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 ts.scheduled_departure_date >= $1::timestamptz + AND ts.scheduled_departure_date < $2::timestamptz + 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 COALESCE(i.issued_at, i.created_at) >= $1::timestamptz + AND COALESCE(i.issued_at, i.created_at) < $2::timestamptz + 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, 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 >= $1::timestamptz), 0))::float8 AS current, + ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < $1::timestamptz + AND i.due_at >= $1::timestamptz - interval '30 days'), 0))::float8 AS overdue_0_30, + ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < $1::timestamptz - interval '30 days' + AND i.due_at >= $1::timestamptz - interval '60 days'), 0))::float8 AS overdue_31_60, + ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < $1::timestamptz - interval '60 days' + AND i.due_at >= $1::timestamptz - interval '90 days'), 0))::float8 AS overdue_61_90, + ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < $1::timestamptz - 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 i.created_at < $1::timestamptz + 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 p.created_at >= $1::timestamptz AND p.created_at < $2::timestamptz + 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, + }; +}; + +export const REPORT_QUERIES: Record = { + '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/reports.controller.ts b/apps/edr-freight-api/src/modules/reports/reports.controller.ts new file mode 100644 index 000000000..bc2671680 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/reports.controller.ts @@ -0,0 +1,33 @@ +import { Controller, Get, Param, Query } from '@nestjs/common'; +import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { CurrentUser } from '@edr/api-common'; +import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; + +import { BookingView } from '../../common/booking-guards'; +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'; + +@ApiTags('Reports') +@ApiBearerAuth() +@Controller('reports') +export class ReportsController { + constructor( + private readonly reportsService: ReportsService, + private readonly userTradeAccessService: UserTradeAccessService, + ) {} + + @Get(':key') + @BookingView() + @ApiOperation({ summary: 'Run a canned report by key with optional filters' }) + @ApiOkResponse({ type: ReportResultDto }) + async run( + @Param('key') key: string, + @Query() query: ReportQueryDto, + @CurrentUser() user: TCurrentUser, + ): Promise { + const allowed = await this.userTradeAccessService.resolveAllowedDirections(user); + return this.reportsService.run(key, query, allowed); + } +} diff --git a/apps/edr-freight-api/src/modules/reports/reports.module.ts b/apps/edr-freight-api/src/modules/reports/reports.module.ts new file mode 100644 index 000000000..a7fe792a5 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/reports.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; + +import { UserTradeAccessModule } from '../user-trade-access/user-trade-access.module'; +import { ReportsController } from './reports.controller'; +import { ReportsRepository } from './reports.repository'; +import { ReportsService } from './reports.service'; + +@Module({ + imports: [UserTradeAccessModule], + controllers: [ReportsController], + providers: [ReportsService, ReportsRepository], +}) +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 new file mode 100644 index 000000000..65f154b22 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/reports.repository.ts @@ -0,0 +1,14 @@ +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 new file mode 100644 index 000000000..0e5c7d928 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/reports.service.ts @@ -0,0 +1,45 @@ +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}`); + } + const to = dto.dateTo ? new Date(dto.dateTo) : new Date(); + const from = dto.dateFrom ? new Date(dto.dateFrom) : new Date(to.getTime() - 30 * DAY_MS); + const filters: ReportFilters = { + dateFrom: from.toISOString(), + // dateTo is inclusive in the API; queries treat the bound as exclusive. + dateTo: new Date(to.getTime() + DAY_MS).toISOString(), + 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-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index ceb2a7d06..d4892d198 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -2,6 +2,7 @@ import { ArrowLeftRight, Boxes, Building2, + BarChart3, Container, FileSignature, FileText, @@ -71,6 +72,8 @@ import InvoiceDetailPage from "./pages/invoices/InvoiceDetailPage"; import InvoicesPage from "./pages/invoices/InvoicesPage"; import MyProfilePage from "./pages/dashboard/MyProfilePage"; import OverviewPage from "./pages/dashboard/OverviewPage"; +import ReportsHubPage from "./pages/reports/ReportsHubPage"; +import ReportPage from "./pages/reports/ReportPage"; import AiBookingMockTestPage from "./pages/ai/AiBookingMockTestPage"; import PaymentsPage from "./pages/payments/PaymentsPage"; //import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage"; @@ -154,6 +157,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , permission: FREIGHT_PERMS.overview.view, }, + { + label: "Reports", + href: "/dashboard/reports", + icon: , + permission: FREIGHT_PERMS.bookings.view, + }, { label: "Customers", href: "/dashboard/customers", @@ -823,6 +832,8 @@ const App = () => { /> }> } /> + } /> + } /> {/* Dev/testing page for the mock AI booking assistant. */} - link.permission.some((key) => hasPermission(user, key)), - ); - if (!visible.length) return null; - - return ( - - Quick links - - {visible.map((link) => { - const Icon = link.icon; - return ( - navigate(link.href)} - > - - - - - - - - {link.title} - - - {link.description} - - - - - - - ); - })} - - - ); -} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/OverviewTabContent.tsx b/apps/edr-freight-web/backoffice/src/components/overview/OverviewTabContent.tsx index 7452e36cf..cf71198a6 100644 --- a/apps/edr-freight-web/backoffice/src/components/overview/OverviewTabContent.tsx +++ b/apps/edr-freight-web/backoffice/src/components/overview/OverviewTabContent.tsx @@ -14,6 +14,7 @@ import { OverviewBillingTabPanel } from "./tabs/OverviewBillingTabPanel"; import { OverviewBookingsTabPanel } from "./tabs/OverviewBookingsTabPanel"; import { OverviewContractsTabPanel } from "./tabs/OverviewContractsTabPanel"; import { OverviewCustomersTabPanel } from "./tabs/OverviewCustomersTabPanel"; +import { OverviewFleetTabPanel } from "./tabs/OverviewFleetTabPanel"; import { OverviewOperationsTabPanel } from "./tabs/OverviewOperationsTabPanel"; import { OverviewStaffTabPanel } from "./tabs/OverviewStaffTabPanel"; @@ -36,7 +37,12 @@ export function OverviewTabContent({ tab, range }: OverviewTabContentProps) { const bookings = useOverviewBookingsTab(range, tab === "bookings"); const contracts = useOverviewContractsTab(range, tab === "contracts"); const billing = useOverviewBillingTab(range, tab === "billing"); - const operations = useOverviewOperationsTab(range, tab === "operations"); + // Fleet reuses the operations dataset — same query key, so switching between + // the two tabs costs one fetch. + const operations = useOverviewOperationsTab( + range, + tab === "operations" || tab === "fleet", + ); const customers = useOverviewCustomersTab(range, tab === "customers"); const staff = useOverviewStaffTab(range, tab === "staff"); @@ -47,7 +53,7 @@ export function OverviewTabContent({ tab, range }: OverviewTabContentProps) { ? contracts : tab === "billing" ? billing - : tab === "operations" + : tab === "operations" || tab === "fleet" ? operations : tab === "customers" ? customers @@ -99,6 +105,9 @@ export function OverviewTabContent({ tab, range }: OverviewTabContentProps) { {tab === "operations" && operations.data && ( )} + {tab === "fleet" && operations.data && ( + + )} {tab === "customers" && customers.data && ( )} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewBookingsTabPanel.tsx b/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewBookingsTabPanel.tsx index 9dce6cc9e..cacba63e1 100644 --- a/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewBookingsTabPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewBookingsTabPanel.tsx @@ -24,6 +24,13 @@ export function OverviewBookingsTabPanel({ data }: OverviewBookingsTabPanelProps char.toUpperCase()); +} + +function sumCounts(items: IOverviewStatusCount[]) { + return items.reduce((sum, item) => sum + item.count, 0); +} + +function countByStatus(items: IOverviewStatusCount[], status: string) { + return items.find((item) => item.status === status)?.count ?? 0; +} + +function toDonutData(items: IOverviewStatusCount[]) { + return items.map((item) => ({ + name: formatStatusLabel(item.status), + value: item.count, + })); +} + +interface OverviewFleetTabPanelProps { + data: IOverviewOperationsTab; +} + +export function OverviewFleetTabPanel({ data }: OverviewFleetTabPanelProps) { + return ( + + + + + + ({ + label: item.label, + value: item.count, + }))} + valueLabel="Wagons" + /> + + + ({ + label: item.label, + value: item.count, + }))} + valueLabel="Wagons" + emptyMessage="No wagons assigned to yards" + /> + + + + + + + + + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewOperationsTabPanel.tsx b/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewOperationsTabPanel.tsx index 0fcdef9e6..675a81b32 100644 --- a/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewOperationsTabPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewOperationsTabPanel.tsx @@ -105,30 +105,6 @@ export function OverviewOperationsTabPanel({ data }: OverviewOperationsTabPanelP - - - ({ - label: item.label, - value: item.count, - }))} - valueLabel="Wagons" - /> - - - ({ - label: item.label, - value: item.count, - }))} - valueLabel="Wagons" - emptyMessage="No wagons assigned to yards" - /> - - - - - - - - - `/api/customers/user/${id}`, }, + REPORTS: { + RUN: (key: string) => `/reports/${key}`, + }, + OVERVIEW: { BASE: "/overview", BOOKINGS: "/overview/bookings", diff --git a/apps/edr-freight-web/backoffice/src/pages/dashboard/OverviewPage.tsx b/apps/edr-freight-web/backoffice/src/pages/dashboard/OverviewPage.tsx index c7606641e..12bce6aae 100644 --- a/apps/edr-freight-web/backoffice/src/pages/dashboard/OverviewPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/dashboard/OverviewPage.tsx @@ -5,6 +5,7 @@ import { FileSignature, FileText, Train, + TrainFront, UserCheck, Users, } from "lucide-react"; @@ -13,7 +14,6 @@ import { Badge, Button, Container, - Paper, Skeleton, Stack, Tabs, @@ -22,7 +22,6 @@ import { useQueryClient } from "@tanstack/react-query"; import { useAuth } from "@/auth/useAuth"; import { OverviewPageHeader } from "@/components/overview/OverviewPageHeader"; -import { OverviewQuickLinks } from "@/components/overview/OverviewQuickLinks"; import { OverviewTabContent } from "@/components/overview/OverviewTabContent"; import "@/components/overview/overview.css"; import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; @@ -82,6 +81,18 @@ const TAB_ITEMS: Array<{ FREIGHT_PERMS.lastMile.view, ], }, + { + value: "fleet", + label: "Fleet", + icon: TrainFront, + kpiKey: "operations", + metricKey: "wagonsAvailable", + permission: [ + FREIGHT_PERMS.fleet.view, + FREIGHT_PERMS.wagons.view, + FREIGHT_PERMS.trainScheduling.view, + ], + }, { value: "customers", label: "Customers", @@ -174,6 +185,12 @@ const OverviewPage = () => { )} + {visibleTabs.length === 0 && !isLoading && !isError && ( + + Your role has no access to any overview section. + + )} + {visibleTabs.length > 0 && ( { ))} )} - - - - ); diff --git a/apps/edr-freight-web/backoffice/src/pages/reports/ReportPage.tsx b/apps/edr-freight-web/backoffice/src/pages/reports/ReportPage.tsx new file mode 100644 index 000000000..0ac68325d --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/reports/ReportPage.tsx @@ -0,0 +1,441 @@ +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 { 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 ( + + ); + })} + + + + ); +} + +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="30 days ago" + /> + setParam("dateTo", toParam(d))} + placeholder="Today" + /> + {config.filters.includes("granularity") ? ( + setParam("direction", v)} + placeholder="All" + /> + ) : null} + {config.filters.includes("freightType") ? ( +