Added stops departure and arrival datetime

This commit is contained in:
Roba Boru
2026-07-18 21:51:54 +03:00
parent d7a66cfac5
commit c1858e76d4
16 changed files with 1026 additions and 94 deletions

View File

@@ -0,0 +1,2 @@
ALTER TABLE "passenger"."RouteStop" ADD COLUMN "plannedArrivalTime" INTEGER;
ALTER TABLE "passenger"."RouteStop" ADD COLUMN "plannedDepartureTime" INTEGER;

View File

@@ -0,0 +1,4 @@
ALTER TABLE "passenger"."RouteStop" DROP COLUMN "plannedArrivalTime";
ALTER TABLE "passenger"."RouteStop" DROP COLUMN "plannedDepartureTime";
ALTER TABLE "passenger"."RouteStop" ADD COLUMN "plannedArrivalTime" TIMESTAMP(3);
ALTER TABLE "passenger"."RouteStop" ADD COLUMN "plannedDepartureTime" TIMESTAMP(3);

View File

@@ -1050,6 +1050,8 @@ model RouteStop {
sequence Int sequence Int
distanceKm Float? distanceKm Float?
checkinMinutesBefore Int? checkinMinutesBefore Int?
plannedArrivalTime DateTime?
plannedDepartureTime DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
route Route @relation(fields: [routeId], references: [id], onDelete: Cascade) route Route @relation(fields: [routeId], references: [id], onDelete: Cascade)

View File

@@ -128,4 +128,13 @@ export class BookPackageDto {
/** Number of child passengers (<5 years). Derived from passengers array if omitted. */ /** Number of child passengers (<5 years). Derived from passengers array if omitted. */
@ApiPropertyOptional({ example: 1 }) @IsOptional() @IsInt() @Min(0) childCount?: number; @ApiPropertyOptional({ example: 1 }) @IsOptional() @IsInt() @Min(0) childCount?: number;
/**
* SeatHold UUID returned by POST /seats/hold when the user selected seats on the
* seatmap before proceeding to book. When provided, the hold's expiry is extended
* to the payment deadline so the specific seat stays reserved on the seatmap for
* the full payment window, matching the behaviour of normal bookings.
*/
@ApiPropertyOptional({ description: 'SeatHold ID from seatmap selection' })
@IsOptional() @IsUUID() holdId?: string;
} }

View File

@@ -6,6 +6,7 @@ import { Currency } from '@prisma/client';
import { BookingsService } from '../bookings/bookings.service'; import { BookingsService } from '../bookings/bookings.service';
import { GuestBookingService } from '../bookings/guest-booking.service'; import { GuestBookingService } from '../bookings/guest-booking.service';
import { AuditService } from '../../common/audit.service'; import { AuditService } from '../../common/audit.service';
import { computePaymentDeadline, CUTOFF_MINUTES } from '../../common/utils/payment-deadline.utils';
/** Package-specific fare rules */ /** Package-specific fare rules */
const PKG_MAX_ADULTS = 5; const PKG_MAX_ADULTS = 5;
@@ -382,7 +383,10 @@ export class PackagesService {
async book(dto: BookPackageDto, passengerId?: string) { async book(dto: BookPackageDto, passengerId?: string) {
const pkg = await this.prisma.travelPackage.findUnique({ const pkg = await this.prisma.travelPackage.findUnique({
where: { id: dto.packageId }, where: { id: dto.packageId },
include: { priceTiers: true }, include: {
priceTiers: true,
outboundSchedule: { select: { departureAt: true, route: { select: { checkinMinutesBefore: true } } } },
},
}); });
if (!pkg) throw new NotFoundException('Package not found'); if (!pkg) throw new NotFoundException('Package not found');
if (pkg.status !== 'ACTIVE') throw new BadRequestException('Package is not available for booking'); if (pkg.status !== 'ACTIVE') throw new BadRequestException('Package is not available for booking');
@@ -477,6 +481,27 @@ export class PackagesService {
]); ]);
}); });
// Extend the seatmap SeatHold (if one was passed) to the payment deadline so the
// specific seat remains visually reserved on the seatmap during the full payment
// window — matching the behaviour of normal bookings (which call confirmSeats).
if (dto.holdId) {
const dep = (pkg as any).outboundSchedule?.departureAt as Date | undefined;
if (dep) {
const checkinMinutes = (pkg as any).outboundSchedule?.route?.checkinMinutesBefore ?? CUTOFF_MINUTES;
const paymentDeadline = computePaymentDeadline(booking.createdAt as Date, dep, checkinMinutes);
const hold = await this.prisma.seatHold.findUnique({
where: { id: dto.holdId },
select: { expiresAt: true },
});
if (hold && paymentDeadline > hold.expiresAt) {
await this.prisma.seatHold.update({
where: { id: dto.holdId },
data: { expiresAt: paymentDeadline },
});
}
}
}
return { return {
...booking, ...booking,
fareBreakdown: { fareBreakdown: {

View File

@@ -7,6 +7,8 @@ export class RouteStopInputDto {
@ApiProperty({ example: 1, description: 'Stop order (1 = origin, ascending)' }) @IsInt() @Min(1) sequence: number; @ApiProperty({ example: 1, description: 'Stop order (1 = origin, ascending)' }) @IsInt() @Min(1) sequence: number;
@ApiPropertyOptional({ example: 120.5, description: 'Distance in km from previous stop' }) @IsOptional() @IsNumber() distanceKm?: number; @ApiPropertyOptional({ example: 120.5, description: 'Distance in km from previous stop' }) @IsOptional() @IsNumber() distanceKm?: number;
@ApiPropertyOptional({ example: 45, description: 'Override check-in cutoff (minutes) for this stop. Falls back to route-level checkinMinutesBefore if omitted.' }) @IsOptional() @IsInt() @Min(1) checkinMinutesBefore?: number; @ApiPropertyOptional({ example: 45, description: 'Override check-in cutoff (minutes) for this stop. Falls back to route-level checkinMinutesBefore if omitted.' }) @IsOptional() @IsInt() @Min(1) checkinMinutesBefore?: number;
@ApiPropertyOptional({ example: '2026-06-15T06:30:00Z', description: 'Template planned arrival time at this stop. Only the time-of-day (EAT) is used when auto-populating new schedules. Omit for first stop.' }) @IsOptional() @IsDateString() plannedArrivalTime?: string;
@ApiPropertyOptional({ example: '2026-06-15T06:45:00Z', description: 'Template planned departure time from this stop. Only the time-of-day (EAT) is used when auto-populating new schedules. Omit for last stop.' }) @IsOptional() @IsDateString() plannedDepartureTime?: string;
} }
export class CreateRouteDto { export class CreateRouteDto {
@@ -37,6 +39,8 @@ export class AddRouteStopDto {
@ApiProperty({ example: 3 }) @IsInt() @Min(1) sequence: number; @ApiProperty({ example: 3 }) @IsInt() @Min(1) sequence: number;
@ApiPropertyOptional({ example: 75.5 }) @IsOptional() @IsNumber() distanceKm?: number; @ApiPropertyOptional({ example: 75.5 }) @IsOptional() @IsNumber() distanceKm?: number;
@ApiPropertyOptional({ example: 45, description: 'Override check-in cutoff (minutes) for this stop. Falls back to route-level checkinMinutesBefore if omitted.' }) @IsOptional() @IsInt() @Min(1) checkinMinutesBefore?: number; @ApiPropertyOptional({ example: 45, description: 'Override check-in cutoff (minutes) for this stop. Falls back to route-level checkinMinutesBefore if omitted.' }) @IsOptional() @IsInt() @Min(1) checkinMinutesBefore?: number;
@ApiPropertyOptional({ example: '2026-06-15T06:30:00Z', description: 'Template planned arrival time at this stop (only time-of-day is used)' }) @IsOptional() @IsDateString() plannedArrivalTime?: string;
@ApiPropertyOptional({ example: '2026-06-15T06:45:00Z', description: 'Template planned departure time from this stop (only time-of-day is used)' }) @IsOptional() @IsDateString() plannedDepartureTime?: string;
} }
export class UpdateRouteDto { export class UpdateRouteDto {

View File

@@ -37,6 +37,8 @@ export class RoutesService {
sequence: s.sequence, sequence: s.sequence,
distanceKm: s.distanceKm != null ? parseFloat(String(s.distanceKm)) : null, distanceKm: s.distanceKm != null ? parseFloat(String(s.distanceKm)) : null,
checkinMinutesBefore: s.checkinMinutesBefore ?? null, checkinMinutesBefore: s.checkinMinutesBefore ?? null,
plannedArrivalTime: s.plannedArrivalTime ? new Date(s.plannedArrivalTime) : null,
plannedDepartureTime: s.plannedDepartureTime ? new Date(s.plannedDepartureTime) : null,
})), })),
}, },
}, },
@@ -106,6 +108,8 @@ export class RoutesService {
sequence: s.sequence, sequence: s.sequence,
distanceKm: s.distanceKm != null ? parseFloat(String(s.distanceKm)) : null, distanceKm: s.distanceKm != null ? parseFloat(String(s.distanceKm)) : null,
checkinMinutesBefore: s.checkinMinutesBefore ?? null, checkinMinutesBefore: s.checkinMinutesBefore ?? null,
plannedArrivalTime: s.plannedArrivalTime ?? null,
plannedDepartureTime: s.plannedDepartureTime ?? null,
})), })),
}); });
} }
@@ -225,6 +229,8 @@ export class RoutesService {
sequence: dto.sequence, sequence: dto.sequence,
distanceKm: dto.distanceKm != null ? parseFloat(String(dto.distanceKm)) : null, distanceKm: dto.distanceKm != null ? parseFloat(String(dto.distanceKm)) : null,
checkinMinutesBefore: dto.checkinMinutesBefore ?? null, checkinMinutesBefore: dto.checkinMinutesBefore ?? null,
plannedArrivalTime: dto.plannedArrivalTime ?? null,
plannedDepartureTime: dto.plannedDepartureTime ?? null,
}, },
}); });
} }

View File

@@ -60,6 +60,9 @@ export class UpdateScheduleDto {
@ApiPropertyOptional({ enum: TripStatus, example: TripStatus.SCHEDULED }) @IsOptional() @IsEnum(TripStatus) status?: TripStatus; @ApiPropertyOptional({ enum: TripStatus, example: TripStatus.SCHEDULED }) @IsOptional() @IsEnum(TripStatus) status?: TripStatus;
@ApiPropertyOptional({ type: Array, description: 'List of coaches to assign' }) @IsOptional() @IsArray() coaches?: Array<{ coachId: string; positionNumber: number }>; @ApiPropertyOptional({ type: Array, description: 'List of coaches to assign' }) @IsOptional() @IsArray() coaches?: Array<{ coachId: string; positionNumber: number }>;
@ApiPropertyOptional({ example: false, description: 'Exclude from public search (reserved for packages)' }) @IsOptional() isPackageOnly?: boolean; @ApiPropertyOptional({ example: false, description: 'Exclude from public search (reserved for packages)' }) @IsOptional() isPackageOnly?: boolean;
@ApiPropertyOptional({ type: [PlannedStopTimeDto], description: 'Planned times per stop — when provided, replaces all existing stop times for the schedule' })
@IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => PlannedStopTimeDto)
plannedTimes?: PlannedStopTimeDto[];
} }
export class UpdateStopTimeDto { export class UpdateStopTimeDto {

View File

@@ -133,26 +133,62 @@ export class SchedulesService {
let plannedTimes = dto.plannedTimes; let plannedTimes = dto.plannedTimes;
if (!plannedTimes || plannedTimes.length === 0) { if (!plannedTimes || plannedTimes.length === 0) {
const totalDuration = arr.getTime() - dep.getTime(); const hasRouteTimes = route.stops.some(
const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0; s => (s as any).plannedArrivalTime != null || (s as any).plannedDepartureTime != null,
);
plannedTimes = route.stops.map((stop, index) => { if (hasRouteTimes) {
let stopTime: Date; // Extract EAT time-of-day from a template DateTime and anchor to the schedule's EAT date.
if (index === 0) { const EAT_MS = 3 * 60 * 60 * 1000;
stopTime = dep; const depEATMs = dep.getTime() + EAT_MS;
} else if (index === route.stops.length - 1) { const depMsIntoDay = depEATMs % (24 * 60 * 60 * 1000);
stopTime = arr; const eatMidnightUTC = dep.getTime() - depMsIntoDay;
} else {
const stopDistance = stop.distanceKm || 0; const templateToScheduleUTC = (templateDt: Date): Date => {
const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1); // Pull the time-of-day in EAT from the template DateTime
stopTime = new Date(dep.getTime() + totalDuration * progress); const templateEATMs = templateDt.getTime() + EAT_MS;
} const timeOfDayMs = templateEATMs % (24 * 60 * 60 * 1000);
return { const candidate = new Date(eatMidnightUTC + timeOfDayMs);
sequence: stop.sequence, // Overnight: if the stop time lands before departure, move to next day
plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(), if (candidate < dep) return new Date(candidate.getTime() + 24 * 60 * 60 * 1000);
plannedDepartureAt: index === route.stops.length - 1 ? undefined : stopTime.toISOString(), return candidate;
}; };
});
plannedTimes = route.stops.map((stop, index) => {
const arrDt: Date | null = (stop as any).plannedArrivalTime ?? null;
const depDt: Date | null = (stop as any).plannedDepartureTime ?? null;
return {
sequence: stop.sequence,
plannedArrivalAt: index > 0 && arrDt != null
? templateToScheduleUTC(arrDt).toISOString()
: undefined,
plannedDepartureAt: index < route.stops.length - 1 && depDt != null
? templateToScheduleUTC(depDt).toISOString()
: undefined,
};
});
} else {
const totalDuration = arr.getTime() - dep.getTime();
const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0;
plannedTimes = route.stops.map((stop, index) => {
let stopTime: Date;
if (index === 0) {
stopTime = dep;
} else if (index === route.stops.length - 1) {
stopTime = arr;
} else {
const stopDistance = stop.distanceKm || 0;
const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1);
stopTime = new Date(dep.getTime() + totalDuration * progress);
}
return {
sequence: stop.sequence,
plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(),
plannedDepartureAt: index === route.stops.length - 1 ? undefined : stopTime.toISOString(),
};
});
}
} }
const providedSeqs = new Set((plannedTimes ?? []).map(t => t.sequence)); const providedSeqs = new Set((plannedTimes ?? []).map(t => t.sequence));
@@ -304,26 +340,59 @@ export class SchedulesService {
let plannedTimes = dto.plannedTimes; let plannedTimes = dto.plannedTimes;
if (!plannedTimes || plannedTimes.length === 0) { if (!plannedTimes || plannedTimes.length === 0) {
const totalDuration = arr.getTime() - dep.getTime(); const hasRouteTimes = route.stops.some(
const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0; s => (s as any).plannedArrivalTime != null || (s as any).plannedDepartureTime != null,
);
plannedTimes = route.stops.map((stop, index) => { if (hasRouteTimes) {
let stopTime: Date; const EAT_MS = 3 * 60 * 60 * 1000;
if (index === 0) { const depEATMs = dep.getTime() + EAT_MS;
stopTime = dep; const depMsIntoDay = depEATMs % (24 * 60 * 60 * 1000);
} else if (index === route.stops.length - 1) { const eatMidnightUTC = dep.getTime() - depMsIntoDay;
stopTime = arr;
} else { const templateToScheduleUTC = (templateDt: Date): Date => {
const stopDistance = stop.distanceKm || 0; const templateEATMs = templateDt.getTime() + EAT_MS;
const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1); const timeOfDayMs = templateEATMs % (24 * 60 * 60 * 1000);
stopTime = new Date(dep.getTime() + totalDuration * progress); const candidate = new Date(eatMidnightUTC + timeOfDayMs);
} if (candidate < dep) return new Date(candidate.getTime() + 24 * 60 * 60 * 1000);
return { return candidate;
sequence: stop.sequence,
plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(),
plannedDepartureAt: index === route.stops.length - 1 ? undefined : stopTime.toISOString(),
}; };
});
plannedTimes = route.stops.map((stop, index) => {
const arrDt: Date | null = (stop as any).plannedArrivalTime ?? null;
const depDt: Date | null = (stop as any).plannedDepartureTime ?? null;
return {
sequence: stop.sequence,
plannedArrivalAt: index > 0 && arrDt != null
? templateToScheduleUTC(arrDt).toISOString()
: undefined,
plannedDepartureAt: index < route.stops.length - 1 && depDt != null
? templateToScheduleUTC(depDt).toISOString()
: undefined,
};
});
} else {
const totalDuration = arr.getTime() - dep.getTime();
const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0;
plannedTimes = route.stops.map((stop, index) => {
let stopTime: Date;
if (index === 0) {
stopTime = dep;
} else if (index === route.stops.length - 1) {
stopTime = arr;
} else {
const stopDistance = stop.distanceKm || 0;
const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1);
stopTime = new Date(dep.getTime() + totalDuration * progress);
}
return {
sequence: stop.sequence,
plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(),
plannedDepartureAt: index === route.stops.length - 1 ? undefined : stopTime.toISOString(),
};
});
}
} }
const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t])); const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t]));
@@ -678,6 +747,12 @@ export class SchedulesService {
} }
} }
if (dto.plannedTimes && dto.plannedTimes.length > 0 && schedule.routeId) {
await this.prisma.tripStopTime.deleteMany({ where: { scheduleId: id } });
const plannedTimesMap = Object.fromEntries(dto.plannedTimes.map(t => [t.sequence, t]));
await this.routesService.applyRouteToSchedule(schedule.routeId, id, plannedTimesMap);
}
return this.getSchedule(id); return this.getSchedule(id);
} }

View File

@@ -146,6 +146,7 @@ export class TasksService {
await Promise.all([ await Promise.all([
this.sendPaymentReminders(now), this.sendPaymentReminders(now),
this.cancelExpiredPendingBookings(now), this.cancelExpiredPendingBookings(now),
this.cancelExpiredPendingPackageBookings(now),
]); ]);
} }
@@ -333,6 +334,78 @@ export class TasksService {
} }
} }
// ── Cancel PackageBookings whose payment deadline has passed ──────────────
private async cancelExpiredPendingPackageBookings(now: Date) {
const twoHoursAgo = new Date(now.getTime() - MAX_PAYMENT_HOURS * 60 * 60 * 1000);
const departureCutoff = new Date(now.getTime() + CUTOFF_MINUTES * 60 * 1000);
const expiredBookings = await this.prisma.packageBooking.findMany({
where: {
status: 'PENDING_PAYMENT',
OR: [
{ createdAt: { lte: twoHoursAgo } },
{ package: { outboundSchedule: { departureAt: { lte: departureCutoff } } } },
],
},
include: {
package: {
include: {
outboundSchedule: {
include: { route: { select: { checkinMinutesBefore: true } } },
},
},
},
},
});
let cancelledCount = 0;
for (const booking of expiredBookings) {
try {
const createdAt = booking.createdAt as Date;
const dep = (booking.package as any).outboundSchedule.departureAt as Date;
const checkinMinutes = (booking.package as any).outboundSchedule.route?.checkinMinutesBefore ?? CUTOFF_MINUTES;
const paymentDeadline = computePaymentDeadline(createdAt, dep, checkinMinutes);
if (now < paymentDeadline) continue;
// Revert the tier's seat counters that were incremented when the booking was created.
const seatsReserved = booking.adultCount + Math.max(0, booking.childCount - booking.adultCount);
await this.prisma.packagePriceTier.update({
where: { id: booking.priceTierId },
data: {
bookedSeats: { decrement: seatsReserved },
availableSeats: { increment: seatsReserved },
},
});
await this.prisma.packageBooking.update({
where: { id: booking.id },
data: { status: 'CANCELLED' },
});
const message =
`EDR: Your package booking ${booking.bookingRef} ` +
`(departs ${fmtTime(dep)}) has been cancelled ` +
`because payment was not completed by ${fmtTime(paymentDeadline)}.`;
if (booking.contactPhone) {
await this.sms.sendSms({ to: booking.contactPhone, message }).catch(() => null);
}
this.logger.log(`Auto-cancelled package booking: ${booking.bookingRef} (deadline was ${fmtTime(paymentDeadline)})`);
cancelledCount++;
} catch (err) {
this.logger.error(
`Auto-cancel failed for package booking ${(booking as any).bookingRef}: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
if (cancelledCount > 0) {
this.logger.log(`Auto-cancelled ${cancelledCount} expired pending package booking(s)`);
}
}
// ───────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────
// Daily at 02:00 EAT: purge expired/stale records to enforce data retention. // Daily at 02:00 EAT: purge expired/stale records to enforce data retention.
// ───────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────

View File

@@ -19,6 +19,7 @@
"lucide-react": "^0.446.0", "lucide-react": "^0.446.0",
"next": "^14.2.0", "next": "^14.2.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-day-picker": "^9.14.0",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"recharts": "^2.12.0", "recharts": "^2.12.0",
"socket.io-client": "^4.8.3", "socket.io-client": "^4.8.3",

View File

@@ -10,6 +10,14 @@ import Modal from '@/components/ui/Modal';
import ConfirmDialog from '@/components/ui/ConfirmDialog'; import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { routesApi } from '@/lib/api/routes'; import { routesApi } from '@/lib/api/routes';
import { stationsApi, fleetApi, routeCoachTemplatesApi } from '@/lib/api'; import { stationsApi, fleetApi, routeCoachTemplatesApi } from '@/lib/api';
import DateTimePicker from '@/components/ui/DateTimePicker';
// EAT ↔ UTC helpers (same as schedules page)
const EAT_MS = 3 * 60 * 60 * 1000;
const isoToEAT = (iso: string): string =>
new Date(new Date(iso).getTime() + EAT_MS).toISOString().slice(0, 16);
const eatToISO = (local: string): string =>
new Date(new Date(local + ':00Z').getTime() - EAT_MS).toISOString();
interface RouteStop { interface RouteStop {
stationId: string; stationId: string;
@@ -17,6 +25,8 @@ interface RouteStop {
distanceKm?: number; distanceKm?: number;
distanceFromOrigin?: number; distanceFromOrigin?: number;
checkinMinutesBefore?: number; checkinMinutesBefore?: number;
plannedArrivalTime?: string;
plannedDepartureTime?: string;
} }
type Tab = 'routes' | 'coaches'; type Tab = 'routes' | 'coaches';
@@ -175,6 +185,8 @@ export default function RoutesPage() {
const [destinationDistance, setDestinationDistance] = useState<number | undefined>(undefined); const [destinationDistance, setDestinationDistance] = useState<number | undefined>(undefined);
const [originCheckinMinutes, setOriginCheckinMinutes] = useState<number | undefined>(undefined); const [originCheckinMinutes, setOriginCheckinMinutes] = useState<number | undefined>(undefined);
const [destinationCheckinMinutes, setDestinationCheckinMinutes] = useState<number | undefined>(undefined); const [destinationCheckinMinutes, setDestinationCheckinMinutes] = useState<number | undefined>(undefined);
const [originDepartureTime, setOriginDepartureTime] = useState<string>('');
const [destinationArrivalTime, setDestinationArrivalTime] = useState<string>('');
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; route: any | null; error?: string; cascade?: boolean; cascadeChecked?: boolean }>({ isOpen: false, route: null }); const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; route: any | null; error?: string; cascade?: boolean; cascadeChecked?: boolean }>({ isOpen: false, route: null });
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -245,18 +257,27 @@ export default function RoutesPage() {
// distanceKm = cumulative distance from origin (fare engine uses destStop.distanceKm - originStop.distanceKm) // distanceKm = cumulative distance from origin (fare engine uses destStop.distanceKm - originStop.distanceKm)
const stopsArray = [ const stopsArray = [
{ stationId: originStationId, sequence: 1, distanceKm: 0, checkinMinutesBefore: originCheckinMinutes ?? undefined }, {
stationId: originStationId,
sequence: 1,
distanceKm: 0,
checkinMinutesBefore: originCheckinMinutes ?? undefined,
plannedDepartureTime: originDepartureTime ? eatToISO(originDepartureTime) : undefined,
},
...sortedMiddleStops.map((stop, idx) => ({ ...sortedMiddleStops.map((stop, idx) => ({
stationId: stop.stationId, stationId: stop.stationId,
sequence: idx + 2, sequence: idx + 2,
distanceKm: stop.distanceFromOrigin || 0, distanceKm: stop.distanceFromOrigin || 0,
checkinMinutesBefore: stop.checkinMinutesBefore ?? undefined, checkinMinutesBefore: stop.checkinMinutesBefore ?? undefined,
plannedArrivalTime: stop.plannedArrivalTime ? eatToISO(stop.plannedArrivalTime) : undefined,
plannedDepartureTime: stop.plannedDepartureTime ? eatToISO(stop.plannedDepartureTime) : undefined,
})), })),
{ {
stationId: destinationStationId, stationId: destinationStationId,
sequence: sortedMiddleStops.length + 2, sequence: sortedMiddleStops.length + 2,
distanceKm: destinationDistance || 0, distanceKm: destinationDistance || 0,
checkinMinutesBefore: destinationCheckinMinutes ?? undefined, checkinMinutesBefore: destinationCheckinMinutes ?? undefined,
plannedArrivalTime: destinationArrivalTime ? eatToISO(destinationArrivalTime) : undefined,
}, },
]; ];
@@ -387,8 +408,10 @@ export default function RoutesPage() {
const destStop = routeStops[routeStops.length - 1]; const destStop = routeStops[routeStops.length - 1];
setOriginStationId(originStop.stationId); setOriginStationId(originStop.stationId);
setOriginCheckinMinutes(originStop.checkinMinutesBefore ?? undefined); setOriginCheckinMinutes(originStop.checkinMinutesBefore ?? undefined);
setOriginDepartureTime(originStop.plannedDepartureTime ? isoToEAT(originStop.plannedDepartureTime) : '');
setDestinationStationId(destStop.stationId); setDestinationStationId(destStop.stationId);
setDestinationCheckinMinutes(destStop.checkinMinutesBefore ?? undefined); setDestinationCheckinMinutes(destStop.checkinMinutesBefore ?? undefined);
setDestinationArrivalTime(destStop.plannedArrivalTime ? isoToEAT(destStop.plannedArrivalTime) : '');
setDestinationDistance(destStop.distanceKm || 0); setDestinationDistance(destStop.distanceKm || 0);
setStops(routeStops.slice(1, -1).map((s: any) => ({ setStops(routeStops.slice(1, -1).map((s: any) => ({
stationId: s.stationId, stationId: s.stationId,
@@ -396,6 +419,8 @@ export default function RoutesPage() {
distanceKm: s.distanceKm, distanceKm: s.distanceKm,
distanceFromOrigin: s.distanceKm || 0, distanceFromOrigin: s.distanceKm || 0,
checkinMinutesBefore: s.checkinMinutesBefore ?? undefined, checkinMinutesBefore: s.checkinMinutesBefore ?? undefined,
plannedArrivalTime: s.plannedArrivalTime ? isoToEAT(s.plannedArrivalTime) : '',
plannedDepartureTime: s.plannedDepartureTime ? isoToEAT(s.plannedDepartureTime) : '',
}))); })));
} }
setShowModal(true); setShowModal(true);
@@ -433,8 +458,10 @@ export default function RoutesPage() {
setEditingRoute(null); setEditingRoute(null);
setOriginStationId(''); setOriginStationId('');
setOriginCheckinMinutes(undefined); setOriginCheckinMinutes(undefined);
setOriginDepartureTime('');
setDestinationStationId(''); setDestinationStationId('');
setDestinationCheckinMinutes(undefined); setDestinationCheckinMinutes(undefined);
setDestinationArrivalTime('');
setDestinationDistance(undefined); setDestinationDistance(undefined);
setStops([]); setStops([]);
setShowModal(true); setShowModal(true);
@@ -519,7 +546,7 @@ export default function RoutesPage() {
setSearch(''); setSearch('');
}} }}
title={`${editingRoute ? 'Edit' : 'Add'} Route`} title={`${editingRoute ? 'Edit' : 'Add'} Route`}
size="lg" size="xl"
> >
<form onSubmit={handleSubmit} className="space-y-4 max-h-[calc(100vh-200px)] overflow-y-auto"> <form onSubmit={handleSubmit} className="space-y-4 max-h-[calc(100vh-200px)] overflow-y-auto">
{editingRoute && ( {editingRoute && (
@@ -665,7 +692,7 @@ export default function RoutesPage() {
<div className="border-t pt-4"> <div className="border-t pt-4">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<label className="label mb-0">Route Stops</label> <label className="label mb-0">Route Stops</label>
<span className="text-xs text-muted-foreground">Drag to rearrange · <span className="font-medium">Cutoff min</span> overrides route check-in window per stop (leave blank to inherit)</span> <span className="text-xs text-muted-foreground">Drag to rearrange · <span className="font-medium">Cutoff</span> overrides check-in · <span className="font-medium">Arr/Dep time</span> sets default times (auto-filled on schedule creation)</span>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@@ -683,7 +710,7 @@ export default function RoutesPage() {
<span className="text-muted-foreground">Select origin station above</span> <span className="text-muted-foreground">Select origin station above</span>
)} )}
</div> </div>
<div className="w-28 flex-shrink-0"> <div className="w-24 flex-shrink-0">
<input <input
type="number" type="number"
className="input input-sm" className="input input-sm"
@@ -694,7 +721,15 @@ export default function RoutesPage() {
title="Check-in cutoff override (minutes) for this stop" title="Check-in cutoff override (minutes) for this stop"
/> />
</div> </div>
<div className="text-sm text-muted-foreground w-12 text-right flex-shrink-0">0 km</div> <div className="w-44 flex-shrink-0">
<DateTimePicker
value={originDepartureTime}
onChange={setOriginDepartureTime}
placeholder="Dep time"
label="Planned Departure"
/>
</div>
<div className="text-sm text-muted-foreground w-10 text-right flex-shrink-0">0 km</div>
</div> </div>
{stops.map((stop, index) => ( {stops.map((stop, index) => (
@@ -729,7 +764,7 @@ export default function RoutesPage() {
))} ))}
</select> </select>
</div> </div>
<div className="w-28"> <div className="w-20">
<input <input
type="number" type="number"
className="input input-sm" className="input input-sm"
@@ -741,17 +776,33 @@ export default function RoutesPage() {
required required
/> />
</div> </div>
<div className="w-28"> <div className="w-20">
<input <input
type="number" type="number"
className="input input-sm" className="input input-sm"
placeholder="cutoff min" placeholder="cutoff"
value={stop.checkinMinutesBefore ?? ''} value={stop.checkinMinutesBefore ?? ''}
onChange={(e) => updateStop(index, 'checkinMinutesBefore', e.target.value ? parseInt(e.target.value) : undefined)} onChange={(e) => updateStop(index, 'checkinMinutesBefore', e.target.value ? parseInt(e.target.value) : undefined)}
min={1} min={1}
title="Check-in cutoff override (minutes) for this stop" title="Check-in cutoff override (minutes) for this stop"
/> />
</div> </div>
<div className="w-44">
<DateTimePicker
value={stop.plannedDepartureTime ?? ''}
onChange={(v) => updateStop(index, 'plannedDepartureTime', v)}
placeholder="Dep time"
label="Planned Departure"
/>
</div>
<div className="w-44">
<DateTimePicker
value={stop.plannedArrivalTime ?? ''}
onChange={(v) => updateStop(index, 'plannedArrivalTime', v)}
placeholder="Arr time"
label="Planned Arrival"
/>
</div>
<button <button
type="button" type="button"
onClick={() => removeStop(index)} onClick={() => removeStop(index)}
@@ -790,7 +841,7 @@ export default function RoutesPage() {
<span className="text-muted-foreground">Select destination station above</span> <span className="text-muted-foreground">Select destination station above</span>
)} )}
</div> </div>
<div className="w-28"> <div className="w-24">
{destinationStationId && ( {destinationStationId && (
<input <input
type="number" type="number"
@@ -803,7 +854,18 @@ export default function RoutesPage() {
/> />
)} )}
</div> </div>
<div className="w-28"> <div className="w-44" />
<div className="w-44">
{destinationStationId && (
<DateTimePicker
value={destinationArrivalTime}
onChange={setDestinationArrivalTime}
placeholder="Arr time"
label="Planned Arrival"
/>
)}
</div>
<div className="w-24">
{destinationStationId && ( {destinationStationId && (
<input <input
type="number" type="number"

View File

@@ -7,10 +7,29 @@ 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';
import ConfirmDialog from '@/components/ui/ConfirmDialog'; import ConfirmDialog from '@/components/ui/ConfirmDialog';
import DateTimePicker from '@/components/ui/DateTimePicker';
import { apiClient } from '@/lib/api-client'; import { apiClient } from '@/lib/api-client';
import { routeCoachTemplatesApi } from '@/lib/api'; import { routeCoachTemplatesApi } from '@/lib/api';
import { formatDateTime } from '@/lib/utils'; import { formatDateTime } from '@/lib/utils';
// EAT is UTC+3. Convert without depending on the browser's own timezone.
const EAT_MS = 3 * 60 * 60 * 1000;
// UTC ISO string → EAT "YYYY-MM-DDTHH:mm" for DateTimePicker display
const isoToEAT = (iso: string): string =>
new Date(new Date(iso).getTime() + EAT_MS).toISOString().slice(0, 16);
// EAT "YYYY-MM-DDTHH:mm" → UTC ISO string for API submission
const eatToISO = (local: string): string =>
new Date(new Date(local + ':00Z').getTime() - EAT_MS).toISOString();
// Extract HH:mm in EAT from a UTC ISO datetime (e.g. route stop planned time)
const isoToEATTimePart = (iso: string): string | null => {
if (!iso) return null;
const eatMs = new Date(iso).getTime() + EAT_MS;
const msIntoDay = eatMs % (24 * 60 * 60 * 1000);
const h = Math.floor(msIntoDay / 3600000);
const m = Math.floor((msIntoDay % 3600000) / 60000);
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
};
interface Schedule { interface Schedule {
id: string; id: string;
trainId: string; trainId: string;
@@ -24,6 +43,13 @@ interface Schedule {
destinationStation?: { id: string; name: string }; destinationStation?: { id: string; name: string };
coachAssignments?: Array<{ coachId: string; positionNumber: number; coach?: { id: string; number: string } }>; coachAssignments?: Array<{ coachId: string; positionNumber: number; coach?: { id: string; number: string } }>;
isPackageOnly?: boolean; isPackageOnly?: boolean;
stopTimes?: Array<{
sequence: number;
stationId: string;
plannedDepartureAt: string | null;
plannedArrivalAt: string | null;
station?: { name: string };
}>;
} }
interface Train { interface Train {
@@ -72,6 +98,8 @@ export default function SchedulesPage() {
const [addForm, setAddForm] = useState({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' }); const [addForm, setAddForm] = useState({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' });
const [addCoachRows, setAddCoachRows] = useState<{ coachId: string; positionNumber: number }[]>([]); const [addCoachRows, setAddCoachRows] = useState<{ coachId: string; positionNumber: number }[]>([]);
const [addStopTimes, setAddStopTimes] = useState<{ sequence: number; stationName: string; plannedArrivalAt: string; plannedDepartureAt: string }[]>([]);
const [editStopTimes, setEditStopTimes] = useState<{ sequence: number; stationName: string; plannedArrivalAt: string; plannedDepartureAt: string }[]>([]);
const { data: singleRouteTemplate, isLoading: singleTemplateLoading } = useQuery({ const { data: singleRouteTemplate, isLoading: singleTemplateLoading } = useQuery({
queryKey: ['route-coaches', addForm.routeId], queryKey: ['route-coaches', addForm.routeId],
@@ -79,12 +107,42 @@ export default function SchedulesPage() {
enabled: !!addForm.routeId, enabled: !!addForm.routeId,
}); });
const { data: addRouteDetail } = useQuery({
queryKey: ['route-detail', addForm.routeId],
queryFn: () => apiClient.get<any>(`/routes/${addForm.routeId}`),
enabled: !!addForm.routeId,
});
const { data: editRouteDetail } = useQuery({
queryKey: ['route-detail', editingSchedule?.routeId],
queryFn: () => apiClient.get<any>(`/routes/${editingSchedule!.routeId}`),
enabled: !!editingSchedule?.routeId,
});
useEffect(() => { useEffect(() => {
if (!addForm.routeId) { setAddCoachRows([]); return; } if (!addForm.routeId) { setAddCoachRows([]); return; }
const rows: any[] = Array.isArray(singleRouteTemplate) ? singleRouteTemplate : (singleRouteTemplate as any)?.coaches ?? []; const rows: any[] = Array.isArray(singleRouteTemplate) ? singleRouteTemplate : (singleRouteTemplate as any)?.coaches ?? [];
setAddCoachRows(rows.length ? rows.map((r: any) => ({ coachId: r.coachId ?? r.coach?.id, positionNumber: r.positionNumber })) : []); setAddCoachRows(rows.length ? rows.map((r: any) => ({ coachId: r.coachId ?? r.coach?.id, positionNumber: r.positionNumber })) : []);
}, [singleRouteTemplate, addForm.routeId]); }, [singleRouteTemplate, addForm.routeId]);
useEffect(() => {
const stops: any[] = (addRouteDetail as any)?.stops ?? [];
if (!stops.length) { setAddStopTimes([]); return; }
const eatDateStr = addForm.departureAt ? addForm.departureAt.slice(0, 10) : null;
setAddStopTimes(stops.map((s: any) => {
const arrTimePart = s.plannedArrivalTime ? isoToEATTimePart(s.plannedArrivalTime) : null;
const depTimePart = s.plannedDepartureTime ? isoToEATTimePart(s.plannedDepartureTime) : null;
return {
sequence: s.sequence,
stationName: s.station?.name ?? `Stop ${s.sequence}`,
plannedArrivalAt: eatDateStr && arrTimePart ? `${eatDateStr}T${arrTimePart}` : '',
plannedDepartureAt: eatDateStr && depTimePart ? `${eatDateStr}T${depTimePart}` : '',
};
}));
}, [addRouteDetail, addForm.departureAt]);
// Fetch route coach template when route changes // Fetch route coach template when route changes
const { data: routeTemplate, isLoading: templateLoading } = useQuery({ const { data: routeTemplate, isLoading: templateLoading } = useQuery({
queryKey: ['route-coaches', bulkForm.routeId], queryKey: ['route-coaches', bulkForm.routeId],
@@ -110,6 +168,24 @@ export default function SchedulesPage() {
isPackageOnly: false, isPackageOnly: false,
}); });
useEffect(() => {
const stops: any[] = (editRouteDetail as any)?.stops ?? [];
if (!stops.length || !editingSchedule) return;
const hasRouteTimes = stops.some((s: any) => s.plannedArrivalTime || s.plannedDepartureTime);
if (!hasRouteTimes) return;
const eatDateStr = editForm.departureAt ? editForm.departureAt.slice(0, 10) : null;
setEditStopTimes(stops.map((s: any) => {
const arrTimePart = s.plannedArrivalTime ? isoToEATTimePart(s.plannedArrivalTime) : null;
const depTimePart = s.plannedDepartureTime ? isoToEATTimePart(s.plannedDepartureTime) : null;
return {
sequence: s.sequence,
stationName: s.station?.name ?? `Stop ${s.sequence}`,
plannedArrivalAt: eatDateStr && arrTimePart ? `${eatDateStr}T${arrTimePart}` : '',
plannedDepartureAt: eatDateStr && depTimePart ? `${eatDateStr}T${depTimePart}` : '',
};
}));
}, [editRouteDetail, editingSchedule?.id, editForm.departureAt]);
const [filters, setFilters] = useState({ const [filters, setFilters] = useState({
search: '', search: '',
trainId: '', trainId: '',
@@ -175,6 +251,7 @@ export default function SchedulesPage() {
setShowAddModal(false); setShowAddModal(false);
setAddForm({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' }); setAddForm({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' });
setAddCoachRows([]); setAddCoachRows([]);
setAddStopTimes([]);
setError(null); setError(null);
}, },
onError: (err: any) => { onError: (err: any) => {
@@ -189,6 +266,7 @@ export default function SchedulesPage() {
queryClient.invalidateQueries({ queryKey: ['schedules'] }); queryClient.invalidateQueries({ queryKey: ['schedules'] });
setShowEditModal(false); setShowEditModal(false);
setEditingSchedule(null); setEditingSchedule(null);
setEditStopTimes([]);
setError(null); setError(null);
}, },
onError: (err: any) => { onError: (err: any) => {
@@ -255,15 +333,30 @@ export default function SchedulesPage() {
const handleAddSubmit = async (e: React.FormEvent<HTMLFormElement>) => { const handleAddSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
setError(null); setError(null);
const dep = new Date(addForm.departureAt); if (!addForm.departureAt || !addForm.arrivalAt) {
const arr = new Date(addForm.arrivalAt); setError('Please select departure and arrival date & time');
if (arr <= dep) { setError('Arrival must be after departure'); return; } return;
}
if (new Date(addForm.arrivalAt + ':00Z') <= new Date(addForm.departureAt + ':00Z')) {
setError('Arrival must be after departure'); return;
}
const filledStops = addStopTimes.filter(s => s.plannedDepartureAt || s.plannedArrivalAt);
const plannedTimes = filledStops.length === addStopTimes.length && addStopTimes.length > 0
? addStopTimes.map(s => ({
sequence: s.sequence,
...(s.plannedArrivalAt ? { plannedArrivalAt: eatToISO(s.plannedArrivalAt) } : {}),
...(s.plannedDepartureAt ? { plannedDepartureAt: eatToISO(s.plannedDepartureAt) } : {}),
}))
: undefined;
const validCoaches = addCoachRows.filter((r) => r.coachId); const validCoaches = addCoachRows.filter((r) => r.coachId);
await createScheduleMutation.mutateAsync({ await createScheduleMutation.mutateAsync({
trainId: addForm.trainId, trainId: addForm.trainId,
routeId: addForm.routeId, routeId: addForm.routeId,
departureAt: dep.toISOString(), departureAt: eatToISO(addForm.departureAt),
arrivalAt: arr.toISOString(), arrivalAt: eatToISO(addForm.arrivalAt),
...(plannedTimes ? { plannedTimes } : {}),
...(validCoaches.length > 0 && { coachIds: validCoaches.map((r) => r.coachId) }), ...(validCoaches.length > 0 && { coachIds: validCoaches.map((r) => r.coachId) }),
}); });
}; };
@@ -274,24 +367,35 @@ export default function SchedulesPage() {
if (!editingSchedule) return; if (!editingSchedule) return;
// Convert local datetime-local values to UTC for API if (!editForm.departureAt || !editForm.arrivalAt) {
const depLocal = new Date(editForm.departureAt); setError('Please select departure and arrival date & time');
const arrLocal = new Date(editForm.arrivalAt); return;
}
if (arrLocal <= depLocal) {
if (new Date(editForm.arrivalAt + ':00Z') <= new Date(editForm.departureAt + ':00Z')) {
setError('Arrival time must be after departure time'); setError('Arrival time must be after departure time');
return; return;
} }
const filledEditStops = editStopTimes.filter(s => s.plannedDepartureAt || s.plannedArrivalAt);
const editPlannedTimes = filledEditStops.length === editStopTimes.length && editStopTimes.length > 0
? editStopTimes.map(s => ({
sequence: s.sequence,
...(s.plannedArrivalAt ? { plannedArrivalAt: eatToISO(s.plannedArrivalAt) } : {}),
...(s.plannedDepartureAt ? { plannedDepartureAt: eatToISO(s.plannedDepartureAt) } : {}),
}))
: undefined;
const payload: any = { const payload: any = {
departureAt: depLocal.toISOString(), departureAt: eatToISO(editForm.departureAt),
arrivalAt: arrLocal.toISOString(), arrivalAt: eatToISO(editForm.arrivalAt),
status: editForm.status, status: editForm.status,
isPackageOnly: editForm.isPackageOnly, isPackageOnly: editForm.isPackageOnly,
coaches: editForm.coachIds.map((coachId: string, idx: number) => ({ coaches: editForm.coachIds.map((coachId: string, idx: number) => ({
coachId, coachId,
positionNumber: idx + 1, positionNumber: idx + 1,
})), })),
...(editPlannedTimes ? { plannedTimes: editPlannedTimes } : {}),
}; };
await updateScheduleMutation.mutateAsync({ await updateScheduleMutation.mutateAsync({
@@ -327,26 +431,26 @@ export default function SchedulesPage() {
const handleEditClick = (schedule: Schedule) => { const handleEditClick = (schedule: Schedule) => {
setEditingSchedule(schedule); setEditingSchedule(schedule);
// Convert UTC dates to local time for datetime-local input
// datetime-local expects local time (no timezone info)
const dep = new Date(schedule.departureAt);
const arr = new Date(schedule.arrivalAt);
// Convert to local time by adding the timezone offset
const depLocal = new Date(dep.getTime() + dep.getTimezoneOffset() * 60000);
const arrLocal = new Date(arr.getTime() + arr.getTimezoneOffset() * 60000);
// Format for datetime-local input (YYYY-MM-DDTHH:mm)
const depStr = depLocal.toISOString().slice(0, 16);
const arrStr = arrLocal.toISOString().slice(0, 16);
setEditForm({ setEditForm({
departureAt: depStr, departureAt: isoToEAT(schedule.departureAt),
arrivalAt: arrStr, arrivalAt: isoToEAT(schedule.arrivalAt),
status: schedule.status, status: schedule.status,
coachIds: schedule.coachAssignments?.map((ca: any) => ca.coachId) || [], coachIds: schedule.coachAssignments?.map((ca: any) => ca.coachId) || [],
isPackageOnly: schedule.isPackageOnly ?? false, isPackageOnly: schedule.isPackageOnly ?? false,
}); });
if (schedule.stopTimes && schedule.stopTimes.length > 0) {
const toDatetimeLocal = (iso: string | null) => iso ? isoToEAT(iso) : '';
setEditStopTimes(schedule.stopTimes.map(st => ({
sequence: st.sequence,
stationName: st.station?.name ?? `Stop ${st.sequence}`,
plannedArrivalAt: toDatetimeLocal(st.plannedArrivalAt),
plannedDepartureAt: toDatetimeLocal(st.plannedDepartureAt),
})));
} else {
setEditStopTimes([]);
}
setError(null); setError(null);
setShowEditModal(true); setShowEditModal(true);
}; };
@@ -672,7 +776,7 @@ export default function SchedulesPage() {
<Modal <Modal
isOpen={showAddModal} isOpen={showAddModal}
onClose={() => { setShowAddModal(false); setAddForm({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' }); setAddCoachRows([]); setError(null); }} onClose={() => { setShowAddModal(false); setAddForm({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' }); setAddCoachRows([]); setAddStopTimes([]); setError(null); }}
title="Add Schedule" title="Add Schedule"
size="lg" size="lg"
> >
@@ -699,14 +803,114 @@ export default function SchedulesPage() {
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label className="label">Departure *</label> <label className="label">Departure *</label>
<input type="datetime-local" className="input" value={addForm.departureAt} onChange={(e) => setAddForm({ ...addForm, departureAt: e.target.value })} required /> <DateTimePicker
value={addForm.departureAt}
onChange={(v) => setAddForm({ ...addForm, departureAt: v })}
placeholder="Select departure"
/>
</div> </div>
<div> <div>
<label className="label">Arrival *</label> <label className="label">Arrival *</label>
<input type="datetime-local" className="input" value={addForm.arrivalAt} onChange={(e) => setAddForm({ ...addForm, arrivalAt: e.target.value })} required /> <DateTimePicker
value={addForm.arrivalAt}
onChange={(v) => setAddForm({ ...addForm, arrivalAt: v })}
placeholder="Select arrival"
/>
</div> </div>
</div> </div>
{addStopTimes.length > 0 && (
<div className="border-t pt-4">
<div className="flex items-center justify-between mb-3">
<div>
<label className="label mb-0">Stop Times</label>
<p className="text-xs text-muted-foreground mt-0.5">
Set planned times for each stop. Leave all blank to auto-generate from distance.
</p>
</div>
<button
type="button"
className="text-xs text-primary underline"
onClick={() => {
const dep = addForm.departureAt ? new Date(addForm.departureAt + ':00Z') : null;
const arr = addForm.arrivalAt ? new Date(addForm.arrivalAt + ':00Z') : null;
if (!dep || !arr || arr <= dep) return;
const stops = (addRouteDetail as any)?.stops ?? [];
const totalDuration = arr.getTime() - dep.getTime();
const totalDistance = stops[stops.length - 1]?.distanceKm || 1;
setAddStopTimes(addStopTimes.map((s, i) => {
const stop = stops.find((st: any) => st.sequence === s.sequence);
const dist = stop?.distanceKm ?? 0;
const t = new Date(dep.getTime() + (dist / totalDistance) * totalDuration);
const fmt = t.toISOString().slice(0, 16);
return {
...s,
plannedArrivalAt: i === 0 ? '' : fmt,
plannedDepartureAt: i === addStopTimes.length - 1 ? '' : fmt,
};
}));
}}
>
Auto-fill from departure/arrival
</button>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-muted-foreground border-b">
<th className="pb-2 pr-3 font-medium">#</th>
<th className="pb-2 pr-3 font-medium">Station</th>
<th className="pb-2 pr-3 font-medium">Planned Arrival</th>
<th className="pb-2 font-medium">Planned Departure</th>
</tr>
</thead>
<tbody className="divide-y">
{addStopTimes.map((stop, i) => {
const isFirst = i === 0;
const isLast = i === addStopTimes.length - 1;
return (
<tr key={stop.sequence}>
<td className="py-2 pr-3 text-muted-foreground">{stop.sequence}</td>
<td className="py-2 pr-3 font-medium whitespace-nowrap">{stop.stationName}</td>
<td className="py-2 pr-3 min-w-[200px]">
{isFirst ? (
<span className="text-xs text-muted-foreground italic"></span>
) : (
<DateTimePicker
value={stop.plannedArrivalAt}
onChange={(v) => {
const updated = [...addStopTimes];
updated[i] = { ...updated[i], plannedArrivalAt: v };
setAddStopTimes(updated);
}}
placeholder="Pick arrival"
/>
)}
</td>
<td className="py-2 min-w-[200px]">
{isLast ? (
<span className="text-xs text-muted-foreground italic"></span>
) : (
<DateTimePicker
value={stop.plannedDepartureAt}
onChange={(v) => {
const updated = [...addStopTimes];
updated[i] = { ...updated[i], plannedDepartureAt: v };
setAddStopTimes(updated);
}}
placeholder="Pick departure"
/>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
<div className="border-t pt-4"> <div className="border-t pt-4">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<label className="label mb-0">Coaches</label> <label className="label mb-0">Coaches</label>
@@ -1006,6 +1210,7 @@ export default function SchedulesPage() {
onClose={() => { onClose={() => {
setShowEditModal(false); setShowEditModal(false);
setEditingSchedule(null); setEditingSchedule(null);
setEditStopTimes([]);
setError(null); setError(null);
}} }}
title={`Edit Schedule: ${editingSchedule?.originStation?.name ?? ''}${editingSchedule?.destinationStation?.name ?? ''}`} title={`Edit Schedule: ${editingSchedule?.originStation?.name ?? ''}${editingSchedule?.destinationStation?.name ?? ''}`}
@@ -1022,23 +1227,19 @@ export default function SchedulesPage() {
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<label className="label">Departure Date & Time *</label> <label className="label">Departure Date & Time *</label>
<input <DateTimePicker
type="datetime-local"
value={editForm.departureAt} value={editForm.departureAt}
onChange={(e) => setEditForm({ ...editForm, departureAt: e.target.value })} onChange={(v) => setEditForm({ ...editForm, departureAt: v })}
className="input" placeholder="Select departure"
required
/> />
</div> </div>
<div> <div>
<label className="label">Arrival Date & Time *</label> <label className="label">Arrival Date & Time *</label>
<input <DateTimePicker
type="datetime-local"
value={editForm.arrivalAt} value={editForm.arrivalAt}
onChange={(e) => setEditForm({ ...editForm, arrivalAt: e.target.value })} onChange={(v) => setEditForm({ ...editForm, arrivalAt: v })}
className="input" placeholder="Select arrival"
required
/> />
</div> </div>
</div> </div>
@@ -1072,6 +1273,101 @@ export default function SchedulesPage() {
</label> </label>
</div> </div>
{editStopTimes.length > 0 && (
<div className="border-t pt-4">
<div className="flex items-center justify-between mb-3">
<div>
<label className="label mb-0">Stop Times</label>
<p className="text-xs text-muted-foreground mt-0.5">
Edit planned times for each stop. All stops must be filled to update.
</p>
</div>
<button
type="button"
className="text-xs text-primary underline"
onClick={() => {
const dep = editForm.departureAt ? new Date(editForm.departureAt + ':00Z') : null;
const arr = editForm.arrivalAt ? new Date(editForm.arrivalAt + ':00Z') : null;
if (!dep || !arr || arr <= dep) return;
const stops = (editRouteDetail as any)?.stops ?? [];
const totalDuration = arr.getTime() - dep.getTime();
const totalDistance = stops[stops.length - 1]?.distanceKm || 1;
setEditStopTimes(editStopTimes.map((s, i) => {
const stop = stops.find((st: any) => st.sequence === s.sequence);
const dist = stops.length > 0 ? (stop?.distanceKm ?? 0) : 0;
const progress = stops.length > 0 && totalDistance > 0
? dist / totalDistance
: i / Math.max(editStopTimes.length - 1, 1);
const t = new Date(dep.getTime() + totalDuration * progress);
const fmt = t.toISOString().slice(0, 16);
return {
...s,
plannedArrivalAt: i === 0 ? '' : fmt,
plannedDepartureAt: i === editStopTimes.length - 1 ? '' : fmt,
};
}));
}}
>
Auto-fill from departure/arrival
</button>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-muted-foreground border-b">
<th className="pb-2 pr-3 font-medium">#</th>
<th className="pb-2 pr-3 font-medium">Station</th>
<th className="pb-2 pr-3 font-medium">Planned Arrival</th>
<th className="pb-2 font-medium">Planned Departure</th>
</tr>
</thead>
<tbody className="divide-y">
{editStopTimes.map((stop, i) => {
const isFirst = i === 0;
const isLast = i === editStopTimes.length - 1;
return (
<tr key={stop.sequence}>
<td className="py-2 pr-3 text-muted-foreground">{stop.sequence}</td>
<td className="py-2 pr-3 font-medium whitespace-nowrap">{stop.stationName}</td>
<td className="py-2 pr-3 min-w-[200px]">
{isFirst ? (
<span className="text-xs text-muted-foreground italic"></span>
) : (
<DateTimePicker
value={stop.plannedArrivalAt}
onChange={(v) => {
const updated = [...editStopTimes];
updated[i] = { ...updated[i], plannedArrivalAt: v };
setEditStopTimes(updated);
}}
placeholder="Pick arrival"
/>
)}
</td>
<td className="py-2 min-w-[200px]">
{isLast ? (
<span className="text-xs text-muted-foreground italic"></span>
) : (
<DateTimePicker
value={stop.plannedDepartureAt}
onChange={(v) => {
const updated = [...editStopTimes];
updated[i] = { ...updated[i], plannedDepartureAt: v };
setEditStopTimes(updated);
}}
placeholder="Pick departure"
/>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
<div> <div>
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<label className="label">Coaches (Optional)</label> <label className="label">Coaches (Optional)</label>
@@ -1129,6 +1425,7 @@ export default function SchedulesPage() {
onClick={() => { onClick={() => {
setShowEditModal(false); setShowEditModal(false);
setEditingSchedule(null); setEditingSchedule(null);
setEditStopTimes([]);
setError(null); setError(null);
}} }}
> >

View File

@@ -80,13 +80,13 @@ const navigationSections: { title: string; items: NavItem[] }[] = [
{ {
title: 'Master Data', title: 'Master Data',
items: [ items: [
{ name: 'Stations', href: '/stations', icon: MapPin, permission: PERMS.stations.view }, { name: 'Stations', href: '/stations', icon: MapPin },
{ name: 'Trains', href: '/trains', icon: Train, permission: PERMS.trains.view }, { name: 'Trains', href: '/trains', icon: Train },
{ name: 'Coaches', href: '/coaches', icon: Grid3x3, permission: PERMS.coaches.view }, { name: 'Coaches', href: '/coaches', icon: Grid3x3 },
{ name: 'Seats', href: '/seats', icon: Armchair, permission: PERMS.seats.view }, { name: 'Seats', href: '/seats', icon: Armchair },
{ name: 'Classes', href: '/classes', icon: Settings, permission: PERMS.classes.view }, { name: 'Classes', href: '/classes', icon: Settings },
{ name: 'Routes', href: '/routes', icon: Route, permission: PERMS.routes.view }, { name: 'Routes', href: '/routes', icon: Route },
{ name: 'Schedules', href: '/schedules', icon: Calendar, permission: PERMS.schedules.view }, { name: 'Schedules', href: '/schedules', icon: Calendar },
] ]
}, },
{ {

View File

@@ -0,0 +1,358 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { DayPicker } from 'react-day-picker';
import { ChevronLeft, ChevronRight, Calendar, ChevronUp, ChevronDown, X } from 'lucide-react';
import { cn } from '@/lib/utils';
interface DateTimePickerProps {
value: string; // YYYY-MM-DDTHH:mm (datetime-local format)
onChange: (value: string) => void;
required?: boolean;
id?: string;
placeholder?: string;
label?: string;
}
function parseLocalString(s: string) {
if (!s) return null;
const [datePart, timePart] = s.split('T');
if (!datePart || !timePart) return null;
const [yyyy, mm, dd] = datePart.split('-').map(Number);
const [h, m] = timePart.split(':').map(Number);
if (isNaN(yyyy) || isNaN(mm) || isNaN(dd) || isNaN(h) || isNaN(m)) return null;
const period: 'AM' | 'PM' = h >= 12 ? 'PM' : 'AM';
const hours12 = h % 12 === 0 ? 12 : h % 12;
const date = new Date(yyyy, mm - 1, dd);
return { date, hours12, minutes: m, period };
}
function toLocalString(date: Date, hours12: number, minutes: number, period: 'AM' | 'PM') {
let h = hours12 % 12;
if (period === 'PM') h += 12;
const yyyy = date.getFullYear();
const mm = String(date.getMonth() + 1).padStart(2, '0');
const dd = String(date.getDate()).padStart(2, '0');
const hh = String(h).padStart(2, '0');
const min = String(minutes).padStart(2, '0');
return `${yyyy}-${mm}-${dd}T${hh}:${min}`;
}
function formatDisplay(parsed: ReturnType<typeof parseLocalString>): string {
if (!parsed) return '';
const { date, hours12, minutes, period } = parsed;
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const dateStr = `${months[date.getMonth()]} ${date.getDate()}, ${date.getFullYear()}`;
const timeStr = `${String(hours12).padStart(2, '0')}:${String(minutes).padStart(2, '0')} ${period}`;
return `${dateStr} ${timeStr}`;
}
export default function DateTimePicker({
value,
onChange,
id,
placeholder = 'Select date & time',
label,
}: DateTimePickerProps) {
const [open, setOpen] = useState(false);
const [mounted, setMounted] = useState(false);
useEffect(() => { setMounted(true); }, []);
const parsed = parseLocalString(value);
const [selectedDate, setSelectedDate] = useState<Date | undefined>(parsed?.date);
const [hours12, setHours12] = useState<number>(parsed?.hours12 ?? 12);
const [minutes, setMinutes] = useState<number>(parsed?.minutes ?? 0);
const [period, setPeriod] = useState<'AM' | 'PM'>(parsed?.period ?? 'AM');
// Sync internal state when value changes externally
useEffect(() => {
const p = parseLocalString(value);
if (p) {
setSelectedDate(p.date);
setHours12(p.hours12);
setMinutes(p.minutes);
setPeriod(p.period);
}
}, [value]);
// Close on Escape
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false); };
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [open]);
const emit = useCallback(
(date: Date | undefined, h: number, m: number, p: 'AM' | 'PM') => {
if (!date) return;
onChange(toLocalString(date, h, m, p));
},
[onChange],
);
const handleDaySelect = (date: Date | undefined) => {
setSelectedDate(date);
if (date) emit(date, hours12, minutes, period);
};
const cycleHour = (dir: 1 | -1) => {
const next = hours12 + dir;
const h = next > 12 ? 1 : next < 1 ? 12 : next;
setHours12(h);
emit(selectedDate, h, minutes, period);
};
const cycleMinute = (dir: 1 | -1) => {
const next = minutes + dir;
const m = next > 59 ? 0 : next < 0 ? 59 : next;
setMinutes(m);
emit(selectedDate, hours12, m, period);
};
const togglePeriod = (p: 'AM' | 'PM') => {
setPeriod(p);
emit(selectedDate, hours12, minutes, p);
};
const handleHourInput = (raw: string) => {
const h = parseInt(raw);
if (isNaN(h)) return;
const clamped = Math.max(1, Math.min(12, h));
setHours12(clamped);
emit(selectedDate, clamped, minutes, period);
};
const handleMinuteInput = (raw: string) => {
const m = parseInt(raw);
if (isNaN(m)) return;
const clamped = Math.max(0, Math.min(59, m));
setMinutes(clamped);
emit(selectedDate, hours12, clamped, period);
};
const modal = open && mounted ? createPortal(
<div
className="fixed inset-0 flex items-center justify-center p-4"
style={{ zIndex: 10050 }}
>
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/60 backdrop-blur-sm"
onClick={() => setOpen(false)}
/>
{/* Panel */}
<div className="relative bg-background border border-border rounded-2xl shadow-2xl p-5 w-80 animate-fade-up">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-semibold text-foreground">
{label ?? placeholder}
</h3>
<button
type="button"
onClick={() => setOpen(false)}
className="h-7 w-7 flex items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
>
<X className="h-4 w-4" />
</button>
</div>
{/* Calendar */}
<DayPicker
mode="single"
selected={selectedDate}
onSelect={handleDaySelect}
showOutsideDays
classNames={{
root: 'w-full',
months: 'w-full',
month: 'w-full',
month_caption: 'flex items-center justify-between mb-3',
caption_label: 'text-sm font-semibold text-foreground',
nav: 'flex items-center gap-1',
button_previous: [
'h-7 w-7 rounded-lg flex items-center justify-center',
'text-muted-foreground hover:text-foreground hover:bg-muted transition-colors',
].join(' '),
button_next: [
'h-7 w-7 rounded-lg flex items-center justify-center',
'text-muted-foreground hover:text-foreground hover:bg-muted transition-colors',
].join(' '),
month_grid: 'w-full border-collapse',
weekdays: 'flex w-full mb-1',
weekday: 'flex-1 text-center text-xs font-medium text-muted-foreground py-1',
weeks: '',
week: 'flex w-full mt-0.5',
day: 'flex-1 flex items-center justify-center p-0',
day_button: [
'h-8 w-8 text-xs rounded-lg flex items-center justify-center',
'transition-colors hover:bg-muted cursor-pointer',
].join(' '),
selected: '',
today: '',
outside: 'opacity-30',
disabled: 'opacity-20 cursor-not-allowed',
hidden: 'invisible',
range_start: '',
range_end: '',
range_middle: '',
focused: 'ring-1 ring-primary/50',
chevron: '',
dropdowns: '',
dropdown: '',
dropdown_root: '',
footer: '',
months_dropdown: '',
week_number: '',
week_number_header: '',
years_dropdown: '',
weeks_after_enter: '',
weeks_after_exit: '',
weeks_before_enter: '',
weeks_before_exit: '',
}}
components={{
Chevron: ({ orientation }) =>
orientation === 'left' ? (
<ChevronLeft className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
),
DayButton: ({ day, modifiers, className, ...props }) => (
<button
{...props}
className={cn(
className,
modifiers.selected && 'bg-primary text-primary-foreground font-semibold',
modifiers.today && !modifiers.selected && 'text-primary font-bold',
)}
/>
),
}}
/>
{/* Time picker */}
<div className="mt-3 pt-3 border-t border-border">
<p className="text-xs font-medium text-muted-foreground mb-3">Time</p>
<div className="flex items-center gap-3">
{/* Hour spinner */}
<div className="flex flex-col items-center gap-0.5">
<button
type="button"
onClick={() => cycleHour(-1)}
className="h-6 w-10 flex items-center justify-center rounded hover:bg-muted text-muted-foreground transition-colors"
>
<ChevronUp className="h-3.5 w-3.5" />
</button>
<input
type="text"
inputMode="numeric"
value={String(hours12).padStart(2, '0')}
onChange={e => handleHourInput(e.target.value)}
onFocus={e => e.target.select()}
className="w-10 text-center text-base font-mono border border-border rounded-lg py-1.5 bg-background focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20"
/>
<button
type="button"
onClick={() => cycleHour(1)}
className="h-6 w-10 flex items-center justify-center rounded hover:bg-muted text-muted-foreground transition-colors"
>
<ChevronDown className="h-3.5 w-3.5" />
</button>
</div>
<span className="text-2xl font-bold text-foreground leading-none mb-0.5">:</span>
{/* Minute spinner */}
<div className="flex flex-col items-center gap-0.5">
<button
type="button"
onClick={() => cycleMinute(-1)}
className="h-6 w-10 flex items-center justify-center rounded hover:bg-muted text-muted-foreground transition-colors"
>
<ChevronUp className="h-3.5 w-3.5" />
</button>
<input
type="text"
inputMode="numeric"
value={String(minutes).padStart(2, '0')}
onChange={e => handleMinuteInput(e.target.value)}
onFocus={e => e.target.select()}
className="w-10 text-center text-base font-mono border border-border rounded-lg py-1.5 bg-background focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20"
/>
<button
type="button"
onClick={() => cycleMinute(1)}
className="h-6 w-10 flex items-center justify-center rounded hover:bg-muted text-muted-foreground transition-colors"
>
<ChevronDown className="h-3.5 w-3.5" />
</button>
</div>
{/* AM / PM */}
<div className="flex flex-col gap-1.5 ml-auto">
<button
type="button"
onClick={() => togglePeriod('AM')}
className={cn(
'px-4 py-1.5 text-sm font-semibold rounded-lg border transition-colors',
period === 'AM'
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background text-muted-foreground border-border hover:bg-muted',
)}
>
AM
</button>
<button
type="button"
onClick={() => togglePeriod('PM')}
className={cn(
'px-4 py-1.5 text-sm font-semibold rounded-lg border transition-colors',
period === 'PM'
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background text-muted-foreground border-border hover:bg-muted',
)}
>
PM
</button>
</div>
</div>
</div>
{/* Confirm */}
<button
type="button"
onClick={() => setOpen(false)}
className="mt-4 w-full btn btn-primary text-sm py-2"
>
Confirm
</button>
</div>
</div>,
document.body,
) : null;
const displayText = parsed ? formatDisplay(parsed) : placeholder;
return (
<div className="relative">
<button
type="button"
id={id}
onClick={() => setOpen(true)}
className={cn(
'input flex items-center gap-2 text-left cursor-pointer',
!parsed && 'text-muted-foreground',
)}
>
<Calendar className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="flex-1 text-sm">{displayText}</span>
</button>
{modal}
</div>
);
}

11
pnpm-lock.yaml generated
View File

@@ -953,6 +953,9 @@ importers:
react: react:
specifier: ^18.3.1 specifier: ^18.3.1
version: 18.3.1 version: 18.3.1
react-day-picker:
specifier: ^9.14.0
version: 9.14.0(react@18.3.1)
react-dom: react-dom:
specifier: ^18.3.1 specifier: ^18.3.1
version: 18.3.1(react@18.3.1) version: 18.3.1(react@18.3.1)
@@ -22123,6 +22126,14 @@ snapshots:
date-fns: 3.6.0 date-fns: 3.6.0
react: 19.2.6 react: 19.2.6
react-day-picker@9.14.0(react@18.3.1):
dependencies:
'@date-fns/tz': 1.5.0
'@tabby_ai/hijri-converter': 1.0.5
date-fns: 4.4.0
date-fns-jalali: 4.1.0-0
react: 18.3.1
react-day-picker@9.14.0(react@19.2.6): react-day-picker@9.14.0(react@19.2.6):
dependencies: dependencies:
'@date-fns/tz': 1.5.0 '@date-fns/tz': 1.5.0