mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 05:58:18 +00:00
Fix checkin time cutoff
This commit is contained in:
@@ -1,21 +1,19 @@
|
|||||||
/**
|
/**
|
||||||
* Resolves the booking/check-in cutoff for one boarding stop: stop-level
|
* Resolves the booking/check-in cutoff for one boarding stop.
|
||||||
* `RouteStop.checkinMinutesBefore` override wins, else the route-level default
|
|
||||||
* (`Route.checkinMinutesBefore`), else a bare 30-minute fallback for routes/stops with
|
|
||||||
* neither configured. The basis is the stop's own estimated ARRIVAL time (the train reaching
|
|
||||||
* that stop), not its departure or the schedule's overall origin departure — a downstream
|
|
||||||
* stop's cutoff must be independent of how long ago the train left its origin. The first stop
|
|
||||||
* of a route has no arrival (nothing to arrive at), so it falls back to its own departure.
|
|
||||||
*
|
*
|
||||||
* Single source of truth for this computation — SeatsService.holdSeats and
|
* Priority for checkinMinutes: RouteStop.checkinMinutesBefore → Route.checkinMinutesBefore → 30.
|
||||||
* SearchService.buildScheduleResult already applied it (search results only ever showed a
|
*
|
||||||
* segment as bookable if this same cutoff hadn't passed); GuestBookingService.createGuestBooking
|
* Anchor (segmentTime): plannedDepartureAt ?? plannedArrivalAt ?? schedule.departureAt.
|
||||||
* used to independently hardcode a flat, non-configurable 30 minutes off the schedule's origin
|
* - For the origin stop: plannedDepartureAt = schedule.departureAt (no arrival).
|
||||||
* departure, which could reject a booking the search/hold steps had just accepted under the
|
* - For intermediate stops: plannedDepartureAt = plannedArrivalAt + dwell (checkinMinutesBefore).
|
||||||
* route's actual configured cutoff.
|
* cutoffAt = departureAt − checkinMinutesBefore = arrivalAt, so booking closes the
|
||||||
|
* moment the train reaches the stop — independent of how long ago it left the origin.
|
||||||
|
*
|
||||||
|
* Single source of truth — SeatsService.holdSeats and SearchService.buildScheduleResult both
|
||||||
|
* apply it; GuestBookingService.createGuestBooking also applies it per boarding stop.
|
||||||
*/
|
*/
|
||||||
export interface CheckinCutoff {
|
export interface CheckinCutoff {
|
||||||
/** The stop's own estimated arrival time (or departure, for the first stop / missing data). */
|
/** The stop's planned departure time (or arrival / schedule departure as fallback). */
|
||||||
segmentTime: Date;
|
segmentTime: Date;
|
||||||
/** Minutes before segmentTime that booking/holding closes. */
|
/** Minutes before segmentTime that booking/holding closes. */
|
||||||
checkinMinutes: number;
|
checkinMinutes: number;
|
||||||
@@ -34,7 +32,7 @@ export function resolveCheckinCutoff(
|
|||||||
stopTime: { plannedArrivalAt?: Date | null; plannedDepartureAt?: Date | null } | null | undefined,
|
stopTime: { plannedArrivalAt?: Date | null; plannedDepartureAt?: Date | null } | null | undefined,
|
||||||
stationId: string | null | undefined,
|
stationId: string | null | undefined,
|
||||||
): CheckinCutoff {
|
): CheckinCutoff {
|
||||||
const segmentTime = stopTime?.plannedArrivalAt ?? stopTime?.plannedDepartureAt ?? schedule.departureAt;
|
const segmentTime = stopTime?.plannedDepartureAt ?? stopTime?.plannedArrivalAt ?? schedule.departureAt;
|
||||||
const routeStop = stationId ? schedule.route?.stops?.find((s) => s.stationId === stationId) : undefined;
|
const routeStop = stationId ? schedule.route?.stops?.find((s) => s.stationId === stationId) : undefined;
|
||||||
const checkinMinutes = routeStop?.checkinMinutesBefore ?? schedule.route?.checkinMinutesBefore ?? 30;
|
const checkinMinutes = routeStop?.checkinMinutesBefore ?? schedule.route?.checkinMinutesBefore ?? 30;
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
|
||||||
|
const logger = new Logger('ScheduleTimesUtils');
|
||||||
|
|
||||||
|
export type StopForTiming = {
|
||||||
|
sequence: number;
|
||||||
|
distanceKm: number | null;
|
||||||
|
travelMinutesToStop: number | null;
|
||||||
|
checkinMinutesBefore: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PlannedStopTime = {
|
||||||
|
sequence: number;
|
||||||
|
plannedArrivalAt: string | undefined;
|
||||||
|
plannedDepartureAt: string | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes each stop's planned arrival/departure time by walking the route in sequence order.
|
||||||
|
*
|
||||||
|
* Model per intermediate stop:
|
||||||
|
* arrival = departureCursor + travelMinutesToStop (falls back to distance interpolation)
|
||||||
|
* departure = arrival + checkinMinutesBefore (dwell time; 0 if null)
|
||||||
|
* next-stop travel starts from this departure, not from arrival.
|
||||||
|
*
|
||||||
|
* This means booking for stop B closes at B.departureAt − checkinMinutesBefore = B.arrivalAt,
|
||||||
|
* i.e. the train must not yet have arrived at the stop for a booking to succeed.
|
||||||
|
*
|
||||||
|
* The last stop is always locked to arr so schedule.arrivalAt stays authoritative.
|
||||||
|
*/
|
||||||
|
export function computePlannedStopTimes(
|
||||||
|
route: { id: string; stops: StopForTiming[] },
|
||||||
|
dep: Date,
|
||||||
|
arr: Date,
|
||||||
|
): PlannedStopTime[] {
|
||||||
|
const totalDuration = arr.getTime() - dep.getTime();
|
||||||
|
const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0;
|
||||||
|
|
||||||
|
// cursor tracks the DEPARTURE time from the most-recently processed stop.
|
||||||
|
let departureCursor = dep;
|
||||||
|
|
||||||
|
return route.stops.map((stop, index) => {
|
||||||
|
if (index === 0) {
|
||||||
|
// Origin: train starts here, no arrival.
|
||||||
|
departureCursor = dep;
|
||||||
|
return { sequence: stop.sequence, plannedArrivalAt: undefined, plannedDepartureAt: dep.toISOString() };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index === route.stops.length - 1) {
|
||||||
|
// Final destination: arrival is authoritative; no departure.
|
||||||
|
return { sequence: stop.sequence, plannedArrivalAt: arr.toISOString(), plannedDepartureAt: undefined };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Intermediate stop: compute arrival from the previous stop's departure.
|
||||||
|
let arrivalAt: Date;
|
||||||
|
if (stop.travelMinutesToStop != null) {
|
||||||
|
arrivalAt = new Date(departureCursor.getTime() + stop.travelMinutesToStop * 60_000);
|
||||||
|
} else {
|
||||||
|
const stopDistance = stop.distanceKm || 0;
|
||||||
|
const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1);
|
||||||
|
arrivalAt = new Date(dep.getTime() + totalDuration * progress);
|
||||||
|
logger.warn(`Route ${route.id} stop seq ${stop.sequence} missing travelMinutesToStop; falling back to distance interpolation`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dwell at this stop = checkinMinutesBefore (the boarding window).
|
||||||
|
const dwell = stop.checkinMinutesBefore ?? 0;
|
||||||
|
const departureAt = new Date(arrivalAt.getTime() + dwell * 60_000);
|
||||||
|
departureCursor = departureAt;
|
||||||
|
|
||||||
|
return {
|
||||||
|
sequence: stop.sequence,
|
||||||
|
plannedArrivalAt: arrivalAt.toISOString(),
|
||||||
|
plannedDepartureAt: departureAt.toISOString(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { CreateRouteDto, AddRouteStopDto, UpdateRouteDto, SetRouteCoachTemplateD
|
|||||||
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
|
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
|
||||||
import { AuditService } from '../../common/audit.service';
|
import { AuditService } from '../../common/audit.service';
|
||||||
import { parseEthiopianTime } from '../../common/utils/timezone.utils';
|
import { parseEthiopianTime } from '../../common/utils/timezone.utils';
|
||||||
|
import { computePlannedStopTimes } from '../../common/utils/schedule-times.utils';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class RoutesService {
|
export class RoutesService {
|
||||||
@@ -148,6 +149,20 @@ export class RoutesService {
|
|||||||
travelMinutesToStop: s.travelMinutesToStop ?? null,
|
travelMinutesToStop: s.travelMinutesToStop ?? null,
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Propagate new stop timing to all future schedules on this route so that
|
||||||
|
// per-stop check-in cutoffs reflect the updated travelMinutesToStop values.
|
||||||
|
const futureSchedules = await this.prisma.trainSchedule.findMany({
|
||||||
|
where: { routeId: id, status: { in: ['SCHEDULED', 'BOARDING'] }, departureAt: { gt: new Date() } },
|
||||||
|
select: { id: true, departureAt: true, arrivalAt: true },
|
||||||
|
});
|
||||||
|
const stopsForTiming = dto.stops
|
||||||
|
.map(s => ({ sequence: s.sequence, distanceKm: s.distanceKm ?? null, travelMinutesToStop: s.travelMinutesToStop ?? null, checkinMinutesBefore: s.checkinMinutesBefore ?? null }))
|
||||||
|
.sort((a, b) => a.sequence - b.sequence);
|
||||||
|
for (const sched of futureSchedules) {
|
||||||
|
const times = computePlannedStopTimes({ id, stops: stopsForTiming }, new Date(sched.departureAt), new Date(sched.arrivalAt));
|
||||||
|
await this.applyRouteToSchedule(id, sched.id, Object.fromEntries(times.map(t => [t.sequence, t])));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.auditService.log({ action: 'UPDATE', entityType: 'Route', entityId: id, newData: { name: dto.name, active: dto.active } });
|
await this.auditService.log({ action: 'UPDATE', entityType: 'Route', entityId: id, newData: { name: dto.name, active: dto.active } });
|
||||||
|
|||||||
@@ -154,6 +154,12 @@ export class SchedulesController {
|
|||||||
@ApiQuery({ name: 'cascade', required: false, type: Boolean })
|
@ApiQuery({ name: 'cascade', required: false, type: Boolean })
|
||||||
deleteSchedule(@Param('id') id: string, @Query('cascade') cascade?: string) { return this.service.deleteSchedule(id, cascade === 'true'); }
|
deleteSchedule(@Param('id') id: string, @Query('cascade') cascade?: string) { return this.service.deleteSchedule(id, cascade === 'true'); }
|
||||||
|
|
||||||
|
@Post(':id/recalculate-stops')
|
||||||
|
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
|
||||||
|
@ApiOperation({ summary: 'Recompute TripStopTime records from current route travelMinutesToStop values' })
|
||||||
|
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
||||||
|
recalculateStops(@Param('id') id: string) { return this.service.recalculateStopTimes(id); }
|
||||||
|
|
||||||
@Get(':id/stops')
|
@Get(':id/stops')
|
||||||
@IsPublic()
|
@IsPublic()
|
||||||
@ApiOperation({ summary: 'List all stops for a schedule' })
|
@ApiOperation({ summary: 'List all stops for a schedule' })
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateSchedule
|
|||||||
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
|
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
|
||||||
import { parseEthiopianTime, startOfDayEAT, startOfNextDayEAT } from '../../common/utils/timezone.utils';
|
import { parseEthiopianTime, startOfDayEAT, startOfNextDayEAT } from '../../common/utils/timezone.utils';
|
||||||
import { AuditService } from '../../common/audit.service';
|
import { AuditService } from '../../common/audit.service';
|
||||||
|
import { computePlannedStopTimes } from '../../common/utils/schedule-times.utils';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SchedulesService {
|
export class SchedulesService {
|
||||||
@@ -18,44 +19,6 @@ export class SchedulesService {
|
|||||||
private auditService: AuditService,
|
private auditService: AuditService,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
/**
|
|
||||||
* Computes each stop's planned arrival/departure time by walking the route in sequence
|
|
||||||
* order and accumulating `RouteStop.travelMinutesToStop` (minutes of travel from the
|
|
||||||
* previous stop). Falls back to distance-proportional interpolation over `distanceKm` for
|
|
||||||
* any stop missing `travelMinutesToStop`. The last stop is always locked to the confirmed
|
|
||||||
* overall `arr` regardless of the accumulated cursor, so schedule.arrivalAt stays
|
|
||||||
* authoritative even if per-stop estimates drift.
|
|
||||||
*/
|
|
||||||
private computePlannedTimes(
|
|
||||||
route: { id: string; stops: { sequence: number; distanceKm: number | null; travelMinutesToStop: number | null }[] },
|
|
||||||
dep: Date,
|
|
||||||
arr: Date,
|
|
||||||
) {
|
|
||||||
const totalDuration = arr.getTime() - dep.getTime();
|
|
||||||
const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0;
|
|
||||||
|
|
||||||
let cursor = dep;
|
|
||||||
return route.stops.map((stop, index) => {
|
|
||||||
if (index === 0) {
|
|
||||||
cursor = dep;
|
|
||||||
} else if (index === route.stops.length - 1) {
|
|
||||||
cursor = arr;
|
|
||||||
} else if (stop.travelMinutesToStop != null) {
|
|
||||||
cursor = new Date(cursor.getTime() + stop.travelMinutesToStop * 60_000);
|
|
||||||
} else {
|
|
||||||
const stopDistance = stop.distanceKm || 0;
|
|
||||||
const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1);
|
|
||||||
cursor = new Date(dep.getTime() + totalDuration * progress);
|
|
||||||
this.logger.warn(`Route ${route.id} stop seq ${stop.sequence} missing travelMinutesToStop; falling back to distance interpolation`);
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
sequence: stop.sequence,
|
|
||||||
plannedArrivalAt: index === 0 ? undefined : cursor.toISOString(),
|
|
||||||
plannedDepartureAt: index === route.stops.length - 1 ? undefined : cursor.toISOString(),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async bulkGenerateSchedules(dto: BulkCreateSchedulesDto) {
|
async bulkGenerateSchedules(dto: BulkCreateSchedulesDto) {
|
||||||
const startDate = parseEthiopianTime(dto.startDateTime);
|
const startDate = parseEthiopianTime(dto.startDateTime);
|
||||||
const endDate = new Date(startDate.getTime() + dto.forNextDays * 24 * 60 * 60 * 1000);
|
const endDate = new Date(startDate.getTime() + dto.forNextDays * 24 * 60 * 60 * 1000);
|
||||||
@@ -169,7 +132,7 @@ export class SchedulesService {
|
|||||||
|
|
||||||
let plannedTimes = dto.plannedTimes;
|
let plannedTimes = dto.plannedTimes;
|
||||||
if (!plannedTimes || plannedTimes.length === 0) {
|
if (!plannedTimes || plannedTimes.length === 0) {
|
||||||
plannedTimes = this.computePlannedTimes(route, dep, arr);
|
plannedTimes = computePlannedStopTimes(route, dep, arr);
|
||||||
}
|
}
|
||||||
|
|
||||||
const providedSeqs = new Set((plannedTimes ?? []).map(t => t.sequence));
|
const providedSeqs = new Set((plannedTimes ?? []).map(t => t.sequence));
|
||||||
@@ -340,7 +303,7 @@ export class SchedulesService {
|
|||||||
|
|
||||||
let plannedTimes = dto.plannedTimes;
|
let plannedTimes = dto.plannedTimes;
|
||||||
if (!plannedTimes || plannedTimes.length === 0) {
|
if (!plannedTimes || plannedTimes.length === 0) {
|
||||||
plannedTimes = this.computePlannedTimes(route, dep, arr);
|
plannedTimes = computePlannedStopTimes(route, dep, arr);
|
||||||
}
|
}
|
||||||
|
|
||||||
const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t]));
|
const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t]));
|
||||||
@@ -634,6 +597,24 @@ export class SchedulesService {
|
|||||||
return { synced, errors };
|
return { synced, errors };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async recalculateStopTimes(scheduleId: string) {
|
||||||
|
const schedule = await this.prisma.trainSchedule.findUnique({
|
||||||
|
where: { id: scheduleId },
|
||||||
|
include: { route: { include: { stops: { orderBy: { sequence: 'asc' } } } } },
|
||||||
|
});
|
||||||
|
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||||
|
if (!schedule.routeId || !schedule.route) throw new BadRequestException('Schedule has no associated route');
|
||||||
|
|
||||||
|
const plannedTimes = computePlannedStopTimes(
|
||||||
|
schedule.route,
|
||||||
|
new Date(schedule.departureAt),
|
||||||
|
new Date(schedule.arrivalAt),
|
||||||
|
);
|
||||||
|
const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t]));
|
||||||
|
await this.routesService.applyRouteToSchedule(schedule.routeId, scheduleId, plannedTimesMap);
|
||||||
|
return { recalculated: true, scheduleId, stopCount: plannedTimes.length };
|
||||||
|
}
|
||||||
|
|
||||||
async assignCoaches(scheduleId: string, coaches: Array<{ coachId: string; positionNumber: number }>) {
|
async assignCoaches(scheduleId: string, coaches: Array<{ coachId: string; positionNumber: number }>) {
|
||||||
const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: scheduleId } });
|
const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: scheduleId } });
|
||||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||||
@@ -700,7 +681,7 @@ export class SchedulesService {
|
|||||||
include: { stops: { orderBy: { sequence: 'asc' } } },
|
include: { stops: { orderBy: { sequence: 'asc' } } },
|
||||||
});
|
});
|
||||||
if (route && route.stops.length >= 2) {
|
if (route && route.stops.length >= 2) {
|
||||||
const plannedTimes = this.computePlannedTimes(route, dep, arr);
|
const plannedTimes = computePlannedStopTimes(route, dep, arr);
|
||||||
const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t]));
|
const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t]));
|
||||||
await this.routesService.applyRouteToSchedule(schedule.routeId, id, plannedTimesMap);
|
await this.routesService.applyRouteToSchedule(schedule.routeId, id, plannedTimesMap);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -295,9 +295,10 @@ export class SeatsService {
|
|||||||
|
|
||||||
// Stop-level override wins; falls back to route-level; then to 30 min.
|
// Stop-level override wins; falls back to route-level; then to 30 min.
|
||||||
const checkinMinutes = originRouteStop?.checkinMinutesBefore ?? schedule.route?.checkinMinutesBefore ?? 30;
|
const checkinMinutes = originRouteStop?.checkinMinutesBefore ?? schedule.route?.checkinMinutesBefore ?? 30;
|
||||||
// Arrival basis: the origin stop's own estimated arrival, not its departure. The first
|
// Departure basis: plannedDepartureAt = arrival + dwell. For the origin there is no
|
||||||
// stop of a route has no arrival (nothing to arrive at), so it falls back to its departure.
|
// arrival so plannedDepartureAt = schedule.departureAt. cutoffAt = departure - dwell = arrival,
|
||||||
const segmentDepartureAt = originStopTime?.plannedArrivalAt ?? originStopTime?.plannedDepartureAt ?? schedule.departureAt;
|
// so holding closes the moment the train reaches the boarding stop.
|
||||||
|
const segmentDepartureAt = originStopTime?.plannedDepartureAt ?? originStopTime?.plannedArrivalAt ?? schedule.departureAt;
|
||||||
const msUntilDeparture = segmentDepartureAt.getTime() - Date.now();
|
const msUntilDeparture = segmentDepartureAt.getTime() - Date.now();
|
||||||
if (msUntilDeparture <= checkinMinutes * 60 * 1000) {
|
if (msUntilDeparture <= checkinMinutes * 60 * 1000) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Plus, Loader2, Zap, Trash2, Edit, Search, X, GripVertical } from 'lucide-react';
|
import { Plus, Loader2, Zap, Trash2, Edit, Search, X, GripVertical, RefreshCw } from 'lucide-react';
|
||||||
import DataTable from '@/components/ui/DataTable';
|
import DataTable from '@/components/ui/DataTable';
|
||||||
import ActionButton from '@/components/ui/ActionButton';
|
import ActionButton from '@/components/ui/ActionButton';
|
||||||
import Modal from '@/components/ui/Modal';
|
import Modal from '@/components/ui/Modal';
|
||||||
@@ -210,6 +210,13 @@ export default function SchedulesPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const recalculateStopsMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => apiClient.post(`/schedules/${id}/recalculate-stops`, {}),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['schedules'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const deleteScheduleMutation = useMutation({
|
const deleteScheduleMutation = useMutation({
|
||||||
mutationFn: ({ id, cascade }: { id: string; cascade?: boolean }) => apiClient.delete(`/schedules/${id}${cascade ? '?cascade=true' : ''}`),
|
mutationFn: ({ id, cascade }: { id: string; cascade?: boolean }) => apiClient.delete(`/schedules/${id}${cascade ? '?cascade=true' : ''}`),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -1130,6 +1137,15 @@ export default function SchedulesPage() {
|
|||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
|
<ActionButton
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
icon={RefreshCw}
|
||||||
|
loading={recalculateStopsMutation.isPending}
|
||||||
|
onClick={() => editingSchedule && recalculateStopsMutation.mutate(editingSchedule.id)}
|
||||||
|
>
|
||||||
|
Recalculate Stop Times
|
||||||
|
</ActionButton>
|
||||||
<ActionButton type="submit" loading={updateScheduleMutation.isPending}>
|
<ActionButton type="submit" loading={updateScheduleMutation.isPending}>
|
||||||
Update Schedule
|
Update Schedule
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
|
|||||||
Reference in New Issue
Block a user