mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-05 17:43:39 +00:00
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.
This commit is contained in:
@@ -105,4 +105,18 @@ export class ListWagonsQueryDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsDateString()
|
@IsDateString()
|
||||||
maintenanceTo?: string;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -177,6 +177,7 @@ export class WagonsService {
|
|||||||
async findAll(query: ListWagonsQueryDto = {}): Promise<PaginatedResponse<Wagon>> {
|
async findAll(query: ListWagonsQueryDto = {}): Promise<PaginatedResponse<Wagon>> {
|
||||||
const page = await paginateQuery(this.buildListQuery(query), query, { defaultPageSize: 10 });
|
const page = await paginateQuery(this.buildListQuery(query), query, { defaultPageSize: 10 });
|
||||||
await this.attachStatusDates(page.items);
|
await this.attachStatusDates(page.items);
|
||||||
|
await this.attachMovementStats(page.items, query.statsWindowDays ?? 90);
|
||||||
return page;
|
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<void> {
|
||||||
|
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<Wagon> {
|
async findById(id: string): Promise<Wagon> {
|
||||||
const wagon = await this.wagonRepo.findOne({
|
const wagon = await this.wagonRepo.findOne({
|
||||||
where: { id },
|
where: { id },
|
||||||
@@ -328,11 +379,35 @@ export class WagonsService {
|
|||||||
/** Movement ledger for one wagon, newest first (loaded legs, repositions, manual moves). */
|
/** Movement ledger for one wagon, newest first (loaded legs, repositions, manual moves). */
|
||||||
async listMovements(wagonId: string): Promise<WagonMovement[]> {
|
async listMovements(wagonId: string): Promise<WagonMovement[]> {
|
||||||
await this.findById(wagonId); // 404 on unknown wagon
|
await this.findById(wagonId); // 404 on unknown wagon
|
||||||
return this.dataSource.getRepository(WagonMovement).find({
|
const movements = await this.dataSource.getRepository(WagonMovement).find({
|
||||||
where: { wagonId },
|
where: { wagonId },
|
||||||
relations: { fromYard: true, toYard: true },
|
relations: { fromYard: true, toYard: true },
|
||||||
order: { occurredAt: 'DESC', createdAt: 'DESC' },
|
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<void> {
|
||||||
|
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<void> {
|
async remove(id: string, userId?: string | null): Promise<void> {
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ import ContractTemplatesPage from "./pages/contract_templates/ContractTemplatesP
|
|||||||
import PortalContentPage from "./pages/portal_content/PortalContentPage";
|
import PortalContentPage from "./pages/portal_content/PortalContentPage";
|
||||||
import ContractTemplateEditorPage from "./pages/contract_templates/ContractTemplateEditorPage";
|
import ContractTemplateEditorPage from "./pages/contract_templates/ContractTemplateEditorPage";
|
||||||
import FleetResourcePage from "./pages/fleet/FleetResourcePage";
|
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 WagonTransfersPage from "./pages/wagons/WagonTransfersPage";
|
||||||
import VehicleDetailPage from "./pages/fleet/VehicleDetailPage";
|
import VehicleDetailPage from "./pages/fleet/VehicleDetailPage";
|
||||||
import DriverDetailPage from "./pages/fleet/DriverDetailPage";
|
import DriverDetailPage from "./pages/fleet/DriverDetailPage";
|
||||||
@@ -232,6 +234,24 @@ const App = () => {
|
|||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
{/* Wagon performance — a read-only executive report beside Overview.
|
||||||
|
Separate from the Fleet Management wagons desk, which owns CRUD. */}
|
||||||
|
<Route
|
||||||
|
path="wagon-performance"
|
||||||
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.wagons.view}>
|
||||||
|
<WagonPerformancePage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="wagon-performance/:id"
|
||||||
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.wagons.view}>
|
||||||
|
<WagonPerformanceDetailPage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
{/* One drill-down route per overview domain — the old per-tab charts,
|
{/* One drill-down route per overview domain — the old per-tab charts,
|
||||||
now each on its own page. Single source of truth for the
|
now each on its own page. Single source of truth for the
|
||||||
permission gate is OVERVIEW_DOMAINS, shared with the summary
|
permission gate is OVERVIEW_DOMAINS, shared with the summary
|
||||||
|
|||||||
@@ -69,6 +69,12 @@ export const buildSidebarSections = (
|
|||||||
icon: <LayoutDashboard />,
|
icon: <LayoutDashboard />,
|
||||||
permission: FREIGHT_PERMS.overview.view,
|
permission: FREIGHT_PERMS.overview.view,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "Wagon Performance",
|
||||||
|
href: "/dashboard/wagon-performance",
|
||||||
|
icon: <TrainFront />,
|
||||||
|
permission: FREIGHT_PERMS.wagons.view,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "Customers",
|
label: "Customers",
|
||||||
href: "/dashboard/customers",
|
href: "/dashboard/customers",
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Collapse,
|
|
||||||
Group,
|
Group,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
Textarea,
|
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import type { UseMutationResult } from "@tanstack/react-query";
|
import type { UseMutationResult } from "@tanstack/react-query";
|
||||||
@@ -143,9 +140,6 @@ const RateApprovalsSection = ({
|
|||||||
reject,
|
reject,
|
||||||
refLabels,
|
refLabels,
|
||||||
}: RateApprovalsSectionProps) => {
|
}: RateApprovalsSectionProps) => {
|
||||||
const [openId, setOpenId] = useState<string | null>(null);
|
|
||||||
const [notes, setNotes] = useState<Record<string, string>>({});
|
|
||||||
|
|
||||||
if (requests.length === 0) return null;
|
if (requests.length === 0) return null;
|
||||||
|
|
||||||
const decidingId = approve.variables?.id ?? reject.variables?.id ?? null;
|
const decidingId = approve.variables?.id ?? reject.variables?.id ?? null;
|
||||||
@@ -165,7 +159,6 @@ const RateApprovalsSection = ({
|
|||||||
|
|
||||||
<Stack gap={8}>
|
<Stack gap={8}>
|
||||||
{requests.map((r) => {
|
{requests.map((r) => {
|
||||||
const isOpen = openId === r.id;
|
|
||||||
const fields = Object.keys(r.payload);
|
const fields = Object.keys(r.payload);
|
||||||
const rows = summaryRows(r, refLabels);
|
const rows = summaryRows(r, refLabels);
|
||||||
// Only the row being decided shows a spinner — the mutation's
|
// Only the row being decided shows a spinner — the mutation's
|
||||||
@@ -201,21 +194,10 @@ const RateApprovalsSection = ({
|
|||||||
</Group>
|
</Group>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<Group gap={6}>
|
<Text size="xs" c="dimmed">
|
||||||
<Text size="xs" c="dimmed">
|
Submitted {fmtDateTime(r.createdAt)} · {fields.length}{" "}
|
||||||
Submitted {fmtDateTime(r.createdAt)} · {fields.length}{" "}
|
{fields.length === 1 ? "field" : "fields"} changed
|
||||||
{fields.length === 1 ? "field" : "fields"} changed
|
</Text>
|
||||||
</Text>
|
|
||||||
{canDecide ? (
|
|
||||||
<Button
|
|
||||||
size="compact-xs"
|
|
||||||
variant="subtle"
|
|
||||||
onClick={() => setOpenId(isOpen ? null : r.id)}
|
|
||||||
>
|
|
||||||
{isOpen ? "Hide note" : "Add a note"}
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
{canDecide ? (
|
{canDecide ? (
|
||||||
@@ -228,7 +210,7 @@ const RateApprovalsSection = ({
|
|||||||
loading={busy && reject.isPending}
|
loading={busy && reject.isPending}
|
||||||
disabled={busy && approve.isPending}
|
disabled={busy && approve.isPending}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
reject.mutate({ id: r.id, decisionNote: notes[r.id] || undefined })
|
reject.mutate({ id: r.id })
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Reject
|
Reject
|
||||||
@@ -240,7 +222,7 @@ const RateApprovalsSection = ({
|
|||||||
loading={busy && approve.isPending}
|
loading={busy && approve.isPending}
|
||||||
disabled={busy && reject.isPending}
|
disabled={busy && reject.isPending}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
approve.mutate({ id: r.id, decisionNote: notes[r.id] || undefined })
|
approve.mutate({ id: r.id })
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Approve & apply
|
Approve & apply
|
||||||
@@ -255,25 +237,6 @@ const RateApprovalsSection = ({
|
|||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Collapse in={isOpen}>
|
|
||||||
{canDecide ? (
|
|
||||||
<Stack gap={6} mt="sm" pt="sm" style={{ borderTop: "1px solid var(--mantine-color-default-border)" }}>
|
|
||||||
{/* The change itself is always visible above, so this panel
|
|
||||||
carries only what the approver adds. */}
|
|
||||||
<Textarea
|
|
||||||
size="xs"
|
|
||||||
autosize
|
|
||||||
minRows={2}
|
|
||||||
label="Decision note (optional)"
|
|
||||||
placeholder="Shown to the requester with your decision"
|
|
||||||
value={notes[r.id] ?? ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
setNotes((prev) => ({ ...prev, [r.id]: e.currentTarget.value }))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
) : null}
|
|
||||||
</Collapse>
|
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -322,10 +322,6 @@ const RuleEngineResourcePage = () => {
|
|||||||
);
|
);
|
||||||
const { data: yardOptions, isLoading: yardOptionsLoading } =
|
const { data: yardOptions, isLoading: yardOptionsLoading } =
|
||||||
useYardOptions(usesYardField);
|
useYardOptions(usesYardField);
|
||||||
const yardLabelById = useMemo(
|
|
||||||
() => Object.fromEntries((yardOptions ?? []).map((y) => [y.value, y.label])),
|
|
||||||
[yardOptions],
|
|
||||||
);
|
|
||||||
/**
|
/**
|
||||||
* Every id a rate diff can name, in one map. A pending change that swaps the
|
* Every id a rate diff can name, in one map. A pending change that swaps the
|
||||||
* cargo type or the container size stores raw uuids, so without this the
|
* cargo type or the container size stores raw uuids, so without this the
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { ActionIcon, Tooltip } from "@mantine/core";
|
||||||
|
import { Download } from "lucide-react";
|
||||||
|
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
|
export interface SectionExportButtonProps {
|
||||||
|
/** What this button downloads, e.g. "wagon list" — used in the tooltip and toast. */
|
||||||
|
label: string;
|
||||||
|
/** Runs the download; false means there was nothing to write. */
|
||||||
|
onExport: () => boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Excel download for one section of the wagon performance report. Sits in the
|
||||||
|
* section's own header, so what it exports is unambiguous — the block it is
|
||||||
|
* attached to, exactly as filtered on screen.
|
||||||
|
*/
|
||||||
|
export function SectionExportButton({
|
||||||
|
label,
|
||||||
|
onExport,
|
||||||
|
disabled,
|
||||||
|
}: SectionExportButtonProps) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip label={`Download ${label} as Excel`}>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
size="md"
|
||||||
|
aria-label={`Download ${label} as Excel`}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={(e) => {
|
||||||
|
// The row underneath may navigate; a download must not trigger it.
|
||||||
|
e.stopPropagation();
|
||||||
|
const wrote = onExport();
|
||||||
|
if (!wrote) {
|
||||||
|
toast({
|
||||||
|
title: "Nothing to export",
|
||||||
|
description: `There are no ${label} rows to download yet.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Download size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,80 @@
|
|||||||
|
import * as XLSX from "xlsx";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Excel download for one section of the wagon performance report.
|
||||||
|
*
|
||||||
|
* Each section on the page exports exactly what is on screen — the same rows,
|
||||||
|
* in the same order, honouring the same filters and date window — so a figure
|
||||||
|
* in the spreadsheet always reconciles with the figure the CEO just read.
|
||||||
|
*
|
||||||
|
* Built client-side from data already in the browser: the report holds the
|
||||||
|
* whole fleet in memory (see WagonPerformancePage), so there is nothing to
|
||||||
|
* re-fetch and no server round-trip.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** A sheet's worth of rows: ordered column headers plus plain-value records. */
|
||||||
|
export interface SheetSpec {
|
||||||
|
/** Sheet tab name. Excel caps these at 31 chars and forbids : \ / ? * [ ]. */
|
||||||
|
name: string;
|
||||||
|
rows: Array<Record<string, string | number | null>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Excel rejects these in a sheet name, and silently truncates past 31 chars. */
|
||||||
|
const safeSheetName = (name: string): string =>
|
||||||
|
name.replace(/[:\\/?*[\]]/g, "-").slice(0, 31) || "Sheet1";
|
||||||
|
|
||||||
|
/** Widen each column to its longest cell, so nothing opens as ####. */
|
||||||
|
function fitColumns(
|
||||||
|
rows: Array<Record<string, unknown>>,
|
||||||
|
): Array<{ wch: number }> {
|
||||||
|
const headers = Object.keys(rows[0] ?? {});
|
||||||
|
return headers.map((h) => {
|
||||||
|
const longest = rows.reduce((max, row) => {
|
||||||
|
const cell = row[h];
|
||||||
|
const len = cell == null ? 0 : String(cell).length;
|
||||||
|
return len > max ? len : max;
|
||||||
|
}, h.length);
|
||||||
|
// Cap the width so one long note cannot push a column off the screen.
|
||||||
|
return { wch: Math.min(Math.max(longest + 2, 10), 60) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Timestamp suffix so repeated downloads don't overwrite each other. */
|
||||||
|
const stamp = (): string => {
|
||||||
|
const d = new Date();
|
||||||
|
const pad = (n: number) => String(n).padStart(2, "0");
|
||||||
|
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download one or more sheets as a single .xlsx.
|
||||||
|
*
|
||||||
|
* `filenameBase` gets the timestamp and extension appended. Sheets with no
|
||||||
|
* rows are skipped; if that leaves nothing, the download is skipped entirely
|
||||||
|
* and the function returns false so the caller can say so.
|
||||||
|
*/
|
||||||
|
export function downloadSheets(
|
||||||
|
filenameBase: string,
|
||||||
|
sheets: SheetSpec[],
|
||||||
|
): boolean {
|
||||||
|
const populated = sheets.filter((s) => s.rows.length > 0);
|
||||||
|
if (!populated.length) return false;
|
||||||
|
|
||||||
|
const workbook = XLSX.utils.book_new();
|
||||||
|
for (const spec of populated) {
|
||||||
|
const sheet = XLSX.utils.json_to_sheet(spec.rows);
|
||||||
|
sheet["!cols"] = fitColumns(spec.rows);
|
||||||
|
XLSX.utils.book_append_sheet(workbook, sheet, safeSheetName(spec.name));
|
||||||
|
}
|
||||||
|
XLSX.writeFile(workbook, `${filenameBase}-${stamp()}.xlsx`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Single-sheet convenience wrapper — the shape most sections need. */
|
||||||
|
export function downloadSheet(
|
||||||
|
filenameBase: string,
|
||||||
|
sheetName: string,
|
||||||
|
rows: Array<Record<string, string | number | null>>,
|
||||||
|
): boolean {
|
||||||
|
return downloadSheets(filenameBase, [{ name: sheetName, rows }]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
/**
|
||||||
|
* Derived wagon performance figures for the CEO's wagon report.
|
||||||
|
*
|
||||||
|
* Nothing here is stored: every number is computed in the browser from the
|
||||||
|
* ledgers the API already returns — `wagon_movements` (relocations),
|
||||||
|
* `wagon_status_logs` (roster flips) and `wagon_events` (unified history).
|
||||||
|
* Keeping the derivation in one place means the report and the wagon record
|
||||||
|
* can never disagree about what "idle" or "utilisation" means.
|
||||||
|
*
|
||||||
|
* This report is READ-ONLY and lives beside the Overview dashboard. It does
|
||||||
|
* not replace the Fleet Management wagons desk, which owns wagon CRUD.
|
||||||
|
*/
|
||||||
|
import { Freight } from "@edr/types";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
Wagon,
|
||||||
|
WagonMovementRecord,
|
||||||
|
WagonStatusLog,
|
||||||
|
} from "@/services/wagon.service";
|
||||||
|
|
||||||
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
/** Days past which a parked wagon is treated as stranded. */
|
||||||
|
export const IDLE_THRESHOLD_DAYS = 21;
|
||||||
|
|
||||||
|
/** Days off the roster past which a repair is treated as overdue. */
|
||||||
|
export const DOWN_THRESHOLD_DAYS = 30;
|
||||||
|
|
||||||
|
/** Statuses that take a wagon off the earning roster. */
|
||||||
|
export const OFF_ROSTER_STATUSES: Freight.WagonStatus[] = [
|
||||||
|
Freight.WagonStatus.Maintenance,
|
||||||
|
Freight.WagonStatus.Detained,
|
||||||
|
Freight.WagonStatus.OutOfService,
|
||||||
|
];
|
||||||
|
|
||||||
|
export const isOffRoster = (status: Freight.WagonStatus): boolean =>
|
||||||
|
OFF_ROSTER_STATUSES.includes(status);
|
||||||
|
|
||||||
|
/** Whole days between `iso` and now; null when the timestamp is missing. */
|
||||||
|
export function daysSince(iso: string | null | undefined): number | null {
|
||||||
|
if (!iso) return null;
|
||||||
|
const t = new Date(iso).getTime();
|
||||||
|
if (Number.isNaN(t)) return null;
|
||||||
|
return Math.max(0, Math.floor((Date.now() - t) / DAY_MS));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fractional days between two timestamps; `to` null means "still open". */
|
||||||
|
export function daysBetween(
|
||||||
|
from: string | null | undefined,
|
||||||
|
to: string | null | undefined,
|
||||||
|
): number | null {
|
||||||
|
if (!from) return null;
|
||||||
|
const a = new Date(from).getTime();
|
||||||
|
if (Number.isNaN(a)) return null;
|
||||||
|
const b = to ? new Date(to).getTime() : Date.now();
|
||||||
|
if (Number.isNaN(b)) return null;
|
||||||
|
return Math.max(0, (b - a) / DAY_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WagonPerformance {
|
||||||
|
/** Days since the wagon last arrived anywhere — the idle clock. */
|
||||||
|
idleDays: number | null;
|
||||||
|
/** Days in the current off-roster spell; null while in service. */
|
||||||
|
downDays: number | null;
|
||||||
|
loads: number;
|
||||||
|
moves: number;
|
||||||
|
emptyMoves: number;
|
||||||
|
manualMoves: number;
|
||||||
|
/** Share of moves that carried cargo, 0–100; null when nothing moved. */
|
||||||
|
loadedShare: number | null;
|
||||||
|
lastMovement: WagonMovementRecord | null;
|
||||||
|
/** Off-roster spells overlapping the window. */
|
||||||
|
spells: number;
|
||||||
|
/** Days off roster inside the window. */
|
||||||
|
downDaysInWindow: number;
|
||||||
|
/** Share of the window spent on the roster, 0–100. */
|
||||||
|
availability: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Roll one wagon's ledgers up into the figures the report shows.
|
||||||
|
*
|
||||||
|
* `windowDays` bounds loads, moves and downtime. Idle days and the current
|
||||||
|
* down spell are "how long has this been true right now" — never windowed.
|
||||||
|
*/
|
||||||
|
export function computeWagonPerformance(
|
||||||
|
wagon: Pick<Wagon, "status" | "lastMaintenanceAt" | "lastAvailableAt">,
|
||||||
|
movements: WagonMovementRecord[],
|
||||||
|
statusLogs: WagonStatusLog[],
|
||||||
|
windowDays: number,
|
||||||
|
): WagonPerformance {
|
||||||
|
const since = Date.now() - windowDays * DAY_MS;
|
||||||
|
|
||||||
|
// Movements arrive newest-first from the API; don't rely on it.
|
||||||
|
const ordered = [...movements].sort(
|
||||||
|
(a, b) =>
|
||||||
|
new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime(),
|
||||||
|
);
|
||||||
|
const lastMovement = ordered[0] ?? null;
|
||||||
|
|
||||||
|
const inWindow = ordered.filter((m) => {
|
||||||
|
const t = new Date(m.occurredAt).getTime();
|
||||||
|
return !Number.isNaN(t) && t >= since;
|
||||||
|
});
|
||||||
|
|
||||||
|
const loads = inWindow.filter(
|
||||||
|
(m) => m.kind === Freight.WagonMovementKind.Loaded,
|
||||||
|
).length;
|
||||||
|
const emptyMoves = inWindow.filter(
|
||||||
|
(m) => m.kind === Freight.WagonMovementKind.EmptyReposition,
|
||||||
|
).length;
|
||||||
|
const manualMoves = inWindow.filter(
|
||||||
|
(m) => m.kind === Freight.WagonMovementKind.Manual,
|
||||||
|
).length;
|
||||||
|
const moves = inWindow.length;
|
||||||
|
|
||||||
|
const idleDays = daysSince(lastMovement?.occurredAt ?? null);
|
||||||
|
|
||||||
|
// Newest first, so a flip's "until" is the log entry before it in the array.
|
||||||
|
const logs = [...statusLogs].sort(
|
||||||
|
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let downDays: number | null = null;
|
||||||
|
if (isOffRoster(wagon.status)) {
|
||||||
|
const entered = logs.find((l) => l.toStatus === wagon.status);
|
||||||
|
downDays = daysSince(entered?.createdAt ?? wagon.lastMaintenanceAt ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Downtime inside the window: walk each off-roster entry to the flip that
|
||||||
|
// ended it, clamping both ends to the window.
|
||||||
|
let downDaysInWindow = 0;
|
||||||
|
let spells = 0;
|
||||||
|
logs.forEach((log, i) => {
|
||||||
|
if (!isOffRoster(log.toStatus)) return;
|
||||||
|
const start = new Date(log.createdAt).getTime();
|
||||||
|
if (Number.isNaN(start)) return;
|
||||||
|
const closed = logs[i - 1];
|
||||||
|
const end = closed ? new Date(closed.createdAt).getTime() : Date.now();
|
||||||
|
const from = Math.max(start, since);
|
||||||
|
const to = Math.min(end, Date.now());
|
||||||
|
if (to <= from) return;
|
||||||
|
downDaysInWindow += (to - from) / DAY_MS;
|
||||||
|
spells += 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
const availability =
|
||||||
|
windowDays > 0
|
||||||
|
? Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(
|
||||||
|
100,
|
||||||
|
Math.round(((windowDays - downDaysInWindow) / windowDays) * 100),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: 100;
|
||||||
|
|
||||||
|
return {
|
||||||
|
idleDays,
|
||||||
|
downDays,
|
||||||
|
loads,
|
||||||
|
moves,
|
||||||
|
emptyMoves,
|
||||||
|
manualMoves,
|
||||||
|
loadedShare: moves > 0 ? Math.round((loads / moves) * 100) : null,
|
||||||
|
lastMovement,
|
||||||
|
spells,
|
||||||
|
downDaysInWindow: Math.round(downDaysInWindow),
|
||||||
|
availability,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mantine colour per wagon status. */
|
||||||
|
export function statusColor(status: Freight.WagonStatus): string {
|
||||||
|
switch (status) {
|
||||||
|
case Freight.WagonStatus.Available:
|
||||||
|
return "edr-green";
|
||||||
|
case Freight.WagonStatus.Assigned:
|
||||||
|
case Freight.WagonStatus.ImportReady:
|
||||||
|
return "blue";
|
||||||
|
case Freight.WagonStatus.ExportReady:
|
||||||
|
return "teal";
|
||||||
|
case Freight.WagonStatus.Maintenance:
|
||||||
|
return "yellow";
|
||||||
|
case Freight.WagonStatus.Detained:
|
||||||
|
return "red";
|
||||||
|
case Freight.WagonStatus.OutOfService:
|
||||||
|
default:
|
||||||
|
return "gray";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mantine colour per movement kind. */
|
||||||
|
export function movementKindColor(kind: Freight.WagonMovementKind): string {
|
||||||
|
switch (kind) {
|
||||||
|
case Freight.WagonMovementKind.Loaded:
|
||||||
|
return "edr-green";
|
||||||
|
case Freight.WagonMovementKind.EmptyReposition:
|
||||||
|
return "teal";
|
||||||
|
case Freight.WagonMovementKind.Maintenance:
|
||||||
|
return "yellow";
|
||||||
|
case Freight.WagonMovementKind.Manual:
|
||||||
|
default:
|
||||||
|
return "gray";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mantine colour per history-event category. */
|
||||||
|
export function eventCategoryColor(
|
||||||
|
category: Freight.WagonEventCategory,
|
||||||
|
): string {
|
||||||
|
switch (category) {
|
||||||
|
case Freight.WagonEventCategory.Yard:
|
||||||
|
return "yellow";
|
||||||
|
case Freight.WagonEventCategory.Train:
|
||||||
|
return "blue";
|
||||||
|
case Freight.WagonEventCategory.Schedule:
|
||||||
|
return "indigo";
|
||||||
|
case Freight.WagonEventCategory.Cargo:
|
||||||
|
return "edr-green";
|
||||||
|
case Freight.WagonEventCategory.Status:
|
||||||
|
return "orange";
|
||||||
|
case Freight.WagonEventCategory.Lifecycle:
|
||||||
|
default:
|
||||||
|
return "gray";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Idle banding shared by the table and the distribution chart. */
|
||||||
|
export function idleBand(
|
||||||
|
idleDays: number | null,
|
||||||
|
): "ok" | "watch" | "stranded" | "unknown" {
|
||||||
|
if (idleDays == null) return "unknown";
|
||||||
|
if (idleDays > IDLE_THRESHOLD_DAYS) return "stranded";
|
||||||
|
if (idleDays > Math.round(IDLE_THRESHOLD_DAYS / 2)) return "watch";
|
||||||
|
return "ok";
|
||||||
|
}
|
||||||
@@ -29,6 +29,12 @@ export interface Wagon {
|
|||||||
/** Latest status-log flip to MAINTENANCE / to AVAILABLE (list endpoint only). */
|
/** Latest status-log flip to MAINTENANCE / to AVAILABLE (list endpoint only). */
|
||||||
lastMaintenanceAt?: string | null;
|
lastMaintenanceAt?: string | null;
|
||||||
lastAvailableAt?: string | null;
|
lastAvailableAt?: string | null;
|
||||||
|
/** Newest wagon_movements arrival — the idle clock's start (list endpoint only). */
|
||||||
|
lastMovedAt?: string | null;
|
||||||
|
/** Loaded / empty / total moves inside `statsWindowDays` (list endpoint only). */
|
||||||
|
loadsInWindow?: number;
|
||||||
|
movesInWindow?: number;
|
||||||
|
emptyMovesInWindow?: number;
|
||||||
currentYardId: string | null;
|
currentYardId: string | null;
|
||||||
currentYard?: { id: string; label?: string; code?: string } | null;
|
currentYard?: { id: string; label?: string; code?: string } | null;
|
||||||
/** Odd EXPORT run (Ethiopia → Djibouti); null when the wagon is not on a run. */
|
/** Odd EXPORT run (Ethiopia → Djibouti); null when the wagon is not on a run. */
|
||||||
@@ -55,6 +61,8 @@ export interface WagonListFilters {
|
|||||||
* the latest status-log flip to MAINTENANCE, not a stored column. */
|
* the latest status-log flip to MAINTENANCE, not a stored column. */
|
||||||
maintenanceFrom?: string;
|
maintenanceFrom?: string;
|
||||||
maintenanceTo?: string;
|
maintenanceTo?: string;
|
||||||
|
/** Window (days) the per-row load/move counts cover. Does not filter rows. */
|
||||||
|
statsWindowDays?: number;
|
||||||
/** Only read by `getPaged`. */
|
/** Only read by `getPaged`. */
|
||||||
page?: number;
|
page?: number;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
@@ -73,6 +81,8 @@ const wagonListQuery = (filters: WagonListFilters): string => {
|
|||||||
if (filters.createdTo) params.set('createdTo', filters.createdTo);
|
if (filters.createdTo) params.set('createdTo', filters.createdTo);
|
||||||
if (filters.maintenanceFrom) params.set('maintenanceFrom', filters.maintenanceFrom);
|
if (filters.maintenanceFrom) params.set('maintenanceFrom', filters.maintenanceFrom);
|
||||||
if (filters.maintenanceTo) params.set('maintenanceTo', filters.maintenanceTo);
|
if (filters.maintenanceTo) params.set('maintenanceTo', filters.maintenanceTo);
|
||||||
|
if (filters.statsWindowDays)
|
||||||
|
params.set('statsWindowDays', String(filters.statsWindowDays));
|
||||||
if (filters.page) params.set('page', String(filters.page));
|
if (filters.page) params.set('page', String(filters.page));
|
||||||
if (filters.pageSize) params.set('pageSize', String(filters.pageSize));
|
if (filters.pageSize) params.set('pageSize', String(filters.pageSize));
|
||||||
const qs = params.toString();
|
const qs = params.toString();
|
||||||
@@ -101,6 +111,8 @@ export interface WagonMovementRecord {
|
|||||||
note: string | null;
|
note: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
wagon?: { id: string; wagonNumber?: string } | null;
|
wagon?: { id: string; wagonNumber?: string } | null;
|
||||||
|
/** The booking's human reference, joined at read time. Null when unloaded. */
|
||||||
|
bookingReference?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One row of the wagon status audit trail. Returned newest first by the API. */
|
/** One row of the wagon status audit trail. Returned newest first by the API. */
|
||||||
|
|||||||
Reference in New Issue
Block a user