mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat(reports): publish per-stop times when loading & unloading groups by train
Grouped by Train, a row was a period average over the train's stops, which threw away the one thing that grain is for: when the work actually happened. The row is now the stop itself — logged arrival, departure, unloading and loading times, and that stop's own durations. Grouped by Station it still averages over every train that called there. The two shapes need different columns, so `ReportColumn.hideWhen` names the filter values that hide a column, and the runner resolves sort against the visible set — sorting by an average and then switching to Train falls back to the default sort instead of a 42703 on a column the query no longer selects. `ReportFilterDef.defaultValue` pins the unset grain to `train`, so "no value" is never a third shape. The table, the export field list and the chart toggle all follow the visible set; the chart is station-grain only, since per-stop rows have nothing to bar-chart. Both grains EXPLAIN-validated and run against the dev database. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,30 @@
|
|||||||
|
import { visibleColumns } from '../report-runner.service';
|
||||||
|
import { loadingUnloadingReport as def } from './loading-unloading.report';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The two grains select different columns — per train the stop's own times,
|
||||||
|
* per station the averages over it. A column shown under a grain its query
|
||||||
|
* doesn't select is a blank column; a column SORTED under one is a 42703.
|
||||||
|
*/
|
||||||
|
describe('loading-unloading', () => {
|
||||||
|
const keys = (grain: string) => visibleColumns(def, { grain }).map((c) => c.key);
|
||||||
|
|
||||||
|
it('shows the stop times per train and the averages per station, never both', () => {
|
||||||
|
expect(keys('train')).toEqual(expect.arrayContaining(['arrivedAt', 'loadUnloadHours']));
|
||||||
|
expect(keys('train')).not.toEqual(expect.arrayContaining(['avgLoadUnloadHours', 'stops']));
|
||||||
|
expect(keys('station')).toEqual(expect.arrayContaining(['avgLoadUnloadHours', 'stops']));
|
||||||
|
expect(keys('station')).not.toEqual(expect.arrayContaining(['arrivedAt', 'trainNumber']));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sorts by a column both grains select, so the default sort never 42703s', () => {
|
||||||
|
for (const grain of ['train', 'station']) {
|
||||||
|
expect(keys(grain)).toContain(def.defaultSort!.key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults the grain, so an unset filter can't show the wrong half", () => {
|
||||||
|
const grain = def.filters.find((f) => f.key === 'grain')!.defaultValue;
|
||||||
|
expect(grain).toBe('train');
|
||||||
|
expect(keys(grain!)).toEqual(keys('train'));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,8 +6,10 @@ import {
|
|||||||
cycleRateExpr,
|
cycleRateExpr,
|
||||||
handlingHours,
|
handlingHours,
|
||||||
hoursBetween,
|
hoursBetween,
|
||||||
|
loadingEnd,
|
||||||
loadingHours,
|
loadingHours,
|
||||||
loadingSource,
|
loadingSource,
|
||||||
|
loadingStart,
|
||||||
otherActivityHours,
|
otherActivityHours,
|
||||||
stationStaysQb,
|
stationStaysQb,
|
||||||
unloadingHours,
|
unloadingHours,
|
||||||
@@ -18,9 +20,12 @@ import { PERIOD_FILTER, periodExprOn, periodTruncExprOn } from '../revenue-class
|
|||||||
* Loading and unloading per train — the spec's own report format: train number,
|
* Loading and unloading per train — the spec's own report format: train number,
|
||||||
* total loading and unloading time, other activity, station staying time.
|
* total loading and unloading time, other activity, station staying time.
|
||||||
*
|
*
|
||||||
* The staying-time report publishes one row per individual stop; this one rolls
|
* Two shapes, one definition. Per train the row is the stop itself: the logged
|
||||||
* a train's stops up into the chosen period, which is what "for week report,
|
* arrival, departure, unloading and loading times and that stop's own
|
||||||
* calculate average in the week" asks for. The station stays in the grain
|
* durations, because the train number is what makes a specific stop worth
|
||||||
|
* naming. Per station it rolls up into the chosen period — one row per station,
|
||||||
|
* averaged over every train that called there, which is what "for week report,
|
||||||
|
* calculate average in the week" asks for. The station stays in both grains
|
||||||
* because a train works both ends of the corridor and the standard it is judged
|
* because a train works both ends of the corridor and the standard it is judged
|
||||||
* against differs by side (10h Ethiopia, 13h Djibouti) — averaging a train's
|
* against differs by side (10h Ethiopia, 13h Djibouti) — averaging a train's
|
||||||
* Nagad and Gelan stops together would compare that mixture to one standard.
|
* Nagad and Gelan stops together would compare that mixture to one standard.
|
||||||
@@ -56,12 +61,20 @@ const GRAIN_FILTER: ReportFilterDef = {
|
|||||||
key: 'grain',
|
key: 'grain',
|
||||||
label: 'Group by',
|
label: 'Group by',
|
||||||
type: 'select',
|
type: 'select',
|
||||||
|
defaultValue: 'train',
|
||||||
options: [
|
options: [
|
||||||
{ value: 'train', label: 'Train' },
|
{ value: 'train', label: 'Train' },
|
||||||
{ value: 'station', label: 'Station' },
|
{ value: 'station', label: 'Station' },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Per train the rows are stops, so they carry times; per station, averages. */
|
||||||
|
const TRAIN_ONLY = { grain: 'station' };
|
||||||
|
const STATION_ONLY = { grain: 'train' };
|
||||||
|
|
||||||
|
/** Same display as the staying-time report, so a stop reads alike in both. */
|
||||||
|
const at = (expr: string): string => `to_char(${expr}, 'YYYY-MM-DD HH24:MI')`;
|
||||||
|
|
||||||
/** Whitelisted here, so the user's value never reaches SQL. */
|
/** Whitelisted here, so the user's value never reaches SQL. */
|
||||||
const byStation = (ctx: ReportContext): boolean => ctx.params.grain === 'station';
|
const byStation = (ctx: ReportContext): boolean => ctx.params.grain === 'station';
|
||||||
|
|
||||||
@@ -69,9 +82,10 @@ export const loadingUnloadingReport: ReportDefinition = {
|
|||||||
key: 'loading-unloading',
|
key: 'loading-unloading',
|
||||||
title: 'Loading & Unloading',
|
title: 'Loading & Unloading',
|
||||||
description:
|
description:
|
||||||
'Loading and unloading per train, at the granularity you choose — one row per train per ' +
|
'Loading and unloading, at the granularity you choose. Grouped by Train the row is one ' +
|
||||||
'station per period, which at week or month grain is that train’s average over its stops ' +
|
'stop — its logged arrival, departure, unloading and loading times and that stop’s own ' +
|
||||||
'in the period, the way the OCC report publishes it. Total loading and unloading is ' +
|
'durations. Grouped by Station it is one row per station per period, averaged over every ' +
|
||||||
|
'train that called there, the way the OCC report publishes it. Total loading and unloading is ' +
|
||||||
'the stop’s handling window, unloading start to loading end, which is the container ' +
|
'the stop’s handling window, unloading start to loading end, which is the container ' +
|
||||||
'measure; the unloading and loading columns split it for bulk stations that only do ' +
|
'measure; the unloading and loading columns split it for bulk stations that only do ' +
|
||||||
'one of the two (Nagad, BCC and DMP on the Djibouti side; Sebeta, GMP, Adama and Modjo ' +
|
'one of the two (Nagad, BCC and DMP on the Djibouti side; Sebeta, GMP, Adama and Modjo ' +
|
||||||
@@ -95,74 +109,241 @@ export const loadingUnloadingReport: ReportDefinition = {
|
|||||||
],
|
],
|
||||||
columns: [
|
columns: [
|
||||||
{ key: 'period', label: 'Period', type: 'string', sortable: true },
|
{ key: 'period', label: 'Period', type: 'string', sortable: true },
|
||||||
{ key: 'trainNumber', label: 'Train No.', type: 'string', sortable: true },
|
{
|
||||||
|
key: 'trainNumber',
|
||||||
|
label: 'Train No.',
|
||||||
|
type: 'string',
|
||||||
|
sortable: true,
|
||||||
|
hideWhen: TRAIN_ONLY,
|
||||||
|
},
|
||||||
{ key: 'station', label: 'Station', type: 'string', sortable: true },
|
{ key: 'station', label: 'Station', type: 'string', sortable: true },
|
||||||
{ key: 'country', label: 'Country', type: 'string' },
|
{ key: 'country', label: 'Country', type: 'string' },
|
||||||
{ key: 'trainType', label: 'Train type', type: 'string' },
|
// Per station this would be a MAX over whatever mix of trains called there.
|
||||||
{ key: 'stops', label: 'Stops', type: 'number', sortable: true },
|
{
|
||||||
{ key: 'handlingMeasured', label: 'Handling measured', type: 'number' },
|
key: 'trainType',
|
||||||
|
label: 'Train type',
|
||||||
|
type: 'string',
|
||||||
|
hideWhen: TRAIN_ONLY,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'stops',
|
||||||
|
label: 'Stops',
|
||||||
|
type: 'number',
|
||||||
|
sortable: true,
|
||||||
|
hideWhen: STATION_ONLY,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'handlingMeasured',
|
||||||
|
label: 'Handling measured',
|
||||||
|
type: 'number',
|
||||||
|
hideWhen: STATION_ONLY,
|
||||||
|
},
|
||||||
{ key: 'loadingSource', label: 'Loading from', type: 'string' },
|
{ key: 'loadingSource', label: 'Loading from', type: 'string' },
|
||||||
{ key: 'avgUnloadingHours', label: 'Avg unloading (hrs)', type: 'number', sortable: true },
|
// Per train: this stop's own clock, not a mean of several.
|
||||||
{ key: 'avgLoadingHours', label: 'Avg loading (hrs)', type: 'number', sortable: true },
|
{
|
||||||
|
key: 'arrivedAt',
|
||||||
|
label: 'Arrived',
|
||||||
|
type: 'date',
|
||||||
|
sortable: true,
|
||||||
|
hideWhen: TRAIN_ONLY,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'departedAt',
|
||||||
|
label: 'Departed',
|
||||||
|
type: 'date',
|
||||||
|
hideWhen: TRAIN_ONLY,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'unloadingStartedAt',
|
||||||
|
label: 'Unloading start',
|
||||||
|
type: 'date',
|
||||||
|
hideWhen: TRAIN_ONLY,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'unloadingCompletedAt',
|
||||||
|
label: 'Unloading end',
|
||||||
|
type: 'date',
|
||||||
|
hideWhen: TRAIN_ONLY,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'loadingStartedAt',
|
||||||
|
label: 'Loading start',
|
||||||
|
type: 'date',
|
||||||
|
hideWhen: TRAIN_ONLY,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'loadingCompletedAt',
|
||||||
|
label: 'Loading end',
|
||||||
|
type: 'date',
|
||||||
|
hideWhen: TRAIN_ONLY,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'unloadingHours',
|
||||||
|
label: 'Unloading (hrs)',
|
||||||
|
type: 'number',
|
||||||
|
sortable: true,
|
||||||
|
hideWhen: TRAIN_ONLY,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'loadingHours',
|
||||||
|
label: 'Loading (hrs)',
|
||||||
|
type: 'number',
|
||||||
|
sortable: true,
|
||||||
|
hideWhen: TRAIN_ONLY,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'loadUnloadHours',
|
||||||
|
label: 'Loading + unloading (hrs)',
|
||||||
|
type: 'number',
|
||||||
|
sortable: true,
|
||||||
|
hideWhen: TRAIN_ONLY,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'otherActivityHours',
|
||||||
|
label: 'Other activity (hrs)',
|
||||||
|
type: 'number',
|
||||||
|
hideWhen: TRAIN_ONLY,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'stayingHours',
|
||||||
|
label: 'Staying (hrs)',
|
||||||
|
type: 'number',
|
||||||
|
sortable: true,
|
||||||
|
hideWhen: TRAIN_ONLY,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'avgUnloadingHours',
|
||||||
|
label: 'Avg unloading (hrs)',
|
||||||
|
type: 'number',
|
||||||
|
sortable: true,
|
||||||
|
hideWhen: STATION_ONLY,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'avgLoadingHours',
|
||||||
|
label: 'Avg loading (hrs)',
|
||||||
|
type: 'number',
|
||||||
|
sortable: true,
|
||||||
|
hideWhen: STATION_ONLY,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'avgLoadUnloadHours',
|
key: 'avgLoadUnloadHours',
|
||||||
label: 'Avg loading + unloading (hrs)',
|
label: 'Avg loading + unloading (hrs)',
|
||||||
type: 'number',
|
type: 'number',
|
||||||
sortable: true,
|
sortable: true,
|
||||||
|
hideWhen: STATION_ONLY,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'avgOtherActivityHours',
|
||||||
|
label: 'Avg other activity (hrs)',
|
||||||
|
type: 'number',
|
||||||
|
hideWhen: STATION_ONLY,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'avgStayingHours',
|
||||||
|
label: 'Avg staying (hrs)',
|
||||||
|
type: 'number',
|
||||||
|
sortable: true,
|
||||||
|
hideWhen: STATION_ONLY,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'stayStandardHours',
|
||||||
|
label: 'Staying standard (hrs)',
|
||||||
|
type: 'number',
|
||||||
},
|
},
|
||||||
{ key: 'avgOtherActivityHours', label: 'Avg other activity (hrs)', type: 'number' },
|
|
||||||
{ key: 'avgStayingHours', label: 'Avg staying (hrs)', type: 'number', sortable: true },
|
|
||||||
{ key: 'stayStandardHours', label: 'Staying standard (hrs)', type: 'number' },
|
|
||||||
{ key: 'stayVerdict', label: 'Staying verdict', type: 'string' },
|
{ key: 'stayVerdict', label: 'Staying verdict', type: 'string' },
|
||||||
{ key: 'handlingStandardHours', label: 'Handling standard (hrs)', type: 'number' },
|
{
|
||||||
{ key: 'handlingRate', label: 'Handling rate', type: 'percent', sortable: true },
|
key: 'handlingStandardHours',
|
||||||
|
label: 'Handling standard (hrs)',
|
||||||
|
type: 'number',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'handlingRate',
|
||||||
|
label: 'Handling rate',
|
||||||
|
type: 'percent',
|
||||||
|
sortable: true,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
defaultSort: { key: 'period', dir: 'DESC' },
|
defaultSort: { key: 'period', dir: 'DESC' },
|
||||||
chart: { type: 'bar', x: 'trainNumber', y: ['avgLoadUnloadHours'] },
|
// Only plottable at station grain — per train the rows are individual stops,
|
||||||
|
// and the frontend drops the chart toggle when its columns are hidden.
|
||||||
|
chart: { type: 'bar', x: 'station', y: ['avgLoadUnloadHours'] },
|
||||||
query(ctx) {
|
query(ctx) {
|
||||||
const { params } = ctx;
|
const { params } = ctx;
|
||||||
|
|
||||||
|
// Per train the row IS the stop: its own logged times and its own
|
||||||
|
// durations, since an average of one stop is just the stop with the clock
|
||||||
|
// thrown away. Averaging starts where the grain stops naming the train.
|
||||||
|
if (!byStation(ctx)) {
|
||||||
|
return stationStaysQb(ctx)
|
||||||
|
.select(periodExprOn('s.arrived_at', params), 'period')
|
||||||
|
.addSelect(TRAIN_NUMBER, 'trainNumber')
|
||||||
|
.addSelect('s.station', 'station')
|
||||||
|
.addSelect('s.country', 'country')
|
||||||
|
.addSelect('s.train_type', 'trainType')
|
||||||
|
.addSelect(loadingSource('s'), 'loadingSource')
|
||||||
|
.addSelect(at('s.arrived_at'), 'arrivedAt')
|
||||||
|
.addSelect(at('s.departed_at'), 'departedAt')
|
||||||
|
.addSelect(at('s.unloading_started_at'), 'unloadingStartedAt')
|
||||||
|
.addSelect(at('s.unloading_completed_at'), 'unloadingCompletedAt')
|
||||||
|
.addSelect(at(loadingStart('s')), 'loadingStartedAt')
|
||||||
|
.addSelect(at(loadingEnd('s')), 'loadingCompletedAt')
|
||||||
|
.addSelect(unloadingHours('s'), 'unloadingHours')
|
||||||
|
.addSelect(loadingHours('s'), 'loadingHours')
|
||||||
|
.addSelect(HANDLING_HOURS, 'loadUnloadHours')
|
||||||
|
.addSelect(OTHER_ACTIVITY_HOURS, 'otherActivityHours')
|
||||||
|
.addSelect(STAYING_HOURS, 'stayingHours')
|
||||||
|
.addSelect('s.standard_hours::float8', 'stayStandardHours')
|
||||||
|
.addSelect(
|
||||||
|
`CASE WHEN (${STAYING_HOURS})::numeric <= s.standard_hours
|
||||||
|
THEN 'Encouraging' ELSE 'Needs reason' END`,
|
||||||
|
'stayVerdict',
|
||||||
|
)
|
||||||
|
.addSelect('s.handling_standard_hours::float8', 'handlingStandardHours')
|
||||||
|
.addSelect(
|
||||||
|
cycleRateExpr(`(${HANDLING_HOURS})::numeric`, 's.handling_standard_hours'),
|
||||||
|
'handlingRate',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Reused verbatim in the GROUP BY, per the trap documented on `periodExpr`.
|
// Reused verbatim in the GROUP BY, per the trap documented on `periodExpr`.
|
||||||
const bucket = periodTruncExprOn('s.arrived_at', params);
|
const bucket = periodTruncExprOn('s.arrived_at', params);
|
||||||
|
|
||||||
const perStation = byStation(ctx);
|
return (
|
||||||
const qb = stationStaysQb(ctx)
|
stationStaysQb(ctx)
|
||||||
.select(periodExprOn('s.arrived_at', params), 'period')
|
.select(periodExprOn('s.arrived_at', params), 'period')
|
||||||
.addSelect(perStation ? "'All trains'" : TRAIN_NUMBER, 'trainNumber')
|
.addSelect('s.station', 'station')
|
||||||
.addSelect('s.station', 'station')
|
.addSelect('s.country', 'country')
|
||||||
.addSelect('s.country', 'country')
|
.addSelect('COUNT(*)::int', 'stops')
|
||||||
.addSelect('MAX(s.train_type)', 'trainType')
|
.addSelect(`COUNT(${HANDLING_HOURS})::int`, 'handlingMeasured')
|
||||||
.addSelect('COUNT(*)::int', 'stops')
|
// Which side of the COALESCE the loading columns came from. A group that
|
||||||
.addSelect(`COUNT(${HANDLING_HOURS})::int`, 'handlingMeasured')
|
// mixes both says so rather than claiming either.
|
||||||
// Which side of the COALESCE the loading columns came from. A group that
|
.addSelect(
|
||||||
// mixes both says so rather than claiming either.
|
`CASE WHEN COUNT(DISTINCT ${loadingSource('s')}) > 1 THEN 'Mixed'
|
||||||
.addSelect(
|
|
||||||
`CASE WHEN COUNT(DISTINCT ${loadingSource('s')}) > 1 THEN 'Mixed'
|
|
||||||
ELSE MAX(${loadingSource('s')}) END`,
|
ELSE MAX(${loadingSource('s')}) END`,
|
||||||
'loadingSource',
|
'loadingSource',
|
||||||
)
|
)
|
||||||
.addSelect(avg(unloadingHours('s')), 'avgUnloadingHours')
|
.addSelect(avg(unloadingHours('s')), 'avgUnloadingHours')
|
||||||
.addSelect(avg(loadingHours('s')), 'avgLoadingHours')
|
.addSelect(avg(loadingHours('s')), 'avgLoadingHours')
|
||||||
.addSelect(avg(HANDLING_HOURS), 'avgLoadUnloadHours')
|
.addSelect(avg(HANDLING_HOURS), 'avgLoadUnloadHours')
|
||||||
.addSelect(avg(OTHER_ACTIVITY_HOURS), 'avgOtherActivityHours')
|
.addSelect(avg(OTHER_ACTIVITY_HOURS), 'avgOtherActivityHours')
|
||||||
.addSelect(avg(STAYING_HOURS), 'avgStayingHours')
|
.addSelect(avg(STAYING_HOURS), 'avgStayingHours')
|
||||||
.addSelect('MAX(s.standard_hours)::float8', 'stayStandardHours')
|
.addSelect('MAX(s.standard_hours)::float8', 'stayStandardHours')
|
||||||
.addSelect(
|
.addSelect(
|
||||||
`CASE WHEN AVG((${STAYING_HOURS})::numeric) <= MAX(s.standard_hours)
|
`CASE WHEN AVG((${STAYING_HOURS})::numeric) <= MAX(s.standard_hours)
|
||||||
THEN 'Encouraging' ELSE 'Needs reason' END`,
|
THEN 'Encouraging' ELSE 'Needs reason' END`,
|
||||||
'stayVerdict',
|
'stayVerdict',
|
||||||
)
|
)
|
||||||
.addSelect(`${HANDLING_STANDARD}::float8`, 'handlingStandardHours')
|
.addSelect(`${HANDLING_STANDARD}::float8`, 'handlingStandardHours')
|
||||||
// Same formula the turnaround cycle publishes, so the two read alike.
|
// Same formula the turnaround cycle publishes, so the two read alike.
|
||||||
// NULL standard in, NULL rate out — nothing to measure against yet.
|
// NULL standard in, NULL rate out — nothing to measure against yet.
|
||||||
.addSelect(
|
.addSelect(
|
||||||
cycleRateExpr(`AVG((${HANDLING_HOURS})::numeric)`, HANDLING_STANDARD),
|
cycleRateExpr(`AVG((${HANDLING_HOURS})::numeric)`, HANDLING_STANDARD),
|
||||||
'handlingRate',
|
'handlingRate',
|
||||||
)
|
)
|
||||||
.groupBy(bucket)
|
.groupBy(bucket)
|
||||||
.addGroupBy('s.station')
|
.addGroupBy('s.station')
|
||||||
.addGroupBy('s.country');
|
.addGroupBy('s.country')
|
||||||
if (!perStation) qb.addGroupBy(TRAIN_NUMBER);
|
);
|
||||||
return qb;
|
|
||||||
},
|
},
|
||||||
async summary(ctx) {
|
async summary(ctx) {
|
||||||
const row = await stationStaysQb(ctx)
|
const row = await stationStaysQb(ctx)
|
||||||
@@ -170,13 +351,26 @@ export const loadingUnloadingReport: ReportDefinition = {
|
|||||||
.addSelect(`COUNT(${HANDLING_HOURS})::int`, 'measured')
|
.addSelect(`COUNT(${HANDLING_HOURS})::int`, 'measured')
|
||||||
.addSelect(avg(HANDLING_HOURS), 'avgHandling')
|
.addSelect(avg(HANDLING_HOURS), 'avgHandling')
|
||||||
.addSelect(avg(OTHER_ACTIVITY_HOURS), 'avgOther')
|
.addSelect(avg(OTHER_ACTIVITY_HOURS), 'avgOther')
|
||||||
.getRawOne<{ stops: number; measured: number; avgHandling: number; avgOther: number }>();
|
.getRawOne<{
|
||||||
|
stops: number;
|
||||||
|
measured: number;
|
||||||
|
avgHandling: number;
|
||||||
|
avgOther: number;
|
||||||
|
}>();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{ label: 'Stops measured', value: Number(row?.stops ?? 0) },
|
{ label: 'Stops measured', value: Number(row?.stops ?? 0) },
|
||||||
{ label: 'Handling measured', value: Number(row?.measured ?? 0) },
|
{ label: 'Handling measured', value: Number(row?.measured ?? 0) },
|
||||||
{ label: 'Average loading + unloading', value: Number(row?.avgHandling ?? 0), unit: 'h' },
|
{
|
||||||
{ label: 'Average other activity', value: Number(row?.avgOther ?? 0), unit: 'h' },
|
label: 'Average loading + unloading',
|
||||||
|
value: Number(row?.avgHandling ?? 0),
|
||||||
|
unit: 'h',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Average other activity',
|
||||||
|
value: Number(row?.avgOther ?? 0),
|
||||||
|
unit: 'h',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
normalizePagination,
|
normalizePagination,
|
||||||
} from '../../common/utils/pagination.util';
|
} from '../../common/utils/pagination.util';
|
||||||
import { applyBookingRefDirectionScope } from '../user-trade-access/trade-scope.util';
|
import { applyBookingRefDirectionScope } from '../user-trade-access/trade-scope.util';
|
||||||
import { ReportDefinition, ReportRunResult } from './report.types';
|
import { ReportColumn, ReportDefinition, ReportRunResult } from './report.types';
|
||||||
|
|
||||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ function coerceParams(
|
|||||||
const items = csv?.split(',').map((s) => s.trim()).filter(Boolean) ?? [];
|
const items = csv?.split(',').map((s) => s.trim()).filter(Boolean) ?? [];
|
||||||
params[filter.key] = items.length ? items : null;
|
params[filter.key] = items.length ? items : null;
|
||||||
} else {
|
} else {
|
||||||
params[filter.key] = raw[filter.key]?.trim() || null;
|
params[filter.key] = raw[filter.key]?.trim() || filter.defaultValue || null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// idKey, when the report declares one, is a plain string param.
|
// idKey, when the report declares one, is a plain string param.
|
||||||
@@ -57,19 +57,34 @@ function coerceParams(
|
|||||||
*/
|
*/
|
||||||
const aliasSortExpr = (key: string): string => `"${key.replace(/"/g, '""')}"`;
|
const aliasSortExpr = (key: string): string => `"${key.replace(/"/g, '""')}"`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Columns the current filter values don't hide. A hidden column is not in the
|
||||||
|
* SELECT list of the shape those filters produce, so sorting by one would be a
|
||||||
|
* 42703 — the sort falls back to the default instead.
|
||||||
|
*/
|
||||||
|
export const visibleColumns = (
|
||||||
|
def: ReportDefinition,
|
||||||
|
params: Record<string, unknown>,
|
||||||
|
): ReportColumn[] =>
|
||||||
|
def.columns.filter((c) =>
|
||||||
|
Object.entries(c.hideWhen ?? {}).every(([key, value]) => params[key] !== value),
|
||||||
|
);
|
||||||
|
|
||||||
/** Resolve a client-requested sort column against the report's own whitelist. */
|
/** Resolve a client-requested sort column against the report's own whitelist. */
|
||||||
function resolveSort(
|
function resolveSort(
|
||||||
def: ReportDefinition,
|
def: ReportDefinition,
|
||||||
|
params: Record<string, unknown>,
|
||||||
sortBy?: string,
|
sortBy?: string,
|
||||||
sortOrder?: string,
|
sortOrder?: string,
|
||||||
): { key: string; expr: string; dir: 'ASC' | 'DESC' } | null {
|
): { key: string; expr: string; dir: 'ASC' | 'DESC' } | null {
|
||||||
const dir = sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
|
const dir = sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
|
||||||
const requested = sortBy && def.columns.find((c) => c.key === sortBy && c.sortable);
|
const columns = visibleColumns(def, params);
|
||||||
|
const requested = sortBy && columns.find((c) => c.key === sortBy && c.sortable);
|
||||||
if (requested) {
|
if (requested) {
|
||||||
return { key: requested.key, expr: requested.sortExpr ?? aliasSortExpr(requested.key), dir };
|
return { key: requested.key, expr: requested.sortExpr ?? aliasSortExpr(requested.key), dir };
|
||||||
}
|
}
|
||||||
if (!def.defaultSort) return null;
|
if (!def.defaultSort) return null;
|
||||||
const fallback = def.columns.find((c) => c.key === def.defaultSort!.key);
|
const fallback = columns.find((c) => c.key === def.defaultSort!.key);
|
||||||
if (!fallback) return null;
|
if (!fallback) return null;
|
||||||
return {
|
return {
|
||||||
key: fallback.key,
|
key: fallback.key,
|
||||||
@@ -91,7 +106,7 @@ export class ReportRunnerService {
|
|||||||
const ctx = { ds: this.ds, params, directions };
|
const ctx = { ds: this.ds, params, directions };
|
||||||
|
|
||||||
const qb = def.query(ctx);
|
const qb = def.query(ctx);
|
||||||
const sort = resolveSort(def, raw.sortBy, raw.sortOrder);
|
const sort = resolveSort(def, params, raw.sortBy, raw.sortOrder);
|
||||||
if (sort) qb.orderBy(sort.expr, sort.dir);
|
if (sort) qb.orderBy(sort.expr, sort.dir);
|
||||||
|
|
||||||
const { page: pageNum, pageSize, skip, take } = normalizePagination({
|
const { page: pageNum, pageSize, skip, take } = normalizePagination({
|
||||||
@@ -141,7 +156,7 @@ export class ReportRunnerService {
|
|||||||
const qb = def.query(ctx);
|
const qb = def.query(ctx);
|
||||||
// Same sort the on-screen table is using, not always the default — an
|
// Same sort the on-screen table is using, not always the default — an
|
||||||
// export is supposed to match what the user is looking at.
|
// export is supposed to match what the user is looking at.
|
||||||
const sort = resolveSort(def, raw.sortBy, raw.sortOrder);
|
const sort = resolveSort(def, params, raw.sortBy, raw.sortOrder);
|
||||||
if (sort) qb.orderBy(sort.expr, sort.dir);
|
if (sort) qb.orderBy(sort.expr, sort.dir);
|
||||||
|
|
||||||
const ceiling = limit ?? cap;
|
const ceiling = limit ?? cap;
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ export interface ReportColumn {
|
|||||||
sortable?: boolean;
|
sortable?: boolean;
|
||||||
/** SQL to ORDER BY when this column is sorted, if different from `key`. */
|
/** SQL to ORDER BY when this column is sorted, if different from `key`. */
|
||||||
sortExpr?: string;
|
sortExpr?: string;
|
||||||
|
/**
|
||||||
|
* Hide the column while a filter holds a given value — how one report serves
|
||||||
|
* two group-by grains without two column lists. Display only: the value is
|
||||||
|
* still selected, exported and sortable, it just isn't shown.
|
||||||
|
*/
|
||||||
|
hideWhen?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ReportFilterType = 'daterange' | 'date' | 'select' | 'multiselect' | 'text';
|
export type ReportFilterType = 'daterange' | 'date' | 'select' | 'multiselect' | 'text';
|
||||||
@@ -34,6 +40,12 @@ export interface ReportFilterDef {
|
|||||||
type: ReportFilterType;
|
type: ReportFilterType;
|
||||||
/** Static option list for select/multiselect. */
|
/** Static option list for select/multiselect. */
|
||||||
options?: ReportFilterOption[];
|
options?: ReportFilterOption[];
|
||||||
|
/**
|
||||||
|
* Value the filter takes when the client sends nothing — so a report whose
|
||||||
|
* shape depends on a filter (see `ReportColumn.hideWhen`) never has to guess
|
||||||
|
* what "unset" meant.
|
||||||
|
*/
|
||||||
|
defaultValue?: string;
|
||||||
/**
|
/**
|
||||||
* Resolves the option list from the database instead of declaring it inline —
|
* Resolves the option list from the database instead of declaring it inline —
|
||||||
* for filters whose choices are reference data (stations, cargo types).
|
* for filters whose choices are reference data (stations, cargo types).
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import { Button, Checkbox, Group, Modal, Radio, Select, SimpleGrid, Stack, Text } from "@mantine/core";
|
import { Button, Checkbox, Group, Modal, Radio, Select, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||||
import { Download, FileSpreadsheet, FileText } from "lucide-react";
|
import { Download, FileSpreadsheet, FileText } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import { reportsService } from "@/services/reports.service";
|
import { reportsService } from "@/services/reports.service";
|
||||||
import type { ReportCatalogEntry, ReportRunParams } from "@/types/reports";
|
import type { ReportCatalogEntry, ReportColumn, ReportRunParams } from "@/types/reports";
|
||||||
|
|
||||||
interface ReportExportButtonProps {
|
interface ReportExportButtonProps {
|
||||||
def: ReportCatalogEntry;
|
def: ReportCatalogEntry;
|
||||||
/** Filters + sort currently applied on screen — no key/page/pageSize. */
|
/** Filters + sort currently applied on screen — no key/page/pageSize. */
|
||||||
params: Omit<ReportRunParams, "key" | "page" | "pageSize">;
|
params: Omit<ReportRunParams, "key" | "page" | "pageSize">;
|
||||||
|
/** The columns on screen, which for a report with `hideWhen` is not all of them. */
|
||||||
|
columns: ReportColumn[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const RECORD_OPTIONS = [
|
const RECORD_OPTIONS = [
|
||||||
@@ -31,17 +33,21 @@ function saveBlob(blob: Blob, filename: string) {
|
|||||||
/** One export button: format, which fields, how many records — applies the
|
/** One export button: format, which fields, how many records — applies the
|
||||||
* filters/sort already on screen. Record count defaults to all (capped
|
* filters/sort already on screen. Record count defaults to all (capped
|
||||||
* server-side per format). */
|
* server-side per format). */
|
||||||
export function ReportExportButton({ def, params }: ReportExportButtonProps) {
|
export function ReportExportButton({ def, params, columns }: ReportExportButtonProps) {
|
||||||
const [opened, setOpened] = useState(false);
|
const [opened, setOpened] = useState(false);
|
||||||
const [format, setFormat] = useState<"xlsx" | "pdf">("xlsx");
|
const [format, setFormat] = useState<"xlsx" | "pdf">("xlsx");
|
||||||
const [fields, setFields] = useState<string[]>(def.columns.map((c) => c.key));
|
const [fields, setFields] = useState<string[]>(columns.map((c) => c.key));
|
||||||
const [records, setRecords] = useState("all");
|
const [records, setRecords] = useState("all");
|
||||||
const [exporting, setExporting] = useState(false);
|
const [exporting, setExporting] = useState(false);
|
||||||
|
|
||||||
const allSelected = fields.length === def.columns.length;
|
// A filter change can change which columns exist at all — start over from the
|
||||||
|
// new set rather than exporting keys the query no longer selects.
|
||||||
|
useEffect(() => setFields(columns.map((c) => c.key)), [columns]);
|
||||||
|
|
||||||
|
const allSelected = fields.length === columns.length;
|
||||||
const toggleField = (key: string) =>
|
const toggleField = (key: string) =>
|
||||||
setFields((prev) => (prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key]));
|
setFields((prev) => (prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key]));
|
||||||
const toggleAll = () => setFields(allSelected ? [] : def.columns.map((c) => c.key));
|
const toggleAll = () => setFields(allSelected ? [] : columns.map((c) => c.key));
|
||||||
|
|
||||||
const handleDownload = async () => {
|
const handleDownload = async () => {
|
||||||
setExporting(true);
|
setExporting(true);
|
||||||
@@ -110,7 +116,7 @@ export function ReportExportButton({ def, params }: ReportExportButtonProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
<SimpleGrid cols={2} spacing="xs">
|
<SimpleGrid cols={2} spacing="xs">
|
||||||
{def.columns.map((col) => (
|
{columns.map((col) => (
|
||||||
<Checkbox
|
<Checkbox
|
||||||
key={col.key}
|
key={col.key}
|
||||||
label={col.label}
|
label={col.label}
|
||||||
|
|||||||
@@ -173,9 +173,29 @@ export function ReportView({ reportKey, idKeyValue, pageHeader, defaultView }: R
|
|||||||
const total = data?.meta.total ?? 0;
|
const total = data?.meta.total ?? 0;
|
||||||
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Columns the applied filters don't hide — see ReportColumn.hideWhen. A
|
||||||
|
* filter the user hasn't touched counts as its declared default, which is
|
||||||
|
* the value the server will have used to shape the rows.
|
||||||
|
*/
|
||||||
|
const visibleColumns = useMemo(() => {
|
||||||
|
const applied = appliedParams as Record<string, unknown>;
|
||||||
|
const valueOf = (key: string) => applied[key] ?? def?.filters.find((f) => f.key === key)?.defaultValue;
|
||||||
|
return (def?.columns ?? []).filter((col) =>
|
||||||
|
Object.entries(col.hideWhen ?? {}).every(([key, value]) => valueOf(key) !== value),
|
||||||
|
);
|
||||||
|
}, [def?.columns, def?.filters, appliedParams]);
|
||||||
|
|
||||||
|
/** A chart whose x or y column is hidden has nothing to plot — drop the toggle. */
|
||||||
|
const chartDef = useMemo(() => {
|
||||||
|
if (!def?.chart) return undefined;
|
||||||
|
const shown = new Set(visibleColumns.map((c) => c.key));
|
||||||
|
return shown.has(def.chart.x) && def.chart.y.every((k) => shown.has(k)) ? def.chart : undefined;
|
||||||
|
}, [def?.chart, visibleColumns]);
|
||||||
|
|
||||||
const columns: ColumnDef<Record<string, unknown>>[] = useMemo(
|
const columns: ColumnDef<Record<string, unknown>>[] = useMemo(
|
||||||
() =>
|
() =>
|
||||||
(def?.columns ?? []).map((col) => ({
|
visibleColumns.map((col) => ({
|
||||||
id: col.key,
|
id: col.key,
|
||||||
accessorKey: col.key,
|
accessorKey: col.key,
|
||||||
header: col.sortable
|
header: col.sortable
|
||||||
@@ -188,7 +208,7 @@ export function ReportView({ reportKey, idKeyValue, pageHeader, defaultView }: R
|
|||||||
</Text>
|
</Text>
|
||||||
),
|
),
|
||||||
})),
|
})),
|
||||||
[def?.columns],
|
[visibleColumns],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!def) {
|
if (!def) {
|
||||||
@@ -197,7 +217,7 @@ export function ReportView({ reportKey, idKeyValue, pageHeader, defaultView }: R
|
|||||||
) : null;
|
) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const chartToggle = def.chart ? (
|
const chartToggle = chartDef ? (
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
size="xs"
|
size="xs"
|
||||||
value={view}
|
value={view}
|
||||||
@@ -223,7 +243,7 @@ export function ReportView({ reportKey, idKeyValue, pageHeader, defaultView }: R
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
|
|
||||||
const exportButton = <ReportExportButton def={def} params={appliedParams} />;
|
const exportButton = <ReportExportButton def={def} params={appliedParams} columns={visibleColumns} />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
@@ -269,8 +289,8 @@ export function ReportView({ reportKey, idKeyValue, pageHeader, defaultView }: R
|
|||||||
</FilterBar>
|
</FilterBar>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{view === "chart" && def.chart ? (
|
{view === "chart" && chartDef ? (
|
||||||
<ReportChart chart={def.chart} items={data?.items ?? []} columns={def.columns} total={total} />
|
<ReportChart chart={chartDef} items={data?.items ?? []} columns={visibleColumns} total={total} />
|
||||||
) : (
|
) : (
|
||||||
<Box style={{ overflowX: "auto" }} w="100%">
|
<Box style={{ overflowX: "auto" }} w="100%">
|
||||||
<DataTable
|
<DataTable
|
||||||
|
|||||||
@@ -11,9 +11,16 @@ export interface ReportColumn {
|
|||||||
label: string;
|
label: string;
|
||||||
type: ReportColumnType;
|
type: ReportColumnType;
|
||||||
sortable?: boolean;
|
sortable?: boolean;
|
||||||
|
/** Hide the column while these filter values are applied. */
|
||||||
|
hideWhen?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ReportFilterType = "daterange" | "date" | "select" | "multiselect" | "text";
|
export type ReportFilterType =
|
||||||
|
| "daterange"
|
||||||
|
| "date"
|
||||||
|
| "select"
|
||||||
|
| "multiselect"
|
||||||
|
| "text";
|
||||||
|
|
||||||
export interface ReportFilterOption {
|
export interface ReportFilterOption {
|
||||||
value: string;
|
value: string;
|
||||||
@@ -25,6 +32,8 @@ export interface ReportFilterDef {
|
|||||||
label: string;
|
label: string;
|
||||||
type: ReportFilterType;
|
type: ReportFilterType;
|
||||||
options?: ReportFilterOption[];
|
options?: ReportFilterOption[];
|
||||||
|
/** Value the server assumes when the filter is unset. */
|
||||||
|
defaultValue?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReportIdKey {
|
export interface ReportIdKey {
|
||||||
|
|||||||
Reference in New Issue
Block a user