From eaa006a932f9f773cf7117fc4cfca93cbb959be3 Mon Sep 17 00:00:00 2001 From: marshalyordanos Date: Thu, 3 Sep 2026 23:25:19 +0300 Subject: [PATCH] feat: add wagon performance report and export functionality - Implemented for Excel download of wagon performance report sections, including column width adjustment and timestamped filenames. - Created to compute derived wagon performance figures based on movements and status logs, ensuring consistency with API data. - Enhanced with new fields for tracking last movement and statistics window days for improved reporting capabilities. --- .../wagons/dto/list-wagons-query.dto.ts | 14 + .../src/modules/wagons/wagons.service.ts | 77 +- apps/edr-freight-web/backoffice/src/App.tsx | 20 + .../components/layout/sidebar-sections.tsx | 6 + .../pages/ruleEngine/RateApprovalsSection.tsx | 49 +- .../ruleEngine/RuleEngineResourcePage.tsx | 4 - .../wagon-performance/SectionExportButton.tsx | 50 + .../WagonPerformanceDetailPage.tsx | 1004 +++++++++++ .../WagonPerformancePage.tsx | 1533 +++++++++++++++++ .../pages/wagon-performance/exportSection.ts | 80 + .../wagon-performance/wagonPerformance.ts | 237 +++ .../backoffice/src/services/wagon.service.ts | 12 + 12 files changed, 3038 insertions(+), 48 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/pages/wagon-performance/SectionExportButton.tsx create mode 100644 apps/edr-freight-web/backoffice/src/pages/wagon-performance/WagonPerformanceDetailPage.tsx create mode 100644 apps/edr-freight-web/backoffice/src/pages/wagon-performance/WagonPerformancePage.tsx create mode 100644 apps/edr-freight-web/backoffice/src/pages/wagon-performance/exportSection.ts create mode 100644 apps/edr-freight-web/backoffice/src/pages/wagon-performance/wagonPerformance.ts diff --git a/apps/edr-freight-api/src/modules/wagons/dto/list-wagons-query.dto.ts b/apps/edr-freight-api/src/modules/wagons/dto/list-wagons-query.dto.ts index 1e2db1631..4631430e0 100644 --- a/apps/edr-freight-api/src/modules/wagons/dto/list-wagons-query.dto.ts +++ b/apps/edr-freight-api/src/modules/wagons/dto/list-wagons-query.dto.ts @@ -105,4 +105,18 @@ export class ListWagonsQueryDto { @IsOptional() @IsDateString() maintenanceTo?: string; + + @ApiPropertyOptional({ + description: + 'Window (days) the per-row load/move counts are counted over. Does not filter rows.', + default: 90, + minimum: 1, + maximum: 3650, + }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(3650) + statsWindowDays?: number; } diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts index f15722b67..1a0de17d7 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts @@ -177,6 +177,7 @@ export class WagonsService { async findAll(query: ListWagonsQueryDto = {}): Promise> { const page = await paginateQuery(this.buildListQuery(query), query, { defaultPageSize: 10 }); await this.attachStatusDates(page.items); + await this.attachMovementStats(page.items, query.statsWindowDays ?? 90); return page; } @@ -216,6 +217,56 @@ export class WagonsService { } } + /** + * Per-wagon movement rollups for the wagon performance report: when the + * wagon last arrived anywhere (the idle clock), and how many loaded / total + * moves it made inside `windowDays`. One grouped query per page, in the same + * shape as `attachStatusDates` above — never one request per row. + */ + private async attachMovementStats(wagons: Wagon[], windowDays: number): Promise { + if (!wagons.length) return; + const since = new Date(Date.now() - windowDays * 24 * 60 * 60 * 1000); + const rows: Array<{ + wagonId: string; + lastMovedAt: Date | null; + loadsInWindow: string; + movesInWindow: string; + emptyMovesInWindow: string; + }> = await this.dataSource + .getRepository(WagonMovement) + .createQueryBuilder('m') + .select('m.wagon_id', 'wagonId') + .addSelect('MAX(m.occurred_at)', 'lastMovedAt') + .addSelect( + 'COUNT(*) FILTER (WHERE m.occurred_at >= :since AND m.kind = :loaded)', + 'loadsInWindow', + ) + .addSelect( + 'COUNT(*) FILTER (WHERE m.occurred_at >= :since AND m.kind = :empty)', + 'emptyMovesInWindow', + ) + .addSelect('COUNT(*) FILTER (WHERE m.occurred_at >= :since)', 'movesInWindow') + .where('m.wagon_id IN (:...ids)', { ids: wagons.map((w) => w.id) }) + .setParameters({ + since, + loaded: WagonMovementKind.Loaded, + empty: WagonMovementKind.EmptyReposition, + }) + .groupBy('m.wagon_id') + .getRawMany(); + + const byId = new Map(rows.map((r) => [r.wagonId, r])); + for (const w of wagons) { + const r = byId.get(w.id); + Object.assign(w, { + lastMovedAt: r?.lastMovedAt ?? null, + loadsInWindow: Number(r?.loadsInWindow ?? 0), + movesInWindow: Number(r?.movesInWindow ?? 0), + emptyMovesInWindow: Number(r?.emptyMovesInWindow ?? 0), + }); + } + } + async findById(id: string): Promise { const wagon = await this.wagonRepo.findOne({ where: { id }, @@ -328,11 +379,35 @@ export class WagonsService { /** Movement ledger for one wagon, newest first (loaded legs, repositions, manual moves). */ async listMovements(wagonId: string): Promise { await this.findById(wagonId); // 404 on unknown wagon - return this.dataSource.getRepository(WagonMovement).find({ + const movements = await this.dataSource.getRepository(WagonMovement).find({ where: { wagonId }, relations: { fromYard: true, toYard: true }, order: { occurredAt: 'DESC', createdAt: 'DESC' }, }); + await this.attachBookingReferences(movements); + return movements; + } + + /** + * Resolve each loaded move's booking to its human reference, so the UI can + * show (and link to) "BKG-11284" rather than a raw uuid. One query for the + * whole ledger; `wagon_movements` deliberately has no FK to bookings, so + * this is a read-time join on primary keys, exactly like the labels in + * `wagon-history.service`. + */ + private async attachBookingReferences(movements: WagonMovement[]): Promise { + const ids = [...new Set(movements.map((m) => m.bookingId).filter((v): v is string => !!v))]; + if (!ids.length) return; + const rows: Array<{ id: string; reference: string }> = await this.dataSource.query( + `SELECT id, reference FROM freight.bookings WHERE id = ANY($1::uuid[])`, + [ids], + ); + const byId = new Map(rows.map((r) => [r.id, r.reference])); + for (const m of movements) { + Object.assign(m, { + bookingReference: m.bookingId ? (byId.get(m.bookingId) ?? null) : null, + }); + } } async remove(id: string, userId?: string | null): Promise { diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index f95fe7cca..652a331c7 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -62,6 +62,8 @@ import ContractTemplatesPage from "./pages/contract_templates/ContractTemplatesP import PortalContentPage from "./pages/portal_content/PortalContentPage"; import ContractTemplateEditorPage from "./pages/contract_templates/ContractTemplateEditorPage"; import FleetResourcePage from "./pages/fleet/FleetResourcePage"; +import WagonPerformancePage from "./pages/wagon-performance/WagonPerformancePage"; +import WagonPerformanceDetailPage from "./pages/wagon-performance/WagonPerformanceDetailPage"; import WagonTransfersPage from "./pages/wagons/WagonTransfersPage"; import VehicleDetailPage from "./pages/fleet/VehicleDetailPage"; import DriverDetailPage from "./pages/fleet/DriverDetailPage"; @@ -232,6 +234,24 @@ const App = () => { } /> + {/* Wagon performance — a read-only executive report beside Overview. + Separate from the Fleet Management wagons desk, which owns CRUD. */} + + + + } + /> + + + + } + /> {/* One drill-down route per overview domain — the old per-tab charts, now each on its own page. Single source of truth for the permission gate is OVERVIEW_DOMAINS, shared with the summary 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 1180518fc..861b73014 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 @@ -69,6 +69,12 @@ export const buildSidebarSections = ( icon: , permission: FREIGHT_PERMS.overview.view, }, + { + label: "Wagon Performance", + href: "/dashboard/wagon-performance", + icon: , + permission: FREIGHT_PERMS.wagons.view, + }, { label: "Customers", href: "/dashboard/customers", diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RateApprovalsSection.tsx b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RateApprovalsSection.tsx index ce77041eb..45fe2f071 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RateApprovalsSection.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RateApprovalsSection.tsx @@ -1,13 +1,10 @@ -import { useState } from "react"; import { Badge, Button, Card, - Collapse, Group, Stack, Text, - Textarea, Tooltip, } from "@mantine/core"; import type { UseMutationResult } from "@tanstack/react-query"; @@ -143,9 +140,6 @@ const RateApprovalsSection = ({ reject, refLabels, }: RateApprovalsSectionProps) => { - const [openId, setOpenId] = useState(null); - const [notes, setNotes] = useState>({}); - if (requests.length === 0) return null; const decidingId = approve.variables?.id ?? reject.variables?.id ?? null; @@ -165,7 +159,6 @@ const RateApprovalsSection = ({ {requests.map((r) => { - const isOpen = openId === r.id; const fields = Object.keys(r.payload); const rows = summaryRows(r, refLabels); // Only the row being decided shows a spinner — the mutation's @@ -201,21 +194,10 @@ const RateApprovalsSection = ({ ))} - - - Submitted {fmtDateTime(r.createdAt)} · {fields.length}{" "} - {fields.length === 1 ? "field" : "fields"} changed - - {canDecide ? ( - - ) : null} - + + Submitted {fmtDateTime(r.createdAt)} · {fields.length}{" "} + {fields.length === 1 ? "field" : "fields"} changed + {canDecide ? ( @@ -228,7 +210,7 @@ const RateApprovalsSection = ({ loading={busy && reject.isPending} disabled={busy && approve.isPending} onClick={() => - reject.mutate({ id: r.id, decisionNote: notes[r.id] || undefined }) + reject.mutate({ id: r.id }) } > Reject @@ -240,7 +222,7 @@ const RateApprovalsSection = ({ loading={busy && approve.isPending} disabled={busy && reject.isPending} onClick={() => - approve.mutate({ id: r.id, decisionNote: notes[r.id] || undefined }) + approve.mutate({ id: r.id }) } > Approve & apply @@ -255,25 +237,6 @@ const RateApprovalsSection = ({ )} - - {canDecide ? ( - - {/* The change itself is always visible above, so this panel - carries only what the approver adds. */} -