diff --git a/apps/edr-freight-api/src/migrations/3690000000000-CheckpointHandlingTimes.ts b/apps/edr-freight-api/src/migrations/3690000000000-CheckpointHandlingTimes.ts new file mode 100644 index 000000000..f4cae1161 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3690000000000-CheckpointHandlingTimes.ts @@ -0,0 +1,41 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Loading and unloading times, on the stop that already records the train + * standing at a station. + * + * The OCC report publishes, per train, "total loading and unloading time" and + * the "other activity" left over from the station stay. Nothing recorded when + * handling started or ended — the July 2026 seed had to write the figure into + * a checkpoint's note — so the staying-time report could only ever publish the + * whole stay. + * + * These four go on `train_checkpoint_events` rather than a table of their own: + * a stop is already one row there, keyed (schedule, sequence_no), and the + * arrival row is the one the staying-time report builds a stay from. All four + * are nullable — a stop where nobody logged the handling still reports its + * staying time, with the handling columns empty rather than zero. + */ +export class CheckpointHandlingTimes3690000000000 implements MigrationInterface { + name = "CheckpointHandlingTimes3690000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_checkpoint_events + ADD COLUMN IF NOT EXISTS unloading_started_at timestamptz, + ADD COLUMN IF NOT EXISTS unloading_completed_at timestamptz, + ADD COLUMN IF NOT EXISTS loading_started_at timestamptz, + ADD COLUMN IF NOT EXISTS loading_completed_at timestamptz; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_checkpoint_events + DROP COLUMN IF EXISTS unloading_started_at, + DROP COLUMN IF EXISTS unloading_completed_at, + DROP COLUMN IF EXISTS loading_started_at, + DROP COLUMN IF EXISTS loading_completed_at; + `); + } +} diff --git a/apps/edr-freight-api/src/modules/reports/definitions/loading-unloading.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/loading-unloading.report.ts new file mode 100644 index 000000000..c6dfed921 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/loading-unloading.report.ts @@ -0,0 +1,129 @@ +import { ReportDefinition } from '../report.types'; +import { + COUNTRY_FILTER, + DIRECTION_FILTER, + handlingHours, + hoursBetween, + loadingHours, + otherActivityHours, + stationStaysQb, + unloadingHours, +} from '../operations-classification'; +import { PERIOD_FILTER, periodExprOn, periodTruncExprOn } from '../revenue-classification'; + +/** + * Loading and unloading per train — the spec's own report format: train number, + * total loading and unloading time, other activity, station staying time. + * + * The staying-time report publishes one row per individual stop; this one rolls + * a train's stops up into the chosen period, which is what "for week report, + * calculate average in the week" asks for. The station stays in the grain + * 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 + * Nagad and Gelan stops together would compare that mixture to one standard. + * + * Container work reads as one handling window (unloading start to loading end) + * because that is how the corridor measures a turnaround; bulk stations, which + * load OR unload rather than both, get the two halves in their own columns. + * + * Averages skip the stops nobody logged handling for rather than counting them + * as zero — `AVG` ignores nulls — so `Stops` is the population and `Handling + * logged` says how much of it the handling averages actually rest on. + */ +const STAYING_HOURS = hoursBetween('s.arrived_at', 's.departed_at'); +const HANDLING_HOURS = handlingHours('s'); +const OTHER_ACTIVITY_HOURS = otherActivityHours(STAYING_HOURS, HANDLING_HOURS); + +const avg = (expr: string): string => `ROUND(AVG((${expr})::numeric), 1)::float8`; + +/** Reused verbatim in the GROUP BY — the group key is the coalesced value. */ +const TRAIN_NUMBER = "COALESCE(s.train_number, '—')"; + +export const loadingUnloadingReport: ReportDefinition = { + key: 'loading-unloading', + title: 'Loading & Unloading', + description: + 'Loading and unloading per train, at the granularity you choose — one row per train per ' + + 'station per period, which at week or month grain is that train’s average over its stops ' + + 'in the period, 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 ' + + '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 ' + + 'on the Ethiopian). Other activity is the rest of the station stay, against the 10h ' + + 'Ethiopia / 13h Djibouti standard from Operating standards. Averages rest only on the ' + + 'stops whose handling was actually logged — “Handling logged” counts them.', + group: 'Operations', + filters: [ + PERIOD_FILTER, + { key: 'date', label: 'Arrival', type: 'daterange' }, + DIRECTION_FILTER, + { key: 'trainNumber', label: 'Train No.', type: 'text' }, + { key: 'station', label: 'Station', type: 'text' }, + COUNTRY_FILTER, + ], + columns: [ + { key: 'period', label: 'Period', type: 'string', sortable: true }, + { key: 'trainNumber', label: 'Train No.', type: 'string', sortable: true }, + { key: 'station', label: 'Station', type: 'string', sortable: true }, + { key: 'country', label: 'Country', type: 'string' }, + { key: 'stops', label: 'Stops', type: 'number', sortable: true }, + { key: 'handlingLogged', label: 'Handling logged', type: 'number' }, + { key: 'avgUnloadingHours', label: 'Avg unloading (hrs)', type: 'number', sortable: true }, + { key: 'avgLoadingHours', label: 'Avg loading (hrs)', type: 'number', sortable: true }, + { + key: 'avgLoadUnloadHours', + label: 'Avg loading + unloading (hrs)', + type: 'number', + sortable: true, + }, + { key: 'avgOtherActivityHours', label: 'Avg other activity (hrs)', type: 'number' }, + { key: 'avgStayingHours', label: 'Avg staying (hrs)', type: 'number', sortable: true }, + { key: 'standardHours', label: 'Standard (hrs)', type: 'number' }, + { key: 'verdict', label: 'Verdict', type: 'string' }, + ], + defaultSort: { key: 'period', dir: 'DESC' }, + chart: { type: 'bar', x: 'trainNumber', y: ['avgLoadUnloadHours'] }, + query(ctx) { + const { params } = ctx; + // Reused verbatim in the GROUP BY, per the trap documented on `periodExpr`. + const bucket = periodTruncExprOn('s.arrived_at', params); + + return stationStaysQb(ctx) + .select(periodExprOn('s.arrived_at', params), 'period') + .addSelect(TRAIN_NUMBER, 'trainNumber') + .addSelect('s.station', 'station') + .addSelect('s.country', 'country') + .addSelect('COUNT(*)::int', 'stops') + .addSelect(`COUNT(${HANDLING_HOURS})::int`, 'handlingLogged') + .addSelect(avg(unloadingHours('s')), 'avgUnloadingHours') + .addSelect(avg(loadingHours('s')), 'avgLoadingHours') + .addSelect(avg(HANDLING_HOURS), 'avgLoadUnloadHours') + .addSelect(avg(OTHER_ACTIVITY_HOURS), 'avgOtherActivityHours') + .addSelect(avg(STAYING_HOURS), 'avgStayingHours') + .addSelect('MAX(s.standard_hours)::float8', 'standardHours') + .addSelect( + `CASE WHEN AVG((${STAYING_HOURS})::numeric) <= MAX(s.standard_hours) + THEN 'Encouraging' ELSE 'Needs reason' END`, + 'verdict', + ) + .groupBy(bucket) + .addGroupBy(TRAIN_NUMBER) + .addGroupBy('s.station') + .addGroupBy('s.country'); + }, + async summary(ctx) { + const row = await stationStaysQb(ctx) + .select('COUNT(*)::int', 'stops') + .addSelect(`COUNT(${HANDLING_HOURS})::int`, 'logged') + .addSelect(avg(HANDLING_HOURS), 'avgHandling') + .addSelect(avg(OTHER_ACTIVITY_HOURS), 'avgOther') + .getRawOne<{ stops: number; logged: number; avgHandling: number; avgOther: number }>(); + + return [ + { label: 'Stops measured', value: Number(row?.stops ?? 0) }, + { label: 'Handling logged', value: Number(row?.logged ?? 0) }, + { label: 'Average loading + unloading', value: Number(row?.avgHandling ?? 0), unit: 'h' }, + { label: 'Average other activity', value: Number(row?.avgOther ?? 0), unit: 'h' }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/station-staying-time.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/station-staying-time.report.ts index 3cf0464d8..e69f526fb 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/station-staying-time.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/station-staying-time.report.ts @@ -1,87 +1,18 @@ -import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; - -import { OperationsStandard } from '../../operations-reporting/entities/operations-standard.entity'; -import { TrainCheckpointEvent } from '../../train-scheduling/entities/train-checkpoint-event.entity'; -import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity'; -import { TrainSet } from '../../train-sets/entities/train-set.entity'; -import { Yard } from '../../rule-engine/entities/yard.entity'; -import { applyDirectionScope } from '../../user-trade-access/trade-scope.util'; -import { ReportContext, ReportDefinition } from '../report.types'; +import { ReportDefinition } from '../report.types'; import { COUNTRY_FILTER, DIRECTION_FILTER, - OPS_DATE, - STANDARDS_JOIN, - STATION_STANDARD_HOURS_EXPR, + handlingHours, hoursBetween, + otherActivityHours, + stationStaysQb, } from '../operations-classification'; -/** - * A stay is an ARRIVED followed by the next DEPARTED at the same station by the - * same physical train — NOT by the same schedule. - * - * When a train turns around at a station the two halves belong to different - * departures: the arrival closes the inbound schedule and the departure opens - * the outbound one. Pairing within a schedule finds only pass-through stops and - * silently drops every turnaround, which is the longest stay a train makes. - */ -const TRAIN_KEY = 'COALESCE(tset.train_id::text, ts.train_set_id::text)'; -const STAY_WINDOW = `PARTITION BY ${TRAIN_KEY}, ev.yard_id ORDER BY ev.occurred_at`; - const STAYING_HOURS = hoursBetween('s.arrived_at', 's.departed_at'); +const HANDLING_HOURS = handlingHours('s'); +const OTHER_ACTIVITY_HOURS = otherActivityHours(STAYING_HOURS, HANDLING_HOURS); const STANDARD_HOURS = 's.standard_hours'; -/** Every logged stop, with the event that followed it at the same station. */ -function stopsQuery(ctx: ReportContext): SelectQueryBuilder { - const { params, directions } = ctx; - - const qb = ctx.ds - .createQueryBuilder() - .from(TrainCheckpointEvent, 'ev') - .innerJoin(TrainSchedule, 'ts', 'ts.id = ev.train_schedule_id AND ts.deleted_at IS NULL') - .leftJoin(TrainSet, 'tset', 'tset.id = ts.train_set_id AND tset.deleted_at IS NULL') - .innerJoin(Yard, 'y', 'y.id = ev.yard_id') - .leftJoin(Yard, 'oy', 'oy.id = ts.origin_station_id') - .leftJoin(Yard, 'dy', 'dy.id = ts.destination_station_id') - .leftJoin(OperationsStandard, 'std', STANDARDS_JOIN) - .where('ev.deleted_at IS NULL') - .andWhere("ev.kind IN ('ARRIVED', 'DEPARTED')") - .select('ts.train_number', 'train_number') - .addSelect("COALESCE(y.label, y.code, '—')", 'station') - .addSelect("COALESCE(y.country, '—')", 'country') - .addSelect('ev.kind', 'kind') - .addSelect('ev.occurred_at', 'arrived_at') - .addSelect(`lead(ev.occurred_at) OVER (${STAY_WINDOW})`, 'departed_at') - .addSelect(`lead(ev.kind) OVER (${STAY_WINDOW})`, 'next_kind') - .addSelect(`ROUND(${STATION_STANDARD_HOURS_EXPR}, 1)`, 'standard_hours') - .addSelect("COALESCE(ev.note, '')", 'note'); - - if (params.dateFrom) qb.andWhere(`${OPS_DATE} >= :dateFrom`, { dateFrom: params.dateFrom }); - if (params.dateTo) qb.andWhere(`${OPS_DATE} < :dateTo`, { dateTo: params.dateTo }); - if (params.direction) qb.andWhere('ts.direction = :direction', { direction: params.direction }); - if (params.trainNumber) { - qb.andWhere('ts.train_number ILIKE :trainNumber', { - trainNumber: `%${params.trainNumber as string}%`, - }); - } - if (params.station) qb.andWhere('y.code = :station', { station: params.station }); - if (params.country) qb.andWhere('y.country = :country', { country: params.country }); - - applyDirectionScope(qb, 'ts.direction', directions); - return qb; -} - -/** Only completed stops — an arrival whose departure was also logged. */ -function baseQuery(ctx: ReportContext): SelectQueryBuilder { - const inner = stopsQuery(ctx); - return ctx.ds - .createQueryBuilder() - .from(`(${inner.getQuery()})`, 's') - .setParameters(inner.getParameters()) - .where("s.kind = 'ARRIVED'") - .andWhere("s.next_kind = 'DEPARTED'"); -} - export const stationStayingTimeReport: ReportDefinition = { key: 'station-staying-time', title: 'Station Staying Time', @@ -89,8 +20,9 @@ export const stationStayingTimeReport: ReportDefinition = { 'How long each train stood at each station — the logged arrival to the same train’s ' + 'next departure from that station — against the standard for that side of the line ' + '(10h Ethiopia, 13h Djibouti, both editable in Operating standards). A stop over ' + - 'standard needs a reason. Loading and unloading times are not split out: nothing in ' + - 'the system records when they start and end yet.', + 'standard needs a reason. Loading and unloading time is the stop’s logged handling ' + + 'window, unloading start to loading end, and other activity is whatever is left of the ' + + 'stay; both read empty on a stop whose handling was never logged, rather than zero.', group: 'Operations', filters: [ { key: 'date', label: 'Departure', type: 'daterange' }, @@ -106,6 +38,14 @@ export const stationStayingTimeReport: ReportDefinition = { { key: 'arrivedAt', label: 'Arrived', type: 'date', sortable: true, sortExpr: 's.arrived_at' }, { key: 'departedAt', label: 'Departed', type: 'date' }, { key: 'stayingHours', label: 'Staying (hrs)', type: 'number', sortable: true, sortExpr: STAYING_HOURS }, + { + key: 'loadUnloadHours', + label: 'Loading + unloading (hrs)', + type: 'number', + sortable: true, + sortExpr: HANDLING_HOURS, + }, + { key: 'otherActivityHours', label: 'Other activity (hrs)', type: 'number' }, { key: 'standardHours', label: 'Standard (hrs)', type: 'number' }, { key: 'varianceHours', label: 'Variance (hrs)', type: 'number' }, { key: 'verdict', label: 'Verdict', type: 'string' }, @@ -113,13 +53,15 @@ export const stationStayingTimeReport: ReportDefinition = { ], defaultSort: { key: 'arrivedAt', dir: 'DESC' }, query(ctx) { - return baseQuery(ctx) + return stationStaysQb(ctx) .select("COALESCE(s.train_number, '—')", 'trainNumber') .addSelect('s.station', 'station') .addSelect('s.country', 'country') .addSelect(`to_char(s.arrived_at, 'YYYY-MM-DD HH24:MI')`, 'arrivedAt') .addSelect(`to_char(s.departed_at, 'YYYY-MM-DD HH24:MI')`, 'departedAt') .addSelect(STAYING_HOURS, 'stayingHours') + .addSelect(HANDLING_HOURS, 'loadUnloadHours') + .addSelect(OTHER_ACTIVITY_HOURS, 'otherActivityHours') .addSelect(`${STANDARD_HOURS}::float8`, 'standardHours') .addSelect(`ROUND((${STAYING_HOURS})::numeric - ${STANDARD_HOURS}, 1)::float8`, 'varianceHours') .addSelect( @@ -132,18 +74,20 @@ export const stationStayingTimeReport: ReportDefinition = { .addSelect('s.note', 'reason'); }, async summary(ctx) { - const row = await baseQuery(ctx) + const row = await stationStaysQb(ctx) .select('COUNT(*)::int', 'stops') .addSelect(`ROUND(AVG((${STAYING_HOURS})::numeric), 1)::float8`, 'avgHours') + .addSelect(`ROUND(AVG((${HANDLING_HOURS})::numeric), 1)::float8`, 'avgHandling') .addSelect( `COUNT(*) FILTER (WHERE (${STAYING_HOURS})::numeric > ${STANDARD_HOURS})::int`, 'overStandard', ) - .getRawOne<{ stops: number; avgHours: number; overStandard: number }>(); + .getRawOne<{ stops: number; avgHours: number; avgHandling: number; overStandard: number }>(); return [ { label: 'Stops measured', value: Number(row?.stops ?? 0) }, { label: 'Average stay', value: Number(row?.avgHours ?? 0), unit: 'h' }, + { label: 'Average loading + unloading', value: Number(row?.avgHandling ?? 0), unit: 'h' }, { label: 'Over standard', value: Number(row?.overStandard ?? 0) }, ]; }, diff --git a/apps/edr-freight-api/src/modules/reports/definitions/turnaround-cycle.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/turnaround-cycle.report.ts index 1a130e329..9ae3173f1 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/turnaround-cycle.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/turnaround-cycle.report.ts @@ -6,7 +6,10 @@ import { CYCLE_STANDARD_HOURS_EXPR, DIRECTION_FILTER, cycleRateExpr, + handlingEnd, + handlingStart, hoursBetween, + otherActivityHours, scheduleLedgerQb, } from '../operations-classification'; @@ -61,6 +64,29 @@ const DJIBOUTI_HOURS = stayHours('Djibouti'); const AD_HOURS = hoursBetween('c.cycle_start', 'c.cycle_end'); const TRAVEL_HOURS = `ROUND(GREATEST((${AD_HOURS})::numeric - ${ETHIOPIA_HOURS} - ${DJIBOUTI_HOURS}, 0), 1)::float8`; +/** + * Loading and unloading logged at the cycle's own stops, summed across both + * ends of the line. + * + * NULL — not zero — when no stop in the cycle recorded a handling window: SUM + * over no rows is NULL, and a cycle nobody logged handling for has an unknown + * handling time. Other activity follows it, so an unlogged cycle shows both + * columns empty rather than claiming the whole stay was other activity. + */ +const HANDLING_HOURS = `( + SELECT ROUND(SUM( + EXTRACT(EPOCH FROM (${handlingEnd('e')} - ${handlingStart('e')})) / 3600 + )::numeric, 1)::float8 + FROM freight.train_checkpoint_events e + WHERE e.deleted_at IS NULL + AND e.train_schedule_id IN (c.schedule_id, c.return_schedule_id, c.next_cycle_schedule_id) + AND ${handlingStart('e')} IS NOT NULL + AND ${handlingEnd('e')} IS NOT NULL +)`; + +const STATION_STAY_HOURS = `(${ETHIOPIA_HOURS} + ${DJIBOUTI_HOURS})`; +const OTHER_ACTIVITY_HOURS = otherActivityHours(STATION_STAY_HOURS, HANDLING_HOURS); + /** The completed cycles, before the per-cycle stay decomposition. */ function cycleQuery(ctx: ReportContext): SelectQueryBuilder { return scheduleLedgerQb(ctx) @@ -99,7 +125,10 @@ export const turnaroundCycleReport: ReportDefinition = { 'bulk via DMP, 96h via Negad or BCC — editable in Operating standards). Implement ' + 'rate is [(SC − AD) / SC + 1] × 100, so finishing exactly on standard scores 100. ' + 'The Ethiopia, Djibouti and travelling split comes from logged station checkpoints ' + - 'and reads zero for a train whose stops were never logged.', + 'and reads zero for a train whose stops were never logged. Loading and unloading is ' + + 'the handling logged at those stops, unloading start to loading end, summed over the ' + + 'cycle; other activity is the rest of the time standing at stations. Both read empty ' + + 'where no stop in the cycle recorded its handling.', group: 'Operations', filters: [ { key: 'date', label: 'Departure', type: 'daterange' }, @@ -116,6 +145,8 @@ export const turnaroundCycleReport: ReportDefinition = { { key: 'implementRate', label: 'Implement rate', type: 'percent', sortable: true }, { key: 'ethiopiaHours', label: 'Ethiopia stay (hrs)', type: 'number' }, { key: 'djiboutiHours', label: 'Djibouti stay (hrs)', type: 'number' }, + { key: 'loadUnloadHours', label: 'Loading + unloading (hrs)', type: 'number' }, + { key: 'otherActivityHours', label: 'Other activity (hrs)', type: 'number' }, { key: 'travellingHours', label: 'Travelling (hrs)', type: 'number' }, { key: 'averageDays', label: 'Average day', type: 'number' }, ], @@ -132,6 +163,8 @@ export const turnaroundCycleReport: ReportDefinition = { .addSelect(cycleRateExpr(`(${AD_HOURS})::numeric`, 'c.standard_hours'), 'implementRate') .addSelect(`${ETHIOPIA_HOURS}::float8`, 'ethiopiaHours') .addSelect(`${DJIBOUTI_HOURS}::float8`, 'djiboutiHours') + .addSelect(HANDLING_HOURS, 'loadUnloadHours') + .addSelect(OTHER_ACTIVITY_HOURS, 'otherActivityHours') .addSelect(TRAVEL_HOURS, 'travellingHours') .addSelect(`ROUND((${AD_HOURS})::numeric / 24, 2)::float8`, 'averageDays'); }, @@ -143,12 +176,14 @@ export const turnaroundCycleReport: ReportDefinition = { `ROUND(AVG(${cycleRateExpr(`(${AD_HOURS})::numeric`, 'c.standard_hours')}::numeric), 1)::float8`, 'avgRate', ) - .getRawOne<{ cycles: number; avgHours: number; avgRate: number }>(); + .addSelect(`ROUND(AVG((${HANDLING_HOURS})::numeric), 1)::float8`, 'avgHandling') + .getRawOne<{ cycles: number; avgHours: number; avgRate: number; avgHandling: number }>(); return [ { label: 'Cycles', value: Number(row?.cycles ?? 0) }, { label: 'Average duration', value: Number(row?.avgHours ?? 0), unit: 'h' }, { label: 'Average implement rate', value: Number(row?.avgRate ?? 0), unit: '%' }, + { label: 'Average loading + unloading', value: Number(row?.avgHandling ?? 0), unit: 'h' }, ]; }, }; diff --git a/apps/edr-freight-api/src/modules/reports/operations-classification.ts b/apps/edr-freight-api/src/modules/reports/operations-classification.ts index cffb787ee..340d02f06 100644 --- a/apps/edr-freight-api/src/modules/reports/operations-classification.ts +++ b/apps/edr-freight-api/src/modules/reports/operations-classification.ts @@ -3,7 +3,9 @@ import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; import { Booking } from '../bookings/entities/booking.entity'; import { OperationsStandard } from '../operations-reporting/entities/operations-standard.entity'; import { CargoType } from '../rule-engine/entities/cargo-type.entity'; +import { TrainCheckpointEvent } from '../train-scheduling/entities/train-checkpoint-event.entity'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; +import { TrainSet } from '../train-sets/entities/train-set.entity'; import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity'; import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity'; import { Yard } from '../rule-engine/entities/yard.entity'; @@ -471,6 +473,119 @@ export function scheduleLedgerQb(ctx: ReportContext): SelectQueryBuilder + `LEAST(${alias}.unloading_started_at, ${alias}.loading_started_at)`; +export const handlingEnd = (alias: string): string => + `GREATEST(${alias}.loading_completed_at, ${alias}.unloading_completed_at)`; + +/** Total loading and unloading time at a stop, in hours. */ +export const handlingHours = (alias: string): string => + hoursBetween(handlingStart(alias), handlingEnd(alias)); + +export const unloadingHours = (alias: string): string => + hoursBetween(`${alias}.unloading_started_at`, `${alias}.unloading_completed_at`); +export const loadingHours = (alias: string): string => + hoursBetween(`${alias}.loading_started_at`, `${alias}.loading_completed_at`); + +/** + * What the stay was spent on other than handling — the spec's "other activity". + * + * Stays NULL when handling was never logged rather than collapsing to the whole + * stay, and floors at zero: handling logged slightly outside the arrival and + * departure pair is a sloppy entry, not negative activity. + */ +export const otherActivityHours = (stayExpr: string, handlingExpr: string): string => + `CASE WHEN (${handlingExpr}) IS NULL THEN NULL + ELSE ROUND(GREATEST((${stayExpr})::numeric - (${handlingExpr})::numeric, 0), 1)::float8 END`; + +/** + * Every logged stop, with the event that followed it at the same station and + * whatever loading and unloading was recorded against it. + * + * Shared by the staying-time and loading-and-unloading reports so "a stop" + * means one thing across the suite. + */ +export function stationStopsQb(ctx: ReportContext): SelectQueryBuilder { + const { params, directions } = ctx; + + const qb = ctx.ds + .createQueryBuilder() + .from(TrainCheckpointEvent, 'ev') + .innerJoin(TrainSchedule, 'ts', 'ts.id = ev.train_schedule_id AND ts.deleted_at IS NULL') + .leftJoin(TrainSet, 'tset', 'tset.id = ts.train_set_id AND tset.deleted_at IS NULL') + .innerJoin(Yard, 'y', 'y.id = ev.yard_id') + .leftJoin(Yard, 'oy', 'oy.id = ts.origin_station_id') + .leftJoin(Yard, 'dy', 'dy.id = ts.destination_station_id') + .leftJoin(OperationsStandard, 'std', STANDARDS_JOIN) + .where('ev.deleted_at IS NULL') + .andWhere("ev.kind IN ('ARRIVED', 'DEPARTED')") + .select('ts.train_number', 'train_number') + .addSelect("COALESCE(y.label, y.code, '—')", 'station') + .addSelect("COALESCE(y.code, '—')", 'station_code') + .addSelect("COALESCE(y.country, '—')", 'country') + .addSelect('ev.kind', 'kind') + .addSelect('ev.occurred_at', 'arrived_at') + .addSelect(`lead(ev.occurred_at) OVER (${STAY_WINDOW})`, 'departed_at') + .addSelect(`lead(ev.kind) OVER (${STAY_WINDOW})`, 'next_kind') + // Handling rides the arrival row, which is the row a stay is built from. + .addSelect('ev.unloading_started_at', 'unloading_started_at') + .addSelect('ev.unloading_completed_at', 'unloading_completed_at') + .addSelect('ev.loading_started_at', 'loading_started_at') + .addSelect('ev.loading_completed_at', 'loading_completed_at') + .addSelect(`ROUND(${STATION_STANDARD_HOURS_EXPR}, 1)`, 'standard_hours') + .addSelect("COALESCE(ev.note, '')", 'note'); + + if (params.dateFrom) qb.andWhere(`${OPS_DATE} >= :dateFrom`, { dateFrom: params.dateFrom }); + if (params.dateTo) qb.andWhere(`${OPS_DATE} < :dateTo`, { dateTo: params.dateTo }); + if (params.direction) qb.andWhere('ts.direction = :direction', { direction: params.direction }); + if (params.trainNumber) { + qb.andWhere('ts.train_number ILIKE :trainNumber', { + trainNumber: `%${params.trainNumber as string}%`, + }); + } + if (params.station) qb.andWhere('y.code = :station', { station: params.station }); + if (params.country) qb.andWhere('y.country = :country', { country: params.country }); + + applyDirectionScope(qb, 'ts.direction', directions); + return qb; +} + +/** Only completed stops — an arrival whose departure was also logged, alias `s`. */ +export function stationStaysQb(ctx: ReportContext): SelectQueryBuilder { + const inner = stationStopsQb(ctx); + return ctx.ds + .createQueryBuilder() + .from(`(${inner.getQuery()})`, 's') + .setParameters(inner.getParameters()) + .where("s.kind = 'ARRIVED'") + .andWhere("s.next_kind = 'DEPARTED'"); +} + export function applyOperationsFilters( qb: SelectQueryBuilder, params: Record, diff --git a/apps/edr-freight-api/src/modules/reports/report.registry.ts b/apps/edr-freight-api/src/modules/reports/report.registry.ts index 53273fa69..1235f0c83 100644 --- a/apps/edr-freight-api/src/modules/reports/report.registry.ts +++ b/apps/edr-freight-api/src/modules/reports/report.registry.ts @@ -34,6 +34,7 @@ import { cargoVolumePerformanceReport } from "./definitions/cargo-volume-perform import { chargedVsActualVolumeReport } from "./definitions/charged-vs-actual-volume.report"; import { cargoVolumeByStationReport } from "./definitions/cargo-volume-by-station.report"; import { portWarehouseSummaryReport } from "./definitions/port-warehouse-summary.report"; +import { loadingUnloadingReport } from "./definitions/loading-unloading.report"; import { ReportDefinition } from "./report.types"; /** @@ -77,6 +78,7 @@ export const REPORTS: ReportDefinition[] = [ chargedVsActualVolumeReport, cargoVolumeByStationReport, portWarehouseSummaryReport, + loadingUnloadingReport, ]; const BY_KEY = new Map( diff --git a/apps/edr-freight-api/src/modules/train-scheduling/checkpoint-handling-times.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/checkpoint-handling-times.spec.ts new file mode 100644 index 000000000..d42700b81 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/checkpoint-handling-times.spec.ts @@ -0,0 +1,69 @@ +import { TrainSchedulingService } from './services/train-scheduling.service'; +import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity'; + +/** + * Loading and unloading stamps decide two published figures — total handling + * (unloading start to loading end) and the other activity left over from the + * stay. A crossed or future pair would publish negative hours, so the guard is + * the thing worth pinning down. + */ +const svc = Object.create(TrainSchedulingService.prototype) as { + handlingPatch: ( + dto: Record, + existing?: TrainCheckpointEvent | null, + ) => Record; +}; + +const iso = (h: number): string => new Date(Date.UTC(2026, 6, 3, h)).toISOString(); +const stop = (fields: Partial) => fields as TrainCheckpointEvent; + +describe('checkpoint handling times', () => { + it('takes a sane handling window', () => { + const patch = svc.handlingPatch({ + unloadingStartedAt: iso(4), + loadingCompletedAt: iso(9), + }); + + expect(patch.unloadingStartedAt).toEqual(new Date(iso(4))); + expect(patch.loadingCompletedAt).toEqual(new Date(iso(9))); + }); + + it('rejects loading finishing before unloading started', () => { + expect(() => + svc.handlingPatch({ unloadingStartedAt: iso(9), loadingCompletedAt: iso(4) }), + ).toThrow('Loading cannot finish before unloading started'); + }); + + it('rejects a window that runs backwards', () => { + expect(() => + svc.handlingPatch({ loadingStartedAt: iso(9), loadingCompletedAt: iso(8) }), + ).toThrow('Loading cannot finish before it started'); + }); + + it('rejects a stamp in the future', () => { + const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(); + expect(() => svc.handlingPatch({ unloadingStartedAt: tomorrow })).toThrow( + 'Unloading start cannot be in the future', + ); + }); + + // A body that moves one end of a window is still checked against the end + // already stored, or a two-step edit could walk the stop into a crossed pair. + it('checks a one-sided edit against the stored stop', () => { + expect(() => + svc.handlingPatch( + { loadingCompletedAt: iso(4) }, + stop({ unloadingStartedAt: new Date(iso(9)) }), + ), + ).toThrow('Loading cannot finish before unloading started'); + }); + + it('clears a stamp on null and leaves an untouched one alone', () => { + const patch = svc.handlingPatch( + { unloadingStartedAt: null }, + stop({ unloadingStartedAt: new Date(iso(4)), loadingCompletedAt: new Date(iso(9)) }), + ); + + expect(patch).toEqual({ unloadingStartedAt: null }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/record-checkpoint.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/record-checkpoint.dto.ts index 1495185f0..5f9a7a1bc 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/record-checkpoint.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/record-checkpoint.dto.ts @@ -35,6 +35,31 @@ export class RecordCheckpointDto { @IsISO8601() occurredAt?: string; + /** + * Station work during the stay this stop opens — what the OCC report calls + * loading and unloading time. All optional: a stop logged without them still + * records its staying time. + */ + @ApiProperty({ required: false, description: 'ISO timestamp; unloading start.' }) + @IsOptional() + @IsISO8601() + unloadingStartedAt?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsISO8601() + unloadingCompletedAt?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsISO8601() + loadingStartedAt?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsISO8601() + loadingCompletedAt?: string; + @ApiProperty({ required: false }) @IsOptional() @IsString() @@ -52,6 +77,27 @@ export class UpdateCheckpointDto { @IsISO8601() occurredAt?: string; + /** Null clears a mis-entered stamp; undefined leaves it as it is. */ + @ApiProperty({ required: false, nullable: true }) + @IsOptional() + @IsISO8601() + unloadingStartedAt?: string | null; + + @ApiProperty({ required: false, nullable: true }) + @IsOptional() + @IsISO8601() + unloadingCompletedAt?: string | null; + + @ApiProperty({ required: false, nullable: true }) + @IsOptional() + @IsISO8601() + loadingStartedAt?: string | null; + + @ApiProperty({ required: false, nullable: true }) + @IsOptional() + @IsISO8601() + loadingCompletedAt?: string | null; + @ApiProperty({ required: false, nullable: true }) @IsOptional() @IsString() diff --git a/apps/edr-freight-api/src/modules/train-scheduling/entities/train-checkpoint-event.entity.ts b/apps/edr-freight-api/src/modules/train-scheduling/entities/train-checkpoint-event.entity.ts index 5fa90a2e1..044551402 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/entities/train-checkpoint-event.entity.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/entities/train-checkpoint-event.entity.ts @@ -37,6 +37,28 @@ export class TrainCheckpointEvent extends BaseEntity { @Column({ name: 'occurred_at', type: 'timestamptz' }) occurredAt!: Date; + /** + * Station work, on the stop it happened at. Null where nobody logged it — + * an unlogged stop still reports its staying time, with the handling split + * empty rather than zero. + * + * The stay these belong to opens with THIS arrival and closes with the next + * departure, which is a different schedule when the train turns around. That + * is why they ride the arrival row: it is the row the staying-time report + * builds a stop from. + */ + @Column({ name: 'unloading_started_at', type: 'timestamptz', nullable: true }) + unloadingStartedAt?: Date | null; + + @Column({ name: 'unloading_completed_at', type: 'timestamptz', nullable: true }) + unloadingCompletedAt?: Date | null; + + @Column({ name: 'loading_started_at', type: 'timestamptz', nullable: true }) + loadingStartedAt?: Date | null; + + @Column({ name: 'loading_completed_at', type: 'timestamptz', nullable: true }) + loadingCompletedAt?: Date | null; + @Column({ name: 'note', type: 'text', nullable: true }) note?: string | null; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts index 5ac6ed0bb..c4ca3da59 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts @@ -211,6 +211,16 @@ import { const SCHEDULABLE_BOOKING_STATUSES = ['PAID'] as const; +/** The station-work stamps a stop can carry, with the label an error names. */ +const HANDLING_FIELDS = [ + ['unloadingStartedAt', 'Unloading start'], + ['unloadingCompletedAt', 'Unloading completion'], + ['loadingStartedAt', 'Loading start'], + ['loadingCompletedAt', 'Loading completion'], +] as const; + +type HandlingField = (typeof HANDLING_FIELDS)[number][0]; + /** Drops the keys a partial override left undefined, so `...` merges keep the base value. */ function pickDefined(source: T): Partial { return Object.fromEntries( @@ -4372,11 +4382,65 @@ export class TrainSchedulingService { label: e.yard?.label ?? e.yard?.code ?? null, kind: e.kind, occurredAt: e.occurredAt.toISOString(), + unloadingStartedAt: e.unloadingStartedAt?.toISOString() ?? null, + unloadingCompletedAt: e.unloadingCompletedAt?.toISOString() ?? null, + loadingStartedAt: e.loadingStartedAt?.toISOString() ?? null, + loadingCompletedAt: e.loadingCompletedAt?.toISOString() ?? null, note: e.note ?? null, })), }; } + /** + * The station-work stamps off a record/update body, validated as two windows. + * + * Staff enter these after the fact, so the past is allowed and the future is + * not — the same rule the checkpoint's own time follows. Neither window may + * run backwards, and loading may not finish before unloading began: the OCC + * figure is `loading end − unloading start`, and a crossed pair would publish + * negative handling and negative other activity. + * + * `undefined` leaves a stamp untouched; `null` clears a mis-entered one. + */ + private handlingPatch( + dto: Partial>, + existing?: TrainCheckpointEvent | null, + ): Partial> { + const patch: Partial> = {}; + for (const [field, label] of HANDLING_FIELDS) { + const raw = dto[field]; + if (raw === undefined) continue; + if (raw === null) { + patch[field] = null; + continue; + } + const at = new Date(raw); + this.assertNotFuture(at, label); + patch[field] = at; + } + if (!Object.keys(patch).length) return patch; + + // The stop as it will stand after the patch — a body that moves only one + // end of a window is still checked against the end already stored. + const merged = (field: HandlingField): Date | null => + field in patch ? (patch[field] ?? null) : (existing?.[field] ?? null); + const inOrder = (from: HandlingField, to: HandlingField, message: string): void => { + const start = merged(from); + const end = merged(to); + if (start && end && end.getTime() < start.getTime()) { + throw new BadRequestException(message); + } + }; + inOrder('unloadingStartedAt', 'unloadingCompletedAt', 'Unloading cannot finish before it started'); + inOrder('loadingStartedAt', 'loadingCompletedAt', 'Loading cannot finish before it started'); + inOrder( + 'unloadingStartedAt', + 'loadingCompletedAt', + 'Loading cannot finish before unloading started', + ); + return patch; + } + /** Log the train passing a station. Logging the destination station triggers arrival. */ async recordCheckpoint(scheduleId: string, dto: RecordCheckpointDto) { // Slim graph: checkpoint logging reads stops, locomotives, the built @@ -4411,12 +4475,14 @@ export class TrainSchedulingService { const [existing] = await this.trainCheckpointEventsRepository.findAll({ where: { trainScheduleId: scheduleId, sequenceNo: dto.sequenceNo }, }); + const handling = this.handlingPatch(dto, existing); if (existing) { await this.trainCheckpointEventsRepository.update(existing.id, { kind, occurredAt, note: dto.note ?? null, yardId: station.yardId, + ...handling, }); } else { await this.trainCheckpointEventsRepository.create({ @@ -4426,6 +4492,7 @@ export class TrainSchedulingService { kind, occurredAt, note: dto.note ?? null, + ...handling, }); } @@ -4686,6 +4753,7 @@ export class TrainSchedulingService { patch.occurredAt = occurredAt; } if (dto.note !== undefined) patch.note = dto.note; + Object.assign(patch, this.handlingPatch(dto, existing)); if (Object.keys(patch).length) { await this.trainCheckpointEventsRepository.update(existing.id, patch); } diff --git a/apps/edr-freight-api/src/scripts/seed-occ-july-2026.ts b/apps/edr-freight-api/src/scripts/seed-occ-july-2026.ts index 120347132..b2082db93 100644 --- a/apps/edr-freight-api/src/scripts/seed-occ-july-2026.ts +++ b/apps/edr-freight-api/src/scripts/seed-occ-july-2026.ts @@ -487,6 +487,12 @@ async function upsertTrain(ds: DataSource, code: string): Promise { return row.id; } +/** The handling window a stop records — the loading/unloading report's input. */ +interface Handling { + unloadingStartedAt: Date; + loadingCompletedAt: Date; +} + async function upsertCheckpoint( ds: DataSource, scheduleId: string, @@ -495,7 +501,18 @@ async function upsertCheckpoint( kind: 'ARRIVED' | 'DEPARTED', occurredAt: Date, note: string, + handling?: Handling, ): Promise { + const params = [ + scheduleId, + yardId, + sequenceNo, + kind, + occurredAt, + note, + handling?.unloadingStartedAt ?? null, + handling?.loadingCompletedAt ?? null, + ]; const existing = await ds.query>( `SELECT id FROM freight.train_checkpoint_events WHERE train_schedule_id = $1 AND yard_id = $2 AND kind = $3 AND deleted_at IS NULL`, @@ -503,17 +520,20 @@ async function upsertCheckpoint( ); if (existing.length) { await ds.query( - `UPDATE freight.train_checkpoint_events SET occurred_at = $2, note = $3, updated_at = now() + `UPDATE freight.train_checkpoint_events + SET occurred_at = $2, note = $3, + unloading_started_at = $4, loading_completed_at = $5, updated_at = now() WHERE id = $1`, - [existing[0].id, occurredAt, note], + [existing[0].id, occurredAt, note, params[6], params[7]], ); return; } await ds.query( `INSERT INTO freight.train_checkpoint_events - (train_schedule_id, yard_id, sequence_no, kind, occurred_at, note) - VALUES ($1, $2, $3, $4, $5, $6)`, - [scheduleId, yardId, sequenceNo, kind, occurredAt, note], + (train_schedule_id, yard_id, sequence_no, kind, occurred_at, note, + unloading_started_at, loading_completed_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + params, ); } @@ -605,13 +625,17 @@ async function seedDmpTrains(ds: DataSource, ids: Ids): Promise { arrivedAt: arrivedGelan, }); - // The loading/unloading figure has nowhere of its own to live yet — no - // table records when handling starts and ends — so it rides on the stop's - // note, where the staying-time report surfaces it as the stop's reason. + // The measured handling window, on the arrival row the staying-time report + // builds the stop from: work starts when the train lands and ends when + // loading finishes, which is what the OCC figure measures. The rest of the + // stay reports as other activity. const note = `OCC July 2026 — loading/unloading ${handlingHours.toFixed(2)}h of ` + `${stayingHours.toFixed(2)}h total staying`; - await upsertCheckpoint(ds, schedule, dmp, 0, 'ARRIVED', arrivedAtDmp, note); + await upsertCheckpoint(ds, schedule, dmp, 0, 'ARRIVED', arrivedAtDmp, note, { + unloadingStartedAt: arrivedAtDmp, + loadingCompletedAt: addHours(arrivedAtDmp, handlingHours), + }); await upsertCheckpoint(ds, schedule, dmp, 0, 'DEPARTED', departedDmp, note); } console.log(`DMP trains : ${DMP_TRAINS.length} trains with measured staying times`); diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index b7e0ecaf2..d0d437fb1 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -98,6 +98,7 @@ const SEEDED_REPORT_KEYS = [ "charged-vs-actual-volume", "cargo-volume-by-station", "port-warehouse-summary", + "loading-unloading", ] as const; /** diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/CheckpointTimeModal.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/CheckpointTimeModal.tsx index 357585656..c024cb0e9 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/CheckpointTimeModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/CheckpointTimeModal.tsx @@ -1,13 +1,38 @@ -import { Button, Group, Modal, Stack, Text, Textarea } from "@mantine/core"; +import { Button, Divider, Group, Modal, SimpleGrid, Stack, Text, Textarea } from "@mantine/core"; import { DateTimePicker } from "@mantine/dates"; import { useMediaQuery } from "@mantine/hooks"; import { useEffect, useState } from "react"; +import type { CheckpointHandlingTimes } from "@/types/trainScheduling"; + +/** The four station-work stamps, in the order they happen. */ +const HANDLING_FIELDS = [ + ["unloadingStartedAt", "Unloading started"], + ["unloadingCompletedAt", "Unloading finished"], + ["loadingStartedAt", "Loading started"], + ["loadingCompletedAt", "Loading finished"], +] as const; + +type HandlingField = (typeof HANDLING_FIELDS)[number][0]; +type HandlingState = Record; + +const EMPTY_HANDLING: HandlingState = { + unloadingStartedAt: null, + unloadingCompletedAt: null, + loadingStartedAt: null, + loadingCompletedAt: null, +}; + /** - * Time + note for one leg of a train's journey — used both to log a pass - * (defaults to now) and to correct an already-logged leg (prefilled). Past - * times are allowed (staff record after the fact); the future is not, and the - * server additionally keeps legs in corridor order. + * Time, station work and note for one leg of a train's journey — used both to + * log a pass (defaults to now) and to correct an already-logged leg + * (prefilled). Past times are allowed (staff record after the fact); the future + * is not, and the server additionally keeps legs in corridor order. + * + * The four handling stamps are what the loading-and-unloading reports measure: + * total handling is unloading start to loading end, and whatever is left of the + * stay is other activity. All four are optional — a stop logged without them + * still reports its staying time. */ export function CheckpointTimeModal({ opened, @@ -17,6 +42,7 @@ export function CheckpointTimeModal({ description, initialOccurredAt, initialNote, + initialHandling, submitLabel, submitColor = "edr-green", loading, @@ -30,19 +56,37 @@ export function CheckpointTimeModal({ /** ISO; omit to default to now. */ initialOccurredAt?: string | null; initialNote?: string | null; + initialHandling?: CheckpointHandlingTimes | null; submitLabel: string; submitColor?: string; loading: boolean; - onSubmit: (values: { occurredAt: string; note: string }) => void; + onSubmit: ( + values: { occurredAt: string; note: string } & Record, + ) => void; }) { const isSmallScreen = useMediaQuery("(max-width: 48em)"); const [at, setAt] = useState(null); const [note, setNote] = useState(""); + const [handling, setHandling] = useState(EMPTY_HANDLING); useEffect(() => { if (!opened) return; setAt(initialOccurredAt ? new Date(initialOccurredAt) : new Date()); setNote(initialNote ?? ""); - }, [opened, initialOccurredAt, initialNote]); + setHandling({ + unloadingStartedAt: initialHandling?.unloadingStartedAt + ? new Date(initialHandling.unloadingStartedAt) + : null, + unloadingCompletedAt: initialHandling?.unloadingCompletedAt + ? new Date(initialHandling.unloadingCompletedAt) + : null, + loadingStartedAt: initialHandling?.loadingStartedAt + ? new Date(initialHandling.loadingStartedAt) + : null, + loadingCompletedAt: initialHandling?.loadingCompletedAt + ? new Date(initialHandling.loadingCompletedAt) + : null, + }); + }, [opened, initialOccurredAt, initialNote, initialHandling]); return ( + + + + Loading and unloading times for this stop. Total handling is unloading start to + loading finish; the rest of the stay reports as other activity. + + + {HANDLING_FIELDS.map(([field, label]) => ( + + setHandling((prev) => ({ ...prev, [field]: v ? new Date(v) : null })) + } + maxDate={new Date()} + dropdownType={isSmallScreen ? "modal" : "popover"} + popoverProps={{ withinPortal: true }} + valueFormat="DD MMM YYYY HH:mm" + clearable + radius="md" + /> + ))} + +